Merge remote-tracking branch 'upstream/main' into fix_anthropic_adaptive_thinking_sampling_params

This commit is contained in:
Priyansh Nandwana 2026-10-11 15:43:56 +05:30
commit 79efcc892e
1338 changed files with 119904 additions and 112779 deletions

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,7 @@ outside_cost_map_set=false
while IFS= read -r file || [ -n "$file" ]; do
[ -n "$file" ] || continue
case "$file" in
litellm/_redis.py | litellm/_redis_credential_provider.py | litellm/caching/redis_cache.py | litellm/caching/evicted_client_closer.py | tests/unit/test_redis.py | tests/local_testing/test_caching.py | tests/unit/caching/test_redis_connection_pool.py | tests/unit/caching/test_redis_cluster_cache.py | tests/unit/caching/test_evicted_client_closer.py | .circleci/config.yml | .circleci/scripts/classify_changes.sh | .circleci/scripts/path_filter.sh | pyproject.toml | uv.lock)
litellm/_redis.py | litellm/_redis_credential_provider.py | litellm/caching/redis_cache.py | litellm/caching/evicted_client_closer.py | tests/unit/test_redis.py | tests/integration/sdk/test_redis_cluster_iam_auth.py | tests/unit/caching/test_redis_connection_pool.py | tests/unit/caching/test_redis_cluster_cache.py | tests/unit/caching/test_evicted_client_closer.py | .circleci/config.yml | .circleci/scripts/classify_changes.sh | .circleci/scripts/path_filter.sh | pyproject.toml | uv.lock)
has_redis_compat=true ;;
esac
case "$file" in

View file

@ -128,12 +128,26 @@ fi
sudo iptables -L integration_only -n -v -x > "$results/egress-guard.txt"
awk '$3 == "REJECT" && $1 > 0 { rejected=1 } END { exit !rejected }' "$results/egress-guard.txt"
setsid env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" INTEGRATION_RUN_ID="$integration_identity" \
.venv/bin/python -m integration._support.upstream > "$results/upstream.log" 2>&1 &
upstream_pid=$!
isolated_workers=false
if [ "$mode" = parity ]; then
export INTEGRATION_WORKERS=1
else
case "$suite" in
management|accounting) export INTEGRATION_WORKERS=2; isolated_workers=true ;;
providers|extensions) export INTEGRATION_WORKERS=4; isolated_workers=true ;;
esac
fi
if [ "$isolated_workers" = false ]; then
setsid env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" INTEGRATION_RUN_ID="$integration_identity" \
.venv/bin/python -m integration._support.upstream > "$results/upstream.log" 2>&1 &
upstream_pid=$!
fi
if [ "$suite" = cost ]; then
export INTEGRATION_WORKERS=8
fi
if [ "$suite" = security ]; then
export INTEGRATION_WORKERS=4
fi
if [ "$suite" = mcp ]; then
export INTEGRATION_WORKERS=4 INTEGRATION_COVERAGE=1
fi
@ -177,17 +191,22 @@ start_proxy() {
> "$results/$log_name" 2>&1 &
launched_pid=$!
}
start_proxy 4000 proxy.log
proxy_pid="$launched_pid"
.venv/bin/python .circleci/scripts/wait_integration_services.py
curl --noproxy '*' -sSf -X POST "$INTEGRATION_PROXY_URL/config/update" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
-d '{"router_settings": {"num_retries": 0}}' > "$results/seed-router-settings.json"
if [ "$suite" = management ] || [ "$suite" = mcp ]; then
if { [ "$suite" = management ] || [ "$suite" = mcp ]; } && [ "$isolated_workers" = true ]; then
export INTEGRATION_PEER_URL=http://127.0.0.1:4001
start_proxy 4001 peer.log
peer_pid="$launched_pid"
fi
if [ "$isolated_workers" = false ]; then
start_proxy 4000 proxy.log
proxy_pid="$launched_pid"
.venv/bin/python .circleci/scripts/wait_integration_services.py
curl --noproxy '*' -sSf -X POST "$INTEGRATION_PROXY_URL/config/update" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" -H 'Content-Type: application/json' \
-d '{"router_settings": {"num_retries": 0}}' > "$results/seed-router-settings.json"
if [ "$suite" = management ] || [ "$suite" = mcp ]; then
export INTEGRATION_PEER_URL=http://127.0.0.1:4001
start_proxy 4001 peer.log
peer_pid="$launched_pid"
.venv/bin/python .circleci/scripts/wait_integration_services.py
fi
fi
if [ "$suite" = providers ]; then
@ -213,13 +232,11 @@ if [ "$suite" = browser ]; then
exit 0
fi
node_files=()
shard_arguments=()
if [ "${CIRCLE_NODE_TOTAL:-1}" -gt 1 ]; then
split="$(.venv/bin/python tests/integration/run.py "$suite" --list \
| circleci tests split --split-by=timings --timings-type=filename)"
read -r -a node_files <<< "$(printf '%s' "$split" | tr '\n' ' ')"
test "${#node_files[@]}" -gt 0
printf '%s\n' "${node_files[@]}" > "$results/node-files.txt"
.venv/bin/python tests/integration/run.py "$suite" --list \
| circleci tests split --split-by=timings --timings-type=filename > "$results/node-files.txt"
shard_arguments=(--shard-files "$results/node-files.txt")
fi
env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
@ -232,11 +249,13 @@ env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
LITELLM_LICENSE="${LITELLM_LICENSE:-}" \
INTEGRATION_SEED="$INTEGRATION_SEED" \
INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \
INTEGRATION_CASE_SHARDS="${CIRCLE_NODE_TOTAL:-1}" INTEGRATION_CASE_SHARD_INDEX="${CIRCLE_NODE_INDEX:-0}" \
LITELLM_LOCAL_MODEL_COST_MAP=True AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 \
INTEGRATION_PROXY_DATABASE_URL="$INTEGRATION_PROXY_DATABASE_URL" \
INTEGRATION_PROXY_READ_REPLICA_URL="$INTEGRATION_PROXY_READ_REPLICA_URL" \
INTEGRATION_ROUTING="$INTEGRATION_ROUTING" \
.venv/bin/python tests/integration/run.py "$suite" --results "$results" "${node_files[@]}"
.venv/bin/python tests/integration/run.py "$suite" --results "$results" \
"${shard_arguments[@]}"
if [ "${INTEGRATION_COVERAGE:-0}" = 1 ]; then
for covered_pid in "$proxy_pid" "$peer_pid"; do

View file

@ -1,42 +1,31 @@
import os
import time
from contextlib import ExitStack
from typing import Final
import httpx
from integration._support.client import Gateway
from integration._support.workers import wait_for_services
from redis import Redis
def main() -> None:
primary: Final = os.environ["INTEGRATION_PROXY_URL"]
peer: Final = os.environ.get("INTEGRATION_PEER_URL")
proxies: Final = (primary, peer) if peer else (primary,)
deadline: Final = time.monotonic() + 90
headers: Final = {"Authorization": f"Bearer {os.environ['INTEGRATION_MASTER_KEY']}"}
with httpx.Client(trust_env=False, timeout=2) as client, Redis(
host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]), socket_timeout=2
) as cache:
while True:
try:
ready: Final = (
client.get(f"{os.environ['INTEGRATION_UPSTREAM_URL']}/health").status_code == 200
and all(client.get(f"{url}/health/readiness").status_code == 200 for url in proxies)
)
if ready:
for url in proxies:
response: Final = client.get(f"{url}/cache/ping", headers=headers)
response.raise_for_status()
result: Final = response.json()
assert result["status"] == "healthy", result
assert result["cache_type"] == "redis", result
assert result["ping_response"] is True, result
assert result["set_cache_response"] == "success", result
if cache.pubsub_numsub("litellm_proxy.auth_cache_invalidation")[0][1] >= len(proxies):
return
except httpx.TransportError:
pass
if time.monotonic() >= deadline:
raise SystemExit("Integration services or auth-cache subscribers did not become ready")
time.sleep(0.2)
urls: Final = (primary, peer) if peer else (primary,)
upstream: Final = os.environ["INTEGRATION_UPSTREAM_URL"]
with ExitStack() as stack:
proxies: Final = tuple(
Gateway(
stack.enter_context(httpx.Client(base_url=url, trust_env=False, timeout=2)),
os.environ["INTEGRATION_MASTER_KEY"],
upstream,
)
for url in urls
)
cache: Final = stack.enter_context(
Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]), socket_timeout=2)
)
wait_for_services(upstream, proxies, cache)
if __name__ == "__main__":

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View file

@ -18,30 +18,23 @@ test_paths:
paths:
- tests/rust-python-harness
- reason: >-
Live-provider caching cases in tests/local_testing that remain outside CI. Jobs that
glob that directory either deselect them (local_testing_part1 and part2 carry `-k "... and
not caching and not cache"`) or keep only another keyword (langfuse, router, assistants).
Separately, the CircleCI redis-compat jobs select two IAM cluster authentication tests in
test_caching.py by node ID. It does not run that file's other tests.
The gap was eight files and 118 tests when measured 2026-08-20; the five keyless files now
run in the caching-local shard, leaving live cases in these three. Measured 2026-08-21 with no provider
credentials and no Redis: test_caching.py needs both (37 of 65 fail without them),
test_disk_cache_unit_tests.py needs OPENAI_API_KEY for 2 of its 4, and
test_gcs_cache_unit_tests.py needs GCS credentials for all 4. They want the keyless/live
split that porting tests/local_testing off CircleCI will force, not a job that is red by
construction
Owned by the open pull request that moves the Rust/Python parity checks (#45135). It ran in
the CircleCI build_and_test job that globbed tests/test_*.py, which this migration deletes,
and that pull request gives it a new home
paths:
- tests/test_rust_python_harness.py
- reason: >-
Live-provider caching cases in tests/local_testing that remain outside CI. No CircleCI job
globs that directory anymore, and the legacy jobs that did always deselected these files.
The two Redis Cluster IAM authentication tests moved to tests/integration/sdk and remain
selected by node ID in redis_compat, where redis-server is installed.
The five keyless files now run in the caching-local shard, leaving live cases in these two.
Measured 2026-08-21 with no provider credentials: test_disk_cache_unit_tests.py needs
OPENAI_API_KEY for 2 of its 4 tests, and test_gcs_cache_unit_tests.py needs GCS credentials
for all 4. Keep these live cases outside PR CI rather than making a job red by construction
paths:
- tests/local_testing/test_caching.py
- tests/local_testing/test_disk_cache_unit_tests.py
- tests/local_testing/test_gcs_cache_unit_tests.py
- reason: >-
Mixed file that no job has ever run in full. Every CircleCI job that globs
tests/local_testing deselects it by name ("caching") or keeps only another keyword, so only
its router test ran, and that test now lives in tests/unit/router_utils/pre_call_checks.
Its two offline tests now live in tests/unit/llms/anthropic/chat, and the five left all need
ANTHROPIC_API_KEY or Vertex credentials
paths:
- tests/local_testing/test_anthropic_prompt_caching.py
- reason: >-
The end-to-end suite runs against a deployed proxy from its own in-cluster rig rather than
from a pull request; it needs a live gateway and provider credentials no PR job holds

View file

@ -9,6 +9,8 @@ import sys
import warnings
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from functools import lru_cache
from types import MappingProxyType
from typing import Final
import yaml
@ -316,6 +318,7 @@ def _keyword_terms(
def _slices() -> tuple[Slice, ...]:
if not CIRCLECI_CONFIG.exists():
return ()
test_files: Final = frozenset(_test_files())
jobs: Final = yaml.safe_load(CIRCLECI_CONFIG.read_text()).get("jobs", {})
return tuple(
Slice(job=job, globs=globs, named=named, required=required, excluded=excluded, understood=understood)
@ -323,7 +326,7 @@ def _slices() -> tuple[Slice, ...]:
for text in ("\n".join(_strings(body)),)
if "pytest" in text
for globs in (tuple(GLOB_CALL_RE.findall(text)),)
for named in (frozenset(TEST_TOKEN_RE.findall(text)) & frozenset(_test_files()),)
for named in (frozenset(TEST_TOKEN_RE.findall(text)) & test_files,)
for required, excluded, understood in (
_keyword_terms(tuple(KEYWORD_RE.findall(text)), attributable=len(globs) < 2),
)
@ -363,6 +366,7 @@ def _workflow_named_tokens() -> frozenset[str]:
def _deselected_everywhere(allowlist: Allowlist) -> tuple[Finding, ...]:
matchable_names: Final = lru_cache(maxsize=None)(_matchable_names)
slices: Final = _slices()
named_by_workflow: Final = _workflow_named_tokens()
globbed: Final = tuple(
@ -376,7 +380,7 @@ def _deselected_everywhere(allowlist: Allowlist) -> tuple[Finding, ...]:
for path in globbed
if not allowlist.covers_test(path)
and not any(_token_covers(token, path) for token in named_by_workflow)
and not any(slice_.claims(path, _matchable_names(path)) for slice_ in slices)
and not any(slice_.claims(path, matchable_names(path)) for slice_ in slices)
)

View file

@ -35,6 +35,8 @@ jobs:
script: scripts/type_discipline_gate.py
- checker: test-quality
script: scripts/test_quality_gate.py
- checker: openapi-docs
script: scripts/openapi_docs_gate.py
- checker: basedpyright
script: scripts/type_check_gate.py
@ -60,16 +62,24 @@ jobs:
uses: ./.github/actions/cache-cargo-build
- name: Cache Prisma binaries
if: matrix.checker == 'basedpyright'
if: matrix.checker == 'basedpyright' || matrix.checker == 'openapi-docs'
uses: ./.github/actions/cache-prisma-binaries
# The three source scanners only need the pinned dev tools (ruff and the
# The source scanners only need the pinned dev tools (ruff and the
# stdlib checkers), the same versions test-linting.yml's python job runs.
- name: Install the dev tools
if: matrix.checker != 'basedpyright'
if: matrix.checker != 'basedpyright' && matrix.checker != 'openapi-docs'
run: |
uv sync --frozen --only-group dev --no-install-project
# The OpenAPI gate imports the proxy app, so it needs the same install the
# test-linting.yml python job uses, Prisma client included.
- name: Install the proxy
if: matrix.checker == 'openapi-docs'
run: |
uv sync --frozen --group proxy-dev --group e2e-dev --extra proxy
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- name: Emit ${{ matrix.checker }} counts for HEAD
if: matrix.checker != 'basedpyright'
run: |

View file

@ -156,6 +156,14 @@ jobs:
run: |
uv run --no-sync python scripts/test_quality_gate.py --base "$GATE_BASE_SHA"
- name: Check OpenAPI docs (POST routes without a request schema, description or 200 schema, delta vs merge-base counts)
if: steps.changes.outputs.decision != 'skip'
env:
GH_TOKEN: ${{ github.token }}
run: |
uv sync --frozen --group proxy-dev --group e2e-dev --extra proxy
uv run --no-sync python scripts/openapi_docs_gate.py --base "$GATE_BASE_SHA"
- name: Print OpenAI version
if: steps.changes.outputs.decision != 'skip'
run: |

View file

@ -91,9 +91,158 @@ jobs:
if: always()
run: sudo ip netns delete smoke
all-extras:
name: all-extras-py${{ matrix.python-version }}
runs-on: ubuntu-24.04
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13"]
env:
LITELLM_LOCAL_MODEL_COST_MAP: "True"
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ matrix.python-version }}
- name: Set up uv
uses: ./.github/actions/setup-uv-with-retries
with:
version: "0.10.9"
- name: Install every extra and dependency group
run: .github/scripts/uv_sync_with_retries.sh --frozen --all-groups --all-extras --python ${{ matrix.python-version }}
- name: Import the SDK, the proxy server and the enterprise package
run: .venv/bin/python -c "import litellm, litellm.proxy.proxy_server, litellm_enterprise"
windows-sdk:
name: windows-sdk
runs-on: windows-2025
timeout-minutes: 10
env:
LITELLM_LOCAL_MODEL_COST_MAP: "True"
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.11"
- name: Set up uv
uses: ./.github/actions/setup-uv-with-retries
with:
version: "0.10.9"
- name: Install dependencies
run: .github/scripts/uv_sync_with_retries.sh --frozen --no-install-workspace --group dev --python 3.11
- name: Run the Windows tests
run: uv run --no-sync python -m pytest --tb=short -v tests/windows_tests
proxy-build-from-pip:
name: proxy-build-from-pip
runs-on: ubuntu-24.04
timeout-minutes: 10
services:
postgres:
image: postgres:14@sha256:6a70deda415ec296f977890e11aba04a0db9f632a362e3fce45e845e3db74f26
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: circle_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 2s
--health-timeout 5s
--health-retries 30
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Set up uv
uses: ./.github/actions/setup-uv-with-retries
with:
version: "0.10.9"
- name: Install the test client dependencies
run: .github/scripts/uv_sync_with_retries.sh --frozen --no-install-workspace --extra proxy --group dev --python 3.12
- name: Build the image from the published package
run: docker build -t litellm-build-from-pip:smoke -f docker/build_from_pip/Dockerfile.build_from_pip .
- name: Start the fake OpenAI endpoint
run: |
nohup .venv/bin/python tests/_fake_openai_endpoint_server.py --host 0.0.0.0 --port 8190 > "${RUNNER_TEMP}/fake-openai.log" 2>&1 &
for _ in $(seq 1 30); do
curl -sf http://localhost:8190/health > /dev/null && exit 0
sleep 1
done
cat "${RUNNER_TEMP}/fake-openai.log"
exit 1
- name: Generate a master key
run: |
key="sk-$(openssl rand -hex 16)"
echo "::add-mask::${key}"
echo "LITELLM_MASTER_KEY=${key}" >> "${GITHUB_ENV}"
- name: Start the proxy container
run: |
docker run -d \
-p 4000:4000 \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e LITELLM_MASTER_KEY \
-e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e OTEL_EXPORTER=in_memory \
-e USE_DDTRACE=True \
-e GCS_FLUSH_INTERVAL=1 \
-e LITELLM_LOG=ERROR \
--add-host host.docker.internal:host-gateway \
--name litellm-build-from-pip \
-v "${GITHUB_WORKSPACE}/docker/build_from_pip/litellm_config.yaml:/app/config.yaml" \
litellm-build-from-pip:smoke \
--config /app/config.yaml \
--port 4000
for _ in $(seq 1 150); do
curl -sf --max-time 5 -o /dev/null http://localhost:4000 && exit 0
sleep 2
done
exit 1
- name: Run the startup tests
run: .venv/bin/python -m pytest --tb=short -vv tests/basic_proxy_startup_tests
- name: Show the proxy logs
if: always()
run: docker logs litellm-build-from-pip || true
smoke-passed:
name: smoke passed
needs: [dashboard-build, core-checks]
needs: [dashboard-build, core-checks, all-extras, windows-sdk, proxy-build-from-pip]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 2

View file

@ -140,6 +140,7 @@ jobs:
tests/unit/batch_completion
tests/unit/decisions
tests/unit/litellm_core_utils
tests/unit/systemone
python-version: "3.12"
workers: 4
reruns: 0
@ -327,6 +328,7 @@ jobs:
tests/unit/llms/sagemaker
tests/unit/llms/sail
tests/unit/llms/sambanova
tests/unit/llms/scaledown
tests/unit/llms/sap
tests/unit/llms/scaleway
tests/unit/llms/searchapi
@ -449,6 +451,7 @@ jobs:
test-path: >-
tests/unit/enterprise/enterprise_callbacks/test_callback_controls.py
tests/unit/enterprise/enterprise_callbacks/test_llm_guard.py
tests/unit/enterprise/enterprise_callbacks/test_pagerduty_alerting.py
tests/unit/enterprise/enterprise_callbacks/test_secret_detection.py
tests/unit/integration_support
tests/unit/models
@ -863,17 +866,6 @@ jobs:
job-timeout-minutes: 60
dist: loadscope
- shard: guardrails-tests
test-path: >-
tests/guardrails_tests
--deselect=tests/guardrails_tests/test_custom_guardrail.py::test_get_guardrail_dynamic_request_body_params
python-version: "3.12"
workers: 4
reruns: 2
timeout-minutes: 15
job-timeout-minutes: 60
dist: loadscope
- shard: budgets
test-path: |-
tests/unit/proxy/auth/test_default_end_user_budget_simple.py
@ -927,6 +919,7 @@ jobs:
timeout-minutes: 3
with:
persist-credentials: false
fetch-depth: ${{ matrix.shard == 'db-and-spend' && '0' || '1' }}
- name: Detect relevant changes
id: changes
@ -959,11 +952,13 @@ jobs:
with:
artifact: ${{ needs.rust-bridge.outputs.artifact }}
- name: Install dependencies
- name: Install dependencies and check schema
if: steps.changes.outputs.decision != 'skip'
timeout-minutes: 8
env:
RUST_BRIDGE_ARTIFACT: ${{ needs.rust-bridge.outputs.artifact }}
UNIT_SHARD: ${{ matrix.shard }}
SCHEMA_BASE_REF: origin/${{ github.event.repository.default_branch }}
run: |
diff -u model_prices_and_context_window.json litellm/model_prices_and_context_window_backup.json
if [ -z "$RUST_BRIDGE_ARTIFACT" ]; then
@ -975,6 +970,9 @@ jobs:
uv run --no-sync python -c "import importlib.metadata; import litellm.rust_bridge._native; print(importlib.metadata.version('litellm'))"
fi
uv run --no-sync python -c 'import os, sys; print(sys.version); assert f"{sys.version_info.major}.{sys.version_info.minor}" == os.environ["UV_PYTHON"]'
if [ "$UNIT_SHARD" = "db-and-spend" ]; then
uv run --no-sync python scripts/check_schema_compatibility.py --base-ref "$SCHEMA_BASE_REF"
fi
- name: Cache Prisma binaries
if: steps.changes.outputs.decision != 'skip'

View file

@ -196,6 +196,13 @@
"litellm-rust/crates/llms/src/anthropic/batches/**"
]
},
{
"path": "litellm-rust/crates/llms/src/anthropic/chat/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/anthropic/chat/",
"scope": [
"litellm-rust/crates/llms/src/anthropic/chat/**"
]
},
{
"path": "litellm-rust/crates/llms/src/anthropic/count_tokens/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/anthropic/count_tokens/",
@ -224,6 +231,13 @@
"litellm-rust/crates/llms/src/azure_ai/messages/**"
]
},
{
"path": "litellm-rust/crates/llms/src/azure_ai/ocr/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/azure_ai/ocr/",
"scope": [
"litellm-rust/crates/llms/src/azure_ai/ocr/**"
]
},
{
"path": "litellm-rust/crates/llms/src/base_llm/messages/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/base_llm/messages/",
@ -231,6 +245,27 @@
"litellm-rust/crates/llms/src/base_llm/messages/**"
]
},
{
"path": "litellm-rust/crates/llms/src/bedrock/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/bedrock/",
"scope": [
"litellm-rust/crates/llms/src/bedrock/**"
]
},
{
"path": "litellm-rust/crates/llms/src/bedrock/audio_transcription/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/bedrock/audio_transcription/",
"scope": [
"litellm-rust/crates/llms/src/bedrock/audio_transcription/**"
]
},
{
"path": "litellm-rust/crates/llms/src/bedrock/chat/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/bedrock/chat/",
"scope": [
"litellm-rust/crates/llms/src/bedrock/chat/**"
]
},
{
"path": "litellm-rust/crates/llms/src/bedrock/messages/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/bedrock/messages/",
@ -238,6 +273,62 @@
"litellm-rust/crates/llms/src/bedrock/messages/**"
]
},
{
"path": "litellm-rust/crates/llms/src/cohere/ocr/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/cohere/ocr/",
"scope": [
"litellm-rust/crates/llms/src/cohere/ocr/**"
]
},
{
"path": "litellm-rust/crates/llms/src/deepseek/messages/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/deepseek/messages/",
"scope": [
"litellm-rust/crates/llms/src/deepseek/messages/**"
]
},
{
"path": "litellm-rust/crates/llms/src/mistral/ocr/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/mistral/ocr/",
"scope": [
"litellm-rust/crates/llms/src/mistral/ocr/**"
]
},
{
"path": "litellm-rust/crates/llms/src/openai/responses/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/openai/responses/",
"scope": [
"litellm-rust/crates/llms/src/openai/responses/**"
]
},
{
"path": "litellm-rust/crates/llms/src/openai_like/chat/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/openai_like/chat/",
"scope": [
"litellm-rust/crates/llms/src/openai_like/chat/**"
]
},
{
"path": "litellm-rust/crates/llms/src/reducto/ocr/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/reducto/ocr/",
"scope": [
"litellm-rust/crates/llms/src/reducto/ocr/**"
]
},
{
"path": "litellm-rust/crates/llms/src/vertex_ai/messages/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/vertex_ai/messages/",
"scope": [
"litellm-rust/crates/llms/src/vertex_ai/messages/**"
]
},
{
"path": "litellm-rust/crates/llms/src/vertex_ai/ocr/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms/src/vertex_ai/ocr/",
"scope": [
"litellm-rust/crates/llms/src/vertex_ai/ocr/**"
]
},
{
"path": "litellm-rust/crates/llms-types/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms-types/",
@ -246,10 +337,24 @@
]
},
{
"path": "litellm-rust/crates/llms-types/src/formats/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms-types/src/formats/",
"path": "litellm-rust/crates/llms-types/src/formats/audio_transcription/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms-types/src/formats/audio_transcription/",
"scope": [
"litellm-rust/crates/llms-types/src/formats/**"
"litellm-rust/crates/llms-types/src/formats/audio_transcription/**"
]
},
{
"path": "litellm-rust/crates/llms-types/src/formats/batches/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms-types/src/formats/batches/",
"scope": [
"litellm-rust/crates/llms-types/src/formats/batches/**"
]
},
{
"path": "litellm-rust/crates/llms-types/src/formats/chat_completions/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms-types/src/formats/chat_completions/",
"scope": [
"litellm-rust/crates/llms-types/src/formats/chat_completions/**"
]
},
{
@ -260,10 +365,45 @@
]
},
{
"path": "litellm-rust/crates/llms-types/src/providers/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms-types/src/providers/",
"path": "litellm-rust/crates/llms-types/src/formats/ocr/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms-types/src/formats/ocr/",
"scope": [
"litellm-rust/crates/llms-types/src/providers/**"
"litellm-rust/crates/llms-types/src/formats/ocr/**"
]
},
{
"path": "litellm-rust/crates/llms-types/src/formats/responses/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms-types/src/formats/responses/",
"scope": [
"litellm-rust/crates/llms-types/src/formats/responses/**"
]
},
{
"path": "litellm-rust/crates/llms-types/src/providers/anthropic/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms-types/src/providers/anthropic/",
"scope": [
"litellm-rust/crates/llms-types/src/providers/anthropic/**"
]
},
{
"path": "litellm-rust/crates/llms-types/src/providers/bedrock/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms-types/src/providers/bedrock/",
"scope": [
"litellm-rust/crates/llms-types/src/providers/bedrock/**"
]
},
{
"path": "litellm-rust/crates/llms-types/src/providers/minimax/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms-types/src/providers/minimax/",
"scope": [
"litellm-rust/crates/llms-types/src/providers/minimax/**"
]
},
{
"path": "litellm-rust/crates/llms-types/src/providers/vertex_ai/AGENTS.md",
"description": "Conventions for code under litellm-rust/crates/llms-types/src/providers/vertex_ai/",
"scope": [
"litellm-rust/crates/llms-types/src/providers/vertex_ai/**"
]
},
{

View file

@ -54,7 +54,7 @@ Don't hesitate to use values in .env to get needed API keys and other secrets, a
Python max line length is 120, not 88
The four lint gates (`scripts/ruff_strict_gate.py`, `scripts/type_discipline_gate.py`, `scripts/test_quality_gate.py`, `scripts/type_check_gate.py`) compare each rule's codebase count on your branch against the count at its merge-base with the default branch, and a rule may not grow. There are no budget files to edit or ratchet: when a gate fails, fix the violations the branch introduced or remove at least as many of that rule elsewhere in the tree. The one exception is `reportAny` / `reportExplicitAny`, which share a fixed codebase-wide cap in `ANY_CAPS` in `scripts/type_check_gate.py` because Any spreads past the lines you touch. A branch may add Anys while the total stays under that cap. Only lower `ANY_CAPS`, and only in its own PR to the default branch, never raise it on a feature branch
The five lint gates (`scripts/ruff_strict_gate.py`, `scripts/type_discipline_gate.py`, `scripts/test_quality_gate.py`, `scripts/openapi_docs_gate.py`, `scripts/type_check_gate.py`) compare each rule's codebase count on your branch against the count at its merge-base with the default branch, and a rule may not grow. There are no budget files to edit or ratchet: when a gate fails, fix the violations the branch introduced or remove at least as many of that rule elsewhere in the tree. The one exception is `reportAny` / `reportExplicitAny`, which share a fixed codebase-wide cap in `ANY_CAPS` in `scripts/type_check_gate.py` because Any spreads past the lines you touch. A branch may add Anys while the total stays under that cap. Only lower `ANY_CAPS`, and only in its own PR to the default branch, never raise it on a feature branch
`make check` (f.k.a. `make pre-commit`, which still works identically as an alias) saves its complete output to a log file in .git (overwriting previous logs) and prints that path as its first and last output lines. To inspect a run, read or grep that log instead of re-running the multi-minute checks just to see a different slice

View file

@ -7,7 +7,7 @@
test-rust-extension rust-sqlx-prepare \
info lint lint-inner lint-dev lint-checks format \
lint-basedpyright lint-e2e-basedpyright lint-type-discipline \
lint-ruff-strict lint-gate lint-test-quality \
lint-ruff-strict lint-gate lint-test-quality lint-openapi-docs \
install-dev install-proxy-dev install-test-deps install-hooks \
install-helm-unittest check-circular-imports check-import-safety check check-inner pre-commit \
lint-install lint-fetch-base bootstrap
@ -35,6 +35,7 @@ help:
@echo " make lint-ruff-strict - Gate each strict ruff rule's codebase total against its merge-base count"
@echo " make lint-gate - Strict ruff gate in CI-parity mode (fetches the default branch, simulates the merge)"
@echo " make lint-test-quality - Gate the test suite's TQ counts against their merge-base counts"
@echo " make lint-openapi-docs - Gate undocumented POST routes in the proxy OpenAPI document against their merge-base counts"
@echo " make check-circular-imports - Check for circular imports"
@echo " make check-import-safety - Check import safety"
@echo " make test - Run all tests"
@ -219,6 +220,12 @@ lint-type-discipline: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
lint-test-quality: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python scripts/test_quality_gate.py --base "$(BASE_REF)"
# OpenAPI docs gate (POST routes without a request body schema, a description or a
# 200 schema in app.openapi()), counted the same delta-vs-base way so a new endpoint
# has to show up documented in Swagger UI.
lint-openapi-docs: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
$(UV_RUN) python scripts/openapi_docs_gate.py --base "$(BASE_REF)"
lint-format: format-check
lint-ruff-strict: install-dev
@ -250,7 +257,7 @@ lint-inner: lint-install
@base_ref=$$($(RESOLVE_BASE)) && \
$(MAKE) BASE_REF="$$base_ref" -j $(LINT_JOBS) $(LINT_OUTPUT_SYNC) LINT_DEP_INSTALL= LINT_E2E_DEP_INSTALL= LINT_DEP_BASE= lint-checks
lint-checks: lint-format-check-changed lint-ruff lint-gate lint-type-discipline lint-test-quality lint-basedpyright lint-e2e-basedpyright check-circular-imports check-import-safety
lint-checks: lint-format-check-changed lint-ruff lint-gate lint-type-discipline lint-test-quality lint-openapi-docs lint-basedpyright lint-e2e-basedpyright check-circular-imports check-import-safety
# Faster linting for local development (only checks changed code)
lint-dev: lint-format-changed check-circular-imports check-import-safety

View file

@ -407,6 +407,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
| [Sagemaker Chat (`sagemaker_chat`)](https://docs.litellm.ai/docs/providers/aws_sagemaker) | ✅ | ✅ | ✅ | | | | | | | |
| [Sail (`sail`)](https://docs.litellm.ai/docs/providers/sail) | ✅ | ✅ | ✅ | | | | | | | |
| [Sambanova (`sambanova`)](https://docs.litellm.ai/docs/providers/sambanova) | ✅ | ✅ | ✅ | | | | | | | |
| [ScaleDown (`scaledown`)](https://docs.litellm.ai/docs/providers/scaledown) | ✅ | | | | | | | | | |
| [Snowflake (`snowflake`)](https://docs.litellm.ai/docs/providers/snowflake) | ✅ | ✅ | ✅ | | | | | | | |
| [Strands Decider (`strands_decider`)](https://docs.litellm.ai/docs/providers) | | | | | | | | | | |
| [Text Completion Codestral (`text-completion-codestral`)](https://docs.litellm.ai/docs/providers/codestral) | ✅ | ✅ | ✅ | | | | | | | |

View file

@ -12,7 +12,7 @@ Endpoints for /project operations
import json
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING, Final, cast
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import TypeAdapter
@ -31,6 +31,7 @@ from litellm.proxy.management_helpers.utils import (
management_endpoint_wrapper,
)
from litellm.proxy.utils import PrismaClient, handle_exception_on_proxy
from litellm.repositories.base_repository import record_to_dict
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.object_permission_repository import ObjectPermissionRepository
from litellm.repositories.prisma_protocols import TableActions
@ -57,6 +58,15 @@ def _project_table(prisma_client: PrismaClient) -> TableActions["prisma_models.L
return ProjectRepository(prisma_client).table
def _writer_project_table(
prisma_client: PrismaClient,
) -> TableActions["prisma_models.LiteLLM_ProjectTable"]:
return cast( # cast-ok: writer_db exposes generated Prisma tables through a dynamic wrapper
"TableActions[prisma_models.LiteLLM_ProjectTable]",
prisma_client.writer_db.litellm_projecttable,
)
def _verification_token_table(
prisma_client: PrismaClient,
) -> TableActions["prisma_models.LiteLLM_VerificationToken"]:
@ -824,6 +834,38 @@ async def update_project(
**({"max_budget": None} if "max_budget" in data.model_fields_set and data.max_budget is None else {}),
}
object_permission_data: Final = update_data.pop("object_permission", None)
object_permission_payload: Final = (
_OBJECT_PERMISSION_PAYLOAD.validate_python(object_permission_data) if object_permission_data else None
)
if data.team_id is not None:
current_project_record: Final = await _writer_project_table(prisma_client).find_unique(
where={"project_id": data.project_id}
)
current_project: Final = (
LiteLLM_ProjectTable.model_validate(record_to_dict(current_project_record))
if current_project_record is not None
else None
)
if current_project is not None and data.team_id != current_project.team_id:
mismatched_key_count: Final = await prisma_client.writer_db.litellm_verificationtoken.count(
where={
"project_id": data.project_id,
"OR": [{"team_id": {"not": data.team_id}}, {"team_id": None}],
}
)
if mismatched_key_count > 0:
raise ProxyException(
message=(
f"Project {data.project_id} has {mismatched_key_count} key(s) that do not belong to "
f"team {data.team_id}. Detach or delete them before moving the project."
),
type="bad_request",
code=400,
param="team_id",
)
if budget_updates and existing_project.budget_id:
# Update existing budget
await _budget_table(prisma_client).update(
@ -837,23 +879,17 @@ async def update_project(
for field in budget_updates.keys():
update_data.pop(field, None)
# Handle object permissions
if "object_permission" in update_data:
object_permission_data = update_data.pop("object_permission")
if object_permission_data:
object_permission_payload: Final = _OBJECT_PERMISSION_PAYLOAD.validate_python(object_permission_data)
if existing_project.object_permission_id:
# Update existing permission
await _object_permission_table(prisma_client).update(
where={"object_permission_id": existing_project.object_permission_id},
data=object_permission_payload,
)
else:
# Create new permission
created_permission: Final = await _object_permission_table(prisma_client).create(
data=object_permission_payload,
)
update_data["object_permission_id"] = created_permission.object_permission_id
if object_permission_payload is not None:
if existing_project.object_permission_id:
await _object_permission_table(prisma_client).update(
where={"object_permission_id": existing_project.object_permission_id},
data=object_permission_payload,
)
else:
created_permission: Final = await _object_permission_table(prisma_client).create(
data=object_permission_payload,
)
update_data["object_permission_id"] = created_permission.object_permission_id
# Handle metadata fields
for field in LiteLLM_ManagementEndpoint_MetadataFields:

View file

@ -1,4 +1,5 @@
import random
import re
import time
from collections.abc import Generator, Mapping
from contextlib import contextmanager
@ -7,25 +8,50 @@ from typing import TYPE_CHECKING, Final
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from litellm_proxy_extras._logging import logger
from litellm_proxy_extras.prisma_toolchain import MIGRATION_LOCK_TIMEOUT_ENV_VAR, migration_lock_timeout
from litellm_proxy_extras.prisma_toolchain import (
MIGRATION_LOCK_TIMEOUT_ENV_VAR,
migration_ddl_lock_timeout,
migration_lock_timeout,
)
MIGRATION_LOCK_KEY: Final = int.from_bytes(b"llm_mig2", "big")
_LOCK_TIMEOUT_PINNED_RE: Final = re.compile(r"(?:-c\s*|--)lock_timeout=")
if TYPE_CHECKING:
import psycopg
def migration_environment(environment: Mapping[str, str]) -> Mapping[str, str]:
database_url: Final = environment.get("DATABASE_URL")
direct_url: Final = environment.get("DIRECT_URL")
if not database_url or not direct_url:
return environment
def _migration_url(database_url: str, direct_url: "str | None") -> str:
if not direct_url:
return database_url
schema: Final = next((value for key, value in parse_qsl(urlsplit(database_url).query) if key == "schema"), "public")
direct: Final = urlsplit(direct_url)
parameters: Final = tuple((key, value) for key, value in parse_qsl(direct.query) if key != "schema")
return urlunsplit(direct._replace(query=urlencode((*parameters, ("schema", schema)))))
def _with_ddl_lock_timeout(url: str) -> str:
split: Final = urlsplit(url)
pairs: Final = tuple(parse_qsl(split.query))
if any(key == "pgbouncer" and value == "true" for key, value in pairs):
return url
existing: Final = next((value for key, value in pairs if key == "options"), "")
if _LOCK_TIMEOUT_PINNED_RE.search(existing):
return url
timeout_ms: Final = max(1, int(migration_ddl_lock_timeout() * 1000))
options: Final = f"{existing} -c lock_timeout={timeout_ms}".strip()
rewritten: Final = tuple((key, value) for key, value in pairs if key != "options") + (("options", options),)
return urlunsplit(split._replace(query=urlencode(rewritten)))
def migration_environment(environment: Mapping[str, str]) -> Mapping[str, str]:
database_url: Final = environment.get("DATABASE_URL")
if not database_url:
return environment
migration_url: Final = _migration_url(database_url, environment.get("DIRECT_URL"))
return {
**environment,
"DATABASE_URL": urlunsplit(direct._replace(query=urlencode((*parameters, ("schema", schema))))),
"DATABASE_URL": _with_ddl_lock_timeout(migration_url),
}

View file

@ -60,11 +60,13 @@ PRISMA_COMMAND_TIMEOUT_ENV_VAR = "LITELLM_PRISMA_COMMAND_TIMEOUT"
PRISMA_BOOTSTRAP_TIMEOUT_ENV_VAR = "LITELLM_PRISMA_BOOTSTRAP_TIMEOUT"
PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR = "LITELLM_PRISMA_MIGRATE_DEPLOY_TIMEOUT"
MIGRATION_LOCK_TIMEOUT_ENV_VAR = "LITELLM_MIGRATION_LOCK_TIMEOUT"
MIGRATION_DDL_LOCK_TIMEOUT_ENV_VAR = "LITELLM_MIGRATION_DDL_LOCK_TIMEOUT"
NODEENV_CACHE_DIR_ENV_VAR = "PRISMA_NODEENV_CACHE_DIR"
DEFAULT_PRISMA_COMMAND_TIMEOUT = 60.0
DEFAULT_PRISMA_BOOTSTRAP_TIMEOUT = 600.0
DEFAULT_PRISMA_MIGRATE_DEPLOY_TIMEOUT = 600.0
DEFAULT_MIGRATION_DDL_LOCK_TIMEOUT = 10.0
BOOTSTRAP_ARG = "--version"
PRISMA_CONSOLE_SCRIPT = "prisma"
@ -111,6 +113,11 @@ def migration_lock_timeout() -> float:
return _timeout_from_env(MIGRATION_LOCK_TIMEOUT_ENV_VAR, 600.0)
def migration_ddl_lock_timeout() -> float:
"""Seconds one migration statement may wait on a table lock before Postgres cancels it."""
return _timeout_from_env(MIGRATION_DDL_LOCK_TIMEOUT_ENV_VAR, DEFAULT_MIGRATION_DDL_LOCK_TIMEOUT)
def prisma_bootstrap_timeout() -> float:
"""Seconds the one-time Node toolchain install may run for."""
return _timeout_from_env(

View file

@ -18,6 +18,7 @@ from litellm_proxy_extras import prisma_toolchain
from litellm_proxy_extras._logging import logger
from litellm_proxy_extras.migration_lock import held_migration_lock
from litellm_proxy_extras.prisma_toolchain import (
MIGRATION_DDL_LOCK_TIMEOUT_ENV_VAR,
PRISMA_COMMAND_TIMEOUT_ENV_VAR,
PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR,
ensure_prisma_toolchain,
@ -62,6 +63,7 @@ def _get_prisma_env() -> dict:
_MIGRATION_TS_RE = re.compile(r"^(\d{14})_")
_MIGRATION_DEADLOCK_MARKER = "deadlock detected"
_MIGRATION_LOCK_TIMEOUT_MARKER = "canceling statement due to lock timeout"
INDEX_REPAIR_ADVISORY_LOCK_KEY: Final = int.from_bytes(b"litellm", "big")
_TRANSIENT_INDEX_SUFFIX_RE: Final = re.compile(r"_cc(?:new|old)\d*$")
_INVALID_LITELLM_INDEXES_SQL: Final = (
@ -1162,9 +1164,11 @@ class ProxyExtrasDBManager:
raise RuntimeError(
f"Database migration failed after {MAX_MIGRATE_DEPLOY_ATTEMPTS} "
"attempts that made no progress (timeouts or deadlock retries). Check database connectivity, "
"load, and _prisma_migrations ledger state, and raise "
f"{PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR} if the attempts timed out."
"attempts that made no progress (timeouts, lock timeouts or deadlock retries). "
"Check database connectivity, load, long-running transactions on the migrated tables, "
"and _prisma_migrations ledger state. Raise "
f"{PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR} if the attempts timed out, or "
f"{MIGRATION_DDL_LOCK_TIMEOUT_ENV_VAR} if they timed out waiting for a table lock."
)
finally:
os.chdir(original_dir)
@ -1225,6 +1229,13 @@ class ProxyExtrasDBManager:
)
ProxyExtrasDBManager._v2_roll_back_migration_best_effort(migration_name)
return budget.spend()
if ledger_logs and _MIGRATION_LOCK_TIMEOUT_MARKER in ledger_logs:
logger.info(
"Migration %s timed out waiting for a table lock, rolling its ledger row back and retrying",
migration_name,
)
ProxyExtrasDBManager._v2_roll_back_migration_best_effort(migration_name)
return budget.spend()
if ProxyExtrasDBManager._failed_migration_recovered(migration_name, started_at):
logger.info(
"Migration %s started at %s was already rolled back or completed by a concurrent "
@ -1270,6 +1281,16 @@ class ProxyExtrasDBManager:
ProxyExtrasDBManager._v2_roll_back_migration_best_effort(migration_name)
return budget.spend()
if migration_name and _MIGRATION_LOCK_TIMEOUT_MARKER in stderr:
ProxyExtrasDBManager._log_migration_lock_holders(migration_name)
logger.warning(
"Migration %s timed out waiting for a table lock, rolling its ledger row back and retrying. "
f"Raise {MIGRATION_DDL_LOCK_TIMEOUT_ENV_VAR} if the database needs longer.",
migration_name,
)
ProxyExtrasDBManager._v2_roll_back_migration_best_effort(migration_name)
return budget.spend()
raise RuntimeError(
"Database migration failed and cannot be auto-recovered. "
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
@ -1282,6 +1303,13 @@ class ProxyExtrasDBManager:
)
return budget.spend()
if _MIGRATION_LOCK_TIMEOUT_MARKER in stderr:
logger.info(
"Waiting for the advisory lock held by another Prisma migration; "
"contention does not spend a migration failure attempt"
)
return budget.after_contention(attempt_seconds)
if "P1002" in stderr and "advisory lock" in stderr:
logger.info(
"Waiting for the advisory lock held by another Prisma migration; "
@ -1294,6 +1322,68 @@ class ProxyExtrasDBManager:
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
) from error
@staticmethod
def _log_migration_lock_holders(migration_name: str) -> None:
"""Best-effort log of the sessions holding locks on the tables a timed-out migration touches."""
try:
migration_sql: Final = (
Path(ProxyExtrasDBManager._get_prisma_dir()) / "migrations" / migration_name / "migration.sql"
).read_text()
except OSError:
return
relations: Final = sorted(set(re.findall(r'"(LiteLLM_\w+)"', migration_sql)))
database_url: Final = os.getenv("DATABASE_URL")
if not relations or not database_url:
return
try:
import psycopg
except ImportError:
return
try:
with psycopg.connect(
ProxyExtrasDBManager._strip_prisma_query_params(database_url),
connect_timeout=10,
autocommit=True,
options="-c statement_timeout=5000",
) as conn:
rows: Final = conn.execute(
"SELECT l.pid, c.relname, l.mode, a.state, a.application_name, "
"date_trunc('second', now() - a.xact_start) "
"FROM pg_locks l "
"JOIN pg_class c ON c.oid = l.relation "
"JOIN pg_namespace n ON n.oid = c.relnamespace "
"JOIN pg_stat_activity a ON a.pid = l.pid "
"WHERE l.granted AND n.nspname = %s AND c.relname = ANY(%s) AND l.pid <> pg_backend_pid() "
"ORDER BY a.xact_start NULLS LAST "
"LIMIT 10",
(
ProxyExtrasDBManager._prisma_schema_param(database_url) or "public",
relations,
),
).fetchall()
except psycopg.Error:
return
if not rows:
logger.warning(
"Migration %s timed out waiting for a lock, but no current lock holder "
"was found on %s; the holder has likely since committed or rolled back",
migration_name,
", ".join(relations),
)
return
for pid, relname, mode, state, application_name, age in rows:
logger.warning(
"Migration %s timed out waiting for a lock on %s held by pid %s "
"(mode %s, state %s, application %s, transaction open for %s)",
migration_name,
relname,
pid,
mode,
state,
application_name,
age,
)
@staticmethod
def _mark_migration_applied(name: str) -> None:
"""Roll a failed ledger row back if it is still there, then mark it applied."""

View file

@ -11,9 +11,9 @@ Use the derived conversions directly (`<&'static str>::from(x)` / `.into()`, `st
## Test placement
- Never create a `tests.rs` (or `test.rs`) file under `src/`, and never `#[path = "tests.rs"] mod tests;`
- A test that reaches private items lives inline, in a `#[cfg(test)] mod tests { ... }` at the bottom of the file that owns those items
- A test that only uses the crate's public API lives in `crates/<crate>/tests/<subject>.rs`, next to `src/`
- Split a mixed test file along that line instead of widening visibility to move it
- Tests live inline by default, in a `#[cfg(test)] mod tests { ... }` at the bottom of the file that owns the code under test, whether or not they touch private items
- `crates/<crate>/tests/` is only for tests that drive several modules together end to end, such as a full request through a route. A test of one type or function never goes there
- A helper shared by several inline test modules goes in a `#[cfg(test)] mod test_support;` at the crate root, and only once a second module needs it
- A test for another crate's item belongs in that crate, not in a downstream one
- Never set `autotests = false` or hand-list `[[test]]` targets. Every file directly under `tests/` is discovered by cargo, and a shared helper goes in `tests/<name>/mod.rs` or `tests/<subject>/support.rs` so it is not picked up as a test crate of its own

View file

@ -15,51 +15,36 @@ use super::Error;
const HEADER_CONTEXT: &str = "messages";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MessagesProvider {
Anthropic,
AzureAi,
Bedrock,
Deepseek,
VertexAi,
#[derive(Clone, Copy)]
pub(crate) struct MessagesProvider {
provider: LlmProviders,
config: &'static dyn BaseMessagesConfig,
}
impl MessagesProvider {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Anthropic => LlmProviders::Anthropic,
Self::AzureAi => LlmProviders::AzureAi,
Self::Bedrock => LlmProviders::Bedrock,
Self::Deepseek => LlmProviders::Deepseek,
Self::VertexAi => LlmProviders::VertexAi,
}
.into()
self.provider.into()
}
pub(crate) fn config(self) -> &'static dyn BaseMessagesConfig {
match self {
Self::Anthropic => &ANTHROPIC_MESSAGES_CONFIG,
Self::AzureAi => &AZURE_ANTHROPIC_MESSAGES_CONFIG,
Self::Bedrock => &BEDROCK_ANTHROPIC_MESSAGES_CONFIG,
Self::Deepseek => &DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG,
Self::VertexAi => &VERTEX_ANTHROPIC_MESSAGES_CONFIG,
}
self.config
}
}
/// Python's `get_provider_anthropic_messages_config`: Vertex AI serves only its Claude
/// partner models on this route.
pub(crate) fn messages_provider(provider: LlmProviders, model: &str) -> Option<MessagesProvider> {
match provider {
LlmProviders::Anthropic => Some(MessagesProvider::Anthropic),
LlmProviders::AzureAi => Some(MessagesProvider::AzureAi),
LlmProviders::Bedrock => Some(MessagesProvider::Bedrock),
LlmProviders::Deepseek => Some(MessagesProvider::Deepseek),
let config: &'static dyn BaseMessagesConfig = match provider {
LlmProviders::Anthropic => &ANTHROPIC_MESSAGES_CONFIG,
LlmProviders::AzureAi => &AZURE_ANTHROPIC_MESSAGES_CONFIG,
LlmProviders::Bedrock => &BEDROCK_ANTHROPIC_MESSAGES_CONFIG,
LlmProviders::Deepseek => &DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG,
LlmProviders::VertexAi if model.to_ascii_lowercase().contains("claude") => {
Some(MessagesProvider::VertexAi)
&VERTEX_ANTHROPIC_MESSAGES_CONFIG
}
_ => None,
}
_ => return None,
};
Some(MessagesProvider { provider, config })
}
pub(super) fn string_headers(
@ -74,24 +59,19 @@ mod tests {
use rstest::rstest;
use super::{MessagesProvider, messages_provider, string_headers, truncate_error_body};
use super::{messages_provider, string_headers, truncate_error_body};
use crate::Error;
use litellm_core_utils::get_llm_provider_logic::LlmProviders;
#[rstest]
#[case::anthropic("anthropic", MessagesProvider::Anthropic)]
#[case::azure_ai("azure_ai", MessagesProvider::AzureAi)]
#[case::bedrock("bedrock", MessagesProvider::Bedrock)]
#[case::deepseek("deepseek", MessagesProvider::Deepseek)]
#[case::vertex_ai("vertex_ai", MessagesProvider::VertexAi)]
fn provider_round_trips_through_its_python_name(
#[case] name: &str,
#[case] provider: MessagesProvider,
) {
assert_eq!(
messages_provider(name.parse::<LlmProviders>().unwrap(), "claude-sonnet-4-5"),
Some(provider)
);
#[case::anthropic("anthropic")]
#[case::azure_ai("azure_ai")]
#[case::bedrock("bedrock")]
#[case::deepseek("deepseek")]
#[case::vertex_ai("vertex_ai")]
fn provider_keeps_its_python_name(#[case] name: &str) {
let provider =
messages_provider(name.parse::<LlmProviders>().unwrap(), "claude-sonnet-4-5").unwrap();
assert_eq!(provider.as_str(), name);
}
@ -102,7 +82,7 @@ mod tests {
#[case] provider: LlmProviders,
#[case] model: &str,
) {
assert_eq!(messages_provider(provider, model), None);
assert!(messages_provider(provider, model).is_none());
}
#[test]

View file

@ -1,75 +1,63 @@
The same ownership rule applies to Messages, Responses, Chat Completions, OCR, and other API formats. This crate owns their shared API data contracts. Adapter contracts and shared transformation machinery belong in `llms/src/base_llm/<format>/`, provider policy in `llms/src/<provider>/<format>/`, and call orchestration in `inference-<format>`. A provider originating a format, or several providers using a type, does not change these responsibilities. Existing model locations outside this crate are not exceptions to this rule for new shared API contracts
# rules
- `litellm-llms-types` owns shared API data contracts and their serialization
- A type belongs here when it describes a request, response, event, or value that consumers must agree on independently of how a call executes
- Being public, serializable, or used by several crates is not sufficient
- These are intended boundaries, not a claim that every existing item follows them
## Scope
- Organize public API contracts under `formats`: `messages`, `chat_completions`, `responses`, `ocr`, `audio_transcription`, and `batches`
- Use names such as `litellm_llms_types::formats::messages::MessagesRequest`, without an Anthropic prefix solely because Anthropic designed Messages
- Keep one canonical definition and import path when moving a contract, updating consumers together instead of adding duplicate models or compatibility re-exports
- This crate owns shared API data contracts and their serialization, nothing else
- A type belongs here when consumers must agree on a request, response, event or value independently of how a call executes
- Being public, serializable or used by several crates is not enough on its own
- Keep shared provider-specific wire types and extensions under `providers`
- Provider types may reuse format types. Format types must not depend on provider types
- A field belonging to an API format stays under `formats` even when provider support varies. Including it in a type does not promise provider support
- Add a typed provider extension when a consumer needs to interpret or construct it. Keep adapter-only projections in `llms` until a shared public data contract is needed
- Keep one authoritative representation of each field, preserving unknown fields without duplicating typed values in an extension map
- Provider capability checks, defaults, authentication, header selection, and transformations remain in `llms`
## Layout
- Keep format-independent data helpers such as `headers`, `recognized`, and `serde_compat` at the crate root
- `formats/<format>/` holds a provider-neutral API format, `providers/<provider>/` holds one provider's shared pieces
- Every format and provider is a folder with a `mod.rs` entrypoint and its own `AGENTS.md`. No standalone `<name>.rs` next to a folder
- A `mod.rs` only declares modules and glob re-exports them (`pub use request::*`). Constants go in `constants.rs`, types in a file named for what they describe (`request.rs`, `response.rs`, `streaming.rs`)
- A submodule stays namespaced (`pub mod streaming`) only when its names would clash with the parent's
- Format-independent helpers (`headers`, `recognized`, `serde_compat`, `json_schema`) stay at the crate root
- AGENTS.md files follow the convention in `../llms/AGENTS.md`. Nested files: `src/formats/<format>/` for every format, `src/providers/<provider>/` for every provider
- Shared request/response bodies, message and content-block enums, usage records, tool-call chunks, stream-event payloads, and protocol error bodies belong here
- This includes LiteLLM's normalized response contracts and extensions, not just exact upstream schemas
- `ChatCompletionsResponse` currently represents the response handed to the host, so replacing it with a supposedly more complete upstream schema must not silently change that contract
- Messages web-search result/error schemas and encrypted-content fields belong to the Messages format regardless of which providers implement them
## Formats
- Shared LiteLLM input data such as `ProviderSpecificHeader` and `ProviderSpecificHeaders` also belongs here
- Selecting entries for a provider belongs in `core-utils`, and applying headers belongs in `http`
- A genuinely provider-specific wire value may retain its provider name: `AnthropicBeta` and `BetaSet` describe the `anthropic-beta` header
- Parsing, formatting, deduplication, and value equality belong with those types
- Choosing required betas, OAuth companions, header precedence, or credentials belongs in `llms` and the auth crates
- Name types after the format, never the originator (`formats::messages::MessagesRequest`, not `AnthropicMessagesRequest`)
- A field defined by the format stays in the format type even when provider support varies
- Keep one canonical definition and import path. Move consumers with the type instead of adding re-exports
- LiteLLM's normalized contracts live here too. `ChatCompletionsResponse` is the response handed to the host, so do not swap it for a fuller upstream schema
- Allow deterministic constructors, accessors, serialization, schema generation, and validation of the represented data shape
- `ContentBlock::text`, `ResponsesWsEvent::model`, and `Recognized::known` are examples
- Exact value conversions such as `EffortLevel` to the matching `ReasoningEffort` are acceptable
- Clamping effort, choosing a thinking budget, rewriting content, mapping finish reasons, computing normalized usage, and translating between API formats are policy or transformations and belong outside this crate, even when they are pure functions
## Providers
- Keep call envelopes and execution state in their owning crates
- `MessagesCall`, `MessagesShaping`, prepared provider requests, and the response wrapper containing a live stream belong in `inference-messages`
- Provider config traits, `MessagesTransformContext`, `MessagesModelCapabilities`, `ThinkingBudgets`, `StreamShape`, and transformer state belong in `llms`
- Catalog records and pricing belong in `model-catalog`, which may reuse wire enums such as `ReasoningEffort`
- Host hooks, Python objects, credentials, clients, timeouts, and routing decisions do not become API payload types merely because they cross a crate boundary
- Legacy logging operation selection belongs in `callbacks-legacy-python`, not this crate
- A provider with more than one endpoint format puts shared endpoint paths, default headers and wire types used by several of its formats in `providers/<provider>/`
- Projections that only one adapter decodes (`InvokeChunkPayload`, the count-tokens and batches adapter structs, `ReplayedWebSearchResult`) stay in `llms/src/<provider>/<format>/`
- A typed provider extension of a format (MiniMax media blocks) belongs here once a consumer must construct or interpret it
- Provider types may use format types. Format types never depend on provider types
- Stream-event data belongs here, but live streams, decoders, framing, buffering, and stream lifecycle decisions do not
- Keep SSE and AWS framing in `framer`, provider decoding and conversion in `llms`, and call orchestration in the `inference-<fmt>` crates
- `ResponsesWsEvent` belongs here
- `ResponsesWsTransformResult` wraps the output of a provider transformation rather than a wire event and lives in `llms::base_llm::responses::transformation`
- Protocol error payloads may live here, while operational errors remain in the crate that raises them
## Behavior on types
- Keep provider-only wire envelopes and transformation-specific projections local until there is a shared API contract to expose
- `InvokeChunkPayload` and the permissive `ReplayedWebSearchResult` projection serve decoding or rendering and should not be promoted unchanged into public schemas
- Reuse existing shared message contracts inside batch and token-counting adapters without moving every adapter struct into this crate
- Shared normalization intermediates such as the prompt factory's `Conversation` stay with their algorithms in `core-utils`
- Allowed: deterministic constructors, accessors, serialization, schema generation, shape validation and exact value conversions
- Not allowed: clamping, budget selection, content rewriting, finish-reason mapping, usage normalization or cross-format translation. These belong in `llms`, even when pure
- Provider capability checks, defaults, auth and header selection belong in `llms`
- Keep this crate at the bottom of the dependency graph
- Use data, serialization, and optional schema libraries without depending on workspace execution crates, async runtimes, transport clients, or Python bindings
- No network or filesystem I/O, environment lookup, clock access, model catalog lookup, or global configuration reads belong here
- Defaults must describe the data contract, not select runtime policy
## What lives elsewhere
- Represent known discriminators such as `tool_use` and `server_tool_use` with enum variants
- Preserve each contract's existing treatment of unknown variants, extra fields, missing fields, and explicit nulls
- `Recognized<T>` deliberately retains values that fail typed parsing, including wrong-shaped values, so use it only where permissive passthrough is already part of the contract
- Typing an opaque field must neither reject previously accepted inputs nor accept malformed inputs previously rejected
- Do not silently discard unknown data or tighten a partial projection into a stricter public schema
- Call envelopes and execution state (`MessagesCall`, prepared requests, live streams) belong in `inference-<format>`
- Adapter contracts and transform state (`MessagesTransformContext`, `StreamShape`) belong in `llms`
- SSE and AWS framing belong in `framer`. Catalog records and pricing belong in `model-catalog`
- Host hooks, Python objects, credentials, clients and routing decisions are not payload types
- Test observable serialization, malformed-input rejection, unknown-value preservation, and value semantics in this crate
- Follow the workspace test-placement and `rstest` rules
- Test provider transformations, header policy, and stream execution in their owning crates
- Do not test import locations or Rust source structure as substitutes for behavior
## Dependencies
- Data, serde and optional schema libraries only. No workspace execution crates, async runtimes, transport clients or Python bindings
- No I/O, env lookup, clock access or global config reads. Defaults describe the data contract, not runtime policy
## Unknown data
- Model known discriminators as enum variants and keep each contract's existing handling of unknown variants, extra fields, missing fields and explicit nulls
- Use `Recognized<T>` only where permissive passthrough is already part of the contract
- Typing an opaque field must neither reject previously accepted input nor accept previously rejected input
## Tests
- Cover serialization, malformed-input rejection, unknown-value preservation and value semantics here
- Transformations and header policy are tested in their owning crates
# references
## json_schema.rs
- https://json-schema.org/draft/2020-12/json-schema-core

View file

@ -1,36 +0,0 @@
# references
These links describe shared format fields and discriminators. The official API reference is authoritative, and SDK sources only clarify shapes the reference leaves implicit. These contracts are partial typed projections, not exhaustive upstream schemas
## chat_completions.rs and chat_completions/content.rs
- https://developers.openai.com/api/reference/resources/chat
SDK wire definitions:
- https://github.com/openai/openai-python/blob/main/src/openai/types/chat/chat_completion_content_part_param.py
## responses/output.rs and responses/streaming_websocket.rs
- https://developers.openai.com/api/reference/resources/responses
- https://developers.openai.com/api/reference/resources/responses/streaming-events
SDK wire definitions:
- https://github.com/openai/openai-python/blob/main/src/openai/types/responses/response.py
- https://github.com/openai/openai-python/blob/main/src/openai/types/responses/response_output_item.py
- https://github.com/openai/openai-python/blob/main/src/openai/types/responses/mcp_tool_call_error.py
## batches.rs
- https://developers.openai.com/api/reference/resources/batches
## audio_transcription.rs
- https://developers.openai.com/api/reference/resources/audio/subresources/transcriptions
## ocr.rs
- https://docs.mistral.ai/api/endpoint/ocr
Messages references live in `messages/AGENTS.md`. `LiteLLMOcrResponse` follows the Mistral OCR response shape, and `OcrBoundingBox` describes the corner coordinates providers copy into the normalized `bbox`. Normalized `tables` and `keyValuePairs` stay open because providers pass through different native shapes

View file

@ -0,0 +1,9 @@
# rules
- The transcription contract follows OpenAI's `audio/transcriptions` shape, which is LiteLLM's normalized input and output for every transcription provider
- Providers without that endpoint translate in `llms/src/<provider>/audio_transcription/` (Bedrock builds a Converse request)
- Parameter support differs per provider and is filtered in the provider config, not by trimming the type
# references
- https://developers.openai.com/api/reference/resources/audio/subresources/transcriptions

View file

@ -0,0 +1,3 @@
mod response;
pub use response::*;

View file

@ -0,0 +1,9 @@
# rules
- The batch contract follows OpenAI's Batch object, which is LiteLLM's normalized batch shape for every provider
- Providers with their own batch API map into it in `llms/src/<provider>/batches/` (Anthropic Message Batches)
- Statuses and counts a provider lacks are derived or defaulted in the provider transformation, not added here
# references
- https://developers.openai.com/api/reference/resources/batches

View file

@ -0,0 +1,3 @@
mod response;
pub use response::*;

View file

@ -1,214 +0,0 @@
use serde_json::{Map, Value};
use strum::IntoStaticStr;
mod content;
pub use content::{
ChatContentPart, ChatFile, ChatInputAudio, ChatLogprobs, ChatMediaUrl, ChatMediaUrlParameters,
ChatTokenLogprob, ChatTopLogprob, ChatVideoMetadata, PromptCacheBreakpoint, PromptCacheMode,
};
/// Reasoning effort level accepted or applied by the model.
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Copy, Eq, IntoStaticStr, strum::EnumString, strum::VariantArray)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningEffort {
#[strum(serialize = "none")]
None,
#[strum(serialize = "minimal")]
Minimal,
#[strum(serialize = "low")]
Low,
#[strum(serialize = "medium")]
Medium,
#[strum(serialize = "high")]
High,
#[strum(serialize = "xhigh")]
Xhigh,
#[strum(serialize = "max")]
Max,
}
#[macro_rules_attribute::apply(crate::wire_type)]
#[serde(untagged)]
pub enum ChatMessageContent {
Text(String),
Parts(Vec<Value>),
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatMessage {
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<ChatMessageContent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionToolCallFunctionChunk {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub arguments: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_specific_fields: Option<Map<String, Value>>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionToolCallChunk {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(rename = "type")]
pub tool_type: String,
pub function: ChatCompletionToolCallFunctionChunk,
pub index: i64,
}
#[macro_rules_attribute::apply(crate::wire_type)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ChatCompletionThinkingBlock {
Thinking {
#[serde(default, skip_serializing_if = "Option::is_none")]
thinking: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cache_control: Option<Value>,
},
RedactedThinking {
#[serde(default, skip_serializing_if = "Option::is_none")]
data: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cache_control: Option<Value>,
},
}
/// OpenAI `usage`, including the `prompt_tokens_details` split LiteLLM's Python
/// path reports so cost tracking sees the same numbers on either path.
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct PromptTokensDetails {
pub cached_tokens: u64,
pub cache_creation_tokens: u64,
pub text_tokens: u64,
}
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct ChatCompletionsUsage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
pub prompt_tokens_details: PromptTokensDetails,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionsChoiceMessage {
pub role: String,
// Whether an empty turn is `None` or `""` is the provider's choice, not a
// shared invariant: Anthropic's transform ends on `merged_text or None`
// while Converse assigns the joined string unconditionally. Each config
// mirrors its own, so keep this optional and serialize it even when None.
pub content: Option<String>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionsChoice {
pub index: u64,
pub message: ChatCompletionsChoiceMessage,
pub finish_reason: String,
}
/// The normalized response handed back to the host.
///
/// There is deliberately no `id`: Python mints the `chatcmpl-…` id on the
/// `ModelResponse` it already created, and echoing the provider's own id here
/// would change it. Pinned by `response_carries_no_id` in the Anthropic chat transformation tests.
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionsResponse {
pub created: u64,
pub model: String,
pub choices: Vec<ChatCompletionsChoice>,
pub usage: ChatCompletionsUsage,
}
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct ChatCompletionDelta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ChatCompletionToolCallChunk>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_blocks: Option<Vec<ChatCompletionThinkingBlock>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_specific_fields: Option<Map<String, Value>>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionStreamingChoice {
pub index: u64,
pub delta: ChatCompletionDelta,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logprobs: Option<Value>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionChunk {
pub id: String,
pub created: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub object: String,
pub choices: Vec<ChatCompletionStreamingChoice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<ChatCompletionsUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_specific_fields: Option<Map<String, Value>>,
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use strum::VariantArray;
use super::*;
#[rstest]
fn reasoning_effort_names_match_the_wire_and_parse_back(
#[values(
ReasoningEffort::None,
ReasoningEffort::Minimal,
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Xhigh,
ReasoningEffort::Max
)]
effort: ReasoningEffort,
) {
assert_eq!(
serde_json::to_value(effort).unwrap(),
Value::String(<&'static str>::from(effort).to_string())
);
assert_eq!(<&'static str>::from(effort).parse(), Ok(effort));
assert!(ReasoningEffort::VARIANTS.contains(&effort));
}
#[rstest]
#[case::unknown("ultra")]
#[case::uppercase("HIGH")]
#[case::empty("")]
fn reasoning_effort_parse_rejects(#[case] value: &str) {
assert!(value.parse::<ReasoningEffort>().is_err());
}
}

View file

@ -0,0 +1,15 @@
# rules
- Chat Completions originated at OpenAI and is the most widely cloned LLM API. Types here describe the format, not OpenAI's deployment of it
- Spoken natively by OpenAI and OpenAI-compatible hosts, served by `llms/src/openai_like/chat/`
- Other providers translate into it from their own API (Anthropic Messages, Bedrock Converse) in `llms/src/<provider>/chat/`
- Compatible hosts commonly
- accept a subset of parameters, which is recorded per provider in `supported_openai_param_mappings`, not by trimming the type
- add top-level request or response fields, preserved through the existing extra-field maps instead of new typed fields
- return slightly different usage or finish reasons, normalized in the provider transformation
- `ChatCompletionsResponse` is LiteLLM's response contract to the host, a typed projection rather than the full upstream schema
# references
- https://developers.openai.com/api/reference/resources/chat
- https://github.com/openai/openai-python/blob/main/src/openai/types/chat/chat_completion_content_part_param.py

View file

@ -153,3 +153,169 @@ pub struct ChatTopLogprob {
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[cfg(test)]
mod tests {
use crate::formats::{
chat_completions::{ChatContentPart, ChatLogprobs, ChatMediaUrl, PromptCacheMode},
messages::ContentSource,
};
use rstest::rstest;
use serde_json::{Value, json};
#[rstest]
#[case::parts(json!([
{"type":"text","text":"hello","cache_control":{"type":"ephemeral"},"prompt_cache_breakpoint":{"mode":"explicit"}},
{"type":"image_url","image_url":{"url":"https://example.test/image","detail":"high","format":"image/png"}},
{"type":"video_url","video_url":"https://example.test/video"},
{"type":"input_audio","input_audio":{"data":"AA==","format":"wav"}},
{"type":"file","file":{"file_id":"file_1","file_data":"JVBE","filename":"a.mp4","format":"video/mp4","detail":"low","video_metadata":{"fps":1,"start_offset":"1s","end_offset":"2s"}}},
{"type":"document","source":{"type":"text","media_type":"text/plain","data":"doc"},"title":"T","context":"C","citations":{"enabled":true}},
{"type":"refusal","refusal":"refused"}
]))]
fn content_parts_round_trip(#[case] wire: Value) {
let parts: Vec<ChatContentPart> = serde_json::from_value(wire.clone()).unwrap();
let [
ChatContentPart::Text {
text,
cache_control,
prompt_cache_breakpoint: Some(breakpoint),
extra: text_extra,
},
ChatContentPart::ImageUrl {
image_url: ChatMediaUrl::Parameters(image),
prompt_cache_breakpoint: None,
..
},
ChatContentPart::VideoUrl {
video_url: ChatMediaUrl::Url(video),
..
},
ChatContentPart::InputAudio { input_audio, .. },
ChatContentPart::File { file, .. },
ChatContentPart::Document {
source,
title: Some(title),
context: Some(context),
citations: Some(citations),
..
},
ChatContentPart::Refusal { refusal, .. },
] = parts.as_slice()
else {
panic!("expected typed content parts");
};
assert_eq!(text, "hello");
assert_eq!(
cache_control.as_ref().unwrap().cache_type.as_deref(),
Some("ephemeral")
);
assert_eq!(breakpoint.mode, PromptCacheMode::Explicit);
assert!(breakpoint.extra.is_empty());
assert!(text_extra.is_empty());
assert_eq!(image.url, "https://example.test/image");
assert_eq!(image.detail.as_deref(), Some("high"));
assert_eq!(image.format.as_deref(), Some("image/png"));
assert!(image.extra.is_empty());
assert_eq!(video, "https://example.test/video");
assert_eq!(input_audio.data, "AA==");
assert_eq!(input_audio.format, "wav");
assert_eq!(file.file_id.as_deref(), Some("file_1"));
assert_eq!(file.file_data.as_deref(), Some("JVBE"));
assert_eq!(file.filename.as_deref(), Some("a.mp4"));
assert_eq!(file.format.as_deref(), Some("video/mp4"));
assert_eq!(file.detail.as_deref(), Some("low"));
assert!(file.extra.is_empty());
let metadata = file.video_metadata.as_ref().unwrap();
assert_eq!(metadata.fps, Some(1.into()));
assert_eq!(metadata.start_offset.as_deref(), Some("1s"));
assert_eq!(metadata.end_offset.as_deref(), Some("2s"));
assert!(metadata.extra.is_empty());
let ContentSource::Text {
data, media_type, ..
} = source.as_ref()
else {
panic!("expected document text source");
};
assert_eq!(data, "doc");
assert_eq!(media_type, "text/plain");
assert_eq!(title, "T");
assert_eq!(context, "C");
assert_eq!(citations.enabled, Some(true));
assert_eq!(refusal, "refused");
assert_eq!(serde_json::to_value(parts).unwrap(), wire);
}
#[rstest]
fn logprobs_round_trip_with_tokens_and_alternatives() {
let wire = json!({
"content":[{"token":"hi","logprob":-1,"bytes":[104,105],"top_logprobs":[{"token":"hey","logprob":-2.5,"bytes":[104]}]}],
"refusal":[{"token":"refused","logprob":-3}]
});
let logprobs: ChatLogprobs = serde_json::from_value(wire.clone()).unwrap();
let token = &logprobs.content.as_ref().unwrap()[0];
assert_eq!(token.token, "hi");
assert_eq!(token.logprob, (-1).into());
assert_eq!(token.bytes.as_deref(), Some([104, 105].as_slice()));
let alternative = &token.top_logprobs.as_ref().unwrap()[0];
assert_eq!(alternative.token, "hey");
assert_eq!(alternative.bytes.as_deref(), Some([104].as_slice()));
assert_eq!(logprobs.refusal.as_ref().unwrap()[0].token, "refused");
assert!(logprobs.refusal.as_ref().unwrap()[0].top_logprobs.is_none());
assert_eq!(serde_json::to_value(logprobs).unwrap(), wire);
}
#[rstest]
#[case::text(json!({"type":"text","text":false}))]
#[case::missing_image(json!({"type":"image_url"}))]
#[case::audio_shape(json!({"type":"input_audio","input_audio":{"data":7,"format":"wav"}}))]
#[case::audio_missing_format(json!({"type":"input_audio","input_audio":{"data":"AA=="}}))]
#[case::image_missing_url(json!({"type":"image_url","image_url":{"detail":"high"}}))]
#[case::file_metadata(json!({"type":"file","file":{"video_metadata":{"fps":"fast"}}}))]
#[case::document_source(json!({"type":"document","source":{"type":"url","url":false}}))]
#[case::refusal_missing_text(json!({"type":"refusal"}))]
#[case::file_missing_file(json!({"type":"file"}))]
#[case::document_missing_source(json!({"type":"document"}))]
#[case::video_missing_url(json!({"type":"video_url"}))]
#[case::cache_control_shape(json!({"type":"text","text":"t","cache_control":"ephemeral"}))]
#[case::breakpoint_missing_mode(json!({"type":"text","text":"t","prompt_cache_breakpoint":{}}))]
#[case::breakpoint_unknown_mode(json!({"type":"text","text":"t","prompt_cache_breakpoint":{"mode":"auto"}}))]
#[case::unknown_tag(json!({"type":"future"}))]
fn content_parts_reject_malformed_typed_fields(#[case] wire: Value) {
assert!(serde_json::from_value::<ChatContentPart>(wire).is_err());
}
#[rstest]
fn partial_file_preserves_extensions_and_omits_null_optionals() {
let wire = json!({"type":"file","file":{"file_id":null,"filename":"a.pdf","extension":[1,null]},"future":true});
let part: ChatContentPart = serde_json::from_value(wire).unwrap();
let ChatContentPart::File {
file,
prompt_cache_breakpoint: None,
extra,
} = &part
else {
panic!("expected file")
};
assert!(file.file_id.is_none());
assert!(file.file_data.is_none());
assert_eq!(file.filename.as_deref(), Some("a.pdf"));
assert_eq!(extra["future"], json!(true));
assert_eq!(
serde_json::to_value(part).unwrap(),
json!({"type":"file","file":{"filename":"a.pdf","extension":[1,null]},"future":true})
);
}
#[rstest]
#[case::missing_token(json!({"logprob":-1}))]
#[case::missing_logprob(json!({"token":"hi"}))]
#[case::wrong_bytes(json!({"token":"hi","logprob":-1,"bytes":[256]}))]
fn token_logprobs_reject_malformed_fields(#[case] wire: Value) {
assert!(
serde_json::from_value::<crate::formats::chat_completions::ChatTokenLogprob>(wire)
.is_err()
);
}
}

View file

@ -0,0 +1,9 @@
mod content;
mod request;
mod response;
mod streaming;
pub use content::*;
pub use request::*;
pub use response::*;
pub use streaming::*;

View file

@ -0,0 +1,77 @@
use serde_json::{Map, Value};
use strum::IntoStaticStr;
/// Reasoning effort level accepted or applied by the model.
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Copy, Eq, IntoStaticStr, strum::EnumString, strum::VariantArray)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningEffort {
#[strum(serialize = "none")]
None,
#[strum(serialize = "minimal")]
Minimal,
#[strum(serialize = "low")]
Low,
#[strum(serialize = "medium")]
Medium,
#[strum(serialize = "high")]
High,
#[strum(serialize = "xhigh")]
Xhigh,
#[strum(serialize = "max")]
Max,
}
#[macro_rules_attribute::apply(crate::wire_type)]
#[serde(untagged)]
pub enum ChatMessageContent {
Text(String),
Parts(Vec<Value>),
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatMessage {
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<ChatMessageContent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use strum::VariantArray;
#[rstest]
fn reasoning_effort_names_match_the_wire_and_parse_back(
#[values(
ReasoningEffort::None,
ReasoningEffort::Minimal,
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Xhigh,
ReasoningEffort::Max
)]
effort: ReasoningEffort,
) {
assert_eq!(
serde_json::to_value(effort).unwrap(),
Value::String(<&'static str>::from(effort).to_string())
);
assert_eq!(<&'static str>::from(effort).parse(), Ok(effort));
assert!(ReasoningEffort::VARIANTS.contains(&effort));
}
#[rstest]
#[case::unknown("ultra")]
#[case::uppercase("HIGH")]
#[case::empty("")]
fn reasoning_effort_parse_rejects(#[case] value: &str) {
assert!(value.parse::<ReasoningEffort>().is_err());
}
}

View file

@ -0,0 +1,88 @@
use serde_json::{Map, Value};
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionToolCallFunctionChunk {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub arguments: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_specific_fields: Option<Map<String, Value>>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionToolCallChunk {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(rename = "type")]
pub tool_type: String,
pub function: ChatCompletionToolCallFunctionChunk,
pub index: i64,
}
#[macro_rules_attribute::apply(crate::wire_type)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ChatCompletionThinkingBlock {
Thinking {
#[serde(default, skip_serializing_if = "Option::is_none")]
thinking: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cache_control: Option<Value>,
},
RedactedThinking {
#[serde(default, skip_serializing_if = "Option::is_none")]
data: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cache_control: Option<Value>,
},
}
/// OpenAI `usage`, including the `prompt_tokens_details` split LiteLLM's Python
/// path reports so cost tracking sees the same numbers on either path.
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct PromptTokensDetails {
pub cached_tokens: u64,
pub cache_creation_tokens: u64,
pub text_tokens: u64,
}
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct ChatCompletionsUsage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
pub prompt_tokens_details: PromptTokensDetails,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionsChoiceMessage {
pub role: String,
// Whether an empty turn is `None` or `""` is the provider's choice, not a
// shared invariant: Anthropic's transform ends on `merged_text or None`
// while Converse assigns the joined string unconditionally. Each config
// mirrors its own, so keep this optional and serialize it even when None.
pub content: Option<String>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionsChoice {
pub index: u64,
pub message: ChatCompletionsChoiceMessage,
pub finish_reason: String,
}
/// The normalized response handed back to the host.
///
/// There is deliberately no `id`: Python mints the `chatcmpl-…` id on the
/// `ModelResponse` it already created, and echoing the provider's own id here
/// would change it. Pinned by `response_carries_no_id` in the Anthropic chat transformation tests.
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionsResponse {
pub created: u64,
pub model: String,
pub choices: Vec<ChatCompletionsChoice>,
pub usage: ChatCompletionsUsage,
}

View file

@ -0,0 +1,48 @@
use serde_json::{Map, Value};
use crate::formats::chat_completions::{
ChatCompletionThinkingBlock, ChatCompletionToolCallChunk, ChatCompletionsUsage,
};
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct ChatCompletionDelta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ChatCompletionToolCallChunk>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_blocks: Option<Vec<ChatCompletionThinkingBlock>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_specific_fields: Option<Map<String, Value>>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionStreamingChoice {
pub index: u64,
pub delta: ChatCompletionDelta,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logprobs: Option<Value>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct ChatCompletionChunk {
pub id: String,
pub created: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub object: String,
pub choices: Vec<ChatCompletionStreamingChoice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<ChatCompletionsUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_specific_fields: Option<Map<String, Value>>,
}

View file

@ -1,19 +1,25 @@
This directory owns shared Messages API data contracts and serialization: request and response bodies, messages, content blocks, usage, and stream-event payloads. Messages is a format independent of the provider that originated it. Keep one canonical definition of each shared contract here
# rules
Adapter contracts and execution inputs such as `MessagesTransformContext` belong in `llms/src/base_llm/messages`. Provider rewriting and interpretation belong in `llms/src/<provider>/messages`. Call envelopes, live streams, and call orchestration belong in `inference-messages`
Represent web-search results, encrypted-content fields, and thinking configuration as data here. Decisions to flatten results, remove encrypted content, select thinking budgets, or require beta headers belong to provider transformations. Data-shape validation belongs here, while model capability checks and request adaptation do not
- Messages originated at Anthropic and is served by many hosts. Types here describe the format, independent of any host
- Spoken by Anthropic, Bedrock InvokeModel, Vertex AI `rawPredict`, Azure AI Foundry, DeepSeek and MiniMax, each adapted in `llms/src/<provider>/messages/`
- Hosts extend it with
- their own content blocks or media sources, which get typed in `providers/<provider>/` once a consumer needs them (MiniMax image, video and mid-conversation system blocks)
- beta features gated by the `anthropic-beta` header, whose accepted values differ per host (`providers/anthropic/beta.rs`)
- Hosts deviate by
- moving `model` or the API version out of the body or into the URL (Bedrock, Vertex)
- framing the stream differently (AWS event stream on Bedrock instead of SSE)
- rejecting fields or discriminators (DeepSeek rejects `type: custom` tools and billing system blocks)
- Handle these in the provider adapter, never by loosening or forking a type here
- Request and response bodies, messages, content blocks, tools, usage and stream-event payloads live here
- Web-search results, encrypted-content fields and thinking configuration are data here
- Flattening results, stripping encrypted content, choosing thinking budgets and requiring betas are provider policy in `llms`
- `MessagesTransformContext` and other adapter inputs live in `llms/src/base_llm/messages/`. Call envelopes live in `inference-messages`
- Beta-only blocks and tools (MCP, compaction, tool changes, advisor, fallback, browser state, toolsets) follow the beta reference
# references
These upstream contracts document fields and discriminators. They are documentation links, not saved golden snapshots. Beta-only blocks and tools (MCP, compaction, tool changes, advisor, fallback, browser state, and toolsets) follow the beta reference
## Messages bodies, content, tools, and streaming
- https://platform.claude.com/docs/en/api/http/messages/create
- https://platform.claude.com/docs/en/api/messages/create.md
- https://platform.claude.com/docs/en/api/beta/messages/create.md
- https://platform.claude.com/docs/en/build-with-claude/streaming.md
- https://platform.claude.com/docs/en/build-with-claude/context-editing
Anthropic-compatible hosts document their deviations in the `llms/src/<provider>/messages` guides. Copy a host reference into `../../providers/AGENTS.md` only when that host gets a typed extension in `providers`

View file

@ -710,3 +710,518 @@ pub enum FallbackTrigger {
extra: Map<String, Value>,
},
}
#[cfg(test)]
mod tests {
use crate::formats::messages::AdvisorToolResultContent;
use crate::formats::messages::{BashCodeExecutionOutput, BashCodeExecutionToolResultContent};
use crate::formats::messages::{BlockContent, BrowserStateChange};
use crate::formats::messages::Citation;
use crate::formats::messages::CodeExecutionOutput;
use crate::formats::messages::CodeExecutionToolResultContent;
use crate::formats::messages::ContentSource;
use crate::formats::messages::FallbackTrigger;
use crate::formats::messages::{McpToolResultContent, McpToolResultText};
use crate::formats::messages::MessagesContentPart;
use crate::formats::messages::MessagesToolParam;
use crate::formats::messages::{TextEditorCodeExecutionToolResultContent, TextEditorFileType};
use crate::formats::messages::{ToolCaller, ToolChange, ToolChangeTarget};
use crate::formats::messages::ToolSearchReference;
use crate::formats::messages::ToolSearchToolResultContent;
use crate::formats::messages::WebFetchDocument;
use crate::formats::messages::WebFetchToolResultContent;
use crate::formats::messages::WebSearchToolResultContent;
use crate::test_support::*;
use rstest::rstest;
use serde_json::{Value, json};
#[rstest]
#[case::base64(json!({"type":"base64","media_type":"image/png","data":"AA=="}))]
#[case::url(json!({"type":"url","url":"https://example.test/image"}))]
#[case::file(json!({"type":"file","file_id":"file_1"}))]
#[case::text(json!({"type":"text","media_type":"text/plain","data":"document"}))]
#[case::content(json!({"type":"content","content":"nested"}))]
fn content_sources_round_trip(#[case] wire: Value) {
round_trip::<ContentSource>(wire);
}
fn part(wire: Value) -> MessagesContentPart {
round_trip::<MessagesContentPart>(wire)
}
#[rstest]
fn text_blocks_expose_every_citation_location() {
let block = part(json!({
"type":"text",
"text":"cited",
"citations":[
{"type":"char_location","cited_text":"a","document_index":0,"start_char_index":1,"end_char_index":6,"file_id":"file_1"},
{"type":"page_location","cited_text":"b","document_index":1,"start_page_number":1,"end_page_number":2},
{"type":"content_block_location","cited_text":"c","document_index":2,"start_block_index":0,"end_block_index":1},
{"type":"web_search_result_location","cited_text":"d","url":"https://example.test","encrypted_index":"opaque","title":"result"},
{"type":"search_result_location","cited_text":"e","search_result_index":3,"source":"kb","start_block_index":0,"end_block_index":2}
],
"cache_control":{"type":"ephemeral"}
}));
let MessagesContentPart::Text(text) = &block else {
panic!("expected text block");
};
assert_eq!(text.text, "cited");
let [
Citation::CharLocation(chars),
Citation::PageLocation(page),
Citation::ContentBlockLocation(blocks),
Citation::WebSearchResultLocation(search),
Citation::SearchResultLocation(result),
] = text.citations.as_deref().unwrap()
else {
panic!("expected one citation of each location type");
};
assert_eq!((chars.start_char_index, chars.end_char_index), (1, 6));
assert_eq!(chars.file_id.as_deref(), Some("file_1"));
assert_eq!((page.start_page_number, page.end_page_number), (1, 2));
assert_eq!(blocks.document_index, 2);
assert_eq!(search.encrypted_index, "opaque");
assert_eq!(result.search_result_index, 3);
assert_eq!(result.source, "kb");
}
#[rstest]
fn text_block_null_optionals_are_omitted() {
let block: MessagesContentPart = serde_json::from_value(
json!({"type":"text","text":"hi","citations":null,"cache_control":null,"future":null}),
)
.unwrap();
let MessagesContentPart::Text(text) = &block else {
panic!("expected text block");
};
assert!(text.citations.is_none());
assert!(text.cache_control.is_none());
assert_eq!(text.extra.get("future"), Some(&Value::Null));
assert_eq!(
serde_json::to_value(block).unwrap(),
json!({"type":"text","text":"hi","future":null})
);
}
#[rstest]
fn request_media_blocks_expose_sources() {
let MessagesContentPart::Image(image) = part(json!({
"type":"image",
"source":{"type":"base64","media_type":"image/png","data":"AA=="},
"transformations":{"oversized_image":"error"}
})) else {
panic!("expected image block");
};
assert!(
matches!(&image.source, ContentSource::Base64 { media_type, .. } if media_type == "image/png")
);
assert_eq!(image.extra["transformations"]["oversized_image"], "error");
let MessagesContentPart::Document(document) = part(json!({
"type":"document",
"source":{"type":"content","content":[
{"type":"text","text":"Section 1"},
{"type":"image","source":{"type":"url","url":"https://example.test/chart.png"}}
]},
"title":"Q3 report",
"context":"quarterly",
"citations":{"enabled":true}
})) else {
panic!("expected document block");
};
assert_eq!(document.title.as_deref(), Some("Q3 report"));
assert_eq!(document.citations.as_ref().unwrap().enabled, Some(true));
let ContentSource::Content {
content: BlockContent::Blocks(blocks),
..
} = &document.source
else {
panic!("expected content-block source");
};
assert!(matches!(
blocks.as_slice(),
[MessagesContentPart::Text(_), MessagesContentPart::Image(_)]
));
let MessagesContentPart::SearchResult(search) = part(json!({
"type":"search_result",
"source":"https://example.test/result",
"title":"result",
"content":[{"type":"text","text":"found"}],
"citations":{"enabled":false}
})) else {
panic!("expected search result block");
};
assert_eq!(search.source, "https://example.test/result");
assert!(
matches!(search.content.as_slice(), [MessagesContentPart::Text(text)] if text.text == "found")
);
}
#[rstest]
fn reasoning_and_tool_blocks_expose_required_fields() {
let MessagesContentPart::Thinking(thinking) =
part(json!({"type":"thinking","thinking":"plan","signature":"sig"}))
else {
panic!("expected thinking block");
};
assert_eq!(
(thinking.thinking.as_str(), thinking.signature.as_str()),
("plan", "sig")
);
let MessagesContentPart::RedactedThinking(redacted) =
part(json!({"type":"redacted_thinking","data":"opaque"}))
else {
panic!("expected redacted thinking block");
};
assert_eq!(redacted.data, "opaque");
let MessagesContentPart::ToolUse(tool_use) = part(json!({
"type":"tool_use",
"id":"toolu_1",
"name":"lookup",
"input":{"query":[1,null]},
"caller":{"type":"code_execution_20260120","tool_id":"srvtoolu_1"},
"toolset_name":"browser"
})) else {
panic!("expected tool use block");
};
assert_eq!(tool_use.input["query"], json!([1, null]));
assert!(
matches!(&tool_use.caller, Some(ToolCaller::CodeExecution20260120 { tool_id, .. }) if tool_id == "srvtoolu_1")
);
assert_eq!(tool_use.toolset_name.as_deref(), Some("browser"));
let MessagesContentPart::ToolResult(result) = part(json!({
"type":"tool_result",
"tool_use_id":"toolu_1",
"is_error":false,
"content":[
{"type":"tool_reference","tool_name":"lookup"},
{"type":"browser_state","tabs":[{"tab_id":"1","title":"","url":"","active":true}],
"state_changes":[{"type":"download_completed","download_id":"d1","url":"https://example.test/f","size_bytes":3}]}
]
})) else {
panic!("expected tool result block");
};
let Some(BlockContent::Blocks(blocks)) = &result.content else {
panic!("expected nested result blocks");
};
let [
MessagesContentPart::ToolReference(reference),
MessagesContentPart::BrowserState(browser),
] = blocks.as_slice()
else {
panic!("expected tool reference and browser state");
};
assert_eq!(reference.tool_name, "lookup");
assert_eq!(browser.tabs[0].active, Some(true));
assert!(matches!(
browser.state_changes.as_deref(),
Some([BrowserStateChange::DownloadCompleted {
size_bytes: Some(3),
path: None,
..
}])
));
let MessagesContentPart::ToolResult(text_result) =
part(json!({"type":"tool_result","tool_use_id":"toolu_2","content":"done"}))
else {
panic!("expected tool result block");
};
assert_eq!(text_result.content, Some(BlockContent::Text("done".into())));
assert!(text_result.is_error.is_none());
}
#[rstest]
fn server_tool_results_expose_nested_result_unions() {
let MessagesContentPart::ServerToolUse(server) = part(
json!({"type":"server_tool_use","id":"srvtoolu_1","name":"web_search","input":{"query":"rust"}}),
) else {
panic!("expected server tool use");
};
assert_eq!(server.name, "web_search");
let MessagesContentPart::WebSearchToolResult(search) = part(json!({
"type":"web_search_tool_result",
"tool_use_id":"srvtoolu_1",
"content":[{"type":"web_search_result","url":"https://example.test","title":"t","encrypted_content":"e","page_age":"1d"}]
})) else {
panic!("expected web search result");
};
let WebSearchToolResultContent::Results(results) = &search.content else {
panic!("expected search results");
};
assert_eq!(results[0].page_age.as_deref(), Some("1d"));
let MessagesContentPart::WebSearchToolResult(search_error) = part(json!({
"type":"web_search_tool_result",
"tool_use_id":"srvtoolu_1",
"content":{"type":"web_search_tool_result_error","error_code":"max_uses_exceeded"}
})) else {
panic!("expected web search error");
};
assert!(
matches!(&search_error.content, WebSearchToolResultContent::Error(error) if error.error_code == "max_uses_exceeded")
);
let MessagesContentPart::WebFetchToolResult(fetch) = part(json!({
"type":"web_fetch_tool_result",
"tool_use_id":"srvtoolu_2",
"content":{"type":"web_fetch_result","url":"https://example.test","retrieved_at":"2026-01-01T00:00:00Z",
"content":{"type":"document","source":{"type":"text","media_type":"text/plain","data":"page"}}}
})) else {
panic!("expected web fetch result");
};
let WebFetchToolResultContent::WebFetchResult(fetched) = &fetch.content else {
panic!("expected fetched page");
};
let WebFetchDocument::Document(document) = &fetched.content;
assert!(matches!(&document.source, ContentSource::Text { data, .. } if data == "page"));
assert!(fetched.extra.is_empty());
let MessagesContentPart::CodeExecutionToolResult(code) = part(json!({
"type":"code_execution_tool_result",
"tool_use_id":"srvtoolu_3",
"content":{"type":"encrypted_code_execution_result","encrypted_stdout":"opaque","stderr":"","return_code":-1,
"content":[{"type":"code_execution_output","file_id":"file_1"}]}
})) else {
panic!("expected code execution result");
};
let CodeExecutionToolResultContent::EncryptedCodeExecutionResult(encrypted) = &code.content
else {
panic!("expected encrypted execution result");
};
assert_eq!(encrypted.return_code, -1);
assert!(
matches!(encrypted.content.as_slice(), [CodeExecutionOutput::CodeExecutionOutput { file_id, .. }] if file_id == "file_1")
);
let MessagesContentPart::BashCodeExecutionToolResult(bash) = part(json!({
"type":"bash_code_execution_tool_result",
"tool_use_id":"srvtoolu_7",
"content":{"type":"bash_code_execution_result","stdout":"ok","stderr":"","return_code":0,
"content":[{"type":"bash_code_execution_output","file_id":"file_2"}]}
})) else {
panic!("expected bash code execution result");
};
let BashCodeExecutionToolResultContent::BashCodeExecutionResult(ran) = &bash.content else {
panic!("expected bash execution result");
};
assert_eq!((ran.stdout.as_str(), ran.return_code), ("ok", 0));
assert!(
matches!(ran.content.as_slice(), [BashCodeExecutionOutput::BashCodeExecutionOutput { file_id, .. }] if file_id == "file_2")
);
assert!(ran.extra.is_empty());
let MessagesContentPart::TextEditorCodeExecutionToolResult(editor) = part(json!({
"type":"text_editor_code_execution_tool_result",
"tool_use_id":"srvtoolu_4",
"content":{"type":"text_editor_code_execution_view_result","content":"fn main() {}","file_type":"text","num_lines":1}
})) else {
panic!("expected text editor result");
};
let TextEditorCodeExecutionToolResultContent::TextEditorCodeExecutionViewResult(view) =
&editor.content
else {
panic!("expected view result");
};
assert_eq!(view.file_type, TextEditorFileType::Text);
assert_eq!(view.num_lines, Some(1));
assert!(view.start_line.is_none());
let MessagesContentPart::ToolSearchToolResult(tool_search) = part(json!({
"type":"tool_search_tool_result",
"tool_use_id":"srvtoolu_5",
"content":{"type":"tool_search_tool_result_error","error_code":"unavailable","error_message":"down"}
})) else {
panic!("expected tool search result");
};
assert!(
matches!(&tool_search.content, ToolSearchToolResultContent::ToolSearchToolResultError(error) if error.error_message.as_deref() == Some("down"))
);
let MessagesContentPart::ToolSearchToolResult(found) = part(json!({
"type":"tool_search_tool_result",
"tool_use_id":"srvtoolu_8",
"content":{"type":"tool_search_tool_search_result","tool_references":[{"type":"tool_reference","tool_name":"lookup"}]}
})) else {
panic!("expected tool search result");
};
let ToolSearchToolResultContent::ToolSearchToolSearchResult(result) = &found.content else {
panic!("expected tool search hits");
};
let [ToolSearchReference::ToolReference(reference)] = result.tool_references.as_slice()
else {
panic!("expected one tool reference");
};
assert_eq!(reference.tool_name, "lookup");
assert!(reference.extra.is_empty() && result.extra.is_empty());
let MessagesContentPart::AdvisorToolResult(advisor) = part(json!({
"type":"advisor_tool_result",
"tool_use_id":"srvtoolu_6",
"content":{"type":"advisor_result","text":"advice"}
})) else {
panic!("expected advisor result");
};
assert!(
matches!(&advisor.content, AdvisorToolResultContent::AdvisorResult { text, stop_reason: None, .. } if text == "advice")
);
}
#[rstest]
fn beta_blocks_expose_mcp_compaction_and_fallback_fields() {
let MessagesContentPart::McpToolUse(mcp) = part(
json!({"type":"mcp_tool_use","id":"mcptoolu_1","name":"search","server_name":"kb","input":{}}),
) else {
panic!("expected MCP tool use");
};
assert_eq!(mcp.server_name, "kb");
let MessagesContentPart::McpToolResult(mcp_result) = part(
json!({"type":"mcp_tool_result","tool_use_id":"mcptoolu_1","is_error":true,"content":"failed"}),
) else {
panic!("expected MCP tool result");
};
assert_eq!(mcp_result.is_error, Some(true));
assert_eq!(
mcp_result.content,
Some(McpToolResultContent::Text("failed".into()))
);
let MessagesContentPart::McpToolResult(text_result) = part(json!({
"type":"mcp_tool_result",
"tool_use_id":"mcptoolu_3",
"content":[{"type":"text","text":"found"}]
})) else {
panic!("expected MCP tool result");
};
let Some(McpToolResultContent::Blocks(blocks)) = &text_result.content else {
panic!("expected MCP text blocks");
};
let [McpToolResultText::Text(text)] = blocks.as_slice() else {
panic!("expected one text block");
};
assert_eq!(text.text, "found");
let MessagesContentPart::McpToolResult(empty_result) =
part(json!({"type":"mcp_tool_result","tool_use_id":"mcptoolu_2"}))
else {
panic!("expected MCP tool result");
};
assert!(empty_result.content.is_none());
assert!(empty_result.extra.is_empty());
let MessagesContentPart::McpToolListing(listing) = part(json!({
"type":"mcp_tool_listing",
"mcp_server_name":"kb",
"tools":[{"name":"search","input_schema":{"type":"object"}}]
})) else {
panic!("expected MCP tool listing");
};
assert!(listing.tools[0].description.is_none());
let MessagesContentPart::Compaction(compaction) = part(json!({
"type":"compaction",
"content":"summary",
"encrypted_content":"opaque",
"tool_changes":[
{"type":"tool_addition","tool":{"type":"tool_definition","definition":{"name":"lookup","input_schema":{"type":"object"}}}},
{"type":"tool_removal","tool":{"type":"mcp_tool_reference","server_name":"kb","name":"search"}}
]
})) else {
panic!("expected compaction block");
};
let [
ToolChange::ToolAddition(addition),
ToolChange::ToolRemoval(removal),
] = compaction.tool_changes.as_deref().unwrap()
else {
panic!("expected tool addition and removal");
};
let ToolChangeTarget::ToolDefinition { definition, .. } = &addition.tool else {
panic!("expected inline tool definition");
};
assert!(
matches!(definition.as_ref(), MessagesToolParam::Custom(tool) if tool.name == "lookup")
);
assert!(
matches!(&removal.tool, ToolChangeTarget::McpToolReference { server_name, .. } if server_name == "kb")
);
let failed: MessagesContentPart =
serde_json::from_value(json!({"type":"compaction","content":null})).unwrap();
assert_eq!(failed, MessagesContentPart::Compaction(Default::default()));
let MessagesContentPart::Fallback(fallback) = part(json!({
"type":"fallback",
"from":{"model":"claude-opus-5-5"},
"to":{"model":"claude-sonnet-5-5"},
"trigger":{"type":"refusal","category":"cyber"}
})) else {
panic!("expected fallback block");
};
assert_eq!(fallback.from.model, "claude-opus-5-5");
assert_eq!(fallback.to.model, "claude-sonnet-5-5");
let Some(FallbackTrigger::Refusal { category, extra }) = &fallback.trigger else {
panic!("expected refusal trigger");
};
assert_eq!(category.as_deref(), Some("cyber"));
assert!(extra.is_empty() && fallback.extra.is_empty());
let MessagesContentPart::Fallback(untriggered) = part(json!({
"type":"fallback",
"from":{"model":"claude-opus-5-5"},
"to":{"model":"claude-sonnet-5-5"}
})) else {
panic!("expected fallback block");
};
assert!(untriggered.trigger.is_none());
}
#[rstest]
#[case::missing_tag(json!({"text":"hello"}))]
#[case::unknown_tag(json!({"type":"future_block","text":"hello"}))]
#[case::text_without_text(json!({"type":"text"}))]
#[case::wrong_text(json!({"type":"text","text":7}))]
#[case::citation_missing_location(json!({"type":"text","text":"a","citations":[{"type":"char_location","cited_text":"a","document_index":0}]}))]
#[case::unknown_citation(json!({"type":"text","text":"a","citations":[{"type":"future_location"}]}))]
#[case::thinking_without_signature(json!({"type":"thinking","thinking":"plan"}))]
#[case::tool_use_without_id(json!({"type":"tool_use","name":"lookup","input":{}}))]
#[case::tool_use_array_input(json!({"type":"tool_use","id":"t","name":"lookup","input":[]}))]
#[case::tool_result_without_id(json!({"type":"tool_result","content":"done"}))]
#[case::malformed_nested_block(json!({"type":"tool_result","tool_use_id":"t","content":[{"type":"text","text":7}]}))]
#[case::malformed_source(json!({"type":"document","source":{"type":"url","url":7}}))]
#[case::image_without_source(json!({"type":"image"}))]
#[case::search_result_without_title(json!({"type":"search_result","source":"s","content":[]}))]
#[case::web_search_result_without_url(json!({"type":"web_search_tool_result","tool_use_id":"t","content":[{"type":"web_search_result","title":"t","encrypted_content":"e"}]}))]
#[case::unknown_fetch_result(json!({"type":"web_fetch_tool_result","tool_use_id":"t","content":{"type":"future"}}))]
#[case::fractional_return_code(json!({"type":"bash_code_execution_tool_result","tool_use_id":"t","content":{"type":"bash_code_execution_result","stdout":"","stderr":"","return_code":0.5,"content":[]}}))]
#[case::unknown_file_type(json!({"type":"text_editor_code_execution_tool_result","tool_use_id":"t","content":{"type":"text_editor_code_execution_view_result","content":"","file_type":"video"}}))]
#[case::negative_line_count(json!({"type":"text_editor_code_execution_tool_result","tool_use_id":"t","content":{"type":"text_editor_code_execution_str_replace_result","new_lines":-1}}))]
#[case::unknown_tool_change(json!({"type":"compaction","tool_changes":[{"type":"tool_addition","tool":{"type":"future"}}]}))]
#[case::tool_search_non_reference(json!({"type":"tool_search_tool_result","tool_use_id":"t","content":{"type":"tool_search_tool_search_result","tool_references":[{"type":"text","text":"lookup"}]}}))]
#[case::web_fetch_text_content(json!({"type":"web_fetch_tool_result","tool_use_id":"t","content":{"type":"web_fetch_result","url":"https://example.test","content":{"type":"text","text":"page"}}}))]
#[case::bash_result_with_code_output(json!({"type":"bash_code_execution_tool_result","tool_use_id":"t","content":{"type":"bash_code_execution_result","stdout":"","stderr":"","return_code":0,"content":[{"type":"code_execution_output","file_id":"f"}]}}))]
#[case::code_result_with_bash_output(json!({"type":"code_execution_tool_result","tool_use_id":"t","content":{"type":"code_execution_result","stdout":"","stderr":"","return_code":0,"content":[{"type":"bash_code_execution_output","file_id":"f"}]}}))]
#[case::fallback_unknown_trigger(json!({"type":"fallback","from":{"model":"a"},"to":{"model":"b"},"trigger":{"type":"overload"}}))]
#[case::unknown_state_change(json!({"type":"browser_state","tabs":[],"state_changes":[{"type":"tab_closed","tab_id":"1"}]}))]
fn content_parts_reject_malformed_known_fields(#[case] wire: Value) {
assert!(serde_json::from_value::<MessagesContentPart>(wire).is_err());
}
#[rstest]
#[case::image(json!([{"type":"image","source":{"type":"url","url":"https://example.test/a.png"}}]))]
#[case::tool_reference(json!([{"type":"tool_reference","tool_name":"lookup"}]))]
#[case::untagged(json!([{"text":"found"}]))]
fn mcp_tool_result_content_rejects_non_text_blocks(#[case] content: Value) {
assert!(
serde_json::from_value::<MessagesContentPart>(
json!({"type":"mcp_tool_result","tool_use_id":"mcptoolu_1","content":content})
)
.is_err()
);
}
}

View file

@ -338,3 +338,293 @@ pub struct CacheMissedTokens {
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[cfg(test)]
mod tests {
use crate::formats::messages::CacheMissReason;
use crate::formats::messages::ContainerReference;
use crate::formats::messages::ContextManagementResponse;
use crate::formats::messages::McpServer;
use crate::formats::messages::{
MessageRole, MessageType, MessagesCompaction, MessagesContainer,
};
use crate::formats::messages::{
MessagesDiagnostics, MessagesDiagnosticsParam, MessagesMetadata,
};
use crate::formats::messages::OutputFormat;
use crate::formats::messages::Safeguard;
use crate::formats::messages::{SkillType, StopDetails, StopDetailsType, StopReason};
use crate::json_schema::JsonSchema;
use crate::{recognized::Recognized, test_support::*};
use rstest::rstest;
use serde_json::{Value, json};
#[rstest]
fn metadata_contracts_round_trip() {
let metadata = round_trip::<MessagesMetadata>(json!({"user_id":"user_1","future":true}));
assert_eq!(metadata.user_id.as_deref(), Some("user_1"));
assert_eq!(metadata.extra.get("future"), Some(&json!(true)));
let output_format = round_trip::<OutputFormat>(json!({
"type":"json_schema",
"schema":{"type":"object","properties":{"name":{"type":"string"}}},
"strict":true
}));
assert_eq!(output_format.strict, Some(true));
assert!(output_format.extra.is_empty());
let JsonSchema::Object(schema) = &output_format.schema else {
panic!("expected output object schema");
};
assert!(schema.properties.as_ref().unwrap().contains_key("name"));
let compaction =
round_trip::<MessagesCompaction>(json!({"type":"summarize","instructions":"briefly"}));
assert_eq!(compaction.instructions.as_deref(), Some("briefly"));
assert!(compaction.extra.is_empty());
let container = round_trip::<MessagesContainer>(json!({
"id":"container_1",
"expires_at":"2026-01-01T00:00:00Z",
"skills":[{"type":"custom","skill_id":"skill_1","version":"1"}]
}));
assert_eq!(container.id.as_deref(), Some("container_1"));
assert!(container.extra.is_empty());
let [skill] = container.skills.as_ref().unwrap().as_slice() else {
panic!("expected container skill");
};
assert_eq!(skill.skill_type, SkillType::Custom);
assert_eq!(skill.skill_id, "skill_1");
assert_eq!(skill.version.as_deref(), Some("1"));
assert!(skill.extra.is_empty());
let reference = round_trip::<ContainerReference>(json!({"id":"container_1"}));
let ContainerReference::Parameters(parameters) = reference else {
panic!("expected container parameters");
};
assert_eq!(parameters.id.as_deref(), container.id.as_deref());
assert!(parameters.skills.is_none());
round_trip::<ContainerReference>(
json!({"id":"container_1","skills":[{"type":"anthropic","skill_id":"pptx"}]}),
);
let server = round_trip::<McpServer>(json!({
"type":"url",
"url":"https://example.test/mcp",
"name":"search",
"authorization_token":"token",
"tool_configuration":{"allowed_tools":["search"],"enabled":true}
}));
assert_eq!(server.url, "https://example.test/mcp");
assert_eq!(server.name, "search");
assert_eq!(server.authorization_token.as_deref(), Some("token"));
assert!(server.extra.is_empty());
let configuration = server.tool_configuration.as_ref().unwrap();
assert_eq!(configuration.enabled, Some(true));
assert_eq!(
configuration.allowed_tools.as_deref(),
Some([String::from("search")].as_slice())
);
assert!(configuration.extra.is_empty());
let context = round_trip::<ContextManagementResponse>(json!({
"applied_edits":[
{"type":"clear_tool_uses_20250919","cleared_input_tokens":7,"cleared_tool_uses":2},
{"type":"clear_thinking_20251015","cleared_input_tokens":3,"cleared_thinking_turns":1}
]
}));
let [tool_uses, thinking] = context.applied_edits.as_ref().unwrap().as_slice() else {
panic!("expected two applied edits");
};
assert_eq!(
tool_uses.edit_type.as_deref(),
Some("clear_tool_uses_20250919")
);
assert_eq!(
(tool_uses.cleared_input_tokens, tool_uses.cleared_tool_uses),
(Some(7), Some(2))
);
assert!(tool_uses.cleared_thinking_turns.is_none());
assert_eq!(
(
thinking.cleared_input_tokens,
thinking.cleared_thinking_turns
),
(Some(3), Some(1))
);
assert!(tool_uses.extra.is_empty() && thinking.extra.is_empty());
let safeguard = round_trip::<Safeguard>(
json!({"type":"classifier","classifier_context":{"source":"test"}}),
);
assert_eq!(safeguard.safeguard_type, "classifier");
assert_eq!(
safeguard.classifier_context.as_ref().unwrap().get("source"),
Some(&json!("test"))
);
assert!(safeguard.extra.is_empty());
}
#[rstest]
fn stop_details_expose_refusal_and_keep_unknown_types() {
let refusal = round_trip::<StopDetails>(
json!({"type":"refusal","category":"cyber","explanation":"blocked"}),
);
assert_eq!(
refusal.detail_type,
Recognized::Known(StopDetailsType::Refusal)
);
assert_eq!(refusal.category.as_deref(), Some("cyber"));
assert_eq!(refusal.explanation.as_deref(), Some("blocked"));
assert!(refusal.extra.is_empty());
let safeguard =
round_trip::<StopDetails>(json!({"type":"safeguard","safeguard_types":["classifier"]}));
assert_eq!(
safeguard.detail_type,
Recognized::Unrecognized(json!("safeguard"))
);
assert_eq!(safeguard.extra["safeguard_types"], json!(["classifier"]));
}
#[rstest]
fn container_rejects_skill_without_id() {
assert!(
serde_json::from_value::<MessagesContainer>(
json!({"skills":[{"type":"custom","version":"1"}]})
)
.is_err()
);
}
#[rstest]
#[case::without_url(json!({"type":"url","name":"search"}))]
#[case::without_name(json!({"type":"url","url":"https://example.test/mcp"}))]
fn mcp_server_rejects_missing_required_fields(#[case] wire: Value) {
assert!(serde_json::from_value::<McpServer>(wire).is_err());
}
#[rstest]
fn output_format_rejects_missing_schema() {
assert!(serde_json::from_value::<OutputFormat>(json!({"type":"json_schema"})).is_err());
}
#[rstest]
#[case::user(json!("user"))]
#[case::assistant(json!("assistant"))]
#[case::system(json!("system"))]
#[case::future(json!("future_role"))]
fn message_roles_round_trip(#[case] wire: Value) {
round_trip::<MessageRole>(wire);
}
#[rstest]
#[case::message(json!("message"))]
#[case::future(json!("future_type"))]
fn message_types_round_trip(#[case] wire: Value) {
round_trip::<MessageType>(wire);
}
#[rstest]
#[case::end_turn(json!("end_turn"))]
#[case::refusal(json!("refusal"))]
#[case::compaction(json!("compaction"))]
#[case::future(json!("future_reason"))]
fn stop_reasons_round_trip(#[case] wire: Value) {
round_trip::<StopReason>(wire);
}
#[rstest]
#[case::model(json!({"type":"model_changed","cache_missed_input_tokens":12}), Some(12))]
#[case::system(json!({"type":"system_changed","cache_missed_input_tokens":3}), Some(3))]
#[case::tools(json!({"type":"tools_changed","cache_missed_input_tokens":0}), Some(0))]
#[case::messages(json!({"type":"messages_changed","cache_missed_input_tokens":9}), Some(9))]
#[case::not_found(json!({"type":"previous_message_not_found"}), None)]
#[case::unavailable(json!({"type":"unavailable"}), None)]
fn diagnostics_expose_cache_miss_reason(#[case] reason: Value, #[case] missed: Option<u64>) {
let diagnostics = round_trip::<MessagesDiagnostics>(json!({"cache_miss_reason":reason}));
let Some(Recognized::Known(reason)) = &diagnostics.cache_miss_reason else {
panic!("expected known cache miss reason");
};
let actual = match reason {
CacheMissReason::ModelChanged(tokens)
| CacheMissReason::SystemChanged(tokens)
| CacheMissReason::ToolsChanged(tokens)
| CacheMissReason::MessagesChanged(tokens) => {
assert!(tokens.extra.is_empty());
Some(tokens.cache_missed_input_tokens)
}
CacheMissReason::PreviousMessageNotFound { extra }
| CacheMissReason::Unavailable { extra } => {
assert!(extra.is_empty());
None
}
};
assert_eq!(actual, missed);
assert!(diagnostics.extra.is_empty());
}
#[rstest]
#[case::model(json!({"type":"model_changed","cache_missed_input_tokens":12}), "model_changed")]
#[case::system(json!({"type":"system_changed","cache_missed_input_tokens":12}), "system_changed")]
#[case::tools(json!({"type":"tools_changed","cache_missed_input_tokens":12}), "tools_changed")]
#[case::messages(json!({"type":"messages_changed","cache_missed_input_tokens":12}), "messages_changed")]
fn cache_miss_reason_variants_keep_their_tags(#[case] reason: Value, #[case] tag: &str) {
let parsed: CacheMissReason = serde_json::from_value(reason).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap()["type"], json!(tag));
}
#[rstest]
#[case::pending(json!({"cache_miss_reason":null}))]
#[case::future_reason(json!({"cache_miss_reason":{"type":"future_changed","cache_missed_input_tokens":1}}))]
#[case::missing_tokens(json!({"cache_miss_reason":{"type":"model_changed"}}))]
fn diagnostics_keep_pending_and_unmodeled_reasons(#[case] wire: Value) {
let diagnostics: MessagesDiagnostics = serde_json::from_value(wire.clone()).unwrap();
match &diagnostics.cache_miss_reason {
None => assert_eq!(serde_json::to_value(&diagnostics).unwrap(), json!({})),
Some(Recognized::Unrecognized(kept)) => {
assert_eq!(kept, &wire["cache_miss_reason"]);
assert_eq!(serde_json::to_value(&diagnostics).unwrap(), wire);
}
Some(Recognized::Known(reason)) => panic!("unexpected known reason {reason:?}"),
}
}
#[rstest]
#[case::previous(json!({"previous_message_id":"msg_1"}), Some(Some("msg_1")))]
#[case::first_turn(json!({"previous_message_id":null}), Some(None))]
#[case::absent(json!({}), None)]
fn diagnostics_param_distinguishes_null_from_absent_previous_message(
#[case] wire: Value,
#[case] expected: Option<Option<&str>>,
) {
let param = round_trip::<MessagesDiagnosticsParam>(wire);
assert_eq!(
param.previous_message_id.as_ref().map(Option::as_deref),
expected
);
assert!(param.extra.is_empty());
}
#[rstest]
fn diagnostics_param_rejects_non_string_previous_message() {
assert!(
serde_json::from_value::<MessagesDiagnosticsParam>(json!({"previous_message_id":7}))
.is_err()
);
}
#[rstest]
fn cache_miss_reason_rejects_non_numeric_tokens() {
assert!(
serde_json::from_value::<CacheMissReason>(
json!({"type":"model_changed","cache_missed_input_tokens":"many"})
)
.is_err()
);
}
}

View file

@ -6,49 +6,9 @@ pub mod streaming;
mod tools;
mod usage;
pub use content::{
AdvisorToolResultContent, BashCodeExecutionOutput, BashCodeExecutionToolResultContent,
BlockContent, BrowserStateBlock, BrowserStateChange, BrowserTab, CharCitation, Citation,
CitationsConfig, CodeExecutionOutput, CodeExecutionResult, CodeExecutionToolResultContent,
CompactionBlock, ContainerUploadBlock, ContentBlockCitation, ContentSource, DocumentBlock,
EncryptedCodeExecutionResult, FallbackBlock, FallbackModel, FallbackTrigger, ImageBlock,
McpListedTool, McpToolListingBlock, McpToolResultBlock, McpToolResultContent,
McpToolResultText, McpToolUseBlock, MessagesContentPart, PageCitation, RedactedThinkingBlock,
SearchResultBlock, SearchResultCitation, ServerToolError, ServerToolResultBlock,
ServerToolUseBlock, TextBlock, TextEditorCodeExecutionToolResultContent,
TextEditorCreateResult, TextEditorFileType, TextEditorStrReplaceResult, TextEditorViewResult,
ThinkingBlock, ToolCaller, ToolChange, ToolChangeBlock, ToolChangeTarget, ToolReferenceBlock,
ToolResultBlock, ToolSearchReference, ToolSearchResult, ToolSearchToolResultContent,
ToolUseBlock, WebFetchDocument, WebFetchResult, WebFetchToolResultContent, WebSearchCitation,
WebSearchResult, WebSearchResultError, WebSearchResultErrorType, WebSearchResultType,
WebSearchToolResultContent,
};
pub use metadata::{
AppliedEdit, CacheMissReason, CacheMissedTokens, CompactionType, ContainerReference,
ContainerSkill, ContextManagementResponse, McpServer, McpServerType, McpToolConfiguration,
MessageRole, MessageType, MessagesCompaction, MessagesContainer, MessagesDiagnostics,
MessagesDiagnosticsParam, MessagesMetadata, OutputFormat, OutputFormatType, Safeguard,
SkillType, StopDetails, StopDetailsType, StopReason,
};
pub use request::{
AdaptiveThinking, CacheControl, ContentBlock, ContentBlockType, ContextEdit, ContextManagement,
ContextTrigger, DisabledThinking, EffortLevel, EnabledThinking, Message, MessageContent,
MessagesOptionalParams, MessagesRequest, MessagesTool, OutputConfig, Speed, SystemPrompt,
ThinkingConfig, ThinkingDisplay,
};
pub use response::MessagesResponse;
pub use tools::{
AdvisorTool, AdvisorToolName, AllowedCaller, BashToolName, BrowserToolsetConfigs,
BuiltinMessagesTool, ClientTool, CodeExecutionToolName, ComputerTool, ComputerTool20251124,
ComputerToolName, ComputerToolsetConfigs, CustomTool, CustomToolType, McpToolset,
MemoryToolName, MessagesToolParam, ResponseInclusion, ServerTool, StrReplaceBasedEditToolName,
StrReplaceEditorName, TextEditorTool20250728, ToolChoice, ToolChoiceType, ToolResultUrlSource,
ToolSearchBm25ToolName, ToolSearchRegexToolName, Toolset, ToolsetToolConfig,
UrlSourceToolReference, UserInputUrlSource, UserLocationType, WebFetchTool,
WebFetchTool20260309, WebFetchTool20260318, WebFetchToolName, WebFetchUrlSources,
WebSearchTool, WebSearchTool20260318, WebSearchToolName, WebSearchUserLocation,
};
pub use usage::{
CacheCreationUsage, MessagesOutputTokensDetails, MessagesUsage, ServerToolUsage,
UsageIteration, UsageIterationType,
};
pub use content::*;
pub use metadata::*;
pub use request::*;
pub use response::*;
pub use tools::*;
pub use usage::*;

View file

@ -387,10 +387,17 @@ impl Message {
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use serde_json::json;
use super::*;
use crate::formats::messages::ContextTrigger;
use crate::formats::messages::{ContentBlock, ContentBlockType};
use crate::recognized::Recognized;
use serde_json::{Map, Value};
fn round_trip<T: serde::de::DeserializeOwned + serde::Serialize>(value: &Value) -> Value {
let parsed: T = serde_json::from_value(value.clone()).unwrap();
@ -718,4 +725,126 @@ mod tests {
json!(<&'static str>::from(level))
);
}
#[rstest]
#[case::null(json!(null))]
#[case::number(json!(1))]
#[case::boolean(json!(true))]
#[case::array(json!(["tool_use"]))]
#[case::object(json!({"type": "tool_use"}))]
fn content_block_type_rejects_non_string_json(#[case] value: Value) {
assert!(serde_json::from_value::<ContentBlockType>(value).is_err());
}
#[cfg(feature = "schema")]
#[rstest]
fn content_block_type_schema_remains_a_string() {
let schema = schemars::schema_for!(ContentBlockType).to_value();
assert_eq!(schema.get("type"), Some(&json!("string")));
assert_eq!(
schema.get("title"),
Some(&json!(stringify!(ContentBlockType)))
);
}
#[rstest]
#[case::text("text", ContentBlockType::Text)]
#[case::thinking("thinking", ContentBlockType::Thinking)]
#[case::redacted_thinking("redacted_thinking", ContentBlockType::RedactedThinking)]
#[case::tool_use("tool_use", ContentBlockType::ToolUse)]
#[case::server_tool_use("server_tool_use", ContentBlockType::ServerToolUse)]
#[case::tool_result("tool_result", ContentBlockType::ToolResult)]
#[case::compaction("compaction", ContentBlockType::Compaction)]
#[case::advisor_result("advisor_tool_result", ContentBlockType::AdvisorToolResult)]
#[case::web_search_result("web_search_tool_result", ContentBlockType::WebSearchToolResult)]
#[case::image("image", ContentBlockType::Other("image".into()))]
#[case::document("document", ContentBlockType::Other("document".into()))]
#[case::tool_addition("tool_addition", ContentBlockType::Other("tool_addition".into()))]
#[case::tool_removal("tool_removal", ContentBlockType::Other("tool_removal".into()))]
#[case::advisor("advisor_result", ContentBlockType::Other("advisor_result".into()))]
#[case::web_search_error(
"web_search_tool_result_error",
ContentBlockType::Other("web_search_tool_result_error".into())
)]
#[case::future_block("future_block", ContentBlockType::Other("future_block".into()))]
#[case::case_sensitive("Tool_Use", ContentBlockType::Other("Tool_Use".into()))]
#[case::empty("", ContentBlockType::Other(String::new()))]
fn block_type_is_typed_and_round_trips_with_extra_fields(
#[case] wire: &str,
#[case] expected: ContentBlockType,
) {
let input = json!({"type": wire, "future_field": {"nested": [1, null]}});
let block: ContentBlock = serde_json::from_value(input.clone()).unwrap();
assert_eq!(block.block_type.as_ref(), Some(&expected));
assert_eq!(serde_json::to_value(block).unwrap(), input);
}
#[rstest]
#[case::number(json!(1))]
#[case::boolean(json!(true))]
#[case::array(json!(["text"]))]
#[case::enum_object(json!({"text": null}))]
fn block_type_rejects_non_string_values(#[case] value: Value) {
assert!(serde_json::from_value::<ContentBlock>(json!({"type": value})).is_err());
}
#[rstest]
#[case::same_type(json!({"type": "tool_use"}), ContentBlockType::ToolUse, true)]
#[case::other_type(json!({"type": "tool_result"}), ContentBlockType::ToolUse, false)]
#[case::unknown_type(json!({"type": "future_tool"}), ContentBlockType::ToolUse, false)]
#[case::no_type(json!({"text": "x"}), ContentBlockType::Text, false)]
fn is_type_matches_the_exact_block_type(
#[case] block: Value,
#[case] block_type: ContentBlockType,
#[case] expected: bool,
) {
let block: ContentBlock = serde_json::from_value(block).unwrap();
assert_eq!(block.is_type(block_type), expected);
}
#[rstest]
#[case::input_tokens("input_tokens", false)]
#[case::tool_uses("tool_uses", true)]
fn context_trigger_exposes_typed_threshold(#[case] tag: &str, #[case] tool_uses: bool) {
let wire = json!({"type":tag,"value":1024,"extension":true});
let trigger: ContextTrigger = serde_json::from_value(wire.clone()).unwrap();
match &trigger {
ContextTrigger::InputTokens { value, extra } => {
assert!(!tool_uses);
assert_eq!(*value, 1024);
assert_eq!(extra.get("extension"), Some(&json!(true)));
}
ContextTrigger::ToolUses { value, extra } => {
assert!(tool_uses);
assert_eq!(*value, 1024);
assert_eq!(extra.get("extension"), Some(&json!(true)));
}
}
assert_eq!(serde_json::to_value(trigger).unwrap(), wire);
}
#[rstest]
#[case::negative(json!({"type":"input_tokens","value":-1}))]
#[case::wrong_shape(json!({"type":"input_tokens","value":"1024"}))]
#[case::missing_value(json!({"type":"input_tokens"}))]
#[case::null_value(json!({"type":"input_tokens","value":null}))]
#[case::tool_uses_negative(json!({"type":"tool_uses","value":-1}))]
#[case::tool_uses_fractional(json!({"type":"tool_uses","value":1.5}))]
#[case::tool_uses_missing_value(json!({"type":"tool_uses"}))]
#[case::missing_discriminator(json!({"value":1}))]
#[case::unknown_discriminator(json!({"type":"other","value":1}))]
fn token_threshold_requires_unsigned_integer(#[case] wire: Value) {
assert!(serde_json::from_value::<ContextTrigger>(wire).is_err());
}
#[rstest]
fn existing_content_blocks_preserve_opaque_nested_fields() {
let wire =
json!({"type":"tool_result","content":[{"type":"text","text":7}],"source":{"url":7}});
let block: crate::formats::messages::ContentBlock =
serde_json::from_value(wire.clone()).unwrap();
assert_eq!(block.content.as_ref(), wire.get("content"));
assert_eq!(block.extra.get("source"), wire.get("source"));
assert_eq!(serde_json::to_value(block).unwrap(), wire);
}
}

View file

@ -20,11 +20,10 @@ pub struct MessagesResponse {
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use serde_json::json;
use super::*;
fn response(
stop_reason: Option<&str>,
stop_sequence: Option<&str>,

View file

@ -141,3 +141,45 @@ pub enum MessagesStreamEvent {
error: MessagesStreamError,
},
}
#[cfg(test)]
mod tests {
use crate::formats::messages::streaming::MessagesStreamEvent;
use rstest::rstest;
use serde_json::{Value, json};
#[rstest]
#[case::start(json!({
"type": "message_start",
"message": {
"id": "msg", "type": "message", "role": "assistant", "model": "test-model",
"content": [], "stop_reason": null, "stop_sequence": null,
"usage": {"input_tokens": 3, "future_usage": {"count": 9}},
"future_message": [1, 2]
}
}))]
#[case::block(json!({
"type": "content_block_start", "index": 0,
"content_block": {"type": "future", "payload": {"keep": true}}
}))]
#[case::delta(json!({
"type": "message_delta", "delta": {"stop_reason": "end_turn", "future_delta": 42},
"usage": {"output_tokens": 7, "future_usage": true}
}))]
#[case::error(json!({
"type": "error", "error": {"type": "future_error", "message": "failed", "future": 42}
}))]
fn stream_events_preserve_extensible_fields(#[case] wire: Value) {
let event: MessagesStreamEvent = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(serde_json::to_value(event).unwrap(), wire);
}
#[rstest]
#[case::invalid_index(json!({"type": "content_block_stop", "index": "zero"}))]
#[case::missing_delta(json!({"type": "content_block_delta", "index": 0}))]
#[case::unknown_event(json!({"type": "future_event"}))]
fn malformed_or_unrecognized_events_remain_rejected(#[case] wire: Value) {
assert!(serde_json::from_value::<MessagesStreamEvent>(wire).is_err());
}
}

View file

@ -562,3 +562,672 @@ pub enum MessagesToolParam {
Builtin(Box<BuiltinMessagesTool>),
Custom(Box<CustomTool>),
}
#[cfg(test)]
mod tests {
use crate::formats::messages::{
AdvisorTool, AdvisorToolName, CacheControl, UrlSourceToolReference,
};
use serde_json::Map;
use crate::formats::messages::AllowedCaller;
use crate::formats::messages::BashToolName;
use crate::formats::messages::{BrowserToolsetConfigs, BuiltinMessagesTool};
use crate::formats::messages::{CitationsConfig, ClientTool};
use crate::formats::messages::CodeExecutionToolName;
use crate::formats::messages::{
ComputerTool, ComputerTool20251124, ComputerToolName, ComputerToolsetConfigs,
};
use crate::formats::messages::{CustomTool, CustomToolType};
use crate::formats::messages::McpListedTool;
use crate::formats::messages::{McpToolset, MemoryToolName};
use crate::formats::messages::MessagesToolParam;
use crate::formats::messages::ResponseInclusion;
use crate::formats::messages::ServerTool;
use crate::formats::messages::{StrReplaceBasedEditToolName, StrReplaceEditorName};
use crate::formats::messages::TextEditorTool20250728;
use crate::formats::messages::{
ToolChoice, ToolChoiceType, ToolResultUrlSource, ToolSearchBm25ToolName,
};
use crate::formats::messages::ToolSearchRegexToolName;
use crate::formats::messages::{Toolset, ToolsetToolConfig};
use crate::formats::messages::{UserInputUrlSource, UserLocationType};
use crate::formats::messages::{
WebFetchTool, WebFetchTool20260309, WebFetchTool20260318, WebFetchToolName,
};
use crate::formats::messages::{
WebFetchUrlSources, WebSearchTool, WebSearchTool20260318, WebSearchToolName,
};
use crate::{
formats::messages::WebSearchUserLocation,
json_schema::{JsonSchema, JsonSchemaObject, JsonSchemaType},
};
use crate::test_support::*;
use indexmap::IndexMap;
use rstest::rstest;
use serde_json::{Value, json};
#[rstest]
fn tool_choice_round_trips() {
let choice = round_trip::<ToolChoice>(
json!({"type":"tool","name":"search","disable_parallel_tool_use":true}),
);
assert_eq!(choice.choice_type, ToolChoiceType::Tool);
assert_eq!(choice.name.as_deref(), Some("search"));
assert_eq!(choice.disable_parallel_tool_use, Some(true));
assert!(choice.extra.is_empty());
}
fn client_tool<N>(name: N) -> ClientTool<N> {
ClientTool {
name,
allowed_callers: None,
cache_control: None,
defer_loading: None,
input_examples: None,
strict: None,
extra: Map::new(),
}
}
fn server_tool<N>(name: N) -> ServerTool<N> {
ServerTool {
name,
allowed_callers: None,
cache_control: None,
defer_loading: None,
strict: None,
extra: Map::new(),
}
}
fn computer_tool(display_width_px: u64, display_height_px: u64) -> ComputerTool {
ComputerTool {
name: ComputerToolName::Computer,
display_width_px,
display_height_px,
display_number: None,
allowed_callers: None,
cache_control: None,
defer_loading: None,
input_examples: None,
strict: None,
extra: Map::new(),
}
}
fn web_search_tool() -> WebSearchTool {
WebSearchTool {
name: WebSearchToolName::WebSearch,
allowed_callers: None,
allowed_domains: None,
blocked_domains: None,
cache_control: None,
defer_loading: None,
max_uses: None,
strict: None,
user_location: None,
extra: Map::new(),
}
}
fn web_fetch_tool() -> WebFetchTool {
WebFetchTool {
name: WebFetchToolName::WebFetch,
allowed_callers: None,
allowed_domains: None,
blocked_domains: None,
cache_control: None,
citations: None,
defer_loading: None,
max_content_tokens: None,
max_uses: None,
strict: None,
url_sources: None,
extra: Map::new(),
}
}
fn web_fetch_url_sources() -> WebFetchUrlSources {
WebFetchUrlSources {
client_tool_results: None,
server_tool_results: None,
user_input: None,
extra: Map::new(),
}
}
fn toolset_config(enabled: Option<bool>, defer_loading: Option<bool>) -> ToolsetToolConfig {
ToolsetToolConfig {
defer_loading,
enabled,
extra: Map::new(),
}
}
fn browser_toolset_configs() -> BrowserToolsetConfigs {
BrowserToolsetConfigs {
type_text: None,
close_tab: None,
double_click: None,
file_upload: None,
find: None,
form_input: None,
get_page_text: None,
hold_key: None,
hover: None,
javascript_exec: None,
key: None,
left_click: None,
left_click_drag: None,
left_mouse_down: None,
left_mouse_up: None,
list_tabs: None,
middle_click: None,
mouse_move: None,
navigate: None,
new_tab: None,
read_console: None,
read_network: None,
read_page: None,
right_click: None,
screenshot: None,
scroll: None,
scroll_to: None,
switch_tab: None,
triple_click: None,
wait: None,
zoom: None,
extra: Map::new(),
}
}
fn computer_toolset_configs() -> ComputerToolsetConfigs {
ComputerToolsetConfigs {
type_text: None,
cursor_position: None,
double_click: None,
hold_key: None,
key: None,
left_click: None,
left_click_drag: None,
left_mouse_down: None,
left_mouse_up: None,
middle_click: None,
mouse_move: None,
right_click: None,
screenshot: None,
scroll: None,
triple_click: None,
wait: None,
zoom: None,
extra: Map::new(),
}
}
#[rstest]
#[case::bash_20241022(
json!({"type":"bash_20241022","name":"bash","input_examples":[{"command":"ls"}]}),
BuiltinMessagesTool::Bash20241022(ClientTool {
input_examples: Some(vec![Map::from_iter([(String::from("command"), json!("ls"))])]),
..client_tool(BashToolName::Bash)
})
)]
#[case::bash_20250124(
json!({"type":"bash_20250124","name":"bash","allowed_callers":["direct","code_execution_20260521"]}),
BuiltinMessagesTool::Bash20250124(ClientTool {
allowed_callers: Some(vec![AllowedCaller::Direct, AllowedCaller::CodeExecution20260521]),
..client_tool(BashToolName::Bash)
})
)]
#[case::text_editor_20241022(
json!({"type":"text_editor_20241022","name":"str_replace_editor","defer_loading":true}),
BuiltinMessagesTool::TextEditor20241022(ClientTool {
defer_loading: Some(true),
..client_tool(StrReplaceEditorName::StrReplaceEditor)
})
)]
#[case::text_editor_20250124(
json!({"type":"text_editor_20250124","name":"str_replace_editor","strict":true}),
BuiltinMessagesTool::TextEditor20250124(ClientTool {
strict: Some(true),
..client_tool(StrReplaceEditorName::StrReplaceEditor)
})
)]
#[case::text_editor_20250429(
json!({"type":"text_editor_20250429","name":"str_replace_based_edit_tool","cache_control":{"type":"ephemeral","ttl":"1h"}}),
BuiltinMessagesTool::TextEditor20250429(ClientTool {
cache_control: Some(cache_control("ephemeral", "1h")),
..client_tool(StrReplaceBasedEditToolName::StrReplaceBasedEditTool)
})
)]
#[case::text_editor_20250728(
json!({"type":"text_editor_20250728","name":"str_replace_based_edit_tool","max_characters":10000}),
BuiltinMessagesTool::TextEditor20250728(TextEditorTool20250728 {
name: StrReplaceBasedEditToolName::StrReplaceBasedEditTool,
allowed_callers: None,
cache_control: None,
defer_loading: None,
input_examples: None,
max_characters: Some(10000),
strict: None,
extra: Map::new(),
})
)]
#[case::memory_20250818(
json!({"type":"memory_20250818","name":"memory","allowed_callers":["code_execution_20250825"]}),
BuiltinMessagesTool::Memory20250818(ClientTool {
allowed_callers: Some(vec![AllowedCaller::CodeExecution20250825]),
..client_tool(MemoryToolName::Memory)
})
)]
#[case::computer_20241022(
json!({"type":"computer_20241022","name":"computer","display_width_px":1024,"display_height_px":768,"display_number":1}),
BuiltinMessagesTool::Computer20241022(ComputerTool {
display_number: Some(1),
..computer_tool(1024, 768)
})
)]
#[case::computer_20250124(
json!({"type":"computer_20250124","name":"computer","display_width_px":1280,"display_height_px":800}),
BuiltinMessagesTool::Computer20250124(computer_tool(1280, 800))
)]
#[case::computer_20251124(
json!({"type":"computer_20251124","name":"computer","display_width_px":1920,"display_height_px":1080,"enable_zoom":true}),
BuiltinMessagesTool::Computer20251124(ComputerTool20251124 {
name: ComputerToolName::Computer,
display_width_px: 1920,
display_height_px: 1080,
display_number: None,
enable_zoom: Some(true),
allowed_callers: None,
cache_control: None,
defer_loading: None,
input_examples: None,
strict: None,
extra: Map::new(),
})
)]
#[case::code_execution_20250522(
json!({"type":"code_execution_20250522","name":"code_execution","strict":false}),
BuiltinMessagesTool::CodeExecution20250522(ServerTool {
strict: Some(false),
..server_tool(CodeExecutionToolName::CodeExecution)
})
)]
#[case::code_execution_20250825(
json!({"type":"code_execution_20250825","name":"code_execution","defer_loading":false}),
BuiltinMessagesTool::CodeExecution20250825(ServerTool {
defer_loading: Some(false),
..server_tool(CodeExecutionToolName::CodeExecution)
})
)]
#[case::code_execution_20260120(
json!({"type":"code_execution_20260120","name":"code_execution","allowed_callers":["direct"]}),
BuiltinMessagesTool::CodeExecution20260120(ServerTool {
allowed_callers: Some(vec![AllowedCaller::Direct]),
..server_tool(CodeExecutionToolName::CodeExecution)
})
)]
#[case::code_execution_20260521(
json!({"type":"code_execution_20260521","name":"code_execution","allowed_callers":["code_execution_20260120"]}),
BuiltinMessagesTool::CodeExecution20260521(ServerTool {
allowed_callers: Some(vec![AllowedCaller::CodeExecution20260120]),
..server_tool(CodeExecutionToolName::CodeExecution)
})
)]
#[case::tool_search_regex_20251119(
json!({"type":"tool_search_tool_regex_20251119","name":"tool_search_tool_regex","defer_loading":true}),
BuiltinMessagesTool::ToolSearchRegex20251119(ServerTool {
defer_loading: Some(true),
..server_tool(ToolSearchRegexToolName::ToolSearchToolRegex)
})
)]
#[case::tool_search_regex(
json!({"type":"tool_search_tool_regex","name":"tool_search_tool_regex","strict":true}),
BuiltinMessagesTool::ToolSearchRegex(ServerTool {
strict: Some(true),
..server_tool(ToolSearchRegexToolName::ToolSearchToolRegex)
})
)]
#[case::tool_search_bm25_20251119(
json!({"type":"tool_search_tool_bm25_20251119","name":"tool_search_tool_bm25","defer_loading":false}),
BuiltinMessagesTool::ToolSearchBm2520251119(ServerTool {
defer_loading: Some(false),
..server_tool(ToolSearchBm25ToolName::ToolSearchToolBm25)
})
)]
#[case::tool_search_bm25(
json!({"type":"tool_search_tool_bm25","name":"tool_search_tool_bm25","strict":false}),
BuiltinMessagesTool::ToolSearchBm25(ServerTool {
strict: Some(false),
..server_tool(ToolSearchBm25ToolName::ToolSearchToolBm25)
})
)]
#[case::web_search_20250305(
json!({"type":"web_search_20250305","name":"web_search","max_uses":3,"allowed_domains":["example.test"],
"user_location":{"type":"approximate","city":"San Francisco","country":"US","timezone":"America/Los_Angeles"}}),
BuiltinMessagesTool::WebSearch20250305(WebSearchTool {
max_uses: Some(3),
allowed_domains: Some(vec![String::from("example.test")]),
user_location: Some(WebSearchUserLocation {
location_type: UserLocationType::Approximate,
city: Some(String::from("San Francisco")),
country: Some(String::from("US")),
region: None,
timezone: Some(String::from("America/Los_Angeles")),
extra: Map::new(),
}),
..web_search_tool()
})
)]
#[case::web_search_20260209(
json!({"type":"web_search_20260209","name":"web_search","blocked_domains":["blocked.test"]}),
BuiltinMessagesTool::WebSearch20260209(WebSearchTool {
blocked_domains: Some(vec![String::from("blocked.test")]),
..web_search_tool()
})
)]
#[case::web_search_20260318(
json!({"type":"web_search_20260318","name":"web_search","response_inclusion":"excluded"}),
BuiltinMessagesTool::WebSearch20260318(WebSearchTool20260318 {
name: WebSearchToolName::WebSearch,
allowed_callers: None,
allowed_domains: None,
blocked_domains: None,
cache_control: None,
defer_loading: None,
max_uses: None,
response_inclusion: Some(ResponseInclusion::Excluded),
strict: None,
user_location: None,
extra: Map::new(),
})
)]
#[case::web_fetch_20250910(
json!({"type":"web_fetch_20250910","name":"web_fetch","max_content_tokens":5000,"citations":{"enabled":true},
"url_sources":{"client_tool_results":{"type":"only","tools":[{"type":"tool_reference","name":"lookup"}]},
"server_tool_results":{"type":"all"},"user_input":{"type":"none"}}}),
BuiltinMessagesTool::WebFetch20250910(WebFetchTool {
max_content_tokens: Some(5000),
citations: Some(CitationsConfig {
enabled: Some(true),
extra: Map::new(),
}),
url_sources: Some(WebFetchUrlSources {
client_tool_results: Some(ToolResultUrlSource::Only {
tools: vec![tool_reference("lookup")],
extra: Map::new(),
}),
server_tool_results: Some(ToolResultUrlSource::All { extra: Map::new() }),
user_input: Some(UserInputUrlSource::None { extra: Map::new() }),
extra: Map::new(),
}),
..web_fetch_tool()
})
)]
#[case::web_fetch_20260209(
json!({"type":"web_fetch_20260209","name":"web_fetch","url_sources":{"server_tool_results":{"type":"except","tools":[{"type":"tool_reference","name":"web_search"}]}}}),
BuiltinMessagesTool::WebFetch20260209(WebFetchTool {
url_sources: Some(WebFetchUrlSources {
server_tool_results: Some(ToolResultUrlSource::Except {
tools: vec![tool_reference("web_search")],
extra: Map::new(),
}),
..web_fetch_url_sources()
}),
..web_fetch_tool()
})
)]
#[case::web_fetch_20260309(
json!({"type":"web_fetch_20260309","name":"web_fetch","use_cache":false}),
BuiltinMessagesTool::WebFetch20260309(WebFetchTool20260309 {
name: WebFetchToolName::WebFetch,
allowed_callers: None,
allowed_domains: None,
blocked_domains: None,
cache_control: None,
citations: None,
defer_loading: None,
max_content_tokens: None,
max_uses: None,
strict: None,
url_sources: None,
use_cache: Some(false),
extra: Map::new(),
})
)]
#[case::web_fetch_20260318(
json!({"type":"web_fetch_20260318","name":"web_fetch","use_cache":true,"response_inclusion":"full"}),
BuiltinMessagesTool::WebFetch20260318(WebFetchTool20260318 {
name: WebFetchToolName::WebFetch,
allowed_callers: None,
allowed_domains: None,
blocked_domains: None,
cache_control: None,
citations: None,
defer_loading: None,
max_content_tokens: None,
max_uses: None,
response_inclusion: Some(ResponseInclusion::Full),
strict: None,
url_sources: None,
use_cache: Some(true),
extra: Map::new(),
})
)]
#[case::advisor_20260301(
json!({"type":"advisor_20260301","name":"advisor","model":"claude-opus-5-5","max_tokens":2048,"caching":{"type":"ephemeral","ttl":"5m"}}),
BuiltinMessagesTool::Advisor20260301(AdvisorTool {
name: AdvisorToolName::Advisor,
model: String::from("claude-opus-5-5"),
allowed_callers: None,
cache_control: None,
caching: Some(cache_control("ephemeral", "5m")),
defer_loading: None,
max_tokens: Some(2048),
max_uses: None,
strict: None,
extra: Map::new(),
})
)]
#[case::browser_toolset_20260801(
json!({"type":"browser_toolset_20260801","configs":{"type":{"enabled":false},"javascript_exec":{"defer_loading":true}}}),
BuiltinMessagesTool::BrowserToolset20260801(Toolset {
cache_control: None,
configs: Some(Box::new(BrowserToolsetConfigs {
type_text: Some(toolset_config(Some(false), None)),
javascript_exec: Some(toolset_config(None, Some(true))),
..browser_toolset_configs()
})),
extra: Map::new(),
})
)]
#[case::computer_toolset_20260801(
json!({"type":"computer_toolset_20260801","configs":{"zoom":{"enabled":false},"cursor_position":{"enabled":true}}}),
BuiltinMessagesTool::ComputerToolset20260801(Toolset {
cache_control: None,
configs: Some(Box::new(ComputerToolsetConfigs {
zoom: Some(toolset_config(Some(false), None)),
cursor_position: Some(toolset_config(Some(true), None)),
..computer_toolset_configs()
})),
extra: Map::new(),
})
)]
#[case::mcp_toolset(
json!({"type":"mcp_toolset","mcp_server_name":"kb","default_config":{"enabled":false},
"configs":{"search":{"enabled":true,"defer_loading":true}},
"tools":[{"name":"search","input_schema":{"type":"object"},"description":"Search"}]}),
BuiltinMessagesTool::McpToolset(McpToolset {
mcp_server_name: String::from("kb"),
cache_control: None,
configs: Some(IndexMap::from([(String::from("search"), toolset_config(Some(true), Some(true)))])),
default_config: Some(toolset_config(Some(false), None)),
tools: Some(vec![McpListedTool {
name: String::from("search"),
description: Some(String::from("Search")),
input_schema: JsonSchema::Object(Box::new(JsonSchemaObject {
schema_type: Some(JsonSchemaType::Name(String::from("object"))),
..JsonSchemaObject::default()
})),
extra: Map::new(),
}]),
extra: Map::new(),
})
)]
fn builtin_tools_decode_typed_definitions(
#[case] wire: Value,
#[case] expected: BuiltinMessagesTool,
) {
assert_eq!(round_trip::<BuiltinMessagesTool>(wire), expected);
}
#[rstest]
fn builtin_tools_preserve_unknown_fields() {
let tool = round_trip::<BuiltinMessagesTool>(
json!({"type":"bash_20250124","name":"bash","extension":[1,null]}),
);
let BuiltinMessagesTool::Bash20250124(bash) = tool else {
panic!("expected bash tool");
};
assert_eq!(bash.extra.get("extension"), Some(&json!([1, null])));
}
#[rstest]
#[case::missing_discriminator(json!({"name":"bash"}))]
#[case::unknown_discriminator(json!({"type":"future_tool","name":"bash"}))]
#[case::null_discriminator(json!({"type":null,"name":"bash"}))]
#[case::bash_without_name(json!({"type":"bash_20250124"}))]
#[case::bash_wrong_name(json!({"type":"bash_20241022","name":"shell"}))]
#[case::legacy_editor_with_new_name(json!({"type":"text_editor_20250124","name":"str_replace_based_edit_tool"}))]
#[case::new_editor_with_legacy_name(json!({"type":"text_editor_20250728","name":"str_replace_editor"}))]
#[case::editor_20250429_with_legacy_name(json!({"type":"text_editor_20250429","name":"str_replace_editor"}))]
#[case::memory_without_name(json!({"type":"memory_20250818"}))]
#[case::computer_without_width(json!({"type":"computer_20250124","name":"computer","display_height_px":768}))]
#[case::computer_without_height(json!({"type":"computer_20241022","name":"computer","display_width_px":1024}))]
#[case::zoom_computer_without_display(json!({"type":"computer_20251124","name":"computer"}))]
#[case::code_execution_without_name(json!({"type":"code_execution_20250825"}))]
#[case::regex_search_with_bm25_name(json!({"type":"tool_search_tool_regex","name":"tool_search_tool_bm25"}))]
#[case::bm25_search_with_regex_name(json!({"type":"tool_search_tool_bm25_20251119","name":"tool_search_tool_regex"}))]
#[case::web_search_with_fetch_name(json!({"type":"web_search_20250305","name":"web_fetch"}))]
#[case::web_fetch_without_name(json!({"type":"web_fetch_20260318"}))]
#[case::advisor_without_model(json!({"type":"advisor_20260301","name":"advisor"}))]
#[case::advisor_without_name(json!({"type":"advisor_20260301","model":"claude-opus-5-5"}))]
#[case::mcp_toolset_without_server(json!({"type":"mcp_toolset"}))]
#[case::unknown_allowed_caller(json!({"type":"code_execution_20250825","name":"code_execution","allowed_callers":["code_execution_20250522"]}))]
#[case::unknown_response_inclusion(json!({"type":"web_search_20260318","name":"web_search","response_inclusion":"partial"}))]
#[case::user_input_only_filter(json!({"type":"web_fetch_20250910","name":"web_fetch","url_sources":{"user_input":{"type":"only","tools":[]}}}))]
#[case::negative_limit(json!({"type":"web_search_20250305","name":"web_search","max_uses":-1}))]
#[case::wrong_mcp_config(json!({"type":"mcp_toolset","mcp_server_name":"kb","configs":{"search":{"enabled":"yes"}}}))]
#[case::wrong_browser_config(json!({"type":"browser_toolset_20260801","configs":{"navigate":{"enabled":1}}}))]
fn builtin_tools_reject_malformed_fields(#[case] wire: Value) {
assert!(serde_json::from_value::<BuiltinMessagesTool>(wire.clone()).is_err());
assert!(serde_json::from_value::<MessagesToolParam>(wire).is_err());
}
#[rstest]
fn custom_tool_exposes_schema_and_preserves_extensions() {
let tool = round_trip::<CustomTool>(json!({
"type":"custom",
"name":"lookup",
"input_schema":{"type":"object","properties":{"query":{"type":"string"}}},
"strict":false,
"defer_loading":true,
"allowed_callers":["direct","code_execution_20260120"],
"extension":{"nested":[1,null]}
}));
assert_eq!(
tool.allowed_callers.as_deref(),
Some([AllowedCaller::Direct, AllowedCaller::CodeExecution20260120].as_slice())
);
assert_eq!(tool.tool_type, Some(CustomToolType::Custom));
assert_eq!(tool.name, "lookup");
assert_eq!((tool.strict, tool.defer_loading), (Some(false), Some(true)));
let JsonSchema::Object(schema) = &tool.input_schema else {
panic!("expected an object schema");
};
assert!(schema.properties.as_ref().unwrap().contains_key("query"));
assert_eq!(tool.extra["extension"], json!({"nested":[1,null]}));
}
#[rstest]
fn custom_tool_omits_null_discriminator() {
let tool: CustomTool =
serde_json::from_value(json!({"type":null,"name":"lookup","input_schema":true}))
.unwrap();
assert!(tool.tool_type.is_none());
assert!(tool.description.is_none());
assert_eq!(
serde_json::to_value(tool).unwrap(),
json!({"name":"lookup","input_schema":true})
);
}
#[rstest]
#[case::missing_name(json!({"input_schema":{}}))]
#[case::missing_schema(json!({"name":"lookup"}))]
#[case::wrong_name_shape(json!({"name":7,"input_schema":{}}))]
#[case::builtin_tool(json!({"name":"lookup","input_schema":{},"type":"bash_20250124"}))]
#[case::unknown_discriminator(json!({"name":"lookup","input_schema":{},"type":"future_tool"}))]
#[case::unknown_allowed_caller(json!({"name":"lookup","input_schema":{},"allowed_callers":["anyone"]}))]
fn custom_tool_rejects_invalid_shapes(#[case] wire: Value) {
assert!(serde_json::from_value::<CustomTool>(wire).is_err());
}
#[rstest]
#[case::builtin(json!({"type":"web_search_20250305","name":"web_search","max_uses":2}), true)]
#[case::builtin_toolset(json!({"type":"mcp_toolset","mcp_server_name":"kb"}), true)]
#[case::custom(json!({"name":"lookup","input_schema":{"type":"object"}}), false)]
fn tool_params_dispatch_on_discriminator(#[case] wire: Value, #[case] builtin: bool) {
let tool = round_trip::<MessagesToolParam>(wire);
assert_eq!(matches!(tool, MessagesToolParam::Builtin(_)), builtin);
}
#[rstest]
fn tool_params_reject_unknown_tool_types() {
assert!(
serde_json::from_value::<MessagesToolParam>(
json!({"type":"future_tool","name":"lookup","input_schema":{}})
)
.is_err()
);
}
fn tool_reference(name: &str) -> UrlSourceToolReference {
UrlSourceToolReference::ToolReference {
name: String::from(name),
extra: Map::new(),
}
}
fn cache_control(cache_type: &str, ttl: &str) -> CacheControl {
CacheControl {
cache_type: Some(String::from(cache_type)),
ttl: Some(String::from(ttl)),
..CacheControl::default()
}
}
}

View file

@ -73,3 +73,122 @@ pub struct MessagesOutputTokensDetails {
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[cfg(test)]
mod tests {
use crate::formats::messages::MessagesUsage;
use crate::formats::messages::UsageIterationType;
use crate::{recognized::Recognized, test_support::*};
use rstest::rstest;
use serde_json::json;
#[rstest]
fn usage_contracts_round_trip() {
let usage = round_trip::<MessagesUsage>(json!({
"input_tokens":10,
"output_tokens":4,
"server_tool_use":{"web_search_requests":2,"web_fetch_requests":1},
"cache_creation":{"ephemeral_1h_input_tokens":3,"ephemeral_5m_input_tokens":1},
"output_tokens_details":{"thinking_tokens":2},
"iterations":[
{"type":"compaction","input_tokens":7,"output_tokens":1},
{"type":"message","input_tokens":3,"output_tokens":3}
],
"service_tier":"priority",
"inference_geo":"global",
"speed":"fast"
}));
assert_eq!(usage.inference_geo.as_deref(), Some("global"));
assert_eq!(usage.input_tokens, Some(10));
assert_eq!(usage.output_tokens, Some(4));
assert!(usage.extra.is_empty());
let server = usage.server_tool_use.as_ref().unwrap();
assert_eq!(server.web_search_requests, Some(2));
assert_eq!(server.web_fetch_requests, Some(1));
let cache = usage.cache_creation.as_ref().unwrap();
assert_eq!(cache.ephemeral_1h_input_tokens, Some(3));
assert_eq!(cache.ephemeral_5m_input_tokens, Some(1));
assert_eq!(
usage
.output_tokens_details
.as_ref()
.unwrap()
.thinking_tokens,
Some(2)
);
let [compaction, message] = usage.iterations.as_ref().unwrap().as_slice() else {
panic!("expected usage iterations");
};
assert_eq!(
compaction.iteration_type,
Recognized::Known(UsageIterationType::Compaction)
);
assert_eq!(
message.iteration_type,
Recognized::Known(UsageIterationType::Message)
);
assert_eq!(
compaction.input_tokens.unwrap() + message.input_tokens.unwrap(),
usage.input_tokens.unwrap()
);
assert_eq!(
compaction.output_tokens.unwrap() + message.output_tokens.unwrap(),
usage.output_tokens.unwrap()
);
}
#[rstest]
fn usage_iterations_expose_advisor_and_fallback_models() {
let usage = round_trip::<MessagesUsage>(json!({
"iterations":[
{"type":"advisor_message","model":"claude-opus-5-5","input_tokens":5,"output_tokens":2,
"cache_creation_input_tokens":4,"cache_read_input_tokens":1,
"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":4}},
{"type":"fallback_message","model":"claude-sonnet-5-5","input_tokens":6,"output_tokens":3,
"cache_creation_input_tokens":0,"cache_read_input_tokens":2},
{"type":"future_iteration","input_tokens":1}
]
}));
let [advisor, fallback, future] = usage.iterations.as_deref().unwrap() else {
panic!("expected three iterations");
};
assert_eq!(
advisor.iteration_type,
Recognized::Known(UsageIterationType::AdvisorMessage)
);
assert_eq!(advisor.model.as_deref(), Some("claude-opus-5-5"));
assert_eq!(
(
advisor.cache_creation_input_tokens,
advisor.cache_read_input_tokens
),
(Some(4), Some(1))
);
assert_eq!(
advisor
.cache_creation
.as_ref()
.unwrap()
.ephemeral_5m_input_tokens,
advisor.cache_creation_input_tokens
);
assert!(advisor.extra.is_empty());
assert_eq!(
fallback.iteration_type,
Recognized::Known(UsageIterationType::FallbackMessage)
);
assert_eq!(fallback.model.as_deref(), Some("claude-sonnet-5-5"));
assert_eq!(fallback.cache_read_input_tokens, Some(2));
assert!(fallback.extra.is_empty());
assert_eq!(
future.iteration_type,
Recognized::Unrecognized(json!("future_iteration"))
);
assert_eq!(future.input_tokens, Some(1));
}
}

View file

@ -1,6 +1,3 @@
//! Wire types for each provider API format (OpenAI Chat Completions,
//! Anthropic Messages, Responses, ...), one submodule per format.
pub mod audio_transcription;
pub mod batches;
pub mod chat_completions;

View file

@ -1,164 +0,0 @@
use std::collections::BTreeMap;
use serde_json::{Map, Value};
use serde_with::serde_as;
use crate::serde_compat::{FiniteF64, LaxI64};
#[macro_rules_attribute::apply(crate::wire_type)]
#[serde(tag = "type")]
pub enum OcrDocument {
#[serde(rename = "document_url")]
DocumentUrl {
document_url: String,
#[serde(flatten)]
extra_fields: BTreeMap<String, Option<String>>,
},
#[serde(rename = "image_url")]
ImageUrl {
image_url: String,
#[serde(flatten)]
extra_fields: BTreeMap<String, Option<String>>,
},
}
impl OcrDocument {
pub fn source(&self) -> &str {
match self {
Self::DocumentUrl { document_url, .. } => document_url,
Self::ImageUrl { image_url, .. } => image_url,
}
}
pub fn is_remote(&self) -> bool {
let source = self.source();
source.starts_with("http://") || source.starts_with("https://")
}
pub fn with_source(self, source: String) -> Self {
match self {
Self::DocumentUrl { extra_fields, .. } => Self::DocumentUrl {
document_url: source,
extra_fields,
},
Self::ImageUrl { extra_fields, .. } => Self::ImageUrl {
image_url: source,
extra_fields,
},
}
}
}
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Copy, Default, Eq)]
#[serde(rename_all = "lowercase")]
pub enum OcrResponseFormat {
#[default]
Litellm,
Native,
}
#[serde_as]
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct OcrPageDimensions {
#[serde_as(deserialize_as = "Option<LaxI64>")]
pub dpi: Option<i64>,
#[serde_as(deserialize_as = "Option<LaxI64>")]
pub height: Option<i64>,
#[serde_as(deserialize_as = "Option<LaxI64>")]
pub width: Option<i64>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct OcrPageImage {
pub image_base64: Option<String>,
pub bbox: Option<Map<String, Value>>,
#[serde(flatten)]
pub extra_fields: Map<String, Value>,
}
#[serde_as]
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct OcrPage {
#[serde_as(deserialize_as = "LaxI64")]
pub index: i64,
pub markdown: String,
pub images: Option<Vec<OcrPageImage>>,
pub dimensions: Option<OcrPageDimensions>,
#[serde(flatten)]
pub extra_fields: Map<String, Value>,
}
#[serde_as]
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct OcrUsageInfo {
#[serde_as(deserialize_as = "Option<LaxI64>")]
pub pages_processed: Option<i64>,
#[serde_as(deserialize_as = "Option<LaxI64>")]
pub pages_processed_annotation: Option<i64>,
#[serde_as(deserialize_as = "Option<FiniteF64>")]
pub credits: Option<f64>,
#[serde_as(deserialize_as = "Option<LaxI64>")]
pub doc_size_bytes: Option<i64>,
#[serde(flatten)]
pub extra_fields: Map<String, Value>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct LiteLLMOcrResponse {
pub pages: Vec<OcrPage>,
pub model: String,
pub document_annotation: Option<Value>,
pub usage_info: Option<OcrUsageInfo>,
pub content: Option<String>,
pub tables: Option<Vec<Map<String, Value>>>,
#[serde(rename = "keyValuePairs")]
pub key_value_pairs: Option<Vec<Map<String, Value>>>,
#[serde(default = "ocr_object")]
pub object: String,
#[serde(flatten)]
pub extra_fields: Map<String, Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_native_response: Option<Map<String, Value>>,
}
impl LiteLLMOcrResponse {
pub fn new(model: impl Into<String>, pages: Vec<OcrPage>) -> Self {
Self {
pages,
model: model.into(),
document_annotation: None,
usage_info: None,
content: None,
tables: None,
key_value_pairs: None,
object: ocr_object(),
extra_fields: Map::new(),
provider_native_response: None,
}
}
pub fn into_json(self) -> Value {
serde_json::to_value(self).expect("OCR response fields are JSON-compatible")
}
}
fn ocr_object() -> String {
"ocr".into()
}
#[serde_with::skip_serializing_none]
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct OcrBoundingBox {
pub top_left_x: Option<serde_json::Number>,
pub top_left_y: Option<serde_json::Number>,
pub bottom_right_x: Option<serde_json::Number>,
pub bottom_right_y: Option<serde_json::Number>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}

View file

@ -0,0 +1,12 @@
# rules
- LiteLLM's OCR contract follows the Mistral OCR response shape. It is the normalized output every OCR provider returns, not a Mistral type
- Mistral, and Mistral models hosted on Azure AI and Vertex AI, speak it natively
- Cohere, Reducto, AWS Textract, Azure Document Intelligence and DeepSeek OCR on Vertex translate their native output into it in `llms/src/<provider>/ocr/`
- `OcrBoundingBox` is the corner coordinates providers copy into the normalized `bbox`
- Normalized `tables` and `keyValuePairs` stay open because providers pass through different native shapes
- `BaseOcrConfig`, OCR errors and inline-document helpers live in `llms/src/base_llm/ocr/`
# references
- https://docs.mistral.ai/api/endpoint/ocr

View file

@ -0,0 +1,5 @@
mod request;
mod response;
pub use request::*;
pub use response::*;

View file

@ -0,0 +1,95 @@
use std::collections::BTreeMap;
#[macro_rules_attribute::apply(crate::wire_type)]
#[serde(tag = "type")]
pub enum OcrDocument {
#[serde(rename = "document_url")]
DocumentUrl {
document_url: String,
#[serde(flatten)]
extra_fields: BTreeMap<String, Option<String>>,
},
#[serde(rename = "image_url")]
ImageUrl {
image_url: String,
#[serde(flatten)]
extra_fields: BTreeMap<String, Option<String>>,
},
}
impl OcrDocument {
pub fn source(&self) -> &str {
match self {
Self::DocumentUrl { document_url, .. } => document_url,
Self::ImageUrl { image_url, .. } => image_url,
}
}
pub fn is_remote(&self) -> bool {
let source = self.source();
source.starts_with("http://") || source.starts_with("https://")
}
pub fn with_source(self, source: String) -> Self {
match self {
Self::DocumentUrl { extra_fields, .. } => Self::DocumentUrl {
document_url: source,
extra_fields,
},
Self::ImageUrl { extra_fields, .. } => Self::ImageUrl {
image_url: source,
extra_fields,
},
}
}
}
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Copy, Default, Eq)]
#[serde(rename_all = "lowercase")]
pub enum OcrResponseFormat {
#[default]
Litellm,
Native,
}
#[cfg(test)]
mod tests {
use crate::formats::ocr::OcrDocument;
use rstest::rstest;
use serde_json::{Value, json};
#[rstest]
fn document_rejects_non_string_provider_fields() {
assert!(
serde_json::from_value::<OcrDocument>(json!({
"type": "image_url", "image_url": "https://example.com/image", "detail": 42
}))
.is_err()
);
}
#[rstest]
#[case::document_url("document_url", "document_name", "application/pdf")]
#[case::image_url("image_url", "detail", "image/png")]
fn document_variants_preserve_provider_fields_when_rewriting_sources(
#[case] kind: &str,
#[case] field: &str,
#[case] mime_type: &str,
#[values(json!("kept"), Value::Null)] extra: Value,
) {
let original = "https://example.com/input";
let replacement = format!("data:{mime_type};base64,AA==");
let document: OcrDocument =
serde_json::from_value(json!({"type": kind, kind: original, field: extra})).unwrap();
assert_eq!(document.source(), original);
assert!(document.is_remote());
let rewritten = document.with_source(replacement.clone());
assert!(!rewritten.is_remote());
assert_eq!(
serde_json::to_value(rewritten).unwrap(),
json!({"type": kind, kind: replacement, field: extra})
);
}
}

View file

@ -0,0 +1,224 @@
use serde_json::{Map, Value};
use serde_with::serde_as;
use crate::serde_compat::{FiniteF64, LaxI64};
#[serde_as]
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct OcrPageDimensions {
#[serde_as(deserialize_as = "Option<LaxI64>")]
pub dpi: Option<i64>,
#[serde_as(deserialize_as = "Option<LaxI64>")]
pub height: Option<i64>,
#[serde_as(deserialize_as = "Option<LaxI64>")]
pub width: Option<i64>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct OcrPageImage {
pub image_base64: Option<String>,
pub bbox: Option<Map<String, Value>>,
#[serde(flatten)]
pub extra_fields: Map<String, Value>,
}
#[serde_as]
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct OcrPage {
#[serde_as(deserialize_as = "LaxI64")]
pub index: i64,
pub markdown: String,
pub images: Option<Vec<OcrPageImage>>,
pub dimensions: Option<OcrPageDimensions>,
#[serde(flatten)]
pub extra_fields: Map<String, Value>,
}
#[serde_as]
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct OcrUsageInfo {
#[serde_as(deserialize_as = "Option<LaxI64>")]
pub pages_processed: Option<i64>,
#[serde_as(deserialize_as = "Option<LaxI64>")]
pub pages_processed_annotation: Option<i64>,
#[serde_as(deserialize_as = "Option<FiniteF64>")]
pub credits: Option<f64>,
#[serde_as(deserialize_as = "Option<LaxI64>")]
pub doc_size_bytes: Option<i64>,
#[serde(flatten)]
pub extra_fields: Map<String, Value>,
}
#[macro_rules_attribute::apply(crate::wire_type)]
pub struct LiteLLMOcrResponse {
pub pages: Vec<OcrPage>,
pub model: String,
pub document_annotation: Option<Value>,
pub usage_info: Option<OcrUsageInfo>,
pub content: Option<String>,
pub tables: Option<Vec<Map<String, Value>>>,
#[serde(rename = "keyValuePairs")]
pub key_value_pairs: Option<Vec<Map<String, Value>>>,
#[serde(default = "ocr_object")]
pub object: String,
#[serde(flatten)]
pub extra_fields: Map<String, Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_native_response: Option<Map<String, Value>>,
}
impl LiteLLMOcrResponse {
pub fn new(model: impl Into<String>, pages: Vec<OcrPage>) -> Self {
Self {
pages,
model: model.into(),
document_annotation: None,
usage_info: None,
content: None,
tables: None,
key_value_pairs: None,
object: ocr_object(),
extra_fields: Map::new(),
provider_native_response: None,
}
}
pub fn into_json(self) -> Value {
serde_json::to_value(self).expect("OCR response fields are JSON-compatible")
}
}
fn ocr_object() -> String {
"ocr".into()
}
#[serde_with::skip_serializing_none]
#[macro_rules_attribute::apply(crate::wire_type)]
#[derive(Default)]
pub struct OcrBoundingBox {
pub top_left_x: Option<serde_json::Number>,
pub top_left_y: Option<serde_json::Number>,
pub bottom_right_x: Option<serde_json::Number>,
pub bottom_right_y: Option<serde_json::Number>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[cfg(test)]
mod tests {
use crate::formats::ocr::{LiteLLMOcrResponse, OcrBoundingBox, OcrPage};
use rstest::rstest;
use serde_json::{Map, Value, json};
#[rstest]
#[case::missing_page_fields(json!({"pages": [{}]}))]
#[case::invalid_markdown(json!({"pages": [{"index": 0, "markdown": false}]}))]
#[case::invalid_image_bounds(json!({"pages": [{"index": 0, "markdown": "", "images": [{"bbox": []}]}]}))]
#[case::fractional_page_count(json!({"usage_info": {"pages_processed": 1.5}}))]
#[case::invalid_table(json!({"tables": [false]}))]
#[case::invalid_key_value_pair(json!({"keyValuePairs": [[]]}))]
#[case::invalid_native_response(json!({"provider_native_response": []}))]
fn normalized_response_rejects_invalid_shared_fields(#[case] fields: Value) {
let payload: Map<String, Value> = json!({"model": "model", "pages": []})
.as_object()
.unwrap()
.iter()
.chain(fields.as_object().unwrap())
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
assert!(serde_json::from_value::<LiteLLMOcrResponse>(Value::Object(payload)).is_err());
}
#[rstest]
#[case::large_integer(json!("9007199254740993.0"), 9_007_199_254_740_993)]
#[case::signed_decimal(json!("+2.000"), 2)]
#[case::separator(json!("1_000"), 1000)]
#[case::boolean(json!(true), 1)]
#[case::integral_float(json!(2.0), 2)]
fn numeric_coercion_preserves_integer_precision(#[case] value: Value, #[case] expected: i64) {
let page: OcrPage =
serde_json::from_value(json!({"index": value, "markdown": ""})).unwrap();
assert_eq!(page.index, expected);
assert_eq!(
serde_json::to_value(page).unwrap()["index"],
json!(expected)
);
}
#[rstest]
#[case::exponent(json!("1e2"))]
#[case::missing_integer(json!(".0"))]
#[case::missing_fraction(json!("2."))]
#[case::leading_separator(json!("_2"))]
#[case::repeated_separator(json!("2__0"))]
#[case::fractional_float(json!(2.5))]
#[case::null(json!(null))]
fn page_index_rejects_invalid_integers(#[case] value: Value) {
assert!(
serde_json::from_value::<OcrPage>(json!({"index": value, "markdown": ""})).is_err()
);
}
#[rstest]
#[case::absent_native(None)]
#[case::present_native(Some(Map::from_iter([("native".into(), json!({"nested": [null, 1]}))])))]
fn response_serialization_preserves_extensions_and_native_presence(
#[case] native: Option<Map<String, Value>>,
) {
let response = LiteLLMOcrResponse {
extra_fields: Map::from_iter([("provider_field".into(), json!("kept"))]),
provider_native_response: native.clone(),
..LiteLLMOcrResponse::new("model", vec![])
};
let serialized = response.into_json();
assert_eq!(serialized["provider_field"], "kept");
assert_eq!(
serialized.get("provider_native_response").cloned(),
native.clone().map(Value::Object)
);
let decoded: LiteLLMOcrResponse = serde_json::from_value(serialized.clone()).unwrap();
assert_eq!(decoded.provider_native_response, native);
assert_eq!(decoded.into_json(), serialized);
}
#[rstest]
fn bounding_box_exposes_corner_coordinates_and_keeps_extensions() {
let wire = json!({"top_left_x":1,"top_left_y":2.5,"bottom_right_x":30,"bottom_right_y":40,"future":true});
let bounds: OcrBoundingBox = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(bounds.top_left_x, Some(1.into()));
assert_eq!(
bounds
.top_left_y
.as_ref()
.and_then(serde_json::Number::as_f64),
Some(2.5)
);
assert_eq!(bounds.bottom_right_x, Some(30.into()));
assert_eq!(bounds.bottom_right_y, Some(40.into()));
assert_eq!(Value::Object(bounds.extra.clone()), json!({"future":true}));
assert_eq!(serde_json::to_value(bounds).unwrap(), wire);
}
#[rstest]
fn partial_bounding_box_omits_null_corners() {
let bounds: OcrBoundingBox =
serde_json::from_value(json!({"top_left_x":null,"bottom_right_y":4})).unwrap();
assert!(bounds.top_left_x.is_none());
assert_eq!(
serde_json::to_value(bounds).unwrap(),
json!({"bottom_right_y":4})
);
}
#[rstest]
#[case::string_corner(json!({"top_left_x":"1"}))]
#[case::array_corner(json!({"bottom_right_y":[4]}))]
fn bounding_box_rejects_non_numeric_corners(#[case] wire: Value) {
assert!(serde_json::from_value::<OcrBoundingBox>(wire).is_err());
}
}

View file

@ -0,0 +1,15 @@
# rules
- Responses originated at OpenAI and is now offered by other hosts and gateways. Types here describe the format, not OpenAI's deployment of it
- Served over HTTP and over the WebSocket mode. `ResponsesWsEvent` is a wire event and lives here
- `ResponsesWsTransformResult` wraps a provider transformation's output, so it lives in `llms/src/base_llm/responses/`
- Hosts that accept only part of the format, or add output item types, are handled in `llms/src/<provider>/responses/`. Keep unknown output items passing through instead of rejecting them
- Responses-to-Chat emulation and model-specific parameter rewriting are transformations, not format rules
# references
- https://developers.openai.com/api/reference/resources/responses
- https://developers.openai.com/api/reference/resources/responses/streaming-events
- https://github.com/openai/openai-python/blob/main/src/openai/types/responses/response.py
- https://github.com/openai/openai-python/blob/main/src/openai/types/responses/response_output_item.py
- https://github.com/openai/openai-python/blob/main/src/openai/types/responses/mcp_tool_call_error.py

View file

@ -3,4 +3,4 @@ mod response;
pub mod streaming_websocket;
pub use output::*;
pub use response::ResponsesApiResponse;
pub use response::*;

View file

@ -829,3 +829,584 @@ pub struct ResponsesMcpApprovalResponse {
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[cfg(test)]
mod tests {
use crate::formats::{
chat_completions::{PromptCacheBreakpoint, PromptCacheMode},
responses::{
ResponsesAdditionalTools, ResponsesApplyPatchCall, ResponsesApplyPatchCallOutput,
ResponsesApplyPatchOperation, ResponsesCodeOutput, ResponsesCompaction,
ResponsesComputerAction, ResponsesComputerCall, ResponsesComputerCallOutput,
ResponsesComputerOutput, ResponsesContentPart, ResponsesCoordinate,
ResponsesCustomToolCall, ResponsesCustomToolCallOutput, ResponsesFunctionCall,
ResponsesFunctionCallOutput, ResponsesImageGenerationCall, ResponsesInputContent,
ResponsesLocalShellAction, ResponsesLocalShellCall, ResponsesLocalShellCallOutput,
ResponsesMcpApprovalRequest, ResponsesMcpApprovalResponse, ResponsesMcpError,
ResponsesMcpErrorDetail, ResponsesOutputItem, ResponsesProgram, ResponsesProgramOutput,
ResponsesSafetyCheck, ResponsesShellAction, ResponsesShellCall,
ResponsesShellCallOutput, ResponsesShellEnvironment, ResponsesShellOutcome,
ResponsesShellOutputChunk, ResponsesToolCaller, ResponsesToolOutput,
ResponsesToolSearchCall, ResponsesToolSearchOutput, ResponsesWebSearchAction,
},
};
use rstest::rstest;
use serde_json::{Value, json};
#[rstest]
#[case::message(json!({"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer","annotations":[{"type":"url_citation","url":"https://example.test","start_index":0,"end_index":6}]}]}))]
#[case::function_call(json!({"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"query\":\"q\"}"}))]
#[case::custom_tool(json!({"type":"custom_tool_call","name":"lookup","input":"q"}))]
#[case::reasoning(json!({"type":"reasoning","summary":[{"type":"summary_text","text":"summary"}],"encrypted_content":"opaque"}))]
#[case::web_search(json!({"type":"web_search_call","action":{"type":"search","queries":["q"],"sources":[{"type":"url","url":"https://example.test"}]}}))]
#[case::file_search(json!({"type":"file_search_call","queries":["q"],"results":[{"file_id":"file_1","score":1,"attributes":{"custom":[1,null]}}]}))]
#[case::code(json!({"type":"code_interpreter_call","outputs":[{"type":"logs","logs":"done"},{"type":"image","url":"https://example.test"}]}))]
#[case::image(json!({"type":"image_generation_call","result":"generated"}))]
#[case::mcp(json!({"type":"mcp_call","server_label":"server","name":"lookup","arguments":"{}","output":"done"}))]
fn output_items_round_trip(#[case] wire: Value) {
let item: ResponsesOutputItem = serde_json::from_value(wire.clone()).unwrap();
match &item {
ResponsesOutputItem::Message(message) => {
assert_eq!(message.role.as_deref(), Some("assistant"));
let Some(content) = &message.content else {
panic!("expected content")
};
let [
ResponsesContentPart::OutputText {
text,
annotations: Some(annotations),
..
},
] = content.as_slice()
else {
panic!("expected output text and annotations")
};
assert_eq!(text, "answer");
let [crate::formats::responses::ResponsesAnnotation::UrlCitation(citation)] =
annotations.as_slice()
else {
panic!("expected URL citation")
};
assert_eq!(citation.url.as_deref(), Some("https://example.test"));
assert_eq!(citation.start_index, Some(0));
}
ResponsesOutputItem::FunctionCall(call) => {
assert_eq!(call.call_id.as_deref(), Some("call_1"));
assert_eq!(call.name.as_deref(), Some("lookup"));
assert_eq!(call.arguments.as_deref(), Some("{\"query\":\"q\"}"));
}
ResponsesOutputItem::CustomToolCall(call) => {
assert_eq!(call.name.as_deref(), Some("lookup"));
assert_eq!(call.input.as_deref(), Some("q"));
}
ResponsesOutputItem::Reasoning(reasoning) => {
assert_eq!(reasoning.encrypted_content.as_deref(), Some("opaque"));
let Some(summary) = &reasoning.summary else {
panic!("expected summary")
};
let [ResponsesContentPart::SummaryText { text, .. }] = summary.as_slice() else {
panic!("expected summary text")
};
assert_eq!(text, "summary");
}
ResponsesOutputItem::WebSearchCall(call) => {
let Some(ResponsesWebSearchAction::Search {
queries: Some(queries),
sources: Some(sources),
..
}) = &call.action
else {
panic!("expected search action")
};
assert_eq!(queries, &["q"]);
assert_eq!(sources[0].url.as_deref(), Some("https://example.test"));
}
ResponsesOutputItem::FileSearchCall(call) => {
assert_eq!(call.queries.as_deref(), Some(["q".to_owned()].as_slice()));
let Some(results) = &call.results else {
panic!("expected search results")
};
assert_eq!(results[0].file_id.as_deref(), Some("file_1"));
assert_eq!(results[0].score, Some(1.into()));
assert_eq!(
results[0].attributes.as_ref().unwrap()["custom"],
json!([1, null])
);
}
ResponsesOutputItem::CodeInterpreterCall(call) => {
let Some(outputs) = &call.outputs else {
panic!("expected code outputs")
};
let [
ResponsesCodeOutput::Logs { logs, .. },
ResponsesCodeOutput::Image { url, .. },
] = outputs.as_slice()
else {
panic!("expected logs and image")
};
assert_eq!(logs, "done");
assert_eq!(url, "https://example.test");
}
ResponsesOutputItem::ImageGenerationCall(call) => {
assert_eq!(call.result.as_deref(), Some("generated"))
}
ResponsesOutputItem::McpCall(call) => {
assert_eq!(call.server_label.as_deref(), Some("server"));
assert_eq!(call.name.as_deref(), Some("lookup"));
assert_eq!(call.arguments.as_deref(), Some("{}"));
assert_eq!(call.output.as_deref(), Some("done"));
}
other => panic!("unexpected variant {other:?}"),
}
assert_eq!(serde_json::to_value(item).unwrap(), wire);
}
#[rstest]
#[case::find_in_page(
json!({"type":"find_in_page","url":"https://example.test","pattern":"needle"}),
json!({"type":"find_in_page","url":"https://example.test","pattern":"needle"})
)]
#[case::legacy_find(
json!({"type":"find","url":"https://example.test","pattern":"needle"}),
json!({"type":"find_in_page","url":"https://example.test","pattern":"needle"})
)]
fn web_search_find_action_exposes_url_and_pattern(
#[case] wire: Value,
#[case] serialized: Value,
) {
let action: ResponsesWebSearchAction = serde_json::from_value(wire).unwrap();
let ResponsesWebSearchAction::FindInPage {
url,
pattern,
extra,
} = &action
else {
panic!("expected find_in_page action");
};
assert_eq!(url, "https://example.test");
assert_eq!(pattern, "needle");
assert!(extra.is_empty());
assert_eq!(serde_json::to_value(action).unwrap(), serialized);
}
#[rstest]
#[case::with_url(json!({"type":"open_page","url":"https://example.test"}), Some("https://example.test"))]
#[case::without_url(json!({"type":"open_page"}), None)]
fn web_search_open_page_url_is_optional(#[case] wire: Value, #[case] expected: Option<&str>) {
let action: ResponsesWebSearchAction = serde_json::from_value(wire.clone()).unwrap();
let ResponsesWebSearchAction::OpenPage { url, .. } = &action else {
panic!("expected open_page action");
};
assert_eq!(url.as_deref(), expected);
assert_eq!(serde_json::to_value(action).unwrap(), wire);
}
#[rstest]
#[case::find_pattern(json!({"type":"find_in_page","url":"u","pattern":false}))]
#[case::find_missing_url(json!({"type":"find_in_page","pattern":"p"}))]
#[case::open_page_url(json!({"type":"open_page","url":7}))]
fn web_search_action_rejects_malformed_fields(#[case] wire: Value) {
assert!(serde_json::from_value::<ResponsesWebSearchAction>(wire).is_err());
}
#[rstest]
#[case::refusal(json!({"type":"refusal","refusal":"refused","future":null}), ResponsesContentPart::Refusal { refusal:"refused".into(), extra:serde_json::Map::from_iter([("future".into(), Value::Null)]) })]
#[case::reasoning(json!({"type":"reasoning_text","text":"reason"}), ResponsesContentPart::ReasoningText { text:"reason".into(), extra:Default::default() })]
fn content_parts_expose_typed_variants(
#[case] wire: Value,
#[case] expected: ResponsesContentPart,
) {
let content: ResponsesContentPart = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(content, expected);
assert_eq!(serde_json::to_value(content).unwrap(), wire);
}
#[rstest]
fn mcp_discovery_exposes_tools_and_nested_schemas() {
let wire = json!({
"type":"mcp_list_tools","id":"item_1","server_label":"tools",
"tools":[{"name":"lookup","description":"Look up a value","input_schema":{"type":"object","properties":{"query":{"type":"string"}}},"annotations":{"read_only":false},"future":null}],
"extension":[1,null]
});
let item: ResponsesOutputItem = serde_json::from_value(wire.clone()).unwrap();
let ResponsesOutputItem::McpListTools(discovery) = &item else {
panic!("expected discovery")
};
assert_eq!(discovery.server_label.as_deref(), Some("tools"));
let tool = &discovery.tools.as_ref().unwrap()[0];
assert_eq!(tool.name.as_deref(), Some("lookup"));
let Some(crate::json_schema::JsonSchema::Object(schema)) = &tool.input_schema else {
panic!("expected tool schema")
};
assert!(schema.properties.as_ref().unwrap().contains_key("query"));
assert_eq!(tool.annotations, Some(json!({"read_only":false})));
assert_eq!(tool.extra["future"], Value::Null);
assert_eq!(serde_json::to_value(item).unwrap(), wire);
}
#[rstest]
#[case::partial(json!({"server_label":"tools","tools":[]}))]
#[case::failure(json!({"server_label":"tools","error":"unavailable"}))]
fn mcp_discovery_accepts_partial_payloads(#[case] wire: Value) {
let discovery: crate::formats::responses::ResponsesMcpListTools =
serde_json::from_value(wire.clone()).unwrap();
assert_eq!(discovery.server_label.as_deref(), Some("tools"));
assert!(discovery.id.is_none());
assert_eq!(serde_json::to_value(discovery).unwrap(), wire);
}
#[rstest]
#[case::message(json!("unavailable"), ResponsesMcpError::Message("unavailable".into()))]
#[case::protocol(json!({"type":"mcp_protocol_error","code":-32600,"message":"invalid","extension":null}), ResponsesMcpError::Detail(ResponsesMcpErrorDetail::McpProtocolError {code:-32600,message:"invalid".into(),extra:serde_json::Map::from_iter([("extension".into(), Value::Null)])}))]
#[case::http(json!({"type":"http_error","code":503,"message":"unavailable"}), ResponsesMcpError::Detail(ResponsesMcpErrorDetail::HttpError {code:503,message:"unavailable".into(),extra:Default::default()}))]
#[case::tool(json!({"type":"mcp_tool_execution_error","content":{"arbitrary":[1,null]}}), ResponsesMcpError::Detail(ResponsesMcpErrorDetail::McpToolExecutionError {content:json!({"arbitrary":[1,null]}),extra:Default::default()}))]
fn mcp_call_errors_expose_typed_variants(
#[case] wire: Value,
#[case] expected: ResponsesMcpError,
) {
let error: ResponsesMcpError = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(error, expected);
assert_eq!(serde_json::to_value(&error).unwrap(), wire);
let call: ResponsesOutputItem =
serde_json::from_value(json!({"type":"mcp_call","error":wire})).unwrap();
let ResponsesOutputItem::McpCall(call) = call else {
panic!("expected call")
};
assert_eq!(call.error, Some(error));
}
#[rstest]
#[case::tools_not_array(json!({"type":"mcp_list_tools","tools":{}}))]
#[case::invalid_name(json!({"type":"mcp_list_tools","tools":[{"name":7}]}))]
#[case::invalid_schema(json!({"type":"mcp_list_tools","tools":[{"input_schema":{"properties":{"query":7}}}]}))]
#[case::invalid_error_code(json!({"type":"mcp_call","error":{"type":"http_error","code":"503","message":"unavailable"}}))]
#[case::missing_error_content(json!({"type":"mcp_call","error":{"type":"mcp_tool_execution_error"}}))]
#[case::unknown_error_tag(json!({"type":"mcp_call","error":{"type":"future"}}))]
fn mcp_output_rejects_malformed_known_fields(#[case] wire: Value) {
assert!(serde_json::from_value::<ResponsesOutputItem>(wire).is_err());
}
fn program_caller() -> Option<ResponsesToolCaller> {
Some(ResponsesToolCaller::Program {
caller_id: "prog_1".into(),
extra: Default::default(),
})
}
fn direct_caller() -> Option<ResponsesToolCaller> {
Some(ResponsesToolCaller::Direct {
extra: Default::default(),
})
}
fn safety_check() -> ResponsesSafetyCheck {
ResponsesSafetyCheck {
id: text("sc_1"),
code: text("malicious_instructions"),
message: text("check"),
..Default::default()
}
}
#[rstest]
#[case::function_call(
json!({"type":"function_call","id":"fc_1","status":"completed","call_id":"call_1","name":"lookup","arguments":"{}","namespace":"ns","async":true,"caller":{"type":"program","caller_id":"prog_1"}}),
ResponsesOutputItem::FunctionCall(ResponsesFunctionCall {
id: text("fc_1"), status: text("completed"), call_id: text("call_1"), name: text("lookup"),
arguments: text("{}"), namespace: text("ns"), r#async: Some(true), caller: program_caller(), ..Default::default()
})
)]
#[case::custom_tool_call(
json!({"type":"custom_tool_call","call_id":"call_1","name":"lookup","input":"q","namespace":"ns","async":false,"caller":{"type":"direct"}}),
ResponsesOutputItem::CustomToolCall(ResponsesCustomToolCall {
call_id: text("call_1"), name: text("lookup"), input: text("q"), namespace: text("ns"),
r#async: Some(false), caller: direct_caller(), ..Default::default()
})
)]
#[case::image_generation_call(
json!({"type":"image_generation_call","id":"ig_1","status":"completed","result":"b64","action":"edit","background":"opaque","output_format":"webp","quality":"high","revised_prompt":"a cat","size":"1536x864"}),
ResponsesOutputItem::ImageGenerationCall(ResponsesImageGenerationCall {
id: text("ig_1"), status: text("completed"), result: text("b64"), action: text("edit"), background: text("opaque"),
output_format: text("webp"), quality: text("high"), revised_prompt: text("a cat"), size: text("1536x864"), ..Default::default()
})
)]
#[case::function_call_output_text(
json!({"type":"function_call_output","id":"fco_1","status":"completed","call_id":"call_1","output":"done","caller":{"type":"direct"},"created_by":"user_1","name":"lookup","namespace":"ns"}),
ResponsesOutputItem::FunctionCallOutput(ResponsesFunctionCallOutput {
id: text("fco_1"), status: text("completed"), call_id: text("call_1"), output: Some(ResponsesToolOutput::Text("done".into())),
caller: direct_caller(), created_by: text("user_1"), name: text("lookup"), namespace: text("ns"), ..Default::default()
})
)]
#[case::function_call_output_content(
json!({"type":"function_call_output","call_id":"call_1","output":[
{"type":"input_text","text":"t","prompt_cache_breakpoint":{"mode":"explicit"}},
{"type":"input_image","detail":"low","file_id":"file_1","image_url":"https://example.test/i.png"},
{"type":"input_file","detail":"high","file_data":"data","file_id":"file_2","file_url":"https://example.test/f","filename":"f.pdf"}
]}),
ResponsesOutputItem::FunctionCallOutput(ResponsesFunctionCallOutput {
call_id: text("call_1"),
output: Some(ResponsesToolOutput::Content(vec![
ResponsesInputContent::InputText {
text: "t".into(),
prompt_cache_breakpoint: Some(PromptCacheBreakpoint { mode: PromptCacheMode::Explicit, extra: Default::default() }),
extra: Default::default(),
},
ResponsesInputContent::InputImage {
detail: "low".into(), file_id: text("file_1"), image_url: text("https://example.test/i.png"),
prompt_cache_breakpoint: None, extra: Default::default(),
},
ResponsesInputContent::InputFile {
detail: text("high"), file_data: text("data"), file_id: text("file_2"), file_url: text("https://example.test/f"),
filename: text("f.pdf"), prompt_cache_breakpoint: None, extra: Default::default(),
},
])),
..Default::default()
})
)]
#[case::custom_tool_call_output(
json!({"type":"custom_tool_call_output","id":"cto_1","status":"completed","call_id":"call_1","output":[{"type":"input_text","text":"t"}],"caller":{"type":"program","caller_id":"prog_1"},"created_by":"user_1"}),
ResponsesOutputItem::CustomToolCallOutput(ResponsesCustomToolCallOutput {
id: text("cto_1"), status: text("completed"), call_id: text("call_1"),
output: Some(ResponsesToolOutput::Content(vec![ResponsesInputContent::InputText {
text: "t".into(), prompt_cache_breakpoint: None, extra: Default::default(),
}])),
caller: program_caller(), created_by: text("user_1"), ..Default::default()
})
)]
#[case::computer_call(
json!({"type":"computer_call","id":"cu_1","status":"completed","call_id":"call_1",
"pending_safety_checks":[{"id":"sc_1","code":"malicious_instructions","message":"check"}],
"action":{"type":"click","button":"left","x":1,"y":2,"keys":["shift"]},
"actions":[
{"type":"double_click","x":3,"y":4},
{"type":"drag","path":[{"x":5,"y":6},{"x":7,"y":8}],"keys":["ctrl"]},
{"type":"keypress","keys":["enter"]},
{"type":"move","x":-1,"y":9},
{"type":"screenshot"},
{"type":"scroll","scroll_x":0,"scroll_y":-10,"x":11,"y":12},
{"type":"type","text":"hello"},
{"type":"wait"}
]}),
ResponsesOutputItem::ComputerCall(ResponsesComputerCall {
id: text("cu_1"), status: text("completed"), call_id: text("call_1"), pending_safety_checks: Some(vec![safety_check()]),
action: Some(ResponsesComputerAction::Click { button: "left".into(), x: 1, y: 2, keys: Some(vec!["shift".into()]), extra: Default::default() }),
actions: Some(vec![
ResponsesComputerAction::DoubleClick { x: 3, y: 4, keys: None, extra: Default::default() },
ResponsesComputerAction::Drag {
path: vec![
ResponsesCoordinate { x: 5, y: 6, extra: Default::default() },
ResponsesCoordinate { x: 7, y: 8, extra: Default::default() },
],
keys: Some(vec!["ctrl".into()]),
extra: Default::default(),
},
ResponsesComputerAction::Keypress { keys: vec!["enter".into()], extra: Default::default() },
ResponsesComputerAction::Move { x: -1, y: 9, keys: None, extra: Default::default() },
ResponsesComputerAction::Screenshot { extra: Default::default() },
ResponsesComputerAction::Scroll { scroll_x: 0, scroll_y: -10, x: 11, y: 12, keys: None, extra: Default::default() },
ResponsesComputerAction::Type { text: "hello".into(), extra: Default::default() },
ResponsesComputerAction::Wait { extra: Default::default() },
]),
..Default::default()
})
)]
#[case::computer_call_output(
json!({"type":"computer_call_output","id":"cuo_1","status":"completed","call_id":"call_1","output":{"type":"computer_screenshot","file_id":"file_1","image_url":"https://example.test/s.png"},"acknowledged_safety_checks":[{"id":"sc_1","code":"malicious_instructions","message":"check"}],"created_by":"user_1"}),
ResponsesOutputItem::ComputerCallOutput(ResponsesComputerCallOutput {
id: text("cuo_1"), status: text("completed"), call_id: text("call_1"),
output: Some(ResponsesComputerOutput::ComputerScreenshot { file_id: text("file_1"), image_url: text("https://example.test/s.png"), extra: Default::default() }),
acknowledged_safety_checks: Some(vec![safety_check()]), created_by: text("user_1"), ..Default::default()
})
)]
#[case::program(
json!({"type":"program","id":"prog_item","call_id":"prog_1","code":"run()","fingerprint":"fp"}),
ResponsesOutputItem::Program(ResponsesProgram {
id: text("prog_item"), call_id: text("prog_1"), code: text("run()"), fingerprint: text("fp"), ..Default::default()
})
)]
#[case::program_output(
json!({"type":"program_output","id":"po_1","status":"completed","call_id":"prog_1","result":"42"}),
ResponsesOutputItem::ProgramOutput(ResponsesProgramOutput {
id: text("po_1"), status: text("completed"), call_id: text("prog_1"), result: text("42"), ..Default::default()
})
)]
#[case::tool_search_call(
json!({"type":"tool_search_call","id":"ts_1","status":"completed","call_id":"call_1","arguments":{"query":["weather",null]},"execution":"server","created_by":"user_1"}),
ResponsesOutputItem::ToolSearchCall(ResponsesToolSearchCall {
id: text("ts_1"), status: text("completed"), call_id: text("call_1"), arguments: Some(json!({"query":["weather",null]})),
execution: text("server"), created_by: text("user_1"), ..Default::default()
})
)]
#[case::tool_search_output(
json!({"type":"tool_search_output","id":"tso_1","status":"completed","call_id":"call_1","execution":"client","tools":[{"type":"function","name":"lookup","parameters":null,"strict":true}],"created_by":"user_1"}),
ResponsesOutputItem::ToolSearchOutput(ResponsesToolSearchOutput {
id: text("tso_1"), status: text("completed"), call_id: text("call_1"), execution: text("client"),
tools: Some(vec![serde_json::Map::from_iter([
("type".into(), json!("function")), ("name".into(), json!("lookup")),
("parameters".into(), Value::Null), ("strict".into(), json!(true)),
])]),
created_by: text("user_1"), ..Default::default()
})
)]
#[case::additional_tools(
json!({"type":"additional_tools","id":"at_1","role":"developer","tools":[{"type":"local_shell"}]}),
ResponsesOutputItem::AdditionalTools(ResponsesAdditionalTools {
id: text("at_1"), role: text("developer"),
tools: Some(vec![serde_json::Map::from_iter([("type".into(), json!("local_shell"))])]), ..Default::default()
})
)]
#[case::compaction(
json!({"type":"compaction","id":"cmp_1","encrypted_content":"opaque","created_by":"user_1"}),
ResponsesOutputItem::Compaction(ResponsesCompaction {
id: text("cmp_1"), encrypted_content: text("opaque"), created_by: text("user_1"), ..Default::default()
})
)]
#[case::local_shell_call(
json!({"type":"local_shell_call","id":"ls_1","status":"completed","call_id":"call_1","action":{"type":"exec","command":["ls","-a"],"env":{"HOME":"/home/u"},"timeout_ms":1000,"user":"u","working_directory":"/tmp"}}),
ResponsesOutputItem::LocalShellCall(ResponsesLocalShellCall {
id: text("ls_1"), status: text("completed"), call_id: text("call_1"),
action: Some(ResponsesLocalShellAction::Exec {
command: vec!["ls".into(), "-a".into()], env: [("HOME".into(), "/home/u".into())].into(),
timeout_ms: Some(1000), user: text("u"), working_directory: text("/tmp"), extra: Default::default(),
}),
..Default::default()
})
)]
#[case::local_shell_call_output(
json!({"type":"local_shell_call_output","id":"lso_1","status":"completed","output":"{\"stdout\":\"x\"}"}),
ResponsesOutputItem::LocalShellCallOutput(ResponsesLocalShellCallOutput {
id: text("lso_1"), status: text("completed"), output: text("{\"stdout\":\"x\"}"), ..Default::default()
})
)]
#[case::shell_call_local(
json!({"type":"shell_call","id":"sh_1","status":"in_progress","call_id":"call_1","action":{"commands":["ls"],"max_output_length":100,"timeout_ms":500},"environment":{"type":"local"},"caller":{"type":"direct"},"created_by":"user_1"}),
ResponsesOutputItem::ShellCall(ResponsesShellCall {
id: text("sh_1"), status: text("in_progress"), call_id: text("call_1"),
action: Some(ResponsesShellAction { commands: Some(vec!["ls".into()]), max_output_length: Some(100), timeout_ms: Some(500), ..Default::default() }),
environment: Some(ResponsesShellEnvironment::Local { extra: Default::default() }),
caller: direct_caller(), created_by: text("user_1"), ..Default::default()
})
)]
#[case::shell_call_container(
json!({"type":"shell_call","call_id":"call_1","environment":{"type":"container_reference","container_id":"cntr_1"}}),
ResponsesOutputItem::ShellCall(ResponsesShellCall {
call_id: text("call_1"),
environment: Some(ResponsesShellEnvironment::ContainerReference { container_id: "cntr_1".into(), extra: Default::default() }),
..Default::default()
})
)]
#[case::shell_call_output(
json!({"type":"shell_call_output","id":"sho_1","status":"completed","call_id":"call_1","max_output_length":100,"output":[
{"outcome":{"type":"exit","exit_code":2},"stdout":"out","stderr":"err","created_by":"user_1"},
{"outcome":{"type":"timeout"},"stdout":"","stderr":""}
],"caller":{"type":"program","caller_id":"prog_1"},"created_by":"user_1"}),
ResponsesOutputItem::ShellCallOutput(ResponsesShellCallOutput {
id: text("sho_1"), status: text("completed"), call_id: text("call_1"), max_output_length: Some(100),
output: Some(vec![
ResponsesShellOutputChunk {
outcome: Some(ResponsesShellOutcome::Exit { exit_code: 2, extra: Default::default() }),
stdout: text("out"), stderr: text("err"), created_by: text("user_1"), ..Default::default()
},
ResponsesShellOutputChunk {
outcome: Some(ResponsesShellOutcome::Timeout { extra: Default::default() }),
stdout: text(""), stderr: text(""), ..Default::default()
},
]),
caller: program_caller(), created_by: text("user_1"), ..Default::default()
})
)]
#[case::apply_patch_create(
json!({"type":"apply_patch_call","id":"ap_1","status":"completed","call_id":"call_1","operation":{"type":"create_file","path":"a.txt","diff":"+a"},"caller":{"type":"direct"},"created_by":"user_1"}),
ResponsesOutputItem::ApplyPatchCall(ResponsesApplyPatchCall {
id: text("ap_1"), status: text("completed"), call_id: text("call_1"),
operation: Some(ResponsesApplyPatchOperation::CreateFile { path: "a.txt".into(), diff: "+a".into(), extra: Default::default() }),
caller: direct_caller(), created_by: text("user_1"), ..Default::default()
})
)]
#[case::apply_patch_delete(
json!({"type":"apply_patch_call","call_id":"call_1","operation":{"type":"delete_file","path":"a.txt"}}),
ResponsesOutputItem::ApplyPatchCall(ResponsesApplyPatchCall {
call_id: text("call_1"),
operation: Some(ResponsesApplyPatchOperation::DeleteFile { path: "a.txt".into(), extra: Default::default() }),
..Default::default()
})
)]
#[case::apply_patch_update(
json!({"type":"apply_patch_call","call_id":"call_1","operation":{"type":"update_file","path":"a.txt","diff":"-a\n+b"}}),
ResponsesOutputItem::ApplyPatchCall(ResponsesApplyPatchCall {
call_id: text("call_1"),
operation: Some(ResponsesApplyPatchOperation::UpdateFile { path: "a.txt".into(), diff: "-a\n+b".into(), extra: Default::default() }),
..Default::default()
})
)]
#[case::apply_patch_call_output(
json!({"type":"apply_patch_call_output","id":"apo_1","status":"failed","call_id":"call_1","output":"conflict","caller":{"type":"direct"},"created_by":"user_1"}),
ResponsesOutputItem::ApplyPatchCallOutput(ResponsesApplyPatchCallOutput {
id: text("apo_1"), status: text("failed"), call_id: text("call_1"), output: text("conflict"),
caller: direct_caller(), created_by: text("user_1"), ..Default::default()
})
)]
#[case::mcp_approval_request(
json!({"type":"mcp_approval_request","id":"apr_1","server_label":"s","name":"n","arguments":"{}"}),
ResponsesOutputItem::McpApprovalRequest(ResponsesMcpApprovalRequest {
id: text("apr_1"), server_label: text("s"), name: text("n"), arguments: text("{}"), ..Default::default()
})
)]
#[case::mcp_approval_response(
json!({"type":"mcp_approval_response","id":"aprr_1","approval_request_id":"apr_1","approve":false,"reason":"denied"}),
ResponsesOutputItem::McpApprovalResponse(ResponsesMcpApprovalResponse {
id: text("aprr_1"), approval_request_id: text("apr_1"), approve: Some(false), reason: text("denied"), ..Default::default()
})
)]
fn documented_output_items_parse_into_typed_variants(
#[case] wire: Value,
#[case] expected: ResponsesOutputItem,
) {
let item: ResponsesOutputItem = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(item, expected);
assert_eq!(serde_json::to_value(item).unwrap(), wire);
}
#[rstest]
#[case::unknown_caller(json!({"type":"function_call","caller":{"type":"future"}}))]
#[case::program_caller_missing_id(json!({"type":"shell_call","caller":{"type":"program"}}))]
#[case::untagged_caller(json!({"type":"custom_tool_call","caller":{}}))]
#[case::async_not_bool(json!({"type":"function_call","async":"yes"}))]
#[case::output_not_text_or_list(json!({"type":"function_call_output","output":{"text":"t"}}))]
#[case::unknown_input_content(json!({"type":"function_call_output","output":[{"type":"input_audio"}]}))]
#[case::input_text_missing_text(json!({"type":"custom_tool_call_output","output":[{"type":"input_text"}]}))]
#[case::input_image_missing_detail(json!({"type":"custom_tool_call_output","output":[{"type":"input_image","file_id":"f"}]}))]
#[case::bad_cache_mode(json!({"type":"custom_tool_call_output","output":[{"type":"input_text","text":"t","prompt_cache_breakpoint":{"mode":"implicit"}}]}))]
#[case::unknown_computer_action(json!({"type":"computer_call","action":{"type":"teleport"}}))]
#[case::click_missing_button(json!({"type":"computer_call","action":{"type":"click","x":1,"y":2}}))]
#[case::click_fractional_x(json!({"type":"computer_call","action":{"type":"click","button":"left","x":1.5,"y":2}}))]
#[case::drag_point_missing_y(json!({"type":"computer_call","actions":[{"type":"drag","path":[{"x":1}]}]}))]
#[case::keypress_keys_not_list(json!({"type":"computer_call","actions":[{"type":"keypress","keys":"enter"}]}))]
#[case::safety_check_not_object(json!({"type":"computer_call","pending_safety_checks":["sc_1"]}))]
#[case::unknown_screenshot_tag(json!({"type":"computer_call_output","output":{"type":"screenshot"}}))]
#[case::untagged_screenshot(json!({"type":"computer_call_output","output":{"file_id":"f"}}))]
#[case::tool_not_object(json!({"type":"tool_search_output","tools":["lookup"]}))]
#[case::unknown_local_shell_action(json!({"type":"local_shell_call","action":{"type":"spawn","command":[],"env":{}}}))]
#[case::local_shell_missing_env(json!({"type":"local_shell_call","action":{"type":"exec","command":["ls"]}}))]
#[case::local_shell_env_value(json!({"type":"local_shell_call","action":{"type":"exec","command":["ls"],"env":{"A":1}}}))]
#[case::shell_commands_not_list(json!({"type":"shell_call","action":{"commands":"ls"}}))]
#[case::unknown_shell_environment(json!({"type":"shell_call","environment":{"type":"container_auto"}}))]
#[case::container_missing_id(json!({"type":"shell_call","environment":{"type":"container_reference"}}))]
#[case::unknown_shell_outcome(json!({"type":"shell_call_output","output":[{"outcome":{"type":"killed"}}]}))]
#[case::exit_missing_code(json!({"type":"shell_call_output","output":[{"outcome":{"type":"exit"}}]}))]
#[case::negative_max_output(json!({"type":"shell_call_output","max_output_length":-1}))]
#[case::unknown_patch_operation(json!({"type":"apply_patch_call","operation":{"type":"rename_file","path":"a"}}))]
#[case::update_missing_diff(json!({"type":"apply_patch_call","operation":{"type":"update_file","path":"a"}}))]
#[case::approve_not_bool(json!({"type":"mcp_approval_response","approve":"true"}))]
#[case::compaction_content_not_string(json!({"type":"compaction","encrypted_content":7}))]
#[case::program_code_not_string(json!({"type":"program","code":["run()"]}))]
fn output_items_reject_malformed_typed_fields(#[case] wire: Value) {
assert!(serde_json::from_value::<ResponsesOutputItem>(wire).is_err());
}
fn text(value: &str) -> Option<String> {
Some(value.to_owned())
}
}

View file

@ -106,9 +106,18 @@ pub struct ResponsesEventResponse {
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use super::*;
use crate::formats::responses::ResponsesOutputItem;
use crate::{
formats::responses::streaming_websocket::{ResponsesEventResponse, ResponsesWsEventType},
recognized::Recognized,
test_support::*,
};
use serde_json::{Value, json};
#[test]
fn error_frame_matches_proxy_shape() {
@ -135,4 +144,145 @@ mod tests {
let event: ResponsesWsEvent = serde_json::from_value(payload).expect("valid event");
assert_eq!(event.model(), expected);
}
#[rstest]
#[case::create("response.create", ResponsesWsEventType::ResponseCreate)]
#[case::created("response.created", ResponsesWsEventType::ResponseCreated)]
#[case::completed("response.completed", ResponsesWsEventType::ResponseCompleted)]
#[case::failed("response.failed", ResponsesWsEventType::ResponseFailed)]
#[case::incomplete("response.incomplete", ResponsesWsEventType::ResponseIncomplete)]
#[case::error("error", ResponsesWsEventType::Error)]
#[case::unknown(
"response.output_text.delta",
ResponsesWsEventType::Other("response.output_text.delta".to_string())
)]
#[case::empty("", ResponsesWsEventType::Other(String::new()))]
#[case::case_sensitive(
"Response.Completed",
ResponsesWsEventType::Other("Response.Completed".into())
)]
#[case::escaped("future\"\\\n", ResponsesWsEventType::Other("future\"\\\n".into()))]
fn websocket_event_type_round_trips(
#[case] wire: &str,
#[case] expected: ResponsesWsEventType,
) {
let serialized = serde_json::to_string(&expected).unwrap();
assert_eq!(serialized, serde_json::to_string(wire).unwrap());
assert_eq!(
serde_json::from_str::<ResponsesWsEventType>(&serialized).unwrap(),
expected
);
}
#[rstest]
#[case::number("17")]
#[case::boolean("true")]
#[case::null("null")]
#[case::array("[]")]
#[case::object("{}")]
fn websocket_event_type_rejects_non_strings(#[case] wire: &str) {
assert!(serde_json::from_str::<ResponsesWsEventType>(wire).is_err());
}
#[cfg(feature = "schema")]
#[rstest]
fn websocket_event_type_schema_is_open_string() {
let schema = schemars::schema_for!(ResponsesWsEventType);
assert_eq!(
schema.to_value().get("type"),
Some(&serde_json::json!("string"))
);
}
#[rstest]
fn nested_event_response_exposes_typed_output_and_preserves_extensions() {
let wire = json!({
"id":"response_1",
"model":"example-model",
"status":"completed",
"output":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}","extension":true}],
"extension":{"nested":[1,null]}
});
let response: ResponsesEventResponse = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(response.id.as_deref(), Some("response_1"));
assert_eq!(response.model.as_deref(), Some("example-model"));
let Some(output) = &response.output else {
panic!("expected typed output");
};
let [Recognized::Known(ResponsesOutputItem::FunctionCall(call))] = output.as_slice() else {
panic!("expected function call");
};
assert_eq!(call.name.as_deref(), Some("lookup"));
assert_eq!(call.arguments.as_deref(), Some("{}"));
assert_eq!(serde_json::to_value(response).unwrap(), wire);
}
#[rstest]
#[case::empty(json!({}))]
#[case::partial(json!({"id":"response_1","output":[]}))]
fn nested_event_response_accepts_partial_metadata(#[case] wire: Value) {
round_trip::<ResponsesEventResponse>(wire);
}
#[rstest]
#[case::wrong_model(json!({"model":7}))]
#[case::wrong_status(json!({"status":false}))]
#[case::wrong_output(json!({"output":{}}))]
fn event_response_rejects_malformed_typed_fields(#[case] wire: Value) {
assert!(serde_json::from_value::<ResponsesEventResponse>(wire).is_err());
}
#[rstest]
#[case::unknown_type(json!({"type":"future_item","id":"item_1","payload":[1,null]}))]
#[case::missing_tag(json!({"id":"item_1"}))]
#[case::malformed_known(json!({"type":"message","content":[{"type":"output_text","text":7}]}))]
fn event_response_keeps_unmodeled_output_items_beside_typed_ones(#[case] item: Value) {
let wire = json!({"output":[{"type":"function_call","name":"lookup"}, item.clone()]});
let response: ResponsesEventResponse = serde_json::from_value(wire.clone()).unwrap();
let Some(
[
Recognized::Known(ResponsesOutputItem::FunctionCall(call)),
Recognized::Unrecognized(kept),
],
) = response.output.as_deref()
else {
panic!("expected one typed item and one preserved item");
};
assert_eq!(call.name.as_deref(), Some("lookup"));
assert_eq!(kept, &item);
assert_eq!(serde_json::to_value(response).unwrap(), wire);
}
#[rstest]
fn event_response_optional_fields_omit_missing_and_null() {
let response: ResponsesEventResponse = serde_json::from_value(json!({
"id":null,"model":null,"status":null,"output":null,"future":null
}))
.unwrap();
assert!(response.id.is_none());
assert!(response.model.is_none());
assert!(response.status.is_none());
assert!(response.output.is_none());
assert_eq!(
serde_json::to_value(response).unwrap(),
json!({"future":null})
);
}
#[rstest]
#[case::compaction(json!({"type":"compaction","id":"cmp_1","encrypted_content":"opaque"}))]
#[case::approval(json!({"type":"mcp_approval_request","id":"apr_1","server_label":"s","name":"n","arguments":"{}"}))]
#[case::shell(json!({"type":"shell_call","call_id":"call_1","action":{"commands":["ls"]}}))]
fn event_response_types_documented_output_items(#[case] item: Value) {
let wire = json!({"output":[item.clone()]});
let response: ResponsesEventResponse = serde_json::from_value(wire.clone()).unwrap();
let Some([Recognized::Known(known)]) = response.output.as_deref() else {
panic!("expected one typed item");
};
assert_eq!(
known,
&serde_json::from_value::<ResponsesOutputItem>(item).unwrap()
);
assert_eq!(serde_json::to_value(response).unwrap(), wire);
}
}

Some files were not shown because too many files have changed in this diff Show more