mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 23:42:25 -06:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 \
|
||||
&& 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)
|
||||
|
||||
@@ -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.4"
|
||||
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 {
|
||||
@@ -149,6 +155,7 @@ export type ServerEvent =
|
||||
| ContentEvent
|
||||
| ReasoningEvent
|
||||
| StreamEndEvent
|
||||
| StateChangeEvent
|
||||
| ToolInfoEvent
|
||||
| ApproveRequestEvent
|
||||
| ApprovalResolvedEvent
|
||||
@@ -246,6 +253,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()
|
||||
|
||||
@@ -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,235 @@
|
||||
"""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,
|
||||
) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.id = model_id
|
||||
dumped: dict[str, Any] = {"owned_by": owned_by}
|
||||
if meta is not None:
|
||||
dumped["meta"] = meta
|
||||
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_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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.4"
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
+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)
|
||||
|
||||
+559
-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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1189,7 +1189,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 +1370,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 +1396,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 +2119,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 +2186,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 +2291,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 +2339,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/"
|
||||
|
||||
|
||||
+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 []
|
||||
|
||||
@@ -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")
|
||||
@@ -342,3 +434,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 = ""
|
||||
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
|
||||
# (only for known models — the default 200k would be misleading for local servers)
|
||||
if meta is not None:
|
||||
n_ctx = meta.get("n_ctx_train")
|
||||
if isinstance(n_ctx, int) and n_ctx > 0:
|
||||
result["context_window"] = n_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()
|
||||
|
||||
+1156
-275
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,
|
||||
)
|
||||
@@ -103,7 +107,6 @@ 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,
|
||||
@@ -118,7 +121,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 +138,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 +147,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 +406,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 +423,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 +435,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 +2669,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,
|
||||
)
|
||||
@@ -154,7 +158,6 @@ 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,
|
||||
@@ -169,7 +172,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 +202,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 +212,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 +494,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 +508,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 +518,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 +2736,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:
|
||||
|
||||
@@ -80,6 +80,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 +148,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 +158,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 +200,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:
|
||||
|
||||
@@ -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 -------------------------------------------------------
|
||||
|
||||
@@ -107,6 +107,7 @@ class ToolResultEvent(ServerEvent):
|
||||
call_id: str = ""
|
||||
name: str = ""
|
||||
output: str = ""
|
||||
is_error: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -162,11 +162,14 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
async def cancel(self, ws_id: str) -> StatusResponse:
|
||||
async def cancel(self, ws_id: str, *, force: bool = False) -> StatusResponse:
|
||||
body: dict[str, object] = {"ws_id": ws_id}
|
||||
if force:
|
||||
body["force"] = True
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/v1/api/cancel",
|
||||
json_body={"ws_id": ws_id},
|
||||
json_body=body,
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
@@ -473,8 +476,8 @@ class TurnstoneServer:
|
||||
def command(self, *, ws_id: str, command: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.command(ws_id=ws_id, command=command))
|
||||
|
||||
def cancel(self, ws_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.cancel(ws_id))
|
||||
def cancel(self, ws_id: str, *, force: bool = False) -> StatusResponse:
|
||||
return self._runner.run(self._async.cancel(ws_id, force=force))
|
||||
|
||||
# -- streaming -----------------------------------------------------------
|
||||
|
||||
|
||||
+274
-31
@@ -382,14 +382,29 @@ class WebUI:
|
||||
|
||||
return approved, feedback
|
||||
|
||||
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:
|
||||
_metrics.record_tool_call(name)
|
||||
with self._ws_lock:
|
||||
self._ws_tool_calls[name] = self._ws_tool_calls.get(name, 0) + 1
|
||||
self._ws_current_activity = ""
|
||||
self._ws_activity_state = ""
|
||||
self._broadcast_activity()
|
||||
self._enqueue({"type": "tool_result", "call_id": call_id, "name": name, "output": output})
|
||||
event: dict[str, Any] = {
|
||||
"type": "tool_result",
|
||||
"call_id": call_id,
|
||||
"name": name,
|
||||
"output": output,
|
||||
}
|
||||
if is_error:
|
||||
event["is_error"] = True
|
||||
self._enqueue(event)
|
||||
|
||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
|
||||
self._enqueue({"type": "tool_output_chunk", "call_id": call_id, "chunk": chunk})
|
||||
@@ -470,6 +485,9 @@ class WebUI:
|
||||
else:
|
||||
WebUI._workstream_mgr.set_state(self.ws_id, ws_state)
|
||||
self._broadcast_state(state)
|
||||
# Also send to per-workstream listeners so the browser UI can track
|
||||
# busy/idle transitions (stream_end fires per-segment, not per-turn).
|
||||
self._enqueue({"type": "state_change", "state": state})
|
||||
|
||||
def on_rename(self, name: str) -> None:
|
||||
"""Update the workstream's display name and broadcast to all clients."""
|
||||
@@ -624,13 +642,25 @@ def _build_history(
|
||||
}
|
||||
for tc in msg["tool_calls"]
|
||||
]
|
||||
# Detect denied/blocked tool results by their content prefix.
|
||||
# Detect denied/blocked/errored tool results by their content prefix.
|
||||
if msg.get("role") == "tool":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and (
|
||||
content.startswith("Denied by user") or content.startswith("Blocked")
|
||||
):
|
||||
entry["denied"] = True
|
||||
if isinstance(content, str):
|
||||
if content.startswith("Denied by user") or content.startswith("Blocked"):
|
||||
entry["denied"] = True
|
||||
# Use persisted flag if available, fall back to text
|
||||
# heuristic for historical data that predates is_error.
|
||||
if (
|
||||
msg.get("is_error")
|
||||
or content.startswith("Error")
|
||||
or content.startswith("Command timed out")
|
||||
or content.startswith("Search timed out")
|
||||
or content.startswith("Unknown tool:")
|
||||
or content.startswith("JSON parse error:")
|
||||
or content.startswith("MCP prompt timed out")
|
||||
or content.startswith("MCP prompt error")
|
||||
):
|
||||
entry["is_error"] = True
|
||||
history.append(entry)
|
||||
|
||||
# Propagate denial from tool results to their parent assistant entry.
|
||||
@@ -775,6 +805,22 @@ def _get_ws(
|
||||
return None, None
|
||||
|
||||
|
||||
def _audit_context(request: Request) -> tuple[str, str]:
|
||||
"""Extract (user_id, ip_address) from request for audit logging."""
|
||||
auth = getattr(getattr(request, "state", None), "auth_result", None)
|
||||
uid: str = auth.user_id if auth else ""
|
||||
ip = ""
|
||||
if request.client:
|
||||
ip = request.client.host
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded:
|
||||
from turnstone.core.auth import is_secure_request
|
||||
|
||||
if is_secure_request(dict(request.headers), request.url.scheme):
|
||||
ip = forwarded.split(",")[0].strip()
|
||||
return uid, ip
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route handlers — all async
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -994,6 +1040,24 @@ async def list_skills_summary(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"skills": skills})
|
||||
|
||||
|
||||
async def list_available_models(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/models — list available model aliases."""
|
||||
registry = getattr(request.app.state, "registry", None)
|
||||
if registry is None:
|
||||
return JSONResponse({"models": []})
|
||||
models = []
|
||||
for alias in registry.list_aliases():
|
||||
cfg = registry.get_config(alias)
|
||||
models.append(
|
||||
{
|
||||
"alias": cfg.alias,
|
||||
"model": cfg.model,
|
||||
"provider": cfg.provider,
|
||||
}
|
||||
)
|
||||
return JSONResponse({"models": models})
|
||||
|
||||
|
||||
def _count_ws_states(wss: list[Workstream]) -> dict[str, int]:
|
||||
"""Count workstream states for health/metrics endpoints."""
|
||||
counts = dict.fromkeys(("idle", "thinking", "running", "attention", "error"), 0)
|
||||
@@ -1079,22 +1143,37 @@ def _make_watch_dispatch(ws: Workstream, session: ChatSession, ui: Any) -> Any:
|
||||
pending = session._watch_pending
|
||||
|
||||
def dispatch(msg: str) -> None:
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
# Workstream is busy — queue for drain at IDLE (Path A)
|
||||
pending.put({"message": msg})
|
||||
return
|
||||
with ws._lock:
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
# Workstream is busy — queue for drain at IDLE (Path A)
|
||||
try:
|
||||
pending.put_nowait({"message": msg})
|
||||
except queue.Full:
|
||||
log.warning(
|
||||
"Watch pending queue full, dropping result for ws %s",
|
||||
ws.id,
|
||||
)
|
||||
return
|
||||
|
||||
# Workstream is idle — start a worker thread (Path B)
|
||||
def run() -> None:
|
||||
try:
|
||||
session.send(msg)
|
||||
except Exception as exc:
|
||||
if ui:
|
||||
ui.on_error(f"Watch error: {exc}")
|
||||
# Workstream is idle — start a worker thread (Path B)
|
||||
# Mirrors the send_message() run() pattern for proper cleanup.
|
||||
def run() -> None:
|
||||
me = threading.current_thread()
|
||||
try:
|
||||
session.send(msg)
|
||||
except GenerationCancelled:
|
||||
if ws.worker_thread is me and ui:
|
||||
ui.on_stream_end()
|
||||
ui.on_state_change("idle")
|
||||
except Exception as exc:
|
||||
if ws.worker_thread is me and ui:
|
||||
ui.on_error(f"Watch error: {exc}")
|
||||
ui.on_stream_end()
|
||||
ui.on_state_change("error")
|
||||
|
||||
t = threading.Thread(target=run, daemon=True)
|
||||
ws.worker_thread = t
|
||||
t.start()
|
||||
t = threading.Thread(target=run, daemon=True)
|
||||
ws.worker_thread = t
|
||||
t.start()
|
||||
|
||||
return dispatch
|
||||
|
||||
@@ -1116,6 +1195,15 @@ async def send_message(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"error": "Unknown workstream"}, status_code=404)
|
||||
# Atomically check-and-start to prevent two concurrent workers on the
|
||||
# same session (ChatSession.send() is not thread-safe).
|
||||
# If cancel was requested, poll briefly for the worker to exit before
|
||||
# rejecting. Snapshot the thread ref since force-cancel can set it to
|
||||
# None concurrently. Uses async sleep to avoid blocking the event loop.
|
||||
worker = ws.worker_thread
|
||||
if worker and worker.is_alive() and ws.session and ws.session._cancel_event.is_set():
|
||||
for _ in range(30): # up to 3s in 100ms steps
|
||||
await asyncio.sleep(0.1)
|
||||
if not worker.is_alive():
|
||||
break
|
||||
with ws._lock:
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
ui._enqueue(
|
||||
@@ -1130,16 +1218,21 @@ async def send_message(request: Request) -> JSONResponse:
|
||||
|
||||
def run() -> None:
|
||||
assert ui is not None
|
||||
me = threading.current_thread()
|
||||
try:
|
||||
session.send(message)
|
||||
except GenerationCancelled:
|
||||
# Safety net — send() normally handles this internally.
|
||||
ui._enqueue({"type": "stream_end"})
|
||||
ui.on_state_change("idle")
|
||||
# If this thread was force-abandoned, ws.worker_thread will
|
||||
# have been set to None — don't emit spurious events.
|
||||
if ws.worker_thread is me:
|
||||
ui.on_stream_end()
|
||||
ui.on_state_change("idle")
|
||||
except Exception as e:
|
||||
ui.on_error(f"Error: {e}")
|
||||
ui._enqueue({"type": "stream_end"})
|
||||
ui.on_state_change("error")
|
||||
if ws.worker_thread is me:
|
||||
ui.on_error(f"Error: {e}")
|
||||
ui.on_stream_end()
|
||||
ui.on_state_change("error")
|
||||
|
||||
t = threading.Thread(target=run, daemon=True)
|
||||
ws.worker_thread = t
|
||||
@@ -1211,6 +1304,7 @@ async def cancel_generation(request: Request) -> JSONResponse:
|
||||
session = ws.session
|
||||
if session is None:
|
||||
return JSONResponse({"error": "No session"}, status_code=400)
|
||||
force = body.get("force", False) is True
|
||||
# Only act if generation is actually in progress
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
# Set the cooperative cancel flag (worker thread checks at checkpoints)
|
||||
@@ -1218,8 +1312,19 @@ async def cancel_generation(request: Request) -> JSONResponse:
|
||||
# Unblock any pending approval/plan review waits
|
||||
ui.resolve_approval(False, "Cancelled by user")
|
||||
ui.resolve_plan("reject")
|
||||
# Emit cancelled SSE event so SDK consumers get a typed signal
|
||||
ui._enqueue({"type": "cancelled"})
|
||||
if force:
|
||||
# Force cancel: abandon the stuck worker thread (daemon, will
|
||||
# die on process exit or stream timeout) and emit stream_end
|
||||
# so the UI and session recover immediately. The per-generation
|
||||
# cancel event stays set so the abandoned thread still kills
|
||||
# subprocesses at its next checkpoint.
|
||||
with ws._lock:
|
||||
ws.worker_thread = None
|
||||
ui._enqueue({"type": "stream_end"})
|
||||
ui.on_state_change("idle")
|
||||
else:
|
||||
# Emit cancelled SSE event so SDK consumers get a typed signal
|
||||
ui._enqueue({"type": "cancelled"})
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
@@ -1242,11 +1347,30 @@ async def command(request: Request) -> JSONResponse:
|
||||
assert ws.session is not None
|
||||
|
||||
try:
|
||||
# Permission gate for conversation-modifying commands
|
||||
cmd_word = cmd.strip().split(None, 1)[0].lower()
|
||||
if cmd_word in ("/rewind", "/retry"):
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "conversation.modify")
|
||||
if err:
|
||||
ui.on_error("Permission denied: conversation.modify required")
|
||||
return err
|
||||
# Prevent rewind/retry while a generation is in progress
|
||||
with ws._lock:
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
ui._enqueue(
|
||||
{
|
||||
"type": "busy_error",
|
||||
"message": "Cannot rewind/retry while processing.",
|
||||
}
|
||||
)
|
||||
return JSONResponse({"status": "busy"})
|
||||
|
||||
should_exit = ws.session.handle_command(cmd)
|
||||
if should_exit:
|
||||
ui.on_info("Session ended. You can close this tab.")
|
||||
# Handle UI updates for workstream-changing commands
|
||||
cmd_word = cmd.strip().split(None, 1)[0].lower()
|
||||
if cmd_word in ("/clear", "/new"):
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
elif cmd_word == "/resume":
|
||||
@@ -1254,6 +1378,54 @@ async def command(request: Request) -> JSONResponse:
|
||||
history = _build_history(ws.session)
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
elif cmd_word in ("/rewind", "/retry"):
|
||||
# Refresh frontend with truncated history
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
history = _build_history(ws.session)
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
# Audit trail
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
if storage:
|
||||
from turnstone.core.audit import record_audit
|
||||
|
||||
audit_uid, ip = _audit_context(request)
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
f"conversation.{cmd_word[1:]}",
|
||||
"workstream",
|
||||
ws.id,
|
||||
{"command": cmd, "ws_id": ws.id},
|
||||
ip,
|
||||
)
|
||||
# Dispatch deferred retry in background thread
|
||||
retry_msg = ws.session._pending_retry
|
||||
if retry_msg:
|
||||
ws.session._pending_retry = None
|
||||
session = ws.session
|
||||
|
||||
def run_retry() -> None:
|
||||
me = threading.current_thread()
|
||||
try:
|
||||
session.send(retry_msg)
|
||||
except GenerationCancelled:
|
||||
if ws.worker_thread is me:
|
||||
ui.on_stream_end()
|
||||
ui.on_state_change("idle")
|
||||
except Exception as exc:
|
||||
if ws.worker_thread is me:
|
||||
ui.on_error(f"Error: {exc}")
|
||||
ui.on_stream_end()
|
||||
ui.on_state_change("error")
|
||||
|
||||
with ws._lock:
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
ui.on_error("Cannot retry: workstream is busy")
|
||||
else:
|
||||
t = threading.Thread(target=run_retry, daemon=True)
|
||||
ws.worker_thread = t
|
||||
t.start()
|
||||
# Sync in-memory workstream name after any command that can change it.
|
||||
# This ensures /api/workstreams and future page loads see the right name.
|
||||
if cmd_word in ("/name", "/resume"):
|
||||
@@ -1746,6 +1918,60 @@ def internal_mcp_status(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"servers": mcp_mgr.get_all_server_status()})
|
||||
|
||||
|
||||
# -- internal model management -----------------------------------------------
|
||||
|
||||
|
||||
def internal_model_reload(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/_internal/model-reload — rebuild registry from DB + config."""
|
||||
from turnstone.core.model_registry import load_model_registry
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
registry = getattr(request.app.state, "registry", None)
|
||||
cli_args = getattr(request.app.state, "cli_model_args", None)
|
||||
if registry is None or cli_args is None:
|
||||
return JSONResponse({"status": "error", "reason": "no registry"}, status_code=503)
|
||||
|
||||
new_registry = load_model_registry(
|
||||
base_url=cli_args["base_url"],
|
||||
api_key=cli_args["api_key"],
|
||||
model=cli_args["model"],
|
||||
context_window=cli_args["context_window"],
|
||||
provider=cli_args["provider"],
|
||||
storage=get_storage(),
|
||||
)
|
||||
try:
|
||||
registry.reload(
|
||||
new_registry.models,
|
||||
new_registry.default,
|
||||
new_registry.fallback,
|
||||
new_registry.agent_model,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse({"status": "error", "reason": str(exc)}, status_code=422)
|
||||
finally:
|
||||
new_registry.shutdown()
|
||||
return JSONResponse({"status": "ok", "aliases": registry.list_aliases()})
|
||||
|
||||
|
||||
def internal_model_status(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/_internal/model-status — return this node's model aliases."""
|
||||
registry = getattr(request.app.state, "registry", None)
|
||||
if registry is None:
|
||||
return JSONResponse({"models": {}})
|
||||
|
||||
models: dict[str, dict[str, Any]] = {}
|
||||
for alias in registry.list_aliases():
|
||||
cfg = registry.get_config(alias)
|
||||
models[alias] = {
|
||||
"model": cfg.model,
|
||||
"provider": cfg.provider,
|
||||
"source": cfg.source,
|
||||
"context_window": cfg.context_window,
|
||||
"enabled": True,
|
||||
}
|
||||
return JSONResponse({"models": models})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global SSE fan-out
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1936,6 +2162,7 @@ def create_app(
|
||||
Route("/api/dashboard", dashboard),
|
||||
Route("/api/workstreams/saved", list_saved_workstreams),
|
||||
Route("/api/skills", list_skills_summary),
|
||||
Route("/api/models", list_available_models),
|
||||
Route("/api/send", send_message, methods=["POST"]),
|
||||
Route("/api/approve", approve, methods=["POST"]),
|
||||
Route("/api/plan", plan_feedback, methods=["POST"]),
|
||||
@@ -1959,6 +2186,12 @@ def create_app(
|
||||
Route("/api/_internal/config-reload", config_reload, methods=["POST"]),
|
||||
Route("/api/_internal/mcp-reload", internal_mcp_reload, methods=["POST"]),
|
||||
Route("/api/_internal/mcp-status", internal_mcp_status),
|
||||
Route(
|
||||
"/api/_internal/model-reload",
|
||||
internal_model_reload,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route("/api/_internal/model-status", internal_model_status),
|
||||
],
|
||||
),
|
||||
Route("/health", health),
|
||||
@@ -2185,8 +2418,9 @@ def main() -> None:
|
||||
else:
|
||||
context_window = 32768
|
||||
|
||||
# Build model registry (reads [models.*] sections from config.toml)
|
||||
# Build model registry (reads [models.*] + database model definitions)
|
||||
from turnstone.core.model_registry import load_model_registry
|
||||
from turnstone.core.storage._registry import get_storage as _get_storage
|
||||
|
||||
registry = load_model_registry(
|
||||
base_url=base_url,
|
||||
@@ -2194,11 +2428,11 @@ def main() -> None:
|
||||
model=model,
|
||||
context_window=context_window,
|
||||
provider=provider_name,
|
||||
storage=_get_storage(),
|
||||
)
|
||||
|
||||
# Initialize MCP client (connects to configured MCP servers, if any)
|
||||
from turnstone.core.mcp_client import create_mcp_client
|
||||
from turnstone.core.storage._registry import get_storage as _get_storage
|
||||
|
||||
mcp_config_cli = args.mcp_config # CLI-only (no config.toml for this)
|
||||
mcp_client = create_mcp_client(
|
||||
@@ -2427,6 +2661,15 @@ def main() -> None:
|
||||
config_store=config_store,
|
||||
)
|
||||
|
||||
# Store CLI model args for hot-reload (internal_model_reload reads these)
|
||||
app.state.cli_model_args = {
|
||||
"base_url": base_url,
|
||||
"api_key": api_key,
|
||||
"model": model,
|
||||
"context_window": context_window,
|
||||
"provider": provider_name,
|
||||
}
|
||||
|
||||
log.info("Server starting on http://%s:%s", args.host, args.port)
|
||||
log.info("Model: %s", model)
|
||||
if registry.count > 1:
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
--yellow: #fbbf24;
|
||||
--cyan: #67e8f9;
|
||||
--magenta: #c084fc;
|
||||
--blue: #38bdf8;
|
||||
--on-color: var(--bg);
|
||||
|
||||
/* Glow variants for LED effects */
|
||||
@@ -37,6 +38,7 @@
|
||||
--accent-glow-strong: rgba(229, 160, 66, 0.3);
|
||||
--cyan-glow: rgba(103, 232, 249, 0.2);
|
||||
--magenta-glow: rgba(192, 132, 252, 0.25);
|
||||
--blue-glow: rgba(56, 189, 248, 0.25);
|
||||
|
||||
/* Structure */
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
@@ -68,6 +70,7 @@
|
||||
--yellow: #b45309;
|
||||
--cyan: #0e7490;
|
||||
--magenta: #7c3aed;
|
||||
--blue: #0369a1;
|
||||
--on-color: #ffffff;
|
||||
--green-glow: rgba(4, 120, 87, 0.25);
|
||||
--red-glow: rgba(220, 38, 38, 0.25);
|
||||
@@ -75,6 +78,7 @@
|
||||
--accent-glow-strong: rgba(140, 94, 27, 0.15);
|
||||
--cyan-glow: rgba(14, 116, 144, 0.2);
|
||||
--magenta-glow: rgba(124, 58, 237, 0.2);
|
||||
--blue-glow: rgba(3, 105, 161, 0.2);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--border-strong: rgba(0, 0, 0, 0.12);
|
||||
--code-bg: #f0f1f5;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user