mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e02d1b52c | |||
| 9b29453e9b | |||
| c4ff1caf09 | |||
| 22245145db | |||
| 8eacc4d632 | |||
| 23fed785c4 | |||
| 688c27e68a | |||
| 405baf7cb2 | |||
| 1027c22333 | |||
| 381651049b | |||
| 322b7dabc4 | |||
| d5e86c8493 | |||
| c154ea3966 | |||
| 755ab51802 | |||
| 9df8ab836f | |||
| 8d88e6a7eb | |||
| cce292f793 | |||
| 6adc577d30 | |||
| 02c50b81c1 | |||
| 753cd04b4e | |||
| c3217748dc | |||
| 8cbff49694 | |||
| 4f26d63c14 | |||
| 74347fb29f | |||
| 2ace8cccc8 | |||
| e95b8f5ca1 | |||
| 7a32c51a1c | |||
| dce663105b | |||
| cfef3616e6 | |||
| c22d39a798 | |||
| 63921450b1 | |||
| 929fad63be | |||
| 976e9df3b6 | |||
| 7cb21b84f1 | |||
| 7263edd48d | |||
| 6742c7e405 | |||
| 1aa6982868 | |||
| 42d1abbd04 | |||
| f543ed714a | |||
| 2c6abb0fde | |||
| 491fc6748a | |||
| 9a996f0067 | |||
| a465ac6383 | |||
| a4539923e4 | |||
| 42e99d6990 | |||
| a0ff22e137 | |||
| de4b3b3909 | |||
| 120d229b5f | |||
| c6f4c11870 | |||
| 979fab37a9 | |||
| da5bf90a4b | |||
| 70c18467cb | |||
| 801774bc4a | |||
| 497984b452 | |||
| bdc1eba34c | |||
| 028c77cae5 | |||
| 76d007d83f | |||
| 3f432b8a42 | |||
| f74aa2264e | |||
| d00aae2429 | |||
| 5e09940745 | |||
| 72bd62d3d8 | |||
| d31f89b2e3 | |||
| 9bae8f1a10 | |||
| a012561195 | |||
| 4198b59a0f | |||
| 4f6ef13ce9 | |||
| 52716ed611 | |||
| 6c9a7d7351 | |||
| 3518f7953c | |||
| 48769e5a97 | |||
| adb4ff6399 | |||
| 131a1ec943 | |||
| 1cded9b430 | |||
| 62c741eb8a | |||
| 3362917e1e | |||
| 698cbbf988 | |||
| e47a08b7bc | |||
| aaa427debd | |||
| 611af76971 | |||
| bbe28ecab3 |
+34
-107
@@ -5,55 +5,40 @@
|
||||
"helpers:pinGitHubActionDigests",
|
||||
":separateMajorReleases"
|
||||
],
|
||||
"labels": [
|
||||
"dependencies"
|
||||
"gitIgnoredAuthors": [
|
||||
"41898282+github-actions[bot]@users.noreply.github.com"
|
||||
],
|
||||
"labels": ["dependencies"],
|
||||
"prConcurrentLimit": 5,
|
||||
"prHourlyLimit": 2,
|
||||
"schedule": [
|
||||
"before 9am on Monday"
|
||||
],
|
||||
"schedule": ["before 9am on Monday"],
|
||||
"timezone": "America/New_York",
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": [
|
||||
"before 9am on Monday"
|
||||
]
|
||||
"schedule": ["before 9am on Monday"]
|
||||
},
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored KaTeX version",
|
||||
"managerFilePatterns": [
|
||||
"/pyproject\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"katex-(?<currentValue>[\\d.]+)/"
|
||||
],
|
||||
"managerFilePatterns": ["/pyproject\\.toml$/"],
|
||||
"matchStrings": ["katex-(?<currentValue>[\\d.]+)/"],
|
||||
"depNameTemplate": "katex",
|
||||
"datasourceTemplate": "npm"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored Highlight.js version",
|
||||
"managerFilePatterns": [
|
||||
"/pyproject\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"hljs-(?<currentValue>[\\d.]+)/"
|
||||
],
|
||||
"managerFilePatterns": ["/pyproject\\.toml$/"],
|
||||
"matchStrings": ["hljs-(?<currentValue>[\\d.]+)/"],
|
||||
"depNameTemplate": "highlight.js",
|
||||
"datasourceTemplate": "npm"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track vendored Mermaid version",
|
||||
"managerFilePatterns": [
|
||||
"/pyproject\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"mermaid-(?<currentValue>[\\d.]+)/"
|
||||
],
|
||||
"managerFilePatterns": ["/pyproject\\.toml$/"],
|
||||
"matchStrings": ["mermaid-(?<currentValue>[\\d.]+)/"],
|
||||
"depNameTemplate": "mermaid",
|
||||
"datasourceTemplate": "npm"
|
||||
}
|
||||
@@ -62,14 +47,8 @@
|
||||
{
|
||||
"description": "LLM SDKs — always review manually",
|
||||
"groupName": "LLM SDKs",
|
||||
"matchPackageNames": [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"mcp"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on Monday"
|
||||
],
|
||||
"matchPackageNames": ["openai", "anthropic", "mcp"],
|
||||
"schedule": ["before 9am on Monday"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
@@ -83,73 +62,38 @@
|
||||
"httpx-sse",
|
||||
"pydantic"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on Wednesday"
|
||||
],
|
||||
"schedule": ["before 9am on Wednesday"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "Database layer",
|
||||
"groupName": "Database",
|
||||
"matchPackageNames": [
|
||||
"sqlalchemy",
|
||||
"alembic",
|
||||
"psycopg"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on Wednesday"
|
||||
],
|
||||
"matchPackageNames": ["sqlalchemy", "alembic", "psycopg"],
|
||||
"schedule": ["before 9am on Wednesday"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "Security-critical — always review manually",
|
||||
"groupName": "Security",
|
||||
"matchPackageNames": [
|
||||
"PyJWT",
|
||||
"pyjwt",
|
||||
"bcrypt"
|
||||
],
|
||||
"matchPackageNames": ["PyJWT", "pyjwt", "bcrypt"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "Infrastructure dependencies",
|
||||
"groupName": "Infrastructure",
|
||||
"matchPackageNames": [
|
||||
"structlog",
|
||||
"redis",
|
||||
"croniter",
|
||||
"discord.py"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"matchPackageNames": ["structlog", "redis", "croniter", "discord.py"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "Vendored JS — requires manual file download after merge",
|
||||
"description": "Vendored JS — CI workflow downloads files automatically",
|
||||
"groupName": "Vendored JS",
|
||||
"matchPackageNames": [
|
||||
"katex",
|
||||
"highlight.js",
|
||||
"mermaid"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"automerge": false,
|
||||
"prBodyNotes": [
|
||||
"This PR updates version references only.",
|
||||
"After merging, run `scripts/update-vendored-js.sh <lib> <version>` to download the actual files."
|
||||
]
|
||||
"matchPackageNames": ["katex", "highlight.js", "mermaid"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "Dev/test tooling",
|
||||
@@ -162,46 +106,29 @@
|
||||
"pytest-cov",
|
||||
"pre-commit"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "Docker base images",
|
||||
"groupName": "Docker Images",
|
||||
"matchManagers": [
|
||||
"dockerfile",
|
||||
"docker-compose"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"matchManagers": ["dockerfile", "docker-compose"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "TypeScript SDK dev dependencies",
|
||||
"groupName": "TypeScript SDK",
|
||||
"matchFileNames": [
|
||||
"sdk/typescript/**"
|
||||
],
|
||||
"schedule": [
|
||||
"before 9am on the first day of the month"
|
||||
],
|
||||
"matchFileNames": ["sdk/typescript/**"],
|
||||
"schedule": ["before 9am on the first day of the month"],
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": [
|
||||
"patch"
|
||||
]
|
||||
"matchUpdateTypes": ["patch"]
|
||||
},
|
||||
{
|
||||
"description": "GitHub Actions — group all action updates",
|
||||
"groupName": "GitHub Actions",
|
||||
"matchManagers": [
|
||||
"github-actions"
|
||||
],
|
||||
"matchManagers": ["github-actions"],
|
||||
"automerge": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.14"
|
||||
- run: pip install -e ".[test,mq,postgres]"
|
||||
- run: pytest tests/ -m "not live" --storage-backend=postgresql -q
|
||||
env:
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
python-version: "3.14"
|
||||
- run: pip install build
|
||||
- run: python -m build
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Complete Vendored JS Updates
|
||||
|
||||
# When Renovate bumps a vendored JS version in pyproject.toml, this
|
||||
# workflow downloads the actual files and commits them to the PR branch
|
||||
# so the PR is merge-ready without manual intervention.
|
||||
#
|
||||
# Note: the commit is made with GITHUB_TOKEN, so it won't re-trigger CI
|
||||
# automatically. The reviewer should re-run CI once this workflow passes,
|
||||
# or Renovate's next rebase will trigger it.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- pyproject.toml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "PR number to update"
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
vendor-js:
|
||||
if: github.actor == 'renovate[bot]' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve PR head ref
|
||||
id: ref
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
ref=$(gh pr view "${{ inputs.pr_number }}" --repo "${{ github.repository }}" --json headRefName -q .headRefName)
|
||||
else
|
||||
ref="${{ github.head_ref }}"
|
||||
fi
|
||||
echo "head_ref=${ref}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ steps.ref.outputs.head_ref }}
|
||||
|
||||
- name: Detect vendored JS changes
|
||||
id: detect
|
||||
run: |
|
||||
updates=()
|
||||
for lib in katex hljs mermaid; do
|
||||
version=$(grep -oE "${lib}-[0-9.]+" pyproject.toml | head -1 | sed "s/${lib}-//")
|
||||
[[ -z "$version" ]] && continue
|
||||
[[ -d "turnstone/shared_static/${lib}-${version}" ]] && continue
|
||||
updates+=("${lib}:${version}")
|
||||
done
|
||||
if [[ ${#updates[@]} -eq 0 ]]; then
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "${updates[@]}" > /tmp/updates.txt
|
||||
echo "Libs to update:"
|
||||
cat /tmp/updates.txt
|
||||
fi
|
||||
|
||||
- name: Download vendored files
|
||||
if: steps.detect.outputs.found == 'true'
|
||||
run: |
|
||||
while IFS=: read -r lib version; do
|
||||
echo "::group::Updating ${lib} to ${version}"
|
||||
bash scripts/update-vendored-js.sh "$lib" "$version"
|
||||
echo "::endgroup::"
|
||||
done < /tmp/updates.txt
|
||||
|
||||
- name: Commit and push
|
||||
if: steps.detect.outputs.found == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: download vendored JS files"
|
||||
git push
|
||||
@@ -0,0 +1,4 @@
|
||||
# libexpat integer overflow — no fix available in Debian repos yet
|
||||
# https://avd.aquasec.com/nvd/cve-2026-25210
|
||||
# Review: remove this entry once a patched libexpat1 is published
|
||||
CVE-2026-25210
|
||||
+7
-3
@@ -8,10 +8,14 @@ FROM python:3.14-slim
|
||||
LABEL org.opencontainers.image.title="turnstone" \
|
||||
org.opencontainers.image.description="Multi-node AI orchestration platform"
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.1 /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.2 /uv /usr/local/bin/uv
|
||||
|
||||
# System dependencies for psycopg (PostgreSQL client library)
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 \
|
||||
# Remove the slim image's man page exclusion so man-db has actual content
|
||||
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
|
||||
|
||||
# System dependencies: psycopg (libpq5), developer tooling for agent workflows
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||
libpq5 git curl jq man-db manpages procps file \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user
|
||||
|
||||
@@ -290,7 +290,7 @@ All entry points read `~/.config/turnstone/config.toml`. CLI flags override conf
|
||||
[api]
|
||||
base_url = "http://localhost:8000/v1"
|
||||
api_key = ""
|
||||
tavily_key = "" # only needed for local/vLLM models without native search
|
||||
# tavily_key = "" # only needed for local/vLLM models without native search
|
||||
|
||||
[model]
|
||||
name = "" # empty = auto-detect
|
||||
@@ -415,6 +415,7 @@ Per-workstream metrics are labeled by `ws_id` (bounded by `[server].max_workstre
|
||||
- Redis (for message queue bridge — `pip install turnstone[mq]`)
|
||||
- Anthropic provider (optional — `pip install turnstone[anthropic]`)
|
||||
- PostgreSQL (optional, for production — `pip install turnstone[postgres]`)
|
||||
- Math sandbox packages (optional — `pip install turnstone[sandbox]` for sympy, numpy, scipy, pytest)
|
||||
- [Git LFS](https://git-lfs.com/) (for cloning — diagram PNGs are stored in LFS)
|
||||
|
||||
## License
|
||||
|
||||
+23
-8
@@ -386,10 +386,10 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
|
||||
{"type": "tool_output_chunk", "call_id": "call_abc123", "chunk": "Building project...\n"}
|
||||
```
|
||||
|
||||
**`tool_result`** -- final output from a completed tool execution. The `call_id` matches the corresponding `tool_info`/`approve_request` item and any preceding `tool_output_chunk` events. For bash tools, this arrives after all streaming chunks and includes both stdout and stderr.
|
||||
**`tool_result`** -- final output from a completed tool execution. The `call_id` matches the corresponding `tool_info`/`approve_request` item and any preceding `tool_output_chunk` events. For bash tools, this arrives after all streaming chunks and includes both stdout and stderr. The `is_error` field is `true` when the tool execution failed (e.g. bash exit code >= 2 or signal, file not found, timeout). Exit code 1 is ambiguous (e.g. `grep` no-match) and is not flagged. User denials are tracked separately via a `denied` flag. Clients should use `is_error` instead of text-prefix heuristics.
|
||||
|
||||
```json
|
||||
{"type": "tool_result", "call_id": "call_abc123", "name": "bash", "output": "file1.py\nfile2.py\n"}
|
||||
{"type": "tool_result", "call_id": "call_abc123", "name": "bash", "output": "file1.py\nfile2.py\n", "is_error": false}
|
||||
```
|
||||
|
||||
**`status`** -- token usage statistics, sent after each model turn.
|
||||
@@ -452,9 +452,12 @@ after `/clear` or `/new` commands).
|
||||
{"type": "clear_ui"}
|
||||
```
|
||||
|
||||
**`cancelled`** -- the generation was cancelled by the user (via the Stop
|
||||
button or `POST /v1/api/cancel`). The client should finalize any in-progress
|
||||
assistant message with whatever partial content was streamed.
|
||||
**`cancelled`** -- a cancel request was acknowledged (via the Stop button or
|
||||
`POST /v1/api/cancel`). This signals that cancellation is in progress, not
|
||||
that it is complete. The worker thread may still be finishing — wait for
|
||||
`stream_end` before transitioning to a ready state. The client should clear
|
||||
any in-progress assistant rendering but not re-enable the send button until
|
||||
`stream_end` arrives.
|
||||
|
||||
```json
|
||||
{"type": "cancelled"}
|
||||
@@ -793,23 +796,35 @@ containing the resumed session's messages.
|
||||
|
||||
Cancels the active generation in a workstream. Sets a cooperative cancellation
|
||||
flag that is checked at multiple points in the generation loop (per streaming
|
||||
chunk, before tool execution, inside bash commands). The session transitions to
|
||||
`idle` state and preserves any partial content already streamed.
|
||||
chunk, before tool execution, inside bash commands). Also closes the underlying
|
||||
HTTP stream to the LLM provider, unblocking any pending read immediately.
|
||||
The session transitions to `idle` state and preserves any partial content
|
||||
already streamed.
|
||||
|
||||
If the workstream is waiting for tool approval or plan review, the pending
|
||||
prompt is automatically denied/rejected to unblock the worker thread.
|
||||
|
||||
Calling this endpoint when the workstream is already idle is a harmless no-op.
|
||||
|
||||
**Force cancel:** When `force` is `true`, the server abandons the stuck worker
|
||||
thread immediately and transitions the workstream to `idle`. The abandoned
|
||||
thread continues to wind down in the background (killing any running
|
||||
subprocesses and exiting at the next cancellation checkpoint). During this
|
||||
wind-down it may emit a final `stream_end` event which the server suppresses
|
||||
for the orphaned thread. Use force cancel when cooperative cancel has not
|
||||
resolved within a few seconds — the web UI offers this as a "Force Stop"
|
||||
button automatically.
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{"ws_id": "abc123"}
|
||||
{"ws_id": "abc123", "force": false}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------|--------|----------|----------------------|
|
||||
| `ws_id`| string | yes | Target workstream ID |
|
||||
| `force`| bool | no | Abandon stuck worker immediately (default: `false`) |
|
||||
|
||||
**Response:**
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
|
||||
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
|
||||
Anthropic's native Messages API via pluggable provider adapters, and gives the
|
||||
model 17 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||||
model 19 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||||
reading, writing, searching, planning, and executing code.
|
||||
|
||||
The core design principle is a **UI-agnostic engine with pluggable frontends**.
|
||||
@@ -91,7 +91,7 @@ turnstone/
|
||||
_config.py Base ChannelConfig dataclass
|
||||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||||
katex-0.16.42/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
katex-0.16.44/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
ui/
|
||||
colors.py ANSI color constants with NO_COLOR support
|
||||
markdown.py Streaming terminal markdown renderer (line-buffered)
|
||||
@@ -242,7 +242,7 @@ class SessionUI(Protocol):
|
||||
def on_content_token(self, text: str) -> None: ...
|
||||
def on_stream_end(self) -> None: ...
|
||||
def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: ...
|
||||
def on_tool_result(self, call_id: str, name: str, output: str) -> None: ...
|
||||
def on_tool_result(self, call_id: str, name: str, output: str, *, is_error: bool = False) -> None: ...
|
||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
|
||||
def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
|
||||
def on_plan_review(self, content: str) -> str: ...
|
||||
@@ -259,7 +259,7 @@ class SessionUI(Protocol):
|
||||
| Class | Module | Notes |
|
||||
|-------|--------|-------|
|
||||
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
|
||||
| `WebUI` | `turnstone.server` | SSE event queue per workstream, `threading.Event` for blocking on approval/plan |
|
||||
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval/plan. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
|
||||
| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` |
|
||||
|
||||
### WorkstreamTerminalUI
|
||||
|
||||
@@ -97,7 +97,7 @@ package "turnstone/sdk/" <<Rectangle>> {
|
||||
|
||||
' Tool schemas
|
||||
package "turnstone/tools/" <<Rectangle>> {
|
||||
component [*.json\n18 tool schemas] as schemas <<artifact>>
|
||||
component [*.json\n19 tool schemas] as schemas <<artifact>>
|
||||
}
|
||||
|
||||
' Entry point dependencies
|
||||
|
||||
@@ -12,7 +12,7 @@ interface "SessionUI" as SessionUI <<Protocol>> {
|
||||
+ on_content_token(text: str)
|
||||
+ on_stream_end()
|
||||
+ approve_tools(items: list) → (bool, str|None)
|
||||
+ on_tool_result(call_id: str, name: str, output: str)
|
||||
+ on_tool_result(call_id: str, name: str, output: str, *, is_error: bool = False)
|
||||
+ on_tool_output_chunk(call_id: str, chunk: str)
|
||||
+ on_status(usage: dict, ctx_window: int, effort: str)
|
||||
+ on_plan_review(content: str) → str
|
||||
|
||||
@@ -133,7 +133,8 @@ group loop [while tool_calls present]
|
||||
note right of TP
|
||||
bash: on_tool_output_chunk(call_id, line)
|
||||
called per stdout line,
|
||||
then on_tool_result(call_id, name, output).
|
||||
then on_tool_result(call_id, name, output, is_error).
|
||||
is_error=True when execution failed.
|
||||
call_id routes chunks/results to correct
|
||||
tool div during parallel execution.
|
||||
Other tools: on_tool_result() only.
|
||||
|
||||
@@ -24,7 +24,7 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
:Dispatch to _prepare_{func_name}();
|
||||
|
||||
note right
|
||||
**Dispatch table (17 tools):**
|
||||
**Dispatch table (19 built-in + tool_search):**
|
||||
┌───────────────┬──────────────────┐
|
||||
│ Tool │ Needs Approval? │
|
||||
├───────────────┼──────────────────┤
|
||||
@@ -33,16 +33,19 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
│ write_file │ ✓ Yes │
|
||||
│ edit_file │ ✓ Yes │
|
||||
│ search │ ✗ Auto-approve │
|
||||
│ diff_file │ ✗ Auto-approve │
|
||||
│ math │ ✗ Auto-approve │
|
||||
│ man │ ✗ Auto-approve │
|
||||
│ web_fetch │ ✗ Auto-approve │
|
||||
│ web_search │ ✗ Auto-approve │
|
||||
│ tool_search │ ✗ Auto-approve │
|
||||
│ task │ ✓ Yes │
|
||||
│ plan │ ✓ Yes │
|
||||
│ task_agent │ ✓ Yes │
|
||||
│ plan_agent │ ✓ Yes │
|
||||
│ memory │ ✗ Auto-approve │
|
||||
│ recall │ ✗ Auto-approve │
|
||||
│ notify │ ✗ Auto-approve │
|
||||
│ watch │ ✓ create only │
|
||||
│ skill │ ✓ load only │
|
||||
│ read_resource │ ✓ Yes │
|
||||
│ use_prompt │ ✓ Yes │
|
||||
├───────────────┼──────────────────┤
|
||||
@@ -127,7 +130,7 @@ partition "Phase 3: Execute" #E3F2FD {
|
||||
:_truncate_output() on each result\n(max context_window × chars_per_token × 0.5 chars\ndefault: ~context_window × 2 chars);
|
||||
|
||||
:bash: ui.on_tool_output_chunk(call_id, line) per stdout line;
|
||||
:ui.on_tool_result(call_id, name, output) for each;
|
||||
:ui.on_tool_result(call_id, name, output, is_error) for each;
|
||||
|
||||
if (plan tool was executed?) then (yes)
|
||||
:ui.on_plan_review(output);
|
||||
|
||||
@@ -147,6 +147,7 @@ package "Outbound Events (Bridge → Client)" as OutPkg #E3F2FD {
|
||||
+ call_id: str
|
||||
+ name: str
|
||||
+ output: str
|
||||
+ is_error: bool
|
||||
}
|
||||
class PlanReviewEvent {
|
||||
type = "plan_review"
|
||||
|
||||
@@ -40,12 +40,23 @@ running --> error : Exception during\ntool execution
|
||||
|
||||
error --> thinking : New send() call\n_emit_state("thinking")
|
||||
|
||||
thinking --> idle : cancel() called\n_emit_state("idle")
|
||||
thinking --> idle : cancel() called\nstream aborted\n_emit_state("idle")
|
||||
|
||||
running --> idle : cancel() called\n_emit_state("idle")
|
||||
|
||||
attention --> idle : cancel() unblocks\napproval/plan wait\n_emit_state("idle")
|
||||
|
||||
note left of idle
|
||||
**Cancel escalation:**
|
||||
1. **Cooperative**: cancel() sets event + closes
|
||||
SDK stream → worker exits at next checkpoint
|
||||
2. **Force**: force=true abandons the worker
|
||||
thread, emits stream_end immediately.
|
||||
Orphaned thread still kills subprocesses
|
||||
but skips message mutations (generation
|
||||
counter prevents stale writes).
|
||||
end note
|
||||
|
||||
note right of thinking
|
||||
**Emitted via:**
|
||||
session._emit_state(state)
|
||||
|
||||
@@ -61,7 +61,7 @@ end
|
||||
|
||||
Session -> Session : _init_system_messages()\nevery conversation turn
|
||||
|
||||
Session -> Session : _get_visible_memories(\nlimit=fetch_limit)
|
||||
Session -> Session : _list_visible_memories(\nlimit=fetch_limit)
|
||||
note right
|
||||
**Scope resolution:**
|
||||
1. global scope (always)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:efcc7cbe8161a54b5ec24bdfd47e8a142f70029e6e66c707e811b99369f85ebf
|
||||
size 310079
|
||||
oid sha256:2b3ea69f852e93dc1bc7943db0c71d0cdd1afcbcf470a8737189ae56e85b3206
|
||||
size 310075
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0e605963c649574c7bf987b2d338257c78bc1fc68b524ac8276bb25365035e06
|
||||
size 594096
|
||||
oid sha256:6471e611beebf647f3a191eb16588571a404cc52a43067883a2b6f06dd936376
|
||||
size 594676
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:da9d32000e3d92d92ce621661ced60f276f9b5be652f5ed6123b400505415f4a
|
||||
size 319702
|
||||
oid sha256:3aa8d972bba40d78152f9f0c762b9f5ec616d8052c45fa52b7dd1c679ed81d61
|
||||
size 325245
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:43844b07d36beb04db871f6795a3f3be17852a6a484fdc0ea207403bd7f512a6
|
||||
size 274286
|
||||
oid sha256:674712a0563f51837383184652efeb28b7bec13378be636e89d2959bfba39d1e
|
||||
size 281519
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2636e2d4d2f84f26f93de6e984b780f6ad5bf8d3618a0202ea0a2ee80859c5b5
|
||||
size 312409
|
||||
oid sha256:d831c5e10266f0232262b6a29b0ea8e45b3cc63df75f716a80f18462b4f85e66
|
||||
size 319125
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7896c6e041b6dbb89d034468fa980c8fe645df5eb969d45ef966ccc6399edac2
|
||||
size 200083
|
||||
oid sha256:7bf27afa267d5b8d6da38e83213ed1b8e87639d5105a0a1ccc2e5a4bf4d3b67e
|
||||
size 185282
|
||||
|
||||
+1
-1
@@ -338,7 +338,7 @@ from the output before it enters the conversation.
|
||||
| Priority | Category | Risk | Examples |
|
||||
|----------|----------|------|----------|
|
||||
| 1 | Prompt injection | high | Override phrases, role injection (`{"role":"system"}`), instruction override markers |
|
||||
| 2 | Credential leakage | high | API keys, private key blocks, connection strings, `.env` format secrets |
|
||||
| 2 | Credential leakage | high | API keys, private key blocks, connection strings, `.env` format secrets, JSON secrets (`"api_key": "..."`, `"password": "..."`, etc.) |
|
||||
| 3 | Encoded payloads | medium | Script data URIs, hex shellcode sequences |
|
||||
| 4 | Adversarial URLs | medium | Cloud metadata endpoints, credential-bearing query parameters |
|
||||
| 5 | System info disclosure | low | Private IP addresses, sensitive file paths |
|
||||
|
||||
+2
-2
@@ -75,7 +75,7 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|
||||
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
|
||||
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
|
||||
| | `command(*, ws_id, command)` | `StatusResponse` |
|
||||
| | `cancel(ws_id)` | `StatusResponse` |
|
||||
| | `cancel(ws_id, *, force=False)` | `StatusResponse` |
|
||||
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
|
||||
| | `stream_global_events()` | `Iterator[ServerEvent]` |
|
||||
| **High-level** | `send_and_wait(message, ws_id, *, timeout, on_event)` | `TurnResult` |
|
||||
@@ -127,7 +127,7 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
|
||||
| `reasoning` | `ReasoningEvent` | `text` |
|
||||
| `tool_info` | `ToolInfoEvent` | `items` |
|
||||
| `approve_request` | `ApproveRequestEvent` | `items` |
|
||||
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output` |
|
||||
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output`, `is_error` |
|
||||
| `tool_output_chunk` | `ToolOutputChunkEvent` | `call_id`, `chunk` |
|
||||
| `status` | `StatusEvent` | `prompt_tokens`, `total_tokens`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
|
||||
| `plan_review` | `PlanReviewEvent` | `content` |
|
||||
|
||||
+49
-16
@@ -1,6 +1,6 @@
|
||||
# Tools Reference
|
||||
|
||||
turnstone exposes 18 built-in tools plus any number of external MCP tools to the
|
||||
turnstone exposes 19 built-in tools plus any number of external MCP tools to the
|
||||
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
|
||||
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
|
||||
MCP tools are discovered from configured MCP servers at startup by
|
||||
@@ -46,12 +46,12 @@ schema plus turnstone-specific metadata keys:
|
||||
|
||||
| Name | Description |
|
||||
|---------------------|-------------|
|
||||
| `TOOLS` | All 17 tool definitions (sent to the model). |
|
||||
| `TOOLS` | All 19 tool definitions (sent to the model). |
|
||||
| `AGENT_TOOLS` | Tools with `agent: true` -- available to plan sub-agents. Read-only tools. |
|
||||
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
|
||||
| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. |
|
||||
| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). |
|
||||
| `BUILTIN_TOOL_NAMES`| Frozenset of all 17 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
|
||||
| `BUILTIN_TOOL_NAMES`| Frozenset of all 19 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
|
||||
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
|
||||
|
||||
---
|
||||
@@ -69,7 +69,7 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
|
||||
- Parses the JSON arguments (with fallback for malformed JSON).
|
||||
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
|
||||
to the correct parameter.
|
||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 17
|
||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 19
|
||||
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
|
||||
the generic `_prepare_mcp_tool()` handler for MCP tools.
|
||||
- Validates arguments and builds a preview dict containing:
|
||||
@@ -102,10 +102,15 @@ Each item's `execute` callable is invoked:
|
||||
- Errored or denied items return their error/denial message without executing.
|
||||
- The `bash` tool streams stdout incrementally: each line calls
|
||||
`ui.on_tool_output_chunk(call_id, line)` as it is produced, then the final
|
||||
combined output (stdout + stderr) is delivered via `ui.on_tool_result(call_id, name, output)`.
|
||||
combined output (stdout + stderr) is delivered via
|
||||
`ui.on_tool_result(call_id, name, output, is_error=...)`.
|
||||
The `call_id` links `tool_info`/`approve_request` items to their streaming chunks and
|
||||
final result, enabling correct routing when multiple bash tools run in parallel.
|
||||
Other tools deliver results atomically via `ui.on_tool_result(call_id, name, output)` only.
|
||||
The `is_error` flag is `True` when the tool execution failed (e.g. bash exit code >= 2
|
||||
or signal, file not found, timeout). Exit code 1 is ambiguous and not flagged; user
|
||||
denials are tracked separately. This removes the need for text-prefix heuristics.
|
||||
Other tools deliver results atomically via
|
||||
`ui.on_tool_result(call_id, name, output, is_error=...)` only.
|
||||
- Special post-execution gate for `plan`: the plan output is shown to the user
|
||||
for review, and the user can reject or annotate it.
|
||||
|
||||
@@ -183,8 +188,11 @@ Execute a bash command and return stdout + stderr.
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `command` | string | yes | The bash command to execute. |
|
||||
| `timeout` | integer | no | Timeout in seconds (1-600). Omit to use the global `tools.timeout` setting (typically 120s). |
|
||||
| `stop_on_error` | boolean | no | Enable `set -e` so the script exits on the first command failure. Default false. |
|
||||
|
||||
- **What it does**: Runs the command in a subprocess with a configurable timeout. Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`).
|
||||
- **What it does**: Runs the command in a subprocess with a configurable timeout. Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`). Environment variables containing secrets are scrubbed (`*_KEY`, `*_SECRET`, `*_TOKEN`, etc.).
|
||||
- **Output format**: Stdout is returned directly. Stderr lines are prefixed with `[stderr]` so the model can distinguish them. When the command itself redirects stderr to stdout (`2>&1`), no prefix is added. Output exceeding 256KB is truncated (head + tail preserved, middle replaced with a truncation notice).
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: `task_agent` only (not available to plan sub-agents).
|
||||
|
||||
@@ -216,8 +224,9 @@ Write content to a file, creating it if needed.
|
||||
|-----------|--------|----------|-------------|
|
||||
| `path` | string | yes | Absolute or relative file path. |
|
||||
| `content` | string | yes | The full file content to write. |
|
||||
| `mode` | string | no | `"overwrite"` (default) replaces the file. `"append"` adds content to the end. |
|
||||
|
||||
- **What it does**: Creates or overwrites the file at the given path. Parent directories are created as needed.
|
||||
- **What it does**: Creates or overwrites (or appends to) the file at the given path. Parent directories are created as needed.
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: `task_agent` only.
|
||||
|
||||
@@ -225,21 +234,44 @@ Write content to a file, creating it if needed.
|
||||
|
||||
### edit_file
|
||||
|
||||
Replace an exact string in a file with new content.
|
||||
Replace exact strings in a file, or apply multiple replacements atomically.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|--------------|---------|----------|-------------|
|
||||
| `path` | string | yes | Absolute or relative file path. |
|
||||
| `old_string` | string | yes | The exact text to find and replace. |
|
||||
| `new_string` | string | yes | The replacement text. |
|
||||
| `old_string` | string | no* | The exact text to find and replace. |
|
||||
| `new_string` | string | no* | The replacement text. |
|
||||
| `near_line` | integer | no | Disambiguate when `old_string` matches multiple locations. |
|
||||
| `edits` | array | no* | Multiple replacements to apply atomically (see below). |
|
||||
| `replace_all` | boolean | no | Replace ALL occurrences of `old_string`. Cannot combine with `near_line` or `edits`. |
|
||||
|
||||
- **What it does**: Finds `old_string` in the file and replaces it with `new_string`. Fails if the string is not found or matches multiple locations (unless `near_line` is provided to pick the nearest match). Requires a prior `read_file` call on the same path.
|
||||
\* Provide either `old_string`+`new_string` (single edit) or `edits` array (batch), not both.
|
||||
|
||||
- **What it does**: Finds `old_string` in the file and replaces it with `new_string`. Fails if the string is not found or matches multiple locations (unless `near_line` or `replace_all` is provided). Requires a prior `read_file` or `diff_file` call on the same path.
|
||||
- **Batch mode**: The `edits` array accepts multiple `{old_string, new_string, near_line?}` entries applied atomically. All edits are validated before any are applied. Overlapping edits (two entries targeting the same text region) are rejected. Edits are applied in reverse file-position order so character offsets stay stable.
|
||||
- **Replace-all mode**: When `replace_all` is true, all occurrences are replaced via `str.replace()`. The approval preview shows the occurrence count.
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: `task_agent` only.
|
||||
|
||||
---
|
||||
|
||||
### diff_file
|
||||
|
||||
Show a unified diff between two files, or between a file and a provided string.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------------|---------|----------|-------------|
|
||||
| `path_a` | string | yes | Path to the first file. |
|
||||
| `path_b` | string | no | Path to the second file. Mutually exclusive with `content_b`. |
|
||||
| `content_b` | string | no | String content to compare against `path_a`. Mutually exclusive with `path_b`. |
|
||||
| `context_lines` | integer | no | Number of context lines around changes (default 3, max 20). |
|
||||
|
||||
- **What it does**: Returns unified diff output using Python's `difflib`. Binary files (containing null bytes) are rejected with a clear error. Files read through `diff_file` satisfy `edit_file`'s read guard — you can diff then edit without a separate `read_file` call. Large diffs are streamed with early cutoff at the tool truncation limit.
|
||||
- **Auto-approve**: Yes (read-only).
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
### search
|
||||
|
||||
Search file contents for a regex pattern.
|
||||
@@ -265,8 +297,9 @@ Execute Python code for math and computation in a sandbox.
|
||||
|-----------|--------|----------|-------------|
|
||||
| `code` | string | yes | Python code to execute. Must use `print()` for output. |
|
||||
|
||||
- **What it does**: Runs Python code in a sandboxed environment with pre-imported libraries: `sympy`, `numpy`, `scipy`, `math`, `fractions`, `itertools`, `functools`, `collections`, `decimal`, `operator`, `random`, `re`, `string`. Common sympy names (`symbols`, `solve`, `simplify`, `sqrt`, `Matrix`, etc.) are pre-imported.
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **What it does**: Runs Python code in a sandboxed environment with pre-imported libraries: `sympy`, `numpy`, `scipy`, `math`, `fractions`, `itertools`, `functools`, `collections`, `decimal`, `operator`, `random`, `re`, `string`. Common sympy names (`symbols`, `solve`, `simplify`, `sqrt`, `Matrix`, etc.) are pre-imported. `pytest` is also available for import.
|
||||
- **Installation**: `sympy`, `numpy`, `scipy`, and `pytest` require the `[sandbox]` extras group: `pip install turnstone[sandbox]` (included in `[all]`).
|
||||
- **Auto-approve**: Yes.
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
@@ -596,7 +629,7 @@ CLI flags override the config file:
|
||||
directly.
|
||||
|
||||
2. **Partitioning**: When active, tools are split into two sets:
|
||||
- **Always-on** -- the 17 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
- **Always-on** -- the 19 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
These are always visible to the model.
|
||||
- **Deferred** -- all MCP tools. These are not sent in the tool list unless
|
||||
the model searches for them.
|
||||
@@ -639,7 +672,7 @@ MCP-compatible service.
|
||||
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
|
||||
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
|
||||
|
||||
4. **Merging**: MCP tools are appended after the 17 built-in tools via
|
||||
4. **Merging**: MCP tools are appended after the 19 built-in tools via
|
||||
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
|
||||
When dynamic tool search is active, MCP tools are deferred rather than directly
|
||||
visible -- the model discovers them via search as needed (see
|
||||
|
||||
+4
-3
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.8.9"
|
||||
version = "0.9.6"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -54,7 +54,8 @@ postgres = ["psycopg[binary]>=3.2"]
|
||||
ddg = ["ddgs>=9.0"]
|
||||
discord = ["discord.py>=2.4", "redis>=7.2"]
|
||||
tls = ["lacme>=1.0.4"]
|
||||
all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg,tls]"]
|
||||
sandbox = ["sympy>=1.13", "numpy>=2.0", "scipy>=1.14", "pytest>=9.0"]
|
||||
all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg,tls,sandbox]"]
|
||||
|
||||
[project.scripts]
|
||||
turnstone = "turnstone.cli:main"
|
||||
@@ -79,7 +80,7 @@ include = [
|
||||
"turnstone/console/static/*.js",
|
||||
"turnstone/shared_static/*.css",
|
||||
"turnstone/shared_static/*.js",
|
||||
"turnstone/shared_static/katex-0.16.42/**/*",
|
||||
"turnstone/shared_static/katex-0.16.44/**/*",
|
||||
"turnstone/shared_static/hljs-11.11.1/**/*",
|
||||
"turnstone/shared_static/mermaid-11.13.0/**/*",
|
||||
"turnstone/sdk/py.typed",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Console API",
|
||||
"version": "0.8.4",
|
||||
"version": "0.9.2",
|
||||
"description": "Cluster-wide visibility and control across all turnstone nodes."
|
||||
},
|
||||
"paths": {
|
||||
@@ -2103,6 +2103,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/models": {
|
||||
"get": {
|
||||
"summary": "List enabled model aliases for workstream creation",
|
||||
"operationId": "v1_api_models_get",
|
||||
"tags": [
|
||||
"Models"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListAvailableModelsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/skills": {
|
||||
"get": {
|
||||
"summary": "List available skills (summary)",
|
||||
@@ -3002,7 +3023,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StatusResponse"
|
||||
"$ref": "#/components/schemas/DeleteSettingResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3453,6 +3474,473 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/model-definitions": {
|
||||
"get": {
|
||||
"summary": "List model definitions with live status from cluster nodes",
|
||||
"operationId": "v1_api_admin_model-definitions_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListModelDefinitionsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Create a model definition",
|
||||
"operationId": "v1_api_admin_model-definitions_post",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateModelDefinitionRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ModelDefinitionInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Error 409",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/model-definitions/reload": {
|
||||
"post": {
|
||||
"summary": "Tell all nodes to re-read model definitions from DB and rebuild registry",
|
||||
"operationId": "v1_api_admin_model-definitions_reload_post",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ModelReloadResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/model-definitions/{definition_id}": {
|
||||
"get": {
|
||||
"summary": "Get a single model definition",
|
||||
"operationId": "v1_api_admin_model-definitions_{definition_id}_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "definition_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ModelDefinitionInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Update a model definition",
|
||||
"operationId": "v1_api_admin_model-definitions_{definition_id}_put",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "definition_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateModelDefinitionRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ModelDefinitionInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Error 409",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"summary": "Delete a model definition",
|
||||
"operationId": "v1_api_admin_model-definitions_{definition_id}_delete",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "definition_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/model-definitions/detect": {
|
||||
"post": {
|
||||
"summary": "Probe a model endpoint: verify reachability, list models, detect context window and server type",
|
||||
"operationId": "v1_api_admin_model-definitions_detect_post",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DetectModelRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DetectModelResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/model-capabilities": {
|
||||
"get": {
|
||||
"summary": "Look up static capabilities for a known model",
|
||||
"operationId": "v1_api_admin_model-capabilities_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Provider name"
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Model ID to look up"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ModelCapabilitiesResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/model-capabilities/known": {
|
||||
"get": {
|
||||
"summary": "List known model name prefixes for a provider",
|
||||
"operationId": "v1_api_admin_model-capabilities_known_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Provider name"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/KnownModelsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/tls/ca": {
|
||||
"get": {
|
||||
"summary": "CA status: initialization state, CN, cert count, cert inventory",
|
||||
"operationId": "v1_api_admin_tls_ca_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/tls/ca.pem": {
|
||||
"get": {
|
||||
"summary": "Download CA root certificate (PEM format)",
|
||||
"operationId": "v1_api_admin_tls_ca.pem_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/tls/certs": {
|
||||
"get": {
|
||||
"summary": "List all issued TLS certificates",
|
||||
"operationId": "v1_api_admin_tls_certs_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/tls/certs/{domain}/renew": {
|
||||
"post": {
|
||||
"summary": "Force-renew a certificate by domain",
|
||||
"operationId": "v1_api_admin_tls_certs_{domain}_renew_post",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "domain",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Error 500",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/tls/certs/{domain}": {
|
||||
"delete": {
|
||||
"summary": "Delete a certificate by domain",
|
||||
"operationId": "v1_api_admin_tls_certs_{domain}_delete",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "domain",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Console health check",
|
||||
@@ -3507,6 +3995,34 @@
|
||||
"title": "StatusResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"DeleteSettingResponse": {
|
||||
"description": "DELETE /v1/api/admin/settings/{key} response.",
|
||||
"properties": {
|
||||
"status": {
|
||||
"default": "ok",
|
||||
"examples": [
|
||||
"ok"
|
||||
],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"description": "Dotted setting key that was reset",
|
||||
"title": "Key",
|
||||
"type": "string"
|
||||
},
|
||||
"default": {
|
||||
"description": "Registry default value the setting reverted to",
|
||||
"title": "Default"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key",
|
||||
"default"
|
||||
],
|
||||
"title": "DeleteSettingResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"AuthLoginRequest": {
|
||||
"description": "POST /v1/api/auth/login request body.\n\nEither username+password or token must be provided.",
|
||||
"properties": {
|
||||
@@ -6360,6 +6876,443 @@
|
||||
"title": "McpReloadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ModelDefinitionInfo": {
|
||||
"properties": {
|
||||
"definition_id": {
|
||||
"title": "Definition Id",
|
||||
"type": "string"
|
||||
},
|
||||
"alias": {
|
||||
"title": "Alias",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"default": "openai",
|
||||
"title": "Provider",
|
||||
"type": "string"
|
||||
},
|
||||
"base_url": {
|
||||
"default": "",
|
||||
"title": "Base Url",
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"default": "",
|
||||
"title": "Api Key",
|
||||
"type": "string"
|
||||
},
|
||||
"context_window": {
|
||||
"default": 32768,
|
||||
"title": "Context Window",
|
||||
"type": "integer"
|
||||
},
|
||||
"capabilities": {
|
||||
"default": "{}",
|
||||
"title": "Capabilities",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"title": "Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"default": "",
|
||||
"title": "Source",
|
||||
"type": "string"
|
||||
},
|
||||
"created_by": {
|
||||
"default": "",
|
||||
"title": "Created By",
|
||||
"type": "string"
|
||||
},
|
||||
"created": {
|
||||
"default": "",
|
||||
"title": "Created",
|
||||
"type": "string"
|
||||
},
|
||||
"updated": {
|
||||
"default": "",
|
||||
"title": "Updated",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"definition_id",
|
||||
"alias",
|
||||
"model"
|
||||
],
|
||||
"title": "ModelDefinitionInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"CreateModelDefinitionRequest": {
|
||||
"properties": {
|
||||
"alias": {
|
||||
"title": "Alias",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"default": "openai",
|
||||
"title": "Provider",
|
||||
"type": "string"
|
||||
},
|
||||
"base_url": {
|
||||
"default": "",
|
||||
"title": "Base Url",
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"default": "",
|
||||
"title": "Api Key",
|
||||
"type": "string"
|
||||
},
|
||||
"context_window": {
|
||||
"default": 32768,
|
||||
"title": "Context Window",
|
||||
"type": "integer"
|
||||
},
|
||||
"capabilities": {
|
||||
"additionalProperties": true,
|
||||
"title": "Capabilities",
|
||||
"type": "object"
|
||||
},
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"title": "Enabled",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"alias",
|
||||
"model"
|
||||
],
|
||||
"title": "CreateModelDefinitionRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"UpdateModelDefinitionRequest": {
|
||||
"properties": {
|
||||
"alias": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Alias"
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Model"
|
||||
},
|
||||
"provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Provider"
|
||||
},
|
||||
"base_url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Base Url"
|
||||
},
|
||||
"api_key": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Api Key"
|
||||
},
|
||||
"context_window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Context Window"
|
||||
},
|
||||
"capabilities": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Capabilities"
|
||||
},
|
||||
"enabled": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Enabled"
|
||||
}
|
||||
},
|
||||
"title": "UpdateModelDefinitionRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"ListModelDefinitionsResponse": {
|
||||
"properties": {
|
||||
"models": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ModelDefinitionInfo"
|
||||
},
|
||||
"title": "Models",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"models"
|
||||
],
|
||||
"title": "ListModelDefinitionsResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ModelReloadResponse": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"default": "ok",
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
},
|
||||
"results": {
|
||||
"additionalProperties": true,
|
||||
"title": "Results",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"title": "ModelReloadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"DetectModelRequest": {
|
||||
"properties": {
|
||||
"provider": {
|
||||
"default": "openai",
|
||||
"title": "Provider",
|
||||
"type": "string"
|
||||
},
|
||||
"base_url": {
|
||||
"default": "",
|
||||
"title": "Base Url",
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"default": "",
|
||||
"title": "Api Key",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"default": "",
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
"definition_id": {
|
||||
"default": "",
|
||||
"title": "Definition Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "DetectModelRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"DetectModelResponse": {
|
||||
"properties": {
|
||||
"reachable": {
|
||||
"default": false,
|
||||
"title": "Reachable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"model_found": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Model Found"
|
||||
},
|
||||
"available_models": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Available Models",
|
||||
"type": "array"
|
||||
},
|
||||
"context_window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Context Window"
|
||||
},
|
||||
"server_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Server Type"
|
||||
},
|
||||
"error": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Error"
|
||||
}
|
||||
},
|
||||
"title": "DetectModelResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ModelCapabilitiesResponse": {
|
||||
"properties": {
|
||||
"model": {
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"title": "Provider",
|
||||
"type": "string"
|
||||
},
|
||||
"known": {
|
||||
"default": false,
|
||||
"title": "Known",
|
||||
"type": "boolean"
|
||||
},
|
||||
"capabilities": {
|
||||
"additionalProperties": true,
|
||||
"title": "Capabilities",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model",
|
||||
"provider"
|
||||
],
|
||||
"title": "ModelCapabilitiesResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"KnownModelsResponse": {
|
||||
"properties": {
|
||||
"provider": {
|
||||
"title": "Provider",
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Models",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"provider"
|
||||
],
|
||||
"title": "KnownModelsResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"AvailableModelInfo": {
|
||||
"properties": {
|
||||
"alias": {
|
||||
"title": "Alias",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"title": "Provider",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"alias",
|
||||
"model",
|
||||
"provider"
|
||||
],
|
||||
"title": "AvailableModelInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"ListAvailableModelsResponse": {
|
||||
"properties": {
|
||||
"models": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AvailableModelInfo"
|
||||
},
|
||||
"title": "Models",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"title": "ListAvailableModelsResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"RegistrySearchResponse": {
|
||||
"properties": {
|
||||
"servers": {
|
||||
@@ -7630,4 +8583,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "0.8.4",
|
||||
"version": "0.9.2",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
@@ -458,6 +458,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/models": {
|
||||
"get": {
|
||||
"summary": "List available model aliases",
|
||||
"operationId": "v1_api_models_get",
|
||||
"tags": [
|
||||
"Models"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListAvailableModelsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/login": {
|
||||
"post": {
|
||||
"summary": "Authenticate with a token",
|
||||
@@ -1223,6 +1244,12 @@
|
||||
"description": "Target workstream ID",
|
||||
"title": "Ws Id",
|
||||
"type": "string"
|
||||
},
|
||||
"force": {
|
||||
"default": false,
|
||||
"description": "Force cancel: abandon the stuck worker thread immediately. Use when cooperative cancel has not resolved within a few seconds.",
|
||||
"title": "Force",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1977,7 +2004,43 @@
|
||||
],
|
||||
"title": "ListSkillSummaryResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"AvailableModelInfo": {
|
||||
"properties": {
|
||||
"alias": {
|
||||
"title": "Alias",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"title": "Provider",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"alias",
|
||||
"model",
|
||||
"provider"
|
||||
],
|
||||
"title": "AvailableModelInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"ListAvailableModelsResponse": {
|
||||
"properties": {
|
||||
"models": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AvailableModelInfo"
|
||||
},
|
||||
"title": "Models",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"title": "ListAvailableModelsResponse",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+163
-127
@@ -20,6 +20,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.0",
|
||||
"tslib": "^2.4.0"
|
||||
@@ -32,6 +33,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -43,6 +45,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -55,26 +58,28 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
|
||||
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.120.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
|
||||
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
|
||||
"version": "0.122.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
||||
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -82,9 +87,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -99,9 +104,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -116,9 +121,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -133,9 +138,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -150,9 +155,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -167,13 +172,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -184,13 +192,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -201,13 +212,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -218,13 +232,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -235,13 +252,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -252,13 +272,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -269,9 +292,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -286,9 +309,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -303,9 +326,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -320,9 +343,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -337,9 +360,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -387,31 +410,31 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
|
||||
"integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
|
||||
"integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
|
||||
"integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
|
||||
"integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.1",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -432,26 +455,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
|
||||
"integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
|
||||
"integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
|
||||
"integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
|
||||
"integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.1",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -459,14 +482,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
|
||||
"integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
|
||||
"integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -475,9 +498,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
|
||||
"integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
|
||||
"integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -485,15 +508,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
|
||||
"integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
|
||||
"integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -739,6 +762,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -760,6 +786,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -781,6 +810,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -802,6 +834,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -916,6 +951,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -953,14 +989,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.120.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.10"
|
||||
"@oxc-project/types": "=0.122.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.12"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -969,21 +1005,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.10",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.10",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
@@ -1084,16 +1120,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
||||
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
|
||||
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.10",
|
||||
"rolldown": "1.0.0-rc.12",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1162,19 +1198,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
|
||||
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
|
||||
"integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.1",
|
||||
"@vitest/mocker": "4.1.1",
|
||||
"@vitest/pretty-format": "4.1.1",
|
||||
"@vitest/runner": "4.1.1",
|
||||
"@vitest/snapshot": "4.1.1",
|
||||
"@vitest/spy": "4.1.1",
|
||||
"@vitest/utils": "4.1.1",
|
||||
"@vitest/expect": "4.1.2",
|
||||
"@vitest/mocker": "4.1.2",
|
||||
"@vitest/pretty-format": "4.1.2",
|
||||
"@vitest/runner": "4.1.2",
|
||||
"@vitest/snapshot": "4.1.2",
|
||||
"@vitest/spy": "4.1.2",
|
||||
"@vitest/utils": "4.1.2",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -1185,7 +1221,7 @@
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
@@ -1202,10 +1238,10 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.1",
|
||||
"@vitest/browser-preview": "4.1.1",
|
||||
"@vitest/browser-webdriverio": "4.1.1",
|
||||
"@vitest/ui": "4.1.1",
|
||||
"@vitest/browser-playwright": "4.1.2",
|
||||
"@vitest/browser-preview": "4.1.2",
|
||||
"@vitest/browser-webdriverio": "4.1.2",
|
||||
"@vitest/ui": "4.1.2",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
OrgInfo,
|
||||
RoleInfo,
|
||||
ScheduleInfo,
|
||||
DeleteSettingResponse,
|
||||
SettingInfo,
|
||||
StatusResponse,
|
||||
ToolPolicyInfo,
|
||||
@@ -394,7 +395,10 @@ export class TurnstoneConsole extends BaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSetting(key: string, nodeId?: string): Promise<StatusResponse> {
|
||||
async deleteSetting(
|
||||
key: string,
|
||||
nodeId?: string,
|
||||
): Promise<DeleteSettingResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (nodeId) params.node_id = nodeId;
|
||||
return this.request("DELETE", `/v1/api/admin/settings/${key}`, {
|
||||
|
||||
@@ -38,6 +38,11 @@ export interface StreamEndEvent {
|
||||
type: "stream_end";
|
||||
}
|
||||
|
||||
export interface StateChangeEvent {
|
||||
type: "state_change";
|
||||
state: "idle" | "thinking" | "running" | "attention" | "error";
|
||||
}
|
||||
|
||||
export interface ToolInfoEvent {
|
||||
type: "tool_info";
|
||||
items: Array<Record<string, unknown>>;
|
||||
@@ -59,6 +64,7 @@ export interface ToolResultEvent {
|
||||
call_id: string;
|
||||
name: string;
|
||||
output: string;
|
||||
is_error?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolOutputChunkEvent {
|
||||
@@ -77,6 +83,8 @@ export interface StatusEvent {
|
||||
effort: string;
|
||||
cache_creation_tokens?: number;
|
||||
cache_read_tokens?: number;
|
||||
tool_calls_this_turn?: number;
|
||||
turn_count?: number;
|
||||
}
|
||||
|
||||
export interface PlanReviewEvent {
|
||||
@@ -149,6 +157,7 @@ export type ServerEvent =
|
||||
| ContentEvent
|
||||
| ReasoningEvent
|
||||
| StreamEndEvent
|
||||
| StateChangeEvent
|
||||
| ToolInfoEvent
|
||||
| ApproveRequestEvent
|
||||
| ApprovalResolvedEvent
|
||||
@@ -246,6 +255,10 @@ export function isStreamEndEvent(e: ServerEvent): e is StreamEndEvent {
|
||||
return e.type === "stream_end";
|
||||
}
|
||||
|
||||
export function isStateChangeEvent(e: ServerEvent): e is StateChangeEvent {
|
||||
return e.type === "state_change";
|
||||
}
|
||||
|
||||
export function isToolResultEvent(e: ServerEvent): e is ToolResultEvent {
|
||||
return e.type === "tool_result";
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export type {
|
||||
ContentEvent,
|
||||
ReasoningEvent,
|
||||
StreamEndEvent,
|
||||
StateChangeEvent,
|
||||
ToolInfoEvent,
|
||||
ApproveRequestEvent,
|
||||
ApprovalResolvedEvent,
|
||||
@@ -65,6 +66,7 @@ export {
|
||||
isReasoningEvent,
|
||||
isErrorEvent,
|
||||
isStreamEndEvent,
|
||||
isStateChangeEvent,
|
||||
isToolResultEvent,
|
||||
isWsStateEvent,
|
||||
isApproveRequestEvent,
|
||||
@@ -98,6 +100,7 @@ export type {
|
||||
AuthLoginResponse,
|
||||
AuthStatusResponse,
|
||||
AuthSetupResponse,
|
||||
DeleteSettingResponse,
|
||||
StatusResponse,
|
||||
ErrorResponse,
|
||||
ClusterOverviewResponse,
|
||||
|
||||
@@ -93,10 +93,13 @@ export class TurnstoneServer extends BaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(wsId: string): Promise<StatusResponse> {
|
||||
return this.request("POST", "/v1/api/cancel", {
|
||||
json: { ws_id: wsId },
|
||||
});
|
||||
async cancel(
|
||||
wsId: string,
|
||||
opts?: { force?: boolean },
|
||||
): Promise<StatusResponse> {
|
||||
const body: Record<string, unknown> = { ws_id: wsId };
|
||||
if (opts?.force) body.force = true;
|
||||
return this.request("POST", "/v1/api/cancel", { json: body });
|
||||
}
|
||||
|
||||
// -- Streaming ------------------------------------------------------------
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface StatusResponse {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface DeleteSettingResponse {
|
||||
status: string;
|
||||
key: string;
|
||||
default: unknown;
|
||||
}
|
||||
|
||||
export interface AuthLoginRequest {
|
||||
token: string;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,13 @@ def _make_bridge(**overrides) -> Bridge:
|
||||
approval_timeout=1,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return Bridge(**defaults)
|
||||
bridge = Bridge(**defaults)
|
||||
# Replace real httpx client with a mock so daemon threads spawned by
|
||||
# _handle_approval / _handle_plan_review don't make real HTTP calls
|
||||
# after the test's patch context exits.
|
||||
bridge._http.close()
|
||||
bridge._http = MagicMock()
|
||||
return bridge
|
||||
|
||||
|
||||
def _approval_items(tool_name: str = "bash") -> list[dict]:
|
||||
|
||||
+412
-10
@@ -1,5 +1,6 @@
|
||||
"""Tests for generation cancellation (cooperative cancel via threading.Event)."""
|
||||
|
||||
import contextlib
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
@@ -7,7 +8,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.session import ChatSession, GenerationCancelled
|
||||
from turnstone.core.session import ChatSession, GenerationCancelled, _CancelRef
|
||||
|
||||
|
||||
class NullUI:
|
||||
@@ -36,7 +37,7 @@ class NullUI:
|
||||
def approve_tools(self, items):
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id, name, output):
|
||||
def on_tool_result(self, call_id, name, output, **kwargs):
|
||||
pass
|
||||
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
@@ -179,7 +180,7 @@ class TestCancelDuringToolExecution:
|
||||
"""Cancel while tools are being executed."""
|
||||
|
||||
def test_rollback_incomplete_tool_results(self, tmp_db):
|
||||
"""When cancelled during tool execution, incomplete results are rolled back."""
|
||||
"""When cancelled during tool execution, synthesized results replace missing tool outputs."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@@ -235,13 +236,15 @@ class TestCancelDuringToolExecution:
|
||||
|
||||
# Session should be idle
|
||||
assert ui.states[-1] == "idle"
|
||||
# No tool result messages should remain (rolled back)
|
||||
roles = [m["role"] for m in session.messages]
|
||||
assert "tool" not in roles
|
||||
# The assistant message with tool_calls should also be rolled back
|
||||
for m in session.messages:
|
||||
if m["role"] == "assistant":
|
||||
assert "tool_calls" not in m or not m["tool_calls"]
|
||||
# Cancelled tool calls should have synthesized results
|
||||
tool_msgs = [m for m in session.messages if m["role"] == "tool"]
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0]["tool_call_id"] == "tc_1"
|
||||
assert "Cancelled by user" in tool_msgs[0]["content"]
|
||||
assert tool_msgs[0].get("is_error") is True
|
||||
# The assistant message with tool_calls should still be present
|
||||
assistant_msgs = [m for m in session.messages if m.get("tool_calls")]
|
||||
assert len(assistant_msgs) == 1
|
||||
|
||||
|
||||
class TestCancelWhenIdle:
|
||||
@@ -407,3 +410,402 @@ class TestStreamFlushBeforeToolCalls:
|
||||
stream_end_idx = next(i for i, e in enumerate(events) if e[0] == "stream_end")
|
||||
late_content = [e for e in events[stream_end_idx + 1 :] if e[0] == "content"]
|
||||
assert late_content == [], f"Content after stream_end: {late_content}"
|
||||
|
||||
|
||||
class TestStreamAbort:
|
||||
"""Tests for cancel() closing the underlying SDK stream."""
|
||||
|
||||
def test_cancel_closes_cancel_stream(self, tmp_db):
|
||||
"""cancel() calls .close() on the stored SDK stream handle."""
|
||||
session = _make_session()
|
||||
mock_stream = MagicMock()
|
||||
session._cancel_stream = mock_stream
|
||||
session.cancel()
|
||||
mock_stream.close.assert_called_once()
|
||||
assert session._cancel_event.is_set()
|
||||
|
||||
def test_cancel_without_stream_is_safe(self, tmp_db):
|
||||
"""cancel() with no active stream just sets the event."""
|
||||
session = _make_session()
|
||||
assert session._cancel_stream is None
|
||||
session.cancel() # Should not raise
|
||||
assert session._cancel_event.is_set()
|
||||
|
||||
def test_cancel_stream_close_error_suppressed(self, tmp_db):
|
||||
"""Errors from stream.close() are suppressed."""
|
||||
session = _make_session()
|
||||
mock_stream = MagicMock()
|
||||
mock_stream.close.side_effect = RuntimeError("already closed")
|
||||
session._cancel_stream = mock_stream
|
||||
session.cancel() # Should not raise
|
||||
assert session._cancel_event.is_set()
|
||||
|
||||
def test_cancel_ref_populated_after_first_chunk(self, tmp_db):
|
||||
"""_cancel_ref is populated by the provider after the first chunk
|
||||
arrives (lazy generator evaluation)."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = "stop"
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
sdk_stream = MagicMock()
|
||||
|
||||
def fake_provider_stream():
|
||||
# Simulate provider appending to cancel_ref before first yield
|
||||
session._cancel_ref.append(sdk_stream)
|
||||
yield FakeChunk(content_delta="hi", finish_reason="stop")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session,
|
||||
"_create_stream_with_retry",
|
||||
return_value=fake_provider_stream(),
|
||||
),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# After stream completes, cancel_stream should be cleared
|
||||
assert session._cancel_stream is None
|
||||
assert len(session._cancel_ref) == 0
|
||||
|
||||
def test_transport_error_during_cancel_becomes_generation_cancelled(self, tmp_db):
|
||||
"""When cancel() closes the stream, the resulting transport error
|
||||
is converted to GenerationCancelled."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
def stream_that_errors():
|
||||
yield FakeChunk(content_delta="Hello")
|
||||
session._cancel_event.set()
|
||||
raise ConnectionError("stream closed")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session,
|
||||
"_create_stream_with_retry",
|
||||
return_value=stream_that_errors(),
|
||||
),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# Should complete as cancelled, not error
|
||||
assert "idle" in ui.states
|
||||
assert any("cancelled" in i.lower() for i in ui.infos)
|
||||
# Partial content preserved
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assert len(assistant_msgs) == 1
|
||||
assert assistant_msgs[0]["content"] == "Hello"
|
||||
|
||||
def test_non_cancel_exception_not_swallowed(self, tmp_db):
|
||||
"""Exceptions during streaming that aren't caused by cancel
|
||||
should propagate normally."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
def stream_that_errors():
|
||||
yield FakeChunk(content_delta="Hello")
|
||||
raise ValueError("unexpected error")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session,
|
||||
"_create_stream_with_retry",
|
||||
return_value=stream_that_errors(),
|
||||
),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
pytest.raises(ValueError, match="unexpected error"),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
def test_check_cancelled_between_retries(self, tmp_db):
|
||||
"""_try_stream checks for cancellation between retry attempts."""
|
||||
session = _make_session()
|
||||
session.cancel()
|
||||
|
||||
with pytest.raises(GenerationCancelled):
|
||||
session._try_stream(
|
||||
client=MagicMock(),
|
||||
model="test",
|
||||
msgs=[],
|
||||
)
|
||||
|
||||
|
||||
class TestCancelRef:
|
||||
"""Tests for the _CancelRef list proxy."""
|
||||
|
||||
def test_append_sets_cancel_stream(self, tmp_db):
|
||||
"""Appending a stream handle to _CancelRef sets _cancel_stream eagerly."""
|
||||
session = _make_session()
|
||||
mock_stream = MagicMock()
|
||||
assert session._cancel_stream is None
|
||||
|
||||
session._cancel_ref.append(mock_stream)
|
||||
|
||||
assert session._cancel_stream is mock_stream
|
||||
|
||||
def test_append_closes_stream_when_already_cancelled(self, tmp_db):
|
||||
"""If cancel is already set when a stream is appended, it is closed immediately."""
|
||||
session = _make_session()
|
||||
session.cancel() # Set cancel event before stream is created
|
||||
|
||||
mock_stream = MagicMock()
|
||||
session._cancel_ref.append(mock_stream)
|
||||
|
||||
mock_stream.close.assert_called_once()
|
||||
|
||||
def test_append_does_not_close_stream_when_not_cancelled(self, tmp_db):
|
||||
"""Stream is not closed if cancel hasn't been requested."""
|
||||
session = _make_session()
|
||||
mock_stream = MagicMock()
|
||||
|
||||
session._cancel_ref.append(mock_stream)
|
||||
|
||||
mock_stream.close.assert_not_called()
|
||||
assert session._cancel_stream is mock_stream
|
||||
|
||||
def test_append_close_error_suppressed(self, tmp_db):
|
||||
"""Errors from stream.close() during eager close are suppressed."""
|
||||
session = _make_session()
|
||||
session.cancel()
|
||||
|
||||
mock_stream = MagicMock()
|
||||
mock_stream.close.side_effect = RuntimeError("already closed")
|
||||
|
||||
session._cancel_ref.append(mock_stream) # Should not raise
|
||||
|
||||
def test_cancel_ref_is_cancel_ref_instance(self, tmp_db):
|
||||
"""ChatSession._cancel_ref is a _CancelRef instance."""
|
||||
session = _make_session()
|
||||
assert isinstance(session._cancel_ref, _CancelRef)
|
||||
|
||||
def test_cancel_ref_cleared_after_stream_ends(self, tmp_db):
|
||||
"""_cancel_ref is cleared in the send() finally block after streaming."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
mock_stream = MagicMock()
|
||||
session._cancel_ref.append(mock_stream)
|
||||
assert len(session._cancel_ref) == 1
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = "stop"
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session,
|
||||
"_create_stream_with_retry",
|
||||
return_value=iter([FakeChunk(content_delta="hi", finish_reason="stop")]),
|
||||
),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# After send() completes, _cancel_ref is cleared in the finally block
|
||||
assert len(session._cancel_ref) == 0
|
||||
|
||||
|
||||
class TestForceCancelGeneration:
|
||||
"""Tests for per-generation tracking that prevents orphaned-thread side-effects."""
|
||||
|
||||
def test_check_cancelled_raises_for_orphaned_generation(self, tmp_db):
|
||||
"""_check_cancelled raises GenerationCancelled when my_generation is stale."""
|
||||
session = _make_session()
|
||||
session._generation = 2 # Simulate two generations having run
|
||||
|
||||
with pytest.raises(GenerationCancelled):
|
||||
session._check_cancelled(my_generation=1) # Generation 1 is orphaned
|
||||
|
||||
def test_check_cancelled_ok_for_current_generation(self, tmp_db):
|
||||
"""_check_cancelled does not raise when my_generation matches current."""
|
||||
session = _make_session()
|
||||
session._generation = 3
|
||||
session._check_cancelled(my_generation=3) # Should not raise
|
||||
|
||||
def test_force_cancel_orphaned_thread_does_not_mutate_messages(self, tmp_db):
|
||||
"""An abandoned generation (force-cancel) cannot append to session.messages."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
# We can't trivially test the full threading scenario in a unit test,
|
||||
# so directly verify that _check_cancelled raises when my_generation
|
||||
# is stale, which is what guards _stream_response against orphaned
|
||||
# (force-cancelled) threads continuing to mutate messages.
|
||||
session._generation = 5
|
||||
with pytest.raises(GenerationCancelled):
|
||||
session._check_cancelled(my_generation=4) # orphaned generation
|
||||
|
||||
def test_new_cancel_event_per_generation_in_send(self, tmp_db):
|
||||
"""send() replaces _cancel_event with a fresh Event each generation."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = "stop"
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
original_event = session._cancel_event
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session,
|
||||
"_create_stream_with_retry",
|
||||
return_value=iter([FakeChunk(content_delta="hi", finish_reason="stop")]),
|
||||
),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("test")
|
||||
|
||||
# After send() completes, _cancel_event should be a NEW Event
|
||||
# (not the same object as before the call).
|
||||
assert session._cancel_event is not original_event
|
||||
assert not session._cancel_event.is_set()
|
||||
|
||||
|
||||
class TestForceCancelThreaded:
|
||||
"""Force cancel with actual threads — verifies orphaned thread behavior."""
|
||||
|
||||
def test_force_cancel_orphan_does_not_mutate_messages(self, tmp_db):
|
||||
"""After force cancel + new send(), the orphaned thread must not
|
||||
append stale content to session.messages."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = ""
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
barrier = threading.Event()
|
||||
old_done = threading.Event()
|
||||
|
||||
def slow_stream():
|
||||
yield FakeChunk(content_delta="Old content")
|
||||
barrier.set() # signal: first chunk delivered
|
||||
time.sleep(2) # simulate stuck stream
|
||||
yield FakeChunk(content_delta=" more", finish_reason="stop")
|
||||
|
||||
# Start generation 1 (will get stuck)
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=slow_stream()),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
|
||||
def run_old():
|
||||
with contextlib.suppress(Exception):
|
||||
session.send("old message")
|
||||
old_done.set()
|
||||
|
||||
t1 = threading.Thread(target=run_old, daemon=True)
|
||||
t1.start()
|
||||
assert barrier.wait(timeout=5), "stream did not start"
|
||||
|
||||
# Force cancel: simulate what the server does
|
||||
session.cancel()
|
||||
# Increment generation as new send() would
|
||||
session._generation += 1
|
||||
session._cancel_event = threading.Event()
|
||||
|
||||
# Wait for old thread to notice generation mismatch and exit
|
||||
assert old_done.wait(timeout=10), "orphaned thread did not exit"
|
||||
|
||||
# The orphaned thread should NOT have appended its content
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
# May have partial content from before cancel, but NOT the full
|
||||
# "Old content more" that would appear without the generation guard
|
||||
for msg in assistant_msgs:
|
||||
assert "more" not in msg.get("content", "")
|
||||
|
||||
def test_force_cancel_then_new_send_succeeds(self, tmp_db):
|
||||
"""A new send() after force cancel works cleanly."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@dataclass
|
||||
class FakeChunk:
|
||||
content_delta: str = ""
|
||||
reasoning_delta: str = ""
|
||||
tool_call_deltas: list = field(default_factory=list)
|
||||
usage: None = None
|
||||
finish_reason: str = "stop"
|
||||
info_delta: str = ""
|
||||
provider_blocks: list = field(default_factory=list)
|
||||
|
||||
barrier = threading.Event()
|
||||
|
||||
def stuck_stream():
|
||||
yield FakeChunk(content_delta="stuck")
|
||||
barrier.set()
|
||||
time.sleep(2)
|
||||
yield FakeChunk(content_delta=" end", finish_reason="stop")
|
||||
|
||||
# Start stuck generation
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=stuck_stream()),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
t = threading.Thread(target=lambda: session.send("old"), daemon=True)
|
||||
t.start()
|
||||
assert barrier.wait(timeout=5), "stream did not start"
|
||||
|
||||
# Force cancel
|
||||
session.cancel()
|
||||
|
||||
# New generation should work
|
||||
fresh_stream = iter([FakeChunk(content_delta="Fresh response")])
|
||||
with (
|
||||
patch.object(session, "_create_stream_with_retry", return_value=fresh_stream),
|
||||
patch.object(session, "_full_messages", return_value=[]),
|
||||
):
|
||||
session.send("new message")
|
||||
|
||||
# The new generation should have completed successfully
|
||||
assert "idle" in ui.states
|
||||
assistant_msgs = [m for m in session.messages if m["role"] == "assistant"]
|
||||
assert any("Fresh response" in m.get("content", "") for m in assistant_msgs)
|
||||
|
||||
@@ -390,6 +390,7 @@ class TestApprovalVerdictDisplay:
|
||||
bot.config.streaming_edit_interval = 1.5
|
||||
bot.config.auto_approve = False
|
||||
bot.config.auto_approve_tools = []
|
||||
bot.storage = None
|
||||
bot._streaming = {}
|
||||
bot._pending_approval_msgs = {}
|
||||
bot._notify_reply_channels = {}
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Tests for edit_file tool — single edit and batch edit modes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(tmp_db, mock_openai_client):
|
||||
"""Create a ChatSession wired to a temp database."""
|
||||
return ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="test-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_file(tmp_path):
|
||||
"""Create a sample file and return its path."""
|
||||
p = tmp_path / "test.py"
|
||||
p.write_text("line1\nline2\nline3\nline4\nline5\n")
|
||||
return str(p)
|
||||
|
||||
|
||||
def _mark_read(session: ChatSession, path: str) -> None:
|
||||
"""Simulate a prior read_file so the edit guard passes."""
|
||||
resolved = os.path.realpath(os.path.expanduser(path))
|
||||
session._read_files.add(resolved)
|
||||
|
||||
|
||||
# ── Single edit (backward compat) ────────────────────────────────────
|
||||
|
||||
|
||||
class TestSingleEdit:
|
||||
def test_basic_replace(self, session, sample_file):
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"old_string": "line2",
|
||||
"new_string": "replaced",
|
||||
},
|
||||
)
|
||||
assert result["needs_approval"]
|
||||
assert result["func_name"] == "edit_file"
|
||||
|
||||
call_id, msg = session._exec_edit_file(result)
|
||||
assert call_id == "c1"
|
||||
assert "applied 1 edit" in msg
|
||||
with open(sample_file) as f:
|
||||
assert f.read() == "line1\nreplaced\nline3\nline4\nline5\n"
|
||||
|
||||
def test_missing_path(self, session):
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"old_string": "a",
|
||||
"new_string": "b",
|
||||
},
|
||||
)
|
||||
assert result.get("error")
|
||||
assert "missing path" in result["error"]
|
||||
|
||||
def test_missing_old_string(self, session, sample_file):
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"new_string": "b",
|
||||
},
|
||||
)
|
||||
assert result.get("error")
|
||||
assert "old_string" in result["error"]
|
||||
|
||||
def test_identical_strings(self, session, sample_file):
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"old_string": "line1",
|
||||
"new_string": "line1",
|
||||
},
|
||||
)
|
||||
assert result.get("error")
|
||||
assert "identical" in result["error"]
|
||||
|
||||
def test_old_string_not_found(self, session, sample_file):
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"old_string": "nonexistent",
|
||||
"new_string": "replaced",
|
||||
},
|
||||
)
|
||||
assert result.get("error")
|
||||
assert "not found" in result["error"]
|
||||
|
||||
def test_must_read_first(self, session, sample_file):
|
||||
# Don't call _mark_read — should fail
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"old_string": "line1",
|
||||
"new_string": "replaced",
|
||||
},
|
||||
)
|
||||
assert result.get("error")
|
||||
assert "must read_file" in result["error"]
|
||||
|
||||
def test_multiple_occurrences_without_near_line(self, session, tmp_path):
|
||||
p = tmp_path / "dup.txt"
|
||||
p.write_text("foo\nbar\nfoo\n")
|
||||
path = str(p)
|
||||
_mark_read(session, path)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": path,
|
||||
"old_string": "foo",
|
||||
"new_string": "baz",
|
||||
},
|
||||
)
|
||||
assert result.get("error")
|
||||
assert "found 2 times" in result["error"]
|
||||
|
||||
def test_near_line_disambiguates(self, session, tmp_path):
|
||||
p = tmp_path / "dup.txt"
|
||||
p.write_text("foo\nbar\nfoo\n")
|
||||
path = str(p)
|
||||
_mark_read(session, path)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": path,
|
||||
"old_string": "foo",
|
||||
"new_string": "baz",
|
||||
"near_line": 3,
|
||||
},
|
||||
)
|
||||
assert result["needs_approval"]
|
||||
|
||||
call_id, msg = session._exec_edit_file(result)
|
||||
assert "applied 1 edit" in msg
|
||||
with open(path) as f:
|
||||
assert f.read() == "foo\nbar\nbaz\n"
|
||||
|
||||
def test_deletion(self, session, sample_file):
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"old_string": "line3\n",
|
||||
"new_string": "",
|
||||
},
|
||||
)
|
||||
assert result["needs_approval"]
|
||||
assert "deletion" in result["preview"]
|
||||
|
||||
call_id, msg = session._exec_edit_file(result)
|
||||
with open(sample_file) as f:
|
||||
assert f.read() == "line1\nline2\nline4\nline5\n"
|
||||
|
||||
|
||||
# ── Batch edits ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBatchEdit:
|
||||
def test_two_edits_applied_atomically(self, session, sample_file):
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"edits": [
|
||||
{"old_string": "line1", "new_string": "first"},
|
||||
{"old_string": "line5", "new_string": "last"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert result["needs_approval"]
|
||||
assert "2 edits" in result["header"]
|
||||
|
||||
call_id, msg = session._exec_edit_file(result)
|
||||
assert "applied 2 edits" in msg
|
||||
with open(sample_file) as f:
|
||||
assert f.read() == "first\nline2\nline3\nline4\nlast\n"
|
||||
|
||||
def test_three_edits_middle_of_file(self, session, sample_file):
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"edits": [
|
||||
{"old_string": "line2", "new_string": "second"},
|
||||
{"old_string": "line3", "new_string": "third"},
|
||||
{"old_string": "line4", "new_string": "fourth"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert result["needs_approval"]
|
||||
|
||||
call_id, msg = session._exec_edit_file(result)
|
||||
assert "applied 3 edits" in msg
|
||||
with open(sample_file) as f:
|
||||
assert f.read() == "line1\nsecond\nthird\nfourth\nline5\n"
|
||||
|
||||
def test_overlapping_edits_rejected(self, session, tmp_path):
|
||||
p = tmp_path / "overlap.txt"
|
||||
p.write_text("abcdefgh\n")
|
||||
path = str(p)
|
||||
_mark_read(session, path)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": path,
|
||||
"edits": [
|
||||
{"old_string": "abcdef", "new_string": "XXX"},
|
||||
{"old_string": "defgh", "new_string": "YYY"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert result["needs_approval"]
|
||||
|
||||
call_id, msg = session._exec_edit_file(result)
|
||||
assert "overlap" in msg.lower()
|
||||
# File should be untouched
|
||||
with open(path) as f:
|
||||
assert f.read() == "abcdefgh\n"
|
||||
|
||||
def test_batch_edit_not_found(self, session, sample_file):
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"edits": [
|
||||
{"old_string": "line1", "new_string": "first"},
|
||||
{"old_string": "nonexistent", "new_string": "oops"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert result.get("error")
|
||||
assert "edits[1]" in result["error"]
|
||||
assert "not found" in result["error"]
|
||||
|
||||
def test_batch_edit_missing_old_string(self, session, sample_file):
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"edits": [
|
||||
{"old_string": "line1", "new_string": "first"},
|
||||
{"new_string": "oops"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert result.get("error")
|
||||
assert "edits[1]" in result["error"]
|
||||
assert "old_string" in result["error"]
|
||||
|
||||
def test_batch_edit_identical_strings(self, session, sample_file):
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"edits": [
|
||||
{"old_string": "line1", "new_string": "line1"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert result.get("error")
|
||||
assert "identical" in result["error"]
|
||||
|
||||
def test_batch_with_near_line(self, session, tmp_path):
|
||||
p = tmp_path / "dup.txt"
|
||||
p.write_text("foo\nbar\nfoo\nbaz\n")
|
||||
path = str(p)
|
||||
_mark_read(session, path)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": path,
|
||||
"edits": [
|
||||
{"old_string": "foo", "new_string": "first_foo", "near_line": 1},
|
||||
{"old_string": "foo", "new_string": "second_foo", "near_line": 3},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert result["needs_approval"]
|
||||
|
||||
call_id, msg = session._exec_edit_file(result)
|
||||
assert "applied 2 edits" in msg
|
||||
with open(path) as f:
|
||||
assert f.read() == "first_foo\nbar\nsecond_foo\nbaz\n"
|
||||
|
||||
def test_batch_with_deletion(self, session, sample_file):
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"edits": [
|
||||
{"old_string": "line2\n", "new_string": ""},
|
||||
{"old_string": "line4\n", "new_string": ""},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert result["needs_approval"]
|
||||
|
||||
call_id, msg = session._exec_edit_file(result)
|
||||
with open(sample_file) as f:
|
||||
assert f.read() == "line1\nline3\nline5\n"
|
||||
|
||||
def test_single_item_edits_array(self, session, sample_file):
|
||||
"""An edits array with one item should work like a single edit."""
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"edits": [
|
||||
{"old_string": "line3", "new_string": "middle"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert result["needs_approval"]
|
||||
# Single edit — no "(N edits)" count in header
|
||||
assert "edits)" not in result["header"]
|
||||
|
||||
call_id, msg = session._exec_edit_file(result)
|
||||
assert "applied 1 edit" in msg
|
||||
with open(sample_file) as f:
|
||||
assert f.read() == "line1\nline2\nmiddle\nline4\nline5\n"
|
||||
|
||||
|
||||
# ── Mutual exclusivity ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMutualExclusivity:
|
||||
def test_both_single_and_batch_rejected(self, session, sample_file):
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"old_string": "line1",
|
||||
"new_string": "replaced",
|
||||
"edits": [
|
||||
{"old_string": "line2", "new_string": "also_replaced"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert result.get("error")
|
||||
assert "not both" in result["error"]
|
||||
|
||||
def test_neither_single_nor_batch(self, session, sample_file):
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
},
|
||||
)
|
||||
assert result.get("error")
|
||||
assert "old_string" in result["error"]
|
||||
|
||||
def test_empty_edits_array_falls_through_to_single(self, session, sample_file):
|
||||
"""An empty edits array should be treated as no batch."""
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"edits": [],
|
||||
},
|
||||
)
|
||||
# Falls through to single-edit path, which requires old_string
|
||||
assert result.get("error")
|
||||
assert "old_string" in result["error"]
|
||||
|
||||
|
||||
# ── TOCTOU edge cases ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExecEdgeCases:
|
||||
def test_file_changed_between_prepare_and_exec(self, session, sample_file):
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"old_string": "line2",
|
||||
"new_string": "replaced",
|
||||
},
|
||||
)
|
||||
assert result["needs_approval"]
|
||||
|
||||
# Modify the file after prepare
|
||||
with open(sample_file, "w") as f:
|
||||
f.write("completely different content\n")
|
||||
|
||||
call_id, msg = session._exec_edit_file(result)
|
||||
assert "no longer found" in msg
|
||||
|
||||
def test_file_deleted_between_prepare_and_exec(self, session, sample_file):
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"old_string": "line2",
|
||||
"new_string": "replaced",
|
||||
},
|
||||
)
|
||||
assert result["needs_approval"]
|
||||
|
||||
os.unlink(sample_file)
|
||||
|
||||
call_id, msg = session._exec_edit_file(result)
|
||||
assert "Error" in msg
|
||||
|
||||
def test_batch_file_changed_partial_match(self, session, sample_file):
|
||||
"""If file changes so one edit fails, none should be applied."""
|
||||
_mark_read(session, sample_file)
|
||||
result = session._prepare_edit_file(
|
||||
"c1",
|
||||
{
|
||||
"path": sample_file,
|
||||
"edits": [
|
||||
{"old_string": "line1", "new_string": "first"},
|
||||
{"old_string": "line5", "new_string": "last"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert result["needs_approval"]
|
||||
|
||||
# Remove line5 between prepare and exec
|
||||
with open(sample_file, "w") as f:
|
||||
f.write("line1\nline2\nline3\nline4\n")
|
||||
|
||||
call_id, msg = session._exec_edit_file(result)
|
||||
assert "no longer found" in msg
|
||||
# line1 should NOT have been edited (atomic failure)
|
||||
with open(sample_file) as f:
|
||||
assert "line1" in f.read()
|
||||
+29
-16
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
@@ -179,10 +180,13 @@ class TestErrorHandling:
|
||||
provider = _make_mock_provider(side_effect=RuntimeError("API error"))
|
||||
judge = _make_judge(provider)
|
||||
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_provider_error_heuristic_still_returned(self):
|
||||
@@ -211,10 +215,13 @@ class TestErrorHandling:
|
||||
result_mock.content = ""
|
||||
|
||||
judge = _make_judge(provider)
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -254,10 +261,13 @@ class TestMultiTurnToolUse:
|
||||
provider.create_completion.side_effect = [turn1, turn2]
|
||||
|
||||
judge = _make_judge(provider)
|
||||
verdict = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
verdict = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
)
|
||||
assert verdict is not None
|
||||
assert verdict.tier == "llm"
|
||||
assert provider.create_completion.call_count == 2
|
||||
@@ -299,10 +309,13 @@ class TestMultiTurnToolUse:
|
||||
]
|
||||
|
||||
judge = _make_judge(provider)
|
||||
judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
cancel_event=None,
|
||||
executor=pool,
|
||||
)
|
||||
# Should have called create_completion exactly _JUDGE_MAX_TURNS times
|
||||
assert provider.create_completion.call_count == 5
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ def _make_session(skills: list[dict[str, Any]] | None = None):
|
||||
session._notify_on_complete = "{}"
|
||||
session.messages = []
|
||||
session._config = {}
|
||||
session._tool_error_flags = {}
|
||||
|
||||
# Stub set_skill to just record the call
|
||||
session._set_skill_called: list[str | None] = []
|
||||
@@ -423,6 +424,7 @@ class TestSkillCatalogDisclosure:
|
||||
session._tool_search = None
|
||||
session._mcp_client = None
|
||||
session._notify_on_complete = "{}"
|
||||
session._tool_error_flags = {}
|
||||
|
||||
# Memory stubs
|
||||
session._memory_config = MagicMock()
|
||||
@@ -434,7 +436,7 @@ class TestSkillCatalogDisclosure:
|
||||
"turnstone.core.session.list_skills_by_activation",
|
||||
return_value=search_skills or [],
|
||||
),
|
||||
patch.object(session, "_get_visible_memories", return_value=[]),
|
||||
patch.object(session, "_list_visible_memories", return_value=[]),
|
||||
):
|
||||
session._init_system_messages()
|
||||
|
||||
|
||||
@@ -401,6 +401,64 @@ class TestSessionIntegration:
|
||||
prepared = session._prepare_tool(tc)
|
||||
assert "error" in prepared
|
||||
assert "Unknown tool" in prepared["error"]
|
||||
# Error lists available tools so the model can self-correct
|
||||
assert "bash" in prepared["error"]
|
||||
# Surfaces warning to user
|
||||
session.ui.on_error.assert_called_once()
|
||||
assert "nonexistent" in session.ui.on_error.call_args[0][0]
|
||||
|
||||
def test_prepare_tool_strips_whitespace_from_name(self, tmp_db):
|
||||
"""Local models may produce tool names with leading/trailing whitespace."""
|
||||
session = self._make_session(mcp_client=None)
|
||||
tc = {
|
||||
"id": "call_strip",
|
||||
"function": {"name": " bash\n", "arguments": '{"command": "echo hi"}'},
|
||||
}
|
||||
prepared = session._prepare_tool(tc)
|
||||
assert prepared["func_name"] == "bash"
|
||||
assert "error" not in prepared
|
||||
|
||||
def test_prepare_tool_malformed_json_surfaces_error(self, tmp_db):
|
||||
"""Malformed JSON args should surface a warning to the user and
|
||||
give the model a hint about expected format."""
|
||||
session = self._make_session(mcp_client=None)
|
||||
tc = {
|
||||
"id": "call_bad",
|
||||
"function": {"name": "bash", "arguments": "{command: echo hi}"},
|
||||
}
|
||||
prepared = session._prepare_tool(tc)
|
||||
assert "error" in prepared
|
||||
assert "JSON parse error" in prepared["error"]
|
||||
assert "command" in prepared["error"] # hint about expected key
|
||||
assert "Please retry" in prepared["error"]
|
||||
# User-facing warning
|
||||
session.ui.on_error.assert_called_once()
|
||||
assert "Malformed tool call" in session.ui.on_error.call_args[0][0]
|
||||
|
||||
def test_ensure_tool_call_ids_dict(self, tmp_db):
|
||||
"""_ensure_tool_call_ids fills empty IDs on streaming-style dict."""
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
tool_calls_acc = {
|
||||
0: {"id": "", "function": {"name": "bash", "arguments": "{}"}},
|
||||
1: {"id": "", "function": {"name": "read_file", "arguments": "{}"}},
|
||||
}
|
||||
ChatSession._ensure_tool_call_ids(tool_calls_acc)
|
||||
ids = [tc["id"] for tc in tool_calls_acc.values()]
|
||||
assert all(id_.startswith("call_") for id_ in ids)
|
||||
assert len(set(ids)) == 2 # unique
|
||||
|
||||
def test_ensure_tool_call_ids_list(self, tmp_db):
|
||||
"""_ensure_tool_call_ids fills empty IDs on list (agent path)."""
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
tool_calls = [
|
||||
{"id": None, "function": {"name": "bash", "arguments": "{}"}},
|
||||
{"id": "call_existing", "function": {"name": "bash", "arguments": "{}"}},
|
||||
]
|
||||
ChatSession._ensure_tool_call_ids(tool_calls)
|
||||
assert tool_calls[0]["id"].startswith("call_")
|
||||
assert tool_calls[1]["id"] == "call_existing" # preserved
|
||||
|
||||
def test_mcp_command_no_client(self, tmp_db):
|
||||
session = self._make_session(mcp_client=None)
|
||||
@@ -1368,3 +1426,130 @@ class TestShutdownCleanup:
|
||||
assert mgr.get_prompts() == []
|
||||
assert mgr._resource_map == {}
|
||||
assert mgr._prompt_map == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TCP probe and unreachable server handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTCPProbe:
|
||||
"""MCPClientManager._tcp_probe should fail fast on unreachable servers."""
|
||||
|
||||
def test_tcp_probe_unreachable_raises_connection_error(self):
|
||||
"""Unreachable host raises ConnectionError, not TimeoutError."""
|
||||
mgr = MCPClientManager({})
|
||||
|
||||
async def _run():
|
||||
with pytest.raises(ConnectionError, match="unreachable"):
|
||||
await mgr._tcp_probe("test-server", "http://127.0.0.1:1")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_tcp_probe_parses_url_correctly(self):
|
||||
"""Port and host are extracted from the URL."""
|
||||
mgr = MCPClientManager({})
|
||||
|
||||
async def _run():
|
||||
# Non-routable port — should fail with ConnectionError
|
||||
with pytest.raises(ConnectionError):
|
||||
await mgr._tcp_probe("srv", "https://127.0.0.1:1/mcp")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_tcp_probe_default_port_http(self):
|
||||
"""Default port 80 used for http:// URLs without explicit port."""
|
||||
mgr = MCPClientManager({})
|
||||
|
||||
async def _run():
|
||||
# Will fail (nothing on port 80), but should not crash on parsing
|
||||
with pytest.raises(ConnectionError):
|
||||
await mgr._tcp_probe("srv", "http://127.0.0.1")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_tcp_probe_dns_failure(self):
|
||||
"""Unresolvable hostname raises ConnectionError."""
|
||||
mgr = MCPClientManager({})
|
||||
|
||||
async def _run():
|
||||
with pytest.raises(ConnectionError):
|
||||
await mgr._tcp_probe("srv", "http://this.host.does.not.exist.invalid:8080/mcp")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
class TestConnectOneUnreachable:
|
||||
"""_connect_one should handle unreachable HTTP servers gracefully."""
|
||||
|
||||
def test_unreachable_http_server_raises_connection_error(self):
|
||||
"""Unreachable HTTP MCP server raises ConnectionError without spinning."""
|
||||
mgr = MCPClientManager({})
|
||||
mgr._loop = asyncio.new_event_loop()
|
||||
|
||||
async def _run():
|
||||
with pytest.raises(ConnectionError, match="unreachable"):
|
||||
await mgr._connect_one(
|
||||
"bad-server",
|
||||
{
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:1/mcp",
|
||||
},
|
||||
)
|
||||
|
||||
mgr._loop.run_until_complete(_run())
|
||||
mgr._loop.close()
|
||||
|
||||
# Server should NOT be in sessions (connection failed)
|
||||
assert "bad-server" not in mgr._sessions
|
||||
|
||||
def test_connect_all_continues_after_unreachable_server(self):
|
||||
"""_connect_all logs error and continues to next server."""
|
||||
mgr = MCPClientManager(
|
||||
{
|
||||
"bad": {"type": "http", "url": "http://127.0.0.1:1/mcp"},
|
||||
}
|
||||
)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(mgr._connect_all())
|
||||
loop.close()
|
||||
|
||||
assert "bad" not in mgr._sessions
|
||||
assert "bad" in mgr._last_error
|
||||
|
||||
|
||||
class TestSafeCloseStack:
|
||||
"""_safe_close_stack should suppress errors from broken anyio scopes."""
|
||||
|
||||
def test_suppresses_runtime_error(self):
|
||||
"""RuntimeError from broken cancel scope is suppressed."""
|
||||
|
||||
async def _run():
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
|
||||
# Simulate a broken close that raises RuntimeError
|
||||
async def _broken_close():
|
||||
raise RuntimeError("Attempted to exit cancel scope in a different task")
|
||||
|
||||
stack.aclose = _broken_close
|
||||
# Should not raise
|
||||
await MCPClientManager._safe_close_stack(stack)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_suppresses_cancelled_error(self):
|
||||
"""CancelledError during close is suppressed."""
|
||||
|
||||
async def _run():
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
|
||||
async def _cancel_close():
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
stack.aclose = _cancel_close
|
||||
await MCPClientManager._safe_close_stack(stack)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
@@ -4,6 +4,7 @@ from turnstone.core.metacognition import (
|
||||
NUDGE_COMPLETION,
|
||||
NUDGE_CORRECTION,
|
||||
NUDGE_DENIAL,
|
||||
NUDGE_REPEAT,
|
||||
NUDGE_RESUME,
|
||||
NUDGE_START,
|
||||
NUDGE_TOOL_ERROR,
|
||||
@@ -288,3 +289,22 @@ class TestToolErrorNudge:
|
||||
def test_not_with_zero_memories(self):
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("tool_error", state, message_count=5, memory_count=0) is False
|
||||
|
||||
|
||||
class TestRepeatNudge:
|
||||
def test_format(self):
|
||||
assert format_nudge("repeat") == NUDGE_REPEAT
|
||||
|
||||
def test_fires(self):
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("repeat", state, message_count=5) is True
|
||||
|
||||
def test_cooldown(self):
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("repeat", state, message_count=5) is True
|
||||
assert should_nudge("repeat", state, message_count=6) is False
|
||||
|
||||
def test_no_memory_requirement(self):
|
||||
"""Repeat nudge should fire even with zero memories."""
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("repeat", state, message_count=5, memory_count=0) is True
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests for model definition storage CRUD operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
def _make_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
class TestModelDefinitionStorage:
|
||||
def test_create_and_get(self, db: SQLiteBackend) -> None:
|
||||
did = _make_id()
|
||||
db.create_model_definition(
|
||||
definition_id=did,
|
||||
alias="test-model",
|
||||
model="gpt-5",
|
||||
provider="openai",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="sk-test",
|
||||
context_window=128000,
|
||||
)
|
||||
m = db.get_model_definition(did)
|
||||
assert m is not None
|
||||
assert m["alias"] == "test-model"
|
||||
assert m["model"] == "gpt-5"
|
||||
assert m["provider"] == "openai"
|
||||
assert m["base_url"] == "https://api.openai.com/v1"
|
||||
assert m["api_key"] == "sk-test"
|
||||
assert m["context_window"] == 128000
|
||||
assert m["capabilities"] == "{}"
|
||||
assert m["enabled"] is True
|
||||
|
||||
def test_get_by_alias(self, db: SQLiteBackend) -> None:
|
||||
did = _make_id()
|
||||
db.create_model_definition(definition_id=did, alias="by-alias", model="gpt-5")
|
||||
m = db.get_model_definition_by_alias("by-alias")
|
||||
assert m is not None
|
||||
assert m["definition_id"] == did
|
||||
|
||||
def test_get_by_alias_not_found(self, db: SQLiteBackend) -> None:
|
||||
assert db.get_model_definition_by_alias("nope") is None
|
||||
|
||||
def test_get_not_found(self, db: SQLiteBackend) -> None:
|
||||
assert db.get_model_definition("nonexistent") is None
|
||||
|
||||
def test_list_empty(self, db: SQLiteBackend) -> None:
|
||||
assert db.list_model_definitions() == []
|
||||
|
||||
def test_list_all(self, db: SQLiteBackend) -> None:
|
||||
db.create_model_definition(definition_id=_make_id(), alias="alpha", model="gpt-5")
|
||||
db.create_model_definition(
|
||||
definition_id=_make_id(), alias="beta", model="claude-opus-4-6", provider="anthropic"
|
||||
)
|
||||
models = db.list_model_definitions()
|
||||
assert len(models) == 2
|
||||
assert models[0]["alias"] == "alpha" # ordered by alias
|
||||
assert models[1]["alias"] == "beta"
|
||||
|
||||
def test_list_enabled_only(self, db: SQLiteBackend) -> None:
|
||||
db.create_model_definition(
|
||||
definition_id=_make_id(), alias="enabled-model", model="gpt-5", enabled=True
|
||||
)
|
||||
db.create_model_definition(
|
||||
definition_id=_make_id(), alias="disabled-model", model="gpt-5", enabled=False
|
||||
)
|
||||
enabled = db.list_model_definitions(enabled_only=True)
|
||||
assert len(enabled) == 1
|
||||
assert enabled[0]["alias"] == "enabled-model"
|
||||
|
||||
def test_update_basic_fields(self, db: SQLiteBackend) -> None:
|
||||
did = _make_id()
|
||||
db.create_model_definition(
|
||||
definition_id=did, alias="orig", model="gpt-5", base_url="http://old"
|
||||
)
|
||||
ok = db.update_model_definition(did, alias="renamed", base_url="http://new")
|
||||
assert ok is True
|
||||
m = db.get_model_definition(did)
|
||||
assert m is not None
|
||||
assert m["alias"] == "renamed"
|
||||
assert m["base_url"] == "http://new"
|
||||
|
||||
def test_update_boolean_conversion(self, db: SQLiteBackend) -> None:
|
||||
did = _make_id()
|
||||
db.create_model_definition(definition_id=did, alias="booltest", model="gpt-5")
|
||||
db.update_model_definition(did, enabled=False)
|
||||
m = db.get_model_definition(did)
|
||||
assert m is not None
|
||||
assert m["enabled"] is False
|
||||
|
||||
def test_update_not_found(self, db: SQLiteBackend) -> None:
|
||||
ok = db.update_model_definition("nonexistent", alias="x")
|
||||
assert ok is False
|
||||
|
||||
def test_update_ignores_disallowed_fields(self, db: SQLiteBackend) -> None:
|
||||
did = _make_id()
|
||||
db.create_model_definition(
|
||||
definition_id=did, alias="guard", model="gpt-5", created_by="admin"
|
||||
)
|
||||
original = db.get_model_definition(did)
|
||||
assert original is not None
|
||||
original_created = original["created"]
|
||||
# created_by and created are not in the mutable allowlist
|
||||
db.update_model_definition(did, created_by="evil", created="2000-01-01T00:00:00")
|
||||
m = db.get_model_definition(did)
|
||||
assert m is not None
|
||||
assert m["created_by"] == "admin" # unchanged
|
||||
assert m["created"] == original_created # unchanged
|
||||
|
||||
def test_delete(self, db: SQLiteBackend) -> None:
|
||||
did = _make_id()
|
||||
db.create_model_definition(definition_id=did, alias="delme", model="gpt-5")
|
||||
ok = db.delete_model_definition(did)
|
||||
assert ok is True
|
||||
assert db.get_model_definition(did) is None
|
||||
|
||||
def test_delete_not_found(self, db: SQLiteBackend) -> None:
|
||||
ok = db.delete_model_definition("nonexistent")
|
||||
assert ok is False
|
||||
|
||||
def test_create_duplicate_alias(self, db: SQLiteBackend) -> None:
|
||||
db.create_model_definition(definition_id=_make_id(), alias="unique", model="gpt-5")
|
||||
# Second create with same alias but different ID should be no-op (OR IGNORE)
|
||||
did2 = _make_id()
|
||||
db.create_model_definition(definition_id=did2, alias="unique", model="gpt-5")
|
||||
assert db.get_model_definition(did2) is None
|
||||
|
||||
def test_create_idempotent_same_id(self, db: SQLiteBackend) -> None:
|
||||
did = _make_id()
|
||||
db.create_model_definition(definition_id=did, alias="idem", model="gpt-5")
|
||||
db.create_model_definition(definition_id=did, alias="idem", model="gpt-5-mini")
|
||||
m = db.get_model_definition(did)
|
||||
assert m is not None
|
||||
assert m["model"] == "gpt-5" # original preserved
|
||||
|
||||
def test_capabilities_json(self, db: SQLiteBackend) -> None:
|
||||
did = _make_id()
|
||||
caps = '{"supports_vision": true, "supports_web_search": false}'
|
||||
db.create_model_definition(
|
||||
definition_id=did, alias="caps-test", model="gpt-5", capabilities=caps
|
||||
)
|
||||
m = db.get_model_definition(did)
|
||||
assert m is not None
|
||||
assert m["capabilities"] == caps
|
||||
|
||||
def test_defaults(self, db: SQLiteBackend) -> None:
|
||||
"""Verify default values for optional fields."""
|
||||
did = _make_id()
|
||||
db.create_model_definition(definition_id=did, alias="defaults", model="gpt-5")
|
||||
m = db.get_model_definition(did)
|
||||
assert m is not None
|
||||
assert m["provider"] == "openai"
|
||||
assert m["base_url"] == ""
|
||||
assert m["api_key"] == ""
|
||||
assert m["context_window"] == 32768
|
||||
assert m["capabilities"] == "{}"
|
||||
assert m["enabled"] is True
|
||||
assert m["created_by"] == ""
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Tests for probe_model_endpoint() and lookup_model_capabilities()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.model_registry import probe_model_endpoint
|
||||
from turnstone.core.providers import list_known_models, lookup_model_capabilities
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_model(
|
||||
model_id: str,
|
||||
*,
|
||||
owned_by: str = "test",
|
||||
meta: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.id = model_id
|
||||
dumped: dict[str, Any] = {"owned_by": owned_by}
|
||||
if meta is not None:
|
||||
dumped["meta"] = meta
|
||||
if kwargs.get("max_model_len") is not None:
|
||||
dumped["max_model_len"] = kwargs["max_model_len"]
|
||||
m.model_dump.return_value = dumped
|
||||
return m
|
||||
|
||||
|
||||
def _mock_client(*models: MagicMock) -> MagicMock:
|
||||
fast = MagicMock()
|
||||
fast.models.list.return_value = MagicMock(data=list(models))
|
||||
client = MagicMock()
|
||||
client.with_options.return_value = fast
|
||||
return client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# probe_model_endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProbeModelEndpoint:
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_probe_success(self, mock_cc: MagicMock) -> None:
|
||||
m1 = _mock_model("model-a")
|
||||
m2 = _mock_model("model-b")
|
||||
mock_cc.return_value = _mock_client(m1, m2)
|
||||
|
||||
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
|
||||
assert result["reachable"] is True
|
||||
assert result["available_models"] == ["model-a", "model-b"]
|
||||
assert result["error"] is None
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_target_found(self, mock_cc: MagicMock) -> None:
|
||||
m1 = _mock_model("gpt-5")
|
||||
mock_cc.return_value = _mock_client(m1)
|
||||
|
||||
result = probe_model_endpoint(
|
||||
"openai", "http://localhost:8000/v1", "key", target_model="gpt-5"
|
||||
)
|
||||
assert result["model_found"] is True
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_target_not_found(self, mock_cc: MagicMock) -> None:
|
||||
m1 = _mock_model("model-a")
|
||||
mock_cc.return_value = _mock_client(m1)
|
||||
|
||||
result = probe_model_endpoint(
|
||||
"openai", "http://localhost:8000/v1", "key", target_model="gpt-5"
|
||||
)
|
||||
assert result["model_found"] is False
|
||||
assert result["available_models"] == ["model-a"]
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_no_target_model_found_is_none(self, mock_cc: MagicMock) -> None:
|
||||
m1 = _mock_model("model-a")
|
||||
mock_cc.return_value = _mock_client(m1)
|
||||
|
||||
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
|
||||
assert result["model_found"] is None
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_context_window_llama_cpp(self, mock_cc: MagicMock) -> None:
|
||||
m = _mock_model("qwen-32b", meta={"n_ctx_train": 131072})
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
|
||||
assert result["context_window"] == 131072
|
||||
assert result["server_type"] == "llama.cpp"
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_server_type_openai(self, mock_cc: MagicMock) -> None:
|
||||
m = _mock_model("gpt-5")
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint("openai", "https://api.openai.com/v1", "sk-test")
|
||||
assert result["server_type"] == "openai"
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_server_type_sglang(self, mock_cc: MagicMock) -> None:
|
||||
m = _mock_model("meta-llama/Llama-3", owned_by="sglang")
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint("openai", "http://localhost:30000/v1", "key")
|
||||
assert result["server_type"] == "sglang"
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_vllm_max_model_len(self, mock_cc: MagicMock) -> None:
|
||||
m = _mock_model("/models/nemotron", max_model_len=262144, owned_by="vllm")
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
|
||||
assert result["context_window"] == 262144
|
||||
assert result["server_type"] == "vllm"
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_vllm_max_model_len_preferred_over_meta(self, mock_cc: MagicMock) -> None:
|
||||
m = _mock_model(
|
||||
"/models/test",
|
||||
meta={"n_ctx_train": 8192},
|
||||
max_model_len=131072,
|
||||
)
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
|
||||
assert result["context_window"] == 131072
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_server_type_vllm(self, mock_cc: MagicMock) -> None:
|
||||
m = _mock_model("org/model-name")
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
|
||||
assert result["server_type"] == "vllm"
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_server_type_generic(self, mock_cc: MagicMock) -> None:
|
||||
m = _mock_model("my-model")
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
|
||||
assert result["server_type"] == "openai-compatible"
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_anthropic_provider(self, mock_cc: MagicMock) -> None:
|
||||
m = _mock_model("claude-sonnet-4-6")
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint(
|
||||
"anthropic",
|
||||
"https://api.anthropic.com",
|
||||
"sk-ant-test",
|
||||
target_model="claude-sonnet-4-6",
|
||||
)
|
||||
assert result["reachable"] is True
|
||||
assert result["server_type"] == "anthropic"
|
||||
assert result["context_window"] == 1000000
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_connection_failure(self, mock_cc: MagicMock) -> None:
|
||||
mock_cc.side_effect = OSError("Connection refused")
|
||||
|
||||
result = probe_model_endpoint("openai", "http://bad:1234/v1", "key")
|
||||
assert result["reachable"] is False
|
||||
assert "Connection refused" in (result["error"] or "")
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_empty_model_list(self, mock_cc: MagicMock) -> None:
|
||||
mock_cc.return_value = _mock_client() # no models
|
||||
|
||||
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
|
||||
assert result["reachable"] is True
|
||||
assert result["available_models"] == []
|
||||
assert "No models found" in (result["error"] or "")
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_context_window_openai_static_table(self, mock_cc: MagicMock) -> None:
|
||||
"""When base_url is api.openai.com and model is known, use static table."""
|
||||
m = _mock_model("gpt-5")
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint(
|
||||
"openai", "https://api.openai.com/v1", "sk-test", target_model="gpt-5"
|
||||
)
|
||||
assert result["context_window"] == 400000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lookup_model_capabilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLookupModelCapabilities:
|
||||
def test_known_openai_model(self) -> None:
|
||||
caps = lookup_model_capabilities("openai", "gpt-5")
|
||||
assert caps is not None
|
||||
assert caps["context_window"] == 400000
|
||||
assert caps["supports_temperature"] is False
|
||||
|
||||
def test_known_anthropic_model(self) -> None:
|
||||
caps = lookup_model_capabilities("anthropic", "claude-opus-4-6")
|
||||
assert caps is not None
|
||||
assert caps["context_window"] == 1000000
|
||||
assert caps["thinking_mode"] == "adaptive"
|
||||
|
||||
def test_unknown_model_returns_none(self) -> None:
|
||||
caps = lookup_model_capabilities("openai", "totally-unknown-model")
|
||||
assert caps is None
|
||||
|
||||
def test_tuples_converted_to_lists(self) -> None:
|
||||
caps = lookup_model_capabilities("openai", "gpt-5")
|
||||
assert caps is not None
|
||||
for val in caps.values():
|
||||
assert not isinstance(val, tuple), f"Found tuple: {val}"
|
||||
|
||||
def test_reasoning_effort_values_are_list(self) -> None:
|
||||
caps = lookup_model_capabilities("openai", "gpt-5")
|
||||
assert caps is not None
|
||||
assert isinstance(caps["reasoning_effort_values"], list)
|
||||
assert "medium" in caps["reasoning_effort_values"]
|
||||
|
||||
def test_openai_compatible_returns_none(self) -> None:
|
||||
caps = lookup_model_capabilities("openai-compatible", "my-local-model")
|
||||
assert caps is None
|
||||
|
||||
def test_invalid_provider_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="Unknown provider"):
|
||||
lookup_model_capabilities("bad-provider", "gpt-5")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_known_models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListKnownModels:
|
||||
def test_openai_models(self) -> None:
|
||||
models = list_known_models("openai")
|
||||
assert "gpt-5" in models
|
||||
assert isinstance(models, list)
|
||||
assert models == sorted(models)
|
||||
|
||||
def test_anthropic_models(self) -> None:
|
||||
models = list_known_models("anthropic")
|
||||
assert "claude-opus-4-6" in models
|
||||
|
||||
def test_openai_compatible_returns_empty(self) -> None:
|
||||
assert list_known_models("openai-compatible") == []
|
||||
|
||||
def test_unknown_provider_returns_empty(self) -> None:
|
||||
assert list_known_models("bad-provider") == []
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
from turnstone.core.model_registry import (
|
||||
ModelConfig,
|
||||
ModelRegistry,
|
||||
_resolve_env_vars,
|
||||
detect_model,
|
||||
load_model_registry,
|
||||
)
|
||||
@@ -319,6 +320,280 @@ class TestLoadModelRegistry:
|
||||
assert alt_cfg.api_key == "my-key"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_model_registry with DB storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MockStorage:
|
||||
"""Minimal storage mock returning canned model definitions."""
|
||||
|
||||
def __init__(self, rows: list[dict[str, Any]] | None = None) -> None:
|
||||
self._rows = rows or []
|
||||
self.calls: list[str] = []
|
||||
|
||||
def list_model_definitions(self, enabled_only: bool = False) -> list[dict[str, Any]]:
|
||||
self.calls.append("list_model_definitions")
|
||||
if enabled_only:
|
||||
return [r for r in self._rows if r.get("enabled", True)]
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class TestLoadModelRegistryWithDB:
|
||||
def test_db_models_loaded(self) -> None:
|
||||
"""DB model definitions are loaded into the registry."""
|
||||
storage = _MockStorage(
|
||||
[
|
||||
{
|
||||
"alias": "cloud-gpt",
|
||||
"model": "gpt-5",
|
||||
"provider": "openai",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-db",
|
||||
"context_window": 128000,
|
||||
"capabilities": "{}",
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
with patch("turnstone.core.model_registry.load_config", return_value={}):
|
||||
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
|
||||
assert reg.has_alias("cloud-gpt")
|
||||
cfg = reg.get_config("cloud-gpt")
|
||||
assert cfg.model == "gpt-5"
|
||||
assert cfg.source == "db"
|
||||
|
||||
def test_config_overrides_db(self) -> None:
|
||||
"""Config.toml entry overrides DB entry with same alias."""
|
||||
storage = _MockStorage(
|
||||
[
|
||||
{
|
||||
"alias": "shared",
|
||||
"model": "db-model",
|
||||
"provider": "openai",
|
||||
"base_url": "http://db/v1",
|
||||
"api_key": "sk-db",
|
||||
"context_window": 32768,
|
||||
"capabilities": "{}",
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
fake_cfg: dict[str, Any] = {
|
||||
"models": {
|
||||
"shared": {
|
||||
"model": "config-model",
|
||||
"base_url": "http://config/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
|
||||
cfg = reg.get_config("shared")
|
||||
assert cfg.model == "config-model"
|
||||
assert cfg.source == "config"
|
||||
|
||||
def test_db_only_models_coexist(self) -> None:
|
||||
"""DB models coexist alongside config.toml models."""
|
||||
storage = _MockStorage(
|
||||
[
|
||||
{
|
||||
"alias": "db-only",
|
||||
"model": "db-model",
|
||||
"provider": "anthropic",
|
||||
"base_url": "",
|
||||
"api_key": "sk-db",
|
||||
"context_window": 200000,
|
||||
"capabilities": "{}",
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
fake_cfg: dict[str, Any] = {
|
||||
"models": {
|
||||
"config-only": {"model": "config-model"},
|
||||
},
|
||||
}
|
||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
|
||||
assert reg.has_alias("db-only")
|
||||
assert reg.has_alias("config-only")
|
||||
assert reg.has_alias("default")
|
||||
assert reg.get_config("db-only").source == "db"
|
||||
assert reg.get_config("config-only").source == "config"
|
||||
|
||||
def test_source_field_set(self) -> None:
|
||||
"""Source field correctly distinguishes origin."""
|
||||
storage = _MockStorage(
|
||||
[
|
||||
{
|
||||
"alias": "from-db",
|
||||
"model": "m",
|
||||
"provider": "openai",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"context_window": 32768,
|
||||
"capabilities": "{}",
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
with patch("turnstone.core.model_registry.load_config", return_value={}):
|
||||
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
|
||||
assert reg.get_config("from-db").source == "db"
|
||||
assert reg.get_config("default").source == ""
|
||||
|
||||
def test_disabled_db_models_excluded(self) -> None:
|
||||
"""Disabled DB models are not loaded."""
|
||||
storage = _MockStorage(
|
||||
[
|
||||
{
|
||||
"alias": "disabled",
|
||||
"model": "m",
|
||||
"provider": "openai",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"context_window": 32768,
|
||||
"capabilities": "{}",
|
||||
"enabled": False,
|
||||
}
|
||||
]
|
||||
)
|
||||
with patch("turnstone.core.model_registry.load_config", return_value={}):
|
||||
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
|
||||
assert not reg.has_alias("disabled")
|
||||
|
||||
def test_db_capabilities_parsed(self) -> None:
|
||||
"""JSON capabilities from DB are parsed into dict."""
|
||||
storage = _MockStorage(
|
||||
[
|
||||
{
|
||||
"alias": "caps-model",
|
||||
"model": "m",
|
||||
"provider": "openai",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"context_window": 32768,
|
||||
"capabilities": '{"supports_vision": true}',
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
with patch("turnstone.core.model_registry.load_config", return_value={}):
|
||||
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
|
||||
assert reg.get_config("caps-model").capabilities == {"supports_vision": True}
|
||||
|
||||
def test_db_default_alias_not_clobbered(self) -> None:
|
||||
"""DB model with alias='default' is not overwritten by CLI args."""
|
||||
storage = _MockStorage(
|
||||
[
|
||||
{
|
||||
"alias": "default",
|
||||
"model": "db-default-model",
|
||||
"provider": "openai",
|
||||
"base_url": "http://db/v1",
|
||||
"api_key": "sk-db",
|
||||
"context_window": 128000,
|
||||
"capabilities": "{}",
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
with patch("turnstone.core.model_registry.load_config", return_value={}):
|
||||
reg = load_model_registry("http://cli/v1", "cli-key", "cli-model", storage=storage)
|
||||
cfg = reg.get_config("default")
|
||||
assert cfg.model == "db-default-model"
|
||||
assert cfg.source == "db"
|
||||
|
||||
def test_no_db_writes(self) -> None:
|
||||
"""Config.toml models are NOT written to storage."""
|
||||
storage = _MockStorage()
|
||||
fake_cfg: dict[str, Any] = {
|
||||
"models": {"local": {"model": "llama"}},
|
||||
}
|
||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||
load_model_registry("http://x/v1", "x", "x", storage=storage)
|
||||
# Only list_model_definitions should be called, no create
|
||||
assert storage.calls == ["list_model_definitions"]
|
||||
|
||||
def test_storage_failure_graceful(self) -> None:
|
||||
"""Storage errors don't prevent registry creation."""
|
||||
storage = MagicMock()
|
||||
storage.list_model_definitions.side_effect = RuntimeError("db down")
|
||||
with patch("turnstone.core.model_registry.load_config", return_value={}):
|
||||
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
|
||||
assert reg.has_alias("default")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_env_vars
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveEnvVars:
|
||||
def test_expand_single(self) -> None:
|
||||
with patch.dict("os.environ", {"MY_KEY": "secret123"}):
|
||||
assert _resolve_env_vars("sk-${MY_KEY}") == "sk-secret123"
|
||||
|
||||
def test_expand_multiple(self) -> None:
|
||||
with patch.dict("os.environ", {"A": "1", "B": "2"}):
|
||||
assert _resolve_env_vars("${A}-${B}") == "1-2"
|
||||
|
||||
def test_missing_var_empty(self) -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
assert _resolve_env_vars("${MISSING}") == ""
|
||||
|
||||
def test_no_vars(self) -> None:
|
||||
assert _resolve_env_vars("plain-key") == "plain-key"
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
assert _resolve_env_vars("") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ModelRegistry.reload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistryReload:
|
||||
def test_reload_replaces_models(self) -> None:
|
||||
models_a = {"a": ModelConfig("a", "x", "x", "m1")}
|
||||
reg = ModelRegistry(models=models_a, default="a")
|
||||
assert reg.has_alias("a")
|
||||
|
||||
models_b = {"b": ModelConfig("b", "y", "y", "m2")}
|
||||
reg.reload(models_b, "b")
|
||||
assert not reg.has_alias("a")
|
||||
assert reg.has_alias("b")
|
||||
assert reg.default == "b"
|
||||
|
||||
def test_reload_clears_clients(self) -> None:
|
||||
models = {"a": ModelConfig("a", "http://x/v1", "key", "m")}
|
||||
reg = ModelRegistry(models=models, default="a")
|
||||
# Force client creation
|
||||
reg.get_client("a")
|
||||
assert "a" in reg._clients
|
||||
|
||||
# Reload with same models — clients should be cleared
|
||||
reg.reload(dict(models), "a")
|
||||
assert "a" not in reg._clients
|
||||
|
||||
def test_reload_validates_default(self) -> None:
|
||||
models_a = {"a": ModelConfig("a", "x", "x", "m")}
|
||||
reg = ModelRegistry(models=models_a, default="a")
|
||||
with pytest.raises(ValueError, match="Default model"):
|
||||
reg.reload(models_a, "nonexistent")
|
||||
# Registry should be unchanged after failed reload
|
||||
assert reg.has_alias("a")
|
||||
assert reg.default == "a"
|
||||
|
||||
def test_reload_validates_empty(self) -> None:
|
||||
models_a = {"a": ModelConfig("a", "x", "x", "m")}
|
||||
reg = ModelRegistry(models=models_a, default="a")
|
||||
with pytest.raises(ValueError, match="at least one"):
|
||||
reg.reload({}, "a")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session integration
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -339,7 +614,7 @@ class _FakeUI:
|
||||
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id: str, name: str, output: str) -> None: ...
|
||||
def on_tool_result(self, call_id: str, name: str, output: str, **kwargs: Any) -> None: ...
|
||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
|
||||
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ...
|
||||
def on_plan_review(self, content: str) -> str:
|
||||
@@ -624,3 +899,125 @@ class TestDetectModelTimeout:
|
||||
|
||||
result = detect_model(client, provider="openai", fatal=False)
|
||||
assert result == (None, None)
|
||||
|
||||
def test_vllm_max_model_len_detected(self) -> None:
|
||||
"""detect_model() reads max_model_len from vLLM model objects."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.id = "/models/nemotron"
|
||||
mock_model.model_dump.return_value = {
|
||||
"owned_by": "vllm",
|
||||
"max_model_len": 262144,
|
||||
}
|
||||
|
||||
fast_client = MagicMock()
|
||||
fast_client.models.list.return_value = MagicMock(data=[mock_model])
|
||||
|
||||
client = MagicMock()
|
||||
client.with_options.return_value = fast_client
|
||||
|
||||
model_id, ctx = detect_model(client, provider="openai")
|
||||
assert model_id == "/models/nemotron"
|
||||
assert ctx == 262144
|
||||
|
||||
|
||||
class TestExtractContextWindow:
|
||||
def test_vllm_max_model_len(self) -> None:
|
||||
from turnstone.core.model_registry import _extract_context_window
|
||||
|
||||
m = MagicMock()
|
||||
m.id = "/models/test"
|
||||
m.model_dump.return_value = {"max_model_len": 131072}
|
||||
assert _extract_context_window(m, "openai") == 131072
|
||||
|
||||
def test_llama_cpp_meta(self) -> None:
|
||||
from turnstone.core.model_registry import _extract_context_window
|
||||
|
||||
m = MagicMock()
|
||||
m.id = "test"
|
||||
m.model_dump.return_value = {"meta": {"n_ctx_train": 8192}}
|
||||
assert _extract_context_window(m, "openai") == 8192
|
||||
|
||||
def test_vllm_preferred_over_meta(self) -> None:
|
||||
from turnstone.core.model_registry import _extract_context_window
|
||||
|
||||
m = MagicMock()
|
||||
m.id = "test"
|
||||
m.model_dump.return_value = {"max_model_len": 262144, "meta": {"n_ctx_train": 4096}}
|
||||
assert _extract_context_window(m, "openai") == 262144
|
||||
|
||||
def test_no_metadata_returns_none(self) -> None:
|
||||
from turnstone.core.model_registry import _extract_context_window
|
||||
|
||||
m = MagicMock()
|
||||
m.id = "test"
|
||||
m.model_dump.return_value = {}
|
||||
assert _extract_context_window(m, "openai") is None
|
||||
|
||||
|
||||
class TestHealthMonitorModelChange:
|
||||
def test_model_change_fires_callback(self) -> None:
|
||||
from turnstone.core.healthcheck import BackendHealthMonitor
|
||||
|
||||
changes: list[tuple[str, int | None]] = []
|
||||
|
||||
def on_change(model_id: str, ctx: int | None) -> None:
|
||||
changes.append((model_id, ctx))
|
||||
|
||||
client = MagicMock()
|
||||
monitor = BackendHealthMonitor(
|
||||
client=client,
|
||||
provider="openai",
|
||||
initial_model="model-a",
|
||||
on_model_changed=on_change,
|
||||
)
|
||||
|
||||
# Simulate probe returning a different model
|
||||
resp = MagicMock()
|
||||
m = MagicMock()
|
||||
m.id = "model-b"
|
||||
m.model_dump.return_value = {"max_model_len": 131072}
|
||||
resp.data = [m]
|
||||
|
||||
monitor._check_model_change(resp)
|
||||
assert len(changes) == 1
|
||||
assert changes[0] == ("model-b", 131072)
|
||||
assert monitor._last_detected_model == "model-b"
|
||||
|
||||
def test_same_model_no_callback(self) -> None:
|
||||
from turnstone.core.healthcheck import BackendHealthMonitor
|
||||
|
||||
changes: list[tuple[str, int | None]] = []
|
||||
|
||||
def on_change(model_id: str, ctx: int | None) -> None:
|
||||
changes.append((model_id, ctx))
|
||||
|
||||
client = MagicMock()
|
||||
monitor = BackendHealthMonitor(
|
||||
client=client,
|
||||
provider="openai",
|
||||
initial_model="model-a",
|
||||
on_model_changed=on_change,
|
||||
)
|
||||
|
||||
resp = MagicMock()
|
||||
m = MagicMock()
|
||||
m.id = "model-a"
|
||||
m.model_dump.return_value = {}
|
||||
resp.data = [m]
|
||||
|
||||
monitor._check_model_change(resp)
|
||||
assert len(changes) == 0
|
||||
|
||||
def test_no_callback_configured(self) -> None:
|
||||
from turnstone.core.healthcheck import BackendHealthMonitor
|
||||
|
||||
client = MagicMock()
|
||||
monitor = BackendHealthMonitor(client=client, initial_model="model-a")
|
||||
|
||||
resp = MagicMock()
|
||||
m = MagicMock()
|
||||
m.id = "model-b"
|
||||
resp.data = [m]
|
||||
|
||||
# Should not raise
|
||||
monitor._check_model_change(resp)
|
||||
|
||||
@@ -29,7 +29,7 @@ class NullUI:
|
||||
def approve_tools(self, items):
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id, name, output):
|
||||
def on_tool_result(self, call_id, name, output, **kwargs):
|
||||
pass
|
||||
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
|
||||
+346
-13
@@ -94,6 +94,7 @@ def _anthropic_event(
|
||||
delta.type = kwargs.get("delta_type", "text_delta")
|
||||
delta.text = kwargs.get("text", "")
|
||||
delta.thinking = kwargs.get("thinking", "")
|
||||
delta.signature = kwargs.get("signature", "")
|
||||
delta.partial_json = kwargs.get("partial_json", "")
|
||||
event.delta = delta
|
||||
event.index = kwargs.get("index", 0)
|
||||
@@ -140,6 +141,34 @@ class TestOpenAIProvider:
|
||||
def test_provider_name(self) -> None:
|
||||
assert self.provider.provider_name == "openai"
|
||||
|
||||
# -- _sanitize_messages ---------------------------------------------------
|
||||
|
||||
def test_sanitize_messages_none_content_no_tool_calls(self) -> None:
|
||||
msgs = [{"role": "assistant", "content": None}]
|
||||
assert self.provider._sanitize_messages(msgs) == [{"role": "assistant", "content": ""}]
|
||||
|
||||
def test_sanitize_messages_none_content_with_tool_calls(self) -> None:
|
||||
msgs = [{"role": "assistant", "content": None, "tool_calls": [{"id": "1"}]}]
|
||||
result = self.provider._sanitize_messages(msgs)
|
||||
assert result[0]["content"] is None
|
||||
assert result[0]["tool_calls"] == [{"id": "1"}]
|
||||
|
||||
def test_sanitize_messages_empty_string_passthrough(self) -> None:
|
||||
msgs = [{"role": "assistant", "content": ""}]
|
||||
assert self.provider._sanitize_messages(msgs) == msgs
|
||||
|
||||
def test_sanitize_messages_non_assistant_unchanged(self) -> None:
|
||||
msgs = [{"role": "user", "content": None}]
|
||||
result = self.provider._sanitize_messages(msgs)
|
||||
assert result[0]["content"] is None
|
||||
|
||||
def test_sanitize_messages_does_not_mutate_original(self) -> None:
|
||||
original = {"role": "assistant", "content": None}
|
||||
self.provider._sanitize_messages([original])
|
||||
assert original["content"] is None
|
||||
|
||||
# -- convert_tools --------------------------------------------------------
|
||||
|
||||
def test_convert_tools_passthrough(self) -> None:
|
||||
tools = [
|
||||
{
|
||||
@@ -479,10 +508,11 @@ class TestAnthropicProvider:
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "file contents"},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
assert len(converted) == 1
|
||||
assert len(converted) == 2
|
||||
blocks = converted[0]["content"]
|
||||
assert len(blocks) == 2
|
||||
assert blocks[0] == {"type": "text", "text": "Let me check that."}
|
||||
@@ -490,6 +520,8 @@ class TestAnthropicProvider:
|
||||
assert blocks[1]["id"] == "call_1"
|
||||
assert blocks[1]["name"] == "read_file"
|
||||
assert blocks[1]["input"] == {"path": "foo.py"}
|
||||
# Tool result in user message
|
||||
assert converted[1]["role"] == "user"
|
||||
|
||||
def test_message_conversion_tool_results(self) -> None:
|
||||
messages = [
|
||||
@@ -598,7 +630,11 @@ class TestAnthropicProvider:
|
||||
response.usage.output_tokens = 5
|
||||
|
||||
client = MagicMock()
|
||||
client.messages.create.return_value = response
|
||||
stream_ctx = MagicMock()
|
||||
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
|
||||
stream_ctx.__exit__ = MagicMock(return_value=False)
|
||||
stream_ctx.get_final_message.return_value = response
|
||||
client.messages.stream.return_value = stream_ctx
|
||||
|
||||
result = self.provider.create_completion(
|
||||
client=client,
|
||||
@@ -630,7 +666,11 @@ class TestAnthropicProvider:
|
||||
response.usage.output_tokens = 20
|
||||
|
||||
client = MagicMock()
|
||||
client.messages.create.return_value = response
|
||||
stream_ctx = MagicMock()
|
||||
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
|
||||
stream_ctx.__exit__ = MagicMock(return_value=False)
|
||||
stream_ctx.get_final_message.return_value = response
|
||||
client.messages.stream.return_value = stream_ctx
|
||||
|
||||
result = self.provider.create_completion(
|
||||
client=client,
|
||||
@@ -661,7 +701,11 @@ class TestAnthropicProvider:
|
||||
response.usage.output_tokens = 50
|
||||
|
||||
client = MagicMock()
|
||||
client.messages.create.return_value = response
|
||||
stream_ctx = MagicMock()
|
||||
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
|
||||
stream_ctx.__exit__ = MagicMock(return_value=False)
|
||||
stream_ctx.get_final_message.return_value = response
|
||||
client.messages.stream.return_value = stream_ctx
|
||||
|
||||
result = self.provider.create_completion(
|
||||
client=client,
|
||||
@@ -913,7 +957,7 @@ class TestAnthropicHelpers:
|
||||
|
||||
provider = AnthropicProvider()
|
||||
caps = provider.get_capabilities("claude-opus-4-6")
|
||||
assert caps.context_window == 200000
|
||||
assert caps.context_window == 1000000
|
||||
assert caps.max_output_tokens == 128000
|
||||
assert caps.thinking_mode == "adaptive"
|
||||
assert caps.supports_effort is True
|
||||
@@ -922,11 +966,11 @@ class TestAnthropicHelpers:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
provider = AnthropicProvider()
|
||||
# Prefix match: "claude-sonnet-4" matches dated variants
|
||||
caps = provider.get_capabilities("claude-sonnet-4-20260101")
|
||||
assert caps.context_window == 200000
|
||||
# Prefix match: "claude-sonnet-4-6" matches dated variants
|
||||
caps = provider.get_capabilities("claude-sonnet-4-6-20260101")
|
||||
assert caps.context_window == 1000000
|
||||
assert caps.token_param == "max_tokens"
|
||||
assert caps.thinking_mode == "manual"
|
||||
assert caps.thinking_mode == "adaptive"
|
||||
|
||||
def test_capabilities_lookup_unknown(self) -> None:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
@@ -1141,6 +1185,189 @@ class TestOpenAIParameterGating:
|
||||
assert kwargs["reasoning_effort"] == "medium" # fell back from unsupported "low"
|
||||
|
||||
|
||||
class TestAnthropicOrphanedToolUse:
|
||||
"""Verify _convert_messages synthesizes tool_results for orphaned tool_use."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
self.provider = AnthropicProvider()
|
||||
|
||||
def test_orphaned_tool_use_gets_synthetic_result(self) -> None:
|
||||
"""Assistant has tool_calls but next message is user (no tool results)."""
|
||||
messages = [
|
||||
{"role": "user", "content": "do something"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "I'll run that.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc",
|
||||
"function": {"name": "bash", "arguments": '{"command": "ls"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "never mind, do something else"},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
# Should have: user, assistant(tool_use), user(synthetic tool_result), user
|
||||
# After _merge_consecutive, the two user messages may merge.
|
||||
# Find the synthetic tool_result
|
||||
tool_results = []
|
||||
for msg in converted:
|
||||
if msg["role"] == "user" and isinstance(msg["content"], list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
tool_results.append(block)
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0]["tool_use_id"] == "call_abc"
|
||||
assert tool_results[0]["is_error"] is True
|
||||
assert "cancelled" in tool_results[0]["content"].lower()
|
||||
|
||||
def test_multiple_orphaned_tool_calls(self) -> None:
|
||||
"""Assistant has 3 tool_calls, none have results."""
|
||||
messages = [
|
||||
{"role": "user", "content": "do three things"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
{"id": "c2", "function": {"name": "read_file", "arguments": "{}"}},
|
||||
{"id": "c3", "function": {"name": "write_file", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "skip all that"},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
tool_results = []
|
||||
for msg in converted:
|
||||
if msg["role"] == "user" and isinstance(msg["content"], list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
tool_results.append(block)
|
||||
assert len(tool_results) == 3
|
||||
result_ids = {r["tool_use_id"] for r in tool_results}
|
||||
assert result_ids == {"c1", "c2", "c3"}
|
||||
|
||||
def test_partial_results_only_orphans_synthesized(self) -> None:
|
||||
"""2 tool_calls, only 1 has a result — synthesize for the missing one."""
|
||||
messages = [
|
||||
{"role": "user", "content": "do two things"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "file1.txt"},
|
||||
{"role": "user", "content": "skip the write"},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
# c1 should have a real result, c2 should have a synthetic one
|
||||
tool_results = []
|
||||
for msg in converted:
|
||||
if msg["role"] == "user" and isinstance(msg["content"], list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
tool_results.append(block)
|
||||
# Real result should come before synthetic (ordering matters for Anthropic)
|
||||
assert len(tool_results) == 2
|
||||
assert tool_results[0]["tool_use_id"] == "c1"
|
||||
assert tool_results[0]["content"] == "file1.txt" # real result
|
||||
assert tool_results[0].get("is_error") is not True
|
||||
assert tool_results[1]["tool_use_id"] == "c2"
|
||||
assert tool_results[1]["is_error"] is True # synthetic
|
||||
|
||||
def test_complete_results_no_synthesis(self) -> None:
|
||||
"""All tool_calls have results — no synthesis needed."""
|
||||
messages = [
|
||||
{"role": "user", "content": "do it"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "done"},
|
||||
{"role": "user", "content": "thanks"},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
# No synthetic results — only the real one (no is_error flag)
|
||||
tool_results = []
|
||||
for msg in converted:
|
||||
if msg["role"] == "user" and isinstance(msg["content"], list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
tool_results.append(block)
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0]["tool_use_id"] == "c1"
|
||||
assert tool_results[0].get("is_error") is not True
|
||||
|
||||
def test_trailing_orphan(self) -> None:
|
||||
"""Orphaned tool_use at end of conversation (no following messages)."""
|
||||
messages = [
|
||||
{"role": "user", "content": "do it"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Running...",
|
||||
"tool_calls": [
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
tool_results = []
|
||||
for msg in converted:
|
||||
if msg["role"] == "user" and isinstance(msg["content"], list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
tool_results.append(block)
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0]["tool_use_id"] == "c1"
|
||||
assert tool_results[0]["is_error"] is True
|
||||
|
||||
def test_provider_content_orphan(self) -> None:
|
||||
"""Orphaned tool_use inside _provider_content (Anthropic raw blocks)."""
|
||||
messages = [
|
||||
{"role": "user", "content": "run something"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Running...",
|
||||
"_provider_content": [
|
||||
{"type": "text", "text": "Running..."},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_abc",
|
||||
"name": "bash",
|
||||
"input": {"command": "sleep 30"},
|
||||
},
|
||||
],
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "toolu_abc",
|
||||
"function": {"name": "bash", "arguments": '{"command": "sleep 30"}'},
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "never mind"},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
# Should synthesize a tool_result for the orphaned tool_use in provider_content
|
||||
tool_results = []
|
||||
for msg in converted:
|
||||
if msg["role"] == "user" and isinstance(msg["content"], list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
tool_results.append(block)
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0]["tool_use_id"] == "toolu_abc"
|
||||
assert tool_results[0]["is_error"] is True
|
||||
|
||||
|
||||
class TestAnthropicReasoningNone:
|
||||
"""Verify 'none' effort disables thinking for manual-thinking models."""
|
||||
|
||||
@@ -1180,7 +1407,7 @@ class TestAnthropicWebSearch:
|
||||
"""All Anthropic models should support native web search."""
|
||||
caps = self.provider.get_capabilities("claude-opus-4-6")
|
||||
assert caps.supports_web_search is True
|
||||
caps = self.provider.get_capabilities("claude-sonnet-4")
|
||||
caps = self.provider.get_capabilities("claude-sonnet-4-6")
|
||||
assert caps.supports_web_search is True
|
||||
# Unknown models use default which also has web search
|
||||
caps = self.provider.get_capabilities("claude-unknown-99")
|
||||
@@ -1383,7 +1610,11 @@ class TestAnthropicWebSearch:
|
||||
response.usage.output_tokens = 50
|
||||
|
||||
client = MagicMock()
|
||||
client.messages.create.return_value = response
|
||||
stream_ctx = MagicMock()
|
||||
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
|
||||
stream_ctx.__exit__ = MagicMock(return_value=False)
|
||||
stream_ctx.get_final_message.return_value = response
|
||||
client.messages.stream.return_value = stream_ctx
|
||||
|
||||
with patch("turnstone.core.providers._anthropic._ensure_anthropic"):
|
||||
result = self.provider.create_completion(
|
||||
@@ -1934,6 +2165,104 @@ class TestAnthropicProviderBlocks:
|
||||
assert blocks[2]["type"] == "web_search_tool_result"
|
||||
assert blocks[2]["encrypted_content"] == "enc_data"
|
||||
|
||||
def test_streaming_thinking_block_captures_signature(self) -> None:
|
||||
"""Streaming thinking block accumulates signature from signature_delta events."""
|
||||
thinking_block = MagicMock()
|
||||
thinking_block.type = "thinking"
|
||||
thinking_block.model_dump.return_value = {
|
||||
"type": "thinking",
|
||||
"thinking": "",
|
||||
"signature": "",
|
||||
}
|
||||
|
||||
text_block = MagicMock()
|
||||
text_block.type = "text"
|
||||
text_block.model_dump.return_value = {"type": "text", "text": ""}
|
||||
|
||||
events = [
|
||||
MagicMock(type="content_block_start", index=0, content_block=thinking_block),
|
||||
_anthropic_event(
|
||||
"content_block_delta", delta_type="thinking_delta", thinking="step 1", index=0
|
||||
),
|
||||
_anthropic_event(
|
||||
"content_block_delta", delta_type="thinking_delta", thinking=" step 2", index=0
|
||||
),
|
||||
_anthropic_event(
|
||||
"content_block_delta",
|
||||
delta_type="signature_delta",
|
||||
signature="sig_part1",
|
||||
index=0,
|
||||
),
|
||||
_anthropic_event(
|
||||
"content_block_delta",
|
||||
delta_type="signature_delta",
|
||||
signature="sig_part2",
|
||||
index=0,
|
||||
),
|
||||
_anthropic_event("content_block_stop", index=0),
|
||||
MagicMock(type="content_block_start", index=1, content_block=text_block),
|
||||
_anthropic_event("content_block_delta", delta_type="text_delta", text="Hello", index=1),
|
||||
_anthropic_event("content_block_stop", index=1),
|
||||
_anthropic_event("message_delta", stop_reason="end_turn", usage_output_tokens=50),
|
||||
]
|
||||
|
||||
chunks = list(self.provider._iter_anthropic_stream(iter(events)))
|
||||
final_chunks = [c for c in chunks if c.provider_blocks]
|
||||
assert len(final_chunks) == 1
|
||||
blocks = final_chunks[0].provider_blocks
|
||||
assert blocks[0]["type"] == "thinking"
|
||||
assert blocks[0]["thinking"] == "step 1 step 2"
|
||||
assert blocks[0]["signature"] == "sig_part1sig_part2"
|
||||
|
||||
def test_thinking_block_multiturn_roundtrip(self) -> None:
|
||||
"""Thinking block with signature survives _convert_messages round-trip."""
|
||||
provider_content = [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "Let me reason...",
|
||||
"signature": "ErUBCkYIAxgCIkD_valid_sig",
|
||||
},
|
||||
{"type": "text", "text": "Here is my answer."},
|
||||
]
|
||||
messages = [
|
||||
{"role": "user", "content": "Question"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Here is my answer.",
|
||||
"_provider_content": provider_content,
|
||||
},
|
||||
{"role": "user", "content": "Follow up"},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
assistant_msg = converted[1]
|
||||
assert assistant_msg["content"] is provider_content
|
||||
assert assistant_msg["content"][0]["signature"] == "ErUBCkYIAxgCIkD_valid_sig"
|
||||
assert assistant_msg["content"][0]["type"] == "thinking"
|
||||
|
||||
def test_block_to_dict_preserves_thinking_signature(self) -> None:
|
||||
"""_block_to_dict preserves signature on thinking blocks."""
|
||||
from turnstone.core.providers._anthropic import _block_to_dict
|
||||
|
||||
class FakeThinkingBlock:
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "thinking",
|
||||
"thinking": "reasoning...",
|
||||
"signature": "abc123sig",
|
||||
}
|
||||
|
||||
result = _block_to_dict(FakeThinkingBlock())
|
||||
assert result["signature"] == "abc123sig"
|
||||
|
||||
# Also test fallback path (no model_dump)
|
||||
class FallbackBlock:
|
||||
type = "thinking"
|
||||
thinking = "reasoning..."
|
||||
signature = "abc123sig"
|
||||
|
||||
result2 = _block_to_dict(FallbackBlock())
|
||||
assert result2["signature"] == "abc123sig"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool search tests
|
||||
@@ -2307,7 +2636,11 @@ class TestAnthropicPromptCaching:
|
||||
response.usage = usage
|
||||
|
||||
client = MagicMock()
|
||||
client.messages.create.return_value = response
|
||||
stream_ctx = MagicMock()
|
||||
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
|
||||
stream_ctx.__exit__ = MagicMock(return_value=False)
|
||||
stream_ctx.get_final_message.return_value = response
|
||||
client.messages.stream.return_value = stream_ctx
|
||||
|
||||
result = self.provider.create_completion(
|
||||
client=client,
|
||||
|
||||
@@ -9,13 +9,12 @@ def _row(
|
||||
role,
|
||||
content=None,
|
||||
tool_name=None,
|
||||
tool_args=None,
|
||||
tc_id=None,
|
||||
pdata=None,
|
||||
tool_calls=None,
|
||||
):
|
||||
"""Build a 7-element conversation row tuple (post-migration 013 format)."""
|
||||
return (role, content, tool_name, tool_args, tc_id, pdata, tool_calls)
|
||||
"""Build a 6-element conversation row tuple (post-migration 027 format)."""
|
||||
return (role, content, tool_name, tc_id, pdata, tool_calls)
|
||||
|
||||
|
||||
class TestAssistantWithToolCalls:
|
||||
@@ -227,3 +226,91 @@ class TestEdgeCases:
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["role"] == "user"
|
||||
assert msgs[1]["role"] == "assistant"
|
||||
|
||||
|
||||
class TestMidConversationOrphanRepair:
|
||||
"""Mid-conversation orphaned tool_calls get synthetic tool results."""
|
||||
|
||||
def test_all_orphaned_mid_conversation(self):
|
||||
"""Assistant has 2 tool_calls, no tool results, then user message."""
|
||||
tc = json.dumps(
|
||||
[
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
_row("user", "do stuff"),
|
||||
_row("assistant", "Running...", tool_calls=tc),
|
||||
_row("user", "never mind"),
|
||||
]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
# Should have: user, assistant, tool(c1), tool(c2), user
|
||||
assert len(msgs) == 5
|
||||
assert msgs[2]["role"] == "tool"
|
||||
assert msgs[2]["tool_call_id"] == "c1"
|
||||
assert msgs[2]["is_error"] is True
|
||||
assert msgs[3]["role"] == "tool"
|
||||
assert msgs[3]["tool_call_id"] == "c2"
|
||||
assert msgs[4]["role"] == "user"
|
||||
|
||||
def test_partial_results_mid_conversation(self):
|
||||
"""2 tool_calls, 1 result present, 1 missing — synthesize only the missing one."""
|
||||
tc = json.dumps(
|
||||
[
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
_row("user", "do stuff"),
|
||||
_row("assistant", "", tool_calls=tc),
|
||||
_row("tool", "file1.txt", tool_name="bash", tc_id="c1"),
|
||||
_row("user", "skip the write"),
|
||||
]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
# Should have: user, assistant, tool(c1 real), tool(c2 synthetic), user
|
||||
assert len(msgs) == 5
|
||||
assert msgs[2]["role"] == "tool"
|
||||
assert msgs[2]["tool_call_id"] == "c1"
|
||||
assert msgs[2]["content"] == "file1.txt"
|
||||
assert msgs[2].get("is_error") is not True
|
||||
assert msgs[3]["role"] == "tool"
|
||||
assert msgs[3]["tool_call_id"] == "c2"
|
||||
assert msgs[3]["is_error"] is True
|
||||
assert msgs[4]["role"] == "user"
|
||||
|
||||
def test_complete_results_no_synthesis(self):
|
||||
"""All tool_calls have results — no synthesis needed."""
|
||||
tc = json.dumps(
|
||||
[
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
_row("user", "do it"),
|
||||
_row("assistant", "", tool_calls=tc),
|
||||
_row("tool", "done", tool_name="bash", tc_id="c1"),
|
||||
_row("user", "thanks"),
|
||||
]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
assert len(msgs) == 4
|
||||
tool_msgs = [m for m in msgs if m["role"] == "tool"]
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0].get("is_error") is not True
|
||||
|
||||
def test_trailing_orphan_stripped_not_synthesized(self):
|
||||
"""Trailing orphan is handled by the existing strip repair, not synthesis."""
|
||||
tc = json.dumps(
|
||||
[
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
_row("user", "do it"),
|
||||
_row("assistant", "Running...", tool_calls=tc),
|
||||
]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
# Trailing strip removes the assistant message entirely
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["role"] == "user"
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Tests for conversation rewind and retry functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class NullUI:
|
||||
"""UI adapter that discards all output."""
|
||||
|
||||
def on_thinking_start(self):
|
||||
pass
|
||||
|
||||
def on_thinking_stop(self):
|
||||
pass
|
||||
|
||||
def on_reasoning_token(self, text):
|
||||
pass
|
||||
|
||||
def on_content_token(self, text):
|
||||
pass
|
||||
|
||||
def on_stream_end(self):
|
||||
pass
|
||||
|
||||
def approve_tools(self, items):
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id, name, output, **kwargs):
|
||||
pass
|
||||
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
pass
|
||||
|
||||
def on_status(self, usage, context_window, effort):
|
||||
pass
|
||||
|
||||
def on_plan_review(self, content):
|
||||
return ""
|
||||
|
||||
def on_info(self, message):
|
||||
pass
|
||||
|
||||
def on_error(self, message):
|
||||
pass
|
||||
|
||||
def on_state_change(self, state):
|
||||
pass
|
||||
|
||||
def on_rename(self, name):
|
||||
pass
|
||||
|
||||
def on_output_warning(self, call_id, assessment):
|
||||
pass
|
||||
|
||||
|
||||
def _make_session(tmp_db) -> ChatSession:
|
||||
return ChatSession(
|
||||
client=MagicMock(),
|
||||
model="test-model",
|
||||
ui=NullUI(),
|
||||
instructions="",
|
||||
temperature=0.5,
|
||||
max_tokens=4096,
|
||||
tool_timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def _populate_simple(session: ChatSession) -> None:
|
||||
"""Populate with 2 simple turns (no tool calls)."""
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
{"role": "assistant", "content": "I'm fine."},
|
||||
]
|
||||
session._msg_tokens = [10, 20, 10, 20]
|
||||
|
||||
|
||||
def _populate_with_tools(session: ChatSession) -> None:
|
||||
"""Populate with 2 turns, first has tool calls."""
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Write a test"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "tc1", "function": {"name": "bash", "arguments": '{"cmd":"echo hi"}'}}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tc1", "content": "hi"},
|
||||
{"role": "assistant", "content": "Done."},
|
||||
{"role": "user", "content": "Fix the import"},
|
||||
{"role": "assistant", "content": "Fixed."},
|
||||
]
|
||||
session._msg_tokens = [10, 20, 10, 20, 10, 20]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_turn_boundaries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindTurnBoundaries:
|
||||
def test_empty_messages(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
assert session._find_turn_boundaries() == []
|
||||
|
||||
def test_single_turn(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
]
|
||||
assert session._find_turn_boundaries() == [0]
|
||||
|
||||
def test_multi_turn(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
assert session._find_turn_boundaries() == [0, 2]
|
||||
|
||||
def test_with_tool_calls(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_with_tools(session)
|
||||
assert session._find_turn_boundaries() == [0, 4]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rewind
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRewind:
|
||||
def test_rewind_zero(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
assert session.rewind(0) == 0
|
||||
assert len(session.messages) == 4
|
||||
|
||||
def test_rewind_one_turn(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
removed = session.rewind(1)
|
||||
assert removed == 2 # user + assistant
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[0]["content"] == "Hello"
|
||||
assert session.messages[1]["content"] == "Hi there!"
|
||||
assert len(session._msg_tokens) == 2
|
||||
|
||||
def test_rewind_all_turns(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
removed = session.rewind(2)
|
||||
assert removed == 4
|
||||
assert len(session.messages) == 0
|
||||
assert len(session._msg_tokens) == 0
|
||||
|
||||
def test_rewind_clamped(self, tmp_db):
|
||||
"""Rewinding more turns than exist should clamp to available."""
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
removed = session.rewind(999)
|
||||
assert removed == 4
|
||||
assert len(session.messages) == 0
|
||||
|
||||
def test_rewind_empty(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
assert session.rewind(1) == 0
|
||||
|
||||
def test_rewind_with_tools(self, tmp_db):
|
||||
"""Rewinding 1 turn on a multi-sub-turn conversation."""
|
||||
session = _make_session(tmp_db)
|
||||
_populate_with_tools(session)
|
||||
removed = session.rewind(1)
|
||||
assert removed == 2 # user "Fix the import" + assistant "Fixed."
|
||||
assert len(session.messages) == 4
|
||||
assert session.messages[-1]["content"] == "Done."
|
||||
|
||||
def test_rewind_tokens_sync(self, tmp_db):
|
||||
"""_msg_tokens stays in sync with messages."""
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
session.rewind(1)
|
||||
assert len(session._msg_tokens) == len(session.messages)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# retry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetry:
|
||||
def test_retry_returns_user_message(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
msg = session.retry()
|
||||
assert msg == "How are you?"
|
||||
# Only Turn 1 remains, without the second user message
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[-1]["content"] == "Hi there!"
|
||||
|
||||
def test_retry_empty(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
assert session.retry() is None
|
||||
|
||||
def test_retry_with_tools(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_with_tools(session)
|
||||
msg = session.retry()
|
||||
assert msg == "Fix the import"
|
||||
# Only Turn 1 remains (user + assistant w/tools + tool result + assistant)
|
||||
assert len(session.messages) == 4
|
||||
|
||||
def test_retry_sets_pending(self, tmp_db):
|
||||
"""handle_command for /retry should set _pending_retry."""
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
session.handle_command("/retry")
|
||||
assert session._pending_retry == "How are you?"
|
||||
|
||||
def test_retry_tokens_sync(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
session.retry()
|
||||
assert len(session._msg_tokens) == len(session.messages)
|
||||
|
||||
def test_retry_multipart_content_returns_none(self, tmp_db):
|
||||
"""retry() should refuse multipart (vision/image) messages."""
|
||||
session = _make_session(tmp_db)
|
||||
session.messages = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "describe this"}]},
|
||||
{"role": "assistant", "content": "It's an image."},
|
||||
]
|
||||
session._msg_tokens = [10, 20]
|
||||
assert session.retry() is None
|
||||
# Messages should be unchanged
|
||||
assert len(session.messages) == 2
|
||||
|
||||
def test_retry_none_content_returns_none(self, tmp_db):
|
||||
"""retry() should handle content=None gracefully."""
|
||||
session = _make_session(tmp_db)
|
||||
session.messages = [
|
||||
{"role": "user", "content": None},
|
||||
{"role": "assistant", "content": "Ok."},
|
||||
]
|
||||
session._msg_tokens = [10, 20]
|
||||
assert session.retry() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle_command integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
def test_rewind_command(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
session.handle_command("/rewind 1")
|
||||
assert len(session.messages) == 2
|
||||
|
||||
def test_rewind_no_arg(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
ui = session.ui
|
||||
ui.on_info = MagicMock()
|
||||
session.handle_command("/rewind")
|
||||
ui.on_info.assert_called_once()
|
||||
assert "Usage" in ui.on_info.call_args[0][0]
|
||||
|
||||
def test_rewind_invalid_arg(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
ui = session.ui
|
||||
ui.on_info = MagicMock()
|
||||
session.handle_command("/rewind abc")
|
||||
ui.on_info.assert_called_once()
|
||||
assert "integer" in ui.on_info.call_args[0][0]
|
||||
|
||||
def test_retry_nothing_to_retry(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
ui = session.ui
|
||||
ui.on_info = MagicMock()
|
||||
session.handle_command("/retry")
|
||||
ui.on_info.assert_called_once()
|
||||
assert "Nothing" in ui.on_info.call_args[0][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage integration — delete_messages_after
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteMessagesAfter:
|
||||
def test_delete_truncates_db(self, tmp_db):
|
||||
from turnstone.core.memory import (
|
||||
delete_messages_after,
|
||||
load_messages,
|
||||
register_workstream,
|
||||
save_message,
|
||||
)
|
||||
|
||||
ws_id = "test-ws-delete"
|
||||
register_workstream(ws_id)
|
||||
save_message(ws_id, "user", "Hello")
|
||||
save_message(ws_id, "assistant", "Hi!")
|
||||
save_message(ws_id, "user", "Bye")
|
||||
save_message(ws_id, "assistant", "Goodbye!")
|
||||
|
||||
deleted = delete_messages_after(ws_id, 2)
|
||||
assert deleted == 2
|
||||
|
||||
msgs = load_messages(ws_id)
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["content"] == "Hello"
|
||||
assert msgs[1]["content"] == "Hi!"
|
||||
|
||||
def test_delete_nothing(self, tmp_db):
|
||||
from turnstone.core.memory import (
|
||||
delete_messages_after,
|
||||
register_workstream,
|
||||
save_message,
|
||||
)
|
||||
|
||||
ws_id = "test-ws-noop"
|
||||
register_workstream(ws_id)
|
||||
save_message(ws_id, "user", "Hello")
|
||||
|
||||
deleted = delete_messages_after(ws_id, 10)
|
||||
assert deleted == 0
|
||||
|
||||
def test_delete_all(self, tmp_db):
|
||||
from turnstone.core.memory import (
|
||||
delete_messages_after,
|
||||
load_messages,
|
||||
register_workstream,
|
||||
save_message,
|
||||
)
|
||||
|
||||
ws_id = "test-ws-all"
|
||||
register_workstream(ws_id)
|
||||
save_message(ws_id, "user", "Hello")
|
||||
save_message(ws_id, "assistant", "Hi!")
|
||||
|
||||
deleted = delete_messages_after(ws_id, 0)
|
||||
assert deleted == 2
|
||||
assert load_messages(ws_id) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: rewind + DB sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRewindDBSync:
|
||||
def test_rewind_persists_to_db(self, tmp_db):
|
||||
from turnstone.core.memory import load_messages, register_workstream, save_message
|
||||
|
||||
session = _make_session(tmp_db)
|
||||
ws_id = session.ws_id
|
||||
register_workstream(ws_id)
|
||||
|
||||
# Persist messages to DB and set in-memory state
|
||||
save_message(ws_id, "user", "Hello")
|
||||
save_message(ws_id, "assistant", "Hi!")
|
||||
save_message(ws_id, "user", "Bye")
|
||||
save_message(ws_id, "assistant", "Goodbye!")
|
||||
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
{"role": "user", "content": "Bye"},
|
||||
{"role": "assistant", "content": "Goodbye!"},
|
||||
]
|
||||
session._msg_tokens = [5, 5, 5, 5]
|
||||
|
||||
session.rewind(1)
|
||||
|
||||
# Verify DB matches in-memory state
|
||||
db_msgs = load_messages(ws_id)
|
||||
assert len(db_msgs) == 2
|
||||
assert db_msgs[0]["content"] == "Hello"
|
||||
assert db_msgs[1]["content"] == "Hi!"
|
||||
@@ -88,7 +88,7 @@ class RecordingUI:
|
||||
def approve_tools(self, items):
|
||||
return True, None # auto-approve everything
|
||||
|
||||
def on_tool_result(self, call_id, name, output):
|
||||
def on_tool_result(self, call_id, name, output, **kwargs):
|
||||
self.tool_results.append((call_id, name, output))
|
||||
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
|
||||
@@ -28,7 +28,7 @@ class NullUI:
|
||||
def approve_tools(self, items):
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id, name, output):
|
||||
def on_tool_result(self, call_id, name, output, **kwargs):
|
||||
pass
|
||||
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
|
||||
@@ -525,6 +525,37 @@ class TestWorkstreamConfig:
|
||||
assert session.instructions == "be concise"
|
||||
assert session.creative_mode is True
|
||||
|
||||
def test_resume_restores_model(self, tmp_db):
|
||||
"""ChatSession.resume() should restore the model from workstream config."""
|
||||
client = MagicMock()
|
||||
client.models.list.return_value.data = [MagicMock(id="test-model")]
|
||||
ui = MagicMock()
|
||||
ui.on_info = MagicMock()
|
||||
ui.on_error = MagicMock()
|
||||
ui.on_state_change = MagicMock()
|
||||
ui.on_rename = MagicMock()
|
||||
|
||||
# Create a workstream that was using a specific model
|
||||
register_workstream("model_ws")
|
||||
save_message("model_ws", "user", "hello")
|
||||
save_message("model_ws", "assistant", "hi")
|
||||
save_workstream_config("model_ws", {"model": "gpt-5", "model_alias": ""})
|
||||
|
||||
# Resume into a session that was created with a different model
|
||||
session = ChatSession(
|
||||
client=client,
|
||||
model="gpt-5-nano",
|
||||
ui=ui,
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
assert session.model == "gpt-5-nano"
|
||||
result = session.resume("model_ws")
|
||||
assert result is True
|
||||
assert session.model == "gpt-5"
|
||||
|
||||
|
||||
# ── Prune workstreams ─────────────────────────────────────────────────
|
||||
|
||||
@@ -699,6 +730,7 @@ class TestWebSearchGating:
|
||||
with (
|
||||
patch.object(session, "_get_capabilities", return_value=caps),
|
||||
patch("turnstone.core.session.get_tavily_key", return_value=None),
|
||||
patch("turnstone.core.web_search._ddg_available", return_value=False),
|
||||
):
|
||||
tools = session._get_active_tools()
|
||||
|
||||
@@ -754,3 +786,150 @@ class TestWebSearchGating:
|
||||
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "web_search" in names
|
||||
|
||||
|
||||
class TestMCPToolGating:
|
||||
"""MCP tools should not be offered when no MCP servers provide them."""
|
||||
|
||||
def test_mcp_tools_filtered_without_mcp_client(self, tmp_db, mock_openai_client):
|
||||
"""read_resource and use_prompt excluded when no MCP client."""
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
assert session._mcp_client is None
|
||||
|
||||
tools = session._get_active_tools()
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "read_resource" not in names
|
||||
assert "use_prompt" not in names
|
||||
|
||||
def test_read_resource_filtered_when_no_resources(self, tmp_db, mock_openai_client):
|
||||
"""read_resource excluded when MCP client has no resources."""
|
||||
mcp_client = MagicMock()
|
||||
mcp_client.get_tools.return_value = []
|
||||
mcp_client.resource_count = 0
|
||||
mcp_client.prompt_count = 2
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
mcp_client=mcp_client,
|
||||
)
|
||||
|
||||
tools = session._get_active_tools()
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "read_resource" not in names
|
||||
assert "use_prompt" in names
|
||||
|
||||
def test_use_prompt_filtered_when_no_prompts(self, tmp_db, mock_openai_client):
|
||||
"""use_prompt excluded when MCP client has no prompts."""
|
||||
mcp_client = MagicMock()
|
||||
mcp_client.get_tools.return_value = []
|
||||
mcp_client.resource_count = 3
|
||||
mcp_client.prompt_count = 0
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
mcp_client=mcp_client,
|
||||
)
|
||||
|
||||
tools = session._get_active_tools()
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "use_prompt" not in names
|
||||
assert "read_resource" in names
|
||||
|
||||
def test_mcp_tools_kept_when_servers_have_both(self, tmp_db, mock_openai_client):
|
||||
"""Both tools present when MCP client has resources and prompts."""
|
||||
mcp_client = MagicMock()
|
||||
mcp_client.get_tools.return_value = []
|
||||
mcp_client.resource_count = 1
|
||||
mcp_client.prompt_count = 1
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
mcp_client=mcp_client,
|
||||
)
|
||||
|
||||
tools = session._get_active_tools()
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "read_resource" in names
|
||||
assert "use_prompt" in names
|
||||
|
||||
def test_mcp_tools_filtered_with_tool_search_active(self, tmp_db, mock_openai_client):
|
||||
"""Gating applies even when tool_search is active (client-side path)."""
|
||||
mcp_client = MagicMock()
|
||||
mcp_client.get_tools.return_value = []
|
||||
mcp_client.resource_count = 0
|
||||
mcp_client.prompt_count = 0
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
mcp_client=mcp_client,
|
||||
tool_search="on",
|
||||
)
|
||||
assert session._tool_search is not None
|
||||
|
||||
tools = session._get_active_tools()
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "read_resource" not in names
|
||||
assert "use_prompt" not in names
|
||||
|
||||
def test_mcp_tools_filtered_with_native_tool_search(self, tmp_db, mock_openai_client):
|
||||
"""Gating applies when provider handles tool search natively."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
mcp_client = MagicMock()
|
||||
mcp_client.get_tools.return_value = []
|
||||
mcp_client.resource_count = 0
|
||||
mcp_client.prompt_count = 0
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
mcp_client=mcp_client,
|
||||
tool_search="on",
|
||||
)
|
||||
|
||||
caps = ModelCapabilities(supports_tool_search=True)
|
||||
with patch.object(session, "_get_capabilities", return_value=caps):
|
||||
tools = session._get_active_tools()
|
||||
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "read_resource" not in names
|
||||
assert "use_prompt" not in names
|
||||
|
||||
@@ -254,20 +254,46 @@ class TestSecretMasking:
|
||||
by_key = {s["key"]: s for s in r.json()["settings"]}
|
||||
assert by_key["judge.api_key"]["value"] == "***"
|
||||
|
||||
def test_secret_write_blocked(self, client):
|
||||
"""Secret settings cannot be modified via API."""
|
||||
def test_secret_writable_via_api(self, client):
|
||||
"""Secret settings can be written via API (write-only pattern)."""
|
||||
r = client.put(
|
||||
"/v1/api/admin/settings/judge.api_key",
|
||||
json={"value": "sk-secret-123"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert "config.toml" in r.json()["error"]
|
||||
assert r.status_code == 200
|
||||
# Response value is masked even for the write confirmation
|
||||
assert r.json()["value"] == "***"
|
||||
|
||||
def test_secret_shows_managed_label(self, client):
|
||||
"""Secret settings show a label instead of a value."""
|
||||
def test_secret_sentinel_preserves_existing(self, client):
|
||||
"""Submitting '***' for a secret setting is a no-op (preserve existing)."""
|
||||
# First write a real value
|
||||
r1 = client.put(
|
||||
"/v1/api/admin/settings/judge.api_key",
|
||||
json={"value": "sk-real-key"},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
# Now submit the sentinel — should return unchanged with full response shape
|
||||
r2 = client.put(
|
||||
"/v1/api/admin/settings/judge.api_key",
|
||||
json={"value": "***"},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
data = r2.json()
|
||||
assert data.get("unchanged") is True
|
||||
assert data["key"] == "judge.api_key"
|
||||
assert data["value"] == "***"
|
||||
assert data["type"] == "str"
|
||||
assert data["is_secret"] is True
|
||||
|
||||
def test_secret_still_masked_in_list(self, client):
|
||||
"""After writing a secret, list still shows '***'."""
|
||||
client.put(
|
||||
"/v1/api/admin/settings/judge.api_key",
|
||||
json={"value": "sk-written-via-api"},
|
||||
)
|
||||
r = client.get("/v1/api/admin/settings")
|
||||
by_key = {s["key"]: s for s in r.json()["settings"]}
|
||||
assert "managed via" in by_key["judge.api_key"]["value"]
|
||||
assert by_key["judge.api_key"]["value"] == "***"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -52,7 +52,7 @@ class NullUI:
|
||||
def approve_tools(self, items):
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id, name, output):
|
||||
def on_tool_result(self, call_id, name, output, **kwargs):
|
||||
pass
|
||||
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from turnstone.core.memory import (
|
||||
count_structured_memories,
|
||||
delete_structured_memory,
|
||||
get_structured_memory_by_name,
|
||||
list_structured_memories,
|
||||
normalize_key,
|
||||
save_structured_memory,
|
||||
@@ -67,6 +68,29 @@ class TestSearchStructuredMemories:
|
||||
assert any(r["name"] == "db_host" for r in results)
|
||||
|
||||
|
||||
class TestGetStructuredMemoryByName:
|
||||
def test_get_existing(self, tmp_db):
|
||||
save_structured_memory("my_mem", "full content here that is quite long")
|
||||
mem = get_structured_memory_by_name("my_mem", "global", "")
|
||||
assert mem is not None
|
||||
assert mem["content"] == "full content here that is quite long"
|
||||
assert mem["name"] == "my_mem"
|
||||
|
||||
def test_get_nonexistent(self, tmp_db):
|
||||
assert get_structured_memory_by_name("nope", "global", "") is None
|
||||
|
||||
def test_get_wrong_scope(self, tmp_db):
|
||||
save_structured_memory("ws_mem", "data", scope="workstream", scope_id="ws1")
|
||||
assert get_structured_memory_by_name("ws_mem", "global", "") is None
|
||||
assert get_structured_memory_by_name("ws_mem", "workstream", "ws1") is not None
|
||||
|
||||
def test_get_normalizes_key(self, tmp_db):
|
||||
save_structured_memory("My-Key", "value")
|
||||
mem = get_structured_memory_by_name("My-Key", "global", "")
|
||||
assert mem is not None
|
||||
assert mem["name"] == "my_key"
|
||||
|
||||
|
||||
class TestCountStructuredMemories:
|
||||
def test_count_zero(self, tmp_db):
|
||||
assert count_structured_memories() == 0
|
||||
@@ -80,3 +104,109 @@ class TestCountStructuredMemories:
|
||||
class TestNormalizeKey:
|
||||
def test_basic(self):
|
||||
assert normalize_key("My-Key Name") == "my_key_name"
|
||||
|
||||
|
||||
class TestScopeIsolation:
|
||||
"""Verify that list/search without scope only returns visible memories.
|
||||
|
||||
Reproduces the cross-workstream leak: unscoped list/search must not
|
||||
return workstream-scoped memories from other workstreams or
|
||||
user-scoped memories from other users.
|
||||
"""
|
||||
|
||||
def _seed(self):
|
||||
"""Create memories across multiple scopes."""
|
||||
save_structured_memory("global_note", "visible to all", scope="global")
|
||||
save_structured_memory("ws1_note", "belongs to ws1", scope="workstream", scope_id="ws1")
|
||||
save_structured_memory("ws2_note", "belongs to ws2", scope="workstream", scope_id="ws2")
|
||||
save_structured_memory("u1_note", "belongs to user1", scope="user", scope_id="u1")
|
||||
save_structured_memory("u2_note", "belongs to user2", scope="user", scope_id="u2")
|
||||
|
||||
@staticmethod
|
||||
def _list_visible(ws_id: str, user_id: str, mem_type: str = "", limit: int = 50):
|
||||
"""Replicate the scope-filtered list logic from ChatSession."""
|
||||
global_mems = list_structured_memories(mem_type=mem_type, scope="global", limit=limit)
|
||||
ws_mems = list_structured_memories(
|
||||
mem_type=mem_type, scope="workstream", scope_id=ws_id, limit=limit
|
||||
)
|
||||
user_mems = (
|
||||
list_structured_memories(mem_type=mem_type, scope="user", scope_id=user_id, limit=limit)
|
||||
if user_id
|
||||
else []
|
||||
)
|
||||
combined = global_mems + ws_mems + user_mems
|
||||
combined.sort(key=lambda m: m.get("updated", ""), reverse=True)
|
||||
return combined[:limit]
|
||||
|
||||
@staticmethod
|
||||
def _search_visible(query: str, ws_id: str, user_id: str, mem_type: str = "", limit: int = 20):
|
||||
"""Replicate the scope-filtered search logic from ChatSession."""
|
||||
global_mems = search_structured_memories(
|
||||
query, mem_type=mem_type, scope="global", limit=limit
|
||||
)
|
||||
ws_mems = search_structured_memories(
|
||||
query, mem_type=mem_type, scope="workstream", scope_id=ws_id, limit=limit
|
||||
)
|
||||
user_mems = (
|
||||
search_structured_memories(
|
||||
query, mem_type=mem_type, scope="user", scope_id=user_id, limit=limit
|
||||
)
|
||||
if user_id
|
||||
else []
|
||||
)
|
||||
combined = global_mems + ws_mems + user_mems
|
||||
combined.sort(key=lambda m: m.get("updated", ""), reverse=True)
|
||||
return combined[:limit]
|
||||
|
||||
def test_unscoped_list_returns_all_scopes(self, tmp_db):
|
||||
"""Demonstrate the leak: unscoped list returns everything."""
|
||||
self._seed()
|
||||
all_mems = list_structured_memories()
|
||||
assert len(all_mems) == 5 # no scope filter → all memories
|
||||
|
||||
def test_visible_list_excludes_other_workstreams(self, tmp_db):
|
||||
"""Scope-filtered list for ws1/u1 excludes ws2 and u2 memories."""
|
||||
self._seed()
|
||||
visible = self._list_visible("ws1", "u1")
|
||||
names = {m["name"] for m in visible}
|
||||
assert "global_note" in names
|
||||
assert "ws1_note" in names
|
||||
assert "u1_note" in names
|
||||
assert "ws2_note" not in names
|
||||
assert "u2_note" not in names
|
||||
|
||||
def test_visible_list_no_user(self, tmp_db):
|
||||
"""Scope-filtered list with no user_id excludes all user memories."""
|
||||
self._seed()
|
||||
visible = self._list_visible("ws1", "")
|
||||
names = {m["name"] for m in visible}
|
||||
assert "global_note" in names
|
||||
assert "ws1_note" in names
|
||||
assert "u1_note" not in names
|
||||
assert "u2_note" not in names
|
||||
|
||||
def test_visible_search_no_user(self, tmp_db):
|
||||
"""Scope-filtered search with no user_id excludes all user memories."""
|
||||
self._seed()
|
||||
visible = self._search_visible("belongs", "ws1", "")
|
||||
names = {m["name"] for m in visible}
|
||||
assert "ws1_note" in names
|
||||
assert "u1_note" not in names
|
||||
assert "u2_note" not in names
|
||||
|
||||
def test_visible_search_excludes_other_workstreams(self, tmp_db):
|
||||
"""Scope-filtered search for ws1/u1 excludes ws2 and u2 memories."""
|
||||
self._seed()
|
||||
visible = self._search_visible("belongs", "ws1", "u1")
|
||||
names = {m["name"] for m in visible}
|
||||
assert "ws1_note" in names
|
||||
assert "u1_note" in names
|
||||
assert "ws2_note" not in names
|
||||
assert "u2_note" not in names
|
||||
|
||||
def test_explicit_scope_still_works(self, tmp_db):
|
||||
"""Explicit scope filter continues to work as before."""
|
||||
self._seed()
|
||||
ws2_only = list_structured_memories(scope="workstream", scope_id="ws2")
|
||||
assert len(ws2_only) == 1
|
||||
assert ws2_only[0]["name"] == "ws2_note"
|
||||
|
||||
@@ -72,18 +72,19 @@ class TestToolsMetadata:
|
||||
"""Validate the metadata extracted from JSON files."""
|
||||
|
||||
def test_tool_count(self):
|
||||
assert len(TOOLS) == 18
|
||||
assert len(TOOLS) == 19
|
||||
|
||||
def test_agent_tools_count(self):
|
||||
assert len(AGENT_TOOLS) == 9
|
||||
assert len(AGENT_TOOLS) == 10
|
||||
|
||||
def test_task_agent_tools_count(self):
|
||||
assert len(TASK_AGENT_TOOLS) == 12
|
||||
assert len(TASK_AGENT_TOOLS) == 13
|
||||
|
||||
def test_auto_approve_sets_match(self):
|
||||
expected = {
|
||||
"read_file",
|
||||
"search",
|
||||
"diff_file",
|
||||
"math",
|
||||
"man",
|
||||
"web_fetch",
|
||||
@@ -113,6 +114,7 @@ class TestToolsMetadata:
|
||||
"read_resource": "uri",
|
||||
"use_prompt": "name",
|
||||
"skill": "name",
|
||||
"diff_file": "path_a",
|
||||
}
|
||||
assert expected == PRIMARY_KEY_MAP
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for _make_watch_dispatch error/cancel handling and concurrency guards."""
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
|
||||
from turnstone.core.session import GenerationCancelled
|
||||
from turnstone.core.workstream import Workstream
|
||||
from turnstone.server import _make_watch_dispatch
|
||||
|
||||
|
||||
class _StubSession:
|
||||
"""Minimal ChatSession stand-in with controllable send() behaviour."""
|
||||
|
||||
def __init__(self, *, side_effect=None):
|
||||
self._watch_pending: queue.Queue = queue.Queue(maxsize=20)
|
||||
self._side_effect = side_effect
|
||||
|
||||
def send(self, msg: str) -> None:
|
||||
if self._side_effect is not None:
|
||||
raise self._side_effect
|
||||
|
||||
|
||||
class _RecordingUI:
|
||||
"""Track calls made by the dispatch error handlers."""
|
||||
|
||||
def __init__(self):
|
||||
self.errors: list[str] = []
|
||||
self.state_changes: list[str] = []
|
||||
self.stream_end_calls: int = 0
|
||||
|
||||
# -- SessionUI protocol stubs used by the dispatch code --
|
||||
|
||||
def on_error(self, message: str) -> None:
|
||||
self.errors.append(message)
|
||||
|
||||
def on_state_change(self, state: str) -> None:
|
||||
self.state_changes.append(state)
|
||||
|
||||
def on_stream_end(self) -> None:
|
||||
self.stream_end_calls += 1
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _wait_for_worker(ws: Workstream, timeout: float = 2.0) -> None:
|
||||
"""Block until the worker thread started by dispatch() finishes."""
|
||||
t = ws.worker_thread
|
||||
if t is not None:
|
||||
t.join(timeout)
|
||||
assert not t.is_alive(), "worker thread did not finish in time"
|
||||
|
||||
|
||||
# ── GenerationCancelled path ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cancelled_emits_stream_end_and_idle():
|
||||
session = _StubSession(side_effect=GenerationCancelled())
|
||||
ws = Workstream()
|
||||
ui = _RecordingUI()
|
||||
|
||||
dispatch = _make_watch_dispatch(ws, session, ui)
|
||||
dispatch("hello")
|
||||
_wait_for_worker(ws)
|
||||
|
||||
assert ui.stream_end_calls == 1
|
||||
assert ui.state_changes == ["idle"]
|
||||
assert ui.errors == []
|
||||
|
||||
|
||||
# ── Generic exception path ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_exception_emits_stream_end_and_error():
|
||||
session = _StubSession(side_effect=RuntimeError("boom"))
|
||||
ws = Workstream()
|
||||
ui = _RecordingUI()
|
||||
|
||||
dispatch = _make_watch_dispatch(ws, session, ui)
|
||||
dispatch("hello")
|
||||
_wait_for_worker(ws)
|
||||
|
||||
assert ui.stream_end_calls == 1
|
||||
assert ui.state_changes == ["error"]
|
||||
assert len(ui.errors) == 1
|
||||
assert "boom" in ui.errors[0]
|
||||
|
||||
|
||||
# ── Worker-thread identity guard ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_abandoned_thread_emits_no_events():
|
||||
"""After force-cancel sets worker_thread=None, the old thread must not
|
||||
emit stream_end or state changes."""
|
||||
barrier = threading.Event()
|
||||
|
||||
class _BlockingSession(_StubSession):
|
||||
def send(self, msg: str) -> None:
|
||||
barrier.wait(timeout=5)
|
||||
raise RuntimeError("late error")
|
||||
|
||||
session = _BlockingSession()
|
||||
ws = Workstream()
|
||||
ui = _RecordingUI()
|
||||
|
||||
dispatch = _make_watch_dispatch(ws, session, ui)
|
||||
dispatch("hello")
|
||||
|
||||
# Simulate force-cancel: clear the worker_thread reference.
|
||||
ws.worker_thread = None
|
||||
barrier.set()
|
||||
|
||||
# Wait for the thread to actually complete (it's still running).
|
||||
time.sleep(0.3)
|
||||
|
||||
assert ui.stream_end_calls == 0
|
||||
assert ui.state_changes == []
|
||||
assert ui.errors == []
|
||||
|
||||
|
||||
# ── Path A: busy workstream enqueue ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_busy_workstream_enqueues_message():
|
||||
"""When the workstream already has a live worker, dispatch enqueues."""
|
||||
session = _StubSession()
|
||||
ws = Workstream()
|
||||
ui = _RecordingUI()
|
||||
|
||||
# Simulate a live worker thread.
|
||||
blocker = threading.Event()
|
||||
ws.worker_thread = threading.Thread(target=blocker.wait, args=(5,), daemon=True)
|
||||
ws.worker_thread.start()
|
||||
|
||||
try:
|
||||
dispatch = _make_watch_dispatch(ws, session, ui)
|
||||
dispatch("queued msg")
|
||||
|
||||
item = session._watch_pending.get_nowait()
|
||||
assert item == {"message": "queued msg"}
|
||||
finally:
|
||||
blocker.set()
|
||||
ws.worker_thread.join(2)
|
||||
|
||||
|
||||
def test_busy_workstream_drops_on_full_queue():
|
||||
"""When the pending queue is full, dispatch drops the message."""
|
||||
session = _StubSession()
|
||||
# Fill the queue to capacity.
|
||||
for i in range(20):
|
||||
session._watch_pending.put_nowait({"message": f"msg{i}"})
|
||||
|
||||
ws = Workstream()
|
||||
ui = _RecordingUI()
|
||||
|
||||
blocker = threading.Event()
|
||||
ws.worker_thread = threading.Thread(target=blocker.wait, args=(5,), daemon=True)
|
||||
ws.worker_thread.start()
|
||||
|
||||
try:
|
||||
dispatch = _make_watch_dispatch(ws, session, ui)
|
||||
# Should not block or raise — just log a warning and drop.
|
||||
dispatch("overflow msg")
|
||||
|
||||
assert session._watch_pending.full()
|
||||
finally:
|
||||
blocker.set()
|
||||
ws.worker_thread.join(2)
|
||||
|
||||
|
||||
# ── Lock guard ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_dispatch_holds_lock_during_thread_start():
|
||||
"""Dispatch acquires ws._lock before checking/starting the worker."""
|
||||
session = _StubSession()
|
||||
ws = Workstream()
|
||||
ui = _RecordingUI()
|
||||
|
||||
acquire_count = 0
|
||||
inner = ws._lock
|
||||
|
||||
class _CountingLock:
|
||||
def __enter__(self):
|
||||
nonlocal acquire_count
|
||||
acquire_count += 1
|
||||
return inner.__enter__()
|
||||
|
||||
def __exit__(self, *args):
|
||||
return inner.__exit__(*args)
|
||||
|
||||
ws._lock = _CountingLock() # type: ignore[assignment]
|
||||
|
||||
dispatch = _make_watch_dispatch(ws, session, ui)
|
||||
dispatch("hello")
|
||||
_wait_for_worker(ws)
|
||||
|
||||
assert acquire_count >= 1
|
||||
|
||||
|
||||
# ── Happy path ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_successful_send_no_error_events():
|
||||
"""Normal send() completion should not trigger error/cancel events."""
|
||||
session = _StubSession() # send() does nothing (success)
|
||||
ws = Workstream()
|
||||
ui = _RecordingUI()
|
||||
|
||||
dispatch = _make_watch_dispatch(ws, session, ui)
|
||||
dispatch("hello")
|
||||
_wait_for_worker(ws)
|
||||
|
||||
assert ui.stream_end_calls == 0
|
||||
assert ui.state_changes == []
|
||||
assert ui.errors == []
|
||||
@@ -54,7 +54,7 @@ class FakeUI:
|
||||
def approve_tools(self, items):
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id, name, output):
|
||||
def on_tool_result(self, call_id, name, output, **kwargs):
|
||||
pass
|
||||
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.8.9"
|
||||
__version__ = "0.9.6"
|
||||
|
||||
@@ -785,3 +785,94 @@ class RegistryInstallRequest(BaseModel):
|
||||
variables: dict[str, str] = Field(default_factory=dict)
|
||||
env: dict[str, str] = Field(default_factory=dict)
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin: Model Definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModelDefinitionInfo(BaseModel):
|
||||
definition_id: str
|
||||
alias: str
|
||||
model: str
|
||||
provider: str = "openai"
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
context_window: int = 32768
|
||||
capabilities: str = "{}"
|
||||
enabled: bool = True
|
||||
source: str = ""
|
||||
created_by: str = ""
|
||||
created: str = ""
|
||||
updated: str = ""
|
||||
|
||||
|
||||
class CreateModelDefinitionRequest(BaseModel):
|
||||
alias: str
|
||||
model: str
|
||||
provider: str = "openai"
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
context_window: int = 32768
|
||||
capabilities: dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class UpdateModelDefinitionRequest(BaseModel):
|
||||
alias: str | None = None
|
||||
model: str | None = None
|
||||
provider: str | None = None
|
||||
base_url: str | None = None
|
||||
api_key: str | None = None
|
||||
context_window: int | None = None
|
||||
capabilities: dict[str, Any] | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class ListModelDefinitionsResponse(BaseModel):
|
||||
models: list[ModelDefinitionInfo]
|
||||
|
||||
|
||||
class ModelReloadResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
results: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class DetectModelRequest(BaseModel):
|
||||
provider: str = "openai"
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
model: str = ""
|
||||
definition_id: str = ""
|
||||
|
||||
|
||||
class DetectModelResponse(BaseModel):
|
||||
reachable: bool = False
|
||||
model_found: bool | None = None
|
||||
available_models: list[str] = Field(default_factory=list)
|
||||
context_window: int | None = None
|
||||
server_type: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ModelCapabilitiesResponse(BaseModel):
|
||||
model: str
|
||||
provider: str
|
||||
known: bool = False
|
||||
capabilities: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class KnownModelsResponse(BaseModel):
|
||||
provider: str
|
||||
models: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AvailableModelInfo(BaseModel):
|
||||
alias: str
|
||||
model: str
|
||||
provider: str
|
||||
|
||||
|
||||
class ListAvailableModelsResponse(BaseModel):
|
||||
models: list[AvailableModelInfo] = Field(default_factory=list)
|
||||
|
||||
@@ -11,6 +11,7 @@ from turnstone.api.console_schemas import (
|
||||
AdminMemoryInfo,
|
||||
AssignRoleRequest,
|
||||
AuditEventInfo,
|
||||
AvailableModelInfo,
|
||||
ChannelUserInfo,
|
||||
ClusterNodesResponse,
|
||||
ClusterOverviewResponse,
|
||||
@@ -21,16 +22,22 @@ from turnstone.api.console_schemas import (
|
||||
ConsoleHealthResponse,
|
||||
CreateChannelUserRequest,
|
||||
CreateMcpServerRequest,
|
||||
CreateModelDefinitionRequest,
|
||||
CreateRoleRequest,
|
||||
CreateSkillRequest,
|
||||
CreateSkillResourceRequest,
|
||||
CreateToolPolicyRequest,
|
||||
DetectModelRequest,
|
||||
DetectModelResponse,
|
||||
ImportMcpConfigRequest,
|
||||
ImportMcpConfigResponse,
|
||||
KnownModelsResponse,
|
||||
ListAdminMemoriesResponse,
|
||||
ListAuditEventsResponse,
|
||||
ListAvailableModelsResponse,
|
||||
ListChannelUsersResponse,
|
||||
ListMcpServersResponse,
|
||||
ListModelDefinitionsResponse,
|
||||
ListOrgsResponse,
|
||||
ListOutputAssessmentsResponse,
|
||||
ListRolesResponse,
|
||||
@@ -44,6 +51,9 @@ from turnstone.api.console_schemas import (
|
||||
ListVerdictsResponse,
|
||||
McpReloadResponse,
|
||||
McpServerDetail,
|
||||
ModelCapabilitiesResponse,
|
||||
ModelDefinitionInfo,
|
||||
ModelReloadResponse,
|
||||
NodeDetailResponse,
|
||||
OrgInfo,
|
||||
OutputAssessmentInfo,
|
||||
@@ -60,6 +70,7 @@ from turnstone.api.console_schemas import (
|
||||
SkillVersionInfo,
|
||||
ToolPolicyInfo,
|
||||
UpdateMcpServerRequest,
|
||||
UpdateModelDefinitionRequest,
|
||||
UpdateOrgRequest,
|
||||
UpdateRoleRequest,
|
||||
UpdateSettingRequest,
|
||||
@@ -560,6 +571,14 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
response_model=ListSkillVersionsResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Models ---
|
||||
EndpointSpec(
|
||||
"/v1/api/models",
|
||||
"GET",
|
||||
"List enabled model aliases for workstream creation",
|
||||
response_model=ListAvailableModelsResponse,
|
||||
tags=["Models"],
|
||||
),
|
||||
# --- Skills ---
|
||||
EndpointSpec(
|
||||
"/v1/api/skills",
|
||||
@@ -843,6 +862,84 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
error_codes=[400],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Admin: Model Definitions ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/model-definitions",
|
||||
"GET",
|
||||
"List model definitions with live status from cluster nodes",
|
||||
response_model=ListModelDefinitionsResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/model-definitions",
|
||||
"POST",
|
||||
"Create a model definition",
|
||||
request_model=CreateModelDefinitionRequest,
|
||||
response_model=ModelDefinitionInfo,
|
||||
error_codes=[400, 409],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/model-definitions/reload",
|
||||
"POST",
|
||||
"Tell all nodes to re-read model definitions from DB and rebuild registry",
|
||||
response_model=ModelReloadResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/model-definitions/{definition_id}",
|
||||
"GET",
|
||||
"Get a single model definition",
|
||||
response_model=ModelDefinitionInfo,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/model-definitions/{definition_id}",
|
||||
"PUT",
|
||||
"Update a model definition",
|
||||
request_model=UpdateModelDefinitionRequest,
|
||||
response_model=ModelDefinitionInfo,
|
||||
error_codes=[400, 404, 409],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/model-definitions/{definition_id}",
|
||||
"DELETE",
|
||||
"Delete a model definition",
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/model-definitions/detect",
|
||||
"POST",
|
||||
"Probe a model endpoint: verify reachability, list models, detect context window and server type",
|
||||
request_model=DetectModelRequest,
|
||||
response_model=DetectModelResponse,
|
||||
error_codes=[400],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/model-capabilities",
|
||||
"GET",
|
||||
"Look up static capabilities for a known model",
|
||||
response_model=ModelCapabilitiesResponse,
|
||||
query_params=[
|
||||
QueryParam(name="provider", description="Provider name", required=True),
|
||||
QueryParam(name="model", description="Model ID to look up", required=True),
|
||||
],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/model-capabilities/known",
|
||||
"GET",
|
||||
"List known model name prefixes for a provider",
|
||||
response_model=KnownModelsResponse,
|
||||
query_params=[
|
||||
QueryParam(name="provider", description="Provider name", required=True),
|
||||
],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Admin: TLS / ACME ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/tls/ca",
|
||||
@@ -953,6 +1050,17 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
ImportMcpConfigRequest,
|
||||
ImportMcpConfigResponse,
|
||||
McpReloadResponse,
|
||||
ModelDefinitionInfo,
|
||||
CreateModelDefinitionRequest,
|
||||
UpdateModelDefinitionRequest,
|
||||
ListModelDefinitionsResponse,
|
||||
ModelReloadResponse,
|
||||
DetectModelRequest,
|
||||
DetectModelResponse,
|
||||
ModelCapabilitiesResponse,
|
||||
KnownModelsResponse,
|
||||
AvailableModelInfo,
|
||||
ListAvailableModelsResponse,
|
||||
RegistrySearchResponse,
|
||||
RegistryInstallRequest,
|
||||
SkillDiscoverResponse,
|
||||
|
||||
@@ -41,6 +41,11 @@ class CommandRequest(BaseModel):
|
||||
|
||||
class CancelRequest(BaseModel):
|
||||
ws_id: str = Field(description="Target workstream ID")
|
||||
force: bool = Field(
|
||||
default=False,
|
||||
description="Force cancel: abandon the stuck worker thread immediately. "
|
||||
"Use when cooperative cancel has not resolved within a few seconds.",
|
||||
)
|
||||
|
||||
|
||||
class CreateWorkstreamRequest(BaseModel):
|
||||
@@ -252,3 +257,13 @@ class SkillSummary(BaseModel):
|
||||
|
||||
class ListSkillSummaryResponse(BaseModel):
|
||||
skills: list[SkillSummary]
|
||||
|
||||
|
||||
class AvailableModelInfo(BaseModel):
|
||||
alias: str
|
||||
model: str
|
||||
provider: str
|
||||
|
||||
|
||||
class ListAvailableModelsResponse(BaseModel):
|
||||
models: list[AvailableModelInfo] = Field(default_factory=list)
|
||||
|
||||
@@ -20,6 +20,7 @@ from turnstone.api.schemas import (
|
||||
)
|
||||
from turnstone.api.server_schemas import (
|
||||
ApproveRequest,
|
||||
AvailableModelInfo,
|
||||
CancelRequest,
|
||||
CloseWorkstreamRequest,
|
||||
CommandRequest,
|
||||
@@ -27,6 +28,7 @@ from turnstone.api.server_schemas import (
|
||||
CreateWorkstreamResponse,
|
||||
DashboardResponse,
|
||||
HealthResponse,
|
||||
ListAvailableModelsResponse,
|
||||
ListMemoriesResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
ListSkillSummaryResponse,
|
||||
@@ -155,6 +157,14 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
|
||||
response_model=ListSkillSummaryResponse,
|
||||
tags=["Skills"],
|
||||
),
|
||||
# --- Models ---
|
||||
EndpointSpec(
|
||||
"/v1/api/models",
|
||||
"GET",
|
||||
"List available model aliases",
|
||||
response_model=ListAvailableModelsResponse,
|
||||
tags=["Models"],
|
||||
),
|
||||
# --- Auth ---
|
||||
EndpointSpec(
|
||||
"/v1/api/auth/login",
|
||||
@@ -293,6 +303,8 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
SearchMemoriesRequest,
|
||||
SkillSummary,
|
||||
ListSkillSummaryResponse,
|
||||
AvailableModelInfo,
|
||||
ListAvailableModelsResponse,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -183,16 +183,21 @@ class MessageCog:
|
||||
auto_archive_duration=self.ts.config.thread_auto_archive, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# Create workstream with the initial message.
|
||||
# Create workstream WITHOUT initial_message — subscribe to events
|
||||
# first, then send the message. Sending initial_message through
|
||||
# the bridge races with subscription: Redis pub/sub is fire-and-
|
||||
# forget, so response events published before subscribe completes
|
||||
# are silently dropped.
|
||||
ws_id, _is_new = await self.ts.router.get_or_create_workstream(
|
||||
channel_type="discord",
|
||||
channel_id=str(thread.id),
|
||||
name=thread_name,
|
||||
model=self.ts.config.model,
|
||||
initial_message=content,
|
||||
initial_message="",
|
||||
)
|
||||
|
||||
await self.ts.subscribe_ws(ws_id, thread)
|
||||
await self.ts.router.send_message(ws_id, content)
|
||||
log.info(
|
||||
"discord.workstream_created",
|
||||
ws_id=ws_id,
|
||||
@@ -362,10 +367,11 @@ class MessageCog:
|
||||
channel_id=str(thread.id),
|
||||
name=thread_name,
|
||||
model=self.ts.config.model,
|
||||
initial_message=message,
|
||||
initial_message="",
|
||||
)
|
||||
|
||||
await self.ts.subscribe_ws(ws_id, thread)
|
||||
await self.ts.router.send_message(ws_id, message)
|
||||
|
||||
await interaction.followup.send(
|
||||
f"Workstream started in {thread.mention}",
|
||||
|
||||
+33
-4
@@ -63,6 +63,8 @@ SLASH_COMMANDS = [
|
||||
"/creative",
|
||||
"/debug",
|
||||
"/mcp",
|
||||
"/retry",
|
||||
"/rewind",
|
||||
"/help",
|
||||
"/exit",
|
||||
"/quit",
|
||||
@@ -251,8 +253,18 @@ class TerminalUI(SessionUI):
|
||||
item["denial_msg"] = denial_msg
|
||||
return False, None
|
||||
|
||||
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
|
||||
pass # Optional: display summary
|
||||
def on_tool_result(
|
||||
self,
|
||||
call_id: str,
|
||||
name: str,
|
||||
output: str,
|
||||
*,
|
||||
is_error: bool = False,
|
||||
) -> None:
|
||||
if is_error:
|
||||
with self._print_lock:
|
||||
sys.stderr.write(f"{RED}\u2717 {name}: {output}{RESET}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
|
||||
pass # Terminal shows spinner during tool execution
|
||||
@@ -424,9 +436,16 @@ class WorkstreamTerminalUI(TerminalUI):
|
||||
else:
|
||||
self._buffer("error", message)
|
||||
|
||||
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
|
||||
def on_tool_result(
|
||||
self,
|
||||
call_id: str,
|
||||
name: str,
|
||||
output: str,
|
||||
*,
|
||||
is_error: bool = False,
|
||||
) -> None:
|
||||
if self.is_foreground:
|
||||
super().on_tool_result(call_id, name, output)
|
||||
super().on_tool_result(call_id, name, output, is_error=is_error)
|
||||
|
||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
|
||||
if self.is_foreground:
|
||||
@@ -1237,6 +1256,16 @@ def main() -> None:
|
||||
should_exit = active.session.handle_command(user_input)
|
||||
if should_exit:
|
||||
break
|
||||
# Dispatch deferred retry (handle_command sets _pending_retry)
|
||||
retry_msg = active.session._pending_retry
|
||||
if retry_msg:
|
||||
active.session._pending_retry = None
|
||||
try:
|
||||
active.session.send(retry_msg)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{yellow('Interrupted.')}")
|
||||
except Exception as e:
|
||||
print(f"\n{red(f'Error: {e}')}")
|
||||
else:
|
||||
try:
|
||||
active.session.send(user_input)
|
||||
|
||||
+582
-11
@@ -365,6 +365,25 @@ async def oidc_callback(request: Request) -> Response:
|
||||
return await handle_oidc_callback(request, JWT_AUD_CONSOLE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route handlers — available models (lightweight, no admin permission)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def list_available_models(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/models — enabled model aliases for workstream creation."""
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
rows = storage.list_model_definitions(enabled_only=True)
|
||||
# Only expose alias/model/provider — rows also contain api_key, base_url, etc.
|
||||
models = [{"alias": r["alias"], "model": r["model"], "provider": r["provider"]} for r in rows]
|
||||
return JSONResponse({"models": models})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route handlers — workstream creation
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1748,9 +1767,11 @@ _VALID_PERMISSIONS = frozenset(
|
||||
"admin.memories",
|
||||
"admin.settings",
|
||||
"admin.mcp",
|
||||
"admin.models",
|
||||
"tools.approve",
|
||||
"workstreams.create",
|
||||
"workstreams.close",
|
||||
"conversation.modify",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3656,7 +3677,6 @@ async def admin_list_settings(request: Request) -> JSONResponse:
|
||||
if err:
|
||||
return err
|
||||
|
||||
reveal = request.query_params.get("reveal") == "true"
|
||||
stored = {r["key"]: r for r in storage.list_system_settings() if r.get("node_id", "") == ""}
|
||||
|
||||
settings: list[dict[str, Any]] = []
|
||||
@@ -3669,7 +3689,7 @@ async def admin_list_settings(request: Request) -> JSONResponse:
|
||||
val = row["value"]
|
||||
info = {
|
||||
"key": key,
|
||||
"value": "***" if defn.is_secret and not reveal else val,
|
||||
"value": "***" if defn.is_secret else val,
|
||||
"source": "storage",
|
||||
"type": defn.type,
|
||||
"description": defn.description,
|
||||
@@ -3683,7 +3703,7 @@ async def admin_list_settings(request: Request) -> JSONResponse:
|
||||
else:
|
||||
info = {
|
||||
"key": key,
|
||||
"value": "(managed via config file / env)" if defn.is_secret else defn.default,
|
||||
"value": "***" if defn.is_secret else defn.default,
|
||||
"source": "default",
|
||||
"type": defn.type,
|
||||
"description": defn.description,
|
||||
@@ -3758,18 +3778,31 @@ async def admin_update_setting(request: Request) -> JSONResponse:
|
||||
except ValueError:
|
||||
return JSONResponse({"error": f"Unknown setting: {key}"}, status_code=400)
|
||||
|
||||
if defn.is_secret:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Secret settings cannot be modified via API — use config.toml or environment variables"
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
if "value" not in body:
|
||||
return JSONResponse({"error": "value is required"}, status_code=400)
|
||||
|
||||
raw_value = body.get("value")
|
||||
|
||||
# Secret sentinel: "***" means "keep existing value"
|
||||
if defn.is_secret and raw_value == "***":
|
||||
existing = storage.get_system_setting(key)
|
||||
return JSONResponse(
|
||||
{
|
||||
"key": key,
|
||||
"value": "***",
|
||||
"source": "storage" if existing else "default",
|
||||
"type": defn.type,
|
||||
"description": defn.description,
|
||||
"section": defn.section,
|
||||
"is_secret": True,
|
||||
"node_id": existing.get("node_id", "") if existing else "",
|
||||
"changed_by": existing.get("changed_by", "") if existing else "",
|
||||
"updated": existing.get("updated", "") if existing else "",
|
||||
"restart_required": defn.restart_required,
|
||||
"unchanged": True,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
typed_value = validate_value(key, raw_value)
|
||||
except ValueError as e:
|
||||
@@ -4663,6 +4696,484 @@ async def admin_import_mcp_config(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"imported": imported, "skipped": skipped, "errors": errors})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin: Model Definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MODEL_ALIAS_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
_MODEL_PROVIDERS = frozenset({"openai", "anthropic", "openai-compatible"})
|
||||
|
||||
|
||||
def _mask_model_secrets(model: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Replace api_key with '***' (unconditional, write-only)."""
|
||||
m = dict(model)
|
||||
if m.get("api_key"):
|
||||
m["api_key"] = "***"
|
||||
return m
|
||||
|
||||
|
||||
async def _collect_model_status(
|
||||
request: Request,
|
||||
) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
"""Query all nodes for model status. Returns {node_id: {alias: info}}."""
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
nodes = collector.get_all_nodes()
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
headers = _proxy_auth_headers(request)
|
||||
sem = asyncio.Semaphore(_get_fan_out_limit(request))
|
||||
|
||||
async def _fetch(node: dict[str, Any]) -> tuple[str, dict[str, dict[str, Any]] | None]:
|
||||
node_id = node.get("node_id", "")
|
||||
url = node.get("server_url", "")
|
||||
if not url:
|
||||
return node_id, None
|
||||
async with sem:
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{url.rstrip('/')}/v1/api/_internal/model-status",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return node_id, resp.json().get("models", {})
|
||||
except Exception:
|
||||
log.debug("Failed to fetch model status from node %s", node_id, exc_info=True)
|
||||
return node_id, None
|
||||
|
||||
results = await asyncio.gather(*[_fetch(n) for n in nodes])
|
||||
return {nid: models for nid, models in results if models is not None}
|
||||
|
||||
|
||||
async def _notify_nodes_model_reload(request: Request) -> dict[str, Any]:
|
||||
"""Tell all nodes to re-read model definitions from DB and rebuild registry."""
|
||||
collector: ClusterCollector = request.app.state.collector
|
||||
nodes = collector.get_all_nodes()
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
headers = _proxy_auth_headers(request)
|
||||
sem = asyncio.Semaphore(_get_fan_out_limit(request))
|
||||
|
||||
async def _notify(node: dict[str, Any]) -> tuple[str, Any]:
|
||||
node_id = node.get("node_id", "")
|
||||
url = node.get("server_url", "")
|
||||
if not url:
|
||||
return node_id, None
|
||||
async with sem:
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{url.rstrip('/')}/v1/api/_internal/model-reload",
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
return node_id, resp.json()
|
||||
except Exception as exc:
|
||||
log.debug("Failed to notify node %s for model reload", node_id, exc_info=True)
|
||||
return node_id, {"error": str(exc)}
|
||||
|
||||
results = await asyncio.gather(*[_notify(n) for n in nodes])
|
||||
return {nid: data for nid, data in results if data is not None}
|
||||
|
||||
|
||||
async def admin_list_model_definitions(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/model-definitions — list all model definitions."""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.models")
|
||||
if err:
|
||||
return err
|
||||
|
||||
db_models = storage.list_model_definitions()
|
||||
|
||||
# Collect live status from all nodes
|
||||
node_statuses = await _collect_model_status(request)
|
||||
|
||||
db_aliases: set[str] = set()
|
||||
result = []
|
||||
for m in db_models:
|
||||
db_aliases.add(m["alias"])
|
||||
m["source"] = "db"
|
||||
result.append(_mask_model_secrets(m))
|
||||
|
||||
# Merge config-sourced models visible on nodes but not in DB
|
||||
config_aliases: set[str] = set()
|
||||
for node_models in node_statuses.values():
|
||||
for alias in node_models:
|
||||
if alias not in db_aliases:
|
||||
config_aliases.add(alias)
|
||||
for alias in sorted(config_aliases):
|
||||
# Build a synthetic read-only entry from node-reported data
|
||||
model_name = ""
|
||||
provider = "openai"
|
||||
context_window = 0
|
||||
for node_models in node_statuses.values():
|
||||
nm = node_models.get(alias)
|
||||
if nm:
|
||||
model_name = nm.get("model", "")
|
||||
provider = nm.get("provider", "openai")
|
||||
context_window = nm.get("context_window", 0)
|
||||
break
|
||||
result.append(
|
||||
{
|
||||
"definition_id": "",
|
||||
"alias": alias,
|
||||
"model": model_name,
|
||||
"provider": provider,
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"context_window": context_window,
|
||||
"capabilities": "{}",
|
||||
"enabled": True,
|
||||
"source": "config",
|
||||
"created_by": "",
|
||||
"created": "",
|
||||
"updated": "",
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse({"models": result})
|
||||
|
||||
|
||||
async def admin_create_model_definition(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/admin/model-definitions — create a model definition."""
|
||||
import uuid
|
||||
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.models")
|
||||
if err:
|
||||
return err
|
||||
|
||||
body = await read_json_or_400(request)
|
||||
if isinstance(body, JSONResponse):
|
||||
return body
|
||||
|
||||
alias = str(body.get("alias", "")).strip()[:64]
|
||||
model_name = str(body.get("model", "")).strip()[:128]
|
||||
if not alias:
|
||||
return JSONResponse({"error": "alias is required"}, status_code=400)
|
||||
if not model_name:
|
||||
return JSONResponse({"error": "model is required"}, status_code=400)
|
||||
if not _MODEL_ALIAS_RE.match(alias):
|
||||
return JSONResponse(
|
||||
{"error": "alias must match [a-zA-Z0-9._-]+"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Check alias uniqueness
|
||||
if storage.get_model_definition_by_alias(alias):
|
||||
return JSONResponse(
|
||||
{"error": f"Model alias '{alias}' already exists"},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
definition_id = uuid.uuid4().hex
|
||||
audit_uid, ip = _audit_context(request)
|
||||
|
||||
provider = str(body.get("provider", "openai")).strip()
|
||||
if provider not in _MODEL_PROVIDERS:
|
||||
return JSONResponse(
|
||||
{"error": f"Unknown provider: {provider!r}"},
|
||||
status_code=400,
|
||||
)
|
||||
base_url = str(body.get("base_url", "")).strip()
|
||||
api_key = str(body.get("api_key", "")).strip()
|
||||
ctx_raw = body.get("context_window", 32768)
|
||||
context_window = max(0, int(ctx_raw)) if isinstance(ctx_raw, (int, float)) else 0
|
||||
caps = body.get("capabilities", {})
|
||||
capabilities = json.dumps(caps) if isinstance(caps, dict) else "{}"
|
||||
enabled = bool(body.get("enabled", True))
|
||||
|
||||
storage.create_model_definition(
|
||||
definition_id=definition_id,
|
||||
alias=alias,
|
||||
model=model_name,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
context_window=context_window,
|
||||
capabilities=capabilities,
|
||||
enabled=enabled,
|
||||
created_by=audit_uid,
|
||||
)
|
||||
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
"model_definition.create",
|
||||
"model_definition",
|
||||
definition_id,
|
||||
{"alias": alias},
|
||||
ip,
|
||||
)
|
||||
|
||||
created = storage.get_model_definition(definition_id)
|
||||
if created is None:
|
||||
return JSONResponse(
|
||||
{"error": f"Model alias '{alias}' already exists (concurrent insert)"},
|
||||
status_code=409,
|
||||
)
|
||||
return JSONResponse(_mask_model_secrets(created))
|
||||
|
||||
|
||||
async def admin_get_model_definition(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/model-definitions/{definition_id}."""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.models")
|
||||
if err:
|
||||
return err
|
||||
|
||||
definition_id = request.path_params["definition_id"]
|
||||
model_def = storage.get_model_definition(definition_id)
|
||||
if model_def is None:
|
||||
return JSONResponse({"error": "Model definition not found"}, status_code=404)
|
||||
|
||||
return JSONResponse(_mask_model_secrets(model_def))
|
||||
|
||||
|
||||
async def admin_update_model_definition(request: Request) -> JSONResponse:
|
||||
"""PUT /v1/api/admin/model-definitions/{definition_id}."""
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.models")
|
||||
if err:
|
||||
return err
|
||||
|
||||
definition_id = request.path_params["definition_id"]
|
||||
existing = storage.get_model_definition(definition_id)
|
||||
if existing is None:
|
||||
return JSONResponse({"error": "Model definition not found"}, status_code=404)
|
||||
|
||||
body = await read_json_or_400(request)
|
||||
if isinstance(body, JSONResponse):
|
||||
return body
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
if "alias" in body:
|
||||
alias = str(body["alias"]).strip()[:64]
|
||||
if not alias:
|
||||
return JSONResponse({"error": "alias cannot be empty"}, status_code=400)
|
||||
if not _MODEL_ALIAS_RE.match(alias):
|
||||
return JSONResponse(
|
||||
{"error": "alias must match [a-zA-Z0-9._-]+"},
|
||||
status_code=400,
|
||||
)
|
||||
if alias != existing["alias"] and storage.get_model_definition_by_alias(alias):
|
||||
return JSONResponse(
|
||||
{"error": f"Model alias '{alias}' already exists"},
|
||||
status_code=409,
|
||||
)
|
||||
updates["alias"] = alias
|
||||
if "model" in body:
|
||||
model_val = str(body["model"]).strip()[:128]
|
||||
if not model_val:
|
||||
return JSONResponse({"error": "model cannot be empty"}, status_code=400)
|
||||
updates["model"] = model_val
|
||||
if "provider" in body:
|
||||
prov = str(body["provider"]).strip()
|
||||
if prov not in _MODEL_PROVIDERS:
|
||||
return JSONResponse(
|
||||
{"error": f"Unknown provider: {prov!r}"},
|
||||
status_code=400,
|
||||
)
|
||||
updates["provider"] = prov
|
||||
if "base_url" in body:
|
||||
updates["base_url"] = str(body["base_url"]).strip()
|
||||
if "api_key" in body:
|
||||
api_key = str(body["api_key"]).strip()
|
||||
# Sentinel "***" or empty string means "keep existing"
|
||||
if api_key and api_key != "***":
|
||||
updates["api_key"] = api_key
|
||||
if "context_window" in body:
|
||||
ctx_raw = body["context_window"]
|
||||
updates["context_window"] = max(0, int(ctx_raw)) if isinstance(ctx_raw, (int, float)) else 0
|
||||
if "capabilities" in body:
|
||||
caps = body["capabilities"]
|
||||
updates["capabilities"] = json.dumps(caps) if isinstance(caps, dict) else "{}"
|
||||
if "enabled" in body:
|
||||
updates["enabled"] = bool(body["enabled"])
|
||||
|
||||
if updates:
|
||||
storage.update_model_definition(definition_id, **updates)
|
||||
|
||||
audit_uid, ip = _audit_context(request)
|
||||
audit_detail = dict(updates)
|
||||
if "api_key" in audit_detail:
|
||||
audit_detail["api_key"] = "(updated)"
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
"model_definition.update",
|
||||
"model_definition",
|
||||
definition_id,
|
||||
audit_detail,
|
||||
ip,
|
||||
)
|
||||
|
||||
model_def = storage.get_model_definition(definition_id)
|
||||
return JSONResponse(_mask_model_secrets(model_def or {}))
|
||||
|
||||
|
||||
async def admin_delete_model_definition(request: Request) -> JSONResponse:
|
||||
"""DELETE /v1/api/admin/model-definitions/{definition_id}."""
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.models")
|
||||
if err:
|
||||
return err
|
||||
|
||||
definition_id = request.path_params["definition_id"]
|
||||
existing = storage.get_model_definition(definition_id)
|
||||
if existing is None:
|
||||
return JSONResponse({"error": "Model definition not found"}, status_code=404)
|
||||
|
||||
storage.delete_model_definition(definition_id)
|
||||
|
||||
audit_uid, ip = _audit_context(request)
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
"model_definition.delete",
|
||||
"model_definition",
|
||||
definition_id,
|
||||
{"alias": existing.get("alias", "")},
|
||||
ip,
|
||||
)
|
||||
|
||||
return JSONResponse({"status": "ok", "definition_id": definition_id})
|
||||
|
||||
|
||||
async def admin_model_reload(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/admin/model-definitions/reload — tell nodes to re-read DB."""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.models")
|
||||
if err:
|
||||
return err
|
||||
|
||||
results = await _notify_nodes_model_reload(request)
|
||||
return JSONResponse({"status": "ok", "results": results})
|
||||
|
||||
|
||||
async def admin_detect_model(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/admin/model-definitions/detect — stateless endpoint probe."""
|
||||
import asyncio
|
||||
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.model_registry import probe_model_endpoint
|
||||
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.models")
|
||||
if err:
|
||||
return err
|
||||
|
||||
body = await read_json_or_400(request)
|
||||
if isinstance(body, JSONResponse):
|
||||
return body
|
||||
|
||||
provider = str(body.get("provider", "openai")).strip()
|
||||
base_url = str(body.get("base_url", "")).strip()
|
||||
api_key = str(body.get("api_key", "")).strip()
|
||||
model = str(body.get("model", "")).strip()
|
||||
definition_id = str(body.get("definition_id", "")).strip()
|
||||
|
||||
if provider not in _MODEL_PROVIDERS:
|
||||
return JSONResponse({"error": f"Unknown provider: {provider!r}"}, status_code=400)
|
||||
|
||||
# Resolve api_key from DB when the UI sends the masked sentinel
|
||||
if (not api_key or api_key == "***") and definition_id:
|
||||
row = storage.get_model_definition(definition_id)
|
||||
if row:
|
||||
api_key = row.get("api_key", "")
|
||||
if not base_url:
|
||||
base_url = row.get("base_url", "")
|
||||
|
||||
# For commercial endpoints an api_key is required
|
||||
if not api_key and (
|
||||
not base_url or "api.openai.com" in base_url or "api.anthropic.com" in base_url
|
||||
):
|
||||
return JSONResponse({"error": "api_key is required"}, status_code=400)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None, probe_model_endpoint, provider, base_url, api_key, model
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
async def admin_model_capabilities(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/model-capabilities — static capability lookup."""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.providers import lookup_model_capabilities
|
||||
|
||||
err = require_permission(request, "admin.models")
|
||||
if err:
|
||||
return err
|
||||
|
||||
provider = request.query_params.get("provider", "").strip()
|
||||
model = request.query_params.get("model", "").strip()
|
||||
|
||||
if provider not in _MODEL_PROVIDERS:
|
||||
return JSONResponse({"error": f"Unknown provider: {provider!r}"}, status_code=400)
|
||||
if not model:
|
||||
return JSONResponse({"error": "model is required"}, status_code=400)
|
||||
|
||||
caps = lookup_model_capabilities(provider, model)
|
||||
return JSONResponse(
|
||||
{
|
||||
"model": model,
|
||||
"provider": provider,
|
||||
"known": caps is not None,
|
||||
"capabilities": caps or {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_known_models(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/model-capabilities/known — list known model name prefixes."""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.providers import list_known_models
|
||||
|
||||
err = require_permission(request, "admin.models")
|
||||
if err:
|
||||
return err
|
||||
|
||||
provider = request.query_params.get("provider", "").strip()
|
||||
if provider not in _MODEL_PROVIDERS:
|
||||
return JSONResponse({"error": f"Unknown provider: {provider!r}"}, status_code=400)
|
||||
|
||||
return JSONResponse({"provider": provider, "models": list_known_models(provider)})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -4854,6 +5365,7 @@ def create_app(
|
||||
Route("/api/cluster/node/{node_id}", cluster_node_detail),
|
||||
Route("/api/cluster/snapshot", cluster_snapshot),
|
||||
Route("/api/cluster/events", cluster_events_sse),
|
||||
Route("/api/models", list_available_models),
|
||||
Route("/api/skills", list_skills_summary),
|
||||
Route("/api/auth/login", auth_login, methods=["POST"]),
|
||||
Route("/api/auth/logout", auth_logout, methods=["POST"]),
|
||||
@@ -5046,6 +5558,42 @@ def create_app(
|
||||
admin_delete_mcp_server,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# System: Model Definitions
|
||||
Route("/api/admin/model-definitions", admin_list_model_definitions),
|
||||
Route(
|
||||
"/api/admin/model-definitions",
|
||||
admin_create_model_definition,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/model-definitions/reload",
|
||||
admin_model_reload,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/model-definitions/detect",
|
||||
admin_detect_model,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/model-definitions/{definition_id}",
|
||||
admin_get_model_definition,
|
||||
),
|
||||
Route(
|
||||
"/api/admin/model-definitions/{definition_id}",
|
||||
admin_update_model_definition,
|
||||
methods=["PUT"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/model-definitions/{definition_id}",
|
||||
admin_delete_model_definition,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
Route("/api/admin/model-capabilities", admin_model_capabilities),
|
||||
Route(
|
||||
"/api/admin/model-capabilities/known",
|
||||
admin_known_models,
|
||||
),
|
||||
# Governance: Usage & Audit
|
||||
Route("/api/admin/usage", admin_usage),
|
||||
Route("/api/admin/audit", admin_audit),
|
||||
@@ -5321,8 +5869,31 @@ def main() -> None:
|
||||
log.info("TLS enabled")
|
||||
except ImportError:
|
||||
log.warning("TLS enabled but lacme not installed — pip install turnstone[tls]")
|
||||
tls_mgr = None
|
||||
except Exception:
|
||||
log.warning("TLS initialization failed", exc_info=True)
|
||||
tls_mgr = None
|
||||
|
||||
# Sync TLS state to ConfigStore so server nodes see the correct value.
|
||||
# Three cases:
|
||||
# 1. TLS succeeded → write true
|
||||
# 2. TLS not configured (DB false/unset) → write false (definitive)
|
||||
# 3. TLS configured (DB true) but init failed → don't overwrite
|
||||
# (transient failure shouldn't permanently disable TLS)
|
||||
try:
|
||||
db_enabled = _cs.get("tls.enabled")
|
||||
if tls_mgr is not None:
|
||||
if not db_enabled:
|
||||
_cs.set("tls.enabled", True, changed_by="console-startup")
|
||||
elif db_enabled:
|
||||
log.warning(
|
||||
"tls.enabled is true in ConfigStore but TLS init failed — "
|
||||
"server nodes will attempt TLS and fall back to plain HTTP"
|
||||
)
|
||||
else:
|
||||
_cs.set("tls.enabled", False, changed_by="console-startup")
|
||||
except Exception:
|
||||
log.debug("Failed to sync TLS state to ConfigStore", exc_info=True)
|
||||
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
|
||||
@@ -66,6 +66,7 @@ function showAdmin() {
|
||||
settings: "admin.settings",
|
||||
tls: "admin.settings",
|
||||
mcp: "admin.mcp",
|
||||
models: "admin.models",
|
||||
};
|
||||
if (perms) {
|
||||
var permSet = perms.split(",");
|
||||
@@ -193,6 +194,7 @@ function switchAdminTab(tab) {
|
||||
"usage",
|
||||
"audit",
|
||||
"memories",
|
||||
"models",
|
||||
"settings",
|
||||
"tls",
|
||||
"mcp",
|
||||
@@ -216,6 +218,7 @@ function switchAdminTab(tab) {
|
||||
loadGovAudit();
|
||||
}
|
||||
if (tab === "memories") loadAdminMemories();
|
||||
if (tab === "models") loadAdminModels();
|
||||
if (tab === "settings") loadSettings();
|
||||
if (tab === "tls") loadTlsCerts();
|
||||
if (tab === "mcp") loadAdminMcp();
|
||||
@@ -1844,6 +1847,7 @@ function _installTrap(overlayId, boxId, trapRef) {
|
||||
else if (overlayId === "mcp-detail-overlay") hideMcpDetailModal();
|
||||
else if (overlayId === "mcp-install-overlay") hideInstallMcpModal();
|
||||
else if (overlayId === "github-import-overlay") hideGitHubImportModal();
|
||||
else if (overlayId === "model-create-overlay") hideCreateModelModal();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1932,6 +1936,7 @@ document.addEventListener("keydown", function (e) {
|
||||
["mcp-import-overlay", hideImportMcpModal],
|
||||
["mcp-create-overlay", hideCreateMcpModal],
|
||||
["github-import-overlay", hideGitHubImportModal],
|
||||
["model-create-overlay", hideCreateModelModal],
|
||||
];
|
||||
for (var gi = 0; gi < govOverlays.length; gi++) {
|
||||
var govEl = document.getElementById(govOverlays[gi][0]);
|
||||
@@ -4025,3 +4030,563 @@ function _pollInstallStatus(serverId, serverName, attempt) {
|
||||
.catch(function () {});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Models tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _modelDefs = [];
|
||||
var _modelCreateTrap = null;
|
||||
var _modelCreateTrigger = null;
|
||||
|
||||
function loadAdminModels() {
|
||||
authFetch("/v1/api/admin/model-definitions")
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed");
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
_modelDefs = data.models || [];
|
||||
_renderModels(_modelDefs);
|
||||
})
|
||||
.catch(function () {
|
||||
var el = document.getElementById("admin-models-table");
|
||||
el.textContent = "";
|
||||
var d = document.createElement("div");
|
||||
d.className = "dashboard-empty";
|
||||
d.textContent = "Failed to load models";
|
||||
el.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
function _renderModels(items) {
|
||||
var el = document.getElementById("admin-models-table");
|
||||
// Clear previous content
|
||||
el.textContent = "";
|
||||
if (!items.length) {
|
||||
var empty = document.createElement("div");
|
||||
empty.className = "dashboard-empty";
|
||||
empty.textContent = "No model definitions configured";
|
||||
el.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var m = items[i];
|
||||
var isConfig = m.source === "config";
|
||||
|
||||
// Status
|
||||
var dotClass = m.enabled
|
||||
? "model-status-dot enabled"
|
||||
: "model-status-dot disabled";
|
||||
var rowClass = m.enabled ? "model-row-enabled" : "model-row-disabled";
|
||||
var statusText = m.enabled ? "enabled" : "disabled";
|
||||
|
||||
// Context window formatting (0 = auto-detect)
|
||||
var ctxText = m.context_window
|
||||
? m.context_window >= 1000
|
||||
? Math.round(m.context_window / 1000) + "k"
|
||||
: String(m.context_window)
|
||||
: "auto";
|
||||
|
||||
// Provider badge class
|
||||
var providerCls =
|
||||
m.provider === "anthropic"
|
||||
? "model-provider-anthropic"
|
||||
: "model-provider-openai";
|
||||
|
||||
// Build row via DOM
|
||||
var row = document.createElement("div");
|
||||
row.className = "admin-row models-grid " + rowClass;
|
||||
row.setAttribute("role", "listitem");
|
||||
|
||||
// Alias + source badge
|
||||
var colAlias = document.createElement("span");
|
||||
colAlias.className = "admin-col";
|
||||
colAlias.textContent = m.alias;
|
||||
var badge = document.createElement("span");
|
||||
badge.className = isConfig
|
||||
? "scope-badge scope-config"
|
||||
: "scope-badge scope-db";
|
||||
badge.textContent = isConfig ? "config" : "db";
|
||||
colAlias.appendChild(document.createTextNode(" "));
|
||||
colAlias.appendChild(badge);
|
||||
row.appendChild(colAlias);
|
||||
|
||||
// Model ID
|
||||
var colModel = document.createElement("span");
|
||||
colModel.className = "admin-col";
|
||||
var code = document.createElement("code");
|
||||
code.textContent = m.model;
|
||||
colModel.appendChild(code);
|
||||
row.appendChild(colModel);
|
||||
|
||||
// Provider
|
||||
var colProvider = document.createElement("span");
|
||||
colProvider.className = "admin-col";
|
||||
var provBadge = document.createElement("span");
|
||||
provBadge.className = "model-provider-badge " + providerCls;
|
||||
provBadge.textContent = m.provider;
|
||||
colProvider.appendChild(provBadge);
|
||||
row.appendChild(colProvider);
|
||||
|
||||
// Context window
|
||||
var colCtx = document.createElement("span");
|
||||
colCtx.className = "admin-col";
|
||||
colCtx.textContent = ctxText;
|
||||
row.appendChild(colCtx);
|
||||
|
||||
// Status
|
||||
var colStatus = document.createElement("span");
|
||||
colStatus.className = "admin-col";
|
||||
var dot = document.createElement("span");
|
||||
dot.className = dotClass;
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
colStatus.appendChild(dot);
|
||||
colStatus.appendChild(document.createTextNode(statusText));
|
||||
row.appendChild(colStatus);
|
||||
|
||||
// Actions
|
||||
var colActions = document.createElement("span");
|
||||
colActions.className = "admin-col";
|
||||
if (!isConfig) {
|
||||
var editBtn = document.createElement("button");
|
||||
editBtn.className = "admin-btn-action";
|
||||
editBtn.textContent = "edit";
|
||||
editBtn.setAttribute("data-model-edit", m.definition_id);
|
||||
colActions.appendChild(editBtn);
|
||||
|
||||
var delBtn = document.createElement("button");
|
||||
delBtn.className = "admin-btn-danger";
|
||||
delBtn.textContent = "del";
|
||||
delBtn.setAttribute("data-model-delete", m.definition_id);
|
||||
delBtn.setAttribute("data-model-alias", m.alias);
|
||||
colActions.appendChild(delBtn);
|
||||
}
|
||||
row.appendChild(colActions);
|
||||
|
||||
el.appendChild(row);
|
||||
}
|
||||
|
||||
// Bind event handlers
|
||||
el.querySelectorAll("[data-model-edit]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
showEditModelModal(this.getAttribute("data-model-edit"));
|
||||
});
|
||||
});
|
||||
el.querySelectorAll("[data-model-delete]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var did = this.getAttribute("data-model-delete");
|
||||
var dalias = this.getAttribute("data-model-alias");
|
||||
showConfirmModal(
|
||||
"Delete Model",
|
||||
'Delete model "' + dalias + '"?',
|
||||
"Delete",
|
||||
function () {
|
||||
authFetch(
|
||||
"/v1/api/admin/model-definitions/" + encodeURIComponent(did),
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error();
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Model deleted");
|
||||
_flagModelSyncPending();
|
||||
loadAdminModels();
|
||||
})
|
||||
.catch(function () {
|
||||
showToast("Failed to delete model");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showCreateModelModal() {
|
||||
_modelCreateTrigger = document.activeElement;
|
||||
var ov = document.getElementById("model-create-overlay");
|
||||
ov.style.display = "flex";
|
||||
document.getElementById("model-edit-id").value = "";
|
||||
document.getElementById("model-create-title").textContent = "Add Model";
|
||||
document.getElementById("model-create-submit").textContent = "Create";
|
||||
document.getElementById("model-create-error").classList.remove("is-visible");
|
||||
document.getElementById("model-alias").value = "";
|
||||
document.getElementById("model-name").value = "";
|
||||
document.getElementById("model-provider").value = "openai";
|
||||
document.getElementById("model-base-url").value = "";
|
||||
document.getElementById("model-api-key").value = "";
|
||||
document.getElementById("model-api-key").placeholder = "sk-...";
|
||||
document.getElementById("model-ctx-window").value = "0";
|
||||
document.getElementById("model-capabilities").value = "";
|
||||
document.getElementById("model-enabled").checked = true;
|
||||
document.getElementById("model-detect-result").style.display = "none";
|
||||
document.getElementById("model-detect-btn").disabled = false;
|
||||
document.getElementById("model-detect-btn").textContent = "Detect";
|
||||
_refreshModelSuggestions();
|
||||
document.getElementById("model-alias").focus();
|
||||
_modelCreateTrap = _installTrap("model-create-overlay", "model-create-box");
|
||||
}
|
||||
|
||||
function showEditModelModal(definitionId) {
|
||||
authFetch(
|
||||
"/v1/api/admin/model-definitions/" + encodeURIComponent(definitionId),
|
||||
)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed");
|
||||
return r.json();
|
||||
})
|
||||
.then(function (m) {
|
||||
showCreateModelModal();
|
||||
document.getElementById("model-edit-id").value = definitionId;
|
||||
document.getElementById("model-create-title").textContent = "Edit Model";
|
||||
document.getElementById("model-create-submit").textContent = "Save";
|
||||
document.getElementById("model-alias").value = m.alias || "";
|
||||
document.getElementById("model-name").value = m.model || "";
|
||||
document.getElementById("model-provider").value = m.provider || "openai";
|
||||
document.getElementById("model-base-url").value = m.base_url || "";
|
||||
document.getElementById("model-api-key").value = "";
|
||||
document.getElementById("model-api-key").placeholder =
|
||||
"\u2022\u2022\u2022 (leave blank to keep existing)";
|
||||
document.getElementById("model-ctx-window").value =
|
||||
m.context_window != null ? m.context_window : 0;
|
||||
// Parse capabilities JSON for display
|
||||
var caps = m.capabilities || "{}";
|
||||
try {
|
||||
caps = JSON.stringify(JSON.parse(caps), null, 2);
|
||||
} catch (e) {
|
||||
/* keep raw */
|
||||
}
|
||||
if (caps === "{}") caps = "";
|
||||
document.getElementById("model-capabilities").value = caps;
|
||||
document.getElementById("model-enabled").checked = m.enabled !== false;
|
||||
})
|
||||
.catch(function () {
|
||||
showToast("Failed to load model details");
|
||||
});
|
||||
}
|
||||
|
||||
function hideCreateModelModal() {
|
||||
document.getElementById("model-create-overlay").style.display = "none";
|
||||
_modelCreateTrap = _removeTrap(_modelCreateTrap);
|
||||
if (_modelCreateTrigger && _modelCreateTrigger.focus)
|
||||
_modelCreateTrigger.focus();
|
||||
_modelCreateTrigger = null;
|
||||
}
|
||||
|
||||
function submitCreateModel() {
|
||||
var alias = document.getElementById("model-alias").value.trim();
|
||||
var modelName = document.getElementById("model-name").value.trim();
|
||||
if (!alias) {
|
||||
_showModelError("Alias is required");
|
||||
return;
|
||||
}
|
||||
if (!modelName) {
|
||||
_showModelError("Model ID is required");
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(alias)) {
|
||||
_showModelError("Alias must be alphanumeric (with . _ -)");
|
||||
return;
|
||||
}
|
||||
|
||||
var capsText = document.getElementById("model-capabilities").value.trim();
|
||||
var caps = {};
|
||||
if (capsText) {
|
||||
try {
|
||||
caps = JSON.parse(capsText);
|
||||
} catch (e) {
|
||||
_showModelError("Invalid JSON in capabilities");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var form = {
|
||||
alias: alias,
|
||||
model: modelName,
|
||||
provider: document.getElementById("model-provider").value,
|
||||
base_url: document.getElementById("model-base-url").value.trim(),
|
||||
context_window:
|
||||
parseInt(document.getElementById("model-ctx-window").value, 10) || 0,
|
||||
capabilities: caps,
|
||||
enabled: document.getElementById("model-enabled").checked,
|
||||
};
|
||||
|
||||
var apiKey = document.getElementById("model-api-key").value;
|
||||
if (apiKey) form.api_key = apiKey;
|
||||
|
||||
var editId = document.getElementById("model-edit-id").value;
|
||||
var method = editId ? "PUT" : "POST";
|
||||
var url = editId
|
||||
? "/v1/api/admin/model-definitions/" + encodeURIComponent(editId)
|
||||
: "/v1/api/admin/model-definitions";
|
||||
|
||||
document.getElementById("model-create-submit").disabled = true;
|
||||
authFetch(url, {
|
||||
method: method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
hideCreateModelModal();
|
||||
showToast(editId ? "Model updated" : "Model created");
|
||||
_flagModelSyncPending();
|
||||
loadAdminModels();
|
||||
})
|
||||
.catch(function (e) {
|
||||
_showModelError(e.message);
|
||||
})
|
||||
.finally(function () {
|
||||
document.getElementById("model-create-submit").disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function _showModelError(msg) {
|
||||
var e = document.getElementById("model-create-error");
|
||||
e.textContent = msg;
|
||||
e.classList.add("is-visible");
|
||||
}
|
||||
|
||||
function _detectResultLine(text, color) {
|
||||
var div = document.createElement("div");
|
||||
div.style.marginTop = "3px";
|
||||
if (color) div.style.color = "var(--" + color + ")";
|
||||
div.textContent = text;
|
||||
return div;
|
||||
}
|
||||
|
||||
function _clearDetectResult() {
|
||||
var rd = document.getElementById("model-detect-result");
|
||||
if (rd) {
|
||||
rd.style.display = "none";
|
||||
rd.textContent = "";
|
||||
rd.style.borderColor = "";
|
||||
}
|
||||
}
|
||||
|
||||
function detectModel() {
|
||||
var btn = document.getElementById("model-detect-btn");
|
||||
var resultDiv = document.getElementById("model-detect-result");
|
||||
btn.disabled = true;
|
||||
btn.setAttribute("aria-busy", "true");
|
||||
btn.textContent = "Detecting\u2026";
|
||||
resultDiv.style.display = "none";
|
||||
resultDiv.textContent = "";
|
||||
|
||||
var form = {
|
||||
provider: document.getElementById("model-provider").value,
|
||||
base_url: document.getElementById("model-base-url").value.trim(),
|
||||
model: document.getElementById("model-name").value.trim(),
|
||||
};
|
||||
var apiKey = document.getElementById("model-api-key").value;
|
||||
if (apiKey) form.api_key = apiKey;
|
||||
var editId = document.getElementById("model-edit-id").value;
|
||||
if (editId) form.definition_id = editId;
|
||||
|
||||
authFetch("/v1/api/admin/model-definitions/detect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Detect failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
resultDiv.style.display = "block";
|
||||
resultDiv.textContent = "";
|
||||
if (d.error && !d.reachable) {
|
||||
resultDiv.appendChild(
|
||||
_detectResultLine("\u2717 Failed: " + d.error, "red"),
|
||||
);
|
||||
resultDiv.style.borderColor = "var(--red)";
|
||||
return;
|
||||
}
|
||||
var line1 = "\u2713 Connected";
|
||||
if (d.available_models && d.available_models.length) {
|
||||
line1 += " \u2014 " + d.available_models.length + " model(s) available";
|
||||
}
|
||||
resultDiv.appendChild(_detectResultLine(line1, "green"));
|
||||
|
||||
if (d.model_found === false) {
|
||||
var models = d.available_models || [];
|
||||
var msg =
|
||||
'\u26A0 Model "' +
|
||||
form.model +
|
||||
'" not found in ' +
|
||||
models.length +
|
||||
" available model(s)";
|
||||
if (models.length > 0) {
|
||||
var shown = models.slice(0, 8);
|
||||
msg += ": " + shown.join(", ");
|
||||
if (models.length > 8)
|
||||
msg += ", \u2026 +" + (models.length - 8) + " more";
|
||||
}
|
||||
resultDiv.appendChild(_detectResultLine(msg, "yellow"));
|
||||
}
|
||||
if (d.context_window) {
|
||||
resultDiv.appendChild(
|
||||
_detectResultLine(
|
||||
"Context window: " + d.context_window.toLocaleString() + " tokens",
|
||||
),
|
||||
);
|
||||
var ctxInput = document.getElementById("model-ctx-window");
|
||||
if (parseInt(ctxInput.value, 10) === 0) {
|
||||
ctxInput.value = d.context_window;
|
||||
}
|
||||
}
|
||||
if (d.server_type) {
|
||||
resultDiv.appendChild(
|
||||
_detectResultLine("Server type: " + d.server_type),
|
||||
);
|
||||
}
|
||||
resultDiv.style.borderColor = "var(--green)";
|
||||
})
|
||||
.catch(function (e) {
|
||||
if (e.message === "auth") return;
|
||||
resultDiv.style.display = "block";
|
||||
resultDiv.textContent = "";
|
||||
resultDiv.appendChild(_detectResultLine("\u2717 " + e.message, "red"));
|
||||
resultDiv.style.borderColor = "var(--red)";
|
||||
})
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
btn.removeAttribute("aria-busy");
|
||||
btn.textContent = "Detect";
|
||||
});
|
||||
}
|
||||
|
||||
/* Capability auto-fill: when the user types a known model name or
|
||||
changes the provider, look up static capabilities and pre-fill
|
||||
context_window and the capabilities textarea. */
|
||||
var _capsTimer = null;
|
||||
function _onModelFieldChange() {
|
||||
clearTimeout(_capsTimer);
|
||||
_capsTimer = setTimeout(function () {
|
||||
var overlay = document.getElementById("model-create-overlay");
|
||||
if (!overlay || overlay.style.display === "none") return;
|
||||
var provider = document.getElementById("model-provider").value;
|
||||
var modelName = document.getElementById("model-name").value.trim();
|
||||
if (!modelName) return;
|
||||
authFetch(
|
||||
"/v1/api/admin/model-capabilities?provider=" +
|
||||
encodeURIComponent(provider) +
|
||||
"&model=" +
|
||||
encodeURIComponent(modelName),
|
||||
)
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
if (!d.known || !d.capabilities) return;
|
||||
var ctxInput = document.getElementById("model-ctx-window");
|
||||
if (
|
||||
parseInt(ctxInput.value, 10) === 0 &&
|
||||
d.capabilities.context_window
|
||||
) {
|
||||
ctxInput.value = d.capabilities.context_window;
|
||||
}
|
||||
var capsInput = document.getElementById("model-capabilities");
|
||||
if (!capsInput.value.trim()) {
|
||||
var caps = Object.assign({}, d.capabilities);
|
||||
delete caps.context_window;
|
||||
delete caps.max_output_tokens;
|
||||
delete caps.token_param;
|
||||
delete caps.supports_streaming;
|
||||
delete caps.supports_tools;
|
||||
var text = JSON.stringify(caps, null, 2);
|
||||
if (text !== "{}") capsInput.value = text;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
/* silent */
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
/* Populate the model name datalist with known model prefixes for the
|
||||
selected provider. Called on page load and provider change. */
|
||||
function _refreshModelSuggestions() {
|
||||
var dl = document.getElementById("model-name-suggestions");
|
||||
if (!dl) return;
|
||||
var provider = document.getElementById("model-provider").value;
|
||||
authFetch(
|
||||
"/v1/api/admin/model-capabilities/known?provider=" +
|
||||
encodeURIComponent(provider),
|
||||
)
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
dl.textContent = "";
|
||||
(d.models || []).forEach(function (m) {
|
||||
var opt = document.createElement("option");
|
||||
opt.value = m;
|
||||
dl.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
dl.textContent = "";
|
||||
});
|
||||
}
|
||||
|
||||
/* Register listeners once at page load */
|
||||
(function () {
|
||||
var nameEl = document.getElementById("model-name");
|
||||
var provEl = document.getElementById("model-provider");
|
||||
if (nameEl) nameEl.addEventListener("input", _onModelFieldChange);
|
||||
if (provEl) {
|
||||
provEl.addEventListener("change", _onModelFieldChange);
|
||||
provEl.addEventListener("change", _refreshModelSuggestions);
|
||||
provEl.addEventListener("change", _clearDetectResult);
|
||||
}
|
||||
/* Clear stale detect results when probe-relevant inputs change */
|
||||
["model-base-url", "model-api-key"].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.addEventListener("input", _clearDetectResult);
|
||||
});
|
||||
})();
|
||||
|
||||
function _flagModelSyncPending() {
|
||||
var btn = document.getElementById("model-sync-btn");
|
||||
if (btn) btn.classList.add("model-sync-pending");
|
||||
}
|
||||
function _clearModelSyncPending() {
|
||||
var btn = document.getElementById("model-sync-btn");
|
||||
if (btn) btn.classList.remove("model-sync-pending");
|
||||
}
|
||||
|
||||
function reloadModelNodes() {
|
||||
var btn = document.getElementById("model-sync-btn");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Syncing...";
|
||||
authFetch("/v1/api/admin/model-definitions/reload", { method: "POST" })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error();
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Model reload dispatched");
|
||||
_clearModelSyncPending();
|
||||
loadAdminModels();
|
||||
})
|
||||
.catch(function () {
|
||||
showToast("Failed to sync models");
|
||||
})
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Sync to Nodes";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1281,8 +1281,31 @@ function showNewWsModal() {
|
||||
.catch(function () {
|
||||
/* ignore — defaults still work */
|
||||
});
|
||||
// Populate model dropdown
|
||||
var modelSelect = document.getElementById("new-ws-model");
|
||||
modelSelect.textContent = "";
|
||||
var defaultOpt = document.createElement("option");
|
||||
defaultOpt.value = "";
|
||||
defaultOpt.textContent = "Default model";
|
||||
modelSelect.appendChild(defaultOpt);
|
||||
authFetch("/v1/api/models")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
(data.models || []).forEach(function (m) {
|
||||
var opt = document.createElement("option");
|
||||
opt.value = m.alias;
|
||||
opt.textContent =
|
||||
m.alias === m.model ? m.alias : m.alias + " (" + m.model + ")";
|
||||
modelSelect.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
/* ignore — default model still works */
|
||||
});
|
||||
document.getElementById("new-ws-name").value = "";
|
||||
document.getElementById("new-ws-model").value = "";
|
||||
modelSelect.value = "";
|
||||
var taskEl = document.getElementById("new-ws-task");
|
||||
taskEl.value = "";
|
||||
var mod =
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
</div>
|
||||
<div class="admin-sidebar-group" data-group="system" role="group" aria-label="System">
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">System</div>
|
||||
<button id="tab-models" class="admin-nav" data-tab="models" role="tab" aria-selected="false" aria-controls="admin-models" tabindex="-1" onclick="switchAdminTab('models')">Models</button>
|
||||
<button id="tab-settings" class="admin-nav" data-tab="settings" role="tab" aria-selected="false" aria-controls="admin-settings" tabindex="-1" onclick="switchAdminTab('settings')">Settings</button>
|
||||
<button id="tab-tls" class="admin-nav" data-tab="tls" role="tab" aria-selected="false" aria-controls="admin-tls" tabindex="-1" onclick="switchAdminTab('tls')">TLS</button>
|
||||
</div>
|
||||
@@ -394,6 +395,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Models Tab -->
|
||||
<div id="admin-models" class="admin-panel" role="tabpanel" aria-labelledby="tab-models" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header">Models</span>
|
||||
<button id="model-sync-btn" class="admin-action-btn admin-action-btn-ghost" onclick="reloadModelNodes()" title="Push model config to all cluster nodes">Sync to Nodes</button>
|
||||
<button class="admin-action-btn" onclick="showCreateModelModal()">+ Add Model</button>
|
||||
</div>
|
||||
<div class="admin-colheaders models-grid" aria-hidden="true">
|
||||
<span class="admin-col">ALIAS</span>
|
||||
<span class="admin-col">MODEL</span>
|
||||
<span class="admin-col">PROVIDER</span>
|
||||
<span class="admin-col">CTX WINDOW</span>
|
||||
<span class="admin-col">STATUS</span>
|
||||
<span class="admin-col">ACTIONS</span>
|
||||
</div>
|
||||
<div id="admin-models-table" role="list" aria-label="Model definitions" aria-live="polite">
|
||||
<div class="dashboard-empty">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div id="admin-settings" class="admin-panel" role="tabpanel" aria-labelledby="tab-settings" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
@@ -523,7 +544,9 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<label for="new-ws-name">Name <span class="label-hint">optional</span></label>
|
||||
<input id="new-ws-name" type="text" placeholder="Auto-generated if empty" autocomplete="off">
|
||||
<label for="new-ws-model">Model <span class="label-hint">optional</span></label>
|
||||
<input id="new-ws-model" type="text" placeholder="Default model" autocomplete="off">
|
||||
<select id="new-ws-model">
|
||||
<option value="">Default model</option>
|
||||
</select>
|
||||
<label for="new-ws-skill">Skill <span class="label-hint">optional</span></label>
|
||||
<select id="new-ws-skill">
|
||||
<option value="">Use defaults</option>
|
||||
@@ -1189,6 +1212,45 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model create/edit modal -->
|
||||
<div id="model-create-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="model-create-title">
|
||||
<div id="model-create-box" class="admin-modal">
|
||||
<h2 id="model-create-title">Add Model</h2>
|
||||
<div id="model-create-error" role="alert" aria-live="assertive"></div>
|
||||
<input type="hidden" id="model-edit-id" value="">
|
||||
<label for="model-alias">Alias</label>
|
||||
<input type="text" id="model-alias" placeholder="e.g. gpt5-prod" maxlength="64" pattern="[a-zA-Z0-9._-]+">
|
||||
<label for="model-name">Model ID <span style="font-weight:400;text-transform:none">(type to autocomplete)</span></label>
|
||||
<input type="text" id="model-name" placeholder="e.g. gpt-5" list="model-name-suggestions">
|
||||
<datalist id="model-name-suggestions"></datalist>
|
||||
<label for="model-provider">Provider</label>
|
||||
<select id="model-provider">
|
||||
<option value="openai">openai</option>
|
||||
<option value="anthropic">anthropic</option>
|
||||
<option value="openai-compatible">openai-compatible</option>
|
||||
</select>
|
||||
<label for="model-base-url">Base URL <span style="font-weight:400;text-transform:none">(empty = provider default)</span></label>
|
||||
<input type="text" id="model-base-url" placeholder="https://api.openai.com/v1">
|
||||
<label for="model-api-key">API Key <span style="font-weight:400;text-transform:none">(write-only, never displayed)</span></label>
|
||||
<input type="password" id="model-api-key" placeholder="sk-..." autocomplete="off">
|
||||
<label for="model-ctx-window">Context Window <span style="font-weight:400;text-transform:none">(0 = auto-detect from model)</span></label>
|
||||
<input type="number" id="model-ctx-window" value="0" min="0">
|
||||
<label for="model-capabilities">Capabilities <span style="font-weight:400;text-transform:none">(JSON)</span></label>
|
||||
<textarea id="model-capabilities" rows="3" placeholder='{"supports_vision": true}' style="font-family:var(--font-mono);font-size:11px"></textarea>
|
||||
<div style="display:flex;gap:20px;margin-top:14px">
|
||||
<label style="margin:0;font-size:12px;color:var(--fg-dim)"><input type="checkbox" id="model-enabled" checked style="margin-right:5px">Enabled</label>
|
||||
</div>
|
||||
<div id="model-detect-area" style="margin-top:14px">
|
||||
<button type="button" id="model-detect-btn" class="modal-cancel" onclick="detectModel()" style="width:auto;padding:7px 16px;font-size:12px" title="Probe endpoint to verify connectivity and discover models">Detect</button>
|
||||
<div id="model-detect-result" role="status" aria-live="polite" style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:12px;border:1px solid var(--border);word-break:break-word"></div>
|
||||
</div>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideCreateModelModal()">Cancel</button>
|
||||
<button id="model-create-submit" class="modal-submit" onclick="submitCreateModel()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/admin.js"></script>
|
||||
<script src="/static/governance.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
Header overrides — wider padding for console layout
|
||||
========================================================================== */
|
||||
#header { padding: 10px 20px; gap: 16px; }
|
||||
#status-bar { font-size: 11px; color: var(--fg-dim); margin-left: auto; }
|
||||
#status-bar.disconnected { color: var(--red); }
|
||||
.header-dim {
|
||||
color: var(--fg-dim);
|
||||
font-weight: 400;
|
||||
@@ -1189,7 +1191,8 @@
|
||||
.admin-modal label.admin-checkbox input:disabled { opacity: 0.4; }
|
||||
.admin-modal input::placeholder, .admin-modal textarea::placeholder { color: var(--fg-dim); opacity: 0.6; }
|
||||
.admin-modal textarea { resize: vertical; min-height: 40px; }
|
||||
.admin-modal [role="alert"] { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
|
||||
.admin-modal [role="alert"] { display: none; color: var(--red); font-size: 12px; margin-bottom: 8px; }
|
||||
.admin-modal [role="alert"].is-visible { display: block; }
|
||||
|
||||
.admin-details { margin-top: 12px; border: 1px solid var(--border); border-radius: 6px; padding: 0 12px; }
|
||||
.admin-details[open] { padding-bottom: 12px; }
|
||||
@@ -1369,6 +1372,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
}
|
||||
.modal-cancel:hover { background: var(--bg-elevated); }
|
||||
.modal-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.modal-cancel:disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
|
||||
.modal-submit {
|
||||
flex: 1;
|
||||
padding: 9px;
|
||||
@@ -1394,7 +1398,8 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
#create-template-overlay, #edit-template-overlay,
|
||||
#memory-detail-overlay,
|
||||
#mcp-create-overlay, #mcp-import-overlay, #mcp-detail-overlay, #mcp-install-overlay,
|
||||
#github-import-overlay {
|
||||
#github-import-overlay,
|
||||
#model-create-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
@@ -2116,7 +2121,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
|
||||
.admin-action-btn-ghost{background:transparent;color:var(--fg-dim);border:1px solid var(--border-strong)}
|
||||
.admin-action-btn-ghost:hover{color:var(--fg);background:var(--bg-highlight)}
|
||||
.mcp-sync-pending{color:var(--yellow)!important;border-color:var(--yellow)!important;animation:mcp-sync-pulse 2s ease-in-out infinite}
|
||||
.mcp-sync-pending,.model-sync-pending{color:var(--yellow)!important;border-color:var(--yellow)!important;animation:mcp-sync-pulse 2s ease-in-out infinite}
|
||||
@keyframes mcp-sync-pulse{0%,100%{border-color:var(--yellow)}50%{border-color:rgba(251,191,36,.3)}}
|
||||
|
||||
/* -- MCP sub-view toggle -------------------------------------------------- */
|
||||
@@ -2183,7 +2188,8 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
.mcp-reg-card{grid-template-columns:1fr;gap:8px}
|
||||
.mcp-reg-card-actions{flex-direction:row;align-items:center}
|
||||
.mcp-registry-search{flex-direction:column}
|
||||
#admin-mcp .admin-toolbar{flex-wrap:wrap;gap:8px}
|
||||
#admin-mcp .admin-toolbar,
|
||||
#admin-models .admin-toolbar{flex-wrap:wrap;gap:8px}
|
||||
#mcp-servers-toolbar{display:flex;gap:6px;width:100%}
|
||||
#admin-skills .admin-toolbar{flex-wrap:wrap;gap:8px}
|
||||
#skill-installed-toolbar{display:flex;gap:6px;width:100%}
|
||||
@@ -2287,6 +2293,31 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
.oidc-detail-panel { margin-left: 8px; }
|
||||
}
|
||||
|
||||
/* -- Models grid --------------------------------------------------------- */
|
||||
.models-grid{grid-template-columns:1.2fr 1.2fr 80px 90px 80px 120px;gap:0 6px}
|
||||
@media(max-width:700px){
|
||||
.models-grid{grid-template-columns:1fr 80px 120px}
|
||||
.models-grid .admin-col:nth-child(2),
|
||||
.models-grid .admin-col:nth-child(3),
|
||||
.models-grid .admin-col:nth-child(4){display:none}
|
||||
}
|
||||
|
||||
/* Model status indicators */
|
||||
.model-status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin-right:6px}
|
||||
.model-status-dot.enabled{background:var(--blue);box-shadow:0 0 6px var(--blue-glow)}
|
||||
.model-status-dot.disabled{background:var(--fg-dim);opacity:.35}
|
||||
|
||||
.model-row-enabled{border-left:3px solid var(--blue)}
|
||||
.model-row-disabled{border-left:3px solid transparent}
|
||||
|
||||
/* Provider badges */
|
||||
.model-provider-badge{display:inline-block;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:2px;background:var(--bg-highlight);border:1px solid var(--border)}
|
||||
.model-provider-openai{color:var(--blue);border-color:rgba(56,189,248,.2)}
|
||||
.model-provider-anthropic{color:var(--magenta);border-color:rgba(192,132,252,.25)}
|
||||
|
||||
/* Model source badge */
|
||||
.scope-db{color:var(--blue);border-color:rgba(56,189,248,.2)}
|
||||
|
||||
/* ==========================================================================
|
||||
Reduced motion — console-specific
|
||||
========================================================================== */
|
||||
@@ -2310,5 +2341,5 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
.mcp-view-btn, .mcp-reg-card, .mcp-install-btn, .mcp-install-source-label { transition: none; }
|
||||
.mcp-registry-search input[type="search"] { transition: none; }
|
||||
.mcp-reg-card-repo { transition: none; }
|
||||
.mcp-sync-pending { animation: none; }
|
||||
.mcp-sync-pending, .model-sync-pending { animation: none; }
|
||||
}
|
||||
|
||||
@@ -80,7 +80,12 @@ class TLSManager:
|
||||
|
||||
setup_metrics(self._event_dispatcher)
|
||||
except ImportError:
|
||||
pass # prometheus_client not installed
|
||||
pass # prometheus_client or lacme.metrics missing
|
||||
except ValueError as exc:
|
||||
if "Duplicated timeseries" in str(exc):
|
||||
log.debug("tls_metrics_already_registered")
|
||||
else:
|
||||
raise
|
||||
|
||||
def _subscribe_events(self) -> None:
|
||||
"""Subscribe structlog handlers to lacme lifecycle events."""
|
||||
@@ -322,7 +327,7 @@ class TLSManager:
|
||||
_require_lacme()
|
||||
from lacme.mtls import server_ssl_context
|
||||
|
||||
return server_ssl_context( # type: ignore[no-any-return]
|
||||
return server_ssl_context( # type: ignore[no-any-return,unused-ignore]
|
||||
cert_pem=self._frontend_bundle.fullchain_pem,
|
||||
key_pem=self._frontend_bundle.key_pem,
|
||||
ca_cert_pem=self.get_root_cert_pem(),
|
||||
@@ -339,7 +344,7 @@ class TLSManager:
|
||||
_require_lacme()
|
||||
from lacme.mtls import client_ssl_context
|
||||
|
||||
return client_ssl_context( # type: ignore[no-any-return]
|
||||
return client_ssl_context( # type: ignore[no-any-return,unused-ignore]
|
||||
cert_pem=self._internal_bundle.cert_pem,
|
||||
key_pem=self._internal_bundle.key_pem,
|
||||
ca_cert_pem=self.get_root_cert_pem(),
|
||||
|
||||
@@ -173,7 +173,12 @@ WRITE_PATHS: frozenset[str] = frozenset(
|
||||
)
|
||||
|
||||
APPROVE_PATHS: frozenset[str] = frozenset(
|
||||
{"/api/approve", "/api/_internal/config-reload", "/api/_internal/mcp-reload"}
|
||||
{
|
||||
"/api/approve",
|
||||
"/api/_internal/config-reload",
|
||||
"/api/_internal/mcp-reload",
|
||||
"/api/_internal/model-reload",
|
||||
}
|
||||
)
|
||||
ADMIN_PREFIX = "/api/admin/"
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ from __future__ import annotations
|
||||
import enum
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
log = get_logger(__name__)
|
||||
@@ -37,6 +39,10 @@ class BackendHealthMonitor:
|
||||
probe_timeout: float = 5.0,
|
||||
failure_threshold: int = 5,
|
||||
cooldown: float = 60.0,
|
||||
*,
|
||||
provider: str = "openai",
|
||||
initial_model: str = "",
|
||||
on_model_changed: Callable[[str, int | None], None] | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._probe_interval = probe_interval
|
||||
@@ -44,6 +50,11 @@ class BackendHealthMonitor:
|
||||
self._failure_threshold = failure_threshold
|
||||
self._cooldown = cooldown
|
||||
|
||||
# Model change detection
|
||||
self._provider = provider
|
||||
self._last_detected_model = initial_model
|
||||
self._on_model_changed = on_model_changed
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._state = CircuitState.CLOSED
|
||||
self._consecutive_failures = 0
|
||||
@@ -197,11 +208,39 @@ class BackendHealthMonitor:
|
||||
def _probe_once(self) -> bool:
|
||||
"""Single probe: call ``client.models.list()``. Returns True on success."""
|
||||
try:
|
||||
self._client.with_options(timeout=self._probe_timeout).models.list()
|
||||
resp = self._client.with_options(timeout=self._probe_timeout).models.list()
|
||||
self._check_model_change(resp)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _check_model_change(self, resp: Any) -> None:
|
||||
"""Compare detected model against last known and fire callback if changed."""
|
||||
if not self._on_model_changed or not resp.data:
|
||||
return
|
||||
try:
|
||||
from turnstone.core.model_registry import (
|
||||
_extract_context_window,
|
||||
_select_best_model,
|
||||
)
|
||||
|
||||
all_ids = [m.id for m in resp.data]
|
||||
selected = _select_best_model(all_ids, self._provider)
|
||||
if selected == self._last_detected_model:
|
||||
return
|
||||
model_obj = next((m for m in resp.data if m.id == selected), None)
|
||||
ctx = _extract_context_window(model_obj, self._provider) if model_obj else None
|
||||
log.info(
|
||||
"Backend model changed: %s -> %s (ctx=%s)",
|
||||
self._last_detected_model,
|
||||
selected,
|
||||
ctx,
|
||||
)
|
||||
self._last_detected_model = selected
|
||||
self._on_model_changed(selected, ctx)
|
||||
except Exception:
|
||||
log.debug("Model change check failed", exc_info=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Metrics
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
+94
-35
@@ -189,6 +189,20 @@ _CRITICAL_RULES: list[_HeuristicRule] = [
|
||||
"This is a two-step variant of pipe-to-shell."
|
||||
),
|
||||
),
|
||||
_HeuristicRule(
|
||||
name="proc-environ-exfil",
|
||||
risk_level="critical",
|
||||
confidence=0.95,
|
||||
recommendation="deny",
|
||||
tool_pattern="bash",
|
||||
arg_patterns=[r"/proc/\d+/environ", r"/proc/self/environ"],
|
||||
intent_template="Process environment exfiltration: {arg_snippet}",
|
||||
reasoning_template=(
|
||||
"Reading /proc/*/environ exposes all environment variables of the "
|
||||
"target process, which may include database credentials, API keys, "
|
||||
"and JWT secrets. This is a credential exfiltration vector."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
# -- High (confidence 0.80, review) ----------------------------------------
|
||||
@@ -606,7 +620,10 @@ def _get_arg_text(func_name: str, func_args: dict[str, object]) -> str:
|
||||
if func_name == "bash":
|
||||
return str(func_args.get("command", ""))
|
||||
if func_name in ("write_file", "edit_file"):
|
||||
return str(func_args.get("path", ""))
|
||||
path = str(func_args.get("path", ""))
|
||||
expanded = os.path.expanduser(path) if path else ""
|
||||
resolved = os.path.realpath(expanded) if expanded else ""
|
||||
return f"{path} {resolved}" if resolved != os.path.abspath(expanded) else path
|
||||
try:
|
||||
return json.dumps(func_args, ensure_ascii=False, separators=(",", ":"))
|
||||
except (TypeError, ValueError):
|
||||
@@ -853,6 +870,10 @@ 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.
|
||||
|
||||
@@ -912,18 +933,12 @@ class IntentJudge:
|
||||
self._model = session_model
|
||||
self._judge_context_window = context_window
|
||||
|
||||
# Executor for timeout-guarded API calls (1 thread — judge is serial)
|
||||
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Release the executor thread pool."""
|
||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
items: list[dict[str, Any]],
|
||||
messages: list[dict[str, Any]],
|
||||
callback: Callable[[IntentVerdict], None],
|
||||
cancel_event: threading.Event | None = None,
|
||||
) -> list[IntentVerdict]:
|
||||
"""Evaluate tool calls. Returns heuristic verdicts immediately.
|
||||
|
||||
@@ -936,6 +951,10 @@ class IntentJudge:
|
||||
``func_args``, ``approval_label``, ``call_id``).
|
||||
messages: Conversation history (OpenAI message format).
|
||||
callback: Called with each LLM verdict (or timeout/error fallback).
|
||||
cancel_event: When set, the daemon judge thread abandons
|
||||
remaining work. Callers should set this after the user
|
||||
has already made an approval decision so the judge does
|
||||
not keep consuming inference resources.
|
||||
|
||||
Returns:
|
||||
List of heuristic verdicts (one per item), available immediately.
|
||||
@@ -958,7 +977,7 @@ class IntentJudge:
|
||||
# Spawn daemon thread for LLM judge
|
||||
thread = threading.Thread(
|
||||
target=self._run_judge,
|
||||
args=(items, messages, heuristic_verdicts, callback),
|
||||
args=(items, messages, heuristic_verdicts, callback, cancel_event),
|
||||
daemon=True,
|
||||
name="intent-judge",
|
||||
)
|
||||
@@ -972,25 +991,44 @@ class IntentJudge:
|
||||
messages: list[dict[str, Any]],
|
||||
heuristic_verdicts: list[IntentVerdict],
|
||||
callback: Callable[[IntentVerdict], None],
|
||||
cancel_event: threading.Event | None = None,
|
||||
) -> None:
|
||||
"""Daemon thread: run LLM judge for each item and invoke callback."""
|
||||
for item, h_verdict in zip(items, heuristic_verdicts, strict=True):
|
||||
try:
|
||||
llm_verdict = self._evaluate_single(item, messages)
|
||||
# Arbitrate: only callback when LLM upgrades the heuristic
|
||||
if llm_verdict and llm_verdict.confidence > h_verdict.confidence:
|
||||
callback(llm_verdict)
|
||||
# else: heuristic already delivered, no duplicate callback
|
||||
except Exception:
|
||||
log.exception(
|
||||
"Judge evaluation failed for %s",
|
||||
item.get("func_name", "?"),
|
||||
)
|
||||
# Evaluation-scoped executor — avoids sharing mutable state with
|
||||
# other daemon threads from concurrent evaluate() calls.
|
||||
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():
|
||||
log.debug("judge.cancelled", remaining=len(items) - idx)
|
||||
return
|
||||
try:
|
||||
llm_verdict = self._evaluate_single(item, messages, cancel_event, executor)
|
||||
if cancel_event and cancel_event.is_set():
|
||||
return
|
||||
# Arbitrate: only callback when LLM upgrades the heuristic
|
||||
if llm_verdict and llm_verdict.confidence > h_verdict.confidence:
|
||||
callback(llm_verdict)
|
||||
# else: heuristic already delivered, no duplicate callback
|
||||
except _ExecutorPoisonedError:
|
||||
# Timeout left the worker stuck — replace the executor
|
||||
# so subsequent items don't queue behind it.
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
|
||||
except Exception:
|
||||
log.exception(
|
||||
"Judge evaluation failed for %s",
|
||||
item.get("func_name", "?"),
|
||||
)
|
||||
finally:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def _evaluate_single(
|
||||
self,
|
||||
item: dict[str, Any],
|
||||
messages: list[dict[str, Any]],
|
||||
cancel_event: threading.Event | None,
|
||||
executor: ThreadPoolExecutor,
|
||||
) -> IntentVerdict | None:
|
||||
"""Run LLM judge for a single tool call. Returns verdict or None."""
|
||||
start = time.monotonic()
|
||||
@@ -1010,16 +1048,20 @@ class IntentJudge:
|
||||
# Prepare context
|
||||
judge_messages = self._prepare_context(item, messages)
|
||||
|
||||
# Prepare tools (only if read_only_tools enabled)
|
||||
# Prepare tools (only if read_only_tools enabled).
|
||||
# Pass raw OpenAI-format schemas — create_completion handles conversion.
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
if self._config.read_only_tools:
|
||||
tools = self._provider.convert_tools(_JUDGE_TOOL_SCHEMAS)
|
||||
tools = _JUDGE_TOOL_SCHEMAS
|
||||
|
||||
# Multi-turn judge loop
|
||||
timeout_budget = self._config.timeout
|
||||
result = None # will hold the last CompletionResult
|
||||
|
||||
for turn in range(_JUDGE_MAX_TURNS):
|
||||
if cancel_event and cancel_event.is_set():
|
||||
return None
|
||||
|
||||
turn_start = time.monotonic()
|
||||
|
||||
is_last_turn = turn == _JUDGE_MAX_TURNS - 1
|
||||
@@ -1043,7 +1085,7 @@ class IntentJudge:
|
||||
# 10 minutes — far too long for an advisory judge on local models.
|
||||
per_call_timeout = max(timeout_budget, 5.0) # at least 5s
|
||||
try:
|
||||
future = self._executor.submit(
|
||||
future = executor.submit(
|
||||
self._provider.create_completion,
|
||||
client=self._client,
|
||||
model=self._model,
|
||||
@@ -1053,17 +1095,24 @@ class IntentJudge:
|
||||
temperature=0.0,
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
result = future.result(timeout=per_call_timeout)
|
||||
# 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:
|
||||
log.warning("Judge LLM call timed out on turn %d (%.0fs)", turn, per_call_timeout)
|
||||
# Abandon the lingering API call and replace the executor so
|
||||
# subsequent items in the batch don't queue behind it.
|
||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=1,
|
||||
thread_name_prefix="judge-api",
|
||||
)
|
||||
return None
|
||||
raise _ExecutorPoisonedError from None
|
||||
except Exception:
|
||||
log.exception("Judge LLM call failed on turn %d", turn)
|
||||
return None
|
||||
@@ -1181,10 +1230,20 @@ class IntentJudge:
|
||||
f"{json.dumps(func_args, indent=2, ensure_ascii=False)}\n```"
|
||||
)
|
||||
|
||||
# FIFO truncation of conversation history (keep most recent)
|
||||
# Trim to messages from the last user message onward — the judge
|
||||
# only needs the immediate request context, not the full history.
|
||||
# This keeps latency bounded as conversations grow.
|
||||
last_user_idx = None
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "user":
|
||||
last_user_idx = i
|
||||
break
|
||||
recent = messages[last_user_idx:] if last_user_idx is not None else messages
|
||||
|
||||
# Apply FIFO budget cap on the trimmed context
|
||||
truncated: list[dict[str, Any]] = []
|
||||
total_chars = 0
|
||||
for msg in reversed(messages):
|
||||
for msg in reversed(recent):
|
||||
content = msg.get("content", "") or ""
|
||||
if isinstance(content, list):
|
||||
content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||
|
||||
@@ -176,6 +176,8 @@ class MCPClientManager:
|
||||
for name, cfg in self._server_configs.items():
|
||||
try:
|
||||
await self._connect_one(name, cfg)
|
||||
except asyncio.CancelledError:
|
||||
raise # propagate so the background task can be cleanly stopped
|
||||
except Exception as exc:
|
||||
log.warning("Failed to connect MCP server '%s'", name, exc_info=True)
|
||||
self._set_error(name, f"{type(exc).__name__}: {exc}")
|
||||
@@ -199,6 +201,49 @@ class MCPClientManager:
|
||||
self._refresh_task = asyncio.get_running_loop().create_task(self._periodic_refresh())
|
||||
|
||||
_CONNECT_TIMEOUT = 30 # seconds — prevents hung connections on broken remotes
|
||||
_TCP_PROBE_TIMEOUT = 5 # seconds — fast TCP pre-flight for HTTP transports
|
||||
|
||||
async def _tcp_probe(self, name: str, url: str) -> None:
|
||||
"""Fast TCP connect check before entering the MCP transport context.
|
||||
|
||||
Fails fast when the server is unreachable, avoiding the anyio
|
||||
cancel-scope orphan bug that causes 100% CPU spin.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise ConnectionError(f"MCP server '{name}' has invalid URL (no hostname): {url}")
|
||||
try:
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
except ValueError:
|
||||
raise ConnectionError(f"MCP server '{name}' has invalid port in URL: {url}") from None
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port),
|
||||
timeout=self._TCP_PROBE_TIMEOUT,
|
||||
)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except (TimeoutError, OSError) as exc:
|
||||
raise ConnectionError(
|
||||
f"MCP server '{name}' unreachable at {host}:{port}: {exc}"
|
||||
) from None
|
||||
|
||||
@staticmethod
|
||||
async def _safe_close_stack(stack: AsyncExitStack) -> None:
|
||||
"""Close an AsyncExitStack, suppressing errors from broken anyio scopes.
|
||||
|
||||
Called from exception handlers — must not raise, otherwise cleanup
|
||||
errors could mask the original exception. CancelledError is caught
|
||||
explicitly because it is the primary failure mode (stray cancel from
|
||||
broken anyio scope) and is BaseException, not Exception.
|
||||
"""
|
||||
try:
|
||||
await asyncio.wait_for(stack.aclose(), timeout=5)
|
||||
except (Exception, asyncio.CancelledError):
|
||||
log.debug("Error closing AsyncExitStack; ignoring", exc_info=True)
|
||||
|
||||
async def _connect_one(self, name: str, cfg: dict[str, Any]) -> None:
|
||||
"""Connect to a single MCP server and discover its tools."""
|
||||
@@ -213,6 +258,13 @@ class MCPClientManager:
|
||||
transport = cfg.get("type", "stdio")
|
||||
try:
|
||||
if transport in ("http", "streamable-http") or "url" in cfg:
|
||||
# Pre-flight TCP check: fail fast before entering the anyio
|
||||
# task group in streamablehttp_client. An immediate connect
|
||||
# failure (ECONNREFUSED) inside the anyio context causes a
|
||||
# CancelledError that escapes asyncio.wait_for and leaves
|
||||
# orphaned cancel-scope tasks spinning at 100% CPU.
|
||||
await self._tcp_probe(name, cfg["url"])
|
||||
|
||||
read, write, _ = await asyncio.wait_for(
|
||||
stack.enter_async_context(
|
||||
streamablehttp_client(url=cfg["url"], headers=cfg.get("headers"))
|
||||
@@ -235,15 +287,25 @@ class MCPClientManager:
|
||||
env=env,
|
||||
)
|
||||
read, write = await stack.enter_async_context(stdio_client(params))
|
||||
except asyncio.CancelledError:
|
||||
# Stray CancelledError from broken anyio cancel scope — treat as
|
||||
# connection failure. But if the task is genuinely being cancelled
|
||||
# (shutdown), re-raise so we don't block teardown.
|
||||
task = asyncio.current_task()
|
||||
if task is not None and task.cancelling():
|
||||
await self._safe_close_stack(stack)
|
||||
raise
|
||||
log.warning("MCP server '%s' connection failed (anyio cancel)", name)
|
||||
await self._safe_close_stack(stack)
|
||||
raise TimeoutError(f"Connection failed for '{name}'") from None
|
||||
except TimeoutError:
|
||||
log.warning(
|
||||
"MCP server '%s' connection timed out after %ds", name, self._CONNECT_TIMEOUT
|
||||
)
|
||||
with contextlib.suppress(Exception):
|
||||
await stack.aclose()
|
||||
await self._safe_close_stack(stack)
|
||||
raise TimeoutError(f"Connection timed out after {self._CONNECT_TIMEOUT}s") from None
|
||||
except Exception:
|
||||
await stack.aclose()
|
||||
await self._safe_close_stack(stack)
|
||||
raise
|
||||
|
||||
# Register notification handler — dispatches tool, resource, and
|
||||
@@ -274,21 +336,27 @@ class MCPClientManager:
|
||||
ClientSession(read, write, message_handler=_on_notification) # type: ignore[arg-type]
|
||||
)
|
||||
except Exception:
|
||||
await stack.aclose()
|
||||
await self._safe_close_stack(stack)
|
||||
raise
|
||||
|
||||
self._per_server_stacks[name] = stack
|
||||
try:
|
||||
await asyncio.wait_for(session.initialize(), timeout=self._CONNECT_TIMEOUT)
|
||||
except asyncio.CancelledError:
|
||||
self._per_server_stacks.pop(name, None)
|
||||
task = asyncio.current_task()
|
||||
if task is not None and task.cancelling():
|
||||
await self._safe_close_stack(stack)
|
||||
raise
|
||||
await self._safe_close_stack(stack)
|
||||
raise TimeoutError(f"MCP handshake failed for '{name}'") from None
|
||||
except TimeoutError:
|
||||
self._per_server_stacks.pop(name, None)
|
||||
with contextlib.suppress(Exception):
|
||||
await stack.aclose()
|
||||
await self._safe_close_stack(stack)
|
||||
raise TimeoutError(f"MCP handshake timed out after {self._CONNECT_TIMEOUT}s") from None
|
||||
except Exception:
|
||||
self._per_server_stacks.pop(name, None)
|
||||
with contextlib.suppress(Exception):
|
||||
await stack.aclose()
|
||||
await self._safe_close_stack(stack)
|
||||
raise
|
||||
self._sessions[name] = session
|
||||
|
||||
@@ -873,8 +941,7 @@ class MCPClientManager:
|
||||
|
||||
async def _close_all_stacks() -> None:
|
||||
for stack in self._per_server_stacks.values():
|
||||
with contextlib.suppress(Exception):
|
||||
await stack.aclose()
|
||||
await self._safe_close_stack(stack)
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(_close_all_stacks(), self._loop)
|
||||
try:
|
||||
@@ -984,10 +1051,7 @@ class MCPClientManager:
|
||||
self._sessions.pop(name, None)
|
||||
stack = self._per_server_stacks.pop(name, None)
|
||||
if stack is not None:
|
||||
try:
|
||||
await asyncio.wait_for(stack.aclose(), timeout=10)
|
||||
except (TimeoutError, Exception):
|
||||
log.warning("Timed out closing MCP server '%s', forcing cleanup", name)
|
||||
await self._safe_close_stack(stack)
|
||||
# Clean up per-server state (on the event loop thread)
|
||||
self._per_server_tools.pop(name, None)
|
||||
self._per_server_resources.pop(name, None)
|
||||
|
||||
@@ -37,7 +37,6 @@ def save_message(
|
||||
role: str,
|
||||
content: str | None,
|
||||
tool_name: str | None = None,
|
||||
tool_args: str | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
provider_data: str | None = None,
|
||||
tool_calls: str | None = None,
|
||||
@@ -49,7 +48,6 @@ def save_message(
|
||||
role,
|
||||
content,
|
||||
tool_name,
|
||||
tool_args,
|
||||
tool_call_id,
|
||||
provider_data,
|
||||
tool_calls=tool_calls,
|
||||
@@ -67,6 +65,23 @@ def load_messages(ws_id: str) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def delete_messages_after(ws_id: str, keep_count: int) -> int:
|
||||
"""Delete conversation rows beyond the first *keep_count* rows.
|
||||
|
||||
Returns the number of rows deleted, or 0 on error.
|
||||
"""
|
||||
try:
|
||||
return get_storage().delete_messages_after(ws_id, keep_count)
|
||||
except Exception:
|
||||
log.warning(
|
||||
"Failed to delete messages after count=%d for ws=%s",
|
||||
keep_count,
|
||||
ws_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# -- Workstream management ----------------------------------------------------
|
||||
|
||||
|
||||
@@ -252,10 +267,10 @@ def update_workstream_title(ws_id: str, title: str) -> None:
|
||||
# -- Conversation search -------------------------------------------------------
|
||||
|
||||
|
||||
def search_history(query: str, limit: int = 20) -> list[Any]:
|
||||
def search_history(query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
"""Search conversation history."""
|
||||
try:
|
||||
return get_storage().search_history(query, limit)
|
||||
return get_storage().search_history(query, limit, offset)
|
||||
except Exception:
|
||||
log.warning("Failed to search history", exc_info=True)
|
||||
return []
|
||||
@@ -316,6 +331,18 @@ def save_structured_memory(
|
||||
return "", None
|
||||
|
||||
|
||||
def get_structured_memory_by_name(
|
||||
name: str, scope: str = "global", scope_id: str = ""
|
||||
) -> dict[str, str] | None:
|
||||
"""Retrieve a single structured memory by name+scope. Returns full content."""
|
||||
name = normalize_key(name)
|
||||
try:
|
||||
return get_storage().get_structured_memory_by_name(name, scope, scope_id)
|
||||
except Exception:
|
||||
log.warning("Failed to get structured memory name=%s", name, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def delete_structured_memory(name: str, scope: str = "global", scope_id: str = "") -> bool:
|
||||
"""Delete a structured memory by name+scope. Returns True if existed."""
|
||||
name = normalize_key(name)
|
||||
|
||||
@@ -50,6 +50,13 @@ NUDGE_TOOL_ERROR = (
|
||||
"previous session. Use memory(action='search') to find relevant guidance."
|
||||
)
|
||||
|
||||
NUDGE_REPEAT = (
|
||||
"You just called the same tool with the same arguments as a previous "
|
||||
"call in this conversation. Repeating the exact same action will produce "
|
||||
"the same result. Stop and reconsider your approach — try a different "
|
||||
"tool, different arguments, or ask the user for clarification."
|
||||
)
|
||||
|
||||
_NUDGE_MAP: dict[str, str] = {
|
||||
"correction": NUDGE_CORRECTION,
|
||||
"denial": NUDGE_DENIAL,
|
||||
@@ -57,6 +64,7 @@ _NUDGE_MAP: dict[str, str] = {
|
||||
"completion": NUDGE_COMPLETION,
|
||||
"start": NUDGE_START,
|
||||
"tool_error": NUDGE_TOOL_ERROR,
|
||||
"repeat": NUDGE_REPEAT,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -34,6 +34,7 @@ class ModelConfig:
|
||||
context_window: int = 32768
|
||||
provider: str = "openai"
|
||||
capabilities: dict[str, Any] = field(default_factory=dict)
|
||||
source: str = "" # "config", "db", or "" (CLI default)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -81,9 +82,9 @@ class ModelRegistry:
|
||||
|
||||
def get_client(self, alias: str) -> Any:
|
||||
"""Get or lazily create an API client for *alias*. Thread-safe."""
|
||||
if alias not in self._models:
|
||||
raise ValueError(f"Unknown model alias: {alias}")
|
||||
with self._client_lock:
|
||||
if alias not in self._models:
|
||||
raise ValueError(f"Unknown model alias: {alias}")
|
||||
if alias not in self._clients:
|
||||
cfg = self._models[alias]
|
||||
self._clients[alias] = create_client(
|
||||
@@ -93,9 +94,9 @@ class ModelRegistry:
|
||||
|
||||
def get_provider(self, alias: str) -> LLMProvider:
|
||||
"""Get the ``LLMProvider`` for *alias*. Thread-safe, cached."""
|
||||
if alias not in self._models:
|
||||
raise ValueError(f"Unknown model alias: {alias}")
|
||||
with self._client_lock:
|
||||
if alias not in self._models:
|
||||
raise ValueError(f"Unknown model alias: {alias}")
|
||||
if alias not in self._providers:
|
||||
cfg = self._models[alias]
|
||||
self._providers[alias] = create_provider(cfg.provider)
|
||||
@@ -129,8 +130,46 @@ class ModelRegistry:
|
||||
"""Number of registered models."""
|
||||
return len(self._models)
|
||||
|
||||
@property
|
||||
def models(self) -> dict[str, ModelConfig]:
|
||||
"""Return a copy of the models dict (public accessor for reload)."""
|
||||
return dict(self._models)
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
def reload(
|
||||
self,
|
||||
models: dict[str, ModelConfig],
|
||||
default: str,
|
||||
fallback: list[str] | None = None,
|
||||
agent_model: str | None = None,
|
||||
) -> None:
|
||||
"""Hot-reload all model configs. Thread-safe; clears cached clients.
|
||||
|
||||
Validates arguments before mutating state so a bad reload
|
||||
does not leave the registry in an inconsistent state.
|
||||
"""
|
||||
if not models:
|
||||
raise ValueError("ModelRegistry requires at least one model config")
|
||||
if default not in models:
|
||||
raise ValueError(f"Default model '{default}' not found in registry")
|
||||
if fallback:
|
||||
for alias in fallback:
|
||||
if alias not in models:
|
||||
raise ValueError(f"Fallback model '{alias}' not found in registry")
|
||||
if agent_model and agent_model not in models:
|
||||
raise ValueError(f"Agent model '{agent_model}' not found in registry")
|
||||
with self._client_lock:
|
||||
self._models = dict(models)
|
||||
self.default = default
|
||||
self.fallback = list(fallback) if fallback else []
|
||||
self.agent_model = agent_model
|
||||
for client in self._clients.values():
|
||||
if hasattr(client, "close"):
|
||||
client.close()
|
||||
self._clients.clear()
|
||||
self._providers.clear()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Close all cached client connections."""
|
||||
with self._client_lock:
|
||||
@@ -146,32 +185,82 @@ class ModelRegistry:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_env_vars(value: str) -> str:
|
||||
"""Expand ``${VAR}`` patterns in *value* using environment variables.
|
||||
|
||||
Unresolved variables are replaced with empty strings.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
def _replace(m: re.Match[str]) -> str:
|
||||
return os.environ.get(m.group(1), "")
|
||||
|
||||
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", _replace, value)
|
||||
|
||||
|
||||
def load_model_registry(
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
context_window: int = 32768,
|
||||
provider: str = "openai",
|
||||
storage: Any | None = None,
|
||||
) -> ModelRegistry:
|
||||
"""Build a ModelRegistry from CLI args and ``config.toml``.
|
||||
"""Build a ModelRegistry from CLI args, ``config.toml``, and database.
|
||||
|
||||
Precedence:
|
||||
Precedence (highest to lowest):
|
||||
|
||||
1. ``[models.*]`` sections in config.toml define named models.
|
||||
2. CLI ``--base-url`` / ``--api-key`` / ``--model`` always create a
|
||||
``"default"`` entry (overrides any ``[models.default]`` section).
|
||||
3. ``[model].default``, ``[model].fallback``, ``[model].agent_model``
|
||||
1. ``[models.*]`` sections in config.toml define named models
|
||||
(``source="config"``). These override DB entries with the same
|
||||
alias in-memory only — the DB rows are never modified.
|
||||
2. Database model definitions (``source="db"``), loaded when
|
||||
*storage* is provided.
|
||||
3. CLI ``--base-url`` / ``--api-key`` / ``--model`` always create a
|
||||
``"default"`` entry.
|
||||
4. ``[model].default``, ``[model].fallback``, ``[model].agent_model``
|
||||
control routing.
|
||||
4. If no ``[models.*]`` sections exist, a single-entry registry is built
|
||||
from the CLI args.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
cfg = load_config()
|
||||
models_section: dict[str, Any] = cfg.get("models", {})
|
||||
model_section: dict[str, Any] = cfg.get("model", {})
|
||||
|
||||
configs: dict[str, ModelConfig] = {}
|
||||
|
||||
# Build configs from [models.*] sections
|
||||
# 1. Load DB model definitions (lowest priority, overridden by config.toml)
|
||||
if storage is not None:
|
||||
try:
|
||||
for row in storage.list_model_definitions(enabled_only=True):
|
||||
alias = row["alias"]
|
||||
caps: dict[str, Any] = {}
|
||||
if row.get("capabilities"):
|
||||
try:
|
||||
parsed = _json.loads(row["capabilities"])
|
||||
if isinstance(parsed, dict):
|
||||
caps = parsed
|
||||
except (_json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
row_provider = row.get("provider", "openai")
|
||||
row_model = row["model"]
|
||||
# 0 = auto-detect: inherit CLI-detected context_window,
|
||||
# same fallback chain as config.toml models
|
||||
row_ctx = row.get("context_window", 0) or context_window
|
||||
configs[alias] = ModelConfig(
|
||||
alias=alias,
|
||||
base_url=_resolve_env_vars(row.get("base_url", "")),
|
||||
api_key=_resolve_env_vars(row.get("api_key", "")),
|
||||
model=row_model,
|
||||
context_window=row_ctx,
|
||||
provider=row_provider,
|
||||
capabilities=caps,
|
||||
source="db",
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Failed to load model definitions from storage", exc_info=True)
|
||||
|
||||
# 2. Build configs from [models.*] sections (overrides DB for same alias)
|
||||
for alias, entry in models_section.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
@@ -189,17 +278,20 @@ def load_model_registry(
|
||||
capabilities=entry.get("capabilities", {})
|
||||
if isinstance(entry.get("capabilities"), dict)
|
||||
else {},
|
||||
source="config",
|
||||
)
|
||||
|
||||
# Ensure a "default" entry from CLI args
|
||||
configs["default"] = ModelConfig(
|
||||
alias="default",
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
context_window=context_window,
|
||||
provider=provider,
|
||||
)
|
||||
# 3. Ensure a "default" entry from CLI args (only if not already defined
|
||||
# by config.toml or DB — those take precedence)
|
||||
if "default" not in configs:
|
||||
configs["default"] = ModelConfig(
|
||||
alias="default",
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
context_window=context_window,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
# Determine default alias
|
||||
default_alias = model_section.get("default", "default")
|
||||
@@ -274,6 +366,28 @@ def _select_best_model(model_ids: list[str], provider: str) -> str:
|
||||
return model_ids[0]
|
||||
|
||||
|
||||
def _extract_context_window(model_obj: Any, provider: str) -> int | None:
|
||||
"""Extract context window from a model object returned by ``/v1/models``.
|
||||
|
||||
Handles Anthropic (static capability table), vLLM (``max_model_len``),
|
||||
and llama.cpp (``meta.n_ctx_train``). Returns ``None`` when not available.
|
||||
"""
|
||||
if provider == "anthropic":
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
return AnthropicProvider().get_capabilities(model_obj.id).context_window
|
||||
model_data = model_obj.model_dump()
|
||||
max_len = model_data.get("max_model_len")
|
||||
if isinstance(max_len, int) and max_len > 0:
|
||||
return max_len
|
||||
meta = model_data.get("meta")
|
||||
if isinstance(meta, dict):
|
||||
n_ctx = meta.get("n_ctx_train")
|
||||
if isinstance(n_ctx, int) and n_ctx > 0:
|
||||
return n_ctx
|
||||
return None
|
||||
|
||||
|
||||
def detect_model(
|
||||
client: Any,
|
||||
log_fn: Any = print,
|
||||
@@ -319,18 +433,7 @@ def detect_model(
|
||||
log_fn(f"Available models: {', '.join(all_ids)}")
|
||||
log_fn(f"Using: {m.id} (override with --model)")
|
||||
|
||||
ctx: int | None = None
|
||||
if provider == "anthropic":
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
ctx = AnthropicProvider().get_capabilities(m.id).context_window
|
||||
else:
|
||||
# OpenAI-compatible: extract from backend metadata (llama.cpp, vLLM)
|
||||
meta = m.model_dump().get("meta")
|
||||
if isinstance(meta, dict):
|
||||
n_ctx = meta.get("n_ctx_train")
|
||||
if isinstance(n_ctx, int) and n_ctx > 0:
|
||||
ctx = n_ctx
|
||||
ctx = _extract_context_window(m, provider)
|
||||
return m.id, ctx
|
||||
except SystemExit:
|
||||
raise
|
||||
@@ -342,3 +445,114 @@ def detect_model(
|
||||
log_fn(f"Warning: Could not connect to LLM backend: {e}")
|
||||
log_fn("Starting in degraded mode — requests will fail until backend is reachable.")
|
||||
return None, None
|
||||
|
||||
|
||||
def probe_model_endpoint(
|
||||
provider: str,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
target_model: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Stateless probe of a model endpoint.
|
||||
|
||||
Creates a temporary SDK client, calls ``/v1/models``, and returns
|
||||
reachability status, available model IDs, detected context window,
|
||||
and server type. Used by the admin *Detect* button — never persists
|
||||
state or stores the API key.
|
||||
"""
|
||||
from turnstone.core.providers import create_client
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"reachable": False,
|
||||
"model_found": None,
|
||||
"available_models": [],
|
||||
"context_window": None,
|
||||
"server_type": None,
|
||||
"error": None,
|
||||
}
|
||||
client = None
|
||||
try:
|
||||
client = create_client(provider, base_url=base_url, api_key=api_key)
|
||||
fast = client.with_options(timeout=10.0, max_retries=0)
|
||||
models = fast.models.list()
|
||||
if not models.data:
|
||||
result["reachable"] = True
|
||||
result["error"] = "No models found at endpoint"
|
||||
return result
|
||||
|
||||
all_ids = [m.id for m in models.data]
|
||||
result["reachable"] = True
|
||||
result["available_models"] = all_ids
|
||||
|
||||
# Determine which model to inspect for context_window
|
||||
if target_model:
|
||||
result["model_found"] = target_model in all_ids
|
||||
inspect_id = target_model if result["model_found"] else all_ids[0]
|
||||
else:
|
||||
inspect_id = all_ids[0]
|
||||
|
||||
inspect_obj = next((m for m in models.data if m.id == inspect_id), None)
|
||||
|
||||
# --- context window detection ---
|
||||
if provider == "anthropic":
|
||||
from turnstone.core.providers import lookup_model_capabilities
|
||||
|
||||
known = lookup_model_capabilities("anthropic", inspect_id)
|
||||
if known is not None:
|
||||
result["context_window"] = known["context_window"]
|
||||
result["server_type"] = "anthropic"
|
||||
else:
|
||||
# OpenAI-compatible path
|
||||
_detect_openai_compat(result, inspect_obj, inspect_id, base_url)
|
||||
except Exception as exc:
|
||||
err_msg = str(exc)
|
||||
if len(err_msg) > 500:
|
||||
err_msg = err_msg[:500] + "..."
|
||||
result["error"] = err_msg
|
||||
finally:
|
||||
if client is not None and hasattr(client, "close"):
|
||||
client.close()
|
||||
return result
|
||||
|
||||
|
||||
def _detect_openai_compat(
|
||||
result: dict[str, Any],
|
||||
model_obj: Any,
|
||||
model_id: str,
|
||||
base_url: str,
|
||||
) -> None:
|
||||
"""Fill context_window and server_type for an OpenAI-compatible endpoint."""
|
||||
|
||||
meta: dict[str, Any] | None = None
|
||||
owned_by: str = ""
|
||||
dumped: dict[str, Any] = {}
|
||||
if model_obj is not None:
|
||||
dumped = model_obj.model_dump()
|
||||
raw_meta = dumped.get("meta")
|
||||
if isinstance(raw_meta, dict):
|
||||
meta = raw_meta
|
||||
owned_by = str(dumped.get("owned_by", ""))
|
||||
|
||||
# Context window: prefer backend metadata, fall back to static table.
|
||||
if model_obj is not None:
|
||||
ctx = _extract_context_window(model_obj, "openai")
|
||||
if ctx is not None:
|
||||
result["context_window"] = ctx
|
||||
if result["context_window"] is None:
|
||||
from turnstone.core.providers import lookup_model_capabilities
|
||||
|
||||
known = lookup_model_capabilities("openai", model_id)
|
||||
if known is not None:
|
||||
result["context_window"] = known["context_window"]
|
||||
|
||||
# Server type heuristics
|
||||
if base_url and "api.openai.com" in base_url:
|
||||
result["server_type"] = "openai"
|
||||
elif meta is not None and "n_ctx_train" in meta:
|
||||
result["server_type"] = "llama.cpp"
|
||||
elif "sglang" in owned_by.lower():
|
||||
result["server_type"] = "sglang"
|
||||
elif "/" in (model_id or ""):
|
||||
result["server_type"] = "vllm"
|
||||
else:
|
||||
result["server_type"] = "openai-compatible"
|
||||
|
||||
@@ -51,11 +51,19 @@ _RE_PRIVATE_KEY_BLOCK = re.compile(
|
||||
r"-----END\s+(?:RSA\s+|EC\s+|OPENSSH\s+|PGP\s+)?PRIVATE\s+KEY-----",
|
||||
)
|
||||
_RE_CONNECTION_STRING = re.compile(
|
||||
r"(?:postgresql|mysql|mongodb|redis|amqp)://[^:@\s]+:[^@\s]+@",
|
||||
r"(?:postgresql\+?(?:psycopg)?|mysql|mongodb|redis|amqp|sqlite)://[^:@\s]+:[^@\s]+@",
|
||||
)
|
||||
_RE_ENV_SECRET_LINE = re.compile(r"[A-Z][A-Z_0-9]+=\S+")
|
||||
_RE_ENV_SECRET_KEY = re.compile(
|
||||
r"(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL)(?:_|$)|(?:^|_)KEY(?:_|$)",
|
||||
r"(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL|DSN)(?:_|$)"
|
||||
r"|(?:^|_)KEY(?:_|$)"
|
||||
r"|^(?:DATABASE_URL|TURNSTONE_DB_URL|DB_URL)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_JSON_SECRET = re.compile(
|
||||
r'"(?:api_key|apikey|api_secret|secret_key|secret|password|passwd|'
|
||||
r"token|access_token|refresh_token|auth_token|private_key|"
|
||||
r'client_secret|webhook_secret|signing_key|encryption_key)"\s*:\s*"([^"]{8,})"',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -228,6 +236,15 @@ def _check_credentials(
|
||||
found = True
|
||||
risk = "high"
|
||||
|
||||
if _RE_JSON_SECRET.search(text):
|
||||
_add_flag(flags, "credential_leak")
|
||||
flags.append("json_secret_leak")
|
||||
ann.append(
|
||||
"Output contains JSON with secret-bearing keys (api_key, password, token, etc.)."
|
||||
)
|
||||
found = True
|
||||
risk = "high"
|
||||
|
||||
return risk, _redact_credentials(text) if found else None
|
||||
|
||||
|
||||
@@ -247,6 +264,15 @@ def _redact_credentials(text: str) -> str:
|
||||
return key + "=[REDACTED:secret]" if _RE_ENV_SECRET_KEY.search(key) else m.group()
|
||||
|
||||
result = _RE_ENV_SECRET_LINE.sub(_redact_env, result)
|
||||
|
||||
def _redact_json_secret(m: re.Match[str]) -> str:
|
||||
# Positional replacement to avoid corrupting key when value == key name
|
||||
start = m.start(1) - m.start()
|
||||
end = m.end(1) - m.start()
|
||||
full = m.group()
|
||||
return full[:start] + "[REDACTED:secret]" + full[end:]
|
||||
|
||||
result = _RE_JSON_SECRET.sub(_redact_json_secret, result)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ __all__ = [
|
||||
"UsageInfo",
|
||||
"create_client",
|
||||
"create_provider",
|
||||
"list_known_models",
|
||||
"lookup_model_capabilities",
|
||||
]
|
||||
|
||||
# Singleton instances (stateless, safe to share)
|
||||
@@ -36,7 +38,7 @@ _anthropic_provider: LLMProvider | None = None
|
||||
def create_provider(provider_name: str) -> LLMProvider:
|
||||
"""Return a provider adapter for the given provider name. Thread-safe."""
|
||||
global _anthropic_provider # noqa: PLW0603
|
||||
if provider_name == "openai":
|
||||
if provider_name in ("openai", "openai-compatible"):
|
||||
return _openai_provider
|
||||
if provider_name == "anthropic":
|
||||
with _provider_lock:
|
||||
@@ -45,15 +47,19 @@ def create_provider(provider_name: str) -> LLMProvider:
|
||||
|
||||
_anthropic_provider = AnthropicProvider()
|
||||
return _anthropic_provider
|
||||
raise ValueError(f"Unknown provider: {provider_name!r}. Supported: openai, anthropic")
|
||||
raise ValueError(
|
||||
f"Unknown provider: {provider_name!r}. Supported: openai, anthropic, openai-compatible"
|
||||
)
|
||||
|
||||
|
||||
def create_client(provider_name: str, *, base_url: str, api_key: str) -> Any:
|
||||
"""Create an SDK client for the given provider."""
|
||||
if provider_name == "openai":
|
||||
if provider_name in ("openai", "openai-compatible"):
|
||||
from openai import OpenAI
|
||||
|
||||
return OpenAI(base_url=base_url, api_key=api_key)
|
||||
if base_url:
|
||||
return OpenAI(base_url=base_url, api_key=api_key)
|
||||
return OpenAI(api_key=api_key)
|
||||
if provider_name == "anthropic":
|
||||
from turnstone.core.providers._anthropic import _ensure_anthropic
|
||||
|
||||
@@ -62,4 +68,42 @@ def create_client(provider_name: str, *, base_url: str, api_key: str) -> Any:
|
||||
if base_url and base_url != "https://api.anthropic.com":
|
||||
kwargs["base_url"] = base_url
|
||||
return anthropic.Anthropic(**kwargs)
|
||||
raise ValueError(f"Unknown provider: {provider_name!r}. Supported: openai, anthropic")
|
||||
raise ValueError(
|
||||
f"Unknown provider: {provider_name!r}. Supported: openai, anthropic, openai-compatible"
|
||||
)
|
||||
|
||||
|
||||
def lookup_model_capabilities(provider: str, model: str) -> dict[str, Any] | None:
|
||||
"""Return static capabilities for a known model, or ``None`` if unknown.
|
||||
|
||||
The returned dict has JSON-friendly values (tuples converted to lists).
|
||||
Returns ``None`` for ``openai-compatible`` (no static table for local models).
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
if provider == "openai-compatible":
|
||||
return None
|
||||
prov = create_provider(provider)
|
||||
caps = prov.get_capabilities(model)
|
||||
default = prov.get_capabilities("")
|
||||
if caps is default:
|
||||
return None
|
||||
result = dataclasses.asdict(caps)
|
||||
# Convert tuples to lists for JSON serialisation
|
||||
for key, val in result.items():
|
||||
if isinstance(val, tuple):
|
||||
result[key] = list(val)
|
||||
return result
|
||||
|
||||
|
||||
def list_known_models(provider: str) -> list[str]:
|
||||
"""Return the model name prefixes in the static capability table."""
|
||||
if provider == "openai":
|
||||
from turnstone.core.providers._openai import _OPENAI_CAPABILITIES
|
||||
|
||||
return sorted(_OPENAI_CAPABILITIES.keys())
|
||||
if provider == "anthropic":
|
||||
from turnstone.core.providers._anthropic import _ANTHROPIC_CAPABILITIES
|
||||
|
||||
return sorted(_ANTHROPIC_CAPABILITIES.keys())
|
||||
return []
|
||||
|
||||
@@ -7,6 +7,8 @@ The ``anthropic`` SDK is imported lazily so it remains an optional dependency.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.core.providers._protocol import (
|
||||
@@ -21,6 +23,8 @@ from turnstone.core.providers._protocol import (
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_anthropic() -> Any:
|
||||
"""Lazy import anthropic SDK, raising helpful error if not installed."""
|
||||
@@ -80,7 +84,7 @@ _ANTHROPIC_DEFAULT = ModelCapabilities(
|
||||
|
||||
_ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
"claude-opus-4-6": ModelCapabilities(
|
||||
context_window=200000,
|
||||
context_window=1000000,
|
||||
max_output_tokens=128000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="adaptive",
|
||||
@@ -91,7 +95,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-sonnet-4-6": ModelCapabilities(
|
||||
context_window=200000,
|
||||
context_window=1000000,
|
||||
max_output_tokens=64000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="adaptive",
|
||||
@@ -127,24 +131,6 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_web_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-opus-4": ModelCapabilities(
|
||||
context_window=200000,
|
||||
max_output_tokens=32000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-sonnet-4": ModelCapabilities(
|
||||
context_window=200000,
|
||||
max_output_tokens=64000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -286,6 +272,7 @@ class AnthropicProvider:
|
||||
"""
|
||||
system_parts: list[str] = []
|
||||
converted: list[dict[str, Any]] = []
|
||||
pending_orphan_results: list[dict[str, Any]] = []
|
||||
|
||||
i = 0
|
||||
while i < len(messages):
|
||||
@@ -299,11 +286,50 @@ class AnthropicProvider:
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
# Safety: flush any unconsumed synthetic results from a prior
|
||||
# assistant message (should not happen with well-formed data).
|
||||
if pending_orphan_results:
|
||||
converted.append({"role": "user", "content": pending_orphan_results})
|
||||
pending_orphan_results = []
|
||||
# If raw provider content was preserved, pass it through verbatim
|
||||
# so encrypted_content/encrypted_index from web search are retained
|
||||
provider_content = msg.get("_provider_content")
|
||||
if provider_content:
|
||||
converted.append({"role": "assistant", "content": provider_content})
|
||||
# Check for orphaned tool_use in provider content too
|
||||
if isinstance(provider_content, list):
|
||||
pc_tool_ids = [
|
||||
b["id"]
|
||||
for b in provider_content
|
||||
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id")
|
||||
]
|
||||
if pc_tool_ids:
|
||||
j = i + 1
|
||||
result_ids_pc: set[str] = set()
|
||||
while j < len(messages) and messages[j]["role"] == "tool":
|
||||
tc_id = messages[j].get("tool_call_id", "")
|
||||
if tc_id:
|
||||
result_ids_pc.add(tc_id)
|
||||
j += 1
|
||||
orphaned_pc = [uid for uid in pc_tool_ids if uid not in result_ids_pc]
|
||||
if orphaned_pc:
|
||||
log.debug(
|
||||
"Synthesizing %d tool_result(s) for orphaned provider_content tool_use IDs",
|
||||
len(orphaned_pc),
|
||||
)
|
||||
synthetic_pc = [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": uid,
|
||||
"content": "Tool execution was cancelled.",
|
||||
"is_error": True,
|
||||
}
|
||||
for uid in orphaned_pc
|
||||
]
|
||||
if j == i + 1:
|
||||
converted.append({"role": "user", "content": synthetic_pc})
|
||||
else:
|
||||
pending_orphan_results = synthetic_pc
|
||||
i += 1
|
||||
continue
|
||||
|
||||
@@ -328,27 +354,105 @@ class AnthropicProvider:
|
||||
)
|
||||
if content_blocks:
|
||||
converted.append({"role": "assistant", "content": content_blocks})
|
||||
|
||||
# Repair orphaned tool_use blocks: if this assistant message
|
||||
# has tool_use blocks but the next messages don't provide
|
||||
# matching tool_results, synthesize error results. This
|
||||
# happens when a cancel interrupts tool execution — the
|
||||
# assistant message is saved to DB before tools run, but
|
||||
# GenerationCancelled prevents tool results from being created.
|
||||
# Collect IDs in order, skip empty IDs (from malformed tool calls).
|
||||
tool_use_ids = [
|
||||
b["id"] for b in content_blocks if b.get("type") == "tool_use" and b.get("id")
|
||||
]
|
||||
if tool_use_ids:
|
||||
# Peek ahead to collect tool_result IDs
|
||||
j = i + 1
|
||||
result_ids: set[str] = set()
|
||||
while j < len(messages) and messages[j]["role"] == "tool":
|
||||
tc_id = messages[j].get("tool_call_id", "")
|
||||
if tc_id:
|
||||
result_ids.add(tc_id)
|
||||
j += 1
|
||||
orphaned = [uid for uid in tool_use_ids if uid not in result_ids]
|
||||
if orphaned:
|
||||
log.debug(
|
||||
"Synthesizing %d tool_result(s) for orphaned tool_use IDs",
|
||||
len(orphaned),
|
||||
)
|
||||
# Store for deferred injection — synthetic results are
|
||||
# appended after any real tool results so
|
||||
# _merge_consecutive produces them in tool_use order.
|
||||
synthetic = [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": uid,
|
||||
"content": "Tool execution was cancelled.",
|
||||
"is_error": True,
|
||||
}
|
||||
for uid in orphaned
|
||||
]
|
||||
if j == i + 1:
|
||||
# No real tool messages follow — inject immediately
|
||||
converted.append({"role": "user", "content": synthetic})
|
||||
else:
|
||||
# Real tool messages follow — they'll be converted
|
||||
# next iteration. Stash synthetic results to append
|
||||
# after them.
|
||||
pending_orphan_results = synthetic
|
||||
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
# Anthropic: tool results are content blocks in a user message
|
||||
# Anthropic: tool results are content blocks in a user message.
|
||||
# Collect valid tool_use IDs from the preceding assistant message
|
||||
# so we can drop orphaned tool_results that have no matching
|
||||
# tool_use (e.g. from compaction boundary, old cancel stripping).
|
||||
prev_tool_use_ids: set[str] = set()
|
||||
if converted and converted[-1].get("role") == "assistant":
|
||||
prev_content = converted[-1].get("content", [])
|
||||
if isinstance(prev_content, list):
|
||||
for block in prev_content:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_use":
|
||||
bid = block.get("id", "")
|
||||
if bid:
|
||||
prev_tool_use_ids.add(bid)
|
||||
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
while i < len(messages) and messages[i]["role"] == "tool":
|
||||
tool_msg = messages[i]
|
||||
tc_id = tool_msg.get("tool_call_id", "")
|
||||
# Drop orphaned tool_results with no matching tool_use.
|
||||
# When prev_tool_use_ids is empty (no preceding assistant
|
||||
# tool_use), let all results through — avoids false drops
|
||||
# from unexpected message ordering.
|
||||
if prev_tool_use_ids and tc_id not in prev_tool_use_ids:
|
||||
log.debug(
|
||||
"Dropping orphaned tool_result (no matching tool_use): %s",
|
||||
tc_id,
|
||||
)
|
||||
i += 1
|
||||
continue
|
||||
content = tool_msg.get("content", "")
|
||||
# Convert image_url parts to Anthropic image format
|
||||
if isinstance(content, list):
|
||||
content = self._convert_content_parts(content)
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_msg.get("tool_call_id", ""),
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
result_block: dict[str, Any] = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tc_id,
|
||||
"content": content,
|
||||
}
|
||||
if tool_msg.get("is_error"):
|
||||
result_block["is_error"] = True
|
||||
tool_results.append(result_block)
|
||||
i += 1
|
||||
converted.append({"role": "user", "content": tool_results})
|
||||
# Append any deferred synthetic results after real ones
|
||||
if pending_orphan_results:
|
||||
tool_results.extend(pending_orphan_results)
|
||||
pending_orphan_results = []
|
||||
if tool_results:
|
||||
converted.append({"role": "user", "content": tool_results})
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
@@ -459,6 +563,7 @@ class AnthropicProvider:
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
cancel_ref: list[Any] | None = None,
|
||||
) -> Iterator[StreamChunk]:
|
||||
_ensure_anthropic()
|
||||
caps = self.get_capabilities(model)
|
||||
@@ -476,8 +581,25 @@ class AnthropicProvider:
|
||||
deferred_names,
|
||||
)
|
||||
|
||||
with client.messages.stream(**kwargs) as stream:
|
||||
manager = client.messages.stream(**kwargs)
|
||||
try:
|
||||
stream = manager.__enter__()
|
||||
except BaseException:
|
||||
manager.__exit__(*sys.exc_info())
|
||||
raise
|
||||
if cancel_ref is not None:
|
||||
cancel_ref.append(stream)
|
||||
return self._iter_with_cleanup(stream, manager)
|
||||
|
||||
def _iter_with_cleanup(self, stream: Any, manager: Any) -> Iterator[StreamChunk]:
|
||||
"""Iterate the Anthropic stream, ensuring the context manager exits."""
|
||||
try:
|
||||
yield from self._iter_anthropic_stream(stream)
|
||||
except BaseException:
|
||||
manager.__exit__(*sys.exc_info())
|
||||
raise
|
||||
else:
|
||||
manager.__exit__(None, None, None)
|
||||
|
||||
def _iter_anthropic_stream(self, stream: Any) -> Iterator[StreamChunk]:
|
||||
"""Convert Anthropic streaming events to normalized StreamChunks."""
|
||||
@@ -541,6 +663,12 @@ class AnthropicProvider:
|
||||
raw_blocks[event.index]["thinking"] = (
|
||||
raw_blocks[event.index].get("thinking", "") + delta.thinking
|
||||
)
|
||||
elif delta.type == "signature_delta":
|
||||
# Accumulate signature into raw block for round-trip
|
||||
if event.index in raw_blocks:
|
||||
raw_blocks[event.index]["signature"] = (
|
||||
raw_blocks[event.index].get("signature", "") + delta.signature
|
||||
)
|
||||
elif delta.type == "input_json_delta":
|
||||
if event.index in server_tool_blocks:
|
||||
# Accumulate server tool input (search query)
|
||||
@@ -647,7 +775,24 @@ class AnthropicProvider:
|
||||
deferred_names,
|
||||
)
|
||||
|
||||
response = client.messages.create(**kwargs)
|
||||
# Use streaming internally to avoid the Anthropic SDK's 10-minute
|
||||
# timeout on non-streaming requests. get_final_message() returns the
|
||||
# same Message object as messages.create() would.
|
||||
# Mirror create_streaming's defensive __enter__/__exit__ pattern so
|
||||
# resources are cleaned up even if __enter__ fails.
|
||||
manager = client.messages.stream(**kwargs)
|
||||
try:
|
||||
stream = manager.__enter__()
|
||||
except BaseException:
|
||||
manager.__exit__(*sys.exc_info())
|
||||
raise
|
||||
try:
|
||||
response = stream.get_final_message()
|
||||
except BaseException:
|
||||
manager.__exit__(*sys.exc_info())
|
||||
raise
|
||||
else:
|
||||
manager.__exit__(None, None, None)
|
||||
|
||||
# Extract content and tool_calls from content blocks.
|
||||
# Skip server-side blocks (server_tool_use, web_search_tool_result)
|
||||
|
||||
@@ -11,6 +11,8 @@ from typing import TYPE_CHECKING, Any
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
import structlog
|
||||
|
||||
from turnstone.core.providers._protocol import (
|
||||
CompletionResult,
|
||||
ModelCapabilities,
|
||||
@@ -20,6 +22,8 @@ from turnstone.core.providers._protocol import (
|
||||
_lookup_capabilities,
|
||||
)
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
# -- model capabilities -------------------------------------------------------
|
||||
|
||||
_OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
@@ -273,6 +277,29 @@ class OpenAIProvider:
|
||||
result.append(tool)
|
||||
return result
|
||||
|
||||
# -- message sanitisation ------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Ensure assistant messages always have ``content`` or ``tool_calls``.
|
||||
|
||||
OpenAI-compatible APIs reject assistant messages that have neither.
|
||||
This is a defensive catch-all; the upstream layers should already
|
||||
guarantee well-formed messages.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
if (
|
||||
msg.get("role") == "assistant"
|
||||
and msg.get("content") is None
|
||||
and not msg.get("tool_calls")
|
||||
):
|
||||
msg = {**msg, "content": ""}
|
||||
out.append(msg)
|
||||
return out
|
||||
|
||||
# -- streaming -----------------------------------------------------------
|
||||
|
||||
def create_streaming(
|
||||
@@ -287,8 +314,10 @@ class OpenAIProvider:
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
cancel_ref: list[Any] | None = None,
|
||||
) -> Iterator[StreamChunk]:
|
||||
caps = self.get_capabilities(model)
|
||||
messages = self._sanitize_messages(messages)
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -305,19 +334,34 @@ class OpenAIProvider:
|
||||
if extra_params:
|
||||
kwargs["extra_body"] = extra_params
|
||||
|
||||
log.debug(
|
||||
"openai.request",
|
||||
model=model,
|
||||
stream=True,
|
||||
max_tokens=max_tokens,
|
||||
message_count=len(messages),
|
||||
tool_count=len(tools) if tools else 0,
|
||||
)
|
||||
stream = client.chat.completions.create(**kwargs)
|
||||
yield from self._iter_stream(stream)
|
||||
if cancel_ref is not None:
|
||||
cancel_ref.append(stream)
|
||||
return self._iter_stream(stream)
|
||||
|
||||
def _iter_stream(self, stream: Any) -> Iterator[StreamChunk]:
|
||||
"""Convert OpenAI stream chunks to normalized StreamChunks."""
|
||||
first = True
|
||||
annotations: list[Any] = []
|
||||
content_len = 0
|
||||
tool_call_count = 0
|
||||
last_finish_reason: str | None = None
|
||||
completion_tokens: int | None = None
|
||||
for chunk in stream:
|
||||
sc = StreamChunk()
|
||||
|
||||
# Finish reason
|
||||
if chunk.choices and chunk.choices[0].finish_reason:
|
||||
sc.finish_reason = chunk.choices[0].finish_reason
|
||||
last_finish_reason = sc.finish_reason
|
||||
|
||||
# Usage from final chunk
|
||||
if hasattr(chunk, "usage") and chunk.usage is not None:
|
||||
@@ -325,6 +369,7 @@ class OpenAIProvider:
|
||||
pt = getattr(u, "prompt_tokens", None)
|
||||
ct = getattr(u, "completion_tokens", None)
|
||||
tt = getattr(u, "total_tokens", None)
|
||||
completion_tokens = ct
|
||||
if pt is not None and ct is not None:
|
||||
# Extract cached_tokens from prompt_tokens_details.
|
||||
# OpenAI caching is automatic with no write premium, so
|
||||
@@ -353,6 +398,7 @@ class OpenAIProvider:
|
||||
# Content
|
||||
if delta.content:
|
||||
sc.content_delta = delta.content
|
||||
content_len += len(delta.content)
|
||||
|
||||
# Tool calls
|
||||
if delta.tool_calls:
|
||||
@@ -366,6 +412,7 @@ class OpenAIProvider:
|
||||
if tc_delta.function.arguments:
|
||||
tcd.arguments_delta = tc_delta.function.arguments
|
||||
sc.tool_call_deltas.append(tcd)
|
||||
tool_call_count += 1
|
||||
|
||||
# Accumulate url_citation annotations from search models
|
||||
delta_anns = getattr(delta, "annotations", None)
|
||||
@@ -380,6 +427,15 @@ class OpenAIProvider:
|
||||
if has_content or sc.finish_reason or sc.usage:
|
||||
yield sc
|
||||
|
||||
log.debug(
|
||||
"openai.response",
|
||||
stream=True,
|
||||
finish_reason=last_finish_reason,
|
||||
content_length=content_len,
|
||||
tool_call_deltas=tool_call_count,
|
||||
completion_tokens=completion_tokens,
|
||||
)
|
||||
|
||||
# Emit accumulated citations as a final info chunk
|
||||
if annotations:
|
||||
citation_text = self._format_citations("", annotations).strip()
|
||||
@@ -402,6 +458,7 @@ class OpenAIProvider:
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
) -> CompletionResult:
|
||||
caps = self.get_capabilities(model)
|
||||
messages = self._sanitize_messages(messages)
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -417,6 +474,14 @@ class OpenAIProvider:
|
||||
if extra_params:
|
||||
kwargs["extra_body"] = extra_params
|
||||
|
||||
log.debug(
|
||||
"openai.request",
|
||||
model=model,
|
||||
stream=False,
|
||||
max_tokens=max_tokens,
|
||||
message_count=len(messages),
|
||||
tool_count=len(tools) if tools else 0,
|
||||
)
|
||||
response = client.chat.completions.create(**kwargs)
|
||||
choice = response.choices[0]
|
||||
msg = choice.message
|
||||
@@ -454,12 +519,21 @@ class OpenAIProvider:
|
||||
cache_read_tokens=cached or 0,
|
||||
)
|
||||
|
||||
return CompletionResult(
|
||||
result = CompletionResult(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=choice.finish_reason or "stop",
|
||||
usage=usage,
|
||||
)
|
||||
log.debug(
|
||||
"openai.response",
|
||||
stream=False,
|
||||
finish_reason=result.finish_reason,
|
||||
content_length=len(content),
|
||||
tool_call_count=len(tool_calls) if tool_calls else 0,
|
||||
completion_tokens=usage.completion_tokens if usage else None,
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _format_citations(content: str, annotations: list[Any]) -> str:
|
||||
|
||||
@@ -125,8 +125,15 @@ class LLMProvider(Protocol):
|
||||
reasoning_effort: str = "medium",
|
||||
extra_params: dict[str, Any] | None = None,
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
cancel_ref: list[Any] | None = None,
|
||||
) -> Iterator[StreamChunk]:
|
||||
"""Create a streaming request, yielding normalized StreamChunks."""
|
||||
"""Create a streaming request, yielding normalized StreamChunks.
|
||||
|
||||
If *cancel_ref* is provided the provider appends the underlying SDK
|
||||
stream object (which has a ``.close()`` method) before yielding the
|
||||
first chunk. The caller can then close it from another thread to
|
||||
abort a blocked HTTP read immediately.
|
||||
"""
|
||||
...
|
||||
|
||||
def create_completion(
|
||||
|
||||
@@ -11,12 +11,30 @@ BLOCKED_PATTERNS = [
|
||||
"reboot",
|
||||
"halt",
|
||||
"poweroff",
|
||||
"dd if=",
|
||||
"of=/dev/sd",
|
||||
"of=/dev/nvme",
|
||||
"of=/dev/vd",
|
||||
"of=/dev/xvd",
|
||||
"of=/dev/hd",
|
||||
"of=/dev/dm-",
|
||||
"of=/dev/md",
|
||||
"of=/dev/loop",
|
||||
"of=/dev/disk/",
|
||||
":(){ :|:& };:", # fork bomb
|
||||
"> /dev/sda",
|
||||
"> /dev/sd",
|
||||
"> /dev/nvme",
|
||||
"> /dev/vd",
|
||||
"> /dev/xvd",
|
||||
"> /dev/hd",
|
||||
"> /dev/dm-",
|
||||
"> /dev/md",
|
||||
"> /dev/disk/",
|
||||
"mv / ",
|
||||
"chmod -R 777 /",
|
||||
"chown -R ",
|
||||
# Credential exfiltration via procfs
|
||||
"/proc/1/environ",
|
||||
"/proc/self/environ",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ _MATH_BLOCKED_BUILTINS = {
|
||||
"globals",
|
||||
"locals",
|
||||
"vars",
|
||||
# Reflection primitives — bypass AST dunder checks via runtime strings
|
||||
"getattr",
|
||||
"setattr",
|
||||
"delattr",
|
||||
# Type system — can reconstruct arbitrary classes
|
||||
"type",
|
||||
# Import — the replaced _safe_import is in the namespace, but block the
|
||||
# name so direct __import__ calls are caught by the AST validator
|
||||
"__import__",
|
||||
}
|
||||
|
||||
_MATH_BLOCKED_MODULES = {
|
||||
@@ -85,6 +94,9 @@ class _ASTValidator(ast.NodeVisitor):
|
||||
and node.attr not in {"__name__", "__doc__", "__class__"}
|
||||
):
|
||||
self.errors.append(f"Access to '{node.attr}' is not allowed")
|
||||
# Block operator.attrgetter/itemgetter which act as getattr bypasses
|
||||
if node.attr in ("attrgetter", "itemgetter"):
|
||||
self.errors.append(f"Access to '{node.attr}' is not allowed")
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
@@ -109,6 +121,7 @@ def validate_math_code(code: str) -> list[str]:
|
||||
|
||||
def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue[tuple[str, str]]) -> None:
|
||||
"""Execute code in a subprocess, put (status, output) in queue."""
|
||||
import contextlib
|
||||
import signal as _signal
|
||||
import sys as _sys
|
||||
from io import StringIO
|
||||
@@ -124,7 +137,14 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue[tuple[s
|
||||
def _safe_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
if name.split(".")[0] in _MATH_BLOCKED_MODULES:
|
||||
raise ImportError(f"Import of '{name}' is blocked")
|
||||
return original_import(name, *args, **kwargs)
|
||||
mod = original_import(name, *args, **kwargs)
|
||||
# Strip __builtins__ from every imported module so
|
||||
# module.__builtins__['__import__'] can't bypass _safe_import
|
||||
# (covers operator.attrgetter('__builtins__') and similar).
|
||||
if hasattr(mod, "__builtins__"):
|
||||
with contextlib.suppress(AttributeError, TypeError):
|
||||
mod.__builtins__ = {} # type: ignore[attr-defined]
|
||||
return mod
|
||||
|
||||
original_import = (
|
||||
__builtins__["__import__"]
|
||||
@@ -242,7 +262,14 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue[tuple[s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
exec(code, ns)
|
||||
# Strip __builtins__ from all pre-imported modules so
|
||||
# module.__builtins__['__import__'] can't bypass _safe_import.
|
||||
for v in list(ns.values()):
|
||||
if hasattr(v, "__builtins__"):
|
||||
with contextlib.suppress(AttributeError, TypeError):
|
||||
v.__builtins__ = {}
|
||||
|
||||
exec(code, ns) # noqa: S102
|
||||
|
||||
_sys.stdout = _sys.__stdout__
|
||||
printed = captured.getvalue()
|
||||
|
||||
+1375
-309
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ from turnstone.core.storage._schema import (
|
||||
intent_verdicts,
|
||||
mcp_servers,
|
||||
metadata,
|
||||
model_definitions,
|
||||
oidc_identities,
|
||||
oidc_pending_states,
|
||||
orgs,
|
||||
@@ -44,6 +45,9 @@ from turnstone.core.storage._schema import (
|
||||
from turnstone.core.storage._utils import (
|
||||
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
MODEL_DEFINITION_MUTABLE as _MODEL_DEF_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
ORG_MUTABLE as _ORG_MUTABLE,
|
||||
)
|
||||
@@ -68,6 +72,7 @@ from turnstone.core.storage._utils import (
|
||||
from turnstone.core.storage._utils import (
|
||||
row_to_dict as _row_to_dict,
|
||||
)
|
||||
from turnstone.core.storage._utils import sanitize_text
|
||||
from turnstone.core.storage._utils import (
|
||||
scan_skill_content as _scan_skill_content,
|
||||
)
|
||||
@@ -103,12 +108,13 @@ class PostgreSQLBackend:
|
||||
role: str,
|
||||
content: str | None,
|
||||
tool_name: str | None = None,
|
||||
tool_args: str | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
provider_data: str | None = None,
|
||||
tool_calls: str | None = None,
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
content = sanitize_text(content)
|
||||
provider_data = sanitize_text(provider_data)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(conversations),
|
||||
@@ -118,7 +124,6 @@ class PostgreSQLBackend:
|
||||
"role": role,
|
||||
"content": content,
|
||||
"tool_name": tool_name,
|
||||
"tool_args": tool_args,
|
||||
"tool_call_id": tool_call_id,
|
||||
"provider_data": provider_data,
|
||||
"tool_calls": tool_calls,
|
||||
@@ -136,7 +141,6 @@ class PostgreSQLBackend:
|
||||
conversations.c.role,
|
||||
conversations.c.content,
|
||||
conversations.c.tool_name,
|
||||
conversations.c.tool_args,
|
||||
conversations.c.tool_call_id,
|
||||
conversations.c.provider_data,
|
||||
conversations.c.tool_calls,
|
||||
@@ -146,6 +150,29 @@ class PostgreSQLBackend:
|
||||
).fetchall()
|
||||
return _reconstruct_messages(list(rows), ws_id)
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
cutoff_row = conn.execute(
|
||||
sa.select(conversations.c.id)
|
||||
.where(conversations.c.ws_id == ws_id)
|
||||
.order_by(conversations.c.id)
|
||||
.limit(1)
|
||||
.offset(keep_count)
|
||||
).fetchone()
|
||||
if cutoff_row is None:
|
||||
return 0
|
||||
cutoff_id = cutoff_row[0]
|
||||
result = conn.execute(
|
||||
sa.delete(conversations).where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
conversations.c.id >= cutoff_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Workstream management -------------------------------------------------
|
||||
|
||||
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
|
||||
@@ -382,10 +409,11 @@ class PostgreSQLBackend:
|
||||
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20) -> list[Any]:
|
||||
def search_history(self, query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
capped = min(limit, 100)
|
||||
capped = min(int(limit), 100)
|
||||
capped_offset = max(0, int(offset))
|
||||
with self._engine.connect() as conn:
|
||||
# Use PostgreSQL full-text search if search_vector column exists
|
||||
try:
|
||||
@@ -398,9 +426,9 @@ class PostgreSQLBackend:
|
||||
" @@ plainto_tsquery('english', :query) "
|
||||
"ORDER BY ts_rank(to_tsvector('english', COALESCE(c.content, '')), "
|
||||
" plainto_tsquery('english', :query)) DESC "
|
||||
"LIMIT :limit"
|
||||
"LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"query": query, "limit": capped},
|
||||
{"query": query, "limit": capped, "offset": capped_offset},
|
||||
).fetchall()
|
||||
)
|
||||
except Exception:
|
||||
@@ -410,9 +438,9 @@ class PostgreSQLBackend:
|
||||
sa.text(
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations WHERE content ILIKE :pattern "
|
||||
"ORDER BY timestamp DESC LIMIT :limit"
|
||||
"ORDER BY timestamp DESC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"pattern": f"%{query}%", "limit": capped},
|
||||
{"pattern": f"%{query}%", "limit": capped, "offset": capped_offset},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
@@ -2644,6 +2672,102 @@ class PostgreSQLBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Model definitions -----------------------------------------------------
|
||||
|
||||
def create_model_definition(
|
||||
self,
|
||||
definition_id: str,
|
||||
alias: str,
|
||||
model: str,
|
||||
provider: str = "openai",
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
context_window: int = 32768,
|
||||
capabilities: str = "{}",
|
||||
enabled: bool = True,
|
||||
created_by: str = "",
|
||||
) -> None:
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
postgresql.insert(model_definitions)
|
||||
.values(
|
||||
definition_id=definition_id,
|
||||
alias=alias,
|
||||
model=model,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
context_window=context_window,
|
||||
capabilities=capabilities,
|
||||
enabled=1 if enabled else 0,
|
||||
created_by=created_by,
|
||||
created=now,
|
||||
updated=now,
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_model_definition(self, definition_id: str) -> dict[str, Any] | None:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(model_definitions).where(
|
||||
model_definitions.c.definition_id == definition_id
|
||||
)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_dict(row, "enabled")
|
||||
|
||||
def get_model_definition_by_alias(self, alias: str) -> dict[str, Any] | None:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(model_definitions).where(model_definitions.c.alias == alias)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_dict(row, "enabled")
|
||||
|
||||
def list_model_definitions(self, enabled_only: bool = False) -> list[dict[str, Any]]:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(model_definitions).order_by(model_definitions.c.alias)
|
||||
if enabled_only:
|
||||
q = q.where(model_definitions.c.enabled == 1)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "enabled") for r in rows]
|
||||
|
||||
def update_model_definition(self, definition_id: str, **fields: Any) -> bool:
|
||||
|
||||
fields = {k: v for k, v in fields.items() if k in _MODEL_DEF_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "enabled" in fields:
|
||||
fields["enabled"] = 1 if fields["enabled"] else 0
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(model_definitions)
|
||||
.where(model_definitions.c.definition_id == definition_id)
|
||||
.values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_model_definition(self, definition_id: str) -> bool:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(model_definitions).where(
|
||||
model_definitions.c.definition_id == definition_id
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- OIDC identity ---------------------------------------------------------
|
||||
|
||||
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
|
||||
|
||||
@@ -21,7 +21,6 @@ class StorageBackend(Protocol):
|
||||
role: str,
|
||||
content: str | None,
|
||||
tool_name: str | None = None,
|
||||
tool_args: str | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
provider_data: str | None = None,
|
||||
tool_calls: str | None = None,
|
||||
@@ -33,6 +32,15 @@ class StorageBackend(Protocol):
|
||||
"""Load messages for a workstream and reconstruct OpenAI message format."""
|
||||
...
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
"""Delete conversation rows beyond the first *keep_count* rows for a workstream.
|
||||
|
||||
Rows are ordered by auto-increment ``id``. If the workstream has
|
||||
N rows total and ``keep_count`` < N, the last N - keep_count rows
|
||||
are deleted. Returns the number of rows deleted.
|
||||
"""
|
||||
...
|
||||
|
||||
# -- Workstream management -------------------------------------------------
|
||||
|
||||
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
|
||||
@@ -181,7 +189,7 @@ class StorageBackend(Protocol):
|
||||
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20) -> list[Any]:
|
||||
def search_history(self, query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
"""Search conversation history. Returns (timestamp, ws_id, role, content, tool_name)."""
|
||||
...
|
||||
|
||||
@@ -952,6 +960,44 @@ class StorageBackend(Protocol):
|
||||
"""Delete an MCP server definition. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- Model definitions -----------------------------------------------------
|
||||
|
||||
def create_model_definition(
|
||||
self,
|
||||
definition_id: str,
|
||||
alias: str,
|
||||
model: str,
|
||||
provider: str = "openai",
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
context_window: int = 32768,
|
||||
capabilities: str = "{}",
|
||||
enabled: bool = True,
|
||||
created_by: str = "",
|
||||
) -> None:
|
||||
"""Create a model definition. No-op if definition_id already exists."""
|
||||
...
|
||||
|
||||
def get_model_definition(self, definition_id: str) -> dict[str, Any] | None:
|
||||
"""Return model definition dict or None."""
|
||||
...
|
||||
|
||||
def get_model_definition_by_alias(self, alias: str) -> dict[str, Any] | None:
|
||||
"""Return model definition dict by alias or None."""
|
||||
...
|
||||
|
||||
def list_model_definitions(self, enabled_only: bool = False) -> list[dict[str, Any]]:
|
||||
"""Return model definitions ordered by alias."""
|
||||
...
|
||||
|
||||
def update_model_definition(self, definition_id: str, **fields: Any) -> bool:
|
||||
"""Update specified fields on a model definition. Returns True if found."""
|
||||
...
|
||||
|
||||
def delete_model_definition(self, definition_id: str) -> bool:
|
||||
"""Delete a model definition. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- TLS / ACME (lacme Store) ----------------------------------------------
|
||||
|
||||
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
|
||||
|
||||
@@ -35,7 +35,6 @@ conversations = sa.Table(
|
||||
sa.Column("role", sa.Text, nullable=False),
|
||||
sa.Column("content", sa.Text),
|
||||
sa.Column("tool_name", sa.Text),
|
||||
sa.Column("tool_args", sa.Text),
|
||||
sa.Column("tool_call_id", sa.Text),
|
||||
sa.Column("provider_data", sa.Text),
|
||||
sa.Column("tool_calls", sa.Text),
|
||||
@@ -520,6 +519,29 @@ sa.Index(
|
||||
postgresql_where=mcp_servers.c.registry_name.isnot(None),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model definitions — database-backed model configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
model_definitions = sa.Table(
|
||||
"model_definitions",
|
||||
metadata,
|
||||
sa.Column("definition_id", sa.Text, primary_key=True),
|
||||
sa.Column("alias", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("model", sa.Text, nullable=False),
|
||||
sa.Column("provider", sa.Text, nullable=False, server_default="openai"),
|
||||
sa.Column("base_url", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("api_key", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("context_window", sa.Integer, nullable=False, server_default="32768"),
|
||||
sa.Column("capabilities", sa.Text, nullable=False, server_default="{}"),
|
||||
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
sa.Index("idx_model_definitions_enabled", model_definitions.c.enabled)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC identity tables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -17,6 +17,7 @@ from turnstone.core.storage._schema import (
|
||||
intent_verdicts,
|
||||
mcp_servers,
|
||||
metadata,
|
||||
model_definitions,
|
||||
oidc_identities,
|
||||
oidc_pending_states,
|
||||
orgs,
|
||||
@@ -44,6 +45,9 @@ from turnstone.core.storage._schema import (
|
||||
from turnstone.core.storage._utils import (
|
||||
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
MODEL_DEFINITION_MUTABLE as _MODEL_DEF_MUTABLE,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
ORG_MUTABLE as _ORG_MUTABLE,
|
||||
)
|
||||
@@ -68,6 +72,7 @@ from turnstone.core.storage._utils import (
|
||||
from turnstone.core.storage._utils import (
|
||||
row_to_dict as _row_to_dict,
|
||||
)
|
||||
from turnstone.core.storage._utils import sanitize_text
|
||||
from turnstone.core.storage._utils import (
|
||||
scan_skill_content as _scan_skill_content,
|
||||
)
|
||||
@@ -154,12 +159,13 @@ class SQLiteBackend:
|
||||
role: str,
|
||||
content: str | None,
|
||||
tool_name: str | None = None,
|
||||
tool_args: str | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
provider_data: str | None = None,
|
||||
tool_calls: str | None = None,
|
||||
) -> None:
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
content = sanitize_text(content)
|
||||
provider_data = sanitize_text(provider_data)
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.insert(conversations),
|
||||
@@ -169,7 +175,6 @@ class SQLiteBackend:
|
||||
"role": role,
|
||||
"content": content,
|
||||
"tool_name": tool_name,
|
||||
"tool_args": tool_args,
|
||||
"tool_call_id": tool_call_id,
|
||||
"provider_data": provider_data,
|
||||
"tool_calls": tool_calls,
|
||||
@@ -200,7 +205,6 @@ class SQLiteBackend:
|
||||
conversations.c.role,
|
||||
conversations.c.content,
|
||||
conversations.c.tool_name,
|
||||
conversations.c.tool_args,
|
||||
conversations.c.tool_call_id,
|
||||
conversations.c.provider_data,
|
||||
conversations.c.tool_calls,
|
||||
@@ -211,6 +215,43 @@ class SQLiteBackend:
|
||||
|
||||
return _reconstruct_messages(list(rows), ws_id)
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
# Find the id of the first row to delete (the row at offset keep_count)
|
||||
cutoff_row = conn.execute(
|
||||
sa.select(conversations.c.id)
|
||||
.where(conversations.c.ws_id == ws_id)
|
||||
.order_by(conversations.c.id)
|
||||
.limit(1)
|
||||
.offset(keep_count)
|
||||
).fetchone()
|
||||
if cutoff_row is None:
|
||||
return 0 # nothing to delete
|
||||
cutoff_id = cutoff_row[0]
|
||||
# Remove FTS5 entries first (external content table doesn't auto-sync)
|
||||
if self._fts5_available:
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"DELETE FROM conversations_fts WHERE rowid IN "
|
||||
"(SELECT id FROM conversations "
|
||||
" WHERE ws_id = :ws_id AND id >= :cutoff_id)"
|
||||
),
|
||||
{"ws_id": ws_id, "cutoff_id": cutoff_id},
|
||||
)
|
||||
except Exception:
|
||||
self._fts5_available = False
|
||||
result = conn.execute(
|
||||
sa.delete(conversations).where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
conversations.c.id >= cutoff_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Workstream management -------------------------------------------------
|
||||
|
||||
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
|
||||
@@ -456,10 +497,11 @@ class SQLiteBackend:
|
||||
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20) -> list[Any]:
|
||||
def search_history(self, query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
capped = min(limit, 100)
|
||||
capped = min(int(limit), 100)
|
||||
capped_offset = max(0, int(offset))
|
||||
with self._engine.connect() as conn:
|
||||
if self._fts5_available:
|
||||
return list(
|
||||
@@ -469,9 +511,9 @@ class SQLiteBackend:
|
||||
"FROM conversations_fts f "
|
||||
"JOIN conversations c ON c.id = f.rowid "
|
||||
"WHERE conversations_fts MATCH :query "
|
||||
"ORDER BY f.rank ASC LIMIT :limit"
|
||||
"ORDER BY f.rank ASC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"query": _fts5_query(query), "limit": capped},
|
||||
{"query": _fts5_query(query), "limit": capped, "offset": capped_offset},
|
||||
).fetchall()
|
||||
)
|
||||
return list(
|
||||
@@ -479,9 +521,13 @@ class SQLiteBackend:
|
||||
sa.text(
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations WHERE content LIKE :pattern ESCAPE '\\' "
|
||||
"ORDER BY timestamp DESC LIMIT :limit"
|
||||
"ORDER BY timestamp DESC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"pattern": f"%{_escape_like(query)}%", "limit": capped},
|
||||
{
|
||||
"pattern": f"%{_escape_like(query)}%",
|
||||
"limit": capped,
|
||||
"offset": capped_offset,
|
||||
},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
@@ -2693,6 +2739,100 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Model definitions -----------------------------------------------------
|
||||
|
||||
def create_model_definition(
|
||||
self,
|
||||
definition_id: str,
|
||||
alias: str,
|
||||
model: str,
|
||||
provider: str = "openai",
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
context_window: int = 32768,
|
||||
capabilities: str = "{}",
|
||||
enabled: bool = True,
|
||||
created_by: str = "",
|
||||
) -> None:
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(
|
||||
sa.insert(model_definitions).prefix_with("OR IGNORE"),
|
||||
{
|
||||
"definition_id": definition_id,
|
||||
"alias": alias,
|
||||
"model": model,
|
||||
"provider": provider,
|
||||
"base_url": base_url,
|
||||
"api_key": api_key,
|
||||
"context_window": context_window,
|
||||
"capabilities": capabilities,
|
||||
"enabled": 1 if enabled else 0,
|
||||
"created_by": created_by,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_model_definition(self, definition_id: str) -> dict[str, Any] | None:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(model_definitions).where(
|
||||
model_definitions.c.definition_id == definition_id
|
||||
)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_dict(row, "enabled")
|
||||
|
||||
def get_model_definition_by_alias(self, alias: str) -> dict[str, Any] | None:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(model_definitions).where(model_definitions.c.alias == alias)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_dict(row, "enabled")
|
||||
|
||||
def list_model_definitions(self, enabled_only: bool = False) -> list[dict[str, Any]]:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
q = sa.select(model_definitions).order_by(model_definitions.c.alias)
|
||||
if enabled_only:
|
||||
q = q.where(model_definitions.c.enabled == 1)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [_row_to_dict(r, "enabled") for r in rows]
|
||||
|
||||
def update_model_definition(self, definition_id: str, **fields: Any) -> bool:
|
||||
|
||||
fields = {k: v for k, v in fields.items() if k in _MODEL_DEF_MUTABLE}
|
||||
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "enabled" in fields:
|
||||
fields["enabled"] = 1 if fields["enabled"] else 0
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.update(model_definitions)
|
||||
.where(model_definitions.c.definition_id == definition_id)
|
||||
.values(**fields)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def delete_model_definition(self, definition_id: str) -> bool:
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(model_definitions).where(
|
||||
model_definitions.c.definition_id == definition_id
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- OIDC identity ---------------------------------------------------------
|
||||
|
||||
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
|
||||
|
||||
@@ -10,6 +10,22 @@ from turnstone.core.log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text sanitization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sanitize_text(value: str | None) -> str | None:
|
||||
"""Strip NUL bytes that PostgreSQL text fields cannot store.
|
||||
|
||||
SQLite tolerates NUL in TEXT but they cause downstream issues (API
|
||||
payloads, web UI rendering), so both backends use this.
|
||||
"""
|
||||
if value and "\x00" in value:
|
||||
return value.replace("\x00", "")
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Row helper
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -80,6 +96,18 @@ MCP_SERVER_MUTABLE = frozenset(
|
||||
"registry_meta",
|
||||
}
|
||||
)
|
||||
MODEL_DEFINITION_MUTABLE = frozenset(
|
||||
{
|
||||
"alias",
|
||||
"model",
|
||||
"provider",
|
||||
"base_url",
|
||||
"api_key",
|
||||
"context_window",
|
||||
"capabilities",
|
||||
"enabled",
|
||||
}
|
||||
)
|
||||
VERDICT_MUTABLE = frozenset(
|
||||
{
|
||||
"user_decision",
|
||||
@@ -136,8 +164,8 @@ def scan_skill_content(content: str, allowed_tools: str) -> tuple[str, str, str]
|
||||
def reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]:
|
||||
"""Reconstruct OpenAI message format from stored conversation rows.
|
||||
|
||||
Each *row* is a 7-element tuple of ``(role, content, tool_name,
|
||||
tool_args, tool_call_id, provider_data, tool_calls_json)`` ordered
|
||||
Each *row* is a 6-element tuple of ``(role, content, tool_name,
|
||||
tool_call_id, provider_data, tool_calls_json)`` ordered
|
||||
chronologically by row ID.
|
||||
|
||||
Post-migration 013 the only roles are ``user``, ``assistant``, and
|
||||
@@ -146,13 +174,13 @@ def reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
messages: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
role, content, _tool_name, _tool_args, tc_id, provider_data, tool_calls_json = row
|
||||
role, content, _tool_name, tc_id, provider_data, tool_calls_json = row
|
||||
|
||||
if role == "user":
|
||||
messages.append({"role": "user", "content": content or ""})
|
||||
|
||||
elif role == "assistant":
|
||||
msg: dict[str, Any] = {"role": "assistant", "content": content}
|
||||
msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
|
||||
if provider_data:
|
||||
with contextlib.suppress(json.JSONDecodeError, TypeError):
|
||||
msg["_provider_content"] = json.loads(provider_data)
|
||||
@@ -188,4 +216,45 @@ def reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]:
|
||||
break
|
||||
del messages[asst_idx:]
|
||||
|
||||
# Repair: synthesize tool results for mid-conversation orphaned tool calls.
|
||||
# This happens when a cancel interrupts tool execution — the assistant
|
||||
# message with tool_calls is saved to DB but GenerationCancelled prevents
|
||||
# tool results from being created. Both Anthropic (strict) and OpenAI
|
||||
# (lenient today, may tighten) benefit from well-formed histories.
|
||||
i = 0
|
||||
while i < len(messages):
|
||||
msg = messages[i]
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
expected_ids = [tc.get("id", "") for tc in msg["tool_calls"] if tc.get("id")]
|
||||
# Collect tool result IDs that follow
|
||||
j = i + 1
|
||||
result_ids: set[str] = set()
|
||||
while j < len(messages) and messages[j].get("role") == "tool":
|
||||
tc_id = messages[j].get("tool_call_id", "")
|
||||
if tc_id:
|
||||
result_ids.add(tc_id)
|
||||
j += 1
|
||||
# Synthesize results for any missing IDs
|
||||
orphaned = [uid for uid in expected_ids if uid not in result_ids]
|
||||
if orphaned:
|
||||
synthetic = [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": uid,
|
||||
"content": "Tool execution was cancelled.",
|
||||
"is_error": True,
|
||||
}
|
||||
for uid in orphaned
|
||||
]
|
||||
# Insert after the last existing tool result (or after assistant)
|
||||
messages[j:j] = synthetic
|
||||
if orphaned:
|
||||
i = j + len(orphaned) # skip past spliced synthetics
|
||||
elif j > i + 1:
|
||||
i = j # skip past existing tool block
|
||||
else:
|
||||
i += 1 # no tools followed; just advance
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return messages
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Drop vestigial tool_args column from conversations table.
|
||||
|
||||
The tool_args column has not been written since migration 013 moved
|
||||
tool call data into the tool_calls JSON column on assistant rows.
|
||||
All existing rows have NULL in this column.
|
||||
|
||||
Revision ID: 027
|
||||
Revises: 026
|
||||
Create Date: 2026-03-28
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "027"
|
||||
down_revision = "026"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("conversations") as batch_op:
|
||||
batch_op.drop_column("tool_args")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("conversations") as batch_op:
|
||||
batch_op.add_column(sa.Column("tool_args", sa.Text))
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Create model_definitions table and grant admin.models permission.
|
||||
|
||||
Revision ID: 028
|
||||
Revises: 027
|
||||
Create Date: 2026-03-29
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "028"
|
||||
down_revision = "027"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"model_definitions",
|
||||
sa.Column("definition_id", sa.Text, primary_key=True),
|
||||
sa.Column("alias", sa.Text, nullable=False, unique=True),
|
||||
sa.Column("model", sa.Text, nullable=False),
|
||||
sa.Column("provider", sa.Text, nullable=False, server_default="openai"),
|
||||
sa.Column("base_url", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("api_key", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("context_window", sa.Integer, nullable=False, server_default="32768"),
|
||||
sa.Column("capabilities", sa.Text, nullable=False, server_default="{}"),
|
||||
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
sa.Column("updated", sa.Text, nullable=False),
|
||||
)
|
||||
op.create_index("idx_model_definitions_enabled", "model_definitions", ["enabled"])
|
||||
|
||||
# Grant admin.models permission to the built-in admin role
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE roles SET permissions = permissions || ',admin.models' "
|
||||
"WHERE role_id = 'builtin-admin' "
|
||||
"AND permissions NOT LIKE '%admin.models%'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE roles SET permissions = REPLACE(permissions, ',admin.models', '') "
|
||||
"WHERE role_id = 'builtin-admin'"
|
||||
)
|
||||
)
|
||||
op.drop_table("model_definitions")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Grant conversation.modify permission to admin and operator roles.
|
||||
|
||||
Revision ID: 029
|
||||
Revises: 028
|
||||
Create Date: 2026-03-29
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "029"
|
||||
down_revision = "028"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
# Grant to admin role
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE roles SET permissions = permissions || ',conversation.modify' "
|
||||
"WHERE role_id = 'builtin-admin' "
|
||||
"AND permissions NOT LIKE '%conversation.modify%'"
|
||||
)
|
||||
)
|
||||
# Grant to operator role
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE roles SET permissions = permissions || ',conversation.modify' "
|
||||
"WHERE role_id = 'builtin-operator' "
|
||||
"AND permissions NOT LIKE '%conversation.modify%'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE roles SET permissions = REPLACE(permissions, ',conversation.modify', '') "
|
||||
"WHERE role_id IN ('builtin-admin', 'builtin-operator')"
|
||||
)
|
||||
)
|
||||
@@ -79,7 +79,12 @@ class TLSClient:
|
||||
|
||||
setup_metrics(self._event_dispatcher)
|
||||
except ImportError:
|
||||
pass
|
||||
pass # prometheus_client or lacme.metrics missing
|
||||
except ValueError as exc:
|
||||
if "Duplicated timeseries" in str(exc):
|
||||
log.debug("tls_metrics_already_registered")
|
||||
else:
|
||||
raise
|
||||
|
||||
async def init(self) -> None:
|
||||
"""Fetch CA root cert and request a service certificate.
|
||||
@@ -220,7 +225,7 @@ class TLSClient:
|
||||
_require_lacme()
|
||||
from lacme.mtls import server_ssl_context
|
||||
|
||||
return server_ssl_context( # type: ignore[no-any-return]
|
||||
return server_ssl_context( # type: ignore[no-any-return,unused-ignore]
|
||||
cert_pem=self._bundle.fullchain_pem,
|
||||
key_pem=self._bundle.key_pem,
|
||||
ca_cert_pem=self._ca_pem,
|
||||
@@ -233,7 +238,7 @@ class TLSClient:
|
||||
_require_lacme()
|
||||
from lacme.mtls import client_ssl_context
|
||||
|
||||
return client_ssl_context( # type: ignore[no-any-return]
|
||||
return client_ssl_context( # type: ignore[no-any-return,unused-ignore]
|
||||
cert_pem=self._bundle.cert_pem,
|
||||
key_pem=self._bundle.key_pem,
|
||||
ca_cert_pem=self._ca_pem,
|
||||
|
||||
+23
-7
@@ -21,16 +21,32 @@ def strip_html(html: str) -> str:
|
||||
|
||||
|
||||
def check_ssrf(url: str) -> str | None:
|
||||
"""Return error string if URL resolves to a private/link-local address, else None."""
|
||||
"""Return error string if URL resolves to a private/link-local address, else None.
|
||||
|
||||
Checks both IPv4 and IPv6 addresses via getaddrinfo to prevent bypasses
|
||||
using IPv6 loopback (``::1``), link-local (``fe80::``), or unique-local
|
||||
(``fd00::``/``fc00::``) addresses.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
return "Invalid URL: no hostname"
|
||||
addr = socket.gethostbyname(hostname)
|
||||
ip = ipaddress.ip_address(addr)
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
||||
return f"Blocked: URL resolves to private/internal address ({addr})"
|
||||
except (socket.gaierror, ValueError):
|
||||
pass # DNS failure or invalid IP — let the actual fetch handle it
|
||||
# Resolve all address families (IPv4 + IPv6)
|
||||
results = socket.getaddrinfo(hostname, parsed.port or 80, proto=socket.IPPROTO_TCP)
|
||||
for _family, _type, _proto, _canonname, sockaddr in results:
|
||||
addr = str(sockaddr[0])
|
||||
# Strip IPv6 zone/scope identifier (e.g. "fe80::1%lo0")
|
||||
addr_clean = addr.split("%", 1)[0] if "%" in addr else addr
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr_clean)
|
||||
except ValueError:
|
||||
return f"Blocked: unable to parse resolved address ({addr})"
|
||||
# Normalize IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
||||
return f"Blocked: URL resolves to private/internal address ({addr})"
|
||||
except (socket.gaierror, OSError):
|
||||
pass # DNS failure — let the actual fetch handle it
|
||||
return None
|
||||
|
||||
+8
-1
@@ -105,7 +105,14 @@ class NullUI:
|
||||
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
|
||||
def on_tool_result(
|
||||
self,
|
||||
call_id: str,
|
||||
name: str,
|
||||
output: str,
|
||||
*,
|
||||
is_error: bool = False,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
|
||||
|
||||
@@ -665,6 +665,8 @@ class Bridge:
|
||||
effort=data.get("effort", ""),
|
||||
cache_creation_tokens=data.get("cache_creation_tokens", 0),
|
||||
cache_read_tokens=data.get("cache_read_tokens", 0),
|
||||
tool_calls_this_turn=data.get("tool_calls_this_turn", 0),
|
||||
turn_count=data.get("turn_count", 0),
|
||||
),
|
||||
)
|
||||
elif etype == "error":
|
||||
|
||||
@@ -252,6 +252,8 @@ class StatusEvent(OutboundEvent):
|
||||
effort: str = ""
|
||||
cache_creation_tokens: int = 0
|
||||
cache_read_tokens: int = 0
|
||||
tool_calls_this_turn: int = 0
|
||||
turn_count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -50,6 +50,7 @@ from turnstone.api.schemas import (
|
||||
AuthLoginResponse,
|
||||
AuthSetupResponse,
|
||||
AuthStatusResponse,
|
||||
DeleteSettingResponse,
|
||||
ListScheduleRunsResponse,
|
||||
ListSchedulesResponse,
|
||||
ScheduleInfo,
|
||||
@@ -642,13 +643,16 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
"PUT", f"/v1/api/admin/settings/{key}", json_body=body, response_model=SettingInfo
|
||||
)
|
||||
|
||||
async def delete_setting(self, key: str, *, node_id: str = "") -> StatusResponse:
|
||||
async def delete_setting(self, key: str, *, node_id: str = "") -> DeleteSettingResponse:
|
||||
"""Reset a setting to its default value."""
|
||||
params: dict[str, Any] = {}
|
||||
if node_id:
|
||||
params["node_id"] = node_id
|
||||
return await self._request(
|
||||
"DELETE", f"/v1/api/admin/settings/{key}", params=params, response_model=StatusResponse
|
||||
"DELETE",
|
||||
f"/v1/api/admin/settings/{key}",
|
||||
params=params,
|
||||
response_model=DeleteSettingResponse,
|
||||
)
|
||||
|
||||
# -- MCP servers -------------------------------------------------------
|
||||
@@ -1205,7 +1209,7 @@ class TurnstoneConsole:
|
||||
def update_setting(self, key: str, value: Any, *, node_id: str = "") -> SettingInfo:
|
||||
return self._runner.run(self._async.update_setting(key, value, node_id=node_id))
|
||||
|
||||
def delete_setting(self, key: str, *, node_id: str = "") -> StatusResponse:
|
||||
def delete_setting(self, key: str, *, node_id: str = "") -> DeleteSettingResponse:
|
||||
return self._runner.run(self._async.delete_setting(key, node_id=node_id))
|
||||
|
||||
# -- MCP servers -------------------------------------------------------
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user