* Studio: add API key authentication for programmatic access External users want to hit the Studio API (chat completions with tool calling, training, export, etc.) without going through the browser login flow. This adds sk-unsloth- prefixed API keys that work as a drop-in replacement for JWTs in the Authorization: Bearer header. Backend: - New api_keys table in SQLite (storage.py) - create/list/revoke/validate functions with SHA-256 hashed storage - API key detection in _get_current_subject before the JWT path - POST/GET/DELETE /api/auth/api-keys endpoints on the auth router Frontend: - /api-keys page with create form, one-time key reveal, keys table - API Keys link in desktop and mobile navbar - Route registered with requireAuth guard Zero changes to any existing route handler -- every endpoint that uses Depends(get_current_subject) automatically works with API keys. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use actual origin in API key usage examples The examples on /api-keys were hardcoded to localhost:8888 which is wrong for remote users. Use window.location.origin so the examples show the correct URL regardless of where the user is connecting from. * Add `unsloth studio run` CLI command for one-liner model serving Adds a `run` subcommand that starts Studio, loads a model, creates an API key, and prints a ready-to-use curl command -- similar to `ollama run` or `vllm serve`. Usage: unsloth studio run -m unsloth/Qwen3-1.7B-GGUF --gguf-variant UD-Q4_K_XL * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add end-to-end tests for `unsloth studio run` and API key usage Tests the 4 usage examples from the API Keys page: 1. curl basic (non-streaming) chat completions 2. curl streaming (SSE) chat completions 3. OpenAI Python SDK streaming completions 4. curl with tools (web_search + python) Also tests --help output, invalid key rejection, and no-key rejection. All 7 tests pass against Qwen3-1.7B-GGUF. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add /v1/completions, /v1/embeddings, /v1/responses endpoints and --parallel support - llama_cpp.py: accept n_parallel param, pass to llama-server --parallel - run.py: plumb llama_parallel_slots through to app.state - inference.py: add /completions and /embeddings as transparent proxies to llama-server, add /responses as application-level endpoint that converts to ChatCompletionRequest; thread n_parallel through load_model - studio.py: set llama_parallel_slots=4 for `unsloth studio run` path * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make /v1/responses endpoint match OpenAI Responses API format The existing /v1/responses shim returned Chat Completions format, which broke OpenAI SDK clients using openai.responses.create(). This commit replaces the endpoint with a proper implementation that: - Returns `output` array with `output_text` content parts instead of `choices` with `message` - Uses `input_tokens`/`output_tokens` instead of `prompt_tokens`/ `completion_tokens` in usage - Sets `object: "response"` and `id: "resp_..."` - Emits named SSE events for streaming (response.created, response.output_text.delta, response.completed, etc.) - Accepts all OpenAI Responses API fields (tools, store, metadata, previous_response_id) without erroring -- silently ignored - Maps `developer` role to `system` and `input_text`/`input_image` content parts to the internal Chat format Adds Pydantic schemas for request/response models and 23 unit tests covering schema validation, input normalisation, and response format. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: add Anthropic-compatible /v1/messages endpoint (#4981) * Add Anthropic-compatible /v1/messages endpoint with tool support Translate Anthropic Messages API format to/from internal OpenAI format and reuse the existing server-side agentic tool loop. Supports streaming SSE (message_start, content_block_delta, etc.) and non-streaming JSON. Includes offline unit tests and e2e tests in test_studio_run.py. * Add enable_tools, enabled_tools, session_id to /v1/messages endpoint Support the same shorthand as /v1/chat/completions: enable_tools=true with an optional enabled_tools list uses built-in server tools without requiring full Anthropic tool definitions. session_id is passed through for sandbox isolation. max_tokens is now optional. * Strip leaked tool-call XML from Anthropic endpoint content Apply _TOOL_XML_RE to content events in both streaming and non-streaming tool paths, matching the OpenAI endpoint behavior. * Emit custom tool_result SSE event in Anthropic stream Adds a non-standard tool_result event between the tool_use block close and the next text block, so clients can see server-side tool execution results. Anthropic SDKs ignore unknown event types. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Split /v1/messages into server-side and client-side tool paths enable_tools=true runs the existing server-side agentic loop with built-in tools (web_search/python/terminal). A bare tools=[...] field now triggers a client-side pass-through: client-provided tools are forwarded to llama-server and any tool_use output is returned to the caller with stop_reason=tool_use for client execution. This fixes Claude Code (and any Anthropic SDK client) which sends tools=[...] expecting client-side execution but was previously routed through execute_tool() and failing with 'Unknown tool'. Adds AnthropicPassthroughEmitter to convert llama-server OpenAI SSE chunks into Anthropic SSE events, plus unit tests covering text blocks, tool_use blocks, mixed, stop reasons, and usage. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix httpcore GeneratorExit in /v1/messages passthrough stream Explicitly aclose aiter_lines() before the surrounding async with blocks unwind, mirroring the prior fix in external_provider.py (a41160d3) and cc757b78's RuntimeError suppression. * Wire stop_sequences through /v1/messages; warn on tool_choice Plumb payload.stop_sequences to all three code paths (server-side tool loop, no-tool plain, client-side passthrough) so Anthropic SDK clients setting stop_sequences get the behavior they expect. The llama_cpp backend already accepted `stop` on both generate_chat_ completion and generate_chat_completion_with_tools; the Anthropic handler simply wasn't passing it. tool_choice remains declared on the request model for Anthropic SDK compatibility (the SDK often sets it by default) but is not yet honored. Log a structured warning on each request carrying a non- null tool_choice so the silent drop is visible to operators. * Wire min_p / repetition_penalty / presence_penalty through /v1/messages Align the Anthropic endpoint's sampling surface with /v1/chat/completions. Adds the three fields as x-unsloth extensions on AnthropicMessagesRequest and threads them through all three code paths: server-side tool loop, no-tool plain, and client-side passthrough. The passthrough builder emits "repeat_penalty" (not "repetition_penalty") because that is llama-server's field name; the backend methods already apply the same rename internally. * Fix block ordering and prev_text reset in non-streaming tool path _anthropic_tool_non_streaming was building the response by appending all tool_use blocks first, then a single concatenated text block at the end — losing generation order and merging pre-tool and post-tool text into one block. It also never reset prev_text between synthesis turns, so the first N characters of each post-tool turn were dropped (where N = length of the prior turn's final cumulative text). Rewrite to build content_blocks incrementally in generation order, matching the streaming emitter's behavior: deltas within a turn are merged into the trailing text block, tool_use blocks interrupt the text sequence, and prev_text is reset on tool_end so turn N+1 diffs against an empty baseline. Caught by gemini-code-assist[bot] review on #4981. * Make test_studio_run.py e2e tests pytest-compatible Add a hybrid session-scoped studio_server fixture in conftest.py that feeds base_url / api_key into the existing e2e test functions. Three invocation modes are now supported: 1. Script mode (unchanged) — python tests/test_studio_run.py 2. Pytest + external server — point at a running instance via UNSLOTH_E2E_BASE_URL / UNSLOTH_E2E_API_KEY env vars, no per-run GGUF load cost 3. Pytest + fixture-managed server — pytest drives _start_server / _kill_server itself via --unsloth-model / --unsloth-gguf-variant, CI-friendly The existing _start_server / _kill_server helpers and main() stay untouched so the script entry point keeps working exactly as before. Test function signatures are unchanged — the (base_url, api_key) parameters now resolve via the new fixtures when running under pytest. * Rename test_studio_run.py -> test_studio_api.py The file is entirely about HTTP API endpoint testing (OpenAI-compatible /v1/chat/completions, Anthropic-compatible /v1/messages, API key auth, plus a CLI --help sanity check on the command that runs the API). None of its tests cover training, export, chat-UI, or internal-Python-API concerns. The old name misleadingly suggested "tests for the unsloth studio run CLI subcommand" — the new name reflects the actual scope. Updates: - git mv the file (rename tracked, history preserved) - Rewrite opening docstring to state the API surface focus and call out what is explicitly out of scope - Update all 4 Usage-block path references to the new filename - LOG_FILE renamed to test_studio_api.log - conftest.py fixture import rewritten from test_studio_run to test_studio_api, plus 7 docstring/comment references updated No functional changes to test logic, signatures, or main(). --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * Fix httpcore asyncgen cleanup in /v1/messages and /v1/completions The earlier fix in985e92a9was incomplete: it closed aiter_lines() explicitly but still used `async with httpx.AsyncClient()` / `async with client.stream()` inside the generator. When the generator is orphaned (e.g. client disconnects mid-stream and Starlette drops the StreamingResponse iterator without explicitly calling aclose()), Python's asyncgen finalizer runs the cleanup in a DIFFERENT task than the one that originally entered the httpx context managers. The `async with` exits then trigger httpcore's HTTP11ConnectionByteStream .aclose(), which enters anyio.CancelScope.__exit__ with a mismatched task and raises RuntimeError("Attempted to exit cancel scope in a different task"). That error escapes any user-owned try/except because it happens during GC finalization. Replace `async with` with manual client/response lifecycle in both /v1/messages passthrough and /v1/completions proxy. Close the response and client in a finally block wrapped in `try: ... except Exception: pass`. This suppresses RuntimeError (and other Exception subclasses) from the anyio cleanup noise while letting GeneratorExit (a BaseException, not Exception) propagate cleanly so the generator terminates as Python expects. Traceback observed in user report: File ".../httpcore/_async/connection_pool.py", line 404, in __aiter__ yield part RuntimeError: async generator ignored GeneratorExit ... File ".../anyio/_backends/_asyncio.py", line 455, in __exit__ raise RuntimeError( RuntimeError: Attempted to exit cancel scope in a different task * Expand unsloth studio run banner with SDK base URL and more curl examples Add an explicit "OpenAI / Anthropic SDK base URL" line inside the info box so SDK users don't accidentally copy the bare server URL (without /v1) into their OpenAI/Anthropic SDK constructors and hit 404s. Replace the single /v1/chat/completions curl example with three labeled blocks: chat/completions, Anthropic /messages, and OpenAI Responses. The Anthropic example includes max_tokens (Anthropic SDKs require it even though Studio accepts None). All examples derived from a computed sdk_base_url so the /v1 prefix stays in sync if the public path ever changes. * Hash API keys with HMAC-SHA256 + persistent server secret Stores the HMAC secret in a new app_secrets singleton table. Fixes CodeQL py/weak-sensitive-data-hashing alert on storage.py:74-76, 394-395. Refresh tokens stay on plain SHA-256 (unchanged _hash_token) so existing user sessions survive upgrade — API keys are new on this branch so there is no migration. * Use PBKDF2 for API key hashing per CodeQL recommendation HMAC-SHA256 was still flagged by py/weak-sensitive-data-hashing. Switch to hashlib.pbkdf2_hmac, which is in CodeQL's recommended allowlist (Argon2/scrypt/bcrypt/PBKDF2). Persistent server-side salt stays in app_secrets for defense-in-depth. 100k iterations to match auth/hashing.py's password hasher. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
774 lines
27 KiB
Python
774 lines
27 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
"""
|
|
Tests for the Anthropic Messages API schemas and translation layer.
|
|
No running server or GPU required.
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
|
|
_backend = os.path.join(os.path.dirname(__file__), "..")
|
|
sys.path.insert(0, _backend)
|
|
|
|
from models.inference import (
|
|
AnthropicMessagesRequest,
|
|
AnthropicMessagesResponse,
|
|
AnthropicMessage,
|
|
AnthropicTextBlock,
|
|
AnthropicToolUseBlock,
|
|
AnthropicToolResultBlock,
|
|
AnthropicTool,
|
|
AnthropicUsage,
|
|
AnthropicResponseTextBlock,
|
|
AnthropicResponseToolUseBlock,
|
|
)
|
|
from core.inference.anthropic_compat import (
|
|
anthropic_messages_to_openai,
|
|
anthropic_tools_to_openai,
|
|
build_anthropic_sse_event,
|
|
AnthropicStreamEmitter,
|
|
AnthropicPassthroughEmitter,
|
|
)
|
|
|
|
|
|
# =====================================================================
|
|
# Pydantic model tests
|
|
# =====================================================================
|
|
|
|
|
|
class TestAnthropicModels:
|
|
def test_minimal_request(self):
|
|
req = AnthropicMessagesRequest(
|
|
messages = [{"role": "user", "content": "Hi"}],
|
|
)
|
|
assert req.max_tokens is None
|
|
assert req.model == "default"
|
|
assert req.stream is False
|
|
|
|
def test_max_tokens_optional(self):
|
|
req = AnthropicMessagesRequest(
|
|
max_tokens = 100,
|
|
messages = [{"role": "user", "content": "Hi"}],
|
|
)
|
|
assert req.max_tokens == 100
|
|
|
|
def test_system_as_string(self):
|
|
req = AnthropicMessagesRequest(
|
|
max_tokens = 50,
|
|
messages = [{"role": "user", "content": "Hi"}],
|
|
system = "You are helpful.",
|
|
)
|
|
assert req.system == "You are helpful."
|
|
|
|
def test_tools_field_parses(self):
|
|
req = AnthropicMessagesRequest(
|
|
max_tokens = 100,
|
|
messages = [{"role": "user", "content": "Hi"}],
|
|
tools = [{"name": "web_search", "input_schema": {"type": "object"}}],
|
|
)
|
|
assert len(req.tools) == 1
|
|
assert req.tools[0].name == "web_search"
|
|
|
|
def test_extra_fields_accepted(self):
|
|
req = AnthropicMessagesRequest(
|
|
max_tokens = 100,
|
|
messages = [{"role": "user", "content": "Hi"}],
|
|
some_future_field = "hello",
|
|
)
|
|
assert req.max_tokens == 100
|
|
|
|
def test_stream_defaults_false(self):
|
|
req = AnthropicMessagesRequest(
|
|
max_tokens = 100,
|
|
messages = [{"role": "user", "content": "Hi"}],
|
|
)
|
|
assert req.stream is False
|
|
|
|
def test_enable_tools_shorthand(self):
|
|
req = AnthropicMessagesRequest(
|
|
messages = [{"role": "user", "content": "Hi"}],
|
|
enable_tools = True,
|
|
enabled_tools = ["web_search", "python"],
|
|
session_id = "my-session",
|
|
)
|
|
assert req.enable_tools is True
|
|
assert req.enabled_tools == ["web_search", "python"]
|
|
assert req.session_id == "my-session"
|
|
|
|
def test_extension_fields_default_none(self):
|
|
req = AnthropicMessagesRequest(
|
|
messages = [{"role": "user", "content": "Hi"}],
|
|
)
|
|
assert req.enable_tools is None
|
|
assert req.enabled_tools is None
|
|
assert req.session_id is None
|
|
|
|
def test_response_model_defaults(self):
|
|
resp = AnthropicMessagesResponse()
|
|
assert resp.type == "message"
|
|
assert resp.role == "assistant"
|
|
assert resp.id.startswith("msg_")
|
|
assert resp.content == []
|
|
assert resp.usage.input_tokens == 0
|
|
|
|
|
|
# =====================================================================
|
|
# Message translation tests
|
|
# =====================================================================
|
|
|
|
|
|
class TestAnthropicMessagesToOpenAI:
|
|
def test_simple_user_message(self):
|
|
msgs = [{"role": "user", "content": "Hello"}]
|
|
result = anthropic_messages_to_openai(msgs)
|
|
assert result == [{"role": "user", "content": "Hello"}]
|
|
|
|
def test_system_string_prepended(self):
|
|
msgs = [{"role": "user", "content": "Hello"}]
|
|
result = anthropic_messages_to_openai(msgs, system = "Be brief.")
|
|
assert result[0] == {"role": "system", "content": "Be brief."}
|
|
assert result[1] == {"role": "user", "content": "Hello"}
|
|
|
|
def test_system_as_block_list(self):
|
|
system = [
|
|
{"type": "text", "text": "Be brief."},
|
|
{"type": "text", "text": "Be accurate."},
|
|
]
|
|
msgs = [{"role": "user", "content": "Hello"}]
|
|
result = anthropic_messages_to_openai(msgs, system = system)
|
|
assert result[0]["role"] == "system"
|
|
assert "Be brief." in result[0]["content"]
|
|
assert "Be accurate." in result[0]["content"]
|
|
|
|
def test_multi_turn_conversation(self):
|
|
msgs = [
|
|
{"role": "user", "content": "Hi"},
|
|
{"role": "assistant", "content": "Hello!"},
|
|
{"role": "user", "content": "How are you?"},
|
|
]
|
|
result = anthropic_messages_to_openai(msgs)
|
|
assert len(result) == 3
|
|
assert result[0]["role"] == "user"
|
|
assert result[1]["role"] == "assistant"
|
|
assert result[2]["role"] == "user"
|
|
|
|
def test_assistant_tool_use_maps_to_tool_calls(self):
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Let me search."},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "tu_1",
|
|
"name": "web_search",
|
|
"input": {"query": "test"},
|
|
},
|
|
],
|
|
}
|
|
]
|
|
result = anthropic_messages_to_openai(msgs)
|
|
assert len(result) == 1
|
|
m = result[0]
|
|
assert m["role"] == "assistant"
|
|
assert m["content"] == "Let me search."
|
|
assert len(m["tool_calls"]) == 1
|
|
tc = m["tool_calls"][0]
|
|
assert tc["id"] == "tu_1"
|
|
assert tc["function"]["name"] == "web_search"
|
|
assert json.loads(tc["function"]["arguments"]) == {"query": "test"}
|
|
|
|
def test_tool_result_maps_to_tool_role(self):
|
|
msgs = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "tu_1",
|
|
"content": "Result text",
|
|
},
|
|
],
|
|
}
|
|
]
|
|
result = anthropic_messages_to_openai(msgs)
|
|
assert len(result) == 1
|
|
assert result[0]["role"] == "tool"
|
|
assert result[0]["tool_call_id"] == "tu_1"
|
|
assert result[0]["content"] == "Result text"
|
|
|
|
def test_mixed_text_and_tool_use_blocks(self):
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Thinking..."},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "tu_1",
|
|
"name": "python",
|
|
"input": {"code": "1+1"},
|
|
},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "tu_2",
|
|
"name": "terminal",
|
|
"input": {"command": "ls"},
|
|
},
|
|
],
|
|
}
|
|
]
|
|
result = anthropic_messages_to_openai(msgs)
|
|
assert len(result) == 1
|
|
m = result[0]
|
|
assert m["content"] == "Thinking..."
|
|
assert len(m["tool_calls"]) == 2
|
|
|
|
def test_tool_result_with_list_content(self):
|
|
msgs = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "tu_1",
|
|
"content": [
|
|
{"type": "text", "text": "Line 1"},
|
|
{"type": "text", "text": "Line 2"},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
]
|
|
result = anthropic_messages_to_openai(msgs)
|
|
assert result[0]["content"] == "Line 1 Line 2"
|
|
|
|
|
|
# =====================================================================
|
|
# Tool translation tests
|
|
# =====================================================================
|
|
|
|
|
|
class TestAnthropicToolsToOpenAI:
|
|
def test_single_tool(self):
|
|
tools = [
|
|
{
|
|
"name": "web_search",
|
|
"description": "Search",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
},
|
|
}
|
|
]
|
|
result = anthropic_tools_to_openai(tools)
|
|
assert len(result) == 1
|
|
assert result[0]["type"] == "function"
|
|
assert result[0]["function"]["name"] == "web_search"
|
|
assert result[0]["function"]["parameters"]["type"] == "object"
|
|
|
|
def test_multiple_tools(self):
|
|
tools = [
|
|
{"name": "a", "description": "Tool A", "input_schema": {}},
|
|
{"name": "b", "description": "Tool B", "input_schema": {}},
|
|
]
|
|
result = anthropic_tools_to_openai(tools)
|
|
assert len(result) == 2
|
|
assert result[0]["function"]["name"] == "a"
|
|
assert result[1]["function"]["name"] == "b"
|
|
|
|
def test_empty_list(self):
|
|
assert anthropic_tools_to_openai([]) == []
|
|
|
|
def test_pydantic_model_input(self):
|
|
tool = AnthropicTool(
|
|
name = "test", description = "desc", input_schema = {"type": "object"}
|
|
)
|
|
result = anthropic_tools_to_openai([tool])
|
|
assert result[0]["function"]["name"] == "test"
|
|
|
|
|
|
# =====================================================================
|
|
# SSE event helper tests
|
|
# =====================================================================
|
|
|
|
|
|
class TestBuildAnthropicSSEEvent:
|
|
def test_basic_event(self):
|
|
result = build_anthropic_sse_event("message_start", {"type": "message_start"})
|
|
assert result.startswith("event: message_start\n")
|
|
assert "data: " in result
|
|
assert result.endswith("\n\n")
|
|
|
|
def test_data_is_valid_json(self):
|
|
result = build_anthropic_sse_event("test", {"key": "value"})
|
|
data_line = result.split("\n")[1]
|
|
payload = json.loads(data_line.removeprefix("data: "))
|
|
assert payload == {"key": "value"}
|
|
|
|
|
|
# =====================================================================
|
|
# Stream emitter tests
|
|
# =====================================================================
|
|
|
|
|
|
class TestAnthropicStreamEmitter:
|
|
def test_start_emits_message_start_and_content_block_start(self):
|
|
e = AnthropicStreamEmitter()
|
|
events = e.start("msg_123", "test-model")
|
|
assert len(events) == 2
|
|
assert "message_start" in events[0]
|
|
assert "content_block_start" in events[1]
|
|
assert '"type": "text"' in events[1]
|
|
|
|
def test_content_delta_emits_text_delta(self):
|
|
e = AnthropicStreamEmitter()
|
|
e.start("msg_1", "m")
|
|
events = e.feed({"type": "content", "text": "Hello"})
|
|
assert len(events) == 1
|
|
parsed = json.loads(events[0].split("data: ")[1])
|
|
assert parsed["delta"]["type"] == "text_delta"
|
|
assert parsed["delta"]["text"] == "Hello"
|
|
|
|
def test_cumulative_content_diffs_correctly(self):
|
|
e = AnthropicStreamEmitter()
|
|
e.start("msg_1", "m")
|
|
e.feed({"type": "content", "text": "Hel"})
|
|
events = e.feed({"type": "content", "text": "Hello"})
|
|
parsed = json.loads(events[0].split("data: ")[1])
|
|
assert parsed["delta"]["text"] == "lo"
|
|
|
|
def test_empty_content_diff_no_event(self):
|
|
e = AnthropicStreamEmitter()
|
|
e.start("msg_1", "m")
|
|
e.feed({"type": "content", "text": "Hi"})
|
|
events = e.feed({"type": "content", "text": "Hi"})
|
|
assert events == []
|
|
|
|
def test_tool_start_closes_text_opens_tool_block(self):
|
|
e = AnthropicStreamEmitter()
|
|
e.start("msg_1", "m")
|
|
e.feed({"type": "content", "text": "Thinking"})
|
|
events = e.feed(
|
|
{
|
|
"type": "tool_start",
|
|
"tool_name": "web_search",
|
|
"tool_call_id": "tc_1",
|
|
"arguments": {"query": "test"},
|
|
}
|
|
)
|
|
# content_block_stop + content_block_start(tool_use) + content_block_delta(input_json)
|
|
assert len(events) == 3
|
|
assert "content_block_stop" in events[0]
|
|
assert "tool_use" in events[1]
|
|
assert "input_json_delta" in events[2]
|
|
|
|
def test_tool_end_closes_tool_opens_new_text_block(self):
|
|
e = AnthropicStreamEmitter()
|
|
e.start("msg_1", "m")
|
|
e.feed(
|
|
{
|
|
"type": "tool_start",
|
|
"tool_name": "t",
|
|
"tool_call_id": "tc_1",
|
|
"arguments": {},
|
|
}
|
|
)
|
|
events = e.feed(
|
|
{
|
|
"type": "tool_end",
|
|
"tool_name": "t",
|
|
"tool_call_id": "tc_1",
|
|
"result": "done",
|
|
}
|
|
)
|
|
# content_block_stop (tool) + tool_result + content_block_start (new text)
|
|
assert len(events) == 3
|
|
assert "content_block_stop" in events[0]
|
|
assert "tool_result" in events[1]
|
|
parsed = json.loads(events[1].split("data: ")[1])
|
|
assert parsed["content"] == "done"
|
|
assert parsed["tool_use_id"] == "tc_1"
|
|
assert "content_block_start" in events[2]
|
|
assert '"type": "text"' in events[2]
|
|
|
|
def test_finish_emits_stop_events(self):
|
|
e = AnthropicStreamEmitter()
|
|
e.start("msg_1", "m")
|
|
events = e.finish("end_turn")
|
|
# content_block_stop + message_delta + message_stop
|
|
assert len(events) == 3
|
|
assert "content_block_stop" in events[0]
|
|
assert "message_delta" in events[1]
|
|
assert "end_turn" in events[1]
|
|
assert "message_stop" in events[2]
|
|
|
|
def test_metadata_captured_in_finish_usage(self):
|
|
e = AnthropicStreamEmitter()
|
|
e.start("msg_1", "m")
|
|
e.feed(
|
|
{
|
|
"type": "metadata",
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 20},
|
|
}
|
|
)
|
|
events = e.finish("end_turn")
|
|
delta_event = [ev for ev in events if "message_delta" in ev][0]
|
|
parsed = json.loads(delta_event.split("data: ")[1])
|
|
assert parsed["usage"]["output_tokens"] == 20
|
|
|
|
def test_status_events_ignored(self):
|
|
e = AnthropicStreamEmitter()
|
|
e.start("msg_1", "m")
|
|
events = e.feed({"type": "status", "text": "Searching..."})
|
|
assert events == []
|
|
|
|
def test_no_tool_calls_simple_text_flow(self):
|
|
e = AnthropicStreamEmitter()
|
|
start_events = e.start("msg_1", "m")
|
|
content_events = e.feed({"type": "content", "text": "Hello world"})
|
|
meta_events = e.feed(
|
|
{"type": "metadata", "usage": {"prompt_tokens": 5, "completion_tokens": 2}}
|
|
)
|
|
end_events = e.finish("end_turn")
|
|
|
|
assert len(start_events) == 2
|
|
assert len(content_events) == 1
|
|
assert meta_events == []
|
|
assert len(end_events) == 3
|
|
|
|
def test_block_index_increments(self):
|
|
e = AnthropicStreamEmitter()
|
|
e.start("msg_1", "m")
|
|
assert e.block_index == 0
|
|
e.feed(
|
|
{
|
|
"type": "tool_start",
|
|
"tool_name": "t",
|
|
"tool_call_id": "tc_1",
|
|
"arguments": {},
|
|
}
|
|
)
|
|
assert e.block_index == 1
|
|
e.feed(
|
|
{
|
|
"type": "tool_end",
|
|
"tool_name": "t",
|
|
"tool_call_id": "tc_1",
|
|
"result": "ok",
|
|
}
|
|
)
|
|
assert e.block_index == 2
|
|
|
|
def test_text_after_tool_resets_prev_text(self):
|
|
e = AnthropicStreamEmitter()
|
|
e.start("msg_1", "m")
|
|
e.feed({"type": "content", "text": "Before tool"})
|
|
e.feed(
|
|
{
|
|
"type": "tool_start",
|
|
"tool_name": "t",
|
|
"tool_call_id": "tc_1",
|
|
"arguments": {},
|
|
}
|
|
)
|
|
e.feed(
|
|
{
|
|
"type": "tool_end",
|
|
"tool_name": "t",
|
|
"tool_call_id": "tc_1",
|
|
"result": "ok",
|
|
}
|
|
)
|
|
# After tool_end, prev_text should be reset
|
|
events = e.feed({"type": "content", "text": "After tool"})
|
|
parsed = json.loads(events[0].split("data: ")[1])
|
|
assert parsed["delta"]["text"] == "After tool"
|
|
|
|
|
|
# =====================================================================
|
|
# Pass-through emitter tests (client-side tool execution path)
|
|
# =====================================================================
|
|
|
|
|
|
class TestAnthropicPassthroughEmitter:
|
|
def _parse(self, event_str):
|
|
return json.loads(event_str.split("data: ")[1])
|
|
|
|
def test_start_emits_message_start_only(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
events = e.start("msg_1", "test-model")
|
|
assert len(events) == 1
|
|
assert "message_start" in events[0]
|
|
parsed = self._parse(events[0])
|
|
assert parsed["message"]["id"] == "msg_1"
|
|
assert parsed["message"]["model"] == "test-model"
|
|
|
|
def test_text_chunk_opens_text_block_and_emits_delta(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
chunk = {"choices": [{"delta": {"content": "Hello"}}]}
|
|
events = e.feed_chunk(chunk)
|
|
# content_block_start + content_block_delta
|
|
assert len(events) == 2
|
|
assert "content_block_start" in events[0]
|
|
assert '"type": "text"' in events[0]
|
|
delta = self._parse(events[1])
|
|
assert delta["delta"]["type"] == "text_delta"
|
|
assert delta["delta"]["text"] == "Hello"
|
|
|
|
def test_sequential_text_chunks_single_block(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
events1 = e.feed_chunk({"choices": [{"delta": {"content": "Hello"}}]})
|
|
events2 = e.feed_chunk({"choices": [{"delta": {"content": " world"}}]})
|
|
# First chunk opens the block, second only emits delta
|
|
assert len(events1) == 2
|
|
assert len(events2) == 1
|
|
assert self._parse(events2[0])["delta"]["text"] == " world"
|
|
|
|
def test_tool_call_opens_tool_use_block(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
chunk = {
|
|
"choices": [
|
|
{
|
|
"delta": {
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {"name": "Bash", "arguments": ""},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
events = e.feed_chunk(chunk)
|
|
assert len(events) == 1
|
|
parsed = self._parse(events[0])
|
|
assert parsed["type"] == "content_block_start"
|
|
assert parsed["content_block"]["type"] == "tool_use"
|
|
assert parsed["content_block"]["id"] == "call_1"
|
|
assert parsed["content_block"]["name"] == "Bash"
|
|
|
|
def test_tool_call_arguments_streamed_as_input_json_delta(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
# Open the tool call
|
|
e.feed_chunk(
|
|
{
|
|
"choices": [
|
|
{
|
|
"delta": {
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "c1",
|
|
"type": "function",
|
|
"function": {"name": "Bash", "arguments": ""},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
)
|
|
# Stream argument fragments
|
|
events1 = e.feed_chunk(
|
|
{
|
|
"choices": [
|
|
{
|
|
"delta": {
|
|
"tool_calls": [
|
|
{"index": 0, "function": {"arguments": '{"cmd'}}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
)
|
|
events2 = e.feed_chunk(
|
|
{
|
|
"choices": [
|
|
{
|
|
"delta": {
|
|
"tool_calls": [
|
|
{"index": 0, "function": {"arguments": '": "ls"}'}}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
)
|
|
parsed1 = self._parse(events1[0])
|
|
parsed2 = self._parse(events2[0])
|
|
assert parsed1["delta"]["type"] == "input_json_delta"
|
|
assert parsed1["delta"]["partial_json"] == '{"cmd'
|
|
assert parsed2["delta"]["partial_json"] == '": "ls"}'
|
|
|
|
def test_text_then_tool_closes_text_block(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
e.feed_chunk({"choices": [{"delta": {"content": "Let me check."}}]})
|
|
events = e.feed_chunk(
|
|
{
|
|
"choices": [
|
|
{
|
|
"delta": {
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "c1",
|
|
"type": "function",
|
|
"function": {"name": "Bash", "arguments": ""},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
)
|
|
# Should close text block and open tool_use block
|
|
assert "content_block_stop" in events[0]
|
|
assert "content_block_start" in events[1]
|
|
assert '"type": "tool_use"' in events[1]
|
|
|
|
def test_finish_reason_tool_calls_sets_tool_use_stop(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
e.feed_chunk(
|
|
{
|
|
"choices": [
|
|
{
|
|
"delta": {
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "c1",
|
|
"type": "function",
|
|
"function": {"name": "Bash", "arguments": "{}"},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
)
|
|
e.feed_chunk({"choices": [{"delta": {}, "finish_reason": "tool_calls"}]})
|
|
events = e.finish()
|
|
delta_event = [ev for ev in events if "message_delta" in ev][0]
|
|
parsed = self._parse(delta_event)
|
|
assert parsed["delta"]["stop_reason"] == "tool_use"
|
|
|
|
def test_finish_reason_stop_sets_end_turn(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
e.feed_chunk({"choices": [{"delta": {"content": "Hi"}}]})
|
|
e.feed_chunk({"choices": [{"delta": {}, "finish_reason": "stop"}]})
|
|
events = e.finish()
|
|
delta_event = [ev for ev in events if "message_delta" in ev][0]
|
|
parsed = self._parse(delta_event)
|
|
assert parsed["delta"]["stop_reason"] == "end_turn"
|
|
|
|
def test_finish_reason_length_sets_max_tokens(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
e.feed_chunk({"choices": [{"delta": {"content": "Hi"}}]})
|
|
e.feed_chunk({"choices": [{"delta": {}, "finish_reason": "length"}]})
|
|
events = e.finish()
|
|
delta_event = [ev for ev in events if "message_delta" in ev][0]
|
|
parsed = self._parse(delta_event)
|
|
assert parsed["delta"]["stop_reason"] == "max_tokens"
|
|
|
|
def test_finish_closes_current_block(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
e.feed_chunk({"choices": [{"delta": {"content": "Hi"}}]})
|
|
events = e.finish()
|
|
assert "content_block_stop" in events[0]
|
|
assert "message_delta" in events[1]
|
|
assert "message_stop" in events[2]
|
|
|
|
def test_usage_chunk_captured(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
e.feed_chunk({"choices": [{"delta": {"content": "Hi"}}]})
|
|
e.feed_chunk(
|
|
{
|
|
"choices": [],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
|
}
|
|
)
|
|
events = e.finish()
|
|
delta_event = [ev for ev in events if "message_delta" in ev][0]
|
|
parsed = self._parse(delta_event)
|
|
assert parsed["usage"]["output_tokens"] == 5
|
|
|
|
def test_empty_chunk_returns_no_events(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
events = e.feed_chunk({"choices": []})
|
|
assert events == []
|
|
|
|
def test_no_blocks_at_all_still_produces_valid_finish(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
events = e.finish()
|
|
# No content_block_stop because no block was opened
|
|
assert not any("content_block_stop" in ev for ev in events)
|
|
assert any("message_delta" in ev for ev in events)
|
|
assert any("message_stop" in ev for ev in events)
|
|
|
|
def test_multiple_tool_calls_distinct_blocks(self):
|
|
e = AnthropicPassthroughEmitter()
|
|
e.start("msg_1", "m")
|
|
# First tool call
|
|
e.feed_chunk(
|
|
{
|
|
"choices": [
|
|
{
|
|
"delta": {
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "c1",
|
|
"type": "function",
|
|
"function": {"name": "Bash", "arguments": "{}"},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
)
|
|
# Second tool call (different index)
|
|
events = e.feed_chunk(
|
|
{
|
|
"choices": [
|
|
{
|
|
"delta": {
|
|
"tool_calls": [
|
|
{
|
|
"index": 1,
|
|
"id": "c2",
|
|
"type": "function",
|
|
"function": {"name": "Read", "arguments": "{}"},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
)
|
|
# Should close block 0, open block 1
|
|
assert "content_block_stop" in events[0]
|
|
assert "content_block_start" in events[1]
|
|
parsed = self._parse(events[1])
|
|
assert parsed["content_block"]["name"] == "Read"
|
|
assert parsed["content_block"]["id"] == "c2"
|