mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac68efba45 | |||
| ea5b727ae9 | |||
| 87e189ae7d | |||
| 795193fa00 | |||
| 155fbb1427 | |||
| 7e3ec8dea5 | |||
| 6560f1ec4f | |||
| 093239d614 | |||
| f5ab26b4dc |
@@ -35,6 +35,9 @@ jobs:
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
# Cap a hung run at 20 min instead of riding GitHub's 6-hour default
|
||||
# (a flaky-hang run otherwise streams -v output for hours).
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
@@ -51,7 +54,10 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: pip install -e ".[test]"
|
||||
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -q
|
||||
# -v lists each test id as it starts (pytest prints the nodeid at
|
||||
# logstart), so a hang names the culprit on the last line instead of
|
||||
# riding the job timeout with only a trail of "..." dots.
|
||||
- run: pytest tests/ -m "not live" --cov=turnstone --cov-report=term-missing --cov-report=xml -v
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
@@ -60,6 +66,7 @@ jobs:
|
||||
|
||||
test-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
@@ -83,7 +90,7 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
- run: pip install -e ".[test]"
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -v
|
||||
env:
|
||||
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
|
||||
|
||||
|
||||
+3
-1
@@ -17,8 +17,10 @@ RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
|
||||
# ripgrep is the preferred backend for the search tool — natively bounds
|
||||
# per-line, per-file, and per-filesize so pathological inputs (minified
|
||||
# bundles, training-data JSONL with multi-MB single records) can't OOM us.
|
||||
# ffmpeg transcodes omni STT uploads (browser webm/opus) to the 16 kHz mono
|
||||
# WAV the omni chat-audio lane decodes.
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||
libpq5 git curl jq man-db manpages procps file ripgrep \
|
||||
libpq5 git curl jq man-db manpages procps file ripgrep ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js LTS (for npx-based MCP servers like @modelcontextprotocol/server-github)
|
||||
|
||||
+6
-5
@@ -40,7 +40,7 @@ api_key = ""
|
||||
smart_approvals = false # auto-approve high-confidence "approve" LLM verdicts (opt-in)
|
||||
confidence_threshold = 0.95 # Smart Approvals auto-approve bar (LLM recommendation=approve)
|
||||
max_context_ratio = 0.5 # max % of judge context window for history
|
||||
timeout = 60.0 # seconds (generous for local models)
|
||||
timeout = 120.0 # seconds (generous for local models)
|
||||
read_only_tools = true # judge can use read_file/list_directory
|
||||
cancel_on_approval = false # stop judging remaining tool calls once user decides
|
||||
```
|
||||
@@ -71,7 +71,7 @@ All fields are optional. The judge is enabled by default; use `enabled = false`
|
||||
--judge / --no-judge Enable/disable (default: enabled)
|
||||
--judge-model MODEL Model for judge
|
||||
--judge-provider PROVIDER Provider for judge
|
||||
--judge-timeout SECONDS LLM judge timeout (default: 60)
|
||||
--judge-timeout SECONDS LLM judge timeout (default: 120)
|
||||
--judge-confidence FLOAT Confidence threshold, 0-1 (default: 0.95)
|
||||
```
|
||||
|
||||
@@ -193,9 +193,10 @@ Security hardening blocks access to sensitive paths:
|
||||
|
||||
### Timeout
|
||||
|
||||
The `timeout` setting (default 60 seconds) is a total budget across all judge
|
||||
turns. Time is decremented after each LLM call. If the budget expires mid-turn,
|
||||
the judge attempts to parse whatever partial response is available.
|
||||
The `timeout` setting (default 120 seconds) applies **per turn**, not as a total
|
||||
budget across turns — each of the up to 5 turns gets a fresh budget, so a slow
|
||||
earlier turn doesn't starve later ones. If a turn's budget expires, the judge
|
||||
attempts to parse whatever partial response is available.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.6.6"
|
||||
version = "1.6.8"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -27,7 +27,7 @@ dependencies = [
|
||||
"anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks)
|
||||
"httpx>=0.28",
|
||||
"mcp>=1.27,<2", # v2 is a breaking rewrite (2.0.0a1 live 2026-06-11; stable ~2026-07-27) — streamablehttp_client removed, 2-tuple transport, snake_case types; migrate deliberately
|
||||
"starlette>=1.0.1", # PYSEC-2026-161: host-header path-injection in URL reconstruction (auth-bypass on apps comparing reconstructed URL paths)
|
||||
"starlette>=1.3.1", # CVE-2026-54282 (path->authority host spoof) + CVE-2026-54283 (url-encoded form DoS); supersedes the PYSEC-2026-161 host-header path-injection floor
|
||||
"uvicorn>=0.34",
|
||||
"sse-starlette>=2.0",
|
||||
"httpx-sse>=0.4",
|
||||
@@ -39,7 +39,7 @@ dependencies = [
|
||||
"structlog>=24.1",
|
||||
"PyJWT>=2.8",
|
||||
"bcrypt>=4.0",
|
||||
"cryptography>=42",
|
||||
"cryptography>=48.0.1", # GHSA-537c-gmf6-5ccf: PyPI wheels <48.0.1 bundle a vulnerable statically-linked OpenSSL (2026-06-09 secadv)
|
||||
"lacme>=1.0.5",
|
||||
"python-frontmatter>=1.0",
|
||||
"pypdfium2>=4", # PDF text-extract + rasterize for models without native PDF input (core/pdf.py)
|
||||
|
||||
+202
-5
@@ -7,6 +7,7 @@ helper code runs end-to-end without a network call.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -18,11 +19,16 @@ class _Cfg:
|
||||
"""Stand-in for ModelConfig — only the fields audio.py reads."""
|
||||
|
||||
def __init__(
|
||||
self, model: str, capabilities: dict | None = None, provider: str = "openai"
|
||||
self,
|
||||
model: str,
|
||||
capabilities: dict | None = None,
|
||||
provider: str = "openai",
|
||||
server_compat: dict | None = None,
|
||||
) -> None:
|
||||
self.model = model
|
||||
self.capabilities = capabilities or {}
|
||||
self.provider = provider
|
||||
self.server_compat = server_compat or {}
|
||||
|
||||
|
||||
class _FakeConfigStore:
|
||||
@@ -191,7 +197,8 @@ class TestTranscribe:
|
||||
with pytest.raises(audio.AudioBackendError):
|
||||
audio.transcribe(registry=reg, alias="voice", data=b"x", filename="a.wav")
|
||||
|
||||
def test_omni_model_transcribes_via_chat(self):
|
||||
def test_omni_model_transcribes_via_chat(self, monkeypatch):
|
||||
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
|
||||
client = MagicMock()
|
||||
msg = MagicMock(content=" the transcript ")
|
||||
client.chat.completions.create.return_value = MagicMock(choices=[MagicMock(message=msg)])
|
||||
@@ -202,15 +209,18 @@ class TestTranscribe:
|
||||
assert res.transcript == "the transcript"
|
||||
# The dedicated transcription endpoint is NOT used for an omni model.
|
||||
client.audio.transcriptions.create.assert_not_called()
|
||||
# Audio rides as an input_audio chat part; format comes from the filename.
|
||||
parts = client.chat.completions.create.call_args.kwargs["messages"][0]["content"]
|
||||
# Prompt precedes the audio part — the order Gemma documents for transcription.
|
||||
assert [p["type"] for p in parts] == ["text", "input_audio"]
|
||||
# The clip is transcoded to wav regardless of the upload container.
|
||||
audio_part = next(p for p in parts if p["type"] == "input_audio")
|
||||
assert audio_part["input_audio"]["format"] == "webm"
|
||||
assert audio_part["input_audio"]["format"] == "wav"
|
||||
# A blank prompt falls back to the omni STT default instruction.
|
||||
text_part = next(p for p in parts if p["type"] == "text")
|
||||
assert "Only output the transcription" in text_part["text"]
|
||||
|
||||
def test_omni_prompt_override_used(self):
|
||||
def test_omni_prompt_override_used(self, monkeypatch):
|
||||
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
|
||||
client = MagicMock()
|
||||
client.chat.completions.create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="x"))]
|
||||
@@ -338,3 +348,190 @@ class TestTranscribeCached:
|
||||
assert audio.transcribe_cached(**kw) == ""
|
||||
audio.transcribe_cached(**kw)
|
||||
assert len(calls) == 2 # failure not cached -> retried
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Omni chat request shaping — transcode + thinking-off + token cap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOmniChatExtraBody:
|
||||
"""``_omni_chat_extra_body`` re-applies what the raw-client STT path skips."""
|
||||
|
||||
_THINKING = {"thinking_mode": "manual", "thinking_param": "enable_thinking"}
|
||||
|
||||
def test_disables_thinking_via_model_param(self):
|
||||
cfg = _Cfg("gemma", dict(self._THINKING))
|
||||
assert audio._omni_chat_extra_body(cfg) == {
|
||||
"chat_template_kwargs": {"enable_thinking": False}
|
||||
}
|
||||
|
||||
def test_thinking_off_wins_over_operator_flag(self):
|
||||
cfg = _Cfg(
|
||||
"gemma",
|
||||
dict(self._THINKING),
|
||||
server_compat={"extra_body": {"chat_template_kwargs": {"enable_thinking": True}}},
|
||||
)
|
||||
# STT never wants reasoning, even if an operator stored thinking on.
|
||||
assert audio._omni_chat_extra_body(cfg)["chat_template_kwargs"]["enable_thinking"] is False
|
||||
|
||||
def test_forwards_operator_server_compat_extra_body(self):
|
||||
cfg = _Cfg(
|
||||
"model",
|
||||
dict(self._THINKING),
|
||||
server_compat={"extra_body": {"reasoning_format": "auto"}},
|
||||
)
|
||||
extra = audio._omni_chat_extra_body(cfg)
|
||||
assert extra["reasoning_format"] == "auto"
|
||||
assert extra["chat_template_kwargs"] == {"enable_thinking": False}
|
||||
|
||||
def test_empty_for_non_thinking_model(self):
|
||||
cfg = _Cfg("omni", {"supports_audio_input": True})
|
||||
assert audio._omni_chat_extra_body(cfg) == {}
|
||||
|
||||
|
||||
class TestOmniChatCall:
|
||||
"""The omni chat call carries the thinking-off extra_body and a token cap."""
|
||||
|
||||
def test_sends_thinking_off_and_token_cap(self, monkeypatch):
|
||||
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
|
||||
client = MagicMock()
|
||||
client.chat.completions.create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="hi"))]
|
||||
)
|
||||
cfg = _Cfg(
|
||||
"gemma-omni",
|
||||
{
|
||||
"supports_audio_input": True,
|
||||
"thinking_mode": "manual",
|
||||
"thinking_param": "enable_thinking",
|
||||
},
|
||||
)
|
||||
audio.transcribe(
|
||||
registry=_FakeRegistry("omni", cfg, client),
|
||||
alias="omni",
|
||||
data=b"webmbytes",
|
||||
filename="speech.webm",
|
||||
)
|
||||
kwargs = client.chat.completions.create.call_args.kwargs
|
||||
assert kwargs["extra_body"]["chat_template_kwargs"]["enable_thinking"] is False
|
||||
assert kwargs["max_tokens"] == audio._OMNI_STT_MAX_TOKENS
|
||||
|
||||
|
||||
class TestTranscode:
|
||||
"""``_to_wav_16k_mono`` normalizes any container to 16 kHz mono WAV via ffmpeg."""
|
||||
|
||||
def _stereo_wav_44k(self) -> bytes:
|
||||
import io
|
||||
import wave
|
||||
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as w:
|
||||
w.setnchannels(2)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(44100)
|
||||
w.writeframes(b"\x00\x01\x00\x01" * 4410) # 0.1 s of stereo
|
||||
return buf.getvalue()
|
||||
|
||||
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
|
||||
def test_transcodes_to_16k_mono(self):
|
||||
import io
|
||||
import wave
|
||||
|
||||
out = audio._to_wav_16k_mono(self._stereo_wav_44k())
|
||||
with wave.open(io.BytesIO(out), "rb") as w:
|
||||
assert w.getnchannels() == 1
|
||||
assert w.getframerate() == 16000
|
||||
|
||||
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
|
||||
def test_undecodable_bytes_raise_backend_error(self):
|
||||
with pytest.raises(audio.AudioBackendError):
|
||||
audio._to_wav_16k_mono(b"this is not audio at all")
|
||||
|
||||
def test_missing_ffmpeg_raises_backend_error(self, monkeypatch):
|
||||
def _no_ffmpeg(*a, **k):
|
||||
raise FileNotFoundError("ffmpeg")
|
||||
|
||||
monkeypatch.setattr(audio.subprocess, "run", _no_ffmpeg)
|
||||
with pytest.raises(audio.AudioBackendError, match="ffmpeg is not installed"):
|
||||
audio._to_wav_16k_mono(b"x")
|
||||
|
||||
def test_invokes_ffmpeg_with_hardened_argv(self, monkeypatch):
|
||||
# Covers the argv shaping even on a CI image without ffmpeg installed.
|
||||
captured = {}
|
||||
|
||||
def _fake_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["input"] = kwargs.get("input")
|
||||
return MagicMock(returncode=0, stdout=b"RIFF....WAVE", stderr=b"")
|
||||
|
||||
monkeypatch.setattr(audio.subprocess, "run", _fake_run)
|
||||
assert audio._to_wav_16k_mono(b"rawclip") == b"RIFF....WAVE"
|
||||
cmd = captured["cmd"]
|
||||
assert cmd[0] == "ffmpeg"
|
||||
assert captured["input"] == b"rawclip"
|
||||
# SSRF/decompression-bomb hardening + the 16 kHz mono normalization.
|
||||
assert cmd[cmd.index("-protocol_whitelist") + 1] == "pipe"
|
||||
assert "-vn" in cmd
|
||||
assert cmd[cmd.index("-ac") + 1] == "1"
|
||||
assert cmd[cmd.index("-ar") + 1] == "16000"
|
||||
assert cmd[cmd.index("-f") + 1] == "wav"
|
||||
|
||||
def test_nonzero_returncode_raises_backend_error(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
audio.subprocess,
|
||||
"run",
|
||||
lambda *a, **k: MagicMock(returncode=1, stdout=b"", stderr=b"boom"),
|
||||
)
|
||||
with pytest.raises(audio.AudioBackendError, match="Audio transcode failed"):
|
||||
audio._to_wav_16k_mono(b"x")
|
||||
|
||||
|
||||
def _stream_chunk(content):
|
||||
return MagicMock(choices=[MagicMock(delta=MagicMock(content=content))])
|
||||
|
||||
|
||||
class TestTranscribeStream:
|
||||
"""``transcribe_stream`` yields content deltas; resolve/transcode are eager."""
|
||||
|
||||
def test_streams_chat_deltas_with_thinking_off(self, monkeypatch):
|
||||
monkeypatch.setattr(audio, "_to_wav_16k_mono", lambda data: data)
|
||||
client = MagicMock()
|
||||
client.chat.completions.create.return_value = iter(
|
||||
[_stream_chunk("and so"), _stream_chunk(None), _stream_chunk(" my fellow americans")]
|
||||
)
|
||||
cfg = _Cfg(
|
||||
"gemma-omni",
|
||||
{
|
||||
"supports_audio_input": True,
|
||||
"thinking_mode": "manual",
|
||||
"thinking_param": "enable_thinking",
|
||||
},
|
||||
)
|
||||
gen = audio.transcribe_stream(
|
||||
registry=_FakeRegistry("omni", cfg, client), alias="omni", data=b"webmbytes"
|
||||
)
|
||||
# Empty/None deltas are skipped; the rest stream through in order.
|
||||
assert list(gen) == ["and so", " my fellow americans"]
|
||||
kwargs = client.chat.completions.create.call_args.kwargs
|
||||
assert kwargs["stream"] is True
|
||||
assert kwargs["extra_body"]["chat_template_kwargs"]["enable_thinking"] is False
|
||||
|
||||
def test_non_audio_provider_raises_before_streaming(self):
|
||||
client = MagicMock()
|
||||
cfg = _Cfg("gemma", {"supports_audio_input": True}, provider="anthropic-compatible")
|
||||
with pytest.raises(audio.AudioUnavailableError, match="OpenAI-compatible provider"):
|
||||
audio.transcribe_stream(
|
||||
registry=_FakeRegistry("omni", cfg, client), alias="omni", data=b"x"
|
||||
)
|
||||
client.chat.completions.create.assert_not_called()
|
||||
|
||||
def test_whisper_alias_emits_single_chunk(self):
|
||||
client = MagicMock()
|
||||
client.audio.transcriptions.create.return_value = MagicMock(text=" full transcript ")
|
||||
cfg = _Cfg("whisper-1") # name inference -> dedicated endpoint, no chat stream
|
||||
gen = audio.transcribe_stream(
|
||||
registry=_FakeRegistry("w", cfg, client), alias="w", data=b"x"
|
||||
)
|
||||
assert list(gen) == ["full transcript"]
|
||||
client.chat.completions.create.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Tests for turnstone.core.deadline.run_with_deadline.
|
||||
|
||||
The load-bearing property is the daemon worker: on timeout or cancel the call
|
||||
is abandoned, and the abandoned thread must be a daemon so it can never block
|
||||
interpreter exit (the bug that motivated the helper — a non-daemon
|
||||
ThreadPoolExecutor worker is joined by concurrent.futures' atexit hook).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.deadline import (
|
||||
DeadlineCancelledError,
|
||||
DeadlineExceededError,
|
||||
run_with_deadline,
|
||||
)
|
||||
|
||||
|
||||
def test_returns_result_on_success() -> None:
|
||||
assert run_with_deadline(lambda: 42, timeout=1.0) == 42
|
||||
|
||||
|
||||
def test_reraises_callable_exception() -> None:
|
||||
def boom() -> None:
|
||||
raise ValueError("upstream failed")
|
||||
|
||||
with pytest.raises(ValueError, match="upstream failed"):
|
||||
run_with_deadline(boom, timeout=1.0)
|
||||
|
||||
|
||||
def test_timeout_returns_promptly_and_abandons_a_daemon_worker() -> None:
|
||||
# The worker sleeps far past the deadline; the call must return promptly
|
||||
# via DeadlineExceededError, and the abandoned worker must be a daemon so
|
||||
# it cannot pin interpreter exit.
|
||||
start = time.monotonic()
|
||||
with pytest.raises(DeadlineExceededError):
|
||||
run_with_deadline(lambda: time.sleep(2.0), timeout=0.2, poll=0.05, thread_name="dl-timeout")
|
||||
assert time.monotonic() - start < 1.0
|
||||
stragglers = [t for t in threading.enumerate() if t.name == "dl-timeout" and not t.daemon]
|
||||
assert stragglers == [], f"non-daemon worker survived: {stragglers}"
|
||||
|
||||
|
||||
def test_cancel_returns_promptly() -> None:
|
||||
cancel = threading.Event()
|
||||
|
||||
def _fire() -> None:
|
||||
time.sleep(0.1)
|
||||
cancel.set()
|
||||
|
||||
threading.Thread(target=_fire, daemon=True).start()
|
||||
start = time.monotonic()
|
||||
with pytest.raises(DeadlineCancelledError):
|
||||
run_with_deadline(
|
||||
lambda: time.sleep(2.0),
|
||||
timeout=10.0,
|
||||
cancel_event=cancel,
|
||||
poll=0.05,
|
||||
thread_name="dl-cancel",
|
||||
)
|
||||
assert time.monotonic() - start < 1.0
|
||||
stragglers = [t for t in threading.enumerate() if t.name == "dl-cancel" and not t.daemon]
|
||||
assert stragglers == [], f"non-daemon worker survived: {stragglers}"
|
||||
+44
-61
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
@@ -120,8 +119,7 @@ class TestVerdictParsing:
|
||||
[{"role": "user", "content": "Run echo hello"}],
|
||||
callback_results.append,
|
||||
)
|
||||
# Wait for daemon thread
|
||||
time.sleep(0.5)
|
||||
_wait_for(callback_results, 1)
|
||||
|
||||
assert len(heuristics) == 1
|
||||
assert heuristics[0].tier == "heuristic"
|
||||
@@ -184,14 +182,12 @@ class TestErrorHandling:
|
||||
provider = _make_mock_provider(side_effect=RuntimeError("API error"))
|
||||
judge = _make_judge(provider)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
client=MagicMock(),
|
||||
)
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_provider_error_heuristic_still_returned(self):
|
||||
@@ -209,7 +205,7 @@ class TestErrorHandling:
|
||||
[{"role": "user", "content": "test"}],
|
||||
callback_results.append,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
_wait_for(callback_results, 1)
|
||||
|
||||
assert len(heuristics) == 1
|
||||
assert heuristics[0].tier == "heuristic"
|
||||
@@ -233,20 +229,19 @@ class TestErrorHandling:
|
||||
[{"role": "user", "content": "test"}],
|
||||
callback_results.append,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
_wait_for(callback_results, 1)
|
||||
assert len(callback_results) == 1
|
||||
assert callback_results[0].tier == "llm_fallback"
|
||||
|
||||
def test_executor_poison_delivers_fallback(self):
|
||||
"""An _ExecutorPoisonedError (a judge-call timeout poisoning the
|
||||
single-worker executor) restarts the executor AND still delivers one
|
||||
fallback for the interrupted item — the twin of the generic-exception
|
||||
path, and load-bearing for Smart Approvals' batch-completeness wait."""
|
||||
from turnstone.core.judge import _ExecutorPoisonedError
|
||||
|
||||
def test_evaluate_single_none_delivers_fallback(self):
|
||||
"""A judge-call timeout now surfaces as ``_evaluate_single`` returning
|
||||
None (the executor-poison restart dance is gone); the daemon must still
|
||||
deliver exactly one fallback for that item — Smart Approvals waits on
|
||||
the full verdict set before gating, so a silently-skipped item would
|
||||
block that wait until its timeout."""
|
||||
judge = _make_judge()
|
||||
judge._evaluate_single = MagicMock( # type: ignore[method-assign]
|
||||
side_effect=_ExecutorPoisonedError()
|
||||
return_value=None
|
||||
)
|
||||
callback_results: list[IntentVerdict] = []
|
||||
judge.evaluate(
|
||||
@@ -254,7 +249,7 @@ class TestErrorHandling:
|
||||
[{"role": "user", "content": "test"}],
|
||||
callback_results.append,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
_wait_for(callback_results, 1)
|
||||
assert len(callback_results) == 1
|
||||
assert callback_results[0].tier == "llm_fallback"
|
||||
|
||||
@@ -266,14 +261,12 @@ class TestErrorHandling:
|
||||
result_mock.content = ""
|
||||
|
||||
judge = _make_judge(provider)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
client=MagicMock(),
|
||||
)
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_content_length_stop_no_retry(self):
|
||||
@@ -285,14 +278,12 @@ class TestErrorHandling:
|
||||
result_mock.finish_reason = "length"
|
||||
|
||||
judge = _make_judge(provider)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
client=MagicMock(),
|
||||
)
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
assert result is None
|
||||
# Should have been called exactly once — no retries
|
||||
assert provider.create_completion.call_count == 1
|
||||
@@ -404,14 +395,12 @@ class TestMultiTurnToolUse:
|
||||
provider.create_completion.side_effect = [turn1, turn2]
|
||||
|
||||
judge = _make_judge(provider)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
verdict = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
client=MagicMock(),
|
||||
)
|
||||
verdict = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
assert verdict is not None
|
||||
assert verdict.tier == "llm"
|
||||
assert provider.create_completion.call_count == 2
|
||||
@@ -454,14 +443,12 @@ class TestMultiTurnToolUse:
|
||||
]
|
||||
|
||||
judge = _make_judge(provider)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
client=MagicMock(),
|
||||
)
|
||||
judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
client=MagicMock(),
|
||||
)
|
||||
# Should have called create_completion exactly _JUDGE_MAX_TURNS times
|
||||
assert provider.create_completion.call_count == 5
|
||||
|
||||
@@ -507,7 +494,7 @@ class TestConfidenceArbitration:
|
||||
[{"role": "user", "content": "Run echo hello"}],
|
||||
callback_results.append,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
_wait_for(callback_results, 1)
|
||||
|
||||
assert len(heuristics) == 1
|
||||
assert heuristics[0].confidence == 0.85
|
||||
@@ -527,7 +514,7 @@ class TestConfidenceArbitration:
|
||||
[{"role": "user", "content": "Run echo hello"}],
|
||||
callback_results.append,
|
||||
)
|
||||
time.sleep(0.5)
|
||||
_wait_for(callback_results, 1)
|
||||
|
||||
assert len(heuristics) == 1
|
||||
# LLM verdict is always delivered regardless of confidence comparison
|
||||
@@ -966,11 +953,7 @@ class TestModelAliasResolution:
|
||||
[{"role": "user", "content": "delegate the audit"}],
|
||||
callback_results.append,
|
||||
)
|
||||
# Wait for daemon thread.
|
||||
for _ in range(20):
|
||||
if callback_results:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
_wait_for(callback_results, 1)
|
||||
|
||||
assert callback_results, "judge never delivered a verdict"
|
||||
assert callback_results[0].tier == "llm"
|
||||
|
||||
@@ -220,6 +220,26 @@ class TestEvaluateFailurePaths:
|
||||
# Cancel should return promptly, well below the 10s timeout.
|
||||
assert elapsed < 2.0, f"cancel returned in {elapsed:.2f}s, expected < 2.0s"
|
||||
|
||||
def test_timeout_leaves_no_nondaemon_straggler(self) -> None:
|
||||
# Regression: evaluate() abandons a slow upstream call on timeout, but
|
||||
# the worker must be a *daemon* so it can never pin interpreter exit.
|
||||
# The old ThreadPoolExecutor worker was non-daemon and got joined by
|
||||
# concurrent.futures' atexit hook, hanging the whole test run at
|
||||
# shutdown. See turnstone/core/deadline.py.
|
||||
judge = _make_judge(
|
||||
content='{"risk_level":"medium","flags":[],"reasoning":""}',
|
||||
timeout=1.0,
|
||||
delay=5.0,
|
||||
)
|
||||
v = judge.evaluate("payload", call_id="c1")
|
||||
assert v.error == "timeout"
|
||||
stragglers = [
|
||||
t
|
||||
for t in threading.enumerate()
|
||||
if t.name.startswith("output-guard-judge") and not t.daemon
|
||||
]
|
||||
assert stragglers == [], f"non-daemon worker survived evaluate(): {stragglers}"
|
||||
|
||||
|
||||
class TestAliasResolution:
|
||||
def test_unknown_alias_falls_back_to_session_model(self) -> None:
|
||||
|
||||
+20
-13
@@ -19,7 +19,8 @@ class TestSuggestProfile:
|
||||
p = suggest_profile("vllm", "google/gemma-4-31B-it")
|
||||
assert p["capabilities"]["thinking_mode"] == "manual"
|
||||
assert p["capabilities"]["thinking_param"] == "enable_thinking"
|
||||
assert p["server_compat"]["extra_body"]["skip_special_tokens"] is False
|
||||
# No bug-workaround extra_body — gemma-4 needs only the thinking param.
|
||||
assert "extra_body" not in p["server_compat"]
|
||||
|
||||
def test_vllm_gemma3(self) -> None:
|
||||
p = suggest_profile("vllm", "google/gemma-3-27b-it")
|
||||
@@ -147,14 +148,14 @@ class TestMergeServerCompat:
|
||||
result = merge_server_compat(None, {"extra_body": {"skip_special_tokens": False}})
|
||||
assert result == {"skip_special_tokens": False}
|
||||
|
||||
def test_full_vllm_gemma_compat_no_base(self) -> None:
|
||||
"""vLLM workaround forwards on its own."""
|
||||
def test_full_server_compat_extra_body_no_base(self) -> None:
|
||||
"""A server workaround (e.g. llama.cpp reasoning_format) forwards on its own."""
|
||||
compat = {
|
||||
"server_type": "vllm",
|
||||
"extra_body": {"skip_special_tokens": False},
|
||||
"server_type": "llama.cpp",
|
||||
"extra_body": {"reasoning_format": "auto"},
|
||||
}
|
||||
result = merge_server_compat(None, compat)
|
||||
assert result == {"skip_special_tokens": False}
|
||||
assert result == {"reasoning_format": "auto"}
|
||||
|
||||
def test_operator_chat_template_kwargs_only(self) -> None:
|
||||
"""Operator can set chat_template_kwargs explicitly without seeding the base."""
|
||||
@@ -210,21 +211,27 @@ class TestEndToEndRequestShaping:
|
||||
"""Compose both layers — session builds extra_params, provider applies thinking."""
|
||||
|
||||
def test_vllm_gemma_full_flow(self) -> None:
|
||||
"""Session forwards server workarounds, provider adds thinking param."""
|
||||
"""Gemma now needs only the thinking param — no server workaround."""
|
||||
caps = ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking")
|
||||
server_compat = {
|
||||
"server_type": "vllm",
|
||||
"extra_body": {"skip_special_tokens": False},
|
||||
}
|
||||
server_compat = {"server_type": "vllm"}
|
||||
# Step 1: session forwards (no auto-injection of reasoning_effort).
|
||||
extra_params = merge_server_compat(None, server_compat)
|
||||
# Step 2: provider injects thinking param into chat_template_kwargs.
|
||||
extra_body = dict(extra_params)
|
||||
OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps)
|
||||
|
||||
assert extra_body == {"chat_template_kwargs": {"enable_thinking": True}}
|
||||
|
||||
def test_server_workaround_composes_with_thinking(self) -> None:
|
||||
"""A top-level server workaround forwards alongside the injected thinking param."""
|
||||
caps = ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking")
|
||||
compat = {"server_type": "llama.cpp", "extra_body": {"reasoning_format": "auto"}}
|
||||
extra_body = dict(merge_server_compat(None, compat))
|
||||
OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps)
|
||||
|
||||
assert extra_body == {
|
||||
"chat_template_kwargs": {"enable_thinking": True},
|
||||
"skip_special_tokens": False,
|
||||
"reasoning_format": "auto",
|
||||
}
|
||||
|
||||
def test_granite_thinking_key(self) -> None:
|
||||
@@ -292,7 +299,7 @@ class TestProbeIntegration:
|
||||
assert result["server_type"] == "vllm"
|
||||
assert result["suggested_capabilities"]["thinking_mode"] == "manual"
|
||||
assert result["suggested_capabilities"]["thinking_param"] == "enable_thinking"
|
||||
assert result["suggested_server_compat"]["extra_body"]["skip_special_tokens"] is False
|
||||
assert "extra_body" not in result["suggested_server_compat"]
|
||||
|
||||
def test_detect_non_thinking_no_suggested_capabilities(self) -> None:
|
||||
"""Non-thinking vLLM model gets server_compat but no capabilities suggestion."""
|
||||
|
||||
@@ -96,10 +96,8 @@ def _make_flaky_client(monkeypatch, failures: int):
|
||||
"""TLSClient whose CA fetch fails ``failures`` times, then succeeds.
|
||||
|
||||
Returns (client, calls, sleeps) — mutable lists recording each CA-fetch
|
||||
attempt and each backoff delay (asyncio.sleep is stubbed out).
|
||||
attempt and each backoff delay (the client's backoff sleep is stubbed).
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
client = TLSClient(
|
||||
@@ -119,11 +117,14 @@ def _make_flaky_client(monkeypatch, failures: int):
|
||||
pass
|
||||
|
||||
async def fake_sleep(delay):
|
||||
# Stub the client's own _sleep seam, NOT the global asyncio.sleep:
|
||||
# patching the global also intercepts any concurrent task sharing the
|
||||
# event loop, which corrupted a background poller and hung CI.
|
||||
sleeps.append(delay)
|
||||
|
||||
monkeypatch.setattr(client, "_fetch_ca_cert", flaky_fetch)
|
||||
monkeypatch.setattr(client, "_request_cert", ok_request)
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
monkeypatch.setattr(client, "_sleep", fake_sleep)
|
||||
return client, calls, sleeps
|
||||
|
||||
|
||||
@@ -175,8 +176,6 @@ async def test_init_retries_exhausted_raises(monkeypatch):
|
||||
@pytest.mark.anyio
|
||||
async def test_init_retries_discovery_failure(monkeypatch):
|
||||
"""Console discovery (not-yet-registered console) is retried too."""
|
||||
import asyncio
|
||||
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
client = TLSClient(storage=get_storage(), hostnames=["node-1"])
|
||||
@@ -191,10 +190,13 @@ async def test_init_retries_discovery_failure(monkeypatch):
|
||||
async def ok():
|
||||
pass
|
||||
|
||||
async def fake_sleep(_delay):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(client, "_discover_console_url", flaky_discover)
|
||||
monkeypatch.setattr(client, "_fetch_ca_cert", ok)
|
||||
monkeypatch.setattr(client, "_request_cert", ok)
|
||||
monkeypatch.setattr(asyncio, "sleep", lambda _: ok())
|
||||
monkeypatch.setattr(client, "_sleep", fake_sleep)
|
||||
|
||||
await client.init(attempts=2)
|
||||
assert attempts == [1, 2]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Tests for turnstone.console.server._validate_regex_pattern.
|
||||
|
||||
The catastrophic-backtracking branch is verified by simulating the deadline
|
||||
firing rather than running a real ReDoS regex — a genuine runaway pattern would
|
||||
leave a CPU-pinned daemon worker for the rest of the suite. The daemon-abandon
|
||||
mechanism itself is covered in tests/test_deadline.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from turnstone.console.server import _validate_regex_pattern
|
||||
from turnstone.core.deadline import DeadlineExceededError
|
||||
|
||||
|
||||
def test_valid_pattern_returns_none() -> None:
|
||||
assert _validate_regex_pattern(r"\d{3}-\d{4}") is None
|
||||
|
||||
|
||||
def test_invalid_pattern_returns_error() -> None:
|
||||
msg = _validate_regex_pattern(r"(unclosed")
|
||||
assert msg is not None
|
||||
assert msg.startswith("Invalid regex")
|
||||
|
||||
|
||||
def test_catastrophic_backtracking_returns_message(monkeypatch) -> None:
|
||||
def _deadline(*_args, **_kwargs):
|
||||
raise DeadlineExceededError
|
||||
|
||||
monkeypatch.setattr("turnstone.console.server.run_with_deadline", _deadline)
|
||||
# The pattern is arbitrary — run_with_deadline is stubbed to raise, so the
|
||||
# probe never runs; a real backtracking literal here would only trip CodeQL.
|
||||
assert _validate_regex_pattern(r"\w+") == "Regex appears to have catastrophic backtracking"
|
||||
|
||||
|
||||
def test_probe_error_returns_generic_message(monkeypatch) -> None:
|
||||
def _err(*_args, **_kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr("turnstone.console.server.run_with_deadline", _err)
|
||||
assert _validate_regex_pattern(r"abc") == "Regex caused an error during test"
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.6.6"
|
||||
__version__ = "1.6.8"
|
||||
|
||||
+2
-2
@@ -1022,8 +1022,8 @@ def main() -> None:
|
||||
"--judge-timeout",
|
||||
dest="judge_timeout",
|
||||
type=float,
|
||||
default=60.0,
|
||||
help="LLM judge timeout in seconds (default: 60)",
|
||||
default=120.0,
|
||||
help="LLM judge timeout in seconds (default: 120)",
|
||||
)
|
||||
judge_group.add_argument(
|
||||
"--judge-confidence",
|
||||
|
||||
+10
-10
@@ -55,6 +55,7 @@ from turnstone.core.auth import (
|
||||
jwt_version_slot,
|
||||
require_permission,
|
||||
)
|
||||
from turnstone.core.deadline import DeadlineExceededError, run_with_deadline
|
||||
from turnstone.core.rendezvous import NoAvailableNodeError
|
||||
from turnstone.core.session_replay import session_replay_preamble
|
||||
from turnstone.core.session_routes import (
|
||||
@@ -11530,16 +11531,15 @@ def _validate_regex_pattern(pattern: str, flags: int = 0) -> str | None:
|
||||
compiled.search(s)
|
||||
|
||||
try:
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import TimeoutError as FuturesTimeout
|
||||
|
||||
pool = ThreadPoolExecutor(max_workers=1)
|
||||
try:
|
||||
pool.submit(_probe).result(timeout=0.5)
|
||||
except FuturesTimeout:
|
||||
return "Regex appears to have catastrophic backtracking"
|
||||
finally:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
# Daemon worker: a catastrophically-backtracking regex must be
|
||||
# abandonable without pinning a non-daemon thread that would hang
|
||||
# interpreter exit (a ThreadPoolExecutor worker is joined at exit).
|
||||
# Budget is generous — a legitimately complex pattern can take a second
|
||||
# or two on the probe strings; only exponential blowup (which sails past
|
||||
# any few-second bound) should trip the catastrophic-backtracking guard.
|
||||
run_with_deadline(_probe, timeout=3.0, poll=0.1, thread_name="regex-redos-probe")
|
||||
except DeadlineExceededError:
|
||||
return "Regex appears to have catastrophic backtracking"
|
||||
except Exception:
|
||||
return "Regex caused an error during test"
|
||||
return None
|
||||
|
||||
+197
-18
@@ -15,11 +15,16 @@ backend is surfaced as a typed error the endpoint maps to 503 / 502.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.server_compat import merge_server_compat
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
@@ -83,6 +88,22 @@ _OMNI_STT_PROMPT = (
|
||||
"seven, and write 3 instead of three"
|
||||
)
|
||||
|
||||
# Bound the omni STT decode. Gemma caps audio at 30 s and a 30 s transcript is
|
||||
# well under this, so the cap only catches a pathological runaway — it never
|
||||
# truncates a real transcript.
|
||||
_OMNI_STT_MAX_TOKENS = 1024
|
||||
|
||||
# Hard limit on the ffmpeg transcode subprocess (seconds).
|
||||
_FFMPEG_TIMEOUT_S = 30
|
||||
|
||||
# Cap the decoded audio duration so a crafted clip can't expand into an
|
||||
# unbounded decode (the upload itself is already size-capped at the endpoint).
|
||||
_MAX_AUDIO_SECONDS = 300
|
||||
|
||||
# Per-request timeout for the streaming STT chat call — bounds a hung backend
|
||||
# (the whole transcription is ~1 s; this only catches a stalled stream).
|
||||
_OMNI_STT_TIMEOUT_S = 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TranscriptionResult:
|
||||
@@ -185,30 +206,115 @@ def _serves_transcription_endpoint(cfg: Any, model: str) -> bool:
|
||||
return _infer_audio_capability(model, "stt")
|
||||
|
||||
|
||||
def _transcribe_via_chat(client: Any, model: str, data: bytes, filename: str, prompt: str) -> str:
|
||||
def _to_wav_16k_mono(data: bytes) -> bytes:
|
||||
"""Decode any ffmpeg-readable audio container to 16 kHz mono PCM WAV.
|
||||
|
||||
Browsers record webm/opus (or ogg/mp4); the omni chat lane — vLLM in
|
||||
particular — only decodes wav/mp3 and sniffs the bytes, so the raw upload is
|
||||
rejected as an "Invalid or unsupported audio file". ffmpeg reads the
|
||||
container from the byte stream (no reliance on the filename) and resamples to
|
||||
the 16 kHz mono PCM the model documents. Raises :class:`AudioBackendError`
|
||||
(the endpoint maps it to 502) if ffmpeg is missing or the bytes don't decode.
|
||||
"""
|
||||
# ffmpeg reads only the piped bytes (-protocol_whitelist pipe) so a crafted
|
||||
# container can't open file:/http: references (SSRF / local file read); -vn
|
||||
# drops video streams and -t bounds the decode against a decompression bomb.
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-protocol_whitelist",
|
||||
"pipe",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-vn",
|
||||
"-t",
|
||||
str(_MAX_AUDIO_SECONDS),
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-f",
|
||||
"wav",
|
||||
"pipe:1",
|
||||
],
|
||||
input=data,
|
||||
capture_output=True,
|
||||
timeout=_FFMPEG_TIMEOUT_S,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise AudioBackendError("ffmpeg is not installed; cannot transcode audio") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise AudioBackendError("Audio transcode timed out") from exc
|
||||
if proc.returncode != 0 or not proc.stdout:
|
||||
detail = proc.stderr.decode("utf-8", "replace").strip()
|
||||
raise AudioBackendError(f"Audio transcode failed: {detail[-200:] or 'no output'}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _omni_chat_extra_body(cfg: Any) -> dict[str, Any]:
|
||||
"""Build the chat ``extra_body`` for an omni STT call.
|
||||
|
||||
The STT path calls the raw client, so it bypasses the provider's request
|
||||
shaping. Reuse ``merge_server_compat`` to forward any operator-stored
|
||||
``server_compat["extra_body"]``, then force **thinking OFF** via the model's
|
||||
own ``thinking_param``: transcription needs no reasoning, and leaving it on
|
||||
multiplies latency ~10x and (on some chat templates) empties the content.
|
||||
The override is applied last so it wins over any operator thinking flag.
|
||||
"""
|
||||
server_compat = getattr(cfg, "server_compat", None)
|
||||
extra = merge_server_compat(None, server_compat) if isinstance(server_compat, dict) else {}
|
||||
caps = getattr(cfg, "capabilities", None) or {}
|
||||
thinking_param = caps.get("thinking_param")
|
||||
if thinking_param and caps.get("thinking_mode") in ("manual", "adaptive"):
|
||||
extra.setdefault("chat_template_kwargs", {})[thinking_param] = False
|
||||
return extra
|
||||
|
||||
|
||||
def _omni_chat_messages(prompt: str, audio_b64: str) -> list[dict[str, Any]]:
|
||||
"""The single user turn for an omni STT chat call: the prompt precedes the
|
||||
audio part — the order Gemma documents for transcription."""
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "input_audio", "input_audio": {"data": audio_b64, "format": "wav"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _transcribe_via_chat(
|
||||
client: Any,
|
||||
model: str,
|
||||
data: bytes,
|
||||
prompt: str,
|
||||
*,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
max_tokens: int = _OMNI_STT_MAX_TOKENS,
|
||||
) -> str:
|
||||
"""Transcribe by handing the clip to an omni *chat* model as ``input_audio``.
|
||||
|
||||
For models that accept audio in chat (``supports_audio_input``) but don't
|
||||
serve ``/audio/transcriptions``. The instruction ``prompt`` steers the model
|
||||
to emit only the transcript. The audio format is taken from the upload's
|
||||
filename extension (the same shape the attachment wire path uses).
|
||||
serve ``/audio/transcriptions``. The clip is transcoded to 16 kHz mono WAV
|
||||
first (browsers record webm/opus, which the chat lane can't decode). The
|
||||
instruction ``prompt`` precedes the audio part — the order Gemma documents
|
||||
for transcription — and ``extra_body`` carries the thinking-off / server
|
||||
compat params the raw-client path would otherwise skip.
|
||||
"""
|
||||
import base64
|
||||
|
||||
name = filename or "speech.webm"
|
||||
fmt = name.rsplit(".", 1)[-1].lower() if "." in name else "wav"
|
||||
audio_b64 = base64.b64encode(data).decode("ascii")
|
||||
wav = _to_wav_16k_mono(data)
|
||||
audio_b64 = base64.b64encode(wav).decode("ascii")
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "input_audio", "input_audio": {"data": audio_b64, "format": fmt}},
|
||||
],
|
||||
}
|
||||
],
|
||||
messages=_omni_chat_messages(prompt, audio_b64),
|
||||
max_tokens=max_tokens,
|
||||
extra_body=extra_body or None,
|
||||
)
|
||||
choices = getattr(resp, "choices", None) or []
|
||||
if not choices:
|
||||
@@ -261,13 +367,86 @@ def transcribe(
|
||||
transcript = (getattr(resp, "text", "") or "").strip()
|
||||
else:
|
||||
transcript = _transcribe_via_chat(
|
||||
client, model, data, filename, prompt or _OMNI_STT_PROMPT
|
||||
client,
|
||||
model,
|
||||
data,
|
||||
prompt or _OMNI_STT_PROMPT,
|
||||
extra_body=_omni_chat_extra_body(cfg),
|
||||
)
|
||||
except AudioBackendError:
|
||||
# Transcode errors already carry an actionable message — keep it.
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AudioBackendError(f"Transcription backend failed: {exc}") from exc
|
||||
return TranscriptionResult(transcript=transcript, model_alias=alias, model=model)
|
||||
|
||||
|
||||
def _iter_stream_deltas(stream: Any) -> Iterator[str]:
|
||||
"""Yield non-empty content deltas from an OpenAI streaming chat response.
|
||||
|
||||
Owns the stream's lifecycle: exhausting or closing this generator releases
|
||||
the underlying HTTP connection, so an abandoned stream can't leak it.
|
||||
"""
|
||||
try:
|
||||
for chunk in stream:
|
||||
choices = getattr(chunk, "choices", None) or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = getattr(choices[0].delta, "content", None)
|
||||
if delta:
|
||||
yield delta
|
||||
finally:
|
||||
close = getattr(stream, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
|
||||
def transcribe_stream(*, registry: Any, alias: str, data: bytes, prompt: str = "") -> Iterator[str]:
|
||||
"""Stream transcript content deltas for the STT role alias.
|
||||
|
||||
Resolve, transcode, and opening the streaming-chat request all run eagerly
|
||||
(before the returned generator yields its first delta) so the caller can
|
||||
surface a clean 503 / 502; only the token iteration is deferred. A
|
||||
whisper-style endpoint alias has no chat stream, so it emits the whole
|
||||
transcript as a single chunk.
|
||||
"""
|
||||
try:
|
||||
client, model, cfg = registry.resolve(alias)
|
||||
except Exception as exc: # unknown/removed alias
|
||||
raise AudioUnavailableError(f"STT model alias {alias!r} is not available") from exc
|
||||
if not _provider_carries_audio(cfg):
|
||||
raise AudioUnavailableError(
|
||||
f"STT model alias {alias!r} (provider {getattr(cfg, 'provider', 'unknown')!r}) "
|
||||
"can't transcribe audio — audio roles require an OpenAI-compatible provider."
|
||||
)
|
||||
if _serves_transcription_endpoint(cfg, model):
|
||||
# Whisper-style endpoint: no chat stream — emit the whole transcript once.
|
||||
text = transcribe(
|
||||
registry=registry, alias=alias, data=data, filename="speech.webm", prompt=prompt
|
||||
).transcript
|
||||
return iter([text] if text else [])
|
||||
caps = getattr(cfg, "capabilities", None) or {}
|
||||
if not caps.get("supports_audio_input"):
|
||||
raise AudioUnavailableError(f"STT model alias {alias!r} cannot transcribe audio")
|
||||
|
||||
import base64
|
||||
|
||||
wav = _to_wav_16k_mono(data)
|
||||
audio_b64 = base64.b64encode(wav).decode("ascii")
|
||||
try:
|
||||
stream = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=_omni_chat_messages(prompt or _OMNI_STT_PROMPT, audio_b64),
|
||||
max_tokens=_OMNI_STT_MAX_TOKENS,
|
||||
extra_body=_omni_chat_extra_body(cfg) or None,
|
||||
stream=True,
|
||||
timeout=_OMNI_STT_TIMEOUT_S,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise AudioBackendError(f"Transcription backend failed: {exc}") from exc
|
||||
return _iter_stream_deltas(stream)
|
||||
|
||||
|
||||
# -- transcript memoization (no-native-audio wire fallback) -------------------
|
||||
# Caching an STT result is an audio-domain concern, so it lives here next to
|
||||
# ``transcribe``. The wire resolver re-materializes every attachment on every
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Run a blocking call under a wall-clock deadline on a daemon thread.
|
||||
|
||||
The motivating constraint comes from the judges (:mod:`turnstone.core.judge`,
|
||||
:mod:`turnstone.core.output_guard_judge`): an upstream LLM call must be
|
||||
*abandonable* the instant its timeout or cancel fires, without the abandoned
|
||||
call being able to block process or interpreter exit.
|
||||
|
||||
A :class:`~concurrent.futures.ThreadPoolExecutor` worker is **non-daemon**, and
|
||||
``concurrent.futures`` joins every executor worker from an ``atexit`` hook
|
||||
(``_python_exit``) regardless of ``shutdown(wait=False)``. So an upstream call
|
||||
wedged with no socket timeout hangs interpreter shutdown forever — which is
|
||||
exactly how a single slow judge call can deadlock a whole test run at exit.
|
||||
|
||||
A **daemon** worker is never joined at exit, so abandoning one is always safe:
|
||||
the call keeps running until it returns or the process dies, whichever comes
|
||||
first, and never pins shutdown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class DeadlineExceededError(Exception):
|
||||
"""The call did not complete before its wall-clock deadline."""
|
||||
|
||||
|
||||
class DeadlineCancelledError(Exception):
|
||||
"""The cancel event fired before the call completed."""
|
||||
|
||||
|
||||
def run_with_deadline(
|
||||
fn: Callable[[], _T],
|
||||
*,
|
||||
timeout: float,
|
||||
cancel_event: threading.Event | None = None,
|
||||
poll: float = 1.0,
|
||||
thread_name: str = "deadline-worker",
|
||||
) -> _T:
|
||||
"""Run ``fn()`` on a daemon thread, bounded by ``timeout``/``cancel_event``.
|
||||
|
||||
Returns ``fn()``'s result, or re-raises whatever ``fn`` raised. Raises
|
||||
:class:`DeadlineExceededError` if ``timeout`` seconds elapse first, or
|
||||
:class:`DeadlineCancelledError` if ``cancel_event`` fires first. On either
|
||||
abort the worker thread is abandoned; being a daemon it cannot block
|
||||
process or interpreter exit.
|
||||
|
||||
``poll`` bounds how often ``cancel_event`` is checked (and thus the worst-
|
||||
case latency from a cancel to this function returning).
|
||||
"""
|
||||
box: queue.Queue[tuple[bool, object]] = queue.Queue(maxsize=1)
|
||||
|
||||
def _runner() -> None:
|
||||
try:
|
||||
box.put((True, fn()))
|
||||
except BaseException as exc: # noqa: BLE001 - relayed to the caller verbatim
|
||||
box.put((False, exc))
|
||||
|
||||
threading.Thread(target=_runner, name=thread_name, daemon=True).start()
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
# Prefer a result that has already arrived over a deadline or cancel
|
||||
# firing in the same scheduling window — otherwise a completed call
|
||||
# could be reported as a spurious timeout/cancel under jitter.
|
||||
try:
|
||||
ok, payload = box.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
else:
|
||||
if ok:
|
||||
return payload # type: ignore[return-value] # ok=True ⇒ payload is _T
|
||||
raise payload # type: ignore[misc] # ok=False ⇒ payload is the raised exc
|
||||
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise DeadlineCancelledError
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise DeadlineExceededError
|
||||
try:
|
||||
ok, payload = box.get(timeout=min(remaining, poll))
|
||||
except queue.Empty:
|
||||
continue
|
||||
if ok:
|
||||
return payload # type: ignore[return-value]
|
||||
raise payload # type: ignore[misc]
|
||||
+33
-50
@@ -15,11 +15,16 @@ import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core.deadline import (
|
||||
DeadlineCancelledError,
|
||||
DeadlineExceededError,
|
||||
run_with_deadline,
|
||||
)
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -76,8 +81,8 @@ class JudgeConfig:
|
||||
"""Configuration for the intent validation judge.
|
||||
|
||||
The *timeout* value applies **per turn**, not as a total budget across
|
||||
all turns. With the default of 60 s and a maximum of 5 turns, a
|
||||
single tool-call evaluation can take up to 300 s in the worst case
|
||||
all turns. With the default of 120 s and a maximum of 5 turns, a
|
||||
single tool-call evaluation can take up to 600 s in the worst case
|
||||
(e.g. a multi-turn tool-use exchange with a slow local model).
|
||||
"""
|
||||
|
||||
@@ -86,13 +91,13 @@ class JudgeConfig:
|
||||
smart_approvals: bool = False # auto-approve high-confidence "approve" LLM verdicts
|
||||
confidence_threshold: float = 0.95 # Smart Approvals auto-approve bar (recommendation=approve)
|
||||
max_context_ratio: float = 0.5
|
||||
timeout: float = 60.0 # per-turn timeout in seconds (see class docstring)
|
||||
timeout: float = 120.0 # per-turn timeout in seconds (see class docstring)
|
||||
read_only_tools: bool = True
|
||||
output_guard: bool = True
|
||||
output_guard_budget_seconds: float = 30.0 # wall-clock budget for output_guard regex scan
|
||||
output_guard_llm: bool = False # enable LLM stage on tool output (issue #560 mitigation #1)
|
||||
output_guard_model: str = "" # alias for the LLM stage; empty = inherit session model
|
||||
output_guard_llm_timeout: float = 30.0 # wall-clock budget for the LLM stage
|
||||
output_guard_llm_timeout: float = 60.0 # wall-clock budget for the LLM stage
|
||||
redact_secrets: bool = True
|
||||
# True = the approval gate's resolution aborts remaining evaluations
|
||||
# (saves inference; undone items degrade to ``llm_fallback`` verdicts
|
||||
@@ -881,10 +886,6 @@ If you used read_file to check a target, cite what you found."""
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ExecutorPoisonedError(Exception):
|
||||
"""Raised when a timeout leaves the executor's worker thread stuck."""
|
||||
|
||||
|
||||
class IntentJudge:
|
||||
"""Session-scoped LLM judge for intent validation.
|
||||
|
||||
@@ -1054,7 +1055,6 @@ class IntentJudge:
|
||||
verdicts are delivered.
|
||||
"""
|
||||
client = self._create_client()
|
||||
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
|
||||
try:
|
||||
for idx, (item, h_verdict) in enumerate(zip(items, heuristic_verdicts, strict=True)):
|
||||
if cancel_event and cancel_event.is_set():
|
||||
@@ -1071,7 +1071,6 @@ class IntentJudge:
|
||||
item,
|
||||
messages,
|
||||
cancel_event,
|
||||
executor,
|
||||
client,
|
||||
)
|
||||
if llm_verdict:
|
||||
@@ -1116,17 +1115,6 @@ class IntentJudge:
|
||||
"judge cancelled before evaluating this call",
|
||||
)
|
||||
return
|
||||
except _ExecutorPoisonedError:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
|
||||
# Deliver a fallback for the interrupted item so every
|
||||
# call still gets exactly one verdict. Smart Approvals
|
||||
# waits on the full set before gating; a silently-
|
||||
# skipped item would otherwise block that wait until
|
||||
# its timeout (and the advisory UI would miss a chip).
|
||||
self._deliver_fallbacks(
|
||||
[item], [h_verdict], callback, "judge executor restarted"
|
||||
)
|
||||
except Exception:
|
||||
log.exception(
|
||||
"Judge evaluation failed for %s",
|
||||
@@ -1134,7 +1122,6 @@ class IntentJudge:
|
||||
)
|
||||
self._deliver_fallbacks([item], [h_verdict], callback, "judge evaluation error")
|
||||
finally:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
try:
|
||||
if hasattr(client, "close"):
|
||||
client.close()
|
||||
@@ -1172,7 +1159,6 @@ class IntentJudge:
|
||||
item: dict[str, Any],
|
||||
messages: list[dict[str, Any]],
|
||||
cancel_event: threading.Event | None,
|
||||
executor: ThreadPoolExecutor,
|
||||
client: Any,
|
||||
) -> IntentVerdict | None:
|
||||
"""Run LLM judge for a single tool call. Returns verdict or None."""
|
||||
@@ -1237,32 +1223,29 @@ class IntentJudge:
|
||||
# models aren't penalised for slow earlier turns.
|
||||
per_call_timeout = max(self._config.timeout, 5.0) # at least 5s
|
||||
try:
|
||||
future = executor.submit(
|
||||
self._provider.create_completion,
|
||||
client=client,
|
||||
model=self._model,
|
||||
messages=judge_messages,
|
||||
tools=None if is_last_turn else tools,
|
||||
max_tokens=2048,
|
||||
temperature=0.0,
|
||||
reasoning_effort="medium",
|
||||
# Each turn runs on its own daemon worker (1s cancel polling).
|
||||
# A timeout or cancel abandons the call without pinning a
|
||||
# non-daemon thread that would block interpreter exit — the old
|
||||
# single-slot ThreadPoolExecutor left a stuck worker that
|
||||
# poisoned the pool, which is why the restart dance existed.
|
||||
result = run_with_deadline(
|
||||
partial(
|
||||
self._provider.create_completion,
|
||||
client=client,
|
||||
model=self._model,
|
||||
messages=judge_messages,
|
||||
tools=None if is_last_turn else tools,
|
||||
max_tokens=2048,
|
||||
temperature=0.0,
|
||||
reasoning_effort="medium",
|
||||
),
|
||||
timeout=per_call_timeout,
|
||||
cancel_event=cancel_event,
|
||||
thread_name="judge-api",
|
||||
)
|
||||
# Poll in 1s increments so we notice cancellation promptly
|
||||
# instead of blocking for the full per_call_timeout.
|
||||
deadline = time.monotonic() + per_call_timeout
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if cancel_event and cancel_event.is_set():
|
||||
future.cancel()
|
||||
return None
|
||||
if remaining <= 0:
|
||||
raise TimeoutError
|
||||
try:
|
||||
result = future.result(timeout=min(remaining, 1.0))
|
||||
break
|
||||
except TimeoutError:
|
||||
pass # loop back to check remaining/cancel
|
||||
except TimeoutError:
|
||||
except DeadlineCancelledError:
|
||||
return None
|
||||
except DeadlineExceededError:
|
||||
log.info("judge.turn.timeout", turn=turn + 1, timeout=per_call_timeout)
|
||||
# Safety net: if we have a partial result from a previous turn,
|
||||
# try to parse a verdict from it before giving up.
|
||||
@@ -1277,7 +1260,7 @@ class IntentJudge:
|
||||
if verdict:
|
||||
log.info("judge.verdict.from_partial", turn=turn + 1)
|
||||
return verdict
|
||||
raise _ExecutorPoisonedError from None
|
||||
return None
|
||||
except Exception as e:
|
||||
log.info("judge.turn.failed", turn=turn + 1, error=str(e))
|
||||
return None
|
||||
|
||||
@@ -12,13 +12,13 @@ Design:
|
||||
a static tool result doesn't benefit from multi-turn — the text is
|
||||
already in hand.
|
||||
- JSON-in-content verdict. 4-strategy parser inlined from
|
||||
:class:`IntentJudge` (``judge.py:1603-1659``).
|
||||
- ``ThreadPoolExecutor`` + ``future.result(timeout=)`` with 1 s
|
||||
cancel-event polling. The executor is owned explicitly with
|
||||
``shutdown(wait=False, cancel_futures=True)`` so a timeout or
|
||||
cancellation returns promptly even if the worker thread is still
|
||||
blocked on the upstream LLM call. This mirrors
|
||||
:meth:`IntentJudge._run_judge`'s pattern at ``judge.py:1117-1118``.
|
||||
:meth:`IntentJudge._parse_verdict`.
|
||||
- Wall-clock deadline via :func:`turnstone.core.deadline.run_with_deadline`,
|
||||
which runs the call on a *daemon* worker and polls the cancel event each
|
||||
second. A timeout or cancel abandons the call rather than waiting it out,
|
||||
and the daemon worker can never block process or interpreter exit — unlike
|
||||
a ``ThreadPoolExecutor`` worker, which ``concurrent.futures`` joins from an
|
||||
``atexit`` hook regardless of ``shutdown(wait=False)``.
|
||||
- HTTP client is lazy-init + reused across evaluations on a single
|
||||
judge instance. Session-side model swaps drop the entire
|
||||
:class:`OutputGuardJudge` (``session.py:1733``/``:2136``), which
|
||||
@@ -39,11 +39,15 @@ import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core import fence
|
||||
from turnstone.core.deadline import (
|
||||
DeadlineCancelledError,
|
||||
DeadlineExceededError,
|
||||
run_with_deadline,
|
||||
)
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -370,9 +374,10 @@ class OutputGuardJudge:
|
||||
by :meth:`_user_prompt`. Callers that don't have a particular
|
||||
field leave it at its default — the prompt skips empty sections.
|
||||
|
||||
Timeout enforcement is real wall-clock: the executor is shut
|
||||
down with ``wait=False, cancel_futures=True`` on the timeout /
|
||||
cancel path, so a hung upstream LLM call does not block return.
|
||||
Timeout enforcement is real wall-clock: the upstream call runs on a
|
||||
daemon worker via :func:`~turnstone.core.deadline.run_with_deadline`
|
||||
and is abandoned on the timeout / cancel path, so a hung upstream LLM
|
||||
call neither blocks return nor pins interpreter exit.
|
||||
"""
|
||||
if not output:
|
||||
return OutputJudgeVerdict(
|
||||
@@ -407,15 +412,16 @@ class OutputGuardJudge:
|
||||
verdict_id, call_id, start, f"client_create_failed: {type(e).__name__}"
|
||||
)
|
||||
|
||||
# Explicit executor lifetime — the `with ... as ex:` form's
|
||||
# implicit shutdown(wait=True) would block return until the
|
||||
# upstream call completed, defeating the wall-clock timeout.
|
||||
# Mirror IntentJudge's pattern at judge.py:1117-1118.
|
||||
ex = ThreadPoolExecutor(max_workers=1, thread_name_prefix="output-guard-judge")
|
||||
# Run the upstream call on a *daemon* worker bounded by a real
|
||||
# wall-clock deadline: a timeout or cancel abandons the call instead of
|
||||
# waiting it out, and because the worker is a daemon an abandoned call
|
||||
# can never block process or interpreter exit. (A ThreadPoolExecutor
|
||||
# worker is non-daemon, and concurrent.futures joins it from an atexit
|
||||
# hook regardless of shutdown(wait=False) — so a wedged upstream call
|
||||
# would otherwise hang shutdown.)
|
||||
try:
|
||||
try:
|
||||
future = ex.submit(
|
||||
self._provider.create_completion,
|
||||
result = run_with_deadline(
|
||||
lambda: self._provider.create_completion(
|
||||
client=client,
|
||||
model=self._model,
|
||||
messages=judge_messages,
|
||||
@@ -423,27 +429,19 @@ class OutputGuardJudge:
|
||||
max_tokens=512,
|
||||
temperature=0.0,
|
||||
reasoning_effort="low",
|
||||
)
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
future.cancel()
|
||||
return self._error_verdict(verdict_id, call_id, start, "cancelled")
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
future.cancel()
|
||||
return self._error_verdict(verdict_id, call_id, start, "timeout")
|
||||
try:
|
||||
result = future.result(timeout=min(remaining, 1.0))
|
||||
break
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
return self._error_verdict(
|
||||
verdict_id, call_id, start, f"provider_error: {type(e).__name__}"
|
||||
)
|
||||
finally:
|
||||
ex.shutdown(wait=False, cancel_futures=True)
|
||||
),
|
||||
timeout=timeout,
|
||||
cancel_event=cancel_event,
|
||||
thread_name="output-guard-judge",
|
||||
)
|
||||
except DeadlineCancelledError:
|
||||
return self._error_verdict(verdict_id, call_id, start, "cancelled")
|
||||
except DeadlineExceededError:
|
||||
return self._error_verdict(verdict_id, call_id, start, "timeout")
|
||||
except Exception as e:
|
||||
return self._error_verdict(
|
||||
verdict_id, call_id, start, f"provider_error: {type(e).__name__}"
|
||||
)
|
||||
|
||||
content = (getattr(result, "content", "") or "").strip()
|
||||
if not content:
|
||||
|
||||
@@ -14,10 +14,12 @@ request shaping. This module separates three concerns:
|
||||
Stored under ``server_compat`` because it's an endpoint property,
|
||||
not a model property.
|
||||
|
||||
3. **Server workarounds** — ``extra_body`` overrides like
|
||||
``skip_special_tokens=false`` are properties of the *server* (vLLM
|
||||
bug workaround). These stay in ``server_compat`` and get merged
|
||||
into the request's ``extra_body`` at call time.
|
||||
3. **Server workarounds** — ``extra_body`` overrides like llama.cpp's
|
||||
``reasoning_format`` are properties of the *server*, not the model.
|
||||
These stay in ``server_compat`` and get merged into the request's
|
||||
``extra_body`` at call time. Reserve these for stable server config:
|
||||
bug-workaround flags for fast-moving open models go stale the moment
|
||||
the upstream bug is fixed, so we don't carry them speculatively.
|
||||
|
||||
Profiles are *suggestions* only. The admin UI auto-fills them on
|
||||
Detect; the operator has final say, and the stored DB config is what
|
||||
@@ -45,11 +47,6 @@ _PROFILES: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"server_compat": {
|
||||
"server_type": "vllm",
|
||||
# Workaround: vLLM strips special tokens before the Gemma4
|
||||
# reasoning parser sees them. skip_special_tokens=false
|
||||
# preserves <|channel> / <channel|> markers so reasoning
|
||||
# content is extracted correctly.
|
||||
"extra_body": {"skip_special_tokens": False},
|
||||
},
|
||||
},
|
||||
"vllm-qwen-thinking": {
|
||||
|
||||
@@ -577,7 +577,7 @@ def _build_registry() -> dict[str, SettingDef]:
|
||||
SettingDef(
|
||||
"judge.timeout",
|
||||
"float",
|
||||
60.0,
|
||||
120.0,
|
||||
"Judge evaluation timeout in seconds",
|
||||
"judge",
|
||||
min_value=5.0,
|
||||
@@ -641,7 +641,7 @@ def _build_registry() -> dict[str, SettingDef]:
|
||||
SettingDef(
|
||||
"judge.output_guard_llm_timeout",
|
||||
"float",
|
||||
30.0,
|
||||
60.0,
|
||||
"Wall-clock budget for the output-guard LLM judge call",
|
||||
"judge",
|
||||
min_value=1.0,
|
||||
|
||||
+12
-3
@@ -294,8 +294,6 @@ class TLSClient:
|
||||
Discovery, CA fetch, and cert request are all idempotent, so the whole
|
||||
sequence is retried as a unit.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
if attempts < 1:
|
||||
# range(1, attempts + 1) would be empty: init() would return
|
||||
# "successfully" with no CA and no cert.
|
||||
@@ -321,7 +319,18 @@ class TLSClient:
|
||||
delay_seconds=delay,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
await self._sleep(delay)
|
||||
|
||||
async def _sleep(self, delay: float) -> None:
|
||||
"""Backoff sleep behind a seam so tests can stub it in isolation.
|
||||
|
||||
Patching the module-global ``asyncio.sleep`` would also intercept it
|
||||
for every other task sharing the event loop; routing the retry backoff
|
||||
through a method keeps test stubs from corrupting concurrent tasks.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
def _discover_console_url(self) -> str:
|
||||
"""Look up the console URL from the services table."""
|
||||
|
||||
+102
-1
@@ -39,7 +39,7 @@ from sse_starlette import EventSourceResponse
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
@@ -1290,6 +1290,100 @@ async def speech_to_text(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
async def speech_to_text_stream(request: Request) -> Response:
|
||||
"""POST /v1/api/workstreams/{ws_id}/speech-to-text/stream — stream the
|
||||
transcript as plain-text deltas for lower perceived latency than the JSON
|
||||
``speech-to-text`` endpoint. Resolve/transcode failures surface as
|
||||
503 / 502 before any bytes are sent; once streaming begins the body is
|
||||
best-effort (a mid-stream backend error just ends the partial stream)."""
|
||||
from turnstone.core.audio import (
|
||||
AudioBackendError,
|
||||
AudioUnavailableError,
|
||||
resolve_role_alias,
|
||||
transcribe_stream,
|
||||
)
|
||||
from turnstone.core.web_helpers import read_multipart_file_or_400
|
||||
|
||||
ws_id = request.path_params.get("ws_id", "")
|
||||
if not ws_id:
|
||||
return JSONResponse({"error": "ws_id is required"}, status_code=400)
|
||||
_user_id, err = _require_ws_access(request, ws_id)
|
||||
if err:
|
||||
return err
|
||||
|
||||
registry = getattr(request.app.state, "registry", None)
|
||||
config_store = getattr(request.app.state, "config_store", None)
|
||||
alias = resolve_role_alias(config_store=config_store, registry=registry, role="stt")
|
||||
if not alias:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": (
|
||||
"Speech-to-text is not configured. Assign an STT model role in Models → Roles."
|
||||
)
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
got = await read_multipart_file_or_400(request, field="audio", max_bytes=_STT_UPLOAD_CAP)
|
||||
if isinstance(got, JSONResponse):
|
||||
return got
|
||||
_filename, _claimed_mime, data = got
|
||||
if not data:
|
||||
return JSONResponse({"error": "Empty audio upload"}, status_code=400)
|
||||
|
||||
stt_prompt = ""
|
||||
if config_store is not None:
|
||||
stt_prompt = (config_store.get("audio.stt_prompt") or "").strip()
|
||||
|
||||
# Resolve + transcode + open the stream eagerly (off the event loop) so the
|
||||
# common failures map to a clean status before any bytes are sent.
|
||||
try:
|
||||
deltas = await asyncio.to_thread(
|
||||
transcribe_stream, registry=registry, alias=alias, data=data, prompt=stt_prompt
|
||||
)
|
||||
except AudioUnavailableError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=503)
|
||||
except AudioBackendError:
|
||||
log.warning("speech_to_text_stream.backend_failed", exc_info=True)
|
||||
return JSONResponse({"error": "Speech transcription backend failed"}, status_code=502)
|
||||
|
||||
# Drive the blocking stream from one worker thread that owns (and closes)
|
||||
# the upstream connection, handing deltas to the loop via a queue. A client
|
||||
# disconnect sets ``stop`` so the thread releases the connection promptly
|
||||
# instead of being pinned mid-``next()`` (which can't be cancelled).
|
||||
async def _body() -> AsyncGenerator[bytes, None]:
|
||||
loop = asyncio.get_running_loop()
|
||||
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
||||
stop = threading.Event()
|
||||
|
||||
def _pump() -> None:
|
||||
try:
|
||||
for delta in deltas:
|
||||
if stop.is_set():
|
||||
break
|
||||
loop.call_soon_threadsafe(queue.put_nowait, delta.encode("utf-8"))
|
||||
except Exception:
|
||||
# Mid-stream backend failure: end the partial stream (logged).
|
||||
log.warning("speech_to_text_stream.mid_stream_failed", exc_info=True)
|
||||
finally:
|
||||
close = getattr(deltas, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
loop.call_soon_threadsafe(queue.put_nowait, None)
|
||||
|
||||
loop.run_in_executor(None, _pump)
|
||||
try:
|
||||
while True:
|
||||
chunk = await queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
stop.set()
|
||||
|
||||
return StreamingResponse(_body(), media_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
async def text_to_speech(request: Request) -> Response:
|
||||
"""POST /v1/api/tts — synthesize assistant text into playable audio."""
|
||||
from turnstone.core.audio import (
|
||||
@@ -3901,6 +3995,13 @@ def create_app(
|
||||
methods=["POST"],
|
||||
)
|
||||
)
|
||||
v1_routes.append(
|
||||
Route(
|
||||
"/api/workstreams/{ws_id}/speech-to-text/stream",
|
||||
speech_to_text_stream,
|
||||
methods=["POST"],
|
||||
)
|
||||
)
|
||||
v1_routes.append(Route("/api/tts", text_to_speech, methods=["POST"]))
|
||||
|
||||
app = Starlette(
|
||||
|
||||
@@ -1580,32 +1580,62 @@ class Pane {
|
||||
this._micBtn.classList.add("is-busy");
|
||||
}
|
||||
voiceAnnounce("Transcribing…");
|
||||
const resetMic = () => {
|
||||
if (this._micBtn && !this._micDenied) {
|
||||
this._micBtn.disabled = !!this.busy;
|
||||
this._micBtn.classList.remove("is-busy");
|
||||
}
|
||||
};
|
||||
authFetch(
|
||||
this._base +
|
||||
"/v1/api/workstreams/" +
|
||||
encodeURIComponent(this.wsId) +
|
||||
"/speech-to-text",
|
||||
"/speech-to-text/stream",
|
||||
{ method: "POST", body: fd },
|
||||
)
|
||||
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
showToast(
|
||||
(res.body && res.body.error) || "Transcription failed",
|
||||
"error",
|
||||
);
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
let msg = "Transcription failed";
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (body && body.error) msg = body.error;
|
||||
} catch (_e) {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
showToast(msg, "error");
|
||||
return;
|
||||
}
|
||||
const text = (res.body && res.body.transcript) || "";
|
||||
if (text && this.inputEl) {
|
||||
const cur = this.inputEl.value || "";
|
||||
this.inputEl.value = cur
|
||||
? cur.replace(/\s*$/, "") + " " + text
|
||||
: text;
|
||||
// Stream transcript deltas into the composer as they arrive (first word
|
||||
// in ~0.3s) instead of waiting for the whole transcript.
|
||||
const reader = r.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let started = false;
|
||||
let got = false;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
if (!chunk || !this.inputEl) continue;
|
||||
got = true;
|
||||
if (!started) {
|
||||
// Read the composer's value now (not before the await) so text the
|
||||
// user typed while transcribing isn't clobbered.
|
||||
const cur = this.inputEl.value || "";
|
||||
this.inputEl.value = cur
|
||||
? cur.replace(/\s*$/, "") + " " + chunk
|
||||
: chunk;
|
||||
started = true;
|
||||
} else {
|
||||
this.inputEl.value += chunk;
|
||||
}
|
||||
// Drive the composer's auto-resize + send-enable listeners.
|
||||
this.inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
this.inputEl.focus();
|
||||
}
|
||||
if (got) {
|
||||
if (this.inputEl) this.inputEl.focus();
|
||||
voiceAnnounce("Transcript added to message.");
|
||||
} else {
|
||||
showToast("No speech detected", "error");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -1614,12 +1644,7 @@ class Pane {
|
||||
"error",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this._micBtn && !this._micDenied) {
|
||||
this._micBtn.disabled = !!this.busy;
|
||||
this._micBtn.classList.remove("is-busy");
|
||||
}
|
||||
});
|
||||
.finally(resetMic);
|
||||
}
|
||||
|
||||
_addTtsAction(el) {
|
||||
|
||||
@@ -595,61 +595,58 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "48.0.0"
|
||||
version = "49.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2352,15 +2349,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.2.1"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2440,7 +2437,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "turnstone"
|
||||
version = "1.6.6"
|
||||
version = "1.6.8"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
@@ -2498,7 +2495,7 @@ requires-dist = [
|
||||
{ name = "anthropic", specifier = ">=0.108" },
|
||||
{ name = "bcrypt", specifier = ">=4.0" },
|
||||
{ name = "croniter", specifier = ">=3.0" },
|
||||
{ name = "cryptography", specifier = ">=42" },
|
||||
{ name = "cryptography", specifier = ">=48.0.1" },
|
||||
{ name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.4" },
|
||||
{ name = "httpx", specifier = ">=0.28" },
|
||||
{ name = "httpx-sse", specifier = ">=0.4" },
|
||||
@@ -2519,7 +2516,7 @@ requires-dist = [
|
||||
{ name = "slack-bolt", marker = "extra == 'test'", specifier = ">=1.18" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||
{ name = "sse-starlette", specifier = ">=2.0" },
|
||||
{ name = "starlette", specifier = ">=1.0.1" },
|
||||
{ name = "starlette", specifier = ">=1.3.1" },
|
||||
{ name = "structlog", specifier = ">=24.1" },
|
||||
{ name = "turnstone", extras = ["discord", "slack"], marker = "extra == 'all'" },
|
||||
{ name = "uvicorn", specifier = ">=0.34" },
|
||||
|
||||
Reference in New Issue
Block a user