mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-17 04:34:25 +00:00
Merge remote-tracking branch 'origin/main' into litellm_responses_tool_search_anthropic
Some checks failed
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
Some checks failed
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
# Conflicts: # tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py
This commit is contained in:
commit
6083ce413f
1578 changed files with 113579 additions and 117884 deletions
2047
.circleci/config.yml
2047
.circleci/config.yml
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
category="${1:?usage: classify_changes.sh <backend|client|ui|provider-harness|cost-map-only|mcp-dependencies|windows-release|redis-compat>}"
|
||||
category="${1:?usage: classify_changes.sh <backend|client|ui|cost-map-only|mcp-dependencies|windows-release|redis-compat>}"
|
||||
|
||||
has_client=false
|
||||
has_backend=false
|
||||
has_ci=false
|
||||
has_provider_harness=false
|
||||
has_cost_map=false
|
||||
has_mcp_dependencies=false
|
||||
has_windows_release=false
|
||||
|
|
@ -15,7 +14,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/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)
|
||||
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 | .github/workflows/test-unit.yml | .github/actions/detect-changes/* | .github/scripts/detect_changes.sh | .circleci/scripts/classify_changes.sh | tests/integration/run.py | pyproject.toml | uv.lock)
|
||||
has_redis_compat=true ;;
|
||||
esac
|
||||
case "$file" in
|
||||
|
|
@ -23,11 +22,6 @@ while IFS= read -r file || [ -n "$file" ]; do
|
|||
pyproject.toml | */pyproject.toml | uv.lock | uv.toml | .python-version | rust-toolchain.toml | litellm-rust/* | litellm/__init__.py | litellm/proxy/proxy_server.py | litellm/*mcp* | tests/*mcp* | litellm/integrations/arize/* | tests/base_sdk_tests/* | scripts/check_mcp_sdk_install.py | .github/workflows/test-mcp-dependency-resolution.yml | .github/actions/detect-changes/* | .github/actions/setup-uv-with-retries/* | .github/actions/cache-cargo-build/* | .github/scripts/detect_changes.sh | .github/scripts/uv_sync_with_retries.sh | .circleci/scripts/classify_changes.sh | tests/unit/test_circleci_path_filter.py | tests/unit/test_detect_changes.py)
|
||||
has_mcp_dependencies=true ;;
|
||||
esac
|
||||
case "$file" in
|
||||
tests/e2e/*/*.py | tests/e2e_harness/*/*.py) : ;;
|
||||
tests/e2e/*.py | tests/e2e_harness/*.py | tests/code_coverage_tests/test_provider_cache.py | tests/code_coverage_tests/test_provider_replay_harness.py | tests/unit/test_circleci_path_filter.py | .circleci/* | pyproject.toml | uv.lock)
|
||||
has_provider_harness=true ;;
|
||||
esac
|
||||
case "$file" in
|
||||
litellm-rust/* | litellm/rust_bridge/* | rust-toolchain.toml | pyproject.toml | uv.lock | tests/windows_tests/* | .circleci/*)
|
||||
has_windows_release=true ;;
|
||||
|
|
@ -41,7 +35,7 @@ while IFS= read -r file || [ -n "$file" ]; do
|
|||
case "$file" in
|
||||
model_prices_and_context_window.json | litellm/model_prices_and_context_window_backup.json | model_prices_and_context_window.schema.json)
|
||||
has_cost_map=true ;;
|
||||
tests/test_litellm/* | tests/proxy_unit_tests/* | tests/unit/proxy/*) : ;;
|
||||
tests/unit/proxy/*) : ;;
|
||||
*) outside_cost_map_set=true ;;
|
||||
esac
|
||||
done
|
||||
|
|
@ -53,9 +47,6 @@ case "$category" in
|
|||
cost-map-only)
|
||||
{ [ "$has_cost_map" = true ] && [ "$outside_cost_map_set" = false ]; } && echo run || echo skip
|
||||
;;
|
||||
provider-harness)
|
||||
[ "$has_provider_harness" = true ] && echo run || echo skip
|
||||
;;
|
||||
windows-release)
|
||||
[ "$has_windows_release" = true ] && echo run || echo skip
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
category="${1:?usage: path_filter.sh <backend|client|provider-harness|redis-compat>}"
|
||||
category="${1:?usage: path_filter.sh <backend|client|windows-release>}"
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
run_full() {
|
||||
|
|
|
|||
|
|
@ -128,9 +128,14 @@ 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=$!
|
||||
if [ "$mode" = parity ]; then
|
||||
export INTEGRATION_WORKERS=1
|
||||
else
|
||||
case "$suite" in
|
||||
management|accounting) export INTEGRATION_WORKERS=2 ;;
|
||||
providers|extensions) export INTEGRATION_WORKERS=4 ;;
|
||||
esac
|
||||
fi
|
||||
if [ "$suite" = cost ]; then
|
||||
export INTEGRATION_WORKERS=8
|
||||
fi
|
||||
|
|
@ -138,7 +143,16 @@ if [ "$suite" = security ]; then
|
|||
export INTEGRATION_WORKERS=4
|
||||
fi
|
||||
if [ "$suite" = mcp ]; then
|
||||
export INTEGRATION_WORKERS=4 INTEGRATION_COVERAGE=1
|
||||
export INTEGRATION_WORKERS=2 INTEGRATION_COVERAGE=1
|
||||
fi
|
||||
isolated_workers=false
|
||||
if [ "${INTEGRATION_WORKERS:-1}" -gt 1 ]; then
|
||||
isolated_workers=true
|
||||
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
|
||||
coverage_data="$PWD/$results/coverage/data"
|
||||
proxy_command=(.venv/bin/python -m integration._support.proxy)
|
||||
|
|
@ -180,17 +194,19 @@ 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
|
||||
export INTEGRATION_PEER_URL=http://127.0.0.1:4001
|
||||
start_proxy 4001 peer.log
|
||||
peer_pid="$launched_pid"
|
||||
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
|
||||
|
|
@ -223,13 +239,24 @@ if [ "${CIRCLE_NODE_TOTAL:-1}" -gt 1 ]; then
|
|||
shard_arguments=(--shard-files "$results/node-files.txt")
|
||||
fi
|
||||
|
||||
shared_services=()
|
||||
worker_coverage=()
|
||||
if [ "$isolated_workers" = false ]; then
|
||||
shared_services=(
|
||||
"REDIS_HOST=$REDIS_HOST" "REDIS_PORT=$REDIS_PORT"
|
||||
"INTEGRATION_PROXY_URL=$INTEGRATION_PROXY_URL" "INTEGRATION_PEER_URL=$INTEGRATION_PEER_URL"
|
||||
"INTEGRATION_UPSTREAM_URL=$INTEGRATION_UPSTREAM_URL" "INTEGRATION_MASTER_KEY=$INTEGRATION_MASTER_KEY"
|
||||
)
|
||||
elif [ "${INTEGRATION_COVERAGE:-0}" = 1 ]; then
|
||||
worker_coverage=("INTEGRATION_COVERAGE_FILE=$coverage_data")
|
||||
fi
|
||||
|
||||
env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
|
||||
INTEGRATION_RUN_ID="$integration_identity" \
|
||||
DATABASE_URL="$DATABASE_URL" REDIS_HOST="$REDIS_HOST" REDIS_PORT="$REDIS_PORT" \
|
||||
INTEGRATION_PROXY_URL="$INTEGRATION_PROXY_URL" INTEGRATION_PEER_URL="$INTEGRATION_PEER_URL" \
|
||||
INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" \
|
||||
DATABASE_URL="$DATABASE_URL" \
|
||||
"${shared_services[@]}" "${worker_coverage[@]}" \
|
||||
INTEGRATION_WORKERS="${INTEGRATION_WORKERS:-1}" \
|
||||
INTEGRATION_MASTER_KEY="$INTEGRATION_MASTER_KEY" LITELLM_MODE=PRODUCTION \
|
||||
LITELLM_MODE=PRODUCTION \
|
||||
LITELLM_LICENSE="${LITELLM_LICENSE:-}" \
|
||||
INTEGRATION_SEED="$INTEGRATION_SEED" \
|
||||
INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \
|
||||
|
|
@ -238,6 +265,7 @@ env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
|
|||
INTEGRATION_PROXY_DATABASE_URL="$INTEGRATION_PROXY_DATABASE_URL" \
|
||||
INTEGRATION_PROXY_READ_REPLICA_URL="$INTEGRATION_PROXY_READ_REPLICA_URL" \
|
||||
INTEGRATION_ROUTING="$INTEGRATION_ROUTING" \
|
||||
MCP_TEST_PEER_PYTHON="${MCP_TEST_PEER_PYTHON:-}" \
|
||||
.venv/bin/python tests/integration/run.py "$suite" --results "$results" \
|
||||
"${shard_arguments[@]}"
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from typing import Final
|
|||
from xml.etree import ElementTree
|
||||
|
||||
SUITES: Final = {
|
||||
"startup": (("test_startup.py",), 12),
|
||||
"startup": (("test_startup.py",), 13),
|
||||
"recovery": (("test_recovery.py",), 15),
|
||||
"legacy": (("test_legacy.py", "test_pooling.py"), 11),
|
||||
"upgrade": (("test_upgrade.py", "test_rolling_upgrade.py"), 5),
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
2
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
2
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
|
|
@ -68,7 +68,7 @@ After: the same request comes back with real token counts, so the dashboard show
|
|||
- [ ] The handful of test files covering my change pass locally, e.g. `uv run pytest tests/unit/<your_test_file>.py -v`. Leave the suites (`make test-unit-*`, `make test-unit`) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
|
||||
- [ ] My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
|
||||
- [ ] My PR's scope is as isolated as possible; it only solves 1 specific problem
|
||||
- [ ] I have received a Greptile **Confidence Score of at least 4/5** before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment `@greptileai` to re-request a review after pushing changes)
|
||||
- [ ] Every automated review on my PR's latest commit has finished, and I have addressed or replied to each of its findings before requesting a maintainer review
|
||||
|
||||
## Delays in PR merge?
|
||||
|
||||
|
|
|
|||
65
.github/ci-coverage-allowlist.yml
vendored
65
.github/ci-coverage-allowlist.yml
vendored
|
|
@ -18,26 +18,11 @@ 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).
|
||||
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
|
||||
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/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
|
||||
- tests/test_rust_python_harness.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
|
||||
|
|
@ -63,48 +48,6 @@ test_paths:
|
|||
choice it informed is settled
|
||||
paths:
|
||||
- tests/code_coverage_tests/test_aio_http_image_conversion.py
|
||||
- reason: >-
|
||||
No job invokes this suite and its files mix pure transformation tests with ones driving live
|
||||
vendor vector stores, so assigning them needs a per-file decision
|
||||
paths:
|
||||
- tests/vector_store_tests/rag/test_rag_bedrock.py
|
||||
- tests/vector_store_tests/rag/test_rag_openai.py
|
||||
- tests/vector_store_tests/rag/test_rag_s3_vectors.py
|
||||
- tests/vector_store_tests/rag/test_rag_vertex_ai.py
|
||||
- tests/vector_store_tests/test_azure_ai_vector_store.py
|
||||
- tests/vector_store_tests/test_azure_vector_store.py
|
||||
- tests/vector_store_tests/test_bedrock_vector_store.py
|
||||
- tests/vector_store_tests/test_gemini_vector_store.py
|
||||
- tests/vector_store_tests/test_milvus_vector_store.py
|
||||
- tests/vector_store_tests/test_openai_vector_store.py
|
||||
- tests/vector_store_tests/test_ragflow_vector_store.py
|
||||
- tests/vector_store_tests/test_s3_vectors_vector_store.py
|
||||
- tests/vector_store_tests/test_vertex_ai_search_api_vector_store.py
|
||||
- tests/vector_store_tests/test_vertex_ai_vector_store.py
|
||||
- reason: >-
|
||||
Throughput and memory-growth measurements whose runtime and variance make them unsuitable for
|
||||
a per-pull-request job
|
||||
paths:
|
||||
- tests/load_tests/test_datadog_load_test.py
|
||||
- tests/load_tests/test_langsmith_load_test.py
|
||||
- tests/load_tests/test_linear_memory_growth.py
|
||||
- tests/load_tests/test_memory_usage.py
|
||||
- tests/load_tests/test_otel_load_test.py
|
||||
- tests/load_tests/test_vertex_embeddings_load_test.py
|
||||
- tests/load_tests/test_vertex_load_tests.py
|
||||
- reason: >-
|
||||
Env-gated saturation benchmark requires a live proxy and provider credentials, so it is run
|
||||
locally rather than in pull-request jobs
|
||||
paths:
|
||||
- tests/load_tests/test_granian_admission_saturation.py
|
||||
- reason: >-
|
||||
A local-only agent rig: test_a2a_completion_bridge.py needs a LangGraph server on
|
||||
localhost:2024 and test_a2a.py drives a live A2A endpoint, so neither can run in a
|
||||
pull request job. Until 2026-08-20 the CircleCI agent job hid them behind a grep -v
|
||||
that this census could not see; the glob now excludes them structurally and this entry
|
||||
is the decision on the record. Revisit when the A2A bridge gets a recorded-wire fixture
|
||||
paths:
|
||||
- tests/agent_tests/local_only_agent_tests
|
||||
- reason: >-
|
||||
Third-party integration tests that skip themselves without OCI configuration or sandbox
|
||||
credentials, neither of which a pull request job holds
|
||||
|
|
|
|||
1
.github/scripts/assert_ci_coverage.py
vendored
1
.github/scripts/assert_ci_coverage.py
vendored
|
|
@ -37,7 +37,6 @@ GLOB_CHARS = frozenset("*?")
|
|||
# tests has to be named by some shard or it runs nowhere. A child listed here is
|
||||
# itself decomposed one level deeper and is checked through its own entry.
|
||||
SHARDED_ROOTS: tuple[str, ...] = (
|
||||
"tests/test_litellm",
|
||||
"tests/unit",
|
||||
"tests/unit/enterprise",
|
||||
"tests/unit/enterprise/enterprise_callbacks",
|
||||
|
|
|
|||
30
.github/workflows/deploy-dev-on-main.yml
vendored
Normal file
30
.github/workflows/deploy-dev-on-main.yml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
name: Deploy main to gateway-dev
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: deploy-dev-dispatch
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
if: github.repository == 'BerriAI/litellm'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
|
||||
id: token
|
||||
with:
|
||||
app-id: ${{ secrets.LITELLM_OPS_DISPATCH_APP_ID }}
|
||||
private-key: ${{ secrets.LITELLM_OPS_DISPATCH_APP_PRIVATE_KEY }}
|
||||
owner: BerriAI
|
||||
repositories: litellm-ops
|
||||
permission-actions: write
|
||||
- name: Dispatch the litellm-ops dev deploy
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
run: gh api --method POST repos/BerriAI/litellm-ops/actions/workflows/deploy-dev.yml/dispatches -f ref=main
|
||||
14
.github/workflows/image-scan.yml
vendored
14
.github/workflows/image-scan.yml
vendored
|
|
@ -27,7 +27,7 @@ on:
|
|||
- backend/routes/allowlist.py
|
||||
- pyproject.toml
|
||||
- litellm-proxy-extras/**
|
||||
- tests/proxy_migration_tests/**
|
||||
- tests/integration/database/image/**
|
||||
- uv.lock
|
||||
- ui/litellm-dashboard/package-lock.json
|
||||
- ui/Dockerfile
|
||||
|
|
@ -92,7 +92,7 @@ jobs:
|
|||
LITELLM_ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }}
|
||||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py tests/proxy_migration_tests/test_image_admin_mcp.py -v
|
||||
python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v
|
||||
|
||||
# Scans the whole shipped artifact: OS/apk plus every language package
|
||||
# baked into the image, including ones no lockfile declares (e.g. prisma's
|
||||
|
|
@ -145,7 +145,7 @@ jobs:
|
|||
LITELLM_ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }}
|
||||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py tests/proxy_migration_tests/test_image_admin_mcp.py -v
|
||||
python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v
|
||||
|
||||
|
||||
migrations-image:
|
||||
|
|
@ -177,7 +177,7 @@ jobs:
|
|||
LITELLM_MIGRATION_SCRIPT: /app/run.py
|
||||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v
|
||||
python -m pytest --noconftest tests/integration/database/image/test_offline_image_migration.py -v
|
||||
|
||||
gateway-image:
|
||||
name: gateway-image
|
||||
|
|
@ -208,7 +208,7 @@ jobs:
|
|||
LITELLM_IMAGE_COMPONENT: gateway
|
||||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py tests/proxy_migration_tests/test_image_admin_mcp.py -v
|
||||
python -m pytest --noconftest tests/integration/database/image/test_component_image_serves_offline.py tests/integration/database/image/test_image_bedrock_realtime_extra.py tests/integration/database/image/test_image_admin_mcp.py -v
|
||||
|
||||
ui-image:
|
||||
name: ui-image
|
||||
|
|
@ -237,7 +237,7 @@ jobs:
|
|||
LITELLM_IMAGE: litellm-ui-scan:${{ github.sha }}
|
||||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_ui_image_serves_offline.py -v
|
||||
python -m pytest --noconftest tests/integration/database/image/test_ui_image_serves_offline.py -v
|
||||
|
||||
backend-image:
|
||||
name: backend-image
|
||||
|
|
@ -272,4 +272,4 @@ jobs:
|
|||
LITELLM_IMAGE_COMPONENT: backend
|
||||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py tests/proxy_migration_tests/test_image_admin_mcp.py -v
|
||||
python -m pytest --noconftest tests/integration/database/image/test_component_image_serves_offline.py tests/integration/database/image/test_image_admin_mcp.py -v
|
||||
|
|
|
|||
4
.github/workflows/test-e2e-redis-chaos.yml
vendored
4
.github/workflows/test-e2e-redis-chaos.yml
vendored
|
|
@ -91,12 +91,12 @@ jobs:
|
|||
|
||||
- name: Run the Redis chaos load test
|
||||
env:
|
||||
E2E_REDIS_CHAOS: "1"
|
||||
E2E_PERF_TESTS: "1"
|
||||
LITELLM_PROXY_URL: http://localhost:4000
|
||||
REDIS_HOST: 127.0.0.1
|
||||
REDIS_PORT: "6379"
|
||||
run: |
|
||||
uv run --no-sync pytest tests/e2e/load/test_redis_chaos_e2e.py -v --tb=short -rA -s
|
||||
uv run --no-sync pytest tests/e2e/perf/test_redis_chaos_e2e.py -v --tb=short -rA -s
|
||||
|
||||
- name: Show proxy log on failure
|
||||
if: failure()
|
||||
|
|
|
|||
22
.github/workflows/test-linting.yml
vendored
22
.github/workflows/test-linting.yml
vendored
|
|
@ -22,6 +22,16 @@ jobs:
|
|||
actions: read
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
services:
|
||||
redis:
|
||||
image: redis@sha256:e2debfb7956fa12c7ddc79d7e645c8cf26b30c99a6e9161ea9bf4171e1668a5f
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
|
|
@ -189,8 +199,9 @@ jobs:
|
|||
if: steps.changes.outputs.decision != 'skip'
|
||||
env:
|
||||
LITELLM_MASTER_KEY: sk-e2e-harness-tests-reach-no-proxy
|
||||
E2E_CACHE_TEST_REDIS_URL: redis://127.0.0.1:6379/0
|
||||
run: |
|
||||
if ! git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- tests/e2e tests/e2e_harness ':(exclude)tests/e2e/ui' pyproject.toml uv.lock .github/workflows/test-linting.yml | grep -q .; then
|
||||
if ! git diff --name-only --diff-filter=ACMRD "$GATE_BASE_SHA" HEAD -- tests/e2e tests/e2e_harness ':(exclude)tests/e2e/ui' tests/code_coverage_tests/test_provider_replay_harness.py tests/code_coverage_tests/test_provider_cache.py pyproject.toml uv.lock .github/workflows/test-linting.yml | grep -q .; then
|
||||
echo "No changed e2e harness files; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -198,6 +209,9 @@ jobs:
|
|||
CLAUDE_VERSION="$(retry uv run --no-sync python tests/e2e/claude_code/pr_gate_version_resolver.py)"
|
||||
tests/e2e/claude_code/cron_vm/install_claude_code.sh "$CLAUDE_VERSION" "$RUNNER_TEMP/claude-cli"
|
||||
PATH="$RUNNER_TEMP/claude-cli:$PATH" uv run --no-sync pytest -q tests/e2e_harness
|
||||
uv run --no-sync pytest --tb=short -q --noconftest -o addopts= -o "pythonpath=tests/e2e tests/e2e_harness" -p no:rerunfailures \
|
||||
tests/code_coverage_tests/test_provider_replay_harness.py \
|
||||
tests/code_coverage_tests/test_provider_cache.py
|
||||
|
||||
- name: Check for circular imports
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
|
|
@ -601,12 +615,6 @@ jobs:
|
|||
- name: check_migrations_no_data_rewrites
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/check_migrations_no_data_rewrites.py
|
||||
|
||||
- name: check_greptile_files_json
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/check_greptile_files_json.py
|
||||
|
||||
- name: test_check_greptile_files_json
|
||||
run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_check_greptile_files_json.py
|
||||
|
||||
- name: check_no_publicly_known_master_key
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/check_no_publicly_known_master_key.py
|
||||
|
||||
|
|
|
|||
25
.github/workflows/test-merge-smoke.yml
vendored
25
.github/workflows/test-merge-smoke.yml
vendored
|
|
@ -234,7 +234,30 @@ jobs:
|
|||
exit 1
|
||||
|
||||
- name: Run the startup tests
|
||||
run: .venv/bin/python -m pytest --tb=short -vv tests/basic_proxy_startup_tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_url=http://localhost:4000
|
||||
readiness_response="$(curl -sS --fail-with-body --write-out $'\n%{http_code}' "${base_url}/health/readiness")"
|
||||
readiness_status="${readiness_response##*$'\n'}"
|
||||
readiness_body="${readiness_response%$'\n'*}"
|
||||
[[ "${readiness_status}" == "200" ]]
|
||||
jq -e '.status == "healthy" or .status == "connected"' <<< "${readiness_body}" > /dev/null
|
||||
liveness_status="$(curl -sS --fail-with-body --output /dev/null --write-out '%{http_code}' "${base_url}/health/liveness")"
|
||||
[[ "${liveness_status}" == "200" ]]
|
||||
completion_response="$(curl -sS --fail-with-body --write-out $'\n%{http_code}' \
|
||||
--header "Authorization: Bearer ${LITELLM_MASTER_KEY}" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{"model":"gpt-4","messages":[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Hello!"}]}' \
|
||||
"${base_url}/chat/completions")"
|
||||
completion_status="${completion_response##*$'\n'}"
|
||||
completion_body="${completion_response%$'\n'*}"
|
||||
[[ "${completion_status}" == "200" ]]
|
||||
jq -e '
|
||||
(.choices | length) == 1 and
|
||||
.choices[0].message.role == "assistant" and
|
||||
.choices[0].message.content == "Hello! This is a mock response from the fake OpenAI endpoint."
|
||||
' <<< "${completion_body}" > /dev/null
|
||||
|
||||
- name: Show the proxy logs
|
||||
if: always()
|
||||
|
|
|
|||
2
.github/workflows/test-rust.yml
vendored
2
.github/workflows/test-rust.yml
vendored
|
|
@ -18,7 +18,6 @@ on:
|
|||
- "litellm/ocr/**"
|
||||
- "litellm/llms/base_llm/ocr/**"
|
||||
- "litellm/llms/custom_httpx/llm_http_handler.py"
|
||||
- "tests/test_litellm/conftest.py"
|
||||
- "Makefile"
|
||||
- ".cargo/**"
|
||||
- "pyproject.toml"
|
||||
|
|
@ -49,7 +48,6 @@ on:
|
|||
- "litellm/ocr/**"
|
||||
- "litellm/llms/base_llm/ocr/**"
|
||||
- "litellm/llms/custom_httpx/llm_http_handler.py"
|
||||
- "tests/test_litellm/conftest.py"
|
||||
- "Makefile"
|
||||
- ".cargo/**"
|
||||
- "pyproject.toml"
|
||||
|
|
|
|||
180
.github/workflows/test-unit.yml
vendored
180
.github/workflows/test-unit.yml
vendored
|
|
@ -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
|
||||
|
|
@ -173,8 +174,6 @@ jobs:
|
|||
|
||||
- shard: integrations
|
||||
test-path: |-
|
||||
tests/test_litellm/integrations
|
||||
tests/test_litellm/tracing
|
||||
tests/unit/integrations
|
||||
python-version: "3.12"
|
||||
workers: 4
|
||||
|
|
@ -291,6 +290,7 @@ jobs:
|
|||
tests/unit/llms/manus
|
||||
tests/unit/llms/meta_llama
|
||||
tests/unit/llms/microsoft_365_copilot
|
||||
tests/unit/llms/milvus
|
||||
tests/unit/llms/minimax
|
||||
tests/unit/llms/mistral
|
||||
tests/unit/llms/modelscope
|
||||
|
|
@ -375,7 +375,6 @@ jobs:
|
|||
|
||||
- shard: root
|
||||
test-path: >-
|
||||
tests/test_litellm/test_*.py
|
||||
tests/unit/test_*.py
|
||||
python-version: "3.12"
|
||||
workers: 4
|
||||
|
|
@ -394,16 +393,6 @@ jobs:
|
|||
job-timeout-minutes: 60
|
||||
dist: loadscope
|
||||
|
||||
- shard: router-unit-tests
|
||||
test-path: >-
|
||||
tests/router_unit_tests
|
||||
python-version: "3.12"
|
||||
workers: 0
|
||||
reruns: 0
|
||||
timeout-minutes: 10
|
||||
job-timeout-minutes: 60
|
||||
dist: loadscope
|
||||
|
||||
- shard: endpoints
|
||||
test-path: >-
|
||||
tests/unit/a2a_protocol
|
||||
|
|
@ -450,6 +439,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
|
||||
|
|
@ -773,7 +763,7 @@ jobs:
|
|||
|
||||
- shard: proxy-server-core
|
||||
test-path: |-
|
||||
tests/proxy_unit_tests/test_proxy_server_gemini_pass_through.py
|
||||
tests/unit/proxy/pass_through_endpoints/test_llm_passthrough_endpoints.py
|
||||
tests/unit/proxy/test__lazy_features.py
|
||||
tests/unit/proxy/test_aproxy_startup.py
|
||||
tests/unit/proxy/test_proxy_server.py
|
||||
|
|
@ -804,6 +794,10 @@ jobs:
|
|||
tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py
|
||||
tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py
|
||||
tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py
|
||||
tests/unit/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py
|
||||
tests/unit/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py
|
||||
tests/unit/proxy/_experimental/mcp_server/test_mcp_max_concurrent_requests.py
|
||||
tests/unit/proxy/_experimental/mcp_server/faults/test_list_outcomes.py
|
||||
tests/unit/proxy/_experimental/mcp_server/outbound_credentials
|
||||
python-version: "3.12"
|
||||
workers: 4
|
||||
|
|
@ -812,9 +806,31 @@ jobs:
|
|||
job-timeout-minutes: 60
|
||||
dist: loadscope
|
||||
|
||||
- shard: mcp-server
|
||||
test-path: |-
|
||||
tests/unit/experimental_mcp_client
|
||||
tests/unit/proxy/_experimental/mcp_server
|
||||
tests/unit/responses/mcp
|
||||
--ignore=tests/unit/experimental_mcp_client/test_mcp_client.py
|
||||
--ignore=tests/unit/proxy/_experimental/mcp_server/test_capabilities.py
|
||||
--ignore=tests/unit/proxy/_experimental/mcp_server/test_interactions.py
|
||||
--ignore=tests/unit/proxy/_experimental/mcp_server/test_mcp_elicitation_handler.py
|
||||
--ignore=tests/unit/proxy/_experimental/mcp_server/test_mcp_server_manager.py
|
||||
--ignore=tests/unit/proxy/_experimental/mcp_server/test_mcp_server_tool_calls_and_headers.py
|
||||
--ignore=tests/unit/proxy/_experimental/mcp_server/test_operations.py
|
||||
--ignore=tests/unit/proxy/_experimental/mcp_server/test_discoverable_endpoints.py
|
||||
--ignore=tests/unit/proxy/_experimental/mcp_server/test_db_credentials.py
|
||||
--ignore=tests/unit/proxy/_experimental/mcp_server/outbound_credentials
|
||||
python-version: "3.12"
|
||||
workers: 4
|
||||
reruns: 0
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 60
|
||||
dist: loadscope
|
||||
|
||||
- shard: custom-logging
|
||||
test-path: |-
|
||||
tests/proxy_unit_tests/test_proxy_custom_logger.py
|
||||
tests/unit/proxy/test_proxy_server_custom_logger.py
|
||||
tests/unit/proxy/test_custom_callback_input.py
|
||||
tests/unit/proxy/test_custom_logger_s3_gcs.py
|
||||
python-version: "3.12"
|
||||
|
|
@ -864,17 +880,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
|
||||
|
|
@ -889,7 +894,7 @@ jobs:
|
|||
|
||||
- shard: endpoints-and-responses
|
||||
test-path: |-
|
||||
tests/proxy_unit_tests/test_proxy_exception_mapping.py
|
||||
tests/unit/proxy/test_proxy_server_exception_mapping.py
|
||||
tests/unit/proxy/lens
|
||||
tests/unit/proxy/auth/test_models_fallback_endpoint.py
|
||||
tests/unit/proxy/common_utils/test_check_batch_cost.py
|
||||
|
|
@ -1345,6 +1350,115 @@ jobs:
|
|||
- name: Check consumer types with the installed SDK
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: uv run --no-sync basedpyright --pythonpath .venv/bin/python --project tests/typing/pyrightconfig.json
|
||||
redis-compat:
|
||||
name: redis-py ${{ matrix.redis-py }} compatibility
|
||||
needs: rust-bridge
|
||||
if: ${{ !cancelled() }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
redis-py: ["5.3.1", "6.4.0", "7.4.1", "8.0.1"]
|
||||
env:
|
||||
UV_PYTHON: "3.12"
|
||||
LITELLM_LOCAL_MODEL_COST_MAP: "True"
|
||||
REDIS_PY_VERSION: ${{ matrix.redis-py }}
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
timeout-minutes: 3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect relevant changes
|
||||
id: changes
|
||||
timeout-minutes: 2
|
||||
uses: ./.github/actions/detect-changes
|
||||
with:
|
||||
category: redis-compat
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
timeout-minutes: 3
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
|
||||
- name: Set up uv
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
timeout-minutes: 3
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Cache uv dependencies
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
timeout-minutes: 5
|
||||
uses: ./.github/actions/cache-uv-downloads
|
||||
|
||||
- name: Set up the Rust build
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
timeout-minutes: 5
|
||||
uses: ./.github/actions/rust-bridge
|
||||
with:
|
||||
artifact: ${{ needs.rust-bridge.outputs.artifact }}
|
||||
|
||||
- name: Install dependencies with redis-py ${{ matrix.redis-py }}
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
RUST_BRIDGE_ARTIFACT: ${{ needs.rust-bridge.outputs.artifact }}
|
||||
run: |
|
||||
if [ -z "$RUST_BRIDGE_ARTIFACT" ]; then
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra caching --extra extra_proxy --extra proxy-runtime --extra utils
|
||||
else
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra caching --extra extra_proxy --extra proxy-runtime --extra utils --no-install-project
|
||||
uv pip install --no-deps --python .venv/bin/python rust-bridge-dist/*.whl
|
||||
cp rust-bridge-dist/litellm/rust_bridge/_native.abi3.so litellm/rust_bridge/_native.abi3.so
|
||||
fi
|
||||
uv pip install --python .venv/bin/python "redis==${REDIS_PY_VERSION:?}"
|
||||
uv run --no-sync python -c 'import os, redis; assert redis.__version__ == os.environ["REDIS_PY_VERSION"], redis.__version__'
|
||||
|
||||
- name: Install redis-server 7.2.16
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/redis-bin"
|
||||
cid="$(docker create redis:7.2.16@sha256:0637954999d01b7c9ce9167db2da50656e2590d3b884f1c600c5f63bb6e6773c)"
|
||||
docker cp "${cid}:/usr/local/bin/redis-server" "$RUNNER_TEMP/redis-bin/redis-server"
|
||||
docker rm "$cid"
|
||||
"$RUNNER_TEMP/redis-bin/redis-server" --version | grep -q 'v=7.2.16'
|
||||
echo "$RUNNER_TEMP/redis-bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run Redis compatibility tests
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
COVERAGE_CORE: sysmon
|
||||
run: |
|
||||
uv run --no-sync pytest \
|
||||
tests/unit/test_redis.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 \
|
||||
tests/integration/sdk/test_redis_cluster_iam_auth.py \
|
||||
--tb=short -vv -rs -p no:rerunfailures -p no:pytest-retry --timeout=120 --durations=20 \
|
||||
--cov=./litellm --cov-report=xml:coverage.xml
|
||||
|
||||
- name: Upload redis-py ${{ matrix.redis-py }} coverage to Codecov
|
||||
if: always() && steps.changes.outputs.decision != 'skip' && matrix.redis-py == '5.3.1'
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
|
||||
with:
|
||||
use_oidc: true
|
||||
files: coverage.xml
|
||||
root_dir: ${{ github.workspace }}
|
||||
flags: redis-compat
|
||||
fail_ci_if_error: false
|
||||
coverage:
|
||||
name: coverage
|
||||
needs: unit
|
||||
|
|
@ -1390,10 +1504,20 @@ jobs:
|
|||
root_dir: ${{ github.workspace }}
|
||||
flags: unit
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload MCP server coverage under its own flag
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
|
||||
with:
|
||||
use_oidc: true
|
||||
files: coverage-reports/coverage-mcp-server-${{ github.run_id }}-${{ github.run_attempt }}/coverage.xml
|
||||
root_dir: ${{ github.workspace }}
|
||||
flags: mcp-integration
|
||||
fail_ci_if_error: false
|
||||
unit-passed:
|
||||
name: unit passed
|
||||
permissions: {}
|
||||
needs: [rust-bridge, assert-shard-coverage, unit, ui-unit, docs, helm, openai-sdk-compat]
|
||||
needs: [rust-bridge, assert-shard-coverage, unit, ui-unit, docs, helm, openai-sdk-compat, redis-compat]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
|
|
|
|||
4
.github/workflows/weekly_load_anomaly.yml
vendored
4
.github/workflows/weekly_load_anomaly.yml
vendored
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
|
||||
- name: Start the proxy
|
||||
run: |
|
||||
nohup uv run --no-sync litellm --config tests/e2e/load/weekly_anomaly_config.yml --port 4000 > proxy.log 2>&1 &
|
||||
nohup uv run --no-sync litellm --config tests/e2e/perf/weekly_anomaly_config.yml --port 4000 > proxy.log 2>&1 &
|
||||
for _ in $(seq 1 90); do
|
||||
if curl -fs http://localhost:4000/health/liveliness > /dev/null; then
|
||||
exit 0
|
||||
|
|
@ -78,7 +78,7 @@ jobs:
|
|||
env:
|
||||
E2E_WEEKLY_ANOMALY: "1"
|
||||
run: |
|
||||
uv run --no-sync pytest tests/e2e/load/test_weekly_session_anomaly_e2e.py -v --tb=short -rA
|
||||
uv run --no-sync pytest tests/e2e/perf/test_weekly_session_anomaly_e2e.py -v --tb=short -rA
|
||||
|
||||
- name: Show proxy log on failure
|
||||
if: failure()
|
||||
|
|
|
|||
|
|
@ -1,473 +0,0 @@
|
|||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "litellm/_v2/AGENTS.md",
|
||||
"description": "Conventions for code under litellm/_v2/",
|
||||
"scope": [
|
||||
"litellm/_v2/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm/_v2/cache/AGENTS.md",
|
||||
"description": "Conventions for code under litellm/_v2/cache/",
|
||||
"scope": [
|
||||
"litellm/_v2/cache/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm/proxy/_experimental/mcp_server/AGENTS.md",
|
||||
"description": "Conventions for code under litellm/proxy/_experimental/mcp_server/",
|
||||
"scope": [
|
||||
"litellm/proxy/_experimental/mcp_server/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm/rust_bridge/AGENTS.md",
|
||||
"description": "Conventions for code under litellm/rust_bridge/",
|
||||
"scope": [
|
||||
"litellm/rust_bridge/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm/tracing/AGENTS.md",
|
||||
"description": "Conventions for code under litellm/tracing/",
|
||||
"scope": [
|
||||
"litellm/tracing/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/",
|
||||
"scope": [
|
||||
"litellm-rust/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/cache-response/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/cache-response/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/cache-response/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/callbacks-legacy-python/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/callbacks-legacy-python/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/callbacks-legacy-python/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/coroutine/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/coroutine/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/coroutine/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/gateway/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/gateway/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/gateway/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/gateway-auth/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/gateway-auth/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/gateway-auth/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/gateway-inference/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/gateway-inference/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/gateway-inference/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/gateway-mcp/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/gateway-mcp/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/gateway-mcp/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/host/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/host/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/host/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/host/src/machine/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/host/src/machine/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/host/src/machine/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/host-http/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/host-http/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/host-http/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/host-native/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/host-native/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/host-native/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/host-python/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/host-python/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/host-python/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/http/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/http/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/http/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/inference/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/inference/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/inference/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/inference-chat/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/inference-chat/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/inference-chat/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/inference-messages/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/inference-messages/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/inference-messages/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/inference-ocr/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/inference-ocr/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/inference-ocr/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/inference-responses/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/inference-responses/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/inference-responses/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/inference-transcription/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/inference-transcription/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/inference-transcription/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms/src/anthropic/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms/src/anthropic/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms/src/anthropic/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms/src/anthropic/batches/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms/src/anthropic/batches/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms/src/anthropic/batches/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms/src/anthropic/count_tokens/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms/src/anthropic/count_tokens/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms/src/anthropic/count_tokens/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms/src/anthropic/messages/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms/src/anthropic/messages/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms/src/anthropic/messages/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms/src/aws_textract/ocr/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms/src/aws_textract/ocr/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms/src/aws_textract/ocr/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms/src/azure_ai/messages/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms/src/azure_ai/messages/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms/src/azure_ai/messages/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms/src/base_llm/messages/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms/src/base_llm/messages/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms/src/base_llm/messages/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms/src/bedrock/messages/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms/src/bedrock/messages/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms/src/bedrock/messages/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms-types/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms-types/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms-types/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms-types/src/formats/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms-types/src/formats/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms-types/src/formats/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms-types/src/formats/messages/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms-types/src/formats/messages/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms-types/src/formats/messages/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/llms-types/src/providers/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/llms-types/src/providers/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/llms-types/src/providers/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/model-catalog/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/model-catalog/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/model-catalog/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/python-bridge/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/python-bridge/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/python-bridge/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/python-bridge/src/cache/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/python-bridge/src/cache/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/python-bridge/src/cache/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/python-bridge/src/cache/native/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/python-bridge/src/cache/native/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/python-bridge/src/cache/native/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/python-bridge/src/cache/python/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/python-bridge/src/cache/python/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/python-bridge/src/cache/python/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/python-bridge/src/routes/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/python-bridge/src/routes/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/python-bridge/src/routes/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/python-compat/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/python-compat/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/python-compat/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/secrets-aws/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/secrets-aws/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/secrets-aws/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/secrets-azure/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/secrets-azure/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/secrets-azure/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/secrets-cyberark/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/secrets-cyberark/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/secrets-cyberark/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/secrets-google/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/secrets-google/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/secrets-google/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/secrets-hashicorp/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/secrets-hashicorp/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/secrets-hashicorp/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/crates/storage-clickhouse/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/crates/storage-clickhouse/",
|
||||
"scope": [
|
||||
"litellm-rust/crates/storage-clickhouse/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "litellm-rust/docs/adrs/AGENTS.md",
|
||||
"description": "Conventions for code under litellm-rust/docs/adrs/",
|
||||
"scope": [
|
||||
"litellm-rust/docs/adrs/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/AGENTS.md",
|
||||
"description": "Conventions for code under tests/",
|
||||
"scope": [
|
||||
"tests/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/e2e/AGENTS.md",
|
||||
"description": "Conventions for code under tests/e2e/",
|
||||
"scope": [
|
||||
"tests/e2e/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/e2e_harness/AGENTS.md",
|
||||
"description": "Conventions for code under tests/e2e_harness/",
|
||||
"scope": [
|
||||
"tests/e2e_harness/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/integration/AGENTS.md",
|
||||
"description": "Conventions for code under tests/integration/",
|
||||
"scope": [
|
||||
"tests/integration/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/integration/translation/AGENTS.md",
|
||||
"description": "Conventions for code under tests/integration/translation/",
|
||||
"scope": [
|
||||
"tests/integration/translation/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/rust-python-harness/AGENTS.md",
|
||||
"description": "Conventions for code under tests/rust-python-harness/",
|
||||
"scope": [
|
||||
"tests/rust-python-harness/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/rust-python-harness/strategies/e2e_parity/AGENTS.md",
|
||||
"description": "Conventions for code under tests/rust-python-harness/strategies/e2e_parity/",
|
||||
"scope": [
|
||||
"tests/rust-python-harness/strategies/e2e_parity/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/rust-python-harness/strategies/trace_parity/AGENTS.md",
|
||||
"description": "Conventions for code under tests/rust-python-harness/strategies/trace_parity/",
|
||||
"scope": [
|
||||
"tests/rust-python-harness/strategies/trace_parity/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/rust-python-harness/strategies/unit_tests_parity/AGENTS.md",
|
||||
"description": "Conventions for code under tests/rust-python-harness/strategies/unit_tests_parity/",
|
||||
"scope": [
|
||||
"tests/rust-python-harness/strategies/unit_tests_parity/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/rust-python-harness/strategies/unit_tests_rust/AGENTS.md",
|
||||
"description": "Conventions for code under tests/rust-python-harness/strategies/unit_tests_rust/",
|
||||
"scope": [
|
||||
"tests/rust-python-harness/strategies/unit_tests_rust/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/test_litellm_rust/AGENTS.md",
|
||||
"description": "Conventions for code under tests/test_litellm_rust/",
|
||||
"scope": [
|
||||
"tests/test_litellm_rust/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/unit/AGENTS.md",
|
||||
"description": "Conventions for code under tests/unit/",
|
||||
"scope": [
|
||||
"tests/unit/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "tests/unit/rust_bridge/AGENTS.md",
|
||||
"description": "Conventions for code under tests/unit/rust_bridge/",
|
||||
"scope": [
|
||||
"tests/unit/rust_bridge/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "ui/litellm-dashboard/AGENTS.md",
|
||||
"description": "Conventions for code under ui/litellm-dashboard/",
|
||||
"scope": [
|
||||
"ui/litellm-dashboard/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "ui/litellm-dashboard/src/components/chat/AGENTS.md",
|
||||
"description": "Conventions for code under ui/litellm-dashboard/src/components/chat/",
|
||||
"scope": [
|
||||
"ui/litellm-dashboard/src/components/chat/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -130,4 +130,4 @@ Before implementing:
|
|||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify
|
||||
|
||||
Before requesting maintainer review, verify the current PR tip passes required CI and code coverage, meets Greptile confidence of at least 4/5, and has acceptable Veria and Bugbot reviews. Inspect warnings and findings, fix actionable issues, and rerun the affected checks and reviewers after changes. Record evidence for any false positive or unavailable review. Never treat a pending or missing bot result as a pass. Do not lower coverage thresholds or raise `ANY_CAPS` to satisfy a check
|
||||
Before requesting maintainer review, verify the current PR tip passes required CI and code coverage and has acceptable reviews from the review bots on the PR, such as Veria and Bugbot. Inspect warnings and findings, fix actionable issues, and rerun the affected checks and reviewers after changes. Record evidence for any false positive or unavailable review. Never treat a pending or missing bot result as a pass. Do not lower coverage thresholds or raise `ANY_CAPS` to satisfy a check
|
||||
|
|
|
|||
8
Makefile
8
Makefile
|
|
@ -39,7 +39,7 @@ help:
|
|||
@echo " make check-circular-imports - Check for circular imports"
|
||||
@echo " make check-import-safety - Check import safety"
|
||||
@echo " make test - Run all tests"
|
||||
@echo " make test-unit - Run unit tests (tests/unit and tests/test_litellm)"
|
||||
@echo " make test-unit - Run unit tests (tests/unit)"
|
||||
@echo " make test-unit-llms - Run LLM provider tests (~225 files)"
|
||||
@echo " make test-unit-proxy-guardrails - Run proxy guardrails+mgmt tests (~51 files)"
|
||||
@echo " make test-unit-proxy-core - Run proxy auth+client+db+hooks tests (~52 files)"
|
||||
|
|
@ -300,7 +300,7 @@ test: install-test-deps
|
|||
$(UV_RUN) pytest tests/
|
||||
|
||||
test-unit: install-test-deps
|
||||
$(UV_RUN) pytest tests/unit tests/test_litellm -x -vv -n 4
|
||||
$(UV_RUN) pytest tests/unit -x -vv -n 4
|
||||
|
||||
# Matrix test targets (matching CI workflow groups)
|
||||
test-unit-llms: install-test-deps
|
||||
|
|
@ -328,7 +328,7 @@ test-unit-other: install-test-deps
|
|||
$(UV_RUN) pytest tests/unit/caching tests/unit/responses tests/unit/secret_managers tests/unit/vector_stores tests/unit/a2a_protocol tests/unit/completion_extras tests/unit/containers tests/unit/enterprise tests/unit/experimental_mcp_client tests/unit/google_genai tests/unit/images tests/unit/interactions tests/unit/router_strategy tests/unit/router_utils tests/unit/types --tb=short -vv -n 4 --durations=20
|
||||
|
||||
test-unit-root: install-test-deps
|
||||
$(UV_RUN) pytest tests/unit/test_*.py tests/test_litellm/test_*.py --tb=short -vv -n 4 --durations=20
|
||||
$(UV_RUN) pytest tests/unit/test_*.py --tb=short -vv -n 4 --durations=20
|
||||
|
||||
# Proxy unit tests (tests/unit/proxy split alphabetically)
|
||||
test-proxy-unit-a: install-test-deps
|
||||
|
|
@ -338,7 +338,7 @@ test-proxy-unit-b: install-test-deps
|
|||
$(UV_RUN) pytest tests/unit/proxy/test_[p-z]*.py tests/unit/skills --tb=short -vv -n 2 --durations=20
|
||||
|
||||
test-integration: install-test-deps
|
||||
$(UV_RUN) pytest tests/ -k "not test_litellm"
|
||||
$(UV_RUN) pytest tests/
|
||||
|
||||
test-unit-helm: install-helm-unittest
|
||||
helm unittest -f 'tests/*.yaml' helm/litellm-helm
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<a href="https://render.com/deploy?repo=https://github.com/BerriAI/litellm" target="_blank" rel="nofollow"><img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" height="40"></a>
|
||||
<a href="https://railway.com/deploy/RhvhdC?referralCode=7mRv9K&utm_medium=integration&utm_source=template&utm_campaign=generic"><img src="https://railway.com/button.svg" alt="Deploy on Railway" height="40"></a>
|
||||
<a href="https://console.aws.amazon.com/cloudshell/home" target="_blank" rel="nofollow"><img src="./.github/deploy-on-aws.png" alt="Deploy on AWS" height="40"></a>
|
||||
<a href="https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Flitellm&cloudshell_workspace=terraform%2Flitellm%2Fgcp%2Fexamples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true" target="_blank" rel="nofollow"><img src="./.github/deploy-on-gcp.png" alt="Deploy on GCP" height="40"></a>
|
||||
<a href="https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Fterraform-google-litellm&cloudshell_git_branch=v1.105.0&cloudshell_workspace=examples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true" target="_blank" rel="nofollow"><img src="./.github/deploy-on-gcp.png" alt="Deploy on GCP" height="40"></a>
|
||||
</p>
|
||||
</p>
|
||||
<h4 align="center"><a href="https://docs.litellm.ai/docs/simple_proxy" target="_blank">LiteLLM Proxy Server (AI Gateway)</a> | <a href="https://docs.litellm.ai/docs/enterprise#hosted-litellm-proxy" target="_blank"> Hosted Proxy</a> | <a href="https://litellm.ai/enterprise"target="_blank">Enterprise Tier</a> | <a href="https://www.litellm.ai/ai-gateway" target="_blank">Website</a></h4>
|
||||
|
|
@ -342,6 +342,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
|
|||
| [Cohere Chat (`cohere_chat`)](https://docs.litellm.ai/docs/providers/cohere) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [CometAPI (`cometapi`)](https://docs.litellm.ai/docs/providers/cometapi) | ✅ | ✅ | ✅ | ✅ | | | | | | |
|
||||
| [CompactifAI (`compactifai`)](https://docs.litellm.ai/docs/providers/compactifai) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [CoralBricks (`coralbricks`)](https://docs.litellm.ai/docs/providers/coralbricks) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Custom (`custom`)](https://docs.litellm.ai/docs/providers/custom_llm_server) | ✅ | ✅ | ✅ | | | | | | | |
|
||||
| [Custom OpenAI (`custom_openai`)](https://docs.litellm.ai/docs/providers/openai_compatible) | ✅ | ✅ | ✅ | | | ✅ | ✅ | ✅ | ✅ | |
|
||||
| [Dashscope (`dashscope`)](https://docs.litellm.ai/docs/providers/dashscope) | ✅ | ✅ | ✅ | ✅ | | | | | | ✅ |
|
||||
|
|
@ -526,9 +527,9 @@ Provider API keys live in AWS Secrets Manager; reference ARNs via `gateway_extra
|
|||
|
||||
#### GCP — Cloud Run + Cloud SQL + Memorystore + HTTPS LB
|
||||
|
||||
[](https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Flitellm&cloudshell_workspace=terraform%2Flitellm%2Fgcp%2Fexamples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true)
|
||||
[](https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FBerriAI%2Fterraform-google-litellm&cloudshell_git_branch=v1.105.0&cloudshell_workspace=examples%2Fdefault&cloudshell_tutorial=TUTORIAL.md&cloudshell_image=gcr.io/ds-artifacts-cloudshell/deploystack_custom_image&shellonly=true)
|
||||
|
||||
Real 1-click. Opens Cloud Shell, clones this repo, and walks you through `terraform apply` via a built-in [DeployStack tutorial](./terraform/litellm/gcp/examples/default/TUTORIAL.md) — pick the project, the tutorial sets up the Artifact Registry remote repo, writes `terraform.tfvars` from your answers, and runs apply.
|
||||
Real 1-click. Opens Cloud Shell, clones the [terraform-google-litellm](https://github.com/BerriAI/terraform-google-litellm) module mirror at the latest stable release (a small download instead of the full litellm repo), and walks you through `terraform apply` via a built-in [DeployStack tutorial](./terraform/litellm/gcp/examples/default/TUTORIAL.md). Pick the project, the tutorial sets up the Artifact Registry remote repo, writes `terraform.tfvars` from your answers, and runs apply
|
||||
|
||||
[Module page →](https://registry.terraform.io/modules/BerriAI/litellm/google/latest)
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ def _rendered_schema(cost_map: CostMap) -> str:
|
|||
def _evaluation_output_price_failure(
|
||||
key: str, entry: object, evaluation_output_price_sources: Mapping[str, str]
|
||||
) -> str | None:
|
||||
if not isinstance(entry, dict) or entry.get("mode") != "evaluation":
|
||||
if not isinstance(entry, dict) or entry.get("mode") not in ("evaluation", "decisions"):
|
||||
return None
|
||||
output_cost: Final = entry.get("output_cost_per_token")
|
||||
if (
|
||||
|
|
@ -67,7 +67,7 @@ def _evaluation_output_price_failure(
|
|||
):
|
||||
return None
|
||||
return (
|
||||
f"{key}: mode evaluation rows bill input only; output_cost_per_token must be 0 unless the vendor "
|
||||
f"{key}: mode evaluation and decisions rows bill input only; output_cost_per_token must be 0 unless the vendor "
|
||||
"prices output tokens for this model (add the source to EVALUATION_OUTPUT_PRICE_SOURCES)"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ flag_management:
|
|||
carryforward: false
|
||||
- name: lens-python-310
|
||||
carryforward: false
|
||||
- name: lens-postgres
|
||||
carryforward: false
|
||||
- name: proxy-db-key-generation
|
||||
carryforward: false
|
||||
- name: proxy-db-auth-checks
|
||||
|
|
|
|||
|
|
@ -1162,7 +1162,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
)
|
||||
return False
|
||||
|
||||
async def check_file_ids_access(self, file_ids: List[str], user_api_key_dict: UserAPIKeyAuth) -> None:
|
||||
async def check_file_ids_access(self, file_ids: Sequence[str], user_api_key_dict: UserAPIKeyAuth) -> None:
|
||||
"""
|
||||
Check if the user has access to a list of file IDs.
|
||||
Only checks managed (unified) file IDs.
|
||||
|
|
@ -1269,6 +1269,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
elif call_type == CallTypes.acreate_batch.value:
|
||||
input_file_id = cast(Optional[str], data.get("input_file_id"))
|
||||
if input_file_id:
|
||||
await self.check_file_ids_access((input_file_id,), user_api_key_dict)
|
||||
model_file_id_mapping = await self.get_model_file_id_mapping(
|
||||
[input_file_id], user_api_key_dict.parent_otel_span
|
||||
)
|
||||
|
|
@ -1315,11 +1316,12 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
elif retrieve_object_id and accessor_key == "batch_id":
|
||||
await self.enforce_batch_object_access(retrieve_object_id, user_api_key_dict)
|
||||
elif call_type == CallTypes.acreate_fine_tuning_job.value:
|
||||
input_file_id = cast(Optional[str], data.get("training_file"))
|
||||
if input_file_id:
|
||||
model_file_id_mapping = await self.get_model_file_id_mapping(
|
||||
[input_file_id], user_api_key_dict.parent_otel_span
|
||||
)
|
||||
fine_tuning_file_ids: Final = tuple(
|
||||
file_id
|
||||
for file_id in (data.get("training_file"), data.get("validation_file"))
|
||||
if isinstance(file_id, str) and file_id
|
||||
)
|
||||
await self.check_file_ids_access(fine_tuning_file_ids, user_api_key_dict)
|
||||
|
||||
return data
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
3
litellm-rust/Cargo.lock
generated
3
litellm-rust/Cargo.lock
generated
|
|
@ -4024,6 +4024,7 @@ dependencies = [
|
|||
"litellm-cache-memory",
|
||||
"litellm-cache-response",
|
||||
"litellm-core-utils",
|
||||
"litellm-framer",
|
||||
"litellm-host",
|
||||
"litellm-host-native",
|
||||
"litellm-http",
|
||||
|
|
@ -4107,9 +4108,11 @@ name = "litellm-inference-testing"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"litellm-auth",
|
||||
"litellm-http",
|
||||
"litellm-inference",
|
||||
"litellm-secrets",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -442,7 +442,7 @@ impl LegacyLogging {
|
|||
py: Python<'_>,
|
||||
facts: &litellm_host::interceptors::ExecutionFacts,
|
||||
) -> PyResult<HookStep<Self, ()>> {
|
||||
use litellm_host::interceptors::ResultSource;
|
||||
use litellm_host::interceptors::{Cost, ResultSource};
|
||||
|
||||
let logger = self.logger()?.object(py);
|
||||
let params = logger
|
||||
|
|
@ -462,6 +462,12 @@ impl LegacyLogging {
|
|||
),
|
||||
)?;
|
||||
let details = logger.getattr("model_call_details")?;
|
||||
let reported_cost = match &facts.cost {
|
||||
Cost::Reported { amount } => amount.as_f64(),
|
||||
Cost::CacheHit | Cost::Deferred => None,
|
||||
};
|
||||
logger.setattr("_provider_reported_cost", reported_cost)?;
|
||||
details.call_method1("pop", ("response_cost", py.None()))?;
|
||||
self.cache_key = match &facts.source {
|
||||
ResultSource::Provider => None,
|
||||
ResultSource::Cache { key } => Some(key.clone()),
|
||||
|
|
@ -958,6 +964,81 @@ on_pre_call = lambda additional_args: None
|
|||
check = lambda: None
|
||||
";
|
||||
|
||||
#[rstest]
|
||||
#[case::reported(Some(0.37), false)]
|
||||
#[case::free(Some(0.0), false)]
|
||||
#[case::deferred(None, false)]
|
||||
#[case::cached(None, true)]
|
||||
fn result_ready_hands_only_a_reported_cost_to_the_spend_logging_record(
|
||||
#[case] reported: Option<f64>,
|
||||
#[case] cache_hit: bool,
|
||||
#[values(0.001, 7.0)] existing_cost: f64,
|
||||
) {
|
||||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
let locals = namespace(py, PAYLOAD_LOGGER);
|
||||
run(
|
||||
py,
|
||||
&locals,
|
||||
c"
|
||||
logger.litellm_params = {}
|
||||
logger.optional_params = {}
|
||||
logger.model_call_details = {'response_cost': 7.0}
|
||||
logger._provider_reported_cost = None
|
||||
",
|
||||
);
|
||||
let mut logging = LegacyLogging {
|
||||
logger: Some(PythonLogger::new(local(&locals, "logger").unbind())),
|
||||
..legacy_call(py, &locals, false)
|
||||
};
|
||||
let source = if cache_hit {
|
||||
litellm_host::interceptors::ResultSource::Cache {
|
||||
key: "cached-test".into(),
|
||||
}
|
||||
} else {
|
||||
litellm_host::interceptors::ResultSource::Provider
|
||||
};
|
||||
let facts = litellm_host::interceptors::ExecutionFacts {
|
||||
provider: litellm_host::interceptors::ProviderIdentity {
|
||||
model: "native-test".into(),
|
||||
provider: "edenai".into(),
|
||||
},
|
||||
cost: litellm_host::interceptors::Cost::settle(
|
||||
&source,
|
||||
reported.and_then(serde_json::Number::from_f64),
|
||||
),
|
||||
source,
|
||||
};
|
||||
let previous = litellm_host::interceptors::ExecutionFacts {
|
||||
provider: facts.provider.clone(),
|
||||
cost: litellm_host::interceptors::Cost::Reported {
|
||||
amount: serde_json::Number::from_f64(9.0).unwrap(),
|
||||
},
|
||||
source: litellm_host::interceptors::ResultSource::Provider,
|
||||
};
|
||||
logging.result_ready(py, &previous).unwrap();
|
||||
local(&locals, "logger")
|
||||
.getattr("model_call_details")
|
||||
.unwrap()
|
||||
.set_item("response_cost", existing_cost)
|
||||
.unwrap();
|
||||
let step = logging.result_ready(py, &facts).unwrap();
|
||||
assert!(matches!(step, HookStep::Ready(())));
|
||||
locals.set_item("reported", reported).unwrap();
|
||||
locals.set_item("cache_hit", cache_hit).unwrap();
|
||||
run(
|
||||
py,
|
||||
&locals,
|
||||
c"
|
||||
assert logger.model_call_details.get('response_cost') is None, logger.model_call_details
|
||||
assert logger._provider_reported_cost == reported, logger._provider_reported_cost
|
||||
assert logger.model_call_details['cache_hit'] is cache_hit, logger.model_call_details
|
||||
assert logger.update['custom_llm_provider'] == 'edenai', logger.update
|
||||
",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const DOCUMENT: &str = "data:application/pdf;base64,YWJj";
|
||||
const EDITED: &str = "data:application/pdf;base64,ZWRpdGVk";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use pyo3::prelude::*;
|
||||
use strum::{IntoStaticStr, VariantArray};
|
||||
|
||||
const MODULE: &str = "litellm.rust_bridge.callbacks_legacy_python";
|
||||
const MODULE: &str = "litellm.rust_bridge.host.callbacks_legacy_python";
|
||||
|
||||
/// Every litellm Python internal the native call still borrows, grouped by the subsystem it
|
||||
/// belongs to. Rust drives the call; these exist only so behaviour that Python owns today
|
||||
|
|
@ -9,7 +9,7 @@ const MODULE: &str = "litellm.rust_bridge.callbacks_legacy_python";
|
|||
/// A group is deleted once Rust owns that subsystem, so this enum only shrinks. Calling a
|
||||
/// user's own callback is not borrowing and does not belong here.
|
||||
///
|
||||
/// `litellm/rust_bridge/callbacks_legacy_python.py` is the only Python module behind it, and
|
||||
/// `litellm/rust_bridge/host/callbacks_legacy_python.py` is the only Python module behind it, and
|
||||
/// `python_contract.json` pins each function's parameters on both sides.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum LegacyPython {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use pyo3::types::{PyDict, PyTuple};
|
|||
use crate::{LegacyLogging, PublicCall};
|
||||
|
||||
/// The parameters of every `callbacks_legacy_python` function, as the real module declares them.
|
||||
/// `tests/unit/rust_bridge/test_callbacks_legacy_python.py` pins this file to the Python
|
||||
/// `tests/unit/rust_bridge/host/test_callbacks_legacy_python.py` pins this file to the Python
|
||||
/// signatures, and [`namespace`] binds every fake call against it.
|
||||
pub(crate) const PYTHON_CONTRACT: &str = include_str!("../python_contract.json");
|
||||
|
||||
|
|
@ -23,10 +23,10 @@ import sys
|
|||
import traceback
|
||||
import types
|
||||
|
||||
for name in ('litellm', 'litellm.rust_bridge', 'litellm.rust_bridge.callbacks_legacy_python'):
|
||||
for name in ('litellm', 'litellm.rust_bridge', 'litellm.rust_bridge.host', 'litellm.rust_bridge.host.callbacks_legacy_python'):
|
||||
sys.modules.setdefault(name, types.ModuleType(name))
|
||||
|
||||
legacy = sys.modules['litellm.rust_bridge.callbacks_legacy_python']
|
||||
legacy = sys.modules['litellm.rust_bridge.host.callbacks_legacy_python']
|
||||
CONTRACT = json.loads(python_contract)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,18 +16,28 @@ pub enum LlmProviders {
|
|||
AzureAi,
|
||||
#[strum(serialize = "bedrock")]
|
||||
Bedrock,
|
||||
#[strum(serialize = "bedrock_mantle")]
|
||||
BedrockMantle,
|
||||
#[strum(serialize = "cohere")]
|
||||
Cohere,
|
||||
#[strum(serialize = "deepseek")]
|
||||
Deepseek,
|
||||
#[strum(serialize = "edenai")]
|
||||
Edenai,
|
||||
#[strum(serialize = "minimax")]
|
||||
Minimax,
|
||||
#[strum(serialize = "mistral")]
|
||||
Mistral,
|
||||
#[strum(serialize = "openai")]
|
||||
Openai,
|
||||
#[strum(serialize = "openai_like")]
|
||||
OpenaiLike,
|
||||
#[strum(serialize = "openrouter")]
|
||||
Openrouter,
|
||||
#[strum(serialize = "reducto")]
|
||||
Reducto,
|
||||
#[strum(serialize = "tencent")]
|
||||
Tencent,
|
||||
#[strum(serialize = "vertex_ai")]
|
||||
VertexAi,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,12 @@ use crate::{Deployment, Error, Gateway, JsonObject, RequestId, request};
|
|||
|
||||
/// Client headers Python forwards to Anthropic-speaking providers on every call.
|
||||
const ANTHROPIC_API_HEADERS: [&str; 2] = ["anthropic-version", "anthropic-beta"];
|
||||
const ANTHROPIC_API_HEADER_PROVIDERS: &str = "anthropic,bedrock,bedrock_mantle,vertex_ai";
|
||||
const OPENROUTER_API_HEADERS: [&str; 2] = [
|
||||
"x-openrouter-metadata",
|
||||
"x-openrouter-experimental-metadata",
|
||||
];
|
||||
const ANTHROPIC_API_HEADER_PROVIDERS: &str =
|
||||
"anthropic,bedrock,bedrock_mantle,vertex_ai,openrouter";
|
||||
|
||||
pub async fn create(
|
||||
State(gateway): State<Arc<Gateway>>,
|
||||
|
|
@ -88,17 +93,28 @@ fn project(
|
|||
}
|
||||
|
||||
fn anthropic_api_headers(headers: &HeaderMap) -> Option<ProviderSpecificHeaders> {
|
||||
let extra_headers: Map<String, Value> = ANTHROPIC_API_HEADERS
|
||||
let scopes = [
|
||||
(
|
||||
ANTHROPIC_API_HEADER_PROVIDERS,
|
||||
ANTHROPIC_API_HEADERS.as_slice(),
|
||||
),
|
||||
("openrouter", OPENROUTER_API_HEADERS.as_slice()),
|
||||
];
|
||||
let forwarded: Vec<ProviderSpecificHeader> = scopes
|
||||
.into_iter()
|
||||
.filter_map(|name| {
|
||||
let value = headers.get(name)?.to_str().ok()?;
|
||||
Some((name.to_owned(), Value::from(value)))
|
||||
.filter_map(|(provider, names)| {
|
||||
let extra_headers: Map<String, Value> = names
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
let value = headers.get(*name)?.to_str().ok()?;
|
||||
Some(((*name).to_owned(), Value::from(value)))
|
||||
})
|
||||
.collect();
|
||||
(!extra_headers.is_empty()).then(|| ProviderSpecificHeader {
|
||||
custom_llm_provider: provider.into(),
|
||||
extra_headers,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(!extra_headers.is_empty()).then(|| {
|
||||
ProviderSpecificHeaders::One(ProviderSpecificHeader {
|
||||
custom_llm_provider: ANTHROPIC_API_HEADER_PROVIDERS.into(),
|
||||
extra_headers,
|
||||
})
|
||||
})
|
||||
(!forwarded.is_empty()).then_some(ProviderSpecificHeaders::Many(forwarded))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,3 +192,52 @@ async fn model_permissions_prevent_provider_calls(#[case] path: &str) {
|
|||
assert_eq!(response.status(), 403);
|
||||
assert!(upstream.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::completion(false)]
|
||||
#[case::streaming(true)]
|
||||
#[tokio::test]
|
||||
async fn openrouter_receives_native_messages_headers_and_its_deployment_bearer(
|
||||
#[case] streaming: bool,
|
||||
) {
|
||||
let upstream = MockServer::start().await;
|
||||
let message = json!({"id": "msg_test", "type": "message", "role": "assistant", "model": "anthropic/test-model", "content": [{"type": "text", "text": "hello"}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 1, "output_tokens": 1, "cost": 0.001, "cost_details": "unexpected"}, "openrouter_metadata": {"provider": "test-provider"}});
|
||||
let sse = "event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"cost\":0.001}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
|
||||
let template = if streaming {
|
||||
ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream")
|
||||
} else {
|
||||
ResponseTemplate::new(200).set_body_json(message.clone())
|
||||
};
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/messages"))
|
||||
.and(header("authorization", "Bearer test-key"))
|
||||
.and(header("anthropic-beta", "test-feature"))
|
||||
.and(header("anthropic-version", "test-version"))
|
||||
.and(header("x-openrouter-metadata", "enabled"))
|
||||
.and(body_json(json!({"model": "anthropic/test-model", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 16, "stream": streaming, "provider": {"order": ["test-provider"]}})))
|
||||
.respond_with(template).expect(1).mount(&upstream).await;
|
||||
let request = Request::post("/v1/messages")
|
||||
.header("content-type", "application/json")
|
||||
.header("anthropic-beta", "test-feature")
|
||||
.header("anthropic-version", "test-version")
|
||||
.header("x-openrouter-metadata", "enabled")
|
||||
.header("x-api-key", "proxy-key")
|
||||
.header("authorization", "Bearer proxy-key")
|
||||
.body(Body::from(json!({"model": "public/model", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 16, "stream": streaming, "provider": {"order": ["test-provider"]}}).to_string())).unwrap();
|
||||
let response = support::app(
|
||||
"openrouter/anthropic/test-model",
|
||||
&format!("{}/api/v1", upstream.uri()),
|
||||
)
|
||||
.oneshot(request)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
if streaming {
|
||||
assert_eq!(to_bytes(response.into_body(), 4096).await.unwrap(), sse);
|
||||
} else {
|
||||
assert_eq!(support::json(response).await, message);
|
||||
}
|
||||
let requests = upstream.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(!requests[0].headers.contains_key("x-api-key"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ From the repository root, this loopback-only example runs the existing Python MC
|
|||
|
||||
```sh
|
||||
cargo run --manifest-path litellm-rust/Cargo.toml -p litellm-gateway-mcp --example stdio_proxy -- \
|
||||
.venv/bin/python -c 'import runpy; runpy.run_path("tests/mcp_tests/mcp_e2e_upstream_server.py")["mcp"].run(transport="stdio")'
|
||||
.venv/bin/python -c 'import runpy; runpy.run_path("tests/integration/_support/mcp_e2e_upstream_server.py")["mcp"].run(transport="stdio")'
|
||||
```
|
||||
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ The binary reads YAML from `LITELLM_CONFIG` and mounts configured MCP servers al
|
|||
From the repository root, start the Python fixture in one terminal:
|
||||
|
||||
```sh
|
||||
MCP_HOST=127.0.0.1 MCP_PORT=8090 .venv/bin/python tests/mcp_tests/mcp_e2e_upstream_server.py
|
||||
MCP_HOST=127.0.0.1 MCP_PORT=8090 .venv/bin/python tests/integration/_support/mcp_e2e_upstream_server.py
|
||||
```
|
||||
|
||||
Start the gateway in another terminal with the example configuration:
|
||||
|
|
|
|||
|
|
@ -42,10 +42,32 @@ pub enum ResultSource {
|
|||
Cache { key: String },
|
||||
}
|
||||
|
||||
/// What the call cost, settled once by the route. A host records it; it never re-derives it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Cost {
|
||||
/// The amount the provider itself billed, taken from its response.
|
||||
Reported { amount: serde_json::Number },
|
||||
/// Served from the cache, so nothing was billed.
|
||||
CacheHit,
|
||||
/// No provider figure: the host estimates from usage and its price table.
|
||||
Deferred,
|
||||
}
|
||||
|
||||
impl Cost {
|
||||
pub fn settle(source: &ResultSource, reported: Option<serde_json::Number>) -> Self {
|
||||
match (source, reported) {
|
||||
(ResultSource::Cache { .. }, _) => Self::CacheHit,
|
||||
(ResultSource::Provider, Some(amount)) => Self::Reported { amount },
|
||||
(ResultSource::Provider, None) => Self::Deferred,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExecutionFacts {
|
||||
pub provider: ProviderIdentity,
|
||||
pub source: ResultSource,
|
||||
pub cost: Cost,
|
||||
}
|
||||
|
||||
pub trait Interceptors<E>: Send + Sync {
|
||||
|
|
|
|||
|
|
@ -138,7 +138,9 @@ pub fn has_bearer_auth(headers: &[(String, String)]) -> bool {
|
|||
}
|
||||
let value = value.trim();
|
||||
value.len() > 7
|
||||
&& value[..7].eq_ignore_ascii_case("bearer ")
|
||||
&& value
|
||||
.get(..7)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("bearer "))
|
||||
&& !value[7..].trim().is_empty()
|
||||
})
|
||||
}
|
||||
|
|
@ -279,31 +281,19 @@ mod tests {
|
|||
assert!(!has_header(&headers, "authorization"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_detection_requires_a_non_empty_token() {
|
||||
assert!(has_bearer_auth(&[(
|
||||
"Authorization".to_string(),
|
||||
"Bearer abc".to_string()
|
||||
)]));
|
||||
assert!(has_bearer_auth(&[(
|
||||
"authorization".to_string(),
|
||||
"bearer abc".to_string()
|
||||
)]));
|
||||
assert!(!has_bearer_auth(&[(
|
||||
"Authorization".to_string(),
|
||||
"Bearer ".to_string()
|
||||
)]));
|
||||
assert!(!has_bearer_auth(&[(
|
||||
"authorization".to_string(),
|
||||
String::new()
|
||||
)]));
|
||||
assert!(!has_bearer_auth(&[(
|
||||
"Authorization".to_string(),
|
||||
"Basic abc".to_string()
|
||||
)]));
|
||||
assert!(!has_bearer_auth(&[(
|
||||
"x-api-key".to_string(),
|
||||
"abc".to_string()
|
||||
)]));
|
||||
#[rstest::rstest]
|
||||
#[case::bearer("Authorization", "Bearer abc", true)]
|
||||
#[case::lowercase("authorization", "bearer abc", true)]
|
||||
#[case::blank_token("Authorization", "Bearer ", false)]
|
||||
#[case::empty("authorization", "", false)]
|
||||
#[case::basic("Authorization", "Basic abc", false)]
|
||||
#[case::different_header("x-api-key", "abc", false)]
|
||||
#[case::non_ascii("authorization", "invali\u{00e9}auth", false)]
|
||||
fn bearer_detection_requires_a_non_empty_token(
|
||||
#[case] name: &str,
|
||||
#[case] value: &str,
|
||||
#[case] expected: bool,
|
||||
) {
|
||||
assert_eq!(has_bearer_auth(&[(name.into(), value.into())]), expected);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ serde_json = { workspace = true, features = ["preserve_order"] }
|
|||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
litellm-framer = { workspace = true, features = ["sse"] }
|
||||
litellm-cache-memory.workspace = true
|
||||
litellm-host-native.workspace = true
|
||||
litellm-http = { workspace = true, features = ["test-support"] }
|
||||
|
|
|
|||
|
|
@ -1,65 +1,68 @@
|
|||
use litellm_core_utils::get_llm_provider_logic::LlmProviders;
|
||||
use litellm_http::request::string_headers as shared_string_headers;
|
||||
pub(super) use litellm_http::request::truncate_error_body;
|
||||
use litellm_llms::tencent::messages::transformation::TENCENT_MESSAGES_CONFIG;
|
||||
use litellm_llms::{
|
||||
anthropic::messages::transformation::ANTHROPIC_MESSAGES_CONFIG,
|
||||
azure_ai::messages::transformation::AZURE_ANTHROPIC_MESSAGES_CONFIG,
|
||||
base_llm::messages::transformation::BaseMessagesConfig,
|
||||
bedrock::messages::invoke_transformations::anthropic_claude3_transformation::BEDROCK_ANTHROPIC_MESSAGES_CONFIG,
|
||||
deepseek::messages::transformation::DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG,
|
||||
edenai::messages::transformation::EDENAI_MESSAGES_CONFIG,
|
||||
minimax::messages::transformation::MINIMAX_MESSAGES_CONFIG,
|
||||
vertex_ai::messages::transformation::VERTEX_ANTHROPIC_MESSAGES_CONFIG,
|
||||
};
|
||||
use litellm_llms::{
|
||||
bedrock::claude_platform::messages::transformation::BEDROCK_CLAUDE_PLATFORM_MESSAGES_CONFIG,
|
||||
bedrock_mantle::messages::transformation::BEDROCK_MANTLE_MESSAGES_CONFIG,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
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),
|
||||
LlmProviders::VertexAi if model.to_ascii_lowercase().contains("claude") => {
|
||||
Some(MessagesProvider::VertexAi)
|
||||
use litellm_llms::openrouter::messages::transformation::OPENROUTER_MESSAGES_CONFIG;
|
||||
|
||||
let config: &'static dyn BaseMessagesConfig = match provider {
|
||||
LlmProviders::Tencent => &TENCENT_MESSAGES_CONFIG,
|
||||
LlmProviders::Anthropic => &ANTHROPIC_MESSAGES_CONFIG,
|
||||
LlmProviders::AzureAi => &AZURE_ANTHROPIC_MESSAGES_CONFIG,
|
||||
LlmProviders::Bedrock if model.starts_with("claude_platform/") => {
|
||||
&BEDROCK_CLAUDE_PLATFORM_MESSAGES_CONFIG
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
LlmProviders::Bedrock if model.starts_with("mantle/") => &BEDROCK_MANTLE_MESSAGES_CONFIG,
|
||||
LlmProviders::Bedrock => &BEDROCK_ANTHROPIC_MESSAGES_CONFIG,
|
||||
LlmProviders::BedrockMantle => &BEDROCK_MANTLE_MESSAGES_CONFIG,
|
||||
LlmProviders::Deepseek => &DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG,
|
||||
LlmProviders::Edenai => &EDENAI_MESSAGES_CONFIG,
|
||||
LlmProviders::Minimax => &MINIMAX_MESSAGES_CONFIG,
|
||||
LlmProviders::VertexAi if model.to_ascii_lowercase().contains("claude") => {
|
||||
&VERTEX_ANTHROPIC_MESSAGES_CONFIG
|
||||
}
|
||||
LlmProviders::Openrouter => &OPENROUTER_MESSAGES_CONFIG,
|
||||
_ => return None,
|
||||
};
|
||||
Some(MessagesProvider { provider, config })
|
||||
}
|
||||
|
||||
pub(super) fn string_headers(
|
||||
|
|
@ -74,24 +77,22 @@ 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::tencent("tencent")]
|
||||
#[case::anthropic("anthropic")]
|
||||
#[case::azure_ai("azure_ai")]
|
||||
#[case::bedrock("bedrock")]
|
||||
#[case::deepseek("deepseek")]
|
||||
#[case::edenai("edenai")]
|
||||
#[case::minimax("minimax")]
|
||||
#[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 +103,7 @@ mod tests {
|
|||
#[case] provider: LlmProviders,
|
||||
#[case] model: &str,
|
||||
) {
|
||||
assert_eq!(messages_provider(provider, model), None);
|
||||
assert!(messages_provider(provider, model).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ use litellm_inference::{context::CallContext, outbound::outbound_request};
|
|||
pub(super) struct ProviderCall {
|
||||
pub identity: ProviderIdentity,
|
||||
pub wire: WireRequest,
|
||||
provider: super::common_utils::MessagesProvider,
|
||||
pub(super) provider: super::common_utils::MessagesProvider,
|
||||
signer: Option<litellm_auth_aws::SigV4Signer>,
|
||||
timeout: Option<Duration>,
|
||||
stream: bool,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ mod types;
|
|||
|
||||
use futures_util::FutureExt;
|
||||
use litellm_auth::AuthServices;
|
||||
use litellm_host::interceptors::{ExecutionFacts, Interceptors, ResultSource};
|
||||
use litellm_host::interceptors::{Cost, ExecutionFacts, Interceptors, ResultSource};
|
||||
|
||||
use litellm_inference::{caching::CallCache, context::CallContext};
|
||||
use litellm_secrets::source::SecretSource;
|
||||
|
|
@ -87,6 +87,7 @@ impl MessagesRoute {
|
|||
&request.wire,
|
||||
);
|
||||
let identity = request.identity.clone();
|
||||
let config = request.provider.config();
|
||||
let (output, source) = match cache.lookup().await {
|
||||
Some(hit) => hit,
|
||||
None => (
|
||||
|
|
@ -94,9 +95,14 @@ impl MessagesRoute {
|
|||
ResultSource::Provider,
|
||||
),
|
||||
};
|
||||
let reported = match &output {
|
||||
MessagesCallResponse::Complete(response) => config.reported_cost(response),
|
||||
MessagesCallResponse::Stream { .. } => None,
|
||||
};
|
||||
context
|
||||
.result_ready(ExecutionFacts {
|
||||
provider: identity,
|
||||
cost: Cost::settle(&source, reported),
|
||||
source: source.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ fn prepare_provider_request(
|
|||
shaping,
|
||||
..
|
||||
} = call;
|
||||
let connection_params = litellm_router_types::LitellmParams {
|
||||
api_base: api_base.clone().or(litellm_params.api_base.clone()),
|
||||
..litellm_params
|
||||
};
|
||||
let config = provider.config();
|
||||
let env_lookup = |key: &str| secrets.get(key);
|
||||
|
||||
|
|
@ -95,25 +99,22 @@ fn prepare_provider_request(
|
|||
let forwarded = string_headers(Some(
|
||||
extra_headers.into_iter().flatten().chain(scoped).collect(),
|
||||
))?;
|
||||
let validated = config.validate_environment(
|
||||
let forwarded = config.request_headers(
|
||||
with_default_headers(forwarded, config.default_headers()),
|
||||
&transformed,
|
||||
);
|
||||
let environment = config.validate_environment(
|
||||
forwarded,
|
||||
api_key.as_deref(),
|
||||
&transformed.model,
|
||||
&litellm_params,
|
||||
&connection_params,
|
||||
&env_lookup,
|
||||
)?;
|
||||
let environment = ValidatedEnvironment {
|
||||
headers: config.request_headers(
|
||||
with_default_headers(validated.headers, config.default_headers()),
|
||||
&transformed,
|
||||
),
|
||||
auth: validated.auth,
|
||||
};
|
||||
|
||||
let url = config.get_complete_url(
|
||||
api_base.as_deref(),
|
||||
&transformed.model,
|
||||
&litellm_params,
|
||||
&connection_params,
|
||||
transformed.params.stream == Some(true),
|
||||
&env_lookup,
|
||||
)?;
|
||||
|
|
@ -475,4 +476,44 @@ mod tests {
|
|||
))
|
||||
);
|
||||
}
|
||||
#[rstest]
|
||||
#[case::mantle(
|
||||
"bedrock_mantle/claude-test",
|
||||
"https://bedrock-mantle.us-west-2.api.aws",
|
||||
"/anthropic/v1/messages",
|
||||
"bedrock"
|
||||
)]
|
||||
#[case::platform(
|
||||
"bedrock/claude_platform/claude-test",
|
||||
"https://aws-external-anthropic.us-west-2.api.aws",
|
||||
"/v1/messages",
|
||||
"aws-external-anthropic"
|
||||
)]
|
||||
fn explicit_endpoint_region_is_used_by_the_signer(
|
||||
shaping: MessagesShaping,
|
||||
#[case] model: &str,
|
||||
#[case] base: &str,
|
||||
#[case] path: &str,
|
||||
#[case] service: &str,
|
||||
) {
|
||||
let prepared = prepare_with_secrets(MessagesCall {
|
||||
body: body(json!({"model": model, "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}]})),
|
||||
api_key: None,
|
||||
api_base: Some(base.into()),
|
||||
custom_llm_provider: None,
|
||||
litellm_params: Default::default(),
|
||||
extra_headers: None,
|
||||
provider_specific_header: None,
|
||||
timeout: None,
|
||||
shaping,
|
||||
}, &|name: &str| match name {
|
||||
"AWS_REGION_NAME" => Some("us-east-1".into()),
|
||||
"ANTHROPIC_AWS_WORKSPACE_ID" => Some("workspace".into()),
|
||||
_ => None,
|
||||
}).unwrap();
|
||||
assert_eq!(prepared.url, format!("{base}{path}"));
|
||||
assert!(
|
||||
matches!(prepared.environment.auth, litellm_llms::base_llm::auth::AuthScheme::AwsSigV4 { region, service: actual, .. } if region == "us-west-2" && actual == service)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use futures_util::TryStreamExt;
|
||||
use litellm_inference_messages::{Error, MessagesCall, MessagesCallResponse, MessagesShaping};
|
||||
use litellm_inference_testing::{RecordingSecrets, no_secrets};
|
||||
use litellm_llms_types::formats::messages::{MessagesRequest, MessagesResponse};
|
||||
use rstest::{fixture, rstest};
|
||||
use serde_json::{Value, json};
|
||||
use wiremock::ResponseTemplate;
|
||||
|
||||
#[path = "../support/mod.rs"]
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
const MODEL: &str = "claude-test";
|
||||
|
||||
#[fixture]
|
||||
fn call() -> MessagesCall {
|
||||
MessagesCall {
|
||||
body: serde_json::from_value(json!({"model": MODEL, "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}]})).unwrap(),
|
||||
api_key: None,
|
||||
api_base: None,
|
||||
custom_llm_provider: None,
|
||||
litellm_params: Default::default(),
|
||||
extra_headers: None,
|
||||
provider_specific_header: None,
|
||||
timeout: Some(Duration::from_secs(5)),
|
||||
shaping: MessagesShaping::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_fields(call: MessagesCall, fields: Value) -> MessagesCall {
|
||||
let body = serde_json::to_value(call.body).unwrap();
|
||||
MessagesCall {
|
||||
body: serde_json::from_value(Value::Object(
|
||||
body.as_object()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.chain(fields.as_object().unwrap().clone())
|
||||
.collect(),
|
||||
))
|
||||
.unwrap(),
|
||||
..call
|
||||
}
|
||||
}
|
||||
|
||||
fn with_model(call: MessagesCall, model: &str) -> MessagesCall {
|
||||
MessagesCall {
|
||||
body: MessagesRequest {
|
||||
model: model.into(),
|
||||
..call.body
|
||||
},
|
||||
..call
|
||||
}
|
||||
}
|
||||
|
||||
fn message_response() -> ResponseTemplate {
|
||||
json_response(
|
||||
json!({"id": "msg_1", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "hi"}], "model": MODEL, "stop_reason": "end_turn", "usage": {"input_tokens": 1, "output_tokens": 2}}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_with(
|
||||
secrets: Arc<RecordingSecrets>,
|
||||
call: MessagesCall,
|
||||
) -> Result<MessagesCallResponse, Error> {
|
||||
messages_route(secrets).execute(call, &(), None).await
|
||||
}
|
||||
|
||||
async fn run(call: MessagesCall) -> Result<MessagesCallResponse, Error> {
|
||||
run_with(Arc::new(RecordingSecrets::empty()), call).await
|
||||
}
|
||||
|
||||
async fn run_message(call: MessagesCall) -> MessagesResponse {
|
||||
match run(call).await.unwrap() {
|
||||
MessagesCallResponse::Complete(message) => *message,
|
||||
MessagesCallResponse::Stream { .. } => panic!("expected a completed message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::platform(
|
||||
"bedrock/claude_platform/claude-test",
|
||||
"/v1/messages",
|
||||
"x-api-key",
|
||||
"key"
|
||||
)]
|
||||
#[case::mantle(
|
||||
"bedrock_mantle/anthropic.claude-test",
|
||||
"/anthropic/v1/messages",
|
||||
"authorization",
|
||||
"Bearer key"
|
||||
)]
|
||||
#[case::mantle_alias(
|
||||
"bedrock/mantle/anthropic.claude-test",
|
||||
"/anthropic/v1/messages",
|
||||
"authorization",
|
||||
"Bearer key"
|
||||
)]
|
||||
#[tokio::test]
|
||||
async fn prefixes_select_native_messages_and_strip_connection_fields(
|
||||
call: MessagesCall,
|
||||
#[case] model: &str,
|
||||
#[case] path: &str,
|
||||
#[case] header: &str,
|
||||
#[case] credential: &str,
|
||||
) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let message = run_message(MessagesCall {
|
||||
api_key: Some("key".into()),
|
||||
api_base: Some(upstream.uri()),
|
||||
custom_llm_provider: None,
|
||||
..with_fields(
|
||||
call,
|
||||
json!({"model": model, "workspace_id": "request-workspace"}),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
assert_eq!(message.model, MODEL);
|
||||
let request = only_request(&upstream).await;
|
||||
assert_eq!(request.url.path(), path);
|
||||
assert_eq!(request.header(header), Some(credential));
|
||||
assert_eq!(request.json()["model"], model.rsplit('/').next().unwrap());
|
||||
if path == "/v1/messages" {
|
||||
assert_eq!(
|
||||
request.header("anthropic-workspace-id"),
|
||||
Some("request-workspace")
|
||||
);
|
||||
assert_eq!(request.json().get("workspace_id"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::platform(
|
||||
"bedrock/claude_platform/claude-test",
|
||||
"ANTHROPIC_AWS_API_KEY",
|
||||
"ANTHROPIC_AWS_BASE_URL",
|
||||
"/v1/messages",
|
||||
"x-api-key",
|
||||
"managed"
|
||||
)]
|
||||
#[case::mantle(
|
||||
"bedrock_mantle/claude-test",
|
||||
"AWS_BEARER_TOKEN_BEDROCK",
|
||||
"BEDROCK_MANTLE_API_BASE",
|
||||
"/anthropic/v1/messages",
|
||||
"authorization",
|
||||
"Bearer managed"
|
||||
)]
|
||||
#[tokio::test]
|
||||
async fn managed_keys_bases_and_workspaces_reach_the_upstream(
|
||||
call: MessagesCall,
|
||||
#[case] model: &str,
|
||||
#[case] key_name: &str,
|
||||
#[case] base_name: &str,
|
||||
#[case] path: &str,
|
||||
#[case] header: &str,
|
||||
#[case] credential: &str,
|
||||
) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let base = upstream.uri();
|
||||
let secrets = Arc::new(RecordingSecrets::new([
|
||||
(key_name, "managed"),
|
||||
(base_name, base.as_str()),
|
||||
("ANTHROPIC_AWS_WORKSPACE_ID", "managed-workspace"),
|
||||
]));
|
||||
let output = run_with(
|
||||
secrets,
|
||||
MessagesCall {
|
||||
custom_llm_provider: None,
|
||||
..with_model(call, model)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(output, MessagesCallResponse::Complete(_)));
|
||||
let request = only_request(&upstream).await;
|
||||
assert_eq!(request.url.path(), path);
|
||||
assert_eq!(request.header(header), Some(credential));
|
||||
if path == "/v1/messages" {
|
||||
assert_eq!(
|
||||
request.header("anthropic-workspace-id"),
|
||||
Some("managed-workspace")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::platform("bedrock/claude_platform/claude-test", "aws-external-anthropic")]
|
||||
#[case::mantle("bedrock_mantle/claude-test", "bedrock")]
|
||||
#[tokio::test]
|
||||
async fn managed_aws_credentials_sign_the_exact_native_request(
|
||||
call: MessagesCall,
|
||||
#[case] model: &str,
|
||||
#[case] service: &str,
|
||||
) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let secrets = Arc::new(RecordingSecrets::new([
|
||||
("AWS_ACCESS_KEY_ID", "test-access"),
|
||||
("AWS_SECRET_ACCESS_KEY", "test-secret"),
|
||||
("AWS_SESSION_TOKEN", "test-session"),
|
||||
("AWS_REGION_NAME", "us-west-2"),
|
||||
("ANTHROPIC_AWS_WORKSPACE_ID", "managed-workspace"),
|
||||
]));
|
||||
run_with(
|
||||
secrets,
|
||||
MessagesCall {
|
||||
api_base: Some(upstream.uri()),
|
||||
custom_llm_provider: None,
|
||||
..with_model(call, model)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let request = only_request(&upstream).await;
|
||||
let auth = request.header("authorization").unwrap();
|
||||
assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=test-access/"));
|
||||
assert!(auth.contains(&format!("/us-west-2/{service}/aws4_request")));
|
||||
assert_eq!(request.header("x-amz-security-token"), Some("test-session"));
|
||||
assert_eq!(request.header("x-api-key"), None);
|
||||
assert_eq!(request.json()["model"], "claude-test");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn private_mantle_endpoint_is_used_and_signed(call: MessagesCall) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let params = litellm_router_types::LitellmParams {
|
||||
aws: litellm_auth::AwsParams {
|
||||
aws_bedrock_runtime_endpoint: Some(upstream.uri()),
|
||||
aws_access_key_id: Some("private-access".into()),
|
||||
aws_secret_access_key: Some("private-secret".into()),
|
||||
aws_region_name: Some("us-west-2".into()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
run(MessagesCall {
|
||||
custom_llm_provider: None,
|
||||
litellm_params: params,
|
||||
..with_model(call, "bedrock/mantle/claude-test")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let request = only_request(&upstream).await;
|
||||
assert_eq!(request.url.path(), "/anthropic/v1/messages");
|
||||
assert!(
|
||||
request
|
||||
.header("authorization")
|
||||
.unwrap()
|
||||
.contains("/us-west-2/bedrock/aws4_request")
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::platform("bedrock/claude_platform/claude-test")]
|
||||
#[case::mantle("bedrock_mantle/claude-test")]
|
||||
#[tokio::test]
|
||||
async fn native_streams_relay_every_sse_byte(call: MessagesCall, #[case] model: &str) {
|
||||
let sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
|
||||
let upstream =
|
||||
upstream([ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream")]).await;
|
||||
let response = messages_route(no_secrets())
|
||||
.execute(
|
||||
MessagesCall {
|
||||
api_key: Some("key".into()),
|
||||
api_base: Some(upstream.uri()),
|
||||
custom_llm_provider: None,
|
||||
..with_fields(
|
||||
call,
|
||||
json!({"model": model, "stream": true, "workspace_id": "workspace"}),
|
||||
)
|
||||
},
|
||||
&(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let MessagesCallResponse::Stream { chunks, .. } = response else {
|
||||
panic!("expected SSE");
|
||||
};
|
||||
assert_eq!(
|
||||
chunks.try_collect::<Vec<_>>().await.unwrap().concat(),
|
||||
sse.as_bytes()
|
||||
);
|
||||
assert_eq!(only_request(&upstream).await.json()["stream"], true);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::missing(false)]
|
||||
#[case::configured(true)]
|
||||
#[tokio::test]
|
||||
async fn deployment_workspace_is_required_and_sent_as_a_header(
|
||||
call: MessagesCall,
|
||||
#[case] configured: bool,
|
||||
) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let params = if configured {
|
||||
serde_json::from_value(json!({"model": "bedrock/claude_platform/claude-test", "workspace_id": "deployment-workspace"})).unwrap()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let result = run(MessagesCall {
|
||||
api_key: Some("key".into()),
|
||||
api_base: Some(upstream.uri()),
|
||||
litellm_params: params,
|
||||
..with_model(call, "bedrock/claude_platform/claude-test")
|
||||
})
|
||||
.await;
|
||||
if !configured {
|
||||
assert!(matches!(result, Err(Error::Auth(_))));
|
||||
assert!(received(&upstream).await.is_empty());
|
||||
return;
|
||||
}
|
||||
assert!(matches!(result.unwrap(), MessagesCallResponse::Complete(_)));
|
||||
let sent = only_request(&upstream).await;
|
||||
assert_eq!(
|
||||
sent.header("anthropic-workspace-id"),
|
||||
Some("deployment-workspace")
|
||||
);
|
||||
assert_eq!(sent.json().get("workspace_id"), None);
|
||||
}
|
||||
|
|
@ -13,8 +13,8 @@ use litellm_cache_response::{
|
|||
CacheOptions, CacheScope, ResponseCache, ResponseCacheConfig, ResponseCacheService, ScopedCache,
|
||||
};
|
||||
use litellm_host::interceptors::{
|
||||
ExecutionFacts, Interceptors, ProviderIdentity, RawResponse, RequestContext, ResultSource,
|
||||
WireRequest,
|
||||
Cost, ExecutionFacts, Interceptors, ProviderIdentity, RawResponse, RequestContext,
|
||||
ResultSource, WireRequest,
|
||||
};
|
||||
use litellm_inference::{
|
||||
RouteError,
|
||||
|
|
@ -241,3 +241,55 @@ async fn messages_cache_identity_follows_resolved_configuration_and_request_call
|
|||
first.verify().await;
|
||||
second.verify().await;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::reported(json!(0.37), Some(0.37))]
|
||||
#[case::free(json!(0), Some(0.0))]
|
||||
#[case::malformed(json!("invalid"), None)]
|
||||
#[case::missing(Value::Null, None)]
|
||||
#[tokio::test]
|
||||
async fn provider_reported_cost_is_delivered_once_and_never_charged_for_a_cache_hit(
|
||||
cache: Arc<dyn ResponseCacheService>,
|
||||
#[case] cost: Value,
|
||||
#[case] expected: Option<f64>,
|
||||
) {
|
||||
use litellm_inference_messages::{MessagesCall, MessagesCallResponse};
|
||||
let response = json!({"id": "msg_cost", "type": "message", "role": "assistant", "model": "native-test",
|
||||
"content": [{"type": "text", "text": "answer"}], "stop_reason": "end_turn", "stop_sequence": null,
|
||||
"usage": {"input_tokens": 3, "output_tokens": 2}, "cost": cost});
|
||||
let upstream = support::upstream([support::json_response(response)]).await;
|
||||
let hooks = ChangingHooks::default();
|
||||
let route = support::messages_route(litellm_inference_testing::no_secrets())
|
||||
.with_cache(ScopedCache::new(cache, CacheScope::Shared));
|
||||
let request = || {
|
||||
MessagesCall {
|
||||
body: serde_json::from_value(json!({"model": "native-test", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 16})).unwrap(),
|
||||
api_key: Some("eden-key".into()), api_base: Some(upstream.uri()), custom_llm_provider: Some("edenai".into()),
|
||||
litellm_params: Default::default(), extra_headers: None, provider_specific_header: None,
|
||||
timeout: None, shaping: Default::default(),
|
||||
}
|
||||
};
|
||||
let first = route.execute(request(), &hooks, None).await.unwrap();
|
||||
let second = route.execute(request(), &hooks, None).await.unwrap();
|
||||
let (MessagesCallResponse::Complete(first), MessagesCallResponse::Complete(second)) =
|
||||
(first, second)
|
||||
else {
|
||||
panic!("expected Messages responses");
|
||||
};
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.extra.get("cost"), Some(&cost));
|
||||
assert_eq!(support::received(&upstream).await.len(), 1);
|
||||
let facts = hooks.facts.lock().unwrap();
|
||||
let [provider, cached] = facts.as_slice() else {
|
||||
panic!("expected provider and cache results");
|
||||
};
|
||||
assert_eq!(provider.source, ResultSource::Provider);
|
||||
assert_eq!(
|
||||
provider.cost,
|
||||
expected.map_or(Cost::Deferred, |amount| Cost::Reported {
|
||||
amount: serde_json::Number::from_f64(amount).unwrap()
|
||||
})
|
||||
);
|
||||
assert!(matches!(cached.source, ResultSource::Cache { .. }));
|
||||
assert_eq!(cached.cost, Cost::CacheHit);
|
||||
}
|
||||
|
|
|
|||
8
litellm-rust/crates/inference-messages/tests/live.rs
Normal file
8
litellm-rust/crates/inference-messages/tests/live.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#[path = "live/support.rs"]
|
||||
mod support;
|
||||
|
||||
#[path = "live/edenai.rs"]
|
||||
mod edenai;
|
||||
|
||||
#[path = "live/vertex_ai.rs"]
|
||||
mod vertex_ai;
|
||||
92
litellm-rust/crates/inference-messages/tests/live/edenai.rs
Normal file
92
litellm-rust/crates/inference-messages/tests/live/edenai.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use litellm_host::interceptors::Cost;
|
||||
use litellm_inference_messages::MessagesRoute;
|
||||
use litellm_inference_testing::live::within_deadline;
|
||||
use rstest::rstest;
|
||||
use serde_json::{Number, Value, json};
|
||||
|
||||
use super::support::{LiveCall, assert_text, call, complete, request, route, stream_events};
|
||||
|
||||
const PROVIDER: &str = "edenai";
|
||||
|
||||
#[rstest]
|
||||
#[case::basic(json!({"messages": [{"role": "user", "content": "Say hello."}]}))]
|
||||
#[case::system_text(json!({
|
||||
"system": "You are a helpful assistant.",
|
||||
"messages": [{"role": "user", "content": "Say hello."}]
|
||||
}))]
|
||||
#[case::portable_cache_hint(json!({
|
||||
"system": [{"type": "text", "text": "You are a helpful assistant.", "cache_control": {"type": "ephemeral", "ttl": "1h"}}],
|
||||
"messages": [{"role": "user", "content": "Say hello."}]
|
||||
}))]
|
||||
#[ignore = "calls a real provider, requires credentials and a live model"]
|
||||
#[tokio::test]
|
||||
async fn completion_and_cost_handoff(route: MessagesRoute, #[case] payload: Value) {
|
||||
within_deadline(async {
|
||||
let host = LiveCall::new(PROVIDER);
|
||||
let message = complete(request(&route, call(PROVIDER, payload), &host).await);
|
||||
assert_text(&message);
|
||||
host.assert_provider_result();
|
||||
let expected_cost = message
|
||||
.extra
|
||||
.get("cost")
|
||||
.and_then(|value| match value {
|
||||
Value::Number(number) => number.as_f64(),
|
||||
Value::String(text) => text.parse::<f64>().ok(),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|cost| *cost >= 0.0)
|
||||
.and_then(Number::from_f64)
|
||||
.map_or(Cost::Deferred, |amount| Cost::Reported { amount });
|
||||
assert_eq!(host.facts().cost, expected_cost);
|
||||
println!("{}", serde_json::to_string(&message).unwrap());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[ignore = "calls a real provider, requires credentials and a live model"]
|
||||
#[tokio::test]
|
||||
async fn streaming(route: MessagesRoute) {
|
||||
within_deadline(async {
|
||||
let host = LiveCall::new(PROVIDER);
|
||||
let events = stream_events(
|
||||
request(
|
||||
&route,
|
||||
call(
|
||||
PROVIDER,
|
||||
json!({
|
||||
"stream": true, "messages": [{"role": "user", "content": "Say hello."}]
|
||||
}),
|
||||
),
|
||||
&host,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
assert!(events.iter().any(|event| {
|
||||
event["delta"]["text"]
|
||||
.as_str()
|
||||
.is_some_and(|text| !text.is_empty())
|
||||
}));
|
||||
host.assert_provider_result();
|
||||
assert_eq!(host.facts().cost, Cost::Deferred);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::complete(false)]
|
||||
#[case::streaming(true)]
|
||||
#[ignore = "calls a real provider, requires credentials and a live model"]
|
||||
#[tokio::test]
|
||||
async fn tool_round_trip(route: MessagesRoute, #[case] stream: bool) {
|
||||
super::support::tool_round_trip(
|
||||
route,
|
||||
PROVIDER,
|
||||
stream,
|
||||
json!({
|
||||
"max_tokens": 512, "tool_choice": {"type": "auto"}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
265
litellm-rust/crates/inference-messages/tests/live/support.rs
Normal file
265
litellm-rust/crates/inference-messages/tests/live/support.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
use std::{sync::OnceLock, time::Duration};
|
||||
|
||||
use futures_util::{FutureExt, TryStreamExt};
|
||||
use litellm_host::interceptors::{
|
||||
ExecutionFacts, Interceptors, RawResponse, RequestContext, ResultSource, WireRequest,
|
||||
};
|
||||
use litellm_inference_messages::{Error, MessagesCall, MessagesCallResponse, MessagesRoute};
|
||||
use litellm_inference_testing::live::{LiveResources, model, within_deadline};
|
||||
use litellm_llms_types::formats::messages::MessagesResponse;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub(super) struct LiveCall {
|
||||
provider: &'static str,
|
||||
facts: OnceLock<ExecutionFacts>,
|
||||
}
|
||||
|
||||
impl LiveCall {
|
||||
pub(super) fn new(provider: &'static str) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
facts: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn facts(&self) -> &ExecutionFacts {
|
||||
self.facts
|
||||
.get()
|
||||
.expect("the route must deliver execution facts")
|
||||
}
|
||||
|
||||
pub(super) fn assert_provider_result(&self) {
|
||||
let facts = self.facts();
|
||||
assert_eq!(facts.provider.provider, self.provider);
|
||||
assert_eq!(facts.source, ResultSource::Provider);
|
||||
println!(
|
||||
"{}",
|
||||
json!({"route": "messages", "provider": self.provider, "source": "provider"})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Interceptors<Error> for LiveCall {
|
||||
async fn result_ready(&self, facts: ExecutionFacts) -> Result<(), Error> {
|
||||
assert!(
|
||||
self.facts.set(facts).is_ok(),
|
||||
"result must be accepted exactly once"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn before_provider_request(
|
||||
&self,
|
||||
wire: WireRequest,
|
||||
context: RequestContext,
|
||||
) -> Result<WireRequest, Error> {
|
||||
assert_eq!(context.custom_llm_provider, self.provider);
|
||||
println!(
|
||||
"{}",
|
||||
json!({"route": "messages", "provider": self.provider, "model": context.model})
|
||||
);
|
||||
Ok(wire)
|
||||
}
|
||||
|
||||
async fn after_provider_response(&self, _: RawResponse) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest::fixture]
|
||||
pub(super) fn route() -> MessagesRoute {
|
||||
let resources = LiveResources::default();
|
||||
MessagesRoute::new(resources.http, resources.auth, resources.secrets)
|
||||
}
|
||||
|
||||
pub(super) async fn request(
|
||||
route: &MessagesRoute,
|
||||
call: MessagesCall,
|
||||
host: &LiveCall,
|
||||
) -> MessagesCallResponse {
|
||||
route
|
||||
.execute(call, host, None)
|
||||
.boxed()
|
||||
.await
|
||||
.expect("live request failed")
|
||||
}
|
||||
|
||||
pub(super) fn call(provider: &str, payload: Value) -> MessagesCall {
|
||||
let Value::Object(fields) = payload else {
|
||||
panic!("live payload must be an object")
|
||||
};
|
||||
let body = serde_json::from_value(Value::Object(
|
||||
[("max_tokens".into(), json!(128))]
|
||||
.into_iter()
|
||||
.chain(fields)
|
||||
.chain([("model".into(), json!(model("messages", provider)))])
|
||||
.collect(),
|
||||
))
|
||||
.expect("live payload must be a valid Messages request");
|
||||
MessagesCall {
|
||||
body,
|
||||
api_key: None,
|
||||
api_base: None,
|
||||
custom_llm_provider: Some(provider.into()),
|
||||
litellm_params: Default::default(),
|
||||
extra_headers: None,
|
||||
provider_specific_header: None,
|
||||
timeout: Some(Duration::from_secs(60)),
|
||||
shaping: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn complete(response: MessagesCallResponse) -> Box<MessagesResponse> {
|
||||
match response {
|
||||
MessagesCallResponse::Complete(message) => message,
|
||||
MessagesCallResponse::Stream { .. } => panic!("a complete request returned a stream"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn assert_text(message: &MessagesResponse) {
|
||||
assert!(!message.id.is_empty());
|
||||
assert_eq!(message.role, "assistant");
|
||||
assert!(message.stop_reason.is_some());
|
||||
assert!(
|
||||
message
|
||||
.content
|
||||
.iter()
|
||||
.any(|block| block["text"].as_str().is_some_and(|text| !text.is_empty()))
|
||||
);
|
||||
assert!(
|
||||
message
|
||||
.usage
|
||||
.as_ref()
|
||||
.and_then(|usage| usage["output_tokens"].as_u64())
|
||||
.is_some_and(|count| count > 0)
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) async fn stream_events(response: MessagesCallResponse) -> Vec<Value> {
|
||||
let MessagesCallResponse::Stream { head, chunks } = response else {
|
||||
panic!("stream request returned a complete response")
|
||||
};
|
||||
let content_types: Vec<_> = head
|
||||
.headers
|
||||
.iter()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("content-type"))
|
||||
.map(|(_, value)| value.split(';').next().unwrap().trim())
|
||||
.collect();
|
||||
assert_eq!(content_types, ["text/event-stream"]);
|
||||
let frames = litellm_framer::frames(chunks, litellm_framer::sse::SseCodec::default())
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.expect("live SSE framing failed");
|
||||
let data_frames = match frames.last() {
|
||||
Some(frame) if frame.data == "[DONE]" => &frames[..frames.len() - 1],
|
||||
_ => frames.as_slice(),
|
||||
};
|
||||
let events: Vec<Value> = data_frames
|
||||
.iter()
|
||||
.map(|frame| serde_json::from_str(&frame.data).expect("SSE data must be JSON"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
events.first().map(|event| event["type"].as_str()),
|
||||
Some(Some("message_start"))
|
||||
);
|
||||
assert_eq!(
|
||||
events.last().map(|event| event["type"].as_str()),
|
||||
Some(Some("message_stop"))
|
||||
);
|
||||
assert!(!events.iter().any(|event| event["type"] == "error"));
|
||||
assert!(events.iter().any(|event| {
|
||||
event["usage"]["output_tokens"]
|
||||
.as_u64()
|
||||
.is_some_and(|count| count > 0)
|
||||
}));
|
||||
println!("{}", json!({"events": events}));
|
||||
events
|
||||
}
|
||||
|
||||
fn prompt() -> Value {
|
||||
json!({"role": "user", "content": "Use the echo tool with value live-check, then tell me the result."})
|
||||
}
|
||||
|
||||
fn tools() -> Value {
|
||||
json!([{"name": "echo", "description": "Return a value", "input_schema": {
|
||||
"type": "object", "properties": {"value": {"type": "string", "enum": ["live-check"]}}, "required": ["value"]
|
||||
}}])
|
||||
}
|
||||
|
||||
fn fragments(events: &[Value], index: u64, field: &str) -> String {
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| event["index"].as_u64() == Some(index))
|
||||
.filter_map(|event| event["delta"][field].as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn streamed_block(start: &Value, events: &[Value]) -> Value {
|
||||
let index = start["index"]
|
||||
.as_u64()
|
||||
.expect("content block must have an index");
|
||||
let block = &start["content_block"];
|
||||
match block["type"].as_str() {
|
||||
Some("tool_use") => {
|
||||
let partial = fragments(events, index, "partial_json");
|
||||
let input = match partial.is_empty() {
|
||||
true => block["input"].clone(),
|
||||
false => {
|
||||
serde_json::from_str(&partial).expect("streamed tool arguments must be JSON")
|
||||
}
|
||||
};
|
||||
json!({"type": "tool_use", "id": block["id"], "name": block["name"], "input": input})
|
||||
}
|
||||
Some("text") => {
|
||||
json!({"type": "text", "text": format!("{}{}", block["text"].as_str().unwrap_or_default(), fragments(events, index, "text"))})
|
||||
}
|
||||
Some("thinking") => {
|
||||
json!({"type": "thinking", "thinking": format!("{}{}", block["thinking"].as_str().unwrap_or_default(), fragments(events, index, "thinking")),
|
||||
"signature": format!("{}{}", block["signature"].as_str().unwrap_or_default(), fragments(events, index, "signature"))})
|
||||
}
|
||||
_ => block.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn tool_round_trip(
|
||||
route: MessagesRoute,
|
||||
provider: &'static str,
|
||||
stream: bool,
|
||||
tool_params: Value,
|
||||
) {
|
||||
within_deadline(async {
|
||||
let first_host = LiveCall::new(provider);
|
||||
let payload = json!({"messages": [prompt()], "tools": tools(), "stream": stream});
|
||||
let fields = payload.as_object().expect("tool payload must be an object").clone();
|
||||
let params = tool_params.as_object().expect("tool parameters must be an object").clone();
|
||||
let first = request(&route, call(provider, Value::Object(fields.into_iter().chain(params).collect())), &first_host).await;
|
||||
let content = match stream {
|
||||
false => {
|
||||
let message = complete(first);
|
||||
assert_eq!(message.stop_reason.as_deref(), Some("tool_use"));
|
||||
message.content
|
||||
}
|
||||
true => {
|
||||
let events = stream_events(first).await;
|
||||
assert!(events.iter().any(|event| event["delta"]["stop_reason"] == "tool_use"));
|
||||
events.iter().filter(|event| event["type"] == "content_block_start")
|
||||
.map(|event| streamed_block(event, &events)).collect()
|
||||
}
|
||||
};
|
||||
let tool = content.iter().find(|block| block["type"] == "tool_use").expect("requested tool must be returned");
|
||||
assert_eq!(tool["name"], "echo");
|
||||
assert_eq!(tool["input"]["value"], "live-check");
|
||||
let tool_id = tool["id"].as_str().filter(|id| !id.is_empty()).expect("tool must have an id");
|
||||
first_host.assert_provider_result();
|
||||
let second_host = LiveCall::new(provider);
|
||||
let second = complete(request(&route, call(provider, json!({
|
||||
"tools": tools(), "messages": [prompt(),
|
||||
{"role": "assistant", "content": content},
|
||||
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": tool_id, "content": "live-check"}]}]
|
||||
})), &second_host).await);
|
||||
assert_text(&second);
|
||||
second_host.assert_provider_result();
|
||||
println!("{}", serde_json::to_string(&second).unwrap());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
use litellm_inference_messages::MessagesRoute;
|
||||
use litellm_inference_testing::live::within_deadline;
|
||||
use rstest::rstest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::support::{LiveCall, assert_text, call, complete, request, route, stream_events};
|
||||
|
||||
const PROVIDER: &str = "vertex_ai";
|
||||
|
||||
#[rstest]
|
||||
#[case::basic(json!({"messages": [{"role": "user", "content": "Say hello."}]}))]
|
||||
#[case::system_text(json!({
|
||||
"system": "You are a helpful assistant.",
|
||||
"messages": [{"role": "user", "content": "Say hello."}]
|
||||
}))]
|
||||
#[ignore = "calls a real provider, requires credentials and a live model"]
|
||||
#[tokio::test]
|
||||
async fn completion(route: MessagesRoute, #[case] payload: Value) {
|
||||
within_deadline(async {
|
||||
let host = LiveCall::new(PROVIDER);
|
||||
let message = complete(request(&route, call(PROVIDER, payload), &host).await);
|
||||
assert_text(&message);
|
||||
host.assert_provider_result();
|
||||
println!("{}", serde_json::to_string(&message).unwrap());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[ignore = "calls a real provider, requires credentials and a live model"]
|
||||
#[tokio::test]
|
||||
async fn streaming(route: MessagesRoute) {
|
||||
within_deadline(async {
|
||||
let host = LiveCall::new(PROVIDER);
|
||||
let response = request(
|
||||
&route,
|
||||
call(
|
||||
PROVIDER,
|
||||
json!({"stream": true, "messages": [{"role": "user", "content": "Say hello."}]}),
|
||||
),
|
||||
&host,
|
||||
)
|
||||
.await;
|
||||
let events = stream_events(response).await;
|
||||
assert!(events.iter().any(|event| {
|
||||
event["delta"]["text"]
|
||||
.as_str()
|
||||
.is_some_and(|text| !text.is_empty())
|
||||
}));
|
||||
host.assert_provider_result();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::complete(false)]
|
||||
#[case::streaming(true)]
|
||||
#[ignore = "calls a real provider, requires credentials and a live model"]
|
||||
#[tokio::test]
|
||||
async fn tool_round_trip(route: MessagesRoute, #[case] stream: bool) {
|
||||
super::support::tool_round_trip(
|
||||
route,
|
||||
PROVIDER,
|
||||
stream,
|
||||
json!({"tool_choice": {"type": "auto"}}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ mod request;
|
|||
mod response;
|
||||
mod secrets;
|
||||
mod stream;
|
||||
mod tencent;
|
||||
|
||||
const MODEL: &str = "claude-sonnet-4-5";
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ use super::*;
|
|||
|
||||
#[rstest]
|
||||
#[case::anthropic_key("anthropic", Some("sk-ant"), &[], ("x-api-key", "sk-ant"), &["authorization"])]
|
||||
#[case::minimax_key("minimax", Some("sk-test"), &[], ("x-api-key", "sk-test"), &["authorization"])]
|
||||
#[case::minimax_forwarded_key("minimax", Some("deployment"), &[("X-Api-Key", "caller")], ("x-api-key", "caller"), &["authorization"])]
|
||||
#[case::minimax_forwarded_bearer("minimax", None, &[("Authorization", "Bearer caller")], ("authorization", "Bearer caller"), &["x-api-key"])]
|
||||
#[case::azure_key("azure_ai", Some("sk-azure"), &[], ("x-api-key", "sk-azure"), &["authorization"])]
|
||||
#[case::deepseek_key("deepseek", Some("sk-deepseek"), &[], ("x-api-key", "sk-deepseek"), &["authorization"])]
|
||||
#[case::deepseek_forwards_caller_authorization(
|
||||
|
|
@ -88,6 +91,7 @@ async fn credentials_become_exactly_one_auth_header(
|
|||
#[case::anthropic("anthropic")]
|
||||
#[case::azure_ai("azure_ai")]
|
||||
#[case::deepseek("deepseek")]
|
||||
#[case::minimax("minimax")]
|
||||
#[tokio::test]
|
||||
async fn a_call_without_credentials_fails_before_sending(
|
||||
call: MessagesCall,
|
||||
|
|
@ -123,6 +127,9 @@ async fn a_call_without_credentials_fails_before_sending(
|
|||
"/v1/messages",
|
||||
"/v1/messages"
|
||||
)]
|
||||
#[case::minimax(MODEL, Some("minimax"), "", "/v1/messages")]
|
||||
#[case::minimax_prefix("minimax/claude-sonnet-4-5", None, "/v1/messages", "/v1/messages")]
|
||||
#[case::minimax_endpoint_slash(MODEL, Some("minimax"), "/v1/messages/", "/v1/messages")]
|
||||
#[case::azure_ai(MODEL, Some("azure_ai"), "", "/anthropic/v1/messages")]
|
||||
#[case::deepseek(MODEL, Some("deepseek"), "", "/anthropic/v1/messages")]
|
||||
#[case::deepseek_openai_compatible_base(MODEL, Some("deepseek"), "/beta", "/anthropic/v1/messages")]
|
||||
|
|
@ -301,6 +308,7 @@ fn sent_betas(request: &wiremock::Request) -> BetaSet {
|
|||
#[tokio::test]
|
||||
async fn feature_betas_join_the_callers_betas_in_one_sorted_header(
|
||||
call: MessagesCall,
|
||||
#[values("anthropic", "minimax")] provider: &str,
|
||||
#[case] fields: Value,
|
||||
#[case] features: &[AnthropicBeta],
|
||||
) {
|
||||
|
|
@ -312,6 +320,7 @@ async fn feature_betas_join_the_callers_betas_in_one_sorted_header(
|
|||
|
||||
run_message(with_fields(
|
||||
MessagesCall {
|
||||
custom_llm_provider: Some(provider.into()),
|
||||
api_key: Some("sk".into()),
|
||||
api_base: Some(upstream.uri()),
|
||||
extra_headers: headers([("Anthropic-Beta", "caller-beta-2025-01-01")]),
|
||||
|
|
@ -362,6 +371,7 @@ async fn an_oauth_key_sends_the_browser_access_header_and_the_oauth_beta(call: M
|
|||
#[case::anthropic("anthropic")]
|
||||
#[case::azure_ai("azure_ai")]
|
||||
#[case::deepseek("deepseek")]
|
||||
#[case::minimax("minimax")]
|
||||
#[tokio::test]
|
||||
async fn caller_protocol_headers_win_over_the_defaults(call: MessagesCall, #[case] provider: &str) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
|
|
@ -452,6 +462,7 @@ async fn unsupported_params_are_dropped_under_drop_params_and_rejected_without_i
|
|||
#[tokio::test]
|
||||
async fn reasoning_auto_summary_marks_active_thinking_on_the_wire(
|
||||
call: MessagesCall,
|
||||
#[values("anthropic", "minimax")] provider: &str,
|
||||
#[case] thinking: Value,
|
||||
#[case] expected: Value,
|
||||
) {
|
||||
|
|
@ -459,6 +470,7 @@ async fn reasoning_auto_summary_marks_active_thinking_on_the_wire(
|
|||
|
||||
run_message(with_fields(
|
||||
MessagesCall {
|
||||
custom_llm_provider: Some(provider.into()),
|
||||
api_key: Some("sk".into()),
|
||||
api_base: Some(upstream.uri()),
|
||||
shaping: MessagesShaping {
|
||||
|
|
@ -517,6 +529,7 @@ async fn reasoning_auto_summary_marks_active_thinking_on_the_wire(
|
|||
#[tokio::test]
|
||||
async fn reasoning_is_translated_by_the_model_capabilities(
|
||||
call: MessagesCall,
|
||||
#[values("anthropic", "minimax")] provider: &str,
|
||||
#[case] capabilities: MessagesModelCapabilities,
|
||||
#[case] fields: Value,
|
||||
#[case] expected: Value,
|
||||
|
|
@ -525,6 +538,7 @@ async fn reasoning_is_translated_by_the_model_capabilities(
|
|||
|
||||
run_message(with_fields(
|
||||
MessagesCall {
|
||||
custom_llm_provider: Some(provider.into()),
|
||||
api_key: Some("sk".into()),
|
||||
api_base: Some(upstream.uri()),
|
||||
shaping: MessagesShaping {
|
||||
|
|
@ -592,6 +606,7 @@ async fn replayed_history_is_cleaned_before_sending(
|
|||
#[case::anthropic("anthropic")]
|
||||
#[case::azure("azure_ai")]
|
||||
#[case::deepseek("deepseek")]
|
||||
#[case::minimax("minimax")]
|
||||
#[tokio::test]
|
||||
async fn metadata_is_reduced_to_the_user_id(call: MessagesCall, #[case] provider: &str) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
|
|
@ -703,11 +718,16 @@ async fn vertex_ai_addresses_the_model_in_the_url_and_not_in_the_body(
|
|||
#[case::numeric_user_id(json!({"metadata": {"user_id": 7}}))]
|
||||
#[case::missing_max_tokens(json!({"max_tokens": null}))]
|
||||
#[tokio::test]
|
||||
async fn an_invalid_request_fails_before_sending(call: MessagesCall, #[case] fields: Value) {
|
||||
async fn an_invalid_request_fails_before_sending(
|
||||
call: MessagesCall,
|
||||
#[values("anthropic", "minimax")] provider: &str,
|
||||
#[case] fields: Value,
|
||||
) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
|
||||
let error = run(with_fields(
|
||||
MessagesCall {
|
||||
custom_llm_provider: Some(provider.into()),
|
||||
api_key: Some("sk".into()),
|
||||
api_base: Some(upstream.uri()),
|
||||
..call
|
||||
|
|
@ -721,6 +741,45 @@ async fn an_invalid_request_fails_before_sending(call: MessagesCall, #[case] fie
|
|||
assert!(received(&upstream).await.is_empty());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::billing_string(json!("x-anthropic-billing-header: private"), None)]
|
||||
#[case::billing_blocks(json!([{ "type": "text", "text": "x-anthropic-billing-header: private" }]), None)]
|
||||
#[case::mixed_blocks(
|
||||
json!([{ "type": "text", "text": "x-anthropic-billing-header: private" }, { "type": "text", "text": "keep" }]),
|
||||
Some(json!([{ "type": "text", "text": "keep" }]))
|
||||
)]
|
||||
#[case::ordinary_system(json!("keep"), Some(json!("keep")))]
|
||||
#[tokio::test]
|
||||
async fn minimax_strips_only_billing_system_blocks(
|
||||
call: MessagesCall,
|
||||
#[case] system: Value,
|
||||
#[case] expected: Option<Value>,
|
||||
) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let fields = json!({
|
||||
"system": system,
|
||||
"tools": [{"name": "echo", "input_schema": {"type": "object"}}],
|
||||
"messages": [{"role": "user", "content": [{
|
||||
"type": "text", "text": "hi", "cache_control": {"type": "ephemeral", "ttl": "1h"}
|
||||
}]}]
|
||||
});
|
||||
run_message(with_fields(
|
||||
MessagesCall {
|
||||
custom_llm_provider: Some("minimax".into()),
|
||||
api_key: Some("sk".into()),
|
||||
api_base: Some(upstream.uri()),
|
||||
..call
|
||||
},
|
||||
fields.clone(),
|
||||
))
|
||||
.await;
|
||||
|
||||
let sent = only_request(&upstream).await.json();
|
||||
assert_eq!(sent.get("system"), expected.as_ref());
|
||||
assert_eq!(sent["tools"], fields["tools"]);
|
||||
assert_eq!(sent["messages"], fields["messages"]);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::azure("azure_ai", &[], json!([
|
||||
{"type": "text", "text": "top level"},
|
||||
|
|
@ -835,3 +894,38 @@ async fn provider_validation_runs_before_caller_parameter_removal(
|
|||
);
|
||||
assert!(received(&upstream).await.is_empty());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn edenai_preserves_native_thinking_and_strips_billing_and_cache_extensions(
|
||||
call: MessagesCall,
|
||||
) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let thinking = json!({"type": "enabled", "budget_tokens": 512, "native_field": true});
|
||||
run_message(MessagesCall {
|
||||
custom_llm_provider: Some("edenai".into()),
|
||||
api_base: Some(format!("{}/v3", upstream.uri())),
|
||||
api_key: Some("eden-key".into()),
|
||||
extra_headers: headers([("Anthropic-Beta", "unregistered-native-beta")]),
|
||||
..with_fields(call, json!({
|
||||
"thinking": thinking, "temperature": 0.25,
|
||||
"system": [{"type": "text", "text": "x-anthropic-billing-header: hidden"},
|
||||
{"type": "text", "text": "keep", "cache_control": {"type": "ephemeral", "ttl": "1h", "scope": "global"}}],
|
||||
}))
|
||||
}).await;
|
||||
let sent = only_request(&upstream).await;
|
||||
assert_eq!(sent.url.path(), "/v3/v1/messages");
|
||||
assert_eq!(sent.header("authorization"), Some("Bearer eden-key"));
|
||||
assert_eq!(
|
||||
sent.header("anthropic-beta"),
|
||||
Some("unregistered-native-beta")
|
||||
);
|
||||
assert_eq!(
|
||||
sent.json(),
|
||||
json!({
|
||||
"model": MODEL, "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}],
|
||||
"thinking": thinking, "temperature": 0.25,
|
||||
"system": [{"type": "text", "text": "keep", "cache_control": {"type": "ephemeral"}}],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ use super::*;
|
|||
"/anthropic/v1/messages",
|
||||
&["DEEPSEEK_API_KEY", "DEEPSEEK_ANTHROPIC_API_BASE", "DEEPSEEK_API_BASE"]
|
||||
)]
|
||||
#[case::minimax(
|
||||
"minimax",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_API_BASE",
|
||||
"/v1/messages",
|
||||
&["MINIMAX_API_KEY", "MINIMAX_API_BASE"]
|
||||
)]
|
||||
#[tokio::test]
|
||||
async fn the_credential_and_base_come_from_the_secret_source(
|
||||
call: MessagesCall,
|
||||
|
|
|
|||
|
|
@ -150,9 +150,21 @@ async fn stream_through(host: &RecordingStreamHost) -> Result<MessagesOutput, Er
|
|||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn upstream_headers_are_on_the_stream_head_before_the_first_chunk(call: MessagesCall) {
|
||||
async fn upstream_headers_are_on_the_stream_head_before_the_first_chunk(
|
||||
call: MessagesCall,
|
||||
#[values("anthropic", "minimax")] provider: &str,
|
||||
) {
|
||||
let upstream = upstream([sse_response()]).await;
|
||||
let host = RecordingStreamHost::new(streaming(call, upstream.uri()), usize::MAX);
|
||||
let host = RecordingStreamHost::new(
|
||||
streaming(
|
||||
MessagesCall {
|
||||
custom_llm_provider: Some(provider.into()),
|
||||
..call
|
||||
},
|
||||
upstream.uri(),
|
||||
),
|
||||
usize::MAX,
|
||||
);
|
||||
|
||||
let outcome = stream_through(&host).await.expect("streamed call succeeds");
|
||||
|
||||
|
|
@ -336,6 +348,7 @@ async fn the_timeout_covers_a_stalled_stream_body(call: MessagesCall) {
|
|||
#[rstest]
|
||||
#[case::anthropic("anthropic")]
|
||||
#[case::azure_ai("azure_ai")]
|
||||
#[case::edenai("edenai")]
|
||||
#[tokio::test]
|
||||
async fn the_sdk_returns_stream_headers_and_every_sse_byte(
|
||||
call: MessagesCall,
|
||||
|
|
@ -346,6 +359,7 @@ async fn the_sdk_returns_stream_headers_and_every_sse_byte(
|
|||
.execute(
|
||||
MessagesCall {
|
||||
custom_llm_provider: Some(provider.into()),
|
||||
api_key: Some("stream-key".into()),
|
||||
litellm_params: Default::default(),
|
||||
..streaming(call, upstream.uri())
|
||||
},
|
||||
|
|
|
|||
260
litellm-rust/crates/inference-messages/tests/messages/tencent.rs
Normal file
260
litellm-rust/crates/inference-messages/tests/messages/tencent.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
use std::ops::ControlFlow;
|
||||
|
||||
use bytes::Bytes;
|
||||
use litellm_llms::base_llm::messages::context::MessagesModelCapabilities;
|
||||
use litellm_llms_types::providers::anthropic::DEFAULT_HEADERS;
|
||||
use rstest::{fixture, rstest};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[fixture]
|
||||
fn tencent_call(call: MessagesCall) -> MessagesCall {
|
||||
MessagesCall {
|
||||
custom_llm_provider: Some("tencent".into()),
|
||||
api_key: Some("deployment-key".into()),
|
||||
..call
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::deployment_key(&[], "x-api-key", "deployment-key")]
|
||||
#[case::forwarded_key(&[("X-Api-Key", "caller-key")], "x-api-key", "caller-key")]
|
||||
#[case::forwarded_bearer(&[("Authorization", "Bearer caller")], "authorization", "Bearer caller")]
|
||||
#[tokio::test]
|
||||
async fn credentials_reach_the_selected_provider(
|
||||
tencent_call: MessagesCall,
|
||||
#[case] forwarded: &[(&str, &str)],
|
||||
#[case] header: &str,
|
||||
#[case] credential: &str,
|
||||
) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
run_message(MessagesCall {
|
||||
api_base: Some(upstream.uri()),
|
||||
extra_headers: headers(forwarded.iter().copied()),
|
||||
..tencent_call
|
||||
})
|
||||
.await;
|
||||
let sent = only_request(&upstream).await;
|
||||
assert_eq!(sent.header_values(header), [credential]);
|
||||
let version = DEFAULT_HEADERS
|
||||
.iter()
|
||||
.find_map(|(name, value)| (*name == "anthropic-version").then_some(*value))
|
||||
.unwrap();
|
||||
assert_eq!(sent.header_values("anthropic-version"), [version]);
|
||||
assert_eq!(sent.json()["model"], MODEL);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn credentials_and_base_resolve_from_the_secret_source(tencent_call: MessagesCall) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let base = format!("{}/v1/chat/completions/", upstream.uri());
|
||||
let secrets = Arc::new(RecordingSecrets::new([
|
||||
("TENCENT_API_KEY", "secret-key"),
|
||||
("TENCENT_ANTHROPIC_API_BASE", base.as_str()),
|
||||
("TENCENT_API_BASE", UNREACHABLE_BASE),
|
||||
]));
|
||||
run_with(
|
||||
secrets,
|
||||
MessagesCall {
|
||||
api_key: None,
|
||||
..tencent_call
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let sent = only_request(&upstream).await;
|
||||
assert_eq!(sent.url.path(), "/v1/messages");
|
||||
assert_eq!(sent.header("x-api-key"), Some("secret-key"));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn missing_credentials_fail_before_sending(tencent_call: MessagesCall) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let error = run(MessagesCall {
|
||||
api_key: None,
|
||||
api_base: Some(upstream.uri()),
|
||||
..tencent_call
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
Error::Auth(litellm_auth::Error::MissingApiKey {
|
||||
environment_variable: "TENCENT_API_KEY",
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(received(&upstream).await.is_empty());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::missing_max_tokens(json!({"max_tokens": null}))]
|
||||
#[case::invalid_metadata(json!({"metadata": {"user_id": 7}}))]
|
||||
#[case::invalid_reasoning_effort(json!({"reasoning_effort": "unknown"}))]
|
||||
#[tokio::test]
|
||||
async fn malformed_requests_fail_before_sending(tencent_call: MessagesCall, #[case] fields: Value) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let error = run(with_fields(
|
||||
MessagesCall {
|
||||
api_base: Some(upstream.uri()),
|
||||
..tencent_call
|
||||
},
|
||||
fields,
|
||||
))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.is_request(), "{error:?}");
|
||||
assert!(received(&upstream).await.is_empty());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::legacy(false, json!({"type": "enabled", "budget_tokens": 4095}))]
|
||||
#[case::adaptive(true, json!({"type": "adaptive", "display": "summarized"}))]
|
||||
#[tokio::test]
|
||||
async fn reasoning_effort_becomes_model_appropriate_thinking(
|
||||
tencent_call: MessagesCall,
|
||||
#[case] adaptive: bool,
|
||||
#[case] thinking: Value,
|
||||
) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
run_message(with_fields(
|
||||
MessagesCall {
|
||||
api_base: Some(upstream.uri()),
|
||||
shaping: MessagesShaping {
|
||||
capabilities: MessagesModelCapabilities {
|
||||
supports_reasoning: true,
|
||||
supports_adaptive_thinking: adaptive,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
..tencent_call
|
||||
},
|
||||
json!({"max_tokens": 4096, "reasoning_effort": "high"}),
|
||||
))
|
||||
.await;
|
||||
let sent = only_request(&upstream).await.json();
|
||||
assert_eq!(sent["thinking"], thinking);
|
||||
assert_eq!(sent.get("reasoning_effort"), None);
|
||||
assert_eq!(
|
||||
sent.get("output_config"),
|
||||
adaptive.then(|| json!({"effort": "high"})).as_ref()
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::encrypted_reasoning(json!({"type": "thinking", "thinking": "private", "signature": "litellm_encrypted_reasoning:private"}))]
|
||||
#[case::orphaned_advisor(json!({"type": "server_tool_use", "name": "advisor", "id": "srvtoolu_advisor", "input": {}}))]
|
||||
#[tokio::test]
|
||||
async fn replayed_history_is_cleaned_without_losing_text(
|
||||
tencent_call: MessagesCall,
|
||||
#[case] block: Value,
|
||||
) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
run_message(with_fields(
|
||||
MessagesCall {
|
||||
api_base: Some(upstream.uri()),
|
||||
..tencent_call
|
||||
},
|
||||
json!({"messages": [
|
||||
{"role": "assistant", "content": [block, {"type": "text", "text": "keep"}]},
|
||||
{"role": "user", "content": "continue"}
|
||||
]}),
|
||||
))
|
||||
.await;
|
||||
assert_eq!(
|
||||
only_request(&upstream).await.json()["messages"],
|
||||
json!([
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "keep"}]},
|
||||
{"role": "user", "content": "continue"}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::billing_string(json!("x-anthropic-billing-header: private"), None)]
|
||||
#[case::mixed_blocks(json!([
|
||||
{"type": "text", "text": "x-anthropic-billing-header: private"},
|
||||
{"type": "text", "text": "keep"}
|
||||
]), Some(json!([{ "type": "text", "text": "keep" }])))]
|
||||
#[case::ordinary_system(json!("keep"), Some(json!("keep")))]
|
||||
#[tokio::test]
|
||||
async fn billing_is_removed_and_tools_and_cache_hints_are_preserved(
|
||||
tencent_call: MessagesCall,
|
||||
#[case] system: Value,
|
||||
#[case] expected: Option<Value>,
|
||||
) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let fields = json!({
|
||||
"system": system,
|
||||
"tools": [{"name": "echo", "input_schema": {"type": "object"}}],
|
||||
"messages": [{"role": "user", "content": [{
|
||||
"type": "text", "text": "hi", "cache_control": {"type": "ephemeral", "ttl": "1h"}
|
||||
}]}]
|
||||
});
|
||||
run_message(with_fields(
|
||||
MessagesCall {
|
||||
api_base: Some(upstream.uri()),
|
||||
..tencent_call
|
||||
},
|
||||
fields.clone(),
|
||||
))
|
||||
.await;
|
||||
let sent = only_request(&upstream).await.json();
|
||||
assert_eq!(sent.get("system"), expected.as_ref());
|
||||
assert_eq!(sent["tools"], fields["tools"]);
|
||||
assert_eq!(sent["messages"], fields["messages"]);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StreamSink(Mutex<Vec<u8>>);
|
||||
|
||||
impl litellm_host_native::in_process::StreamConsumer<Messages> for StreamSink {
|
||||
async fn open_stream(
|
||||
&self,
|
||||
_: litellm_inference_messages::route::MessagesStreamHead,
|
||||
) -> Result<ControlFlow<()>, Error> {
|
||||
Ok(ControlFlow::Continue(()))
|
||||
}
|
||||
|
||||
async fn send_chunk(&self, chunk: Bytes) -> Result<ControlFlow<()>, Error> {
|
||||
self.0.lock().unwrap().extend_from_slice(&chunk);
|
||||
Ok(ControlFlow::Continue(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn streaming_relays_provider_events_unchanged(tencent_call: MessagesCall) {
|
||||
let events = concat!(
|
||||
"event: message_start\ndata: {\"type\":\"message_start\"}\n\n",
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"OK\"}}\n\n",
|
||||
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
);
|
||||
let upstream =
|
||||
upstream([ResponseTemplate::new(200).set_body_raw(events, "text/event-stream")]).await;
|
||||
let host = LocalMessagesHost::new(with_fields(
|
||||
MessagesCall {
|
||||
api_base: Some(upstream.uri()),
|
||||
..tencent_call
|
||||
},
|
||||
json!({"stream": true}),
|
||||
));
|
||||
let sink = StreamSink::default();
|
||||
let outcome = litellm_host_native::in_process::run_hosted(
|
||||
machine(Arc::new(RecordingSecrets::empty()))(host.request().unwrap()),
|
||||
litellm_host_native::in_process::Host {
|
||||
services: &(),
|
||||
interceptors: &host,
|
||||
stream: &sink,
|
||||
observers: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, MessagesOutput::StreamEnded));
|
||||
assert_eq!(sink.0.into_inner().unwrap(), events.as_bytes());
|
||||
assert_eq!(only_request(&upstream).await.json()["stream"], true);
|
||||
}
|
||||
147
litellm-rust/crates/inference-messages/tests/openrouter/main.rs
Normal file
147
litellm-rust/crates/inference-messages/tests/openrouter/main.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use futures_util::TryStreamExt;
|
||||
use litellm_inference_messages::{Error, MessagesCall, MessagesCallResponse, MessagesShaping};
|
||||
use litellm_inference_testing::{RecordingSecrets, no_secrets};
|
||||
use rstest::{fixture, rstest};
|
||||
use serde_json::json;
|
||||
use wiremock::ResponseTemplate;
|
||||
|
||||
#[path = "../support/mod.rs"]
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
#[fixture]
|
||||
fn call() -> MessagesCall {
|
||||
MessagesCall {
|
||||
body: serde_json::from_value(json!({"model": "openrouter/anthropic/test-model", "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}]})).unwrap(),
|
||||
api_key: None,
|
||||
api_base: None,
|
||||
custom_llm_provider: None,
|
||||
litellm_params: Default::default(),
|
||||
extra_headers: None,
|
||||
provider_specific_header: None,
|
||||
timeout: Some(Duration::from_secs(5)),
|
||||
shaping: MessagesShaping::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn message_response() -> ResponseTemplate {
|
||||
json_response(
|
||||
json!({"id": "msg_1", "type": "message", "role": "assistant", "model": "anthropic/test-model", "content": [{"type": "text", "text": "hi"}], "stop_reason": "end_turn", "usage": {"input_tokens": 1, "output_tokens": 1}}),
|
||||
)
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::explicit(Some("explicit"), None, "Bearer explicit")]
|
||||
#[case::managed(None, None, "Bearer managed")]
|
||||
#[case::forwarded(None, Some("bEaReR caller"), "bEaReR caller")]
|
||||
#[case::blank(Some("explicit"), Some("Bearer "), "Bearer explicit")]
|
||||
#[case::malformed(Some("explicit"), Some("invali\u{00e9}auth"), "Bearer explicit")]
|
||||
#[tokio::test]
|
||||
async fn only_a_valid_bearer_can_override_the_configured_key(
|
||||
call: MessagesCall,
|
||||
#[case] api_key: Option<&str>,
|
||||
#[case] authorization: Option<&str>,
|
||||
#[case] expected: &str,
|
||||
) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let extra_headers = [("x-api-key".to_string(), json!("caller-anthropic-key"))]
|
||||
.into_iter()
|
||||
.chain(authorization.map(|value| ("Authorization".to_string(), json!(value))))
|
||||
.collect();
|
||||
let secrets = Arc::new(RecordingSecrets::new([("OPENROUTER_API_KEY", "managed")]));
|
||||
let output = messages_route(secrets)
|
||||
.execute(
|
||||
MessagesCall {
|
||||
api_key: api_key.map(str::to_string),
|
||||
api_base: Some(format!("{}/api/v1", upstream.uri())),
|
||||
extra_headers: Some(extra_headers),
|
||||
..call
|
||||
},
|
||||
&(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(output, MessagesCallResponse::Complete(_)));
|
||||
let request = only_request(&upstream).await;
|
||||
assert_eq!(request.url.path(), "/api/v1/messages");
|
||||
assert_eq!(request.header_values("authorization"), [expected]);
|
||||
assert_eq!(request.header("x-api-key"), None);
|
||||
assert_eq!(request.json()["model"], "anthropic/test-model");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn an_anthropic_key_alone_is_not_an_openrouter_credential(call: MessagesCall) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let result = messages_route(no_secrets())
|
||||
.execute(
|
||||
MessagesCall {
|
||||
api_base: Some(upstream.uri()),
|
||||
extra_headers: Some(
|
||||
json!({"x-api-key": "caller-key"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
),
|
||||
..call
|
||||
},
|
||||
&(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(Error::Auth(litellm_auth::Error::MissingApiKey {
|
||||
provider: "OpenRouter",
|
||||
..
|
||||
}))
|
||||
));
|
||||
assert!(received(&upstream).await.is_empty());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn the_secret_manager_supplies_the_native_base_and_key(call: MessagesCall) {
|
||||
let upstream = upstream([message_response()]).await;
|
||||
let base = format!("{}/api/v1", upstream.uri());
|
||||
let secrets = Arc::new(RecordingSecrets::new([
|
||||
("OPENROUTER_API_KEY", "managed"),
|
||||
("OPENROUTER_API_BASE", base.as_str()),
|
||||
]));
|
||||
messages_route(secrets)
|
||||
.execute(call, &(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let request = only_request(&upstream).await;
|
||||
assert_eq!(request.url.path(), "/api/v1/messages");
|
||||
assert_eq!(request.header("authorization"), Some("Bearer managed"));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn streaming_relay_preserves_every_event_and_billed_usage(call: MessagesCall) {
|
||||
let sse = "event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"cost\":\"0.001\",\"cost_details\":\"unexpected\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
|
||||
let upstream =
|
||||
upstream([ResponseTemplate::new(200).set_body_raw(sse, "text/event-stream")]).await;
|
||||
let streamed = MessagesCall {
|
||||
body: serde_json::from_value(json!({"model": "openrouter/anthropic/test-model", "max_tokens": 16, "messages": [{"role": "user", "content": "hi"}], "stream": true})).unwrap(),
|
||||
api_key: Some("key".into()),
|
||||
api_base: Some(upstream.uri()),
|
||||
..call
|
||||
};
|
||||
let output = messages_route(no_secrets())
|
||||
.execute(streamed, &(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let MessagesCallResponse::Stream { chunks, .. } = output else {
|
||||
panic!("expected SSE");
|
||||
};
|
||||
assert_eq!(
|
||||
chunks.try_collect::<Vec<_>>().await.unwrap().concat(),
|
||||
sse.as_bytes()
|
||||
);
|
||||
assert_eq!(only_request(&upstream).await.json()["stream"], true);
|
||||
}
|
||||
|
|
@ -7,7 +7,9 @@ repository.workspace = true
|
|||
publish = false
|
||||
|
||||
[dependencies]
|
||||
litellm-auth.workspace = true
|
||||
futures-util.workspace = true
|
||||
litellm-http.workspace = true
|
||||
litellm-inference.workspace = true
|
||||
litellm-secrets.workspace = true
|
||||
tokio.workspace = true
|
||||
|
|
|
|||
46
litellm-rust/crates/inference-testing/README.md
Normal file
46
litellm-rust/crates/inference-testing/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Live provider route tests
|
||||
|
||||
Each route crate owns `tests/live.rs`. Use named `rstest` provider cases for the behaviors that route supports. Shared setup lives in `litellm_inference_testing::live`, with production HTTP settings, authentication services, environment secrets and a deadline
|
||||
|
||||
These tests execute the Rust route against a real provider. They do not prove Python parity, proxy authorization, billing persistence or agent compatibility. Those belong in the corresponding boundary and gateway suites
|
||||
|
||||
All live cases use `#[ignore = "calls a real provider, requires credentials and a live model"]`. Normal Cargo runs report them as ignored. An explicitly selected case fails on missing credentials, unavailable models, provider errors or timeouts. Never return early and count missing coverage as a pass
|
||||
|
||||
Set `LITELLM_LIVE_<ROUTE>_<PROVIDER>_MODEL` to the latest model available to the test account. The model has no compiled default because vendor availability changes independently of LiteLLM. Credentials use the provider's existing configuration rather than a second test-only key convention. Export credentials in the shell before running tests. The harness does not load `.env` or modify process environment while tests run
|
||||
|
||||
## Messages
|
||||
|
||||
Completion checks text and usage. Streaming consumes SSE to completion and checks text, usage and the terminal event. Tool round trips request a tool call and send its returned ID back with a result. Each case checks provider execution facts and rejects cache results
|
||||
|
||||
Messages setup and assertions live in `inference-messages/tests/live/support.rs`. Provider modules under `tests/live/` select their payloads and tool policy. The shared helpers consume streams completely and reconstruct text, thinking signatures and tool arguments for the follow-up request
|
||||
|
||||
The initial provider cases are `vertex_ai` and `edenai`. A registered ignored case is available coverage, not evidence that it passed against that provider
|
||||
|
||||
Run one provider's cases explicitly
|
||||
|
||||
```bash
|
||||
LITELLM_LIVE_MESSAGES_VERTEX_AI_MODEL=<claude-model-on-vertex> \
|
||||
cargo test --manifest-path litellm-rust/Cargo.toml \
|
||||
-p litellm-inference-messages --test live vertex_ai \
|
||||
-- --ignored --nocapture --test-threads=1
|
||||
```
|
||||
|
||||
For Vertex AI, set `LITELLM_LIVE_MESSAGES_VERTEX_AI_MODEL` to a Claude model published in the project's location. Credentials, project and location resolve through the production Rust GCP authentication, from `GOOGLE_APPLICATION_CREDENTIALS` or `VERTEXAI_CREDENTIALS`, with `VERTEXAI_PROJECT` and `VERTEXAI_LOCATION`
|
||||
|
||||
Vertex AI cases live in `inference-messages/tests/live/vertex_ai.rs`. They cover plain and system messages, text streaming, and complete and streaming tool round trips. Tool cases use automatic selection because current Claude models reject forced `tool_choice`. Each case still requires a tool call, checks its arguments, and sends the tool result back
|
||||
|
||||
For EdenAI, set `LITELLM_LIVE_MESSAGES_EDENAI_MODEL` and `EDENAI_API_KEY`, and select `edenai`. EdenAI requires a production token for live coverage, because sandbox tokens return dummy responses
|
||||
|
||||
EdenAI cases live in `inference-messages/tests/live/edenai.rs`. They cover system messages, portable cache hints, provider-reported cost handoff, text streaming, and complete and streaming tool round trips. The cost assertion compares the public response with the execution facts instead of pinning a vendor price
|
||||
EdenAI tool cases use automatic selection because current Claude models reject forced `tool_choice`. The test still requires a tool call and verifies its arguments and follow-up response
|
||||
|
||||
Other tool cases require a model that supports forced `tool_choice`, except where a provider's section says otherwise. A provider rejection fails the selected case instead of skipping it
|
||||
|
||||
Select one behavior by its test name, for example `completion::case_1_basic`. To list every available cell without contacting any provider
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path litellm-rust/Cargo.toml \
|
||||
-p litellm-inference-messages --test live -- --list
|
||||
```
|
||||
|
||||
For another route, reuse `LiveResources`, `model` and `within_deadline`. Keep payload construction and response assertions in that route's test file. Add only supported provider behaviors, and document any gap instead of substituting mocks or skipping failures. Run cases serially to keep spending and rate limits predictable
|
||||
|
|
@ -9,6 +9,8 @@ use litellm_secrets::{SecretValue, source::SecretSource};
|
|||
|
||||
use litellm_inference::resources::CoreResources;
|
||||
|
||||
pub mod live;
|
||||
|
||||
pub fn http_pool() -> HttpClientPool {
|
||||
HttpClientPool::new(Arc::new(PublicDnsResolver))
|
||||
}
|
||||
|
|
|
|||
53
litellm-rust/crates/inference-testing/src/live.rs
Normal file
53
litellm-rust/crates/inference-testing/src/live.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
use std::{future::Future, sync::Arc, time::Duration};
|
||||
|
||||
use litellm_http::{ClientVariant, HttpSettings, HttpSettingsLayer, Resolution};
|
||||
use litellm_inference::resources::CoreResources;
|
||||
use litellm_secrets::source::{EnvironmentSecrets, SecretSource};
|
||||
|
||||
pub const DEADLINE: Duration = Duration::from_secs(120);
|
||||
|
||||
pub struct LiveResources {
|
||||
pub http: litellm_http::Client,
|
||||
pub auth: Arc<litellm_auth::AuthServices>,
|
||||
pub secrets: Arc<dyn SecretSource>,
|
||||
}
|
||||
|
||||
impl Default for LiveResources {
|
||||
fn default() -> Self {
|
||||
let resources = CoreResources::new(Arc::new(super::http_pool()));
|
||||
let settings =
|
||||
HttpSettings::from_layers([HttpSettingsLayer::from_environment(&|key: &str| {
|
||||
std::env::var(key).ok()
|
||||
})]);
|
||||
let resolution = Resolution::from(&settings);
|
||||
let http = resources
|
||||
.pool
|
||||
.client(&resolution.config, ClientVariant::Provider)
|
||||
.expect("live provider HTTP settings must be valid");
|
||||
let secrets_http = resources
|
||||
.pool
|
||||
.client(&resolution.config, ClientVariant::NoRedirect)
|
||||
.expect("live secret HTTP settings must be valid");
|
||||
Self {
|
||||
http,
|
||||
auth: resources.auth,
|
||||
secrets: Arc::new(EnvironmentSecrets::python_compatible(secrets_http)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model(route: &str, provider: &str) -> String {
|
||||
let name = format!("LITELLM_LIVE_{route}_{provider}_MODEL").to_ascii_uppercase();
|
||||
std::env::var(&name)
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
panic!("set {name} to an available current model before running this live case")
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn within_deadline<T>(operation: impl Future<Output = T>) -> T {
|
||||
tokio::time::timeout(DEADLINE, operation)
|
||||
.await
|
||||
.expect("live case exceeded its 120 second deadline")
|
||||
}
|
||||
|
|
@ -13,7 +13,9 @@ use litellm_cache_response::{
|
|||
};
|
||||
use litellm_host::{
|
||||
call::{CallOutput, OutputOf},
|
||||
interceptors::{ExecutionFacts, Interceptors, ProviderIdentity, ResultSource, WireRequest},
|
||||
interceptors::{
|
||||
Cost, ExecutionFacts, Interceptors, ProviderIdentity, ResultSource, WireRequest,
|
||||
},
|
||||
lifecycle::{CallEvent, ExecutionEvent},
|
||||
observation::ObservationSender,
|
||||
protocol::Protocol,
|
||||
|
|
@ -168,10 +170,12 @@ where
|
|||
None => (provider().await?, ResultSource::Provider),
|
||||
};
|
||||
let from_provider = source == ResultSource::Provider;
|
||||
let cost = Cost::settle(&source, None);
|
||||
publish(
|
||||
ExecutionFacts {
|
||||
provider: identity,
|
||||
source,
|
||||
cost,
|
||||
},
|
||||
interceptors,
|
||||
observers,
|
||||
|
|
@ -212,6 +216,7 @@ where
|
|||
publish(
|
||||
ExecutionFacts {
|
||||
provider: identity,
|
||||
cost: Cost::settle(&source, None),
|
||||
source: source.clone(),
|
||||
},
|
||||
interceptors,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
mod response;
|
||||
|
||||
pub use response::*;
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
mod response;
|
||||
|
||||
pub use response::*;
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
mod content;
|
||||
mod request;
|
||||
mod response;
|
||||
mod streaming;
|
||||
|
||||
pub use content::*;
|
||||
pub use request::*;
|
||||
pub use response::*;
|
||||
pub use streaming::*;
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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>>,
|
||||
}
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
12
litellm-rust/crates/llms-types/src/formats/ocr/AGENTS.md
Normal file
12
litellm-rust/crates/llms-types/src/formats/ocr/AGENTS.md
Normal 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
|
||||
5
litellm-rust/crates/llms-types/src/formats/ocr/mod.rs
Normal file
5
litellm-rust/crates/llms-types/src/formats/ocr/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
mod request;
|
||||
mod response;
|
||||
|
||||
pub use request::*;
|
||||
pub use response::*;
|
||||
95
litellm-rust/crates/llms-types/src/formats/ocr/request.rs
Normal file
95
litellm-rust/crates/llms-types/src/formats/ocr/request.rs
Normal 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})
|
||||
);
|
||||
}
|
||||
}
|
||||
224
litellm-rust/crates/llms-types/src/formats/ocr/response.rs
Normal file
224
litellm-rust/crates/llms-types/src/formats/ocr/response.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -3,4 +3,4 @@ mod response;
|
|||
pub mod streaming_websocket;
|
||||
|
||||
pub use output::*;
|
||||
pub use response::ResponsesApiResponse;
|
||||
pub use response::*;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,3 +49,147 @@ pub struct JsonSchemaObject {
|
|||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{
|
||||
json_schema::{JsonSchema, JsonSchemaItems, JsonSchemaObject, JsonSchemaType},
|
||||
test_support::*,
|
||||
};
|
||||
use rstest::rstest;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[rstest]
|
||||
#[case::boolean(json!(false))]
|
||||
#[case::nested(json!({
|
||||
"type":["object","null"],
|
||||
"properties":{"nested":{"type":"array","items":{"$ref":"#/$defs/item"}},"free":true},
|
||||
"additionalProperties":{"type":"string"},
|
||||
"$defs":{"item":{"anyOf":[{"type":"string"},false]}},
|
||||
"required":["nested"],
|
||||
"enum":[{"custom":[1,null]}],
|
||||
"future":null
|
||||
}))]
|
||||
fn recursive_schema_round_trips(#[case] wire: Value) {
|
||||
let schema = round_trip::<JsonSchema>(wire);
|
||||
match schema {
|
||||
JsonSchema::Boolean(allowed) => assert!(!allowed),
|
||||
JsonSchema::Object(schema) => {
|
||||
assert_eq!(
|
||||
schema.schema_type,
|
||||
Some(JsonSchemaType::Names(vec!["object".into(), "null".into()]))
|
||||
);
|
||||
let properties = schema.properties.as_ref().unwrap();
|
||||
let JsonSchema::Object(nested) = &properties["nested"] else {
|
||||
panic!("expected nested schema")
|
||||
};
|
||||
let Some(JsonSchemaItems::Schema(items)) = &nested.items else {
|
||||
panic!("expected single items schema")
|
||||
};
|
||||
let JsonSchema::Object(items) = items.as_ref() else {
|
||||
panic!("expected items schema")
|
||||
};
|
||||
assert_eq!(items.reference.as_deref(), Some("#/$defs/item"));
|
||||
assert_eq!(properties["free"], JsonSchema::Boolean(true));
|
||||
let JsonSchema::Object(definition) = &schema.defs.as_ref().unwrap()["item"] else {
|
||||
panic!("expected schema definition")
|
||||
};
|
||||
assert_eq!(
|
||||
definition.any_of.as_ref().unwrap()[1],
|
||||
JsonSchema::Boolean(false)
|
||||
);
|
||||
assert_eq!(schema.extra["enum"], json!([{"custom":[1,null]}]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn schema_maps_keep_wire_key_order() {
|
||||
let wire = r#"{"type":"object","properties":{"zeta":{"type":"string"},"alpha":{"type":"integer"}},"$defs":{"y":true,"b":false}}"#;
|
||||
let schema: JsonSchemaObject = serde_json::from_str(wire).unwrap();
|
||||
let names: Vec<&str> = schema
|
||||
.properties
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
assert_eq!(names, ["zeta", "alpha"]);
|
||||
assert_eq!(serde_json::to_string(&schema).unwrap(), wire);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn schema_object_round_trips_supported_keywords() {
|
||||
let schema = round_trip::<JsonSchemaObject>(json!({
|
||||
"type":"object",
|
||||
"properties":{"name":{"type":"string"}},
|
||||
"required":["name"],
|
||||
"additionalProperties":false,
|
||||
"$ref":"#/$defs/value",
|
||||
"strict":true,
|
||||
"extension":{"nested":[1,null]}
|
||||
}));
|
||||
assert_eq!(
|
||||
schema.schema_type,
|
||||
Some(JsonSchemaType::Name("object".into()))
|
||||
);
|
||||
assert_eq!(schema.required.as_deref(), Some(["name".into()].as_slice()));
|
||||
assert_eq!(
|
||||
schema.additional_properties.as_deref(),
|
||||
Some(&JsonSchema::Boolean(false))
|
||||
);
|
||||
assert_eq!(schema.reference.as_deref(), Some("#/$defs/value"));
|
||||
assert_eq!(schema.strict, Some(true));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::scalar(json!(7))]
|
||||
#[case::properties_shape(json!({"properties":[]}))]
|
||||
#[case::nested_schema(json!({"properties":{"name":7}}))]
|
||||
#[case::schema_type(json!({"type":["string",7]}))]
|
||||
#[case::items_shape(json!({"items":7}))]
|
||||
#[case::tuple_member(json!({"items":[{"type":"string"},7]}))]
|
||||
#[case::prefix_items_shape(json!({"prefixItems":{"type":"string"}}))]
|
||||
#[case::composite_shape(json!({"anyOf":["string"]}))]
|
||||
fn schemas_reject_malformed_known_keywords(#[case] wire: Value) {
|
||||
assert!(serde_json::from_value::<JsonSchema>(wire).is_err());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::draft_07_tuple(json!({"type":"array","items":[{"type":"string"},true]}))]
|
||||
#[case::draft_2020_12_tuple(json!({"type":"array","prefixItems":[{"type":"string"},true],"items":false}))]
|
||||
fn tuple_schemas_expose_positional_members(#[case] wire: Value) {
|
||||
let schema = round_trip::<JsonSchemaObject>(wire);
|
||||
let members = match (&schema.items, &schema.prefix_items) {
|
||||
(Some(JsonSchemaItems::Tuple(members)), None) => members,
|
||||
(Some(JsonSchemaItems::Schema(rest)), Some(members)) => {
|
||||
assert_eq!(rest.as_ref(), &JsonSchema::Boolean(false));
|
||||
members
|
||||
}
|
||||
_ => panic!("expected positional members"),
|
||||
};
|
||||
let [JsonSchema::Object(first), JsonSchema::Boolean(true)] = members.as_slice() else {
|
||||
panic!("expected string schema then true");
|
||||
};
|
||||
assert_eq!(
|
||||
first.schema_type,
|
||||
Some(JsonSchemaType::Name("string".into()))
|
||||
);
|
||||
assert!(schema.extra.is_empty());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn empty_schema_omits_null_optionals_and_preserves_arbitrary_keywords() {
|
||||
let schema: JsonSchemaObject = serde_json::from_value(
|
||||
json!({"type":null,"properties":null,"const":{"arbitrary":[1,null]},"future":null}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(schema.schema_type.is_none());
|
||||
assert!(schema.properties.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(schema).unwrap(),
|
||||
json!({"const":{"arbitrary":[1,null]},"future":null})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,3 +10,43 @@ pub mod json_schema;
|
|||
pub mod providers;
|
||||
pub mod recognized;
|
||||
pub mod serde_compat;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::formats::chat_completions::ChatMessage;
|
||||
|
||||
use rstest::rstest;
|
||||
use serde_json::json;
|
||||
|
||||
#[rstest]
|
||||
fn wire_type_preserves_serialization() {
|
||||
let message = ChatMessage {
|
||||
role: "user".to_owned(),
|
||||
content: None,
|
||||
name: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(message).unwrap(),
|
||||
json!({"role": "user"})
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "schema")]
|
||||
#[rstest]
|
||||
fn wire_type_supports_schema_generation() {
|
||||
let schema = schemars::schema_for!(ChatMessage);
|
||||
|
||||
assert!(
|
||||
schema
|
||||
.to_value()
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.is_some_and(|properties| properties.contains_key("role"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
# references
|
||||
|
||||
These links describe the provider-specific wire types in this directory. Shared Messages payload references live in `../formats/messages/AGENTS.md`
|
||||
|
||||
## anthropic.rs
|
||||
|
||||
`AnthropicBeta`, `BetaSet` and `BetaProvider` represent the `anthropic-beta` header values, their
|
||||
wire spelling and per-host support
|
||||
|
||||
- https://platform.claude.com/docs/en/api/beta-headers.md
|
||||
|
||||
## minimax.rs
|
||||
|
||||
MiniMax's Messages reference documents its image, video, and mid-conversation system content extensions. Its cache reference documents `cache_control` on those blocks
|
||||
|
||||
- https://platform.minimax.io/docs/api-reference/text-chat-anthropic
|
||||
- https://platform.minimax.io/docs/api-reference/text-chat-anthropic.md
|
||||
- https://platform.minimax.io/docs/api-reference/anthropic-api-compatible-cache.md
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# rules
|
||||
|
||||
- Shared across Anthropic's Messages, chat, count-tokens and batches adapters: API base, endpoint paths, header names, API version and default headers
|
||||
- `beta.rs` holds `AnthropicBeta`, `BetaSet` and `BetaProvider`, the `anthropic-beta` values, their wire spelling and which hosts accept each one
|
||||
- Choosing which betas a request needs is policy in `llms/src/anthropic/`
|
||||
|
||||
# references
|
||||
|
||||
- https://platform.claude.com/docs/en/api/beta-headers.md
|
||||
|
|
@ -380,16 +380,14 @@ impl fmt::Display for BetaSet {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::*;
|
||||
use indexmap::IndexMap;
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn beta_headers_config() -> IndexMap<String, serde_json::Value> {
|
||||
serde_json::from_str(include_str!(
|
||||
"../../../../../litellm/anthropic_beta_headers_config.json"
|
||||
"../../../../../../litellm/anthropic_beta_headers_config.json"
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
pub const API_BASE: &str = "https://api.anthropic.com";
|
||||
pub const MESSAGES_PATH: &str = "/v1/messages";
|
||||
pub const BATCHES_PATH: &str = "/v1/messages/batches";
|
||||
pub const COUNT_TOKENS_PATH: &str = "/v1/messages/count_tokens";
|
||||
|
||||
pub const API_KEY_HEADER: &str = "x-api-key";
|
||||
pub const BETA_HEADER: &str = "anthropic-beta";
|
||||
pub const VERSION_HEADER: &str = "anthropic-version";
|
||||
pub const DIRECT_BROWSER_ACCESS_HEADER: &str = "anthropic-dangerous-direct-browser-access";
|
||||
pub const API_VERSION: &str = "2023-06-01";
|
||||
|
||||
pub const DEFAULT_HEADERS: &[(&str, &str)] = &[
|
||||
(VERSION_HEADER, API_VERSION),
|
||||
("content-type", "application/json"),
|
||||
];
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
mod beta;
|
||||
mod constants;
|
||||
|
||||
pub use beta::*;
|
||||
pub use constants::*;
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# rules
|
||||
|
||||
- Shared across Bedrock's chat, Messages and audio-transcription adapters: Converse and InvokeModel paths, the invocation-metrics key and the Converse response body
|
||||
- The Converse response is a partial projection. Unread blocks decode as `ConverseContentBlock::Other` so adapters can decline them
|
||||
|
||||
# references
|
||||
|
||||
- https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html
|
||||
- https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_InvokeModel.html
|
||||
- https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_InvokeModelWithResponseStream.html
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue