mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
Merge d85430cde7 into 6997223068
This commit is contained in:
commit
2e495a5a8e
7 changed files with 1731 additions and 0 deletions
796
tests/integration/_support/claude_code.py
Normal file
796
tests/integration/_support/claude_code.py
Normal file
|
|
@ -0,0 +1,796 @@
|
|||
"""Shared Claude Code-shaped request builders and upstream stream fixtures for integration contracts."""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from itertools import chain
|
||||
from typing import Final
|
||||
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
ANTHROPIC_API_KEY: Final = "synthetic-anthropic-key"
|
||||
FABLE: Final = "claude-fable-5-1"
|
||||
OPUS: Final = "claude-opus-5-5"
|
||||
CLI_BETA: Final = (
|
||||
"claude-code-20250219,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,"
|
||||
"context-management-2025-06-27,prompt-caching-scope-2026-01-05"
|
||||
)
|
||||
FRONTIER_CLI_BETA: Final = (
|
||||
f"{CLI_BETA},mid-conversation-system-2026-04-07,per-turn-control-2026-07-01,"
|
||||
"mid-conversation-tool-changes-2026-07-01,effort-2025-11-24"
|
||||
)
|
||||
CACHE: Final = {"type": "ephemeral"}
|
||||
CONTEXT_MANAGEMENT: Final = {"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]}
|
||||
THINKING_BUDGET: Final = {"budget_tokens": 31999, "type": "enabled", "display": "omitted"}
|
||||
THINKING_ADAPTIVE: Final = {"type": "adaptive", "display": "omitted"}
|
||||
METADATA_USER_ID: Final = json.dumps(
|
||||
{
|
||||
"device_id": "0" * 64,
|
||||
"account_uuid": "",
|
||||
"session_id": "00000000-0000-4000-8000-000000000000",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def schema(properties: JsonValue, required: tuple[str, ...]) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": list(required),
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def field(description: str, **extra: JsonValue) -> dict[str, JsonValue]:
|
||||
return {"description": description, **extra}
|
||||
|
||||
|
||||
def tools() -> tuple[dict[str, JsonValue], ...]:
|
||||
_MAX: Final = 9007199254740991
|
||||
return (
|
||||
{
|
||||
"name": "Agent",
|
||||
"description": "Launch a new agent to handle complex, multi-step tasks.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"description": field("A short (3-5 word) description of the task", type="string"),
|
||||
"prompt": field("The task for the agent to perform", type="string"),
|
||||
"subagent_type": field("The type of specialized agent to use for this task", type="string"),
|
||||
"model": field(
|
||||
"Optional model override for this agent.",
|
||||
type="string",
|
||||
enum=["sonnet", "opus", "haiku", "fable"],
|
||||
),
|
||||
"run_in_background": field(
|
||||
"Agents run in the background by default; you will be notified when one completes.",
|
||||
type="boolean",
|
||||
),
|
||||
"isolation": field("Isolation mode.", type="string", enum=["worktree", "remote"]),
|
||||
},
|
||||
("description", "prompt"),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Bash",
|
||||
"description": "Executes a given bash command and returns its output.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"command": field("The command to execute", type="string"),
|
||||
"timeout": field("Optional timeout in milliseconds (max 600000)", type="number"),
|
||||
"description": field(
|
||||
"Clear, concise description of what this command does in active voice.",
|
||||
type="string",
|
||||
),
|
||||
"run_in_background": field("Set to true to run this command in the background.", type="boolean"),
|
||||
"dangerouslyDisableSandbox": field(
|
||||
"Set this to true to dangerously override sandbox mode and run commands without sandboxing.",
|
||||
type="boolean",
|
||||
),
|
||||
},
|
||||
("command",),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "CronCreate",
|
||||
"description": "Schedule a prompt to be enqueued at a future time.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"cron": field(
|
||||
'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g.',
|
||||
type="string",
|
||||
),
|
||||
"prompt": field("The prompt to enqueue at each fire time.", type="string"),
|
||||
"recurring": field(
|
||||
"true (default) = fire on every cron match until deleted or auto-expired after 7 days.",
|
||||
type="boolean",
|
||||
),
|
||||
"durable": field(
|
||||
"true = persist to .claude/scheduled_tasks.json and survive restarts.",
|
||||
type="boolean",
|
||||
),
|
||||
},
|
||||
("cron", "prompt"),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "CronDelete",
|
||||
"description": "Cancel a cron job previously scheduled with CronCreate.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"id": field("Job ID returned by CronCreate.", type="string"),
|
||||
},
|
||||
("id",),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "CronList",
|
||||
"description": "List all cron jobs scheduled via CronCreate, both durable (.claude/scheduled_tasks.json) and session-only.",
|
||||
"input_schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Edit",
|
||||
"description": "Performs exact string replacements in files.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"file_path": field("The absolute path to the file to modify", type="string"),
|
||||
"old_string": field("The text to replace", type="string"),
|
||||
"new_string": field(
|
||||
"The text to replace it with (must be different from old_string)", type="string"
|
||||
),
|
||||
"replace_all": field(
|
||||
"Replace all occurrences of old_string (default false)",
|
||||
default=False,
|
||||
type="boolean",
|
||||
),
|
||||
},
|
||||
("file_path", "old_string", "new_string"),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "EnterWorktree",
|
||||
"description": "Use this tool ONLY when explicitly instructed to work in a worktree — either by the user directly, or by project instruc",
|
||||
"input_schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": field("Optional name for a new worktree.", type="string"),
|
||||
"path": field(
|
||||
"Path to an existing worktree to switch into instead of creating a new one.",
|
||||
type="string",
|
||||
),
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "ExitWorktree",
|
||||
"description": "Exit a worktree session created by EnterWorktree and return the session to the original working directory.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"action": field(
|
||||
'"keep" leaves the worktree and branch on disk; "remove" deletes both.',
|
||||
type="string",
|
||||
enum=["keep", "remove"],
|
||||
),
|
||||
"discard_changes": field(
|
||||
'Required true when action is "remove" and the worktree has uncommitted files or unmerged commits.',
|
||||
type="boolean",
|
||||
),
|
||||
},
|
||||
("action",),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "ListAgents",
|
||||
"description": "Lists agents you can SendMessage to — in-process subagents you spawned, the teammates on your team, other local Claude s",
|
||||
"input_schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": field("Not available in this build; leave unset.", type="string", maxLength=256),
|
||||
"q": field("Not available in this build; leave unset.", type="string", maxLength=256),
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "NotebookEdit",
|
||||
"description": "Replaces, inserts, or deletes a single cell in a Jupyter notebook (.ipynb file).",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"notebook_path": field(
|
||||
"The absolute path to the Jupyter notebook file to edit (must be absolute, not relative)",
|
||||
type="string",
|
||||
),
|
||||
"cell_id": field("The ID of the cell to edit.", type="string"),
|
||||
"new_source": field("The new source for the cell", type="string"),
|
||||
"cell_type": field(
|
||||
"The type of the cell (code or markdown).",
|
||||
type="string",
|
||||
enum=["code", "markdown"],
|
||||
),
|
||||
"edit_mode": field(
|
||||
"The type of edit to make (replace, insert, delete).",
|
||||
type="string",
|
||||
enum=["replace", "insert", "delete"],
|
||||
),
|
||||
},
|
||||
("notebook_path", "new_source"),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Read",
|
||||
"description": "Reads a file from the local filesystem.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"file_path": field("The absolute path to the file to read", type="string"),
|
||||
"offset": field("The line number to start reading from.", type="integer", minimum=0, maximum=_MAX),
|
||||
"limit": field(
|
||||
"The number of lines to read.",
|
||||
type="integer",
|
||||
exclusiveMinimum=0,
|
||||
maximum=_MAX,
|
||||
),
|
||||
"pages": field('Page range for PDF files (e.g., "1-5", "3", "10-20").', type="string"),
|
||||
},
|
||||
("file_path",),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "ReportFindings",
|
||||
"description": "Report code-review findings as a typed list so the host UI can render them.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"level": field(
|
||||
"Effort level the review ran at",
|
||||
type="string",
|
||||
enum=["low", "medium", "high", "xhigh", "max"],
|
||||
),
|
||||
"findings": field(
|
||||
"Verified findings, most-severe first; empty if none survived",
|
||||
maxItems=32,
|
||||
type="array",
|
||||
items={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": field("Repo-relative path of the file the finding is in", type="string"),
|
||||
"line": field(
|
||||
"1-indexed line the finding anchors to",
|
||||
type="integer",
|
||||
minimum=-_MAX,
|
||||
maximum=_MAX,
|
||||
),
|
||||
"summary": field("One-sentence statement of the defect", type="string"),
|
||||
"short_summary": field(
|
||||
"Compressed label for compact UI (≤60 chars): the claim alone, no rationale or consequence clause",
|
||||
type="string",
|
||||
maxLength=60,
|
||||
),
|
||||
"failure_scenario": field("Concrete inputs/state → wrong output/crash", type="string"),
|
||||
"category": field(
|
||||
"Short kebab-case slug of the finding type, e.g.",
|
||||
type="string",
|
||||
maxLength=40,
|
||||
),
|
||||
"verdict": field(
|
||||
"Set when a verify pass ran; absent on inline-only reviews",
|
||||
type="string",
|
||||
enum=["CONFIRMED", "PLAUSIBLE"],
|
||||
),
|
||||
"outcome": field(
|
||||
"Set ONLY when re-reporting after applying fixes: what happened to this finding",
|
||||
type="string",
|
||||
enum=["fixed", "skipped", "no_change_needed"],
|
||||
),
|
||||
},
|
||||
"required": ["file", "summary", "failure_scenario"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
},
|
||||
("findings",),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "ScheduleWakeup",
|
||||
"description": "Schedule when to resume work in /loop dynamic mode — the user invoked /loop without an interval, asking you to self-pace",
|
||||
"input_schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"delaySeconds": field("Seconds from now to wake up.", type="number"),
|
||||
"reason": field("One short sentence explaining the chosen delay.", type="string"),
|
||||
"prompt": field("The /loop input to fire on wake-up.", type="string"),
|
||||
"stop": field(
|
||||
"Set to true to end the dynamic loop immediately instead of scheduling another wakeup.",
|
||||
type="boolean",
|
||||
),
|
||||
"noop": field(
|
||||
"true = nothing changed (you checked and there is nothing to report).",
|
||||
type="boolean",
|
||||
),
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "SendMessage",
|
||||
"description": "# SendMessage\n\nSend a message to another agent.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"to": field(
|
||||
'Recipient: a name from ListAgents (append its " [ref]" only when a listing or an error shows one), a teammate name, "mai',
|
||||
type="string",
|
||||
allOf=[{"pattern": "^[^\\n\\r]*$"}, {"pattern": "^[\\s\\S]{0,300}$"}],
|
||||
),
|
||||
"summary": field(
|
||||
"A 5-10 word label for your own transcript row (not transmitted — the recipient previews the first line of `message`).",
|
||||
type="string",
|
||||
maxLength=200,
|
||||
),
|
||||
"message": field("Plain text message content.", default="", type="string"),
|
||||
"notify_when_idle": field(
|
||||
"Ask a session ON THIS MACHINE to send you ONE notice when it next goes idle (finishes its turn with nothing queued) or e",
|
||||
type="boolean",
|
||||
),
|
||||
},
|
||||
("to", "message"),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Skill",
|
||||
"description": "Invoke a skill.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"skill": field("The name of a skill from the available-skills list.", type="string"),
|
||||
"args": field("Optional arguments for the skill", type="string"),
|
||||
},
|
||||
("skill",),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "TaskCreate",
|
||||
"description": "Use this tool to create a structured task list for your current coding session.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"subject": field("A brief title for the task", type="string"),
|
||||
"description": field("What needs to be done", type="string"),
|
||||
"activeForm": field(
|
||||
'Present continuous form shown in spinner when in_progress (e.g., "Running tests")',
|
||||
type="string",
|
||||
),
|
||||
"metadata": field(
|
||||
"Arbitrary metadata to attach to the task",
|
||||
type="object",
|
||||
propertyNames={"type": "string"},
|
||||
additionalProperties={},
|
||||
),
|
||||
},
|
||||
("subject", "description"),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "TaskGet",
|
||||
"description": "Use this tool to retrieve a task by its ID from the task list.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"taskId": field("The ID of the task to retrieve", type="string"),
|
||||
},
|
||||
("taskId",),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "TaskList",
|
||||
"description": "Use this tool to list all tasks in the task list.",
|
||||
"input_schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "TaskStop",
|
||||
"description": "- Stops a running background task by its ID\n- Takes a task_id parameter identifying the task to stop\n- To stop an agent-",
|
||||
"input_schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": field("The ID of the background task to stop.", type="string"),
|
||||
"shell_id": field("Deprecated: use task_id instead", type="string"),
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "TaskUpdate",
|
||||
"description": "Use this tool to update a task in the task list.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"taskId": field("The ID of the task to update", type="string"),
|
||||
"subject": field("New subject for the task", type="string"),
|
||||
"description": field("New description for the task", type="string"),
|
||||
"activeForm": field(
|
||||
'Present continuous form shown in spinner when in_progress (e.g., "Running tests")',
|
||||
type="string",
|
||||
),
|
||||
"status": field(
|
||||
"New status for the task",
|
||||
anyOf=[
|
||||
{"type": "string", "enum": ["pending", "in_progress", "completed"]},
|
||||
{"type": "string", "const": "deleted"},
|
||||
],
|
||||
),
|
||||
"addBlocks": field("Task IDs that this task blocks", type="array", items={"type": "string"}),
|
||||
"addBlockedBy": field("Task IDs that block this task", type="array", items={"type": "string"}),
|
||||
"owner": field("New owner for the task", type="string"),
|
||||
"metadata": field(
|
||||
"Metadata keys to merge into the task.",
|
||||
type="object",
|
||||
propertyNames={"type": "string"},
|
||||
additionalProperties={},
|
||||
),
|
||||
},
|
||||
("taskId",),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "WebFetch",
|
||||
"description": "IMPORTANT: WebFetch WILL FAIL for authenticated or private URLs.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"url": field("The URL to fetch content from", type="string", format="uri"),
|
||||
"prompt": field("The prompt to run on the fetched content", type="string"),
|
||||
},
|
||||
("url", "prompt"),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "WebSearch",
|
||||
"description": "- Allows Claude to search the web and use the results to inform responses\n- Provides up-to-date information for current ",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"query": field("The search query to use", type="string", minLength=2),
|
||||
"allowed_domains": field(
|
||||
"Only include search results from these domains", type="array", items={"type": "string"}
|
||||
),
|
||||
"blocked_domains": field(
|
||||
"Never include search results from these domains", type="array", items={"type": "string"}
|
||||
),
|
||||
},
|
||||
("query",),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Workflow",
|
||||
"description": "Execute a workflow script that orchestrates multiple subagents deterministically.",
|
||||
"input_schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"script": field("Self-contained workflow script.", type="string", maxLength=524288),
|
||||
"name": field(
|
||||
"Name of a predefined workflow (built-in or from .claude/workflows/).", type="string"
|
||||
),
|
||||
"description": field(
|
||||
"Ignored — set the workflow description in the script's `meta` block.", type="string"
|
||||
),
|
||||
"title": field("Ignored — set the workflow title in the script's `meta` block.", type="string"),
|
||||
"args": field("Optional input value exposed to the script as the global `args`, verbatim."),
|
||||
"scriptPath": field("Path to a workflow script file on disk.", type="string"),
|
||||
"resumeFromRunId": field(
|
||||
"Run ID of a prior Workflow invocation to resume from.",
|
||||
type="string",
|
||||
pattern="^wf_[a-z0-9-]{6,}$",
|
||||
),
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Write",
|
||||
"description": "Writes a file to the local filesystem.",
|
||||
"input_schema": schema(
|
||||
{
|
||||
"file_path": field(
|
||||
"The absolute path to the file to write (must be absolute, not relative)", type="string"
|
||||
),
|
||||
"content": field("The content to write to the file", type="string"),
|
||||
},
|
||||
("file_path", "content"),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def system_blocks() -> tuple[dict[str, JsonValue], ...]:
|
||||
return (
|
||||
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.283.00; cc_entrypoint=sdk-cli;"},
|
||||
{"type": "text", "text": "Synthetic agent identity system prompt.", "cache_control": CACHE},
|
||||
{"type": "text", "text": "Synthetic interactive agent instructions.", "cache_control": CACHE},
|
||||
)
|
||||
|
||||
|
||||
def claude_code_request(cache_bust: str) -> dict[str, JsonValue]:
|
||||
reminders: Final = (
|
||||
f"<system-reminder>\n{cache_bust}\n</system-reminder>",
|
||||
"<system-reminder>\nSynthetic model identity reminder.\n</system-reminder>",
|
||||
"<system-reminder>\nSynthetic agent types reminder.\n</system-reminder>",
|
||||
"<system-reminder>\nSynthetic skills reminder.\n</system-reminder>",
|
||||
"<system-reminder>\n<total_tokens>15000000 tokens left</total_tokens>\n</system-reminder>",
|
||||
"<system-reminder>\nSynthetic date reminder.\n</system-reminder>",
|
||||
"<system-reminder>\nSynthetic attribution reminder.\n</system-reminder>",
|
||||
)
|
||||
return {
|
||||
"model": "",
|
||||
"system": list(system_blocks()),
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
*[{"type": "text", "text": reminder} for reminder in reminders],
|
||||
{"type": "text", "text": "Reply with exactly the word PONG", "cache_control": CACHE},
|
||||
],
|
||||
}
|
||||
],
|
||||
"tools": list(tools()),
|
||||
"metadata": {"user_id": METADATA_USER_ID},
|
||||
"max_tokens": 32000,
|
||||
"thinking": dict(THINKING_BUDGET),
|
||||
"context_management": dict(CONTEXT_MANAGEMENT),
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
|
||||
def frontier_request(
|
||||
cache_bust: str,
|
||||
effort: str,
|
||||
max_tokens: int,
|
||||
prompt_text: str = "Reply with exactly the word PONG",
|
||||
stream: bool = True,
|
||||
) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"model": "",
|
||||
"system": list(system_blocks()),
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": f"<system-reminder>\n{cache_bust}\n</system-reminder>"},
|
||||
{"type": "text", "text": prompt_text},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "# Environment\nSynthetic environment block.",
|
||||
"cache_control": CACHE,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"tools": list(tools()),
|
||||
"metadata": {"user_id": METADATA_USER_ID},
|
||||
"max_tokens": max_tokens,
|
||||
"thinking": dict(THINKING_ADAPTIVE),
|
||||
"context_management": dict(CONTEXT_MANAGEMENT),
|
||||
"output_config": {"effort": effort},
|
||||
"stream": stream,
|
||||
}
|
||||
|
||||
|
||||
def tool_loop_turn2(
|
||||
base: dict[str, JsonValue],
|
||||
assistant_content: tuple[dict[str, JsonValue], ...],
|
||||
tool_results: tuple[tuple[str, JsonValue], ...],
|
||||
) -> dict[str, JsonValue]:
|
||||
return {
|
||||
**base,
|
||||
"messages": [
|
||||
*base["messages"],
|
||||
{"role": "assistant", "content": list(assistant_content)},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"tool_use_id": tool_use_id, "type": "tool_result", "content": content}
|
||||
for tool_use_id, content in tool_results
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "<total_tokens>14999970 tokens left</total_tokens>",
|
||||
"cache_control": CACHE,
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "First privately list what you need next; then request every item that doesn't depend on another's result in this one response.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def cli_headers(key: str, beta: str = CLI_BETA) -> dict[str, str]:
|
||||
return {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "claude-cli/2.1.283 (external, sdk-cli)",
|
||||
"x-claude-code-session-id": "00000000-0000-4000-8000-000000000000",
|
||||
"x-stainless-arch": "x64",
|
||||
"x-stainless-lang": "js",
|
||||
"x-stainless-os": "Linux",
|
||||
"x-stainless-package-version": "0.112.1",
|
||||
"x-stainless-retry-count": "0",
|
||||
"x-stainless-runtime": "node",
|
||||
"x-stainless-runtime-version": "v26.3.0",
|
||||
"x-stainless-timeout": "600",
|
||||
"anthropic-beta": beta,
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-app": "cli",
|
||||
"x-api-key": key,
|
||||
}
|
||||
|
||||
|
||||
def sse_frame(event: str, data: JsonValue) -> bytes:
|
||||
return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode()
|
||||
|
||||
|
||||
def sse_events(text: str) -> tuple[tuple[str, dict[str, object]], ...]:
|
||||
frames: Final = tuple(frame for frame in text.split("\n\n") if frame.strip())
|
||||
return tuple(
|
||||
(
|
||||
event,
|
||||
json.loads(next(line.removeprefix("data: ") for line in frame.splitlines() if line.startswith("data: "))),
|
||||
)
|
||||
for frame in frames
|
||||
if (event := next(line.removeprefix("event: ") for line in frame.splitlines() if line.startswith("event: ")))
|
||||
!= "ping"
|
||||
)
|
||||
|
||||
|
||||
def _start_usage(usage: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
return {key: value for key, value in usage.items() if key != "output_tokens"}
|
||||
|
||||
|
||||
def text_stream(identity: str, model: str, text: str, usage: dict[str, int]) -> tuple[bytes, ...]:
|
||||
return (
|
||||
sse_frame(
|
||||
"message_start",
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": identity,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": _start_usage(usage),
|
||||
},
|
||||
},
|
||||
),
|
||||
sse_frame(
|
||||
"content_block_start",
|
||||
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
||||
),
|
||||
sse_frame(
|
||||
"content_block_delta",
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text}},
|
||||
),
|
||||
sse_frame("content_block_stop", {"type": "content_block_stop", "index": 0}),
|
||||
sse_frame(
|
||||
"message_delta",
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
||||
"usage": {"output_tokens": usage["output_tokens"]},
|
||||
},
|
||||
),
|
||||
sse_frame("message_stop", {"type": "message_stop"}),
|
||||
)
|
||||
|
||||
|
||||
def _tool_use_frames(index: int, tool_id: str, name: str, tool_input: JsonValue) -> tuple[bytes, ...]:
|
||||
arguments: Final = json.dumps(tool_input)
|
||||
return (
|
||||
sse_frame(
|
||||
"content_block_start",
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {"type": "tool_use", "id": tool_id, "name": name, "input": {}},
|
||||
},
|
||||
),
|
||||
sse_frame(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {"type": "input_json_delta", "partial_json": arguments[: len(arguments) // 2]},
|
||||
},
|
||||
),
|
||||
sse_frame(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {"type": "input_json_delta", "partial_json": arguments[len(arguments) // 2 :]},
|
||||
},
|
||||
),
|
||||
sse_frame("content_block_stop", {"type": "content_block_stop", "index": index}),
|
||||
)
|
||||
|
||||
|
||||
def tool_use_stream(
|
||||
identity: str,
|
||||
model: str,
|
||||
thinking: str,
|
||||
signature: str,
|
||||
tool_calls: tuple[tuple[str, str, JsonValue], ...],
|
||||
usage: dict[str, int],
|
||||
) -> tuple[bytes, ...]:
|
||||
head: Final = (
|
||||
sse_frame(
|
||||
"message_start",
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": identity,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": _start_usage(usage),
|
||||
},
|
||||
},
|
||||
),
|
||||
sse_frame(
|
||||
"content_block_start",
|
||||
{"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}},
|
||||
),
|
||||
sse_frame(
|
||||
"content_block_delta",
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": thinking}},
|
||||
),
|
||||
sse_frame(
|
||||
"content_block_delta",
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": signature}},
|
||||
),
|
||||
sse_frame("content_block_stop", {"type": "content_block_stop", "index": 0}),
|
||||
)
|
||||
tail: Final = (
|
||||
sse_frame(
|
||||
"message_delta",
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "tool_use", "stop_sequence": None},
|
||||
"usage": {"output_tokens": usage["output_tokens"]},
|
||||
},
|
||||
),
|
||||
sse_frame("message_stop", {"type": "message_stop"}),
|
||||
)
|
||||
frames: Final = (
|
||||
*head,
|
||||
*chain.from_iterable(
|
||||
_tool_use_frames(index, tool_id, name, tool_input)
|
||||
for index, (tool_id, name, tool_input) in enumerate(tool_calls, start=1)
|
||||
),
|
||||
*tail,
|
||||
)
|
||||
return frames
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import uuid
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from integration._support import claude_code as cc
|
||||
from integration._support.client import Gateway, eventually
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
from pydantic import JsonValue
|
||||
|
||||
|
||||
def _diff(expected: dict[str, JsonValue], body: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
return {
|
||||
key: {"expected": expected.get(key), "upstream": body.get(key)}
|
||||
for key in expected.keys() | body.keys()
|
||||
if expected.get(key) != body.get(key)
|
||||
}
|
||||
|
||||
|
||||
def test_adaptive_thinking_and_effort_reach_anthropic_intact(gateway: Gateway) -> None:
|
||||
identity: Final = f"msg_fable_{uuid.uuid4().hex}"
|
||||
request_body: Final = cc.frontier_request(f"cache-bust-{uuid.uuid4().hex}", "high", 64000)
|
||||
cli_beta: Final = frozenset(cc.FRONTIER_CLI_BETA.split(","))
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
assert request.method == "POST"
|
||||
assert request.target == "/v1/messages", request.target
|
||||
assert request.headers["x-api-key"] == cc.ANTHROPIC_API_KEY
|
||||
assert request.headers["anthropic-version"] == "2023-06-01"
|
||||
upstream_beta: Final = request.headers.get("anthropic-beta", "")
|
||||
assert cli_beta <= frozenset(upstream_beta.split(",")), upstream_beta
|
||||
assert upstream_beta.split(",").count("effort-2025-11-24") == 1, upstream_beta
|
||||
body: Final = cc.JSON_OBJECT.validate_json(request.body)
|
||||
expected: Final = {**request_body, "model": cc.FABLE}
|
||||
assert body == expected, _diff(expected, body)
|
||||
return Reply(
|
||||
content_type="text/event-stream",
|
||||
chunks=cc.text_stream(identity, cc.FABLE, "PONG", {"input_tokens": 12, "output_tokens": 4}),
|
||||
)
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{cc.FABLE}", api_base=wire.url, api_key=cc.ANTHROPIC_API_KEY)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/messages",
|
||||
{**request_body, "model": model},
|
||||
params={"beta": "true"},
|
||||
headers=cc.cli_headers(gateway.key, cc.FRONTIER_CLI_BETA),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
events: Final = cc.sse_events(response.text)
|
||||
assert [event for event, _ in events][-1] == "message_stop"
|
||||
assert events[4][1]["delta"]["stop_reason"] == "end_turn"
|
||||
assert len(wire.drain()) == 1
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT prompt_tokens, completion_tokens FROM "LiteLLM_SpendLogs" WHERE request_id=%s',
|
||||
(identity,),
|
||||
),
|
||||
lambda values: len(values) == 1,
|
||||
seconds=70,
|
||||
)
|
||||
assert rows[0]["prompt_tokens"] == 12 and rows[0]["completion_tokens"] == 4
|
||||
|
||||
|
||||
def test_xhigh_effort_reaches_anthropic_and_charges_by_usage(gateway: Gateway) -> None:
|
||||
identity: Final = f"msg_opus_{uuid.uuid4().hex}"
|
||||
request_body: Final = cc.frontier_request(f"cache-bust-{uuid.uuid4().hex}", "xhigh", 128000)
|
||||
cli_beta: Final = frozenset(cc.FRONTIER_CLI_BETA.split(","))
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
assert request.method == "POST"
|
||||
assert request.target == "/v1/messages", request.target
|
||||
assert request.headers["x-api-key"] == cc.ANTHROPIC_API_KEY
|
||||
upstream_beta: Final = request.headers.get("anthropic-beta", "")
|
||||
assert cli_beta <= frozenset(upstream_beta.split(",")), upstream_beta
|
||||
assert upstream_beta.split(",").count("effort-2025-11-24") == 1, upstream_beta
|
||||
body: Final = cc.JSON_OBJECT.validate_json(request.body)
|
||||
expected: Final = {**request_body, "model": cc.OPUS}
|
||||
assert body == expected, _diff(expected, body)
|
||||
return Reply(
|
||||
content_type="text/event-stream",
|
||||
chunks=cc.text_stream(identity, cc.OPUS, "PONG", {"input_tokens": 10, "output_tokens": 5}),
|
||||
)
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model=f"anthropic/{cc.OPUS}",
|
||||
api_base=wire.url,
|
||||
api_key=cc.ANTHROPIC_API_KEY,
|
||||
input_cost_per_token=1e-6,
|
||||
output_cost_per_token=5e-6,
|
||||
)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/messages",
|
||||
{**request_body, "model": model},
|
||||
params={"beta": "true"},
|
||||
headers=cc.cli_headers(gateway.key, cc.FRONTIER_CLI_BETA),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT spend, prompt_tokens, completion_tokens FROM "LiteLLM_SpendLogs" WHERE request_id=%s',
|
||||
(identity,),
|
||||
),
|
||||
lambda values: len(values) == 1,
|
||||
seconds=70,
|
||||
)
|
||||
assert float(rows[0]["spend"]) == pytest.approx(10 * 1e-6 + 5 * 5e-6)
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import uuid
|
||||
from typing import Final
|
||||
|
||||
from integration._support import claude_code as cc
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
from pydantic import JsonValue
|
||||
|
||||
_TURN1_TOOL: Final = ("toolu_a", "Read", {"file_path": "/tmp/cc_probe/a.txt"})
|
||||
_TURN2_TOOL: Final = ("toolu_b", "Read", {"file_path": "/tmp/cc_probe/b.txt"})
|
||||
|
||||
|
||||
def _diff(expected: dict[str, JsonValue], body: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
return {
|
||||
key: {"expected": expected.get(key), "upstream": body.get(key)}
|
||||
for key in expected.keys() | body.keys()
|
||||
if expected.get(key) != body.get(key)
|
||||
}
|
||||
|
||||
|
||||
def _turn2(base: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
return cc.tool_loop_turn2(
|
||||
base,
|
||||
(
|
||||
{"type": "thinking", "thinking": "plan", "signature": "sig1"},
|
||||
{"type": "tool_use", "id": _TURN1_TOOL[0], "name": _TURN1_TOOL[1], "input": _TURN1_TOOL[2]},
|
||||
),
|
||||
((_TURN1_TOOL[0], "ALPHA"),),
|
||||
)
|
||||
|
||||
|
||||
def _turn3(turn2: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
return cc.tool_loop_turn2(
|
||||
turn2,
|
||||
(
|
||||
{"type": "thinking", "thinking": "got A", "signature": "sig2"},
|
||||
{"type": "text", "text": "got A"},
|
||||
{"type": "tool_use", "id": _TURN2_TOOL[0], "name": _TURN2_TOOL[1], "input": _TURN2_TOOL[2]},
|
||||
),
|
||||
((_TURN2_TOOL[0], "BRAVO"),),
|
||||
)
|
||||
|
||||
|
||||
def _interleaved_stream(identity: str) -> tuple[bytes, ...]:
|
||||
usage: Final = {"input_tokens": 20, "output_tokens": 12}
|
||||
return (
|
||||
cc.sse_frame(
|
||||
"message_start",
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": identity,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": cc.FABLE,
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": usage["input_tokens"], "output_tokens": 1},
|
||||
},
|
||||
},
|
||||
),
|
||||
cc.sse_frame(
|
||||
"content_block_start",
|
||||
{"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}},
|
||||
),
|
||||
cc.sse_frame(
|
||||
"content_block_delta",
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "got A"}},
|
||||
),
|
||||
cc.sse_frame(
|
||||
"content_block_delta",
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "sig2"}},
|
||||
),
|
||||
cc.sse_frame("content_block_stop", {"type": "content_block_stop", "index": 0}),
|
||||
cc.sse_frame(
|
||||
"content_block_start",
|
||||
{"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}},
|
||||
),
|
||||
cc.sse_frame(
|
||||
"content_block_delta",
|
||||
{"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "got A"}},
|
||||
),
|
||||
cc.sse_frame("content_block_stop", {"type": "content_block_stop", "index": 1}),
|
||||
cc.sse_frame(
|
||||
"content_block_start",
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 2,
|
||||
"content_block": {"type": "tool_use", "id": _TURN2_TOOL[0], "name": _TURN2_TOOL[1], "input": {}},
|
||||
},
|
||||
),
|
||||
cc.sse_frame(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 2,
|
||||
"delta": {"type": "input_json_delta", "partial_json": '{"file_path": "/tmp/cc_pr'},
|
||||
},
|
||||
),
|
||||
cc.sse_frame(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 2,
|
||||
"delta": {"type": "input_json_delta", "partial_json": 'obe/b.txt"}'},
|
||||
},
|
||||
),
|
||||
cc.sse_frame("content_block_stop", {"type": "content_block_stop", "index": 2}),
|
||||
cc.sse_frame(
|
||||
"message_delta",
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "tool_use", "stop_sequence": None},
|
||||
"usage": {"output_tokens": usage["output_tokens"]},
|
||||
},
|
||||
),
|
||||
cc.sse_frame("message_stop", {"type": "message_stop"}),
|
||||
)
|
||||
|
||||
|
||||
def test_interleaved_thinking_text_and_tool_use_history_reaches_anthropic_identical(gateway: Gateway) -> None:
|
||||
identity: Final = f"msg_il_{uuid.uuid4().hex}"
|
||||
turn1: Final = cc.frontier_request(
|
||||
f"cache-bust-{uuid.uuid4().hex}",
|
||||
"high",
|
||||
64000,
|
||||
prompt_text="Read /tmp/cc_probe/a.txt then /tmp/cc_probe/b.txt one at a time and reply with both words",
|
||||
)
|
||||
turn2: Final = _turn2(turn1)
|
||||
turn3: Final = _turn3(turn2)
|
||||
turn2_expected: Final = {**turn2, "model": cc.FABLE}
|
||||
turn3_expected: Final = {**turn3, "model": cc.FABLE}
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
assert request.method == "POST"
|
||||
assert request.target == "/v1/messages", request.target
|
||||
upstream_beta: Final = request.headers.get("anthropic-beta", "")
|
||||
assert upstream_beta.split(",").count("interleaved-thinking-2025-05-14") == 1, upstream_beta
|
||||
body: Final = cc.JSON_OBJECT.validate_json(request.body)
|
||||
if body == turn2_expected:
|
||||
return Reply(
|
||||
content_type="text/event-stream",
|
||||
chunks=cc.text_stream("msg_il_turn2", cc.FABLE, "got A", {"input_tokens": 20, "output_tokens": 4}),
|
||||
)
|
||||
assert body == turn3_expected, _diff(turn3_expected, body)
|
||||
return Reply(content_type="text/event-stream", chunks=_interleaved_stream(identity))
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{cc.FABLE}", api_base=wire.url, api_key=cc.ANTHROPIC_API_KEY)
|
||||
headers: Final = cc.cli_headers(gateway.key, cc.FRONTIER_CLI_BETA)
|
||||
response2: Final = gateway.request(
|
||||
"POST", "/v1/messages", {**turn2, "model": model}, params={"beta": "true"}, headers=headers
|
||||
)
|
||||
assert response2.status_code == 200, response2.text
|
||||
response3: Final = gateway.request(
|
||||
"POST", "/v1/messages", {**turn3, "model": model}, params={"beta": "true"}, headers=headers
|
||||
)
|
||||
assert response3.status_code == 200, response3.text
|
||||
events: Final = cc.sse_events(response3.text)
|
||||
started: Final = [
|
||||
(data["index"], data["content_block"]["type"]) for event, data in events if event == "content_block_start"
|
||||
]
|
||||
assert started == [(0, "thinking"), (1, "text"), (2, "tool_use")], started
|
||||
assert events[-1][0] == "message_stop"
|
||||
bodies: Final = tuple(cc.JSON_OBJECT.validate_json(request.body) for request in wire.drain())
|
||||
assert bodies == (turn2_expected, turn3_expected), bodies
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import uuid
|
||||
from typing import Final
|
||||
|
||||
from integration._support import claude_code as cc
|
||||
from integration._support.client import Gateway, eventually
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
|
||||
|
||||
def test_mid_loop_model_switch_replays_history_byte_identical(gateway: Gateway) -> None:
|
||||
identity1: Final = f"msg_sw1_{uuid.uuid4().hex}"
|
||||
identity2: Final = f"msg_sw2_{uuid.uuid4().hex}"
|
||||
turn1: Final = cc.frontier_request(
|
||||
f"cache-bust-{uuid.uuid4().hex}",
|
||||
"high",
|
||||
64000,
|
||||
prompt_text="Read /tmp/cc_probe/hello.txt and reply with its single word",
|
||||
)
|
||||
turn2: Final = cc.tool_loop_turn2(
|
||||
turn1,
|
||||
(
|
||||
{"type": "thinking", "thinking": "need to read the file", "signature": "sig_anthropic_1"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_read_1",
|
||||
"name": "Read",
|
||||
"input": {"file_path": "/tmp/cc_probe/hello.txt"},
|
||||
},
|
||||
),
|
||||
(("toolu_read_1", "1\tPROBE\n2\t"),),
|
||||
)
|
||||
first_expected: Final = {**turn1, "model": cc.FABLE}
|
||||
second_expected: Final = {**turn2, "model": cc.OPUS}
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
assert request.method == "POST"
|
||||
assert request.target == "/v1/messages", request.target
|
||||
body: Final = cc.JSON_OBJECT.validate_json(request.body)
|
||||
if body == first_expected:
|
||||
return Reply(
|
||||
content_type="text/event-stream",
|
||||
chunks=cc.tool_use_stream(
|
||||
identity1,
|
||||
cc.FABLE,
|
||||
"need to read the file",
|
||||
"sig_anthropic_1",
|
||||
(("toolu_read_1", "Read", {"file_path": "/tmp/cc_probe/hello.txt"}),),
|
||||
{"input_tokens": 20, "output_tokens": 10},
|
||||
),
|
||||
)
|
||||
assert body == second_expected, {
|
||||
key: (second_expected.get(key), body.get(key))
|
||||
for key in second_expected.keys() | body.keys()
|
||||
if second_expected.get(key) != body.get(key)
|
||||
}
|
||||
return Reply(
|
||||
content_type="text/event-stream",
|
||||
chunks=cc.text_stream(identity2, cc.OPUS, "PROBE", {"input_tokens": 30, "output_tokens": 3}),
|
||||
)
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
fable: Final = scenario.model(model=f"anthropic/{cc.FABLE}", api_base=wire.url, api_key=cc.ANTHROPIC_API_KEY)
|
||||
opus: Final = scenario.model(model=f"anthropic/{cc.OPUS}", api_base=wire.url, api_key=cc.ANTHROPIC_API_KEY)
|
||||
headers: Final = cc.cli_headers(gateway.key, cc.FRONTIER_CLI_BETA)
|
||||
response1: Final = gateway.request(
|
||||
"POST", "/v1/messages", {**turn1, "model": fable}, params={"beta": "true"}, headers=headers
|
||||
)
|
||||
assert response1.status_code == 200, response1.text
|
||||
response2: Final = gateway.request(
|
||||
"POST", "/v1/messages", {**turn2, "model": opus}, params={"beta": "true"}, headers=headers
|
||||
)
|
||||
assert response2.status_code == 200, response2.text
|
||||
bodies: Final = tuple(cc.JSON_OBJECT.validate_json(request.body) for request in wire.drain())
|
||||
assert bodies == (first_expected, second_expected), bodies
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT model FROM "LiteLLM_SpendLogs" WHERE request_id=%s',
|
||||
(identity2,),
|
||||
),
|
||||
lambda values: len(values) == 1,
|
||||
seconds=70,
|
||||
)
|
||||
assert rows[0]["model"] == f"anthropic/{cc.OPUS}"
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
import json
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from integration._support import claude_code as cc
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
from pydantic import JsonValue
|
||||
|
||||
_HAIKU_4_5: Final = "claude-haiku-4-5"
|
||||
_OPUS_4_5: Final = "claude-opus-4-5"
|
||||
_OPUS_4_6: Final = "claude-opus-4-6"
|
||||
_OPUS_4_7: Final = "claude-opus-4-7"
|
||||
_FABLE_5_1: Final = "claude-fable-5-1"
|
||||
_ADAPTIVE: Final = {"type": "adaptive", "display": "omitted"}
|
||||
_ADAPTIVE_SUMMARIZED: Final = {"type": "adaptive", "display": "summarized"}
|
||||
|
||||
|
||||
def _budget(tokens: int) -> dict[str, JsonValue]:
|
||||
return {"type": "enabled", "budget_tokens": tokens}
|
||||
|
||||
|
||||
def _client_body(**reasoning: JsonValue) -> dict[str, JsonValue]:
|
||||
base: Final = {
|
||||
key: value
|
||||
for key, value in cc.claude_code_request(f"cache-bust-{uuid.uuid4().hex}").items()
|
||||
if key != "thinking"
|
||||
}
|
||||
return {**base, "stream": False, **reasoning}
|
||||
|
||||
|
||||
def _without(body: Mapping[str, JsonValue], *keys: str) -> dict[str, JsonValue]:
|
||||
return {key: value for key, value in body.items() if key not in keys}
|
||||
|
||||
|
||||
def _diff(expected: Mapping[str, JsonValue], body: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
return {
|
||||
key: {"expected": expected.get(key), "upstream": body.get(key)}
|
||||
for key in expected.keys() | body.keys()
|
||||
if expected.get(key) != body.get(key)
|
||||
}
|
||||
|
||||
|
||||
def _forwarded_body(
|
||||
gateway: Gateway, upstream_model: str, client_body: Mapping[str, JsonValue]
|
||||
) -> dict[str, JsonValue]:
|
||||
def respond(request: Request) -> Reply:
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"id": f"msg_{uuid.uuid4().hex}",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": upstream_model,
|
||||
"content": [{"type": "text", "text": "PONG"}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 12, "output_tokens": 4},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model=f"anthropic/{upstream_model}", api_base=wire.url, api_key=cc.ANTHROPIC_API_KEY
|
||||
)
|
||||
response: Final = gateway.request("POST", "/v1/messages", {**client_body, "model": model})
|
||||
assert response.status_code == 200, response.text
|
||||
received: Final = wire.drain()
|
||||
assert len(received) == 1, received
|
||||
return cc.JSON_OBJECT.validate_json(received[0].body)
|
||||
|
||||
|
||||
def _assert_forwarded(
|
||||
gateway: Gateway,
|
||||
upstream_model: str,
|
||||
client_body: dict[str, JsonValue],
|
||||
expected_changes: Mapping[str, JsonValue],
|
||||
removed: tuple[str, ...],
|
||||
) -> None:
|
||||
expected: Final = {**_without(client_body, *removed), **expected_changes, "model": upstream_model}
|
||||
body: Final = _forwarded_body(gateway, upstream_model, client_body)
|
||||
assert body == expected, _diff(expected, body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("upstream_model", "effort", "expected_changes", "removed"),
|
||||
(
|
||||
pytest.param(
|
||||
_OPUS_4_5,
|
||||
"high",
|
||||
{},
|
||||
("thinking",),
|
||||
id="opus-4.5-keeps-supported-effort-drops-adaptive",
|
||||
),
|
||||
pytest.param(
|
||||
_OPUS_4_5,
|
||||
"xhigh",
|
||||
{"thinking": _budget(8192)},
|
||||
("output_config",),
|
||||
id="opus-4.5-xhigh-falls-back-to-budget",
|
||||
),
|
||||
pytest.param(_HAIKU_4_5, "low", {"thinking": _budget(1024)}, ("output_config",), id="haiku-4.5-low"),
|
||||
pytest.param(_HAIKU_4_5, "medium", {"thinking": _budget(2048)}, ("output_config",), id="haiku-4.5-medium"),
|
||||
pytest.param(_HAIKU_4_5, "high", {"thinking": _budget(4096)}, ("output_config",), id="haiku-4.5-high"),
|
||||
pytest.param(_HAIKU_4_5, "xhigh", {"thinking": _budget(8192)}, ("output_config",), id="haiku-4.5-xhigh"),
|
||||
pytest.param(_HAIKU_4_5, "max", {"thinking": _budget(16384)}, ("output_config",), id="haiku-4.5-max"),
|
||||
pytest.param(_OPUS_4_6, "high", {}, (), id="opus-4.6-adaptive-unchanged"),
|
||||
pytest.param(_OPUS_4_7, "xhigh", {}, (), id="opus-4.7-adaptive-unchanged"),
|
||||
),
|
||||
)
|
||||
def test_adaptive_thinking_and_effort_are_reshaped_only_for_models_without_adaptive_thinking(
|
||||
gateway: Gateway,
|
||||
upstream_model: str,
|
||||
effort: str,
|
||||
expected_changes: dict[str, JsonValue],
|
||||
removed: tuple[str, ...],
|
||||
) -> None:
|
||||
client_body: Final = _client_body(thinking=dict(_ADAPTIVE), output_config={"effort": effort})
|
||||
_assert_forwarded(gateway, upstream_model, client_body, expected_changes, removed)
|
||||
|
||||
|
||||
def test_adaptive_effort_fallback_budget_is_capped_below_max_tokens(gateway: Gateway) -> None:
|
||||
client_body: Final = _client_body(thinking=dict(_ADAPTIVE), output_config={"effort": "max"}, max_tokens=4000)
|
||||
_assert_forwarded(gateway, _HAIKU_4_5, client_body, {"thinking": _budget(3999)}, ("output_config",))
|
||||
|
||||
|
||||
def test_adaptive_effort_fallback_drops_thinking_when_max_tokens_cannot_fit_the_minimum_budget(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
client_body: Final = _client_body(thinking=dict(_ADAPTIVE), output_config={"effort": "high"}, max_tokens=1024)
|
||||
_assert_forwarded(gateway, _HAIKU_4_5, client_body, {}, ("thinking", "output_config"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("budget_tokens", "effort"),
|
||||
(
|
||||
pytest.param(1024, "low", id="below-medium-threshold"),
|
||||
pytest.param(2048, "medium", id="medium-threshold"),
|
||||
pytest.param(4096, "high", id="high-threshold"),
|
||||
pytest.param(8192, "xhigh", id="xhigh-threshold"),
|
||||
),
|
||||
)
|
||||
def test_legacy_thinking_budget_becomes_adaptive_effort_on_models_that_reject_budgets(
|
||||
gateway: Gateway, budget_tokens: int, effort: str
|
||||
) -> None:
|
||||
client_body: Final = _client_body(thinking=_budget(budget_tokens))
|
||||
_assert_forwarded(
|
||||
gateway,
|
||||
_OPUS_4_7,
|
||||
client_body,
|
||||
{"thinking": {"type": "adaptive"}, "output_config": {"effort": effort}},
|
||||
(),
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_thinking_translation_keeps_the_callers_effort(gateway: Gateway) -> None:
|
||||
client_body: Final = _client_body(thinking=_budget(8192), output_config={"effort": "medium"})
|
||||
_assert_forwarded(gateway, _OPUS_4_7, client_body, {"thinking": {"type": "adaptive"}}, ())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("upstream_model", "removed"),
|
||||
(
|
||||
pytest.param(_FABLE_5_1, ("thinking",), id="always-on-model-drops-disabled"),
|
||||
pytest.param(_OPUS_4_7, (), id="other-model-keeps-disabled"),
|
||||
),
|
||||
)
|
||||
def test_disabled_thinking_is_dropped_only_for_always_on_thinking_models(
|
||||
gateway: Gateway, upstream_model: str, removed: tuple[str, ...]
|
||||
) -> None:
|
||||
client_body: Final = _client_body(thinking={"type": "disabled"})
|
||||
_assert_forwarded(gateway, upstream_model, client_body, {}, removed)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reasoning_effort", "effort"),
|
||||
(
|
||||
pytest.param("minimal", "low", id="minimal"),
|
||||
pytest.param("low", "low", id="low"),
|
||||
pytest.param("medium", "medium", id="medium"),
|
||||
pytest.param("high", "high", id="high"),
|
||||
pytest.param("xhigh", "xhigh", id="xhigh"),
|
||||
pytest.param("max", "max", id="max"),
|
||||
),
|
||||
)
|
||||
def test_reasoning_effort_becomes_adaptive_thinking_and_effort_on_adaptive_models(
|
||||
gateway: Gateway, reasoning_effort: str, effort: str
|
||||
) -> None:
|
||||
client_body: Final = _client_body(reasoning_effort=reasoning_effort)
|
||||
_assert_forwarded(
|
||||
gateway,
|
||||
_OPUS_4_7,
|
||||
client_body,
|
||||
{"thinking": dict(_ADAPTIVE_SUMMARIZED), "output_config": {"effort": effort}},
|
||||
("reasoning_effort",),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reasoning_effort", "budget_tokens"),
|
||||
(
|
||||
pytest.param("minimal", 1024, id="minimal"),
|
||||
pytest.param("low", 1024, id="low"),
|
||||
pytest.param("medium", 2048, id="medium"),
|
||||
pytest.param("high", 4096, id="high"),
|
||||
pytest.param("xhigh", 8192, id="xhigh"),
|
||||
pytest.param("max", 16384, id="max"),
|
||||
),
|
||||
)
|
||||
def test_reasoning_effort_becomes_a_thinking_budget_on_models_without_adaptive_thinking(
|
||||
gateway: Gateway, reasoning_effort: str, budget_tokens: int
|
||||
) -> None:
|
||||
client_body: Final = _client_body(reasoning_effort=reasoning_effort)
|
||||
_assert_forwarded(gateway, _HAIKU_4_5, client_body, {"thinking": _budget(budget_tokens)}, ("reasoning_effort",))
|
||||
|
||||
|
||||
def test_reasoning_effort_none_clears_thinking_and_effort(gateway: Gateway) -> None:
|
||||
client_body: Final = _client_body(
|
||||
reasoning_effort="none", thinking=dict(_ADAPTIVE), output_config={"effort": "high"}
|
||||
)
|
||||
_assert_forwarded(gateway, _OPUS_4_7, client_body, {}, ("reasoning_effort", "thinking", "output_config"))
|
||||
|
||||
|
||||
def test_caller_thinking_wins_over_reasoning_effort(gateway: Gateway) -> None:
|
||||
client_body: Final = _client_body(reasoning_effort="high", thinking=_budget(2000))
|
||||
_assert_forwarded(gateway, _HAIKU_4_5, client_body, {}, ("reasoning_effort",))
|
||||
|
||||
|
||||
def test_caller_effort_wins_over_reasoning_effort(gateway: Gateway) -> None:
|
||||
client_body: Final = _client_body(reasoning_effort="high", output_config={"effort": "low"})
|
||||
_assert_forwarded(gateway, _OPUS_4_7, client_body, {"thinking": dict(_ADAPTIVE_SUMMARIZED)}, ("reasoning_effort",))
|
||||
|
||||
|
||||
def test_reasoning_effort_budget_is_capped_below_max_tokens(gateway: Gateway) -> None:
|
||||
client_body: Final = _client_body(reasoning_effort="max", max_tokens=4000)
|
||||
_assert_forwarded(gateway, _HAIKU_4_5, client_body, {"thinking": _budget(3999)}, ("reasoning_effort",))
|
||||
|
||||
|
||||
def test_reasoning_effort_is_dropped_when_max_tokens_cannot_fit_the_minimum_budget(gateway: Gateway) -> None:
|
||||
client_body: Final = _client_body(reasoning_effort="high", max_tokens=1024)
|
||||
_assert_forwarded(gateway, _HAIKU_4_5, client_body, {}, ("reasoning_effort",))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("upstream_model", "reasoning", "expected_changes", "removed"),
|
||||
(
|
||||
pytest.param(
|
||||
_HAIKU_4_5,
|
||||
{"thinking": dict(_ADAPTIVE), "output_config": {"effort": "high"}},
|
||||
{"thinking": _budget(4096)},
|
||||
("output_config", "temperature"),
|
||||
id="haiku-4.5-effort-translated-to-budget",
|
||||
),
|
||||
pytest.param(
|
||||
_OPUS_4_5,
|
||||
{"thinking": dict(_ADAPTIVE), "output_config": {"effort": "high"}},
|
||||
{},
|
||||
("thinking", "temperature"),
|
||||
id="opus-4.5-effort-kept",
|
||||
),
|
||||
pytest.param(_HAIKU_4_5, {"thinking": _budget(2048)}, {}, ("temperature",), id="haiku-4.5-legacy-budget"),
|
||||
),
|
||||
)
|
||||
def test_non_default_temperature_is_dropped_when_a_non_adaptive_model_thinks(
|
||||
gateway: Gateway,
|
||||
upstream_model: str,
|
||||
reasoning: dict[str, JsonValue],
|
||||
expected_changes: dict[str, JsonValue],
|
||||
removed: tuple[str, ...],
|
||||
) -> None:
|
||||
client_body: Final = _client_body(temperature=0, **reasoning)
|
||||
_assert_forwarded(gateway, upstream_model, client_body, expected_changes, removed)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("upstream_model", "temperature", "reasoning"),
|
||||
(
|
||||
pytest.param(_HAIKU_4_5, 1, {"thinking": _budget(2048)}, id="temperature-1-with-thinking"),
|
||||
pytest.param(_HAIKU_4_5, 0, {}, id="temperature-0-without-thinking"),
|
||||
pytest.param(
|
||||
_OPUS_4_6,
|
||||
0,
|
||||
{"thinking": dict(_ADAPTIVE), "output_config": {"effort": "high"}},
|
||||
id="adaptive-model",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_temperature_is_kept_when_it_does_not_conflict_with_thinking(
|
||||
gateway: Gateway, upstream_model: str, temperature: int, reasoning: dict[str, JsonValue]
|
||||
) -> None:
|
||||
client_body: Final = _client_body(temperature=temperature, **reasoning)
|
||||
_assert_forwarded(gateway, upstream_model, client_body, {}, ())
|
||||
|
||||
|
||||
_SIGNED_THINKING: Final = {"type": "thinking", "thinking": "check the config first", "signature": "EqQBCkgIBRABGAIiQL"}
|
||||
_TOOL_CALL: Final = {"type": "tool_use", "id": "toolu_01", "name": "Read", "input": {"file_path": "/repo/config.yaml"}}
|
||||
_TOOL_RESULT: Final = {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "ok"}]}
|
||||
|
||||
|
||||
def _history_body(assistant_content: tuple[dict[str, JsonValue], ...]) -> dict[str, JsonValue]:
|
||||
base: Final = _client_body(thinking=_budget(2048))
|
||||
first_turn: Final = base["messages"]
|
||||
assert isinstance(first_turn, list)
|
||||
return {**base, "messages": [*first_turn, {"role": "assistant", "content": list(assistant_content)}, _TOOL_RESULT]}
|
||||
|
||||
|
||||
def _with_assistant_content(
|
||||
body: Mapping[str, JsonValue], assistant_content: tuple[dict[str, JsonValue], ...]
|
||||
) -> dict[str, JsonValue]:
|
||||
messages: Final = body["messages"]
|
||||
assert isinstance(messages, list)
|
||||
return {
|
||||
**body,
|
||||
"messages": [*messages[:-2], {"role": "assistant", "content": list(assistant_content)}, messages[-1]],
|
||||
}
|
||||
|
||||
|
||||
def test_encrypted_reasoning_from_another_provider_is_stripped_and_anthropic_signed_thinking_is_kept(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
client_body: Final = _history_body(
|
||||
(
|
||||
{"type": "thinking", "thinking": "bridge reasoning", "signature": "litellm_encrypted_reasoning:gAAAAB"},
|
||||
{"type": "redacted_thinking", "data": "litellm_encrypted_reasoning:gAAAAC"},
|
||||
_SIGNED_THINKING,
|
||||
_TOOL_CALL,
|
||||
)
|
||||
)
|
||||
expected: Final = {**_with_assistant_content(client_body, (_SIGNED_THINKING, _TOOL_CALL)), "model": _HAIKU_4_5}
|
||||
body: Final = _forwarded_body(gateway, _HAIKU_4_5, client_body)
|
||||
assert body == expected, _diff(expected, body)
|
||||
|
||||
|
||||
def test_empty_thinking_block_is_stripped_and_redacted_thinking_is_kept(gateway: Gateway) -> None:
|
||||
redacted: Final = {"type": "redacted_thinking", "data": "EmwKAhgBEgy3va3pzix"}
|
||||
client_body: Final = _history_body(
|
||||
({"type": "thinking", "thinking": "", "signature": "EqQBCkgIBRABGAIiQM"}, redacted, _TOOL_CALL)
|
||||
)
|
||||
expected: Final = {**_with_assistant_content(client_body, (redacted, _TOOL_CALL)), "model": _HAIKU_4_5}
|
||||
body: Final = _forwarded_body(gateway, _HAIKU_4_5, client_body)
|
||||
assert body == expected, _diff(expected, body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("upstream_model", "reasoning_effort"),
|
||||
(
|
||||
pytest.param(_HAIKU_4_5, "turbo", id="unknown-value"),
|
||||
pytest.param(_OPUS_4_6, "xhigh", id="level-the-model-lacks"),
|
||||
),
|
||||
)
|
||||
def test_unsupported_reasoning_effort_is_rejected_before_reaching_anthropic(
|
||||
gateway: Gateway, upstream_model: str, reasoning_effort: str
|
||||
) -> None:
|
||||
with wire_server(lambda request: Reply()) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model=f"anthropic/{upstream_model}", api_base=wire.url, api_key=cc.ANTHROPIC_API_KEY
|
||||
)
|
||||
response: Final = gateway.request(
|
||||
"POST", "/v1/messages", {**_client_body(reasoning_effort=reasoning_effort), "model": model}
|
||||
)
|
||||
assert response.status_code == 400, response.text
|
||||
assert response.json()["error"]["type"] == "invalid_request_error", response.text
|
||||
assert wire.drain() == ()
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import json
|
||||
import uuid
|
||||
from typing import Final
|
||||
|
||||
from integration._support import claude_code as cc
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
from pydantic import JsonValue
|
||||
|
||||
_MODEL: Final = "claude-haiku-4-5"
|
||||
_THINKING: Final = "the user wants a single word"
|
||||
_SIGNATURE: Final = "EqQBCkgIBRABGAIiQLz"
|
||||
_REDACTED: Final = "EmwKAhgBEgy3va3pzixlit"
|
||||
|
||||
|
||||
def _reasoning_stream(identity: str) -> tuple[bytes, ...]:
|
||||
return (
|
||||
cc.sse_frame(
|
||||
"message_start",
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": identity,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": _MODEL,
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 12, "output_tokens": 1},
|
||||
},
|
||||
},
|
||||
),
|
||||
cc.sse_frame(
|
||||
"content_block_start",
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "thinking", "thinking": "", "signature": ""},
|
||||
},
|
||||
),
|
||||
cc.sse_frame(
|
||||
"content_block_delta",
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": _THINKING}},
|
||||
),
|
||||
cc.sse_frame(
|
||||
"content_block_delta",
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": _SIGNATURE}},
|
||||
),
|
||||
cc.sse_frame("content_block_stop", {"type": "content_block_stop", "index": 0}),
|
||||
cc.sse_frame(
|
||||
"content_block_start",
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 1,
|
||||
"content_block": {"type": "redacted_thinking", "data": _REDACTED},
|
||||
},
|
||||
),
|
||||
cc.sse_frame("content_block_stop", {"type": "content_block_stop", "index": 1}),
|
||||
cc.sse_frame(
|
||||
"content_block_start",
|
||||
{"type": "content_block_start", "index": 2, "content_block": {"type": "text", "text": ""}},
|
||||
),
|
||||
cc.sse_frame(
|
||||
"content_block_delta",
|
||||
{"type": "content_block_delta", "index": 2, "delta": {"type": "text_delta", "text": "PONG"}},
|
||||
),
|
||||
cc.sse_frame("content_block_stop", {"type": "content_block_stop", "index": 2}),
|
||||
cc.sse_frame(
|
||||
"message_delta",
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
||||
"usage": {"output_tokens": 30},
|
||||
},
|
||||
),
|
||||
cc.sse_frame("message_stop", {"type": "message_stop"}),
|
||||
)
|
||||
|
||||
|
||||
def _reasoning_message(identity: str) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"id": identity,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": _MODEL,
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": _THINKING, "signature": _SIGNATURE},
|
||||
{"type": "redacted_thinking", "data": _REDACTED},
|
||||
{"type": "text", "text": "PONG"},
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 12, "output_tokens": 30},
|
||||
}
|
||||
|
||||
|
||||
def _content_events(stream: str) -> tuple[tuple[str, dict[str, object]], ...]:
|
||||
return tuple(event for event in cc.sse_events(stream) if event[0].startswith("content_block_"))
|
||||
|
||||
|
||||
def _client_body(stream: bool) -> dict[str, JsonValue]:
|
||||
return {
|
||||
**cc.claude_code_request(f"cache-bust-{uuid.uuid4().hex}"),
|
||||
"thinking": {"type": "enabled", "budget_tokens": 2048},
|
||||
"stream": stream,
|
||||
}
|
||||
|
||||
|
||||
def test_streamed_thinking_signature_and_redacted_thinking_reach_the_client_unchanged(gateway: Gateway) -> None:
|
||||
identity: Final = f"msg_{uuid.uuid4().hex}"
|
||||
upstream_frames: Final = _reasoning_stream(identity)
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
return Reply(chunks=upstream_frames, content_type="text/event-stream")
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=cc.ANTHROPIC_API_KEY)
|
||||
response: Final = gateway.request("POST", "/v1/messages", {**_client_body(stream=True), "model": model})
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
assert _content_events(response.text) == _content_events(b"".join(upstream_frames).decode()), response.text
|
||||
|
||||
|
||||
def test_non_streamed_thinking_signature_and_redacted_thinking_reach_the_client_unchanged(gateway: Gateway) -> None:
|
||||
identity: Final = f"msg_{uuid.uuid4().hex}"
|
||||
upstream_message: Final = _reasoning_message(identity)
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
return Reply(body=json.dumps(upstream_message).encode())
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(model=f"anthropic/{_MODEL}", api_base=wire.url, api_key=cc.ANTHROPIC_API_KEY)
|
||||
response: Final = gateway.request("POST", "/v1/messages", {**_client_body(stream=False), "model": model})
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
assert response.json()["content"] == upstream_message["content"], response.text
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import json
|
||||
import uuid
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from integration._support import claude_code as cc
|
||||
from integration._support.client import Gateway, eventually
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
|
||||
_MODEL: Final = "claude-haiku-4-5"
|
||||
_INPUT_RATE: Final = 1e-6
|
||||
_OUTPUT_RATE: Final = 2e-6
|
||||
_REASONING_RATE: Final = 7e-6
|
||||
|
||||
|
||||
def test_reported_thinking_tokens_are_billed_at_the_reasoning_rate_and_the_rest_at_the_output_rate(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
identity: Final = f"msg_{uuid.uuid4().hex}"
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"id": identity,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": _MODEL,
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "count the words", "signature": "EqQBCkgIBRABGAIiQLz"},
|
||||
{"type": "text", "text": "PONG"},
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"output_tokens_details": {"thinking_tokens": 30},
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model=f"anthropic/{_MODEL}",
|
||||
api_base=wire.url,
|
||||
api_key=cc.ANTHROPIC_API_KEY,
|
||||
input_cost_per_token=_INPUT_RATE,
|
||||
output_cost_per_token=_OUTPUT_RATE,
|
||||
output_cost_per_reasoning_token=_REASONING_RATE,
|
||||
)
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/messages",
|
||||
{
|
||||
**cc.claude_code_request(f"cache-bust-{uuid.uuid4().hex}"),
|
||||
"thinking": {"type": "enabled", "budget_tokens": 2048},
|
||||
"stream": False,
|
||||
"model": model,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(wire.drain()) == 1
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows('SELECT spend FROM "LiteLLM_SpendLogs" WHERE request_id=%s', (identity,)),
|
||||
lambda values: len(values) == 1,
|
||||
seconds=70,
|
||||
)
|
||||
assert float(rows[0]["spend"]) == pytest.approx(100 * _INPUT_RATE + 20 * _OUTPUT_RATE + 30 * _REASONING_RATE)
|
||||
Loading…
Add table
Reference in a new issue