mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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:**
|
||||
|
||||
|
||||
@@ -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.43/ 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -127,7 +127,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: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:72b3932ce99a860f5069544cd8423d3cdae3a51f6566db262b19ced19c780eb0
|
||||
size 274374
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+19
-8
@@ -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.
|
||||
|
||||
@@ -184,7 +189,8 @@ Execute a bash command and return stdout + stderr.
|
||||
|-----------|--------|----------|-------------|
|
||||
| `command` | string | yes | The bash command to execute. |
|
||||
|
||||
- **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 (default 120s). 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).
|
||||
|
||||
@@ -225,16 +231,20 @@ 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). |
|
||||
|
||||
\* 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` is provided to pick the nearest match). Requires a prior `read_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.
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: `task_agent` only.
|
||||
|
||||
@@ -265,8 +275,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`.
|
||||
|
||||
---
|
||||
|
||||
+4
-3
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.9.0"
|
||||
version = "0.9.3"
|
||||
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.43/**/*",
|
||||
"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
+10
-5
@@ -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,20 +58,22 @@
|
||||
"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": {
|
||||
|
||||
@@ -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]:
|
||||
|
||||
+402
-2
@@ -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):
|
||||
@@ -407,3 +408,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()
|
||||
@@ -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"] == 200000
|
||||
|
||||
@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"] == 200000
|
||||
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):
|
||||
|
||||
+312
-7
@@ -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)
|
||||
@@ -507,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."}
|
||||
@@ -518,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 = [
|
||||
@@ -626,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,
|
||||
@@ -658,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,
|
||||
@@ -689,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,
|
||||
@@ -1169,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."""
|
||||
|
||||
@@ -1411,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(
|
||||
@@ -1962,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
|
||||
@@ -2335,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):
|
||||
|
||||
@@ -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.9.0"
|
||||
__version__ = "0.9.3"
|
||||
|
||||
@@ -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/"
|
||||
|
||||
|
||||
@@ -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 ----------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -58,6 +58,12 @@ _RE_ENV_SECRET_KEY = re.compile(
|
||||
r"(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL)(?:_|$)|(?:^|_)KEY(?:_|$)",
|
||||
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,
|
||||
)
|
||||
|
||||
# (pattern, redact_label) — ordered most-specific first for redaction.
|
||||
_CREDENTIAL_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
||||
@@ -228,6 +234,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 +262,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."""
|
||||
@@ -286,6 +290,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 +304,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,6 +372,53 @@ 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
|
||||
|
||||
@@ -340,14 +431,19 @@ class AnthropicProvider:
|
||||
# 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": tool_msg.get("tool_call_id", ""),
|
||||
"content": content,
|
||||
}
|
||||
if tool_msg.get("is_error"):
|
||||
result_block["is_error"] = True
|
||||
tool_results.append(result_block)
|
||||
i += 1
|
||||
# Append any deferred synthetic results after real ones
|
||||
if pending_orphan_results:
|
||||
tool_results.extend(pending_orphan_results)
|
||||
pending_orphan_results = []
|
||||
converted.append({"role": "user", "content": tool_results})
|
||||
continue
|
||||
|
||||
@@ -459,6 +555,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 +573,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 +655,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 +767,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] = {
|
||||
@@ -310,6 +314,7 @@ 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)
|
||||
@@ -329,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:
|
||||
@@ -349,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
|
||||
@@ -377,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:
|
||||
@@ -390,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)
|
||||
@@ -404,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()
|
||||
@@ -442,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
|
||||
@@ -479,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(
|
||||
|
||||
+714
-182
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]:
|
||||
@@ -2644,6 +2668,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]:
|
||||
@@ -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]:
|
||||
@@ -2693,6 +2731,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,7 +158,7 @@ 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 ""})
|
||||
@@ -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,
|
||||
|
||||
+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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user