mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
Merge remote-tracking branch 'origin/main' into litellm_mcp_oauth_happy_path_e2e
Co-Authored-By: bot_apk <apk@cognition.ai> # Conflicts: # tests/e2e/mcp/oauth_chat_client.py
This commit is contained in:
commit
788e158e67
390 changed files with 46916 additions and 4530 deletions
|
|
@ -3015,7 +3015,7 @@ workflows:
|
|||
name: integration-<< matrix.suite >>
|
||||
matrix:
|
||||
parameters:
|
||||
suite: [management, accounting, database, providers, extensions, sdk, browser]
|
||||
suite: [management, accounting, database, providers, extensions, sdk, cost, browser]
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
category="${1:?usage: classify_changes.sh <backend|client|ui|provider-harness|cost-map-only>}"
|
||||
category="${1:?usage: classify_changes.sh <backend|client|ui|provider-harness|cost-map-only|mcp-dependencies>}"
|
||||
|
||||
has_client=false
|
||||
has_backend=false
|
||||
has_ci=false
|
||||
has_provider_harness=false
|
||||
has_cost_map=false
|
||||
has_mcp_dependencies=false
|
||||
outside_cost_map_set=false
|
||||
while IFS= read -r file || [ -n "$file" ]; do
|
||||
[ -n "$file" ] || continue
|
||||
case "$file" in
|
||||
*.md | *.mdx) : ;;
|
||||
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/test_litellm/test_circleci_path_filter.py | tests/test_litellm/test_detect_changes.py)
|
||||
has_mcp_dependencies=true ;;
|
||||
esac
|
||||
case "$file" in
|
||||
tests/e2e/*/*.py) : ;;
|
||||
tests/e2e/*.py | tests/code_coverage_tests/test_provider_cache.py | tests/code_coverage_tests/test_provider_replay_harness.py | tests/test_litellm/test_circleci_path_filter.py | .circleci/* | pyproject.toml | uv.lock)
|
||||
|
|
@ -31,6 +37,9 @@ while IFS= read -r file || [ -n "$file" ]; do
|
|||
done
|
||||
|
||||
case "$category" in
|
||||
mcp-dependencies)
|
||||
[ "$has_mcp_dependencies" = true ] && echo run || echo skip
|
||||
;;
|
||||
cost-map-only)
|
||||
{ [ "$has_cost_map" = true ] && [ "$outside_cost_map_set" = false ]; } && echo run || echo skip
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ fi
|
|||
suite="${1:?integration suite required}"
|
||||
results="test-results/integration-${suite}"
|
||||
mkdir -p "$results"
|
||||
shard_timeout=11m
|
||||
integration_identity="$(.venv/bin/python -c 'import uuid; print(uuid.uuid4().hex)')"
|
||||
upstream_pid=""
|
||||
proxy_pid=""
|
||||
|
|
@ -108,13 +109,26 @@ awk '$3 == "REJECT" && $1 > 0 { rejected=1 } END { exit !rejected }' "$results/e
|
|||
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 [ "$suite" = cost ]; then
|
||||
export INTEGRATION_WORKERS=8
|
||||
fi
|
||||
start_proxy() {
|
||||
local port="$1"
|
||||
local log_name="$2"
|
||||
local -a cost_map_env
|
||||
if [ "$suite" = cost ]; then
|
||||
cost_map_env=(
|
||||
"LITELLM_MODEL_COST_MAP_URL=$INTEGRATION_UPSTREAM_URL/_cost_map"
|
||||
"MODEL_COST_MAP_MIN_MODEL_COUNT=1"
|
||||
"MODEL_COST_MAP_MAX_SHRINK_RATIO=0"
|
||||
)
|
||||
else
|
||||
cost_map_env=("LITELLM_LOCAL_MODEL_COST_MAP=True")
|
||||
fi
|
||||
setsid 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" \
|
||||
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" LITELLM_SALT_KEY="$LITELLM_SALT_KEY" LITELLM_UI_PATH="$LITELLM_UI_PATH" PROXY_BASE_URL="http://127.0.0.1:$port" \
|
||||
LITELLM_MODE=PRODUCTION LITELLM_LOCAL_MODEL_COST_MAP=True STORE_MODEL_IN_DB=True \
|
||||
LITELLM_MODE=PRODUCTION STORE_MODEL_IN_DB=True "${cost_map_env[@]}" \
|
||||
AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 \
|
||||
.venv/bin/python -m integration._support.proxy --config tests/integration/proxy_config.yaml \
|
||||
--host 127.0.0.1 --port "$port" --num_workers 1 --telemetry False \
|
||||
|
|
@ -158,11 +172,12 @@ if [ "$suite" = browser ]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
timeout --signal=TERM --kill-after=20s 11m env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
|
||||
timeout --signal=TERM --kill-after=20s "$shard_timeout" 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" \
|
||||
INTEGRATION_WORKERS="${INTEGRATION_WORKERS:-1}" \
|
||||
INTEGRATION_MASTER_KEY="$INTEGRATION_MASTER_KEY" LITELLM_MODE=PRODUCTION \
|
||||
INTEGRATION_SEED="$INTEGRATION_SEED" \
|
||||
INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \
|
||||
|
|
|
|||
2
.github/actions/detect-changes/action.yml
vendored
2
.github/actions/detect-changes/action.yml
vendored
|
|
@ -14,7 +14,7 @@ description: >-
|
|||
|
||||
inputs:
|
||||
category:
|
||||
description: "Which classification to apply: backend, client or ui"
|
||||
description: "Which classification to apply: backend, client, ui, provider-harness, cost-map-only or mcp-dependencies"
|
||||
required: false
|
||||
default: backend
|
||||
github-token:
|
||||
|
|
|
|||
393
.github/scripts/auto_merge_price_sync.py
vendored
393
.github/scripts/auto_merge_price_sync.py
vendored
|
|
@ -1,393 +0,0 @@
|
|||
"""Auto-merge the provider-info-sync bot's cost-map pull requests.
|
||||
|
||||
Evaluates every gate (author allowlist, cost-map-only diff, required and
|
||||
non-required checks, human reviews) and merges with a merge commit when
|
||||
all of them hold. Every hold reason is logged; the process exits 0 on hold
|
||||
and 1 only on API or programming errors.
|
||||
``DRY_RUN=1`` prints the verdict without calling the merge endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final
|
||||
|
||||
REPO_ROOT: Final = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
CLASSIFY_SCRIPT: Final = os.path.join(REPO_ROOT, ".circleci", "scripts", "classify_changes.sh")
|
||||
API_ROOT: Final = "https://api.github.com"
|
||||
CHANGED_FILE_CEILING: Final = 3000
|
||||
OK_CHECK_CONCLUSIONS: Final = frozenset({"success", "skipped", "neutral"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PullRequest:
|
||||
number: int
|
||||
title: str
|
||||
author_login: str
|
||||
state: str
|
||||
draft: bool
|
||||
mergeable: bool | None
|
||||
mergeable_state: str
|
||||
head_sha: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CheckRun:
|
||||
name: str
|
||||
status: str
|
||||
conclusion: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommitStatus:
|
||||
context: str
|
||||
state: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Review:
|
||||
author_login: str
|
||||
state: str
|
||||
body: str
|
||||
commit_id: str
|
||||
submitted_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Verdict:
|
||||
merge: bool
|
||||
reasons: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EvaluationInputs:
|
||||
pr: PullRequest
|
||||
changed_files: tuple[str, ...]
|
||||
required_contexts: frozenset[str]
|
||||
check_runs: tuple[CheckRun, ...]
|
||||
statuses: tuple[CommitStatus, ...]
|
||||
reviews: tuple[Review, ...]
|
||||
self_check_name: str
|
||||
author_allowlist: frozenset[str]
|
||||
|
||||
|
||||
def _is_bot_login(login: str) -> bool:
|
||||
return login.lower().endswith("[bot]")
|
||||
|
||||
|
||||
def _classify(changed_files: Sequence[str]) -> str:
|
||||
result: Final = subprocess.run(
|
||||
["bash", CLASSIFY_SCRIPT, "cost-map-only"],
|
||||
input="\n".join(changed_files),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return "error"
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def evaluate(
|
||||
inputs: EvaluationInputs,
|
||||
*,
|
||||
classify: Callable[[Sequence[str]], str] = _classify,
|
||||
) -> Verdict:
|
||||
pr: Final = inputs.pr
|
||||
reasons: list[str] = []
|
||||
|
||||
if pr.author_login.lower() not in {login.lower() for login in inputs.author_allowlist}:
|
||||
reasons.append(f"author {pr.author_login!r} not in allowlist")
|
||||
if pr.state != "open":
|
||||
reasons.append("pr not open")
|
||||
if pr.draft:
|
||||
reasons.append("pr is a draft")
|
||||
if pr.mergeable is None:
|
||||
reasons.append("mergeability unknown")
|
||||
elif not pr.mergeable:
|
||||
reasons.append("pr not mergeable")
|
||||
if pr.mergeable_state == "dirty":
|
||||
reasons.append("pr has merge conflicts")
|
||||
|
||||
if len(inputs.changed_files) > CHANGED_FILE_CEILING:
|
||||
reasons.append(f"changed file count {len(inputs.changed_files)} over {CHANGED_FILE_CEILING} ceiling")
|
||||
else:
|
||||
decision: Final = classify(inputs.changed_files)
|
||||
if decision != "run":
|
||||
reasons.append("changed files outside the cost-map-only set")
|
||||
|
||||
green_runs: Final = frozenset(run.name for run in inputs.check_runs if run.conclusion in OK_CHECK_CONCLUSIONS)
|
||||
green_statuses: Final = frozenset(status.context for status in inputs.statuses if status.state == "success")
|
||||
for context in sorted(inputs.required_contexts):
|
||||
if context not in green_runs and context not in green_statuses:
|
||||
reasons.append(f"required check {context!r} not green")
|
||||
for run in inputs.check_runs:
|
||||
if run.name == inputs.self_check_name:
|
||||
continue
|
||||
if run.status != "completed" or run.conclusion not in OK_CHECK_CONCLUSIONS:
|
||||
reasons.append(f"check run {run.name!r} is {run.status}/{run.conclusion}")
|
||||
for status in inputs.statuses:
|
||||
if status.state != "success":
|
||||
reasons.append(f"commit status {status.context!r} is {status.state}")
|
||||
|
||||
latest_state_by_reviewer: Final[dict[str, str]] = {}
|
||||
for review in sorted(inputs.reviews, key=lambda review: review.submitted_at):
|
||||
if _is_bot_login(review.author_login):
|
||||
continue
|
||||
latest_state_by_reviewer[review.author_login] = review.state
|
||||
for reviewer, state in latest_state_by_reviewer.items():
|
||||
if state == "CHANGES_REQUESTED":
|
||||
reasons.append(f"changes requested by {reviewer}")
|
||||
|
||||
return Verdict(merge=not reasons, reasons=tuple(reasons))
|
||||
|
||||
|
||||
def _request(token: str, method: str, path: str, body: Mapping[str, object] | None = None) -> object:
|
||||
url: Final = path if path.startswith("http") else f"{API_ROOT}{path}"
|
||||
data: Final = None if body is None else json.dumps(body).encode("utf-8")
|
||||
request: Final = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _request_allow_fail(
|
||||
token: str, method: str, path: str, body: Mapping[str, object] | None = None
|
||||
) -> tuple[int, object | None]:
|
||||
url: Final = path if path.startswith("http") else f"{API_ROOT}{path}"
|
||||
data: Final = None if body is None else json.dumps(body).encode("utf-8")
|
||||
request: Final = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request) as response:
|
||||
return response.status, json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, None
|
||||
|
||||
|
||||
def _items(payload: object, key: str | None = None) -> tuple[object, ...]:
|
||||
source: Final = payload.get(key) if key and isinstance(payload, Mapping) else payload
|
||||
if not isinstance(source, list):
|
||||
return ()
|
||||
return tuple(source)
|
||||
|
||||
|
||||
def _paginate(token: str, path: str, key: str | None = None) -> list[object]:
|
||||
separator: Final = "&" if "?" in path else "?"
|
||||
results: list[object] = []
|
||||
for page in range(1, 10_000):
|
||||
batch: Final = _items(_request(token, "GET", f"{path}{separator}per_page=100&page={page}"), key)
|
||||
results.extend(batch)
|
||||
if len(batch) < 100:
|
||||
return results
|
||||
return results
|
||||
|
||||
|
||||
def _text(value: object) -> str:
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _int(value: object) -> int:
|
||||
return value if isinstance(value, int) else 0
|
||||
|
||||
|
||||
def _bool(value: object) -> bool:
|
||||
return value is True
|
||||
|
||||
|
||||
def _nested(value: object, *keys: str) -> object:
|
||||
current: object = value
|
||||
for key in keys:
|
||||
if not isinstance(current, Mapping):
|
||||
return None
|
||||
current = current.get(key)
|
||||
return current
|
||||
|
||||
|
||||
def _parse_time(value: object) -> datetime:
|
||||
text: Final = _text(value)
|
||||
if not text:
|
||||
return datetime.min.replace(tzinfo=timezone.utc)
|
||||
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def _load_pr(token: str, repo: str, number: int) -> PullRequest:
|
||||
data: Final = _request(token, "GET", f"/repos/{repo}/pulls/{number}")
|
||||
if not isinstance(data, Mapping):
|
||||
raise RuntimeError(f"unexpected pull payload for #{number}")
|
||||
return PullRequest(
|
||||
number=number,
|
||||
title=_text(data.get("title")),
|
||||
author_login=_text(_nested(data, "user", "login")),
|
||||
state=_text(data.get("state")),
|
||||
draft=_bool(data.get("draft")),
|
||||
mergeable=data.get("mergeable") if isinstance(data.get("mergeable"), bool) else None,
|
||||
mergeable_state=_text(data.get("mergeable_state")),
|
||||
head_sha=_text(_nested(data, "head", "sha")),
|
||||
)
|
||||
|
||||
|
||||
def _list_candidate_prs(token: str, repo: str, base: str, allowlist: frozenset[str]) -> list[int]:
|
||||
candidates: Final = _paginate(token, f"/repos/{repo}/pulls?state=open&base={base}")
|
||||
return [
|
||||
_int(item.get("number"))
|
||||
for item in candidates
|
||||
if isinstance(item, Mapping) and _text(_nested(item, "user", "login")).lower() in allowlist
|
||||
]
|
||||
|
||||
|
||||
def _changed_files(token: str, repo: str, number: int) -> tuple[str, ...]:
|
||||
files: Final = _paginate(token, f"/repos/{repo}/pulls/{number}/files")
|
||||
return tuple(_text(item.get("filename")) for item in files if isinstance(item, Mapping))
|
||||
|
||||
|
||||
def _required_contexts(token: str, repo: str, base: str) -> frozenset[str]:
|
||||
payload: Final = _request(token, "GET", f"/repos/{repo}/rules/branches/{base}")
|
||||
contexts: set[str] = set()
|
||||
for rule in _items(payload):
|
||||
if not isinstance(rule, Mapping) or rule.get("type") != "required_status_checks":
|
||||
continue
|
||||
checks: Final = _nested(rule, "parameters", "required_status_checks")
|
||||
for check in _items(checks):
|
||||
if isinstance(check, Mapping):
|
||||
context: Final = _text(check.get("context"))
|
||||
if context:
|
||||
contexts.add(context)
|
||||
return frozenset(contexts)
|
||||
|
||||
|
||||
def _check_runs(token: str, repo: str, sha: str) -> tuple[CheckRun, ...]:
|
||||
runs: Final = _paginate(token, f"/repos/{repo}/commits/{sha}/check-runs", key="check_runs")
|
||||
return tuple(
|
||||
CheckRun(
|
||||
name=_text(item.get("name")),
|
||||
status=_text(item.get("status")),
|
||||
conclusion=item.get("conclusion") if isinstance(item.get("conclusion"), str) else None,
|
||||
)
|
||||
for item in runs
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
|
||||
|
||||
def _statuses(token: str, repo: str, sha: str) -> tuple[CommitStatus, ...]:
|
||||
payload: Final = _request(token, "GET", f"/repos/{repo}/commits/{sha}/status")
|
||||
return tuple(
|
||||
CommitStatus(context=_text(item.get("context")), state=_text(item.get("state")))
|
||||
for item in _items(payload, "statuses")
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
|
||||
|
||||
def _reviews(token: str, repo: str, number: int) -> tuple[Review, ...]:
|
||||
reviews: Final = _paginate(token, f"/repos/{repo}/pulls/{number}/reviews")
|
||||
return tuple(
|
||||
Review(
|
||||
author_login=_text(_nested(item, "user", "login")),
|
||||
state=_text(item.get("state")),
|
||||
body=_text(item.get("body")),
|
||||
commit_id=_text(item.get("commit_id")),
|
||||
submitted_at=_parse_time(item.get("submitted_at")),
|
||||
)
|
||||
for item in reviews
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
|
||||
|
||||
def _mergeable_or_refetch(token: str, repo: str, pr: PullRequest) -> PullRequest:
|
||||
if pr.mergeable is not None:
|
||||
return pr
|
||||
time.sleep(5)
|
||||
return _load_pr(token, repo, pr.number)
|
||||
|
||||
|
||||
def _gather_inputs(
|
||||
token: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
base: str,
|
||||
self_check_name: str,
|
||||
allowlist: frozenset[str],
|
||||
) -> EvaluationInputs:
|
||||
pr: Final = _mergeable_or_refetch(token, repo, _load_pr(token, repo, number))
|
||||
return EvaluationInputs(
|
||||
pr=pr,
|
||||
changed_files=_changed_files(token, repo, number),
|
||||
required_contexts=_required_contexts(token, repo, base),
|
||||
check_runs=_check_runs(token, repo, pr.head_sha),
|
||||
statuses=_statuses(token, repo, pr.head_sha),
|
||||
reviews=_reviews(token, repo, number),
|
||||
self_check_name=self_check_name,
|
||||
author_allowlist=allowlist,
|
||||
)
|
||||
|
||||
|
||||
def merge_request_body(pr: PullRequest) -> dict[str, str]:
|
||||
return {"merge_method": "merge", "commit_title": f"{pr.title} (#{pr.number})", "sha": pr.head_sha}
|
||||
|
||||
|
||||
def _merge(token: str, repo: str, pr: PullRequest) -> None:
|
||||
status, _ = _request_allow_fail(token, "PUT", f"/repos/{repo}/pulls/{pr.number}/merge", merge_request_body(pr))
|
||||
if status in (200, 405, 409):
|
||||
print(f"auto-merge-price-sync: PR #{pr.number} merge call returned {status}")
|
||||
return
|
||||
raise RuntimeError(f"merge call for PR #{pr.number} returned {status}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
token: Final = os.environ.get("GH_TOKEN", "")
|
||||
repo: Final = os.environ.get("REPO", "")
|
||||
base: Final = os.environ.get("BASE_BRANCH", "main")
|
||||
dry_run: Final = os.environ.get("DRY_RUN", "") != ""
|
||||
self_check_name: Final = os.environ.get("SELF_CHECK_NAME", "auto-merge-price-sync")
|
||||
allowlist: Final = frozenset(login.lower() for login in os.environ.get("PR_AUTHOR_ALLOWLIST", "").split() if login)
|
||||
if not token:
|
||||
print("auto-merge-price-sync: app credentials not configured")
|
||||
return 0
|
||||
if not repo:
|
||||
print("auto-merge-price-sync: REPO not set", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
pr_number_env: Final = os.environ.get("PR_NUMBER", "")
|
||||
candidates: Final = [int(pr_number_env)] if pr_number_env else _list_candidate_prs(token, repo, base, allowlist)
|
||||
for number in candidates:
|
||||
inputs: Final = _gather_inputs(token, repo, number, base, self_check_name, allowlist)
|
||||
verdict: Final = evaluate(inputs)
|
||||
for reason in verdict.reasons:
|
||||
print(f"auto-merge-price-sync: PR #{number} hold: {reason}")
|
||||
if not verdict.merge:
|
||||
continue
|
||||
print(f"auto-merge-price-sync: PR #{number} all gates green")
|
||||
if dry_run:
|
||||
print(f"auto-merge-price-sync: DRY_RUN merge suppressed for PR #{number}")
|
||||
continue
|
||||
_merge(token, repo, inputs.pr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
29
.github/workflows/_test-unit-base.yml
vendored
29
.github/workflows/_test-unit-base.yml
vendored
|
|
@ -37,6 +37,18 @@ on:
|
|||
required: false
|
||||
type: number
|
||||
default: 60
|
||||
test-timeout-seconds:
|
||||
description: >-
|
||||
Per-test ceiling enforced by pytest-timeout, covering fixture setup and
|
||||
teardown as well as the test body. A test that hangs fails with a
|
||||
traceback of where it was stuck instead of idling the shard until
|
||||
`timeout-minutes` cancels it. Timed-out tests are excluded from reruns
|
||||
because pytest-timeout arms its timer once per test and
|
||||
pytest-rerunfailures reruns inside that same window, so a rerun of a
|
||||
timed-out test would run with no timer at all.
|
||||
required: false
|
||||
type: number
|
||||
default: 120
|
||||
max-failures:
|
||||
description: "Stop after this many failures"
|
||||
required: false
|
||||
|
|
@ -51,6 +63,11 @@ on:
|
|||
description: "Unique name for the coverage artifact (must be unique per run)"
|
||||
required: true
|
||||
type: string
|
||||
legacy-mcp-peer:
|
||||
description: "Install the isolated SDK1 peer for MCP compatibility tests"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -113,10 +130,17 @@ jobs:
|
|||
- name: Install dependencies
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
LEGACY_MCP_PEER: ${{ inputs.legacy-mcp-peer }}
|
||||
run: |
|
||||
diff -u model_prices_and_context_window.json litellm/model_prices_and_context_window_backup.json
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml
|
||||
uv run --no-sync python -c 'import os, sys; print(sys.version); assert f"{sys.version_info.major}.{sys.version_info.minor}" == os.environ["UV_PYTHON"]'
|
||||
if [ "$LEGACY_MCP_PEER" = "true" ]; then
|
||||
uv venv --python "${UV_PYTHON}" .venv-mcp-peer
|
||||
uv pip install --python .venv-mcp-peer 'mcp==1.28.1' 'langchain-mcp-adapters==0.2.1'
|
||||
echo "MCP_TEST_PEER_PYTHON=$GITHUB_WORKSPACE/.venv-mcp-peer/bin/python" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Cache Prisma binaries
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
|
|
@ -137,6 +161,7 @@ jobs:
|
|||
MAX_FAILURES: ${{ inputs.max-failures }}
|
||||
WORKERS: ${{ inputs.workers }}
|
||||
RERUNS: ${{ inputs.reruns }}
|
||||
TEST_TIMEOUT_SECONDS: ${{ inputs.test-timeout-seconds }}
|
||||
DIST: ${{ inputs.dist }}
|
||||
COVERAGE_CORE: sysmon
|
||||
run: |
|
||||
|
|
@ -146,6 +171,8 @@ jobs:
|
|||
--maxfail="${MAX_FAILURES}" \
|
||||
--reruns "${RERUNS}" \
|
||||
--reruns-delay 1 \
|
||||
--timeout="${TEST_TIMEOUT_SECONDS}" \
|
||||
--rerun-except "from pytest-timeout" \
|
||||
--durations=20 \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise \
|
||||
--cov-report=xml:coverage.xml \
|
||||
|
|
@ -157,6 +184,8 @@ jobs:
|
|||
-n "${WORKERS}" \
|
||||
--reruns "${RERUNS}" \
|
||||
--reruns-delay 1 \
|
||||
--timeout="${TEST_TIMEOUT_SECONDS}" \
|
||||
--rerun-except "from pytest-timeout" \
|
||||
--dist="${DIST}" \
|
||||
--durations=20 \
|
||||
--cov=./litellm --cov=./enterprise/litellm_enterprise \
|
||||
|
|
|
|||
61
.github/workflows/auto-merge-price-sync.yml
vendored
61
.github/workflows/auto-merge-price-sync.yml
vendored
|
|
@ -1,61 +0,0 @@
|
|||
name: auto-merge-price-sync
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
check_suite:
|
||||
types: [completed]
|
||||
status: {}
|
||||
schedule:
|
||||
- cron: "*/30 * * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr-number:
|
||||
description: "Evaluate only this PR number (empty = scan all open sync-bot PRs)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
checks: read
|
||||
statuses: read
|
||||
|
||||
concurrency:
|
||||
group: auto-merge-price-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
auto-merge-price-sync:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
PROVIDER_INFO_SYNC_APP_ID: ${{ secrets.PROVIDER_INFO_SYNC_APP_ID }}
|
||||
PROVIDER_INFO_SYNC_APP_PRIVATE_KEY: ${{ secrets.PROVIDER_INFO_SYNC_APP_PRIVATE_KEY }}
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Mint app token
|
||||
id: app-token
|
||||
if: ${{ env.PROVIDER_INFO_SYNC_APP_ID != '' && env.PROVIDER_INFO_SYNC_APP_PRIVATE_KEY != '' }}
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.PROVIDER_INFO_SYNC_APP_ID }}
|
||||
private-key: ${{ secrets.PROVIDER_INFO_SYNC_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Auto-merge eligible sync PRs
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ (github.event.issue.pull_request && github.event.issue.number) || github.event.inputs.pr-number || '' }}
|
||||
BASE_BRANCH: main
|
||||
PR_AUTHOR_ALLOWLIST: "berriai-litellm-provider-info-sync[bot]"
|
||||
SELF_CHECK_NAME: auto-merge-price-sync
|
||||
run: python3 .github/scripts/auto_merge_price_sync.py
|
||||
4
.github/workflows/codspeed.yml
vendored
4
.github/workflows/codspeed.yml
vendored
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
uv run --frozen --no-default-groups
|
||||
--with pytest==8.3.5
|
||||
--with pytest-codspeed==4.3.0
|
||||
--with "mcp>=1.26.0,<2.0"
|
||||
--with "mcp>=2.2.0,<3.0"
|
||||
--with "a2a-sdk>=1.1.0,<2.0"
|
||||
pytest
|
||||
-p pytest_codspeed.plugin
|
||||
|
|
@ -86,7 +86,7 @@ jobs:
|
|||
uv run --frozen --no-default-groups
|
||||
--with pytest==8.3.5
|
||||
--with pytest-codspeed==4.3.0
|
||||
--with "mcp>=1.26.0,<2.0"
|
||||
--with "mcp>=2.2.0,<3.0"
|
||||
--with "a2a-sdk>=1.1.0,<2.0"
|
||||
pytest
|
||||
-p pytest_codspeed.plugin
|
||||
|
|
|
|||
98
.github/workflows/test-mcp-dependency-resolution.yml
vendored
Normal file
98
.github/workflows/test-mcp-dependency-resolution.yml
vendored
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
name: LiteLLM MCP Dependency Resolution
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect relevant changes
|
||||
id: changes
|
||||
uses: ./.github/actions/detect-changes
|
||||
with:
|
||||
category: mcp-dependencies
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Set up uv
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Cache the Rust build
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Verify lockfile
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: |
|
||||
uv lock --check
|
||||
|
||||
- name: Check locked runtime installations
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: |
|
||||
for extra in core mcp proxy; do
|
||||
args=()
|
||||
if [ "$extra" != core ]; then args=(--extra "$extra"); fi
|
||||
UV_PROJECT_ENVIRONMENT=".venv-$extra" .github/scripts/uv_sync_with_retries.sh --frozen --no-dev --no-editable --python ${{ matrix.python-version }} "${args[@]}"
|
||||
uv pip check --python ".venv-$extra"
|
||||
if [ "$extra" = core ]; then
|
||||
checker=("$GITHUB_WORKSPACE/tests/base_sdk_tests/check_base_sdk_install.py")
|
||||
else
|
||||
checker=("$GITHUB_WORKSPACE/scripts/check_mcp_sdk_install.py" --extra "$extra")
|
||||
fi
|
||||
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-$extra/bin/python" "${checker[@]}")
|
||||
done
|
||||
|
||||
- name: Build the public wheel
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: uv build --all-packages --wheel --out-dir dist/mcp-check
|
||||
|
||||
- name: Check lowest direct runtime installations
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: |
|
||||
wheel=$(realpath dist/mcp-check/litellm-[0-9]*.whl)
|
||||
for extra in core mcp proxy; do
|
||||
args=()
|
||||
if [ "$extra" != core ]; then args=(--extra "$extra"); fi
|
||||
uv pip compile pyproject.toml --no-sources --find-links dist/mcp-check "${args[@]}" --python-version ${{ matrix.python-version }} --resolution lowest-direct -o "lowest-$extra.txt"
|
||||
uv venv --python ${{ matrix.python-version }} ".venv-lowest-$extra"
|
||||
uv pip sync --find-links dist/mcp-check --python ".venv-lowest-$extra" "lowest-$extra.txt"
|
||||
uv pip install --python ".venv-lowest-$extra" --no-deps "$wheel"
|
||||
uv pip check --python ".venv-lowest-$extra"
|
||||
if [ "$extra" = core ]; then
|
||||
checker=("$GITHUB_WORKSPACE/tests/base_sdk_tests/check_base_sdk_install.py")
|
||||
else
|
||||
checker=("$GITHUB_WORKSPACE/scripts/check_mcp_sdk_install.py" --extra "$extra")
|
||||
fi
|
||||
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/.venv-lowest-$extra/bin/python" "${checker[@]}")
|
||||
done
|
||||
63
.github/workflows/test-mcp.yml
vendored
63
.github/workflows/test-mcp.yml
vendored
|
|
@ -1,63 +0,0 @@
|
|||
name: LiteLLM MCP Tests (folder - tests/mcp_tests)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect relevant changes
|
||||
id: changes
|
||||
uses: ./.github/actions/detect-changes
|
||||
|
||||
- name: Thank You Message
|
||||
run: |
|
||||
echo "### 🙏 Thank you for contributing to LiteLLM!" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Your PR is being tested now. We appreciate your help in making LiteLLM better!" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
uses: ./.github/actions/setup-uv-with-retries
|
||||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Cache the Rust build
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
uses: ./.github/actions/cache-cargo-build
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: |
|
||||
uv lock --check
|
||||
.github/scripts/uv_sync_with_retries.sh --frozen --group proxy-dev --extra proxy --extra semantic-router
|
||||
|
||||
- name: Run MCP tests
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: |
|
||||
uv run --no-sync pytest tests/mcp_tests -x -vv -n 4 --cov=./litellm --cov=./enterprise/litellm_enterprise --cov-report=xml --durations=5
|
||||
9
.github/workflows/test-unit.yml
vendored
9
.github/workflows/test-unit.yml
vendored
|
|
@ -49,6 +49,14 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- shard: mcp-integration
|
||||
artifact-name: mcp-integration
|
||||
test-path: "tests/mcp_tests"
|
||||
workers: 2
|
||||
reruns: 0
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: core-utils
|
||||
artifact-name: core-utils
|
||||
test-path: "tests/test_litellm/litellm_core_utils"
|
||||
|
|
@ -254,3 +262,4 @@ jobs:
|
|||
timeout-minutes: ${{ matrix.timeout-minutes }}
|
||||
job-timeout-minutes: ${{ matrix.job-timeout-minutes }}
|
||||
artifact-name: ${{ matrix.artifact-name }}
|
||||
legacy-mcp-peer: ${{ matrix.shard == 'mcp-integration' }}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
## This provides an LLM Guard Integration for content moderation on the proxy
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from typing import Final, Optional
|
||||
|
||||
import aiohttp
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -137,15 +137,20 @@ class _ENTERPRISE_LLMGuard(CustomLogger):
|
|||
return
|
||||
|
||||
self.print_verbose("Makes LLM Guard Check")
|
||||
if call_type not in [
|
||||
accepted_call_types: Final = (
|
||||
"completion",
|
||||
"acompletion",
|
||||
"text_completion",
|
||||
"atext_completion",
|
||||
"embeddings",
|
||||
"embedding",
|
||||
"aembedding",
|
||||
"image_generation",
|
||||
"moderation",
|
||||
"audio_transcription",
|
||||
]:
|
||||
"aimage_generation",
|
||||
)
|
||||
if call_type not in accepted_call_types:
|
||||
self.print_verbose(
|
||||
f"Call Type - {call_type}, not in accepted list - ['completion','embeddings','image_generation','moderation','audio_transcription']"
|
||||
f"Call Type - {call_type}, not in accepted list - {accepted_call_types}"
|
||||
)
|
||||
return data
|
||||
|
||||
|
|
@ -163,16 +168,14 @@ class _ENTERPRISE_LLMGuard(CustomLogger):
|
|||
*(self._moderate_message(message) for message in messages)
|
||||
)
|
||||
)
|
||||
return data
|
||||
|
||||
input_ = data.get("input")
|
||||
if input_ is not None:
|
||||
data["input"] = await self._moderate_input(input_)
|
||||
return data
|
||||
data["input"] = await self._moderate_text_or_list(input_)
|
||||
|
||||
prompt = data.get("prompt")
|
||||
if isinstance(prompt, str):
|
||||
data["prompt"] = await self.moderation_check(text=prompt)
|
||||
if prompt is not None:
|
||||
data["prompt"] = await self._moderate_text_or_list(prompt)
|
||||
return data
|
||||
|
||||
async def _moderate_message(self, message: dict) -> dict:
|
||||
|
|
@ -195,17 +198,17 @@ class _ENTERPRISE_LLMGuard(CustomLogger):
|
|||
return {**part, "text": await self.moderation_check(text=part["text"])}
|
||||
return part
|
||||
|
||||
async def _moderate_input(self, input_: object) -> object:
|
||||
if isinstance(input_, str):
|
||||
return await self.moderation_check(text=input_)
|
||||
if isinstance(input_, list):
|
||||
async def _moderate_text_or_list(self, value: object) -> object:
|
||||
if isinstance(value, str):
|
||||
return await self.moderation_check(text=value)
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
await self.moderation_check(text=item)
|
||||
if isinstance(item, str)
|
||||
else item
|
||||
for item in input_
|
||||
for item in value
|
||||
]
|
||||
return input_
|
||||
return value
|
||||
|
||||
async def async_post_call_streaming_hook(
|
||||
self, user_api_key_dict: UserAPIKeyAuth, response: str
|
||||
|
|
|
|||
8
litellm-rust/Cargo.lock
generated
8
litellm-rust/Cargo.lock
generated
|
|
@ -1960,6 +1960,7 @@ dependencies = [
|
|||
"aws-smithy-runtime-api",
|
||||
"aws-types",
|
||||
"litellm-auth",
|
||||
"litellm-http",
|
||||
"moka",
|
||||
"reqwest 0.12.28",
|
||||
"serde_json",
|
||||
|
|
@ -1976,6 +1977,7 @@ dependencies = [
|
|||
"azure_identity",
|
||||
"litellm-auth",
|
||||
"moka",
|
||||
"rstest",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"strum",
|
||||
|
|
@ -2137,11 +2139,15 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"http 1.4.2",
|
||||
"hyper-util",
|
||||
"litellm-core-utils",
|
||||
"reqwest 0.12.28",
|
||||
"rstest",
|
||||
"rustls 0.23.42",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"veil",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
|
|
@ -2170,6 +2176,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_with",
|
||||
"strum",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"tokio",
|
||||
|
|
@ -2187,6 +2194,7 @@ dependencies = [
|
|||
"litellm-auth-gcp",
|
||||
"litellm-callbacks-legacy",
|
||||
"litellm-core",
|
||||
"litellm-core-utils",
|
||||
"litellm-host-python",
|
||||
"litellm-http",
|
||||
"litellm-llms",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ repository.workspace = true
|
|||
|
||||
[dependencies]
|
||||
litellm-auth.workspace = true
|
||||
litellm-http.workspace = true
|
||||
|
||||
moka = { workspace = true, features = ["sync"] }
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -20,8 +20,7 @@ use super::constants::{
|
|||
AWS_ACCESS_KEY_ID, AWS_EXTERNAL_ID, AWS_PROFILE_NAME, AWS_REGION, AWS_REGION_NAME,
|
||||
AWS_ROLE_ARN, AWS_ROLE_NAME, AWS_SECRET_ACCESS_KEY, AWS_SESSION_NAME, AWS_SESSION_TOKEN,
|
||||
AWS_SIGNED_HEADER_NAMES, AWS_STS_ENDPOINT, AWS_WEB_IDENTITY_TOKEN, AWS_WEB_IDENTITY_TOKEN_FILE,
|
||||
BEDROCK_SERVICE, DEFAULT_BEDROCK_REGION, DEFAULT_SESSION_NAME_PREFIX,
|
||||
SIGV4_COMPUTED_HEADER_NAMES,
|
||||
DEFAULT_BEDROCK_REGION, DEFAULT_SESSION_NAME_PREFIX, SIGV4_COMPUTED_HEADER_NAMES,
|
||||
};
|
||||
|
||||
const STATIC_CREDENTIALS_TTL: Duration = Duration::from_secs(3600 - 60);
|
||||
|
|
@ -451,11 +450,12 @@ pub fn is_sigv4_computed_header(name: &str) -> bool {
|
|||
SIGV4_COMPUTED_HEADER_NAMES.contains(&name.to_ascii_lowercase().as_str())
|
||||
}
|
||||
|
||||
pub fn sign_bedrock_post(
|
||||
pub fn sign_post(
|
||||
url: &str,
|
||||
body: &[u8],
|
||||
headers: &BTreeMap<String, String>,
|
||||
region: &str,
|
||||
service: &str,
|
||||
credentials: &Credentials,
|
||||
signing_time: SystemTime,
|
||||
) -> Result<BTreeMap<String, String>, Error> {
|
||||
|
|
@ -463,7 +463,7 @@ pub fn sign_bedrock_post(
|
|||
let params = v4::SigningParams::builder()
|
||||
.identity(&identity)
|
||||
.region(region)
|
||||
.name(BEDROCK_SERVICE)
|
||||
.name(service)
|
||||
.time(signing_time)
|
||||
.settings(SigningSettings::default())
|
||||
.build()
|
||||
|
|
@ -534,22 +534,28 @@ fn is_bedrock_region(value: &str) -> bool {
|
|||
.all(|char| char.is_ascii_alphanumeric() || char == '-')
|
||||
}
|
||||
|
||||
/// The region a caller configured: `aws_region_name`, then the model's own
|
||||
/// region, then the environment. Each service decides what a missing one means.
|
||||
pub fn resolve_aws_region(
|
||||
model_region: Option<&str>,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
optional_params
|
||||
.get("aws_region_name")
|
||||
.and_then(Value::as_str)
|
||||
.or(model_region)
|
||||
.map(str::to_string)
|
||||
.or_else(|| env_lookup(AWS_REGION_NAME))
|
||||
.or_else(|| env_lookup(AWS_REGION))
|
||||
}
|
||||
|
||||
pub fn resolve_bedrock_region(
|
||||
model_region: Option<&str>,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> String {
|
||||
if let Some(region) = optional_params
|
||||
.get("aws_region_name")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
return region.to_string();
|
||||
}
|
||||
if let Some(region) = model_region {
|
||||
return region.to_string();
|
||||
}
|
||||
env_lookup(AWS_REGION_NAME)
|
||||
.or_else(|| env_lookup(AWS_REGION))
|
||||
resolve_aws_region(model_region, optional_params, env_lookup)
|
||||
.unwrap_or_else(|| DEFAULT_BEDROCK_REGION.to_string())
|
||||
}
|
||||
|
||||
|
|
@ -609,11 +615,36 @@ pub fn host_supplied_credentials(optional_params: &Map<String, Value>) -> Option
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::constants::BEDROCK_SERVICE;
|
||||
|
||||
fn no_env(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_region_comes_from_the_call_then_the_model_then_the_environment() {
|
||||
let params = Map::from_iter([("aws_region_name".to_string(), Value::from("eu-west-1"))]);
|
||||
let region_name = |key: &str| (key == AWS_REGION_NAME).then(|| "ap-south-1".to_string());
|
||||
let region = |key: &str| (key == AWS_REGION).then(|| "sa-east-1".to_string());
|
||||
|
||||
let resolved = [
|
||||
resolve_aws_region(Some("us-east-2"), ¶ms, ®ion_name),
|
||||
resolve_aws_region(Some("us-east-2"), &Map::new(), ®ion_name),
|
||||
resolve_aws_region(None, &Map::new(), ®ion_name),
|
||||
resolve_aws_region(None, &Map::new(), ®ion),
|
||||
resolve_aws_region(None, &Map::new(), &no_env),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolved.map(|region| region.unwrap_or_else(|| "none".into())),
|
||||
["eu-west-1", "us-east-2", "ap-south-1", "sa-east-1", "none"]
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_bedrock_region(None, &Map::new(), &no_env),
|
||||
DEFAULT_BEDROCK_REGION
|
||||
);
|
||||
}
|
||||
|
||||
fn parity_inputs() -> (String, Vec<u8>, BTreeMap<String, String>) {
|
||||
(
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com/model/amazon.titan-text-express-v1/invoke"
|
||||
|
|
@ -811,11 +842,12 @@ mod tests {
|
|||
None,
|
||||
"test",
|
||||
);
|
||||
let signed = sign_bedrock_post(
|
||||
let signed = sign_post(
|
||||
&url,
|
||||
&body,
|
||||
&signable,
|
||||
"us-east-1",
|
||||
BEDROCK_SERVICE,
|
||||
&credentials,
|
||||
SystemTime::UNIX_EPOCH,
|
||||
)
|
||||
|
|
@ -843,11 +875,12 @@ mod tests {
|
|||
None,
|
||||
"test",
|
||||
);
|
||||
let signed = sign_bedrock_post(
|
||||
let signed = sign_post(
|
||||
&url,
|
||||
&body,
|
||||
&headers,
|
||||
"us-east-1",
|
||||
BEDROCK_SERVICE,
|
||||
&credentials,
|
||||
UNIX_EPOCH + std::time::Duration::from_secs(1_704_164_645),
|
||||
)
|
||||
|
|
@ -878,11 +911,12 @@ mod tests {
|
|||
None,
|
||||
"test",
|
||||
);
|
||||
let signed = sign_bedrock_post(
|
||||
let signed = sign_post(
|
||||
&url,
|
||||
&body,
|
||||
&headers,
|
||||
"us-east-1",
|
||||
BEDROCK_SERVICE,
|
||||
&credentials,
|
||||
UNIX_EPOCH + std::time::Duration::from_secs(1_704_164_645),
|
||||
)
|
||||
|
|
@ -915,11 +949,12 @@ mod tests {
|
|||
let url = format!(
|
||||
"https://bedrock-runtime.{region}.amazonaws.com/model/us.anthropic.claude-opus-4-8/invoke"
|
||||
);
|
||||
let signed_headers = sign_bedrock_post(
|
||||
let signed_headers = sign_post(
|
||||
&url,
|
||||
&body,
|
||||
&headers,
|
||||
region,
|
||||
BEDROCK_SERVICE,
|
||||
&credentials,
|
||||
SystemTime::now(),
|
||||
)?;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
mod aws;
|
||||
pub mod constants;
|
||||
mod error;
|
||||
mod signer;
|
||||
|
||||
pub use aws::*;
|
||||
pub use aws_credential_types::Credentials;
|
||||
pub use error::Error;
|
||||
pub use signer::SigV4Signer;
|
||||
|
|
|
|||
178
litellm-rust/crates/auth-aws/src/signer.rs
Normal file
178
litellm-rust/crates/auth-aws/src/signer.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
use std::{collections::BTreeMap, time::SystemTime};
|
||||
|
||||
use aws_credential_types::Credentials;
|
||||
use litellm_http::outbound::{RequestSigner, UnsignedRequest};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::{
|
||||
Error, aws_auth_config, aws_signature_headers, host_supplied_credentials,
|
||||
is_sigv4_computed_header, resolve_credentials, sign_post,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SigV4Signer {
|
||||
region: String,
|
||||
service: &'static str,
|
||||
credentials: Credentials,
|
||||
clock: fn() -> SystemTime,
|
||||
}
|
||||
|
||||
impl SigV4Signer {
|
||||
pub fn new(region: String, service: &'static str, credentials: Credentials) -> Self {
|
||||
Self {
|
||||
region,
|
||||
service,
|
||||
credentials,
|
||||
clock: SystemTime::now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_clock(self, clock: fn() -> SystemTime) -> Self {
|
||||
Self { clock, ..self }
|
||||
}
|
||||
|
||||
pub async fn resolve(
|
||||
region: String,
|
||||
service: &'static str,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &(dyn Fn(&str) -> Option<String> + Sync),
|
||||
) -> Result<Self, Error> {
|
||||
let credentials = match host_supplied_credentials(optional_params) {
|
||||
Some(credentials) => credentials,
|
||||
None => {
|
||||
resolve_credentials(aws_auth_config(optional_params, env_lookup), env_lookup)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(Self::new(region, service, credentials))
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestSigner for SigV4Signer {
|
||||
fn sign(
|
||||
&self,
|
||||
request: UnsignedRequest<'_>,
|
||||
) -> Result<Vec<(String, String)>, litellm_http::Error> {
|
||||
if let Some((name, _)) = request
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(name, _)| is_sigv4_computed_header(name))
|
||||
{
|
||||
return Err(litellm_http::Error::ComputedHeader(name.clone()));
|
||||
}
|
||||
let headers: BTreeMap<String, String> = request.headers.iter().cloned().collect();
|
||||
sign_post(
|
||||
request.url,
|
||||
request.body,
|
||||
&aws_signature_headers(&headers),
|
||||
&self.region,
|
||||
self.service,
|
||||
&self.credentials,
|
||||
(self.clock)(),
|
||||
)
|
||||
.map(|signature| signature.into_iter().collect())
|
||||
.map_err(|error| litellm_http::Error::Signature(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
use litellm_http::outbound::OutboundRequest;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn fixed_clock() -> SystemTime {
|
||||
UNIX_EPOCH + Duration::from_secs(1_700_000_000)
|
||||
}
|
||||
|
||||
fn signer(service: &'static str) -> SigV4Signer {
|
||||
SigV4Signer::new(
|
||||
"us-east-1".into(),
|
||||
service,
|
||||
Credentials::new("AKIDEXAMPLE", "secret", None, None, "test"),
|
||||
)
|
||||
.with_clock(fixed_clock)
|
||||
}
|
||||
|
||||
fn authorization(body: &Value, service: &'static str) -> String {
|
||||
OutboundRequest::signed_json(
|
||||
"https://textract.us-east-1.amazonaws.com/".into(),
|
||||
vec![("X-Amz-Target".into(), "Textract.DetectDocumentText".into())],
|
||||
body,
|
||||
None,
|
||||
&signer(service),
|
||||
)
|
||||
.unwrap()
|
||||
.header("Authorization")
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_signature_verifies_against_the_bytes_that_are_sent() {
|
||||
let sent = OutboundRequest::signed_json(
|
||||
"https://textract.us-east-1.amazonaws.com/".into(),
|
||||
vec![("X-Amz-Target".into(), "Textract.DetectDocumentText".into())],
|
||||
&json!({"Document": {"Bytes": "aGk="}}),
|
||||
None,
|
||||
&signer("textract"),
|
||||
)
|
||||
.unwrap();
|
||||
let unsigned: BTreeMap<String, String> = sent
|
||||
.headers()
|
||||
.iter()
|
||||
.filter(|(name, _)| !is_sigv4_computed_header(name))
|
||||
.cloned()
|
||||
.collect();
|
||||
let recomputed = sign_post(
|
||||
sent.url(),
|
||||
sent.body(),
|
||||
&aws_signature_headers(&unsigned),
|
||||
"us-east-1",
|
||||
"textract",
|
||||
&Credentials::new("AKIDEXAMPLE", "secret", None, None, "test"),
|
||||
fixed_clock(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sent.header("Authorization"),
|
||||
Some(recomputed["Authorization"].as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_signature_depends_on_the_body_and_the_service() {
|
||||
let original = authorization(&json!({"text": "card 4111"}), "textract");
|
||||
|
||||
assert_ne!(
|
||||
original,
|
||||
authorization(&json!({"text": "card [REDACTED]"}), "textract")
|
||||
);
|
||||
assert_ne!(
|
||||
original,
|
||||
authorization(&json!({"text": "card 4111"}), "bedrock")
|
||||
);
|
||||
assert!(original.contains("/us-east-1/textract/aws4_request"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forwarded_computed_header_is_refused_instead_of_sent_twice() {
|
||||
let error = OutboundRequest::signed_json(
|
||||
"https://textract.us-east-1.amazonaws.com/".into(),
|
||||
vec![("authorization".into(), "Bearer caller".into())],
|
||||
&json!({}),
|
||||
None,
|
||||
&signer("textract"),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
litellm_http::Error::ComputedHeader("authorization".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,4 +18,5 @@ azure_core = "1.0.0"
|
|||
azure_identity = { version = "1.0.0", features = ["tokio"] }
|
||||
|
||||
[dev-dependencies]
|
||||
rstest.workspace = true
|
||||
tokio.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
use serde_json::{Map, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use strum::EnumString;
|
||||
|
||||
use litellm_auth::Error;
|
||||
use litellm_auth::{
|
||||
CredentialResolverHandle, InputSource, SecretValue, Sourced, TokenProviderHandle,
|
||||
CredentialResolverHandle, Error, InputSource, SecretValue, Sourced, TokenProviderHandle,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
use strum::EnumString;
|
||||
|
||||
pub const DEFAULT_AZURE_SCOPE: &str = "https://cognitiveservices.azure.com/.default";
|
||||
|
||||
|
|
@ -52,6 +51,16 @@ pub struct AzureAuthInputs {
|
|||
}
|
||||
|
||||
impl AzureAuthInputs {
|
||||
pub fn or_configured_token_refresh(self, enabled: bool) -> Self {
|
||||
if *self.enable_azure_ad_token_refresh.value() || !enabled {
|
||||
return self;
|
||||
}
|
||||
Self {
|
||||
enable_azure_ad_token_refresh: Sourced::new(true, InputSource::Deployment),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn from_optional_params(params: &Map<String, Value>) -> Result<Self, Error> {
|
||||
Self::from_sourced_optional_params(params, &BTreeMap::new())
|
||||
|
|
@ -115,12 +124,12 @@ fn source_for(sources: &BTreeMap<String, InputSource>, name: &str) -> InputSourc
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{AzureAuthInputs, AzureCredentialType, ConfigValue};
|
||||
use litellm_auth::{InputSource, Sourced};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{AzureAuthInputs, AzureCredentialType, ConfigValue};
|
||||
|
||||
#[test]
|
||||
fn selector_parsing_is_exact() {
|
||||
|
|
@ -189,4 +198,29 @@ mod tests {
|
|||
assert!(!debug.contains("token-value"));
|
||||
assert!(!debug.contains("secret-value"));
|
||||
}
|
||||
|
||||
#[rstest::rstest]
|
||||
#[case::global_turns_refresh_on(json!({}), true, true, InputSource::Deployment)]
|
||||
#[case::global_overrides_a_call_false_like_python(json!({"enable_azure_ad_token_refresh": false}), true, true, InputSource::Deployment)]
|
||||
#[case::call_true_survives_a_global_false(json!({"enable_azure_ad_token_refresh": true}), false, true, InputSource::Request)]
|
||||
#[case::both_off(json!({}), false, false, InputSource::Request)]
|
||||
fn token_refresh_follows_the_configured_global(
|
||||
#[case] params: serde_json::Value,
|
||||
#[case] global: bool,
|
||||
#[case] enabled: bool,
|
||||
#[case] source: InputSource,
|
||||
) {
|
||||
let sources = BTreeMap::from([(
|
||||
"enable_azure_ad_token_refresh".to_string(),
|
||||
InputSource::Request,
|
||||
)]);
|
||||
let inputs =
|
||||
AzureAuthInputs::from_sourced_optional_params(params.as_object().unwrap(), &sources)
|
||||
.unwrap()
|
||||
.or_configured_token_refresh(global);
|
||||
assert_eq!(
|
||||
inputs.enable_azure_ad_token_refresh,
|
||||
Sourced::new(enabled, source)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::{collections::BTreeMap, future::Future, path::Path, pin::Pin, sync::Arc};
|
||||
|
||||
use gcp_auth::{CustomServiceAccount, TokenProvider};
|
||||
use litellm_auth::{
|
||||
CredentialPlacement, Error, InputSource, SecretValue, Sourced, http::apply_credential,
|
||||
};
|
||||
use moka::future::Cache;
|
||||
use serde_json::{Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use litellm_auth::http::apply_credential;
|
||||
use litellm_auth::{CredentialPlacement, Error, InputSource, SecretValue, Sourced};
|
||||
|
||||
const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
|
||||
const GOOGLE_OAUTH_TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
|
||||
const GOOGLE_APPLICATION_CREDENTIALS_ENV: &str = "GOOGLE_APPLICATION_CREDENTIALS";
|
||||
|
|
@ -45,6 +41,16 @@ impl VertexConfig {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn or_configured(self, project_id: Option<&str>, location: Option<&str>) -> Self {
|
||||
let configured =
|
||||
|value: Option<&str>| value.filter(|value| !value.is_empty()).map(str::to_string);
|
||||
Self {
|
||||
project_id: self.project_id.or_else(|| configured(project_id)),
|
||||
location: self.location.or_else(|| configured(location)),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project_id(&self) -> Option<&str> {
|
||||
self.project_id.as_deref()
|
||||
}
|
||||
|
|
@ -571,4 +577,29 @@ mod tests {
|
|||
assert_eq!(loads.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_defaults_sit_between_call_params_and_the_environment() {
|
||||
let env = |name: &str| Some(format!("env-{name}"));
|
||||
let from_config =
|
||||
VertexConfig::default().or_configured(Some("global-project"), Some("global-location"));
|
||||
assert_eq!(
|
||||
get_vertex_ai_project(&from_config, &env).as_deref(),
|
||||
Some("global-project")
|
||||
);
|
||||
assert_eq!(
|
||||
get_vertex_ai_location(&from_config, &env).as_deref(),
|
||||
Some("global-location")
|
||||
);
|
||||
let from_call =
|
||||
config(json!({"vertex_project":"call-project","vertex_location":"call-location"}))
|
||||
.or_configured(Some("global-project"), Some("global-location"));
|
||||
assert_eq!(from_call.project_id(), Some("call-project"));
|
||||
assert_eq!(from_call.location(), Some("call-location"));
|
||||
let empty_global = VertexConfig::default().or_configured(Some(""), None);
|
||||
assert_eq!(
|
||||
get_vertex_ai_project(&empty_global, &env).as_deref(),
|
||||
Some("env-VERTEXAI_PROJECT")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,13 +40,22 @@ pub fn apply_credential(
|
|||
)
|
||||
}
|
||||
|
||||
/// How the upstream call is authenticated. API-key strategies are resolved in
|
||||
/// `prepare`; SigV4 needs the serialized body, so the handler signs it.
|
||||
/// How the upstream call is authenticated. API-key strategies become headers
|
||||
/// in `prepare`; SigV4 covers the serialized body, so it is applied where the
|
||||
/// outbound request is built.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RequestAuth {
|
||||
Header { name: &'static str, value: String },
|
||||
Bearer { token: String },
|
||||
AwsSigV4 { region: String },
|
||||
Header {
|
||||
name: &'static str,
|
||||
value: String,
|
||||
},
|
||||
Bearer {
|
||||
token: String,
|
||||
},
|
||||
AwsSigV4 {
|
||||
region: String,
|
||||
service: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use pyo3::{
|
|||
exceptions::{PyBaseException, PyException},
|
||||
gc::{PyTraverseError, PyVisit},
|
||||
prelude::*,
|
||||
types::{PyDict, PyList},
|
||||
types::{PyDateTime, PyDict, PyList},
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
|
|
@ -73,10 +73,7 @@ pub struct LegacyLogging {
|
|||
}
|
||||
|
||||
fn datetime(py: Python<'_>, epoch_seconds: f64) -> PyResult<Py<PyAny>> {
|
||||
py.import("datetime")?
|
||||
.getattr("datetime")?
|
||||
.call_method1("fromtimestamp", (epoch_seconds,))
|
||||
.map(Bound::unbind)
|
||||
PyDateTime::from_timestamp(py, epoch_seconds, None).map(|value| value.into_any().unbind())
|
||||
}
|
||||
|
||||
fn is_cancellation(py: Python<'_>, error: &PyErr) -> bool {
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ pub mod params;
|
|||
pub mod prompt_templates;
|
||||
pub mod secret_redaction;
|
||||
pub mod serde_compat;
|
||||
pub mod settings;
|
||||
pub mod url_utils;
|
||||
|
|
|
|||
144
litellm-rust/crates/core-utils/src/settings.rs
Normal file
144
litellm-rust/crates/core-utils/src/settings.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
pub trait Lookup {
|
||||
fn get(&self, name: &str) -> Option<String>;
|
||||
|
||||
fn truthy(&self, name: &str) -> Option<String> {
|
||||
self.get(name).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn enabled(&self, name: &str) -> Option<bool> {
|
||||
self.get(name)
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("true"))
|
||||
.then_some(true)
|
||||
}
|
||||
|
||||
fn parsed<T: FromStr>(&self, name: &str) -> Option<T>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
self.get(name).and_then(|value| value.trim().parse().ok())
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: Fn(&str) -> Option<String>> Lookup for F {
|
||||
fn get(&self, name: &str) -> Option<String> {
|
||||
self(name)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProcessEnvironment;
|
||||
|
||||
impl Lookup for ProcessEnvironment {
|
||||
fn get(&self, name: &str) -> Option<String> {
|
||||
std::env::var(name).ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Layer: Default {
|
||||
fn or(self, lower: Self) -> Self;
|
||||
}
|
||||
|
||||
pub fn merge<L: Layer>(highest_precedence_first: impl IntoIterator<Item = L>) -> L {
|
||||
highest_precedence_first
|
||||
.into_iter()
|
||||
.reduce(L::or)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env_of(values: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
|
||||
move |name| {
|
||||
values
|
||||
.iter()
|
||||
.find(|(key, _)| *key == name)
|
||||
.map(|(_, value)| value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_keeps_a_present_empty_value_like_os_getenv_with_a_fallback() {
|
||||
let env = env_of(&[("EMPTY", "")]);
|
||||
assert_eq!(env.get("EMPTY"), Some(String::new()));
|
||||
assert_eq!(env.get("ABSENT"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truthy_drops_an_empty_value_like_a_python_or_chain() {
|
||||
let env = env_of(&[("EMPTY", ""), ("SET", "value")]);
|
||||
assert_eq!(env.truthy("EMPTY"), None);
|
||||
assert_eq!(env.truthy("SET").as_deref(), Some("value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_only_switches_on_for_true_and_never_forces_off() {
|
||||
let env = env_of(&[
|
||||
("LOWER", "true"),
|
||||
("PADDED", " True "),
|
||||
("OFF", "false"),
|
||||
("ONE", "1"),
|
||||
]);
|
||||
assert_eq!(env.enabled("LOWER"), Some(true));
|
||||
assert_eq!(env.enabled("PADDED"), Some(true));
|
||||
assert_eq!(env.enabled("OFF"), None);
|
||||
assert_eq!(env.enabled("ONE"), None);
|
||||
assert_eq!(env.enabled("ABSENT"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_trims_and_skips_values_that_do_not_parse() {
|
||||
let env = env_of(&[("PADDED", " 45 "), ("WORD", "soon"), ("FRACTION", "0.5")]);
|
||||
assert_eq!(env.parsed::<u32>("PADDED"), Some(45));
|
||||
assert_eq!(env.parsed::<u32>("WORD"), None);
|
||||
assert_eq!(env.parsed::<f64>("FRACTION"), Some(0.5));
|
||||
assert_eq!(env.parsed::<u32>("ABSENT"), None);
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Pair {
|
||||
first: Option<u8>,
|
||||
second: Option<u8>,
|
||||
}
|
||||
|
||||
impl Layer for Pair {
|
||||
fn or(self, lower: Self) -> Self {
|
||||
Self {
|
||||
first: self.first.or(lower.first),
|
||||
second: self.second.or(lower.second),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_takes_each_field_from_the_highest_layer_that_sets_it() {
|
||||
let merged = merge([
|
||||
Pair {
|
||||
first: Some(1),
|
||||
second: None,
|
||||
},
|
||||
Pair {
|
||||
first: Some(2),
|
||||
second: Some(2),
|
||||
},
|
||||
Pair {
|
||||
first: Some(3),
|
||||
second: Some(3),
|
||||
},
|
||||
]);
|
||||
assert_eq!(
|
||||
merged,
|
||||
Pair {
|
||||
first: Some(1),
|
||||
second: Some(2),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_no_layers_yields_the_empty_layer() {
|
||||
assert_eq!(merge(Vec::<Pair>::new()), Pair::default());
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,11 @@ litellm-core is the LiteLLM SDK in Rust — it makes the LLM call. Each top-leve
|
|||
Each crate mirrors one top-level Python package, so a Rust path reads as its Python path with the crate name in place of the package directory. Dependencies only point down:
|
||||
|
||||
- `litellm-types` mirrors `litellm/types/`: pure serde data, no I/O
|
||||
- `litellm-core-utils` mirrors `litellm/litellm_core_utils/`: pure helpers (provider resolution, prompt factory, call arguments), no network I/O
|
||||
- `litellm-llms` mirrors `litellm/llms/`: `base_llm/<api>/transformation.rs`, `<provider>/<api>/transformation.rs`, and `custom_httpx/` (HTTP helpers and the OCR request handler)
|
||||
- `litellm-core-utils` mirrors `litellm/litellm_core_utils/`: pure helpers (provider resolution, prompt factory, call arguments, settings lookup and layer merge), no network I/O
|
||||
- `litellm-http` is Rust-only and route-neutral: settings resolution, the pooled `reqwest` clients, TLS, proxies, the SSRF-safe media fetcher, request and header helpers, and transport errors. Python's `litellm/llms/custom_httpx/` is split by responsibility instead of mirrored: its transport half lives here, its OCR handler in `litellm-llms`
|
||||
- `litellm-llms` mirrors `litellm/llms/`: `base_llm/<api>/transformation.rs`, `<provider>/<api>/transformation.rs`, and `base_llm/ocr/handler.rs` (the OCR request handler)
|
||||
- `litellm-core` mirrors the route packages (`litellm/ocr/`, `litellm/messages/`, ...): entrypoints, route request types, provider dispatch, the route machine, and hooks
|
||||
|
||||
A route module owns the call entrypoint, route request types (`*Request<'a>`), credential fallback, provider dispatch, and the handler glue that runs a provider config. Provider code never imports from core; when it needs the caller's hooks mid-call it goes through `litellm_llms::custom_httpx::llm_http_handler::CallHooks`, which each route implements over its host. Import every item from its canonical path. Never re-export another crate's items or give an item a second public path; the only re-export allowed is a private submodule surfacing its item at its module root (`mod error; pub use error::Error;`). Handlers belong in core or llms, never in a host crate
|
||||
A route module owns the call entrypoint, route request types (`*Request<'a>`), credential fallback, provider dispatch, and the handler glue that runs a provider config. Provider code never imports from core; when it needs the caller's hooks mid-call it goes through `litellm_llms::base_llm::ocr::handler::CallHooks`, which each route implements over its host. Import every item from its canonical path. Never re-export another crate's items or give an item a second public path; the only re-export allowed is a private submodule surfacing its item at its module root (`mod error; pub use error::Error;`). Handlers belong in core or llms, never in a host crate
|
||||
|
||||
Not here: serving HTTP (axum routes, extractors), config file reading, rollout state, databases, or callback execution of any kind. Core runs each route as a machine that yields host operations and call events; which integrations consume those events is the host's business.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ futures-util.workspace = true
|
|||
base64.workspace = true
|
||||
litellm-auth.workspace = true
|
||||
litellm-auth-aws.workspace = true
|
||||
litellm-http.workspace = true
|
||||
litellm-llms.workspace = true
|
||||
moka.workspace = true
|
||||
mime_guess = "2.0.5"
|
||||
|
|
@ -36,7 +37,6 @@ veil.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
litellm-auth-gcp.workspace = true
|
||||
litellm-http.workspace = true
|
||||
litellm-llms = { workspace = true, features = ["test-support"] }
|
||||
rstest.workspace = true
|
||||
rstest_reuse.workspace = true
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ pub enum Error {
|
|||
#[error(transparent)]
|
||||
Auth(#[from] litellm_auth::Error),
|
||||
#[error(transparent)]
|
||||
Transport(#[from] litellm_llms::custom_httpx::transport::Error),
|
||||
Transport(#[from] litellm_http::transport::Error),
|
||||
#[error(transparent)]
|
||||
Headers(#[from] litellm_llms::custom_httpx::http_handler::HeaderError),
|
||||
Headers(#[from] litellm_http::request::HeaderError),
|
||||
#[error(transparent)]
|
||||
Http(#[from] litellm_http::Error),
|
||||
#[error(transparent)]
|
||||
Aws(#[from] litellm_auth_aws::Error),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use litellm_llms::custom_httpx::http_handler::{http_request, truncate_error_body};
|
||||
use litellm_http::request::truncate_error_body;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{Error, client::http_client};
|
||||
|
|
@ -7,34 +7,29 @@ use crate::audio_transcription::types::ProviderAudioTranscriptionRequest;
|
|||
pub async fn execute_audio_transcription_provider_call(
|
||||
request: ProviderAudioTranscriptionRequest,
|
||||
) -> Result<Value, Error> {
|
||||
let body = serde_json::to_vec(&request.body)
|
||||
.map_err(|error| Error::InvalidRequest(format!("invalid audio request body: {error}")))?;
|
||||
let headers = signed_headers(&request, &body).await?;
|
||||
let mut request_builder = http_client().post(&request.url).body(body);
|
||||
for (key, value) in headers {
|
||||
request_builder = request_builder.header(key, value);
|
||||
}
|
||||
if let Some(duration) = request.timeout {
|
||||
request_builder = request_builder.timeout(duration);
|
||||
}
|
||||
let response = http_request(request_builder).await.map_err(|error| {
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
|
||||
error.to_string(),
|
||||
))
|
||||
let response = crate::outbound::outbound_request::<Error>(
|
||||
&request.auth,
|
||||
request.url.clone(),
|
||||
request.upstream_headers.clone(),
|
||||
&request.body,
|
||||
request.timeout,
|
||||
&request.optional_params,
|
||||
)
|
||||
.await?
|
||||
.send(http_client())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
Error::Transport(litellm_http::transport::Error::Network(error.to_string()))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let text = response.text().await.map_err(|error| {
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
|
||||
error.to_string(),
|
||||
))
|
||||
Error::Transport(litellm_http::transport::Error::Network(error.to_string()))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(Error::Transport(
|
||||
litellm_llms::custom_httpx::transport::Error::Http {
|
||||
status: status.as_u16(),
|
||||
body: truncate_error_body(&text),
|
||||
},
|
||||
));
|
||||
return Err(Error::Transport(litellm_http::transport::Error::Http {
|
||||
status: status.as_u16(),
|
||||
body: truncate_error_body(&text),
|
||||
}));
|
||||
}
|
||||
let response_json = serde_json::from_str(&text)
|
||||
.map_err(|error| Error::InvalidResponse(format!("invalid audio response JSON: {error}")))?;
|
||||
|
|
@ -43,33 +38,3 @@ pub async fn execute_audio_transcription_provider_call(
|
|||
.transform_audio_transcription_response(&request.model, response_json)?
|
||||
.into_json())
|
||||
}
|
||||
|
||||
async fn signed_headers(
|
||||
request: &ProviderAudioTranscriptionRequest,
|
||||
body: &[u8],
|
||||
) -> Result<Vec<(String, String)>, Error> {
|
||||
use std::{collections::BTreeMap, time::SystemTime};
|
||||
|
||||
use litellm_auth_aws::{aws_auth_config, resolve_credentials, sign_bedrock_post};
|
||||
use litellm_llms::base_llm::audio_transcription::transformation::AudioTranscriptionAuth;
|
||||
|
||||
let AudioTranscriptionAuth::AwsSigV4 { region, .. } = &request.auth else {
|
||||
return Ok(request.upstream_headers.clone());
|
||||
};
|
||||
let env_lookup = |key: &str| std::env::var(key).ok();
|
||||
let credentials = resolve_credentials(
|
||||
aws_auth_config(&request.optional_params, &env_lookup),
|
||||
&env_lookup,
|
||||
)
|
||||
.await?;
|
||||
let unsigned: BTreeMap<String, String> = request.upstream_headers.iter().cloned().collect();
|
||||
let signature = sign_bedrock_post(
|
||||
&request.url,
|
||||
body,
|
||||
&unsigned,
|
||||
region,
|
||||
&credentials,
|
||||
SystemTime::now(),
|
||||
)?;
|
||||
Ok(unsigned.into_iter().chain(signature).collect())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
use litellm_core_utils::get_llm_provider_logic::{CustomLlmProvider, get_custom_llm_provider};
|
||||
use litellm_http::request::{has_header, string_headers};
|
||||
use litellm_llms::{
|
||||
base_llm::audio_transcription::transformation::{
|
||||
AudioTranscriptionAuth, BaseAudioTranscriptionConfig,
|
||||
},
|
||||
base_llm::audio_transcription::transformation::{BaseAudioTranscriptionConfig, RequestAuth},
|
||||
bedrock::audio_transcription::BEDROCK_AUDIO_TRANSCRIPTION_CONFIG,
|
||||
custom_httpx::http_handler::{has_header, string_headers},
|
||||
};
|
||||
|
||||
use super::Error;
|
||||
|
|
@ -43,11 +41,14 @@ pub fn prepare_audio_transcription_provider_call(
|
|||
let env_lookup = |key: &str| std::env::var(key).ok();
|
||||
let mut headers = string_headers("audio transcription", request.extra_headers)?;
|
||||
let auth = config.auth_strategy(&model, &request.optional_params, &env_lookup)?;
|
||||
if matches!(auth, AudioTranscriptionAuth::Bearer)
|
||||
&& !has_header(&headers, "authorization")
|
||||
&& let Some(api_key) = request.api_key
|
||||
{
|
||||
headers.push(("Authorization".to_string(), format!("Bearer {api_key}")));
|
||||
match &auth {
|
||||
RequestAuth::Bearer { token } if !has_header(&headers, "authorization") => {
|
||||
headers.push(("Authorization".to_string(), format!("Bearer {token}")));
|
||||
}
|
||||
RequestAuth::Header { name, value } if !has_header(&headers, name) => {
|
||||
headers.push(((*name).to_string(), value.clone()));
|
||||
}
|
||||
RequestAuth::Bearer { .. } | RequestAuth::Header { .. } | RequestAuth::AwsSigV4 { .. } => {}
|
||||
}
|
||||
if !has_header(&headers, "content-type") {
|
||||
headers.push(("Content-Type".to_string(), "application/json".to_string()));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use litellm_llms::base_llm::audio_transcription::transformation::{
|
||||
AudioTranscriptionAuth, BaseAudioTranscriptionConfig,
|
||||
BaseAudioTranscriptionConfig, RequestAuth,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ pub struct ProviderAudioTranscriptionRequest {
|
|||
pub url: String,
|
||||
pub body: Value,
|
||||
pub upstream_headers: Vec<(String, String)>,
|
||||
pub auth: AudioTranscriptionAuth,
|
||||
pub auth: RequestAuth,
|
||||
pub optional_params: Map<String, Value>,
|
||||
pub timeout: Option<Duration>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use litellm_http::request::string_headers as shared_string_headers;
|
||||
use litellm_llms::{
|
||||
anthropic::chat::transformation::ANTHROPIC_CHAT_COMPLETIONS_CONFIG,
|
||||
base_llm::chat::transformation::BaseConfig,
|
||||
bedrock::chat::converse_transformation::BEDROCK_CHAT_COMPLETIONS_CONFIG,
|
||||
custom_httpx::http_handler::string_headers as shared_string_headers,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ pub enum Error {
|
|||
#[error(transparent)]
|
||||
Auth(#[from] litellm_auth::Error),
|
||||
#[error(transparent)]
|
||||
Transport(#[from] litellm_llms::custom_httpx::transport::Error),
|
||||
Transport(#[from] litellm_http::transport::Error),
|
||||
#[error(transparent)]
|
||||
Headers(#[from] litellm_llms::custom_httpx::http_handler::HeaderError),
|
||||
Headers(#[from] litellm_http::request::HeaderError),
|
||||
#[error(transparent)]
|
||||
Http(#[from] litellm_http::Error),
|
||||
#[error(transparent)]
|
||||
Aws(#[from] litellm_auth_aws::Error),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
use litellm_llms::{
|
||||
base_llm::chat::transformation::{ChatCompletionsAuth, ProviderChatResponseData},
|
||||
custom_httpx::http_handler::{http_request, truncate_error_body},
|
||||
};
|
||||
use litellm_http::{outbound::OutboundRequest, request::truncate_error_body};
|
||||
use litellm_llms::base_llm::chat::transformation::ProviderChatResponseData;
|
||||
use litellm_types::utils::ChatCompletionsResponse;
|
||||
use serde_json::Value;
|
||||
|
||||
|
|
@ -14,50 +12,29 @@ pub(super) async fn execute_chat_completions_provider_call(
|
|||
request: ResolvedChatCompletionsRequest<'_>,
|
||||
) -> Result<ChatCompletionsResponse, Error> {
|
||||
let request = prepare_provider_request(request)?;
|
||||
let body = serde_json::to_vec(&request.body).map_err(|err| {
|
||||
Error::InvalidRequest(format!(
|
||||
"failed to serialize chat completions request: {err}"
|
||||
))
|
||||
})?;
|
||||
let headers = signed_headers(&request, &body).await?;
|
||||
let outbound = outbound_request(&request).await?;
|
||||
|
||||
let mut request_builder = http_client().post(&request.url).body(body);
|
||||
for (key, value) in &headers {
|
||||
request_builder = request_builder.header(key, value);
|
||||
}
|
||||
if let Some(duration) = request.timeout {
|
||||
request_builder = request_builder.timeout(duration);
|
||||
}
|
||||
|
||||
let response = http_request(request_builder).await.map_err(|err| {
|
||||
let response = outbound.send(http_client()).await.map_err(|err| {
|
||||
// Failing to establish the connection means the request never went out,
|
||||
// so the host can still serve it. Everything else here, a timeout
|
||||
// above all, may have reached the provider and been answered.
|
||||
if err.is_connect() || err.is_builder() {
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Connect(
|
||||
err.to_string(),
|
||||
))
|
||||
Error::Transport(litellm_http::transport::Error::Connect(err.to_string()))
|
||||
} else {
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
|
||||
err.to_string(),
|
||||
))
|
||||
Error::Transport(litellm_http::transport::Error::Network(err.to_string()))
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let text = response.text().await.map_err(|err| {
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
|
||||
err.to_string(),
|
||||
))
|
||||
Error::Transport(litellm_http::transport::Error::Network(err.to_string()))
|
||||
})?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(Error::Transport(
|
||||
litellm_llms::custom_httpx::transport::Error::Http {
|
||||
status: status.as_u16(),
|
||||
body: truncate_error_body(&text),
|
||||
},
|
||||
));
|
||||
return Err(Error::Transport(litellm_http::transport::Error::Http {
|
||||
status: status.as_u16(),
|
||||
body: truncate_error_body(&text),
|
||||
}));
|
||||
}
|
||||
|
||||
let body: Value = serde_json::from_str(&text).map_err(|err| {
|
||||
|
|
@ -82,64 +59,29 @@ pub(super) async fn execute_chat_completions_provider_call(
|
|||
pub(super) fn as_response_error(err: Error) -> Error {
|
||||
match err {
|
||||
already @ (Error::InvalidResponse(_)
|
||||
| Error::Transport(litellm_llms::custom_httpx::transport::Error::Http {
|
||||
..
|
||||
})) => already,
|
||||
| Error::Transport(litellm_http::transport::Error::Http { .. })) => already,
|
||||
other => Error::InvalidResponse(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn signed_headers(
|
||||
pub(super) async fn outbound_request(
|
||||
request: &ProviderChatCompletionsRequest,
|
||||
body: &[u8],
|
||||
) -> Result<Vec<(String, String)>, Error> {
|
||||
use std::{collections::BTreeMap, time::SystemTime};
|
||||
|
||||
use litellm_auth_aws::{
|
||||
aws_auth_config, aws_signature_headers, host_supplied_credentials,
|
||||
is_sigv4_computed_header, resolve_credentials, sign_bedrock_post,
|
||||
};
|
||||
|
||||
let ChatCompletionsAuth::AwsSigV4 { region } = &request.auth else {
|
||||
return Ok(request.upstream_headers.clone());
|
||||
};
|
||||
// Reattaching a header the signer also emits would put both copies on the
|
||||
// wire, and Bedrock rejects that pair. Python instead drops the caller's
|
||||
// copy and prefers a forwarded Authorization over the signature, so leave
|
||||
// the request to Python rather than serving it a different way here.
|
||||
if request
|
||||
.upstream_headers
|
||||
.iter()
|
||||
.any(|(name, _)| is_sigv4_computed_header(name))
|
||||
{
|
||||
return Err(Error::Unsupported(
|
||||
"request forwards a header AWS SigV4 computes",
|
||||
));
|
||||
}
|
||||
let env_lookup = |key: &str| std::env::var(key).ok();
|
||||
let unsigned: BTreeMap<String, String> = request.upstream_headers.iter().cloned().collect();
|
||||
// A host with its own resolution chain hands the result down; only fall
|
||||
// back to deriving credentials here when it supplied none.
|
||||
let credentials = match host_supplied_credentials(&request.optional_params) {
|
||||
Some(credentials) => credentials,
|
||||
None => {
|
||||
resolve_credentials(
|
||||
aws_auth_config(&request.optional_params, &env_lookup),
|
||||
&env_lookup,
|
||||
)
|
||||
.await?
|
||||
) -> Result<OutboundRequest, Error> {
|
||||
crate::outbound::outbound_request(
|
||||
&request.auth,
|
||||
request.url.clone(),
|
||||
request.upstream_headers.clone(),
|
||||
&request.body,
|
||||
request.timeout,
|
||||
&request.optional_params,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
// Python drops the caller's copy and prefers a forwarded Authorization
|
||||
// over the signature, so leave the request to it.
|
||||
Error::Http(litellm_http::Error::ComputedHeader(_)) => {
|
||||
Error::Unsupported("request forwards a header AWS SigV4 computes")
|
||||
}
|
||||
};
|
||||
let signature = sign_bedrock_post(
|
||||
&request.url,
|
||||
body,
|
||||
&aws_signature_headers(&unsigned),
|
||||
region,
|
||||
&credentials,
|
||||
SystemTime::now(),
|
||||
)?;
|
||||
// Every original header goes back on the wire alongside the computed ones,
|
||||
// as Python reattaches them. The guard above already rejected the names
|
||||
// that would collide, so no name appears twice.
|
||||
Ok(unsigned.into_iter().chain(signature).collect())
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
use litellm_core_utils::get_llm_provider_logic::{CustomLlmProvider, get_custom_llm_provider};
|
||||
use litellm_llms::{
|
||||
base_llm::chat::transformation::{BaseConfig, ChatCompletionsAuth},
|
||||
custom_httpx::http_handler::has_header,
|
||||
};
|
||||
use litellm_http::request::has_header;
|
||||
use litellm_llms::base_llm::chat::transformation::{BaseConfig, RequestAuth};
|
||||
use litellm_types::llms::openai::ChatMessage;
|
||||
use serde_json::Value;
|
||||
|
||||
|
|
@ -69,7 +67,7 @@ fn validate_environment(
|
|||
request: &ResolvedChatCompletionsRequest<'_>,
|
||||
model: &str,
|
||||
config: &dyn BaseConfig,
|
||||
) -> Result<(Vec<(String, String)>, ChatCompletionsAuth), Error> {
|
||||
) -> Result<(Vec<(String, String)>, RequestAuth), Error> {
|
||||
let env_lookup = |key: &str| std::env::var(key).ok();
|
||||
let mut headers = string_headers(request.extra_headers.clone())?;
|
||||
let auth = config.auth(
|
||||
|
|
@ -79,7 +77,7 @@ fn validate_environment(
|
|||
&env_lookup,
|
||||
)?;
|
||||
match &auth {
|
||||
ChatCompletionsAuth::Header { name, value } => {
|
||||
RequestAuth::Header { name, value } => {
|
||||
// The deployment's credential replaces whatever the caller forwarded
|
||||
// under the same name, mirroring Python's
|
||||
// `{**headers, **anthropic_headers}`: letting a request header win
|
||||
|
|
@ -94,7 +92,7 @@ fn validate_environment(
|
|||
headers.push(((*name).to_string(), value.clone()));
|
||||
}
|
||||
}
|
||||
ChatCompletionsAuth::Bearer { token } => {
|
||||
RequestAuth::Bearer { token } => {
|
||||
// Bedrock's `get_request_headers` assigns `headers["Authorization"]`
|
||||
// unconditionally once a bearer token resolves, so the deployment's
|
||||
// identity outranks whatever the caller forwarded. Keeping the
|
||||
|
|
@ -107,7 +105,7 @@ fn validate_environment(
|
|||
headers.push(("authorization".to_string(), format!("Bearer {token}")));
|
||||
}
|
||||
// SigV4 signs the serialized body, so the handler adds its headers.
|
||||
ChatCompletionsAuth::AwsSigV4 { .. } => {}
|
||||
RequestAuth::AwsSigV4 { .. } => {}
|
||||
}
|
||||
|
||||
for (name, value) in config.default_headers() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use litellm_llms::base_llm::chat::transformation::ChatCompletionsAuth;
|
||||
use litellm_llms::base_llm::chat::transformation::RequestAuth;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{
|
||||
|
|
@ -90,7 +90,7 @@ fn adds_the_auth_and_default_headers() {
|
|||
);
|
||||
assert!(matches!(
|
||||
prepared.auth,
|
||||
ChatCompletionsAuth::Header {
|
||||
RequestAuth::Header {
|
||||
name: "x-api-key",
|
||||
..
|
||||
}
|
||||
|
|
@ -265,7 +265,7 @@ fn rejects_non_string_extra_headers() {
|
|||
call.extra_headers = Some(Map::from_iter([("x-trace".to_string(), json!(7))]));
|
||||
assert_eq!(
|
||||
decline(call),
|
||||
Error::Headers(litellm_llms::custom_httpx::http_handler::HeaderError {
|
||||
Error::Headers(litellm_http::request::HeaderError {
|
||||
context: "chat completions",
|
||||
name: "x-trace".to_string(),
|
||||
actual: "number",
|
||||
|
|
@ -289,8 +289,9 @@ fn prepares_a_bedrock_call_without_resolving_credentials() {
|
|||
);
|
||||
assert_eq!(
|
||||
prepared.auth,
|
||||
ChatCompletionsAuth::AwsSigV4 {
|
||||
region: "us-east-1".to_string()
|
||||
RequestAuth::AwsSigV4 {
|
||||
region: "us-east-1".to_string(),
|
||||
service: "bedrock",
|
||||
}
|
||||
);
|
||||
// SigV4 signs the serialized body, so prepare must not have added an
|
||||
|
|
@ -326,15 +327,14 @@ async fn a_forwarded_client_header_does_not_enter_the_bedrock_signature() {
|
|||
json!("abc-123"),
|
||||
)]));
|
||||
let prepared = prepare_chat_completions_call(call).expect("prepares");
|
||||
let signed = super::handler::signed_headers(&prepared, br#"{"a":1}"#)
|
||||
let signed = super::handler::outbound_request(&prepared)
|
||||
.await
|
||||
.expect("signs");
|
||||
|
||||
let authorization = signed
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case("authorization"))
|
||||
.map(|(_, value)| value.clone())
|
||||
.expect("carries an authorization header");
|
||||
.header("authorization")
|
||||
.expect("carries an authorization header")
|
||||
.to_string();
|
||||
assert!(
|
||||
authorization.starts_with("AWS4-HMAC-SHA256"),
|
||||
"expected a SigV4 signature, got {authorization}"
|
||||
|
|
@ -346,6 +346,7 @@ async fn a_forwarded_client_header_does_not_enter_the_bedrock_signature() {
|
|||
// It still goes on the wire, it is just not part of the signature.
|
||||
assert!(
|
||||
signed
|
||||
.headers()
|
||||
.iter()
|
||||
.any(|(name, value)| name == "x-request-id" && value == "abc-123"),
|
||||
"forwarded header was dropped instead of reattached"
|
||||
|
|
@ -376,7 +377,7 @@ async fn a_forwarded_header_the_signer_computes_declines_to_python() {
|
|||
call.api_key = None;
|
||||
call.extra_headers = Some(Map::from_iter([(forwarded.to_string(), json!("forged"))]));
|
||||
let prepared = prepare_chat_completions_call(call).expect("prepares");
|
||||
let error = super::handler::signed_headers(&prepared, br#"{"a":1}"#)
|
||||
let error = super::handler::outbound_request(&prepared)
|
||||
.await
|
||||
.expect_err("{forwarded} should decline instead of being signed");
|
||||
assert!(
|
||||
|
|
@ -466,7 +467,7 @@ fn a_bedrock_api_key_is_sent_as_a_bearer_token_instead_of_being_signed() {
|
|||
.expect("prepares");
|
||||
assert_eq!(
|
||||
prepared.auth,
|
||||
ChatCompletionsAuth::Bearer {
|
||||
RequestAuth::Bearer {
|
||||
token: "sk-test".to_string()
|
||||
}
|
||||
);
|
||||
|
|
@ -771,10 +772,7 @@ mod round_trip {
|
|||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Http {
|
||||
status: 429,
|
||||
..
|
||||
})
|
||||
Error::Transport(litellm_http::transport::Error::Http { status: 429, .. })
|
||||
),
|
||||
"expected a 429, got {err:?}"
|
||||
);
|
||||
|
|
@ -801,7 +799,7 @@ mod round_trip {
|
|||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Connect(_))
|
||||
Error::Transport(litellm_http::transport::Error::Connect(_))
|
||||
),
|
||||
"expected a pre-send connect failure, got {err:?}"
|
||||
);
|
||||
|
|
@ -825,16 +823,11 @@ mod round_trip {
|
|||
}
|
||||
// An upstream status is already unambiguous, so it survives intact.
|
||||
assert!(matches!(
|
||||
as_response_error(Error::Transport(
|
||||
litellm_llms::custom_httpx::transport::Error::Http {
|
||||
status: 500,
|
||||
body: "boom".to_string()
|
||||
}
|
||||
)),
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Http {
|
||||
as_response_error(Error::Transport(litellm_http::transport::Error::Http {
|
||||
status: 500,
|
||||
..
|
||||
})
|
||||
body: "boom".to_string()
|
||||
})),
|
||||
Error::Transport(litellm_http::transport::Error::Http { status: 500, .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use litellm_llms::base_llm::chat::transformation::{BaseConfig, ChatCompletionsAuth};
|
||||
use litellm_llms::base_llm::chat::transformation::{BaseConfig, RequestAuth};
|
||||
use litellm_types::llms::openai::ChatMessage;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ pub struct ProviderChatCompletionsRequest {
|
|||
pub url: String,
|
||||
pub body: Value,
|
||||
pub upstream_headers: Vec<(String, String)>,
|
||||
pub auth: ChatCompletionsAuth,
|
||||
pub auth: RequestAuth,
|
||||
pub optional_params: Map<String, Value>,
|
||||
pub timeout: Option<Duration>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ pub mod constants;
|
|||
pub mod error;
|
||||
pub mod messages;
|
||||
pub mod ocr;
|
||||
mod outbound;
|
||||
pub mod responses;
|
||||
|
||||
pub use error::Error;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
pub(super) use litellm_llms::custom_httpx::http_handler::{
|
||||
has_bearer_auth, has_header, truncate_error_body,
|
||||
};
|
||||
use litellm_http::request::string_headers as shared_string_headers;
|
||||
pub(super) use litellm_http::request::{has_bearer_auth, has_header, truncate_error_body};
|
||||
use litellm_llms::{
|
||||
anthropic::experimental_pass_through::messages::transformation::ANTHROPIC_MESSAGES_CONFIG,
|
||||
azure_ai::anthropic::messages_transformation::AZURE_ANTHROPIC_MESSAGES_CONFIG,
|
||||
base_llm::anthropic_messages::transformation::BaseAnthropicMessagesConfig,
|
||||
custom_httpx::http_handler::string_headers as shared_string_headers,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ pub enum Error {
|
|||
#[error(transparent)]
|
||||
Auth(#[from] litellm_auth::Error),
|
||||
#[error(transparent)]
|
||||
Transport(#[from] litellm_llms::custom_httpx::transport::Error),
|
||||
Transport(#[from] litellm_http::transport::Error),
|
||||
#[error(transparent)]
|
||||
Headers(#[from] litellm_llms::custom_httpx::http_handler::HeaderError),
|
||||
Headers(#[from] litellm_http::request::HeaderError),
|
||||
}
|
||||
|
||||
impl From<LlmError> for Error {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use litellm_llms::{
|
||||
base_llm::anthropic_messages::transformation::BaseAnthropicMessagesConfig,
|
||||
custom_httpx::{http_handler::http_request, transport::Error as TransportError},
|
||||
};
|
||||
use litellm_http::{request::http_request, transport::Error as TransportError};
|
||||
use litellm_llms::base_llm::anthropic_messages::transformation::BaseAnthropicMessagesConfig;
|
||||
use litellm_types::llms::anthropic_messages::anthropic_response::AnthropicMessagesResponse;
|
||||
use serde_json::Value;
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ fn string_headers_rejects_non_string_values() {
|
|||
let err = string_headers(Some(headers)).expect_err("non-string header rejected");
|
||||
assert_eq!(
|
||||
err,
|
||||
Error::Headers(litellm_llms::custom_httpx::http_handler::HeaderError {
|
||||
Error::Headers(litellm_http::request::HeaderError {
|
||||
context: "messages",
|
||||
name: "x-count".to_string(),
|
||||
actual: "number",
|
||||
|
|
@ -432,7 +432,7 @@ async fn messages_maps_provider_error_status_to_http_error() {
|
|||
|
||||
assert!(matches!(
|
||||
err,
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Http { status: 401, .. })
|
||||
Error::Transport(litellm_http::transport::Error::Http { status: 401, .. })
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,18 @@ const AZURE_AUTH_OPTION_FIELDS: &[&str] = &[
|
|||
"azure_federated_token_file",
|
||||
"enable_azure_ad_token_refresh",
|
||||
];
|
||||
const AWS_AUTH_OPTION_FIELDS: &[&str] = &[
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
"aws_session_token",
|
||||
"aws_region_name",
|
||||
"aws_session_name",
|
||||
"aws_profile_name",
|
||||
"aws_role_name",
|
||||
"aws_web_identity_token",
|
||||
"aws_sts_endpoint",
|
||||
"aws_external_id",
|
||||
];
|
||||
const VERTEX_AUTH_OPTION_FIELDS: &[&str] = &[
|
||||
"vertex_credentials",
|
||||
"vertex_ai_credentials",
|
||||
|
|
@ -35,6 +47,7 @@ pub fn consumed_optional_param_names(
|
|||
let (model, config) = resolve_provider_config(model, custom_llm_provider)?;
|
||||
let provider_fields = config.get_supported_ocr_params(&model);
|
||||
let auth_fields: &[&str] = match config {
|
||||
OcrConfigKind::AwsTextract | OcrConfigKind::AwsTextractAnalyze => AWS_AUTH_OPTION_FIELDS,
|
||||
OcrConfigKind::AzureAi
|
||||
| OcrConfigKind::AzureDocumentIntelligence
|
||||
| OcrConfigKind::AzureCohere => AZURE_AUTH_OPTION_FIELDS,
|
||||
|
|
@ -57,6 +70,9 @@ pub(crate) fn is_secret_param(name: &str) -> bool {
|
|||
| "azure_federated_token_file"
|
||||
| "vertex_credentials"
|
||||
| "vertex_ai_credentials"
|
||||
| "aws_secret_access_key"
|
||||
| "aws_session_token"
|
||||
| "aws_web_identity_token"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
use litellm_llms::{
|
||||
base_llm::ocr::{error::Error, transformation::LiteLLMOcrResponse},
|
||||
custom_httpx::llm_http_handler::OcrClient,
|
||||
use litellm_llms::base_llm::ocr::{
|
||||
error::Error, handler::OcrClient, transformation::LiteLLMOcrResponse,
|
||||
};
|
||||
|
||||
use crate::ocr::{
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
use futures_util::future::BoxFuture;
|
||||
use litellm_auth::SecretValue;
|
||||
use litellm_host::event::{MachineEvent, RawResponse, RequestContext, WireRequest};
|
||||
use litellm_llms::{
|
||||
base_llm::ocr::{
|
||||
error::Error,
|
||||
transformation::{LiteLLMOcrResponse, PreparedOcrRequest},
|
||||
},
|
||||
custom_httpx::llm_http_handler::{CallHooks, OcrClient},
|
||||
use litellm_llms::base_llm::ocr::{
|
||||
error::Error,
|
||||
handler::{CallHooks, OcrClient},
|
||||
transformation::{LiteLLMOcrResponse, PreparedOcrRequest},
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
|
|
@ -24,7 +22,7 @@ pub(crate) async fn perform_ocr_request(
|
|||
) -> Result<LiteLLMOcrResponse, Error> {
|
||||
request.response_format()?;
|
||||
let config = request.config;
|
||||
let request = prepare_request(request, caller_document);
|
||||
let request = prepare_request(request, caller_document, client);
|
||||
let hooks = OcrCallHooks::new(host.clone(), &request, config);
|
||||
config.ocr(client, &request, &hooks).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ pub mod route;
|
|||
pub mod types;
|
||||
pub mod wire;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../../tests/aws_textract_ocr.rs"]
|
||||
mod aws_textract_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../../tests/azure_ai_ocr.rs"]
|
||||
mod azure_ai_tests;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use litellm_auth::{InputSource, SecretValue, Sourced};
|
||||
use litellm_llms::base_llm::ocr::transformation::{
|
||||
OcrConnection, OcrCredentialInputs, PreparedOcrRequest, credential_env,
|
||||
use litellm_llms::base_llm::ocr::{
|
||||
handler::OcrClient,
|
||||
transformation::{OcrConnection, OcrCredentialInputs, PreparedOcrRequest},
|
||||
};
|
||||
|
||||
use super::provider_config::OcrProvider;
|
||||
|
|
@ -9,26 +10,34 @@ use crate::ocr::types::{LiteLLMOcrRequest, ResolvedOcrRequest};
|
|||
pub(crate) fn prepare_request(
|
||||
request: ResolvedOcrRequest,
|
||||
caller_document: bool,
|
||||
client: &OcrClient,
|
||||
) -> PreparedOcrRequest {
|
||||
let credentials = request.credentials.clone();
|
||||
let api_base_env = match request.config.provider() {
|
||||
OcrProvider::Mistral => Some("MISTRAL_API_BASE"),
|
||||
OcrProvider::AzureAi => Some("AZURE_AI_API_BASE"),
|
||||
OcrProvider::Cohere | OcrProvider::Reducto | OcrProvider::VertexAi => None,
|
||||
let (preferred_api_key_env, api_base_env) = match request.config.provider() {
|
||||
OcrProvider::Mistral => (
|
||||
Some("MISTRAL_AZURE_API_KEY"),
|
||||
Some("MISTRAL_AZURE_API_BASE"),
|
||||
),
|
||||
OcrProvider::AzureAi => (None, Some("AZURE_AI_API_BASE")),
|
||||
OcrProvider::AwsTextract
|
||||
| OcrProvider::Cohere
|
||||
| OcrProvider::Reducto
|
||||
| OcrProvider::VertexAi => (None, None),
|
||||
};
|
||||
let secret = |name: &str| client.secrets().truthy(name);
|
||||
let dynamic_api_key = credentials.dynamic_api_key.or_else(|| {
|
||||
credentials.api_key.clone().or_else(|| {
|
||||
request
|
||||
.config
|
||||
.get_api_key_env_var()
|
||||
.and_then(credential_env)
|
||||
preferred_api_key_env
|
||||
.into_iter()
|
||||
.chain(request.config.get_api_key_env_var())
|
||||
.find_map(secret)
|
||||
.map(|value| Sourced::new(SecretValue::new(value), InputSource::Environment))
|
||||
})
|
||||
});
|
||||
let dynamic_api_base = credentials.dynamic_api_base.or_else(|| {
|
||||
credentials.api_base.clone().or_else(|| {
|
||||
api_base_env
|
||||
.and_then(credential_env)
|
||||
.and_then(secret)
|
||||
.map(|value| Sourced::new(value, InputSource::Environment))
|
||||
})
|
||||
});
|
||||
|
|
@ -51,7 +60,12 @@ pub(crate) fn prepare_request(
|
|||
PreparedOcrRequest {
|
||||
model,
|
||||
document,
|
||||
connection: OcrConnection::new(resolved, transport),
|
||||
connection: OcrConnection::new(
|
||||
resolved,
|
||||
transport,
|
||||
client.settings().clone(),
|
||||
client.secrets().clone(),
|
||||
),
|
||||
caller_document,
|
||||
optional_params,
|
||||
input_sources,
|
||||
|
|
@ -61,7 +75,11 @@ pub(crate) fn prepare_request(
|
|||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn prepare_request_for_test(request: ResolvedOcrRequest) -> PreparedOcrRequest {
|
||||
prepare_request(request, true)
|
||||
prepare_request(
|
||||
request,
|
||||
true,
|
||||
&OcrClient::for_test(reqwest::Client::new(), reqwest::Client::new()),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
use litellm_core_utils::get_llm_provider_logic::{CustomLlmProvider, get_custom_llm_provider};
|
||||
use litellm_llms::{
|
||||
aws_textract::ocr::{
|
||||
analyze_transformation::TextractAnalyzeDocumentConfig, common_utils::TextractOperation,
|
||||
transformation::TextractDetectTextConfig,
|
||||
},
|
||||
azure_ai::ocr::{
|
||||
cohere_parse_transformation::AzureAICohereParseConfig,
|
||||
document_intelligence::transformation::AzureDocumentIntelligenceOcrConfig,
|
||||
|
|
@ -7,13 +11,13 @@ use litellm_llms::{
|
|||
},
|
||||
base_llm::ocr::{
|
||||
error::Error,
|
||||
handler::{self, CallHooks, OcrClient},
|
||||
transformation::{
|
||||
BaseOcrConfig, LiteLLMOcrResponse, OcrCredentialInputs, OcrDocument,
|
||||
PreparedOcrRequest, ResolvedOcrCredentials,
|
||||
},
|
||||
},
|
||||
cohere::ocr::transformation::CohereParseConfig,
|
||||
custom_httpx::llm_http_handler::{self, CallHooks, OcrClient},
|
||||
mistral::ocr::transformation::MistralOcrConfig,
|
||||
reducto::ocr::transformation::{ReductoParseLegacyConfig, ReductoParseV3Config},
|
||||
vertex_ai::ocr::{
|
||||
|
|
@ -25,6 +29,14 @@ use strum::{EnumString, IntoStaticStr};
|
|||
macro_rules! with_config {
|
||||
($kind:expr, $config:ident => $body:expr) => {
|
||||
match $kind {
|
||||
OcrConfigKind::AwsTextract => {
|
||||
let $config = TextractDetectTextConfig;
|
||||
$body
|
||||
}
|
||||
OcrConfigKind::AwsTextractAnalyze => {
|
||||
let $config = TextractAnalyzeDocumentConfig;
|
||||
$body
|
||||
}
|
||||
OcrConfigKind::Cohere => {
|
||||
let $config = CohereParseConfig;
|
||||
$body
|
||||
|
|
@ -67,6 +79,8 @@ macro_rules! with_config {
|
|||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum OcrConfigKind {
|
||||
AwsTextract,
|
||||
AwsTextractAnalyze,
|
||||
Cohere,
|
||||
Mistral,
|
||||
AzureAi,
|
||||
|
|
@ -81,6 +95,7 @@ pub(crate) enum OcrConfigKind {
|
|||
impl OcrConfigKind {
|
||||
pub(crate) const fn provider(self) -> OcrProvider {
|
||||
match self {
|
||||
Self::AwsTextract | Self::AwsTextractAnalyze => OcrProvider::AwsTextract,
|
||||
Self::Cohere => OcrProvider::Cohere,
|
||||
Self::Mistral => OcrProvider::Mistral,
|
||||
Self::AzureAi | Self::AzureCohere | Self::AzureDocumentIntelligence => {
|
||||
|
|
@ -116,7 +131,7 @@ impl OcrConfigKind {
|
|||
request: &PreparedOcrRequest,
|
||||
hooks: &dyn CallHooks<Error>,
|
||||
) -> Result<LiteLLMOcrResponse, Error> {
|
||||
with_config!(self, config => llm_http_handler::ocr(&config, client, request, hooks).await)
|
||||
with_config!(self, config => handler::ocr(&config, client, request, hooks).await)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,6 +156,7 @@ pub fn get_health_check_document(
|
|||
#[derive(Clone, Copy, Debug, EnumString, IntoStaticStr, PartialEq, Eq)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub(crate) enum OcrProvider {
|
||||
AwsTextract,
|
||||
Cohere,
|
||||
Mistral,
|
||||
AzureAi,
|
||||
|
|
@ -162,6 +178,10 @@ pub(crate) fn resolve_provider_config(
|
|||
.parse::<OcrProvider>()
|
||||
.map_err(|_| Error::InvalidProvider(provider.custom_llm_provider.to_string()))?;
|
||||
let config = match ocr_provider {
|
||||
OcrProvider::AwsTextract => match TextractOperation::from_model(provider.model)? {
|
||||
TextractOperation::DetectDocumentText => OcrConfigKind::AwsTextract,
|
||||
TextractOperation::AnalyzeDocument => OcrConfigKind::AwsTextractAnalyze,
|
||||
},
|
||||
OcrProvider::Cohere => OcrConfigKind::Cohere,
|
||||
OcrProvider::Mistral => OcrConfigKind::Mistral,
|
||||
OcrProvider::AzureAi if is_document_intelligence_model(provider.model) => {
|
||||
|
|
@ -419,6 +439,22 @@ mod tests {
|
|||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::misspelled_operation("aws_textract/analyse-document")]
|
||||
#[case::operation_name_from_the_api("aws_textract/AnalyzeDocument")]
|
||||
fn textract_models_outside_its_two_operations_are_refused(#[case] model: &str) {
|
||||
assert!(matches!(
|
||||
resolve_provider_config(model, None),
|
||||
Err(Error::InvalidModel {
|
||||
provider: "aws_textract",
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("aws_textract/detect-document-text", OcrConfigKind::AwsTextract)]
|
||||
#[case("aws_textract/analyze-document", OcrConfigKind::AwsTextractAnalyze)]
|
||||
#[case("aws_textract/Analyze-Document", OcrConfigKind::AwsTextractAnalyze)]
|
||||
#[case("reducto/parse-legacy", OcrConfigKind::ReductoLegacy)]
|
||||
#[case("reducto/future-parse-model", OcrConfigKind::ReductoV3)]
|
||||
#[case("azure_ai/Cohere-parse-v5", OcrConfigKind::AzureCohere)]
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ use litellm_host::{
|
|||
machine::{HostChannel, HostTokenProvider, MachineFault, RouteMachine, TokenRoute},
|
||||
route::Route,
|
||||
};
|
||||
use litellm_llms::{
|
||||
base_llm::ocr::{error::Error, transformation::LiteLLMOcrResponse},
|
||||
custom_httpx::llm_http_handler::OcrClient,
|
||||
use litellm_llms::base_llm::ocr::{
|
||||
error::Error, handler::OcrClient, transformation::LiteLLMOcrResponse,
|
||||
};
|
||||
|
||||
use super::handler::perform_ocr_request;
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ mod tests {
|
|||
vec![("x-a".to_string(), "1".to_string())]
|
||||
);
|
||||
assert_eq!(request.transport.extra_headers_source, InputSource::Request);
|
||||
assert_eq!(request.transport.timeout, Duration::from_secs(7));
|
||||
assert_eq!(request.transport.timeout, Some(Duration::from_secs(7)));
|
||||
assert_eq!(request.input_sources.len(), 2);
|
||||
|
||||
let defaulted = LiteLLMOcrRequest::from_inputs(
|
||||
|
|
|
|||
30
litellm-rust/crates/core/src/outbound.rs
Normal file
30
litellm-rust/crates/core/src/outbound.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use litellm_auth::RequestAuth;
|
||||
use litellm_auth_aws::SigV4Signer;
|
||||
use litellm_http::outbound::OutboundRequest;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// Header credentials are already in `headers`; SigV4 is applied here, over the
|
||||
/// bytes that are sent.
|
||||
pub(crate) async fn outbound_request<E>(
|
||||
auth: &RequestAuth,
|
||||
url: String,
|
||||
headers: Vec<(String, String)>,
|
||||
body: &Value,
|
||||
timeout: Option<Duration>,
|
||||
optional_params: &Map<String, Value>,
|
||||
) -> Result<OutboundRequest, E>
|
||||
where
|
||||
E: From<litellm_http::Error> + From<litellm_auth_aws::Error>,
|
||||
{
|
||||
let RequestAuth::AwsSigV4 { region, service } = auth else {
|
||||
return Ok(OutboundRequest::json(url, headers, body, timeout)?);
|
||||
};
|
||||
let env_lookup = |key: &str| std::env::var(key).ok();
|
||||
let signer =
|
||||
SigV4Signer::resolve(region.clone(), service, optional_params, &env_lookup).await?;
|
||||
Ok(OutboundRequest::signed_json(
|
||||
url, headers, body, timeout, &signer,
|
||||
)?)
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ pub enum Error {
|
|||
#[error(transparent)]
|
||||
Auth(#[from] litellm_auth::Error),
|
||||
#[error(transparent)]
|
||||
Transport(#[from] litellm_llms::custom_httpx::transport::Error),
|
||||
Transport(#[from] litellm_http::transport::Error),
|
||||
#[error(transparent)]
|
||||
Headers(#[from] litellm_llms::custom_httpx::http_handler::HeaderError),
|
||||
Headers(#[from] litellm_http::request::HeaderError),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,9 +95,7 @@ impl ResponsesWebSocketConnection {
|
|||
timeout: Option<Duration>,
|
||||
) -> Result<Self, Error> {
|
||||
let mut request = url.into_client_request().map_err(|error| {
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
|
||||
error.to_string(),
|
||||
))
|
||||
Error::Transport(litellm_http::transport::Error::Network(error.to_string()))
|
||||
})?;
|
||||
for (name, value) in headers {
|
||||
let header_name = name
|
||||
|
|
@ -110,7 +108,7 @@ impl ResponsesWebSocketConnection {
|
|||
let connect = connect_upstream(request);
|
||||
let result = match timeout {
|
||||
Some(timeout) => tokio::time::timeout(timeout, connect).await.map_err(|_| {
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
|
||||
Error::Transport(litellm_http::transport::Error::Network(
|
||||
"Responses WebSocket connection timed out".into(),
|
||||
))
|
||||
})?,
|
||||
|
|
@ -118,14 +116,12 @@ impl ResponsesWebSocketConnection {
|
|||
};
|
||||
let (socket, _) = result.map_err(|error| match *error {
|
||||
tokio_tungstenite::tungstenite::Error::Http(response) => {
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Http {
|
||||
Error::Transport(litellm_http::transport::Error::Http {
|
||||
status: response.status().as_u16(),
|
||||
body: String::new(),
|
||||
})
|
||||
}
|
||||
other => Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
|
||||
other.to_string(),
|
||||
)),
|
||||
other => Error::Transport(litellm_http::transport::Error::Network(other.to_string())),
|
||||
})?;
|
||||
Ok(Self {
|
||||
socket: Arc::new(Mutex::new(Some(socket))),
|
||||
|
|
@ -135,16 +131,12 @@ impl ResponsesWebSocketConnection {
|
|||
pub async fn send_text(&self, text: String) -> Result<(), Error> {
|
||||
let mut socket = self.socket.lock().await;
|
||||
let Some(socket) = socket.as_mut() else {
|
||||
return Err(Error::Transport(
|
||||
litellm_llms::custom_httpx::transport::Error::Network(
|
||||
"Responses WebSocket is closed".into(),
|
||||
),
|
||||
));
|
||||
return Err(Error::Transport(litellm_http::transport::Error::Network(
|
||||
"Responses WebSocket is closed".into(),
|
||||
)));
|
||||
};
|
||||
socket.send(Message::Text(text)).await.map_err(|error| {
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
|
||||
error.to_string(),
|
||||
))
|
||||
Error::Transport(litellm_http::transport::Error::Network(error.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -160,9 +152,9 @@ impl ResponsesWebSocketConnection {
|
|||
.map_err(|error| Error::InvalidResponse(error.to_string())),
|
||||
Some(Ok(Message::Close(_))) | None => Ok(None),
|
||||
Some(Ok(_)) => Ok(None),
|
||||
Some(Err(error)) => Err(Error::Transport(
|
||||
litellm_llms::custom_httpx::transport::Error::Network(error.to_string()),
|
||||
)),
|
||||
Some(Err(error)) => Err(Error::Transport(litellm_http::transport::Error::Network(
|
||||
error.to_string(),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -170,9 +162,7 @@ impl ResponsesWebSocketConnection {
|
|||
let mut socket = self.socket.lock().await;
|
||||
if let Some(socket) = socket.as_mut() {
|
||||
socket.close(None).await.map_err(|error| {
|
||||
Error::Transport(litellm_llms::custom_httpx::transport::Error::Network(
|
||||
error.to_string(),
|
||||
))
|
||||
Error::Transport(litellm_http::transport::Error::Network(error.to_string()))
|
||||
})?;
|
||||
}
|
||||
*socket = None;
|
||||
|
|
|
|||
193
litellm-rust/crates/core/tests/aws_textract_ocr.rs
Normal file
193
litellm-rust/crates/core/tests/aws_textract_ocr.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
use std::{collections::BTreeMap, time::SystemTime};
|
||||
|
||||
use litellm_auth_aws::{Credentials, aws_signature_headers, sign_post};
|
||||
use litellm_llms::base_llm::ocr::error::Error;
|
||||
use serde_json::{Value, json};
|
||||
use time::{PrimitiveDateTime, format_description};
|
||||
|
||||
use crate::ocr::{
|
||||
route::LocalOcrHost,
|
||||
test_support::{
|
||||
MockResponse, header, mock_server, perform_ocr_with, request_body,
|
||||
wire_request_with_document,
|
||||
},
|
||||
types::LiteLLMOcrRequest,
|
||||
};
|
||||
|
||||
const ACCESS_KEY_ID: &str = "AKIDEXAMPLE";
|
||||
const SECRET_ACCESS_KEY: &str = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
|
||||
|
||||
fn textract_request(base: &str) -> LiteLLMOcrRequest {
|
||||
textract_request_for("aws_textract/detect-document-text", base)
|
||||
}
|
||||
|
||||
fn textract_request_for(model: &str, base: &str) -> LiteLLMOcrRequest {
|
||||
wire_request_with_document(
|
||||
model,
|
||||
&format!("{base}/"),
|
||||
json!({"type": "image_url", "image_url": "data:image/png;base64,b3JpZ2luYWw="}),
|
||||
json!({
|
||||
"aws_access_key_id": ACCESS_KEY_ID,
|
||||
"aws_secret_access_key": SECRET_ACCESS_KEY,
|
||||
"aws_region_name": "eu-west-1"
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn textract_response() -> MockResponse {
|
||||
MockResponse::json(json!({
|
||||
"DocumentMetadata": {"Pages": 1},
|
||||
"Blocks": [{"BlockType": "PAGE"}, {"BlockType": "LINE", "Text": "Invoice 12345"}]
|
||||
}))
|
||||
}
|
||||
|
||||
/// Recomputes SigV4 over the bytes the server received, at the time the client claimed.
|
||||
fn expected_authorization(url: &str, raw_request: &str) -> String {
|
||||
let format =
|
||||
format_description::parse_borrowed::<2>("[year][month][day]T[hour][minute][second]Z")
|
||||
.unwrap();
|
||||
let signed_at: SystemTime =
|
||||
PrimitiveDateTime::parse(header(raw_request, "x-amz-date").unwrap(), &format)
|
||||
.unwrap()
|
||||
.assume_utc()
|
||||
.into();
|
||||
let headers: BTreeMap<String, String> = ["content-type", "x-amz-target"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
(
|
||||
name.to_string(),
|
||||
header(raw_request, name).unwrap().to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let body = raw_request.split_once("\r\n\r\n").unwrap().1;
|
||||
sign_post(
|
||||
url,
|
||||
body.as_bytes(),
|
||||
&aws_signature_headers(&headers),
|
||||
"eu-west-1",
|
||||
"textract",
|
||||
&Credentials::new(ACCESS_KEY_ID, SECRET_ACCESS_KEY, None, None, "test"),
|
||||
signed_at,
|
||||
)
|
||||
.unwrap()["Authorization"]
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_request_is_signed_for_textract_and_lines_become_the_page() {
|
||||
let (base, seen, server) = mock_server(vec![textract_response()]).await;
|
||||
|
||||
let response = perform_ocr_with(LocalOcrHost::new(textract_request(&base)))
|
||||
.await
|
||||
.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
let raw = seen.lock().unwrap()[0].clone();
|
||||
assert_eq!(
|
||||
header(&raw, "x-amz-target"),
|
||||
Some("Textract.DetectDocumentText")
|
||||
);
|
||||
assert_eq!(
|
||||
header(&raw, "content-type"),
|
||||
Some("application/x-amz-json-1.1")
|
||||
);
|
||||
assert_eq!(
|
||||
request_body(&raw),
|
||||
json!({"Document": {"Bytes": "b3JpZ2luYWw="}})
|
||||
);
|
||||
assert_eq!(
|
||||
header(&raw, "authorization"),
|
||||
Some(expected_authorization(&format!("{base}/"), &raw).as_str())
|
||||
);
|
||||
assert_eq!(response.pages[0].markdown, "Invoice 12345");
|
||||
assert_eq!(response.usage_info.unwrap().pages_processed, Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_body_rewritten_by_before_send_is_what_gets_signed_and_sent() {
|
||||
let (base, seen, server) = mock_server(vec![textract_response()]).await;
|
||||
let host = LocalOcrHost::new(textract_request(&base)).with_before_send(|mut wire, _| {
|
||||
assert!(
|
||||
!wire
|
||||
.headers
|
||||
.iter()
|
||||
.any(|(name, _)| name.eq_ignore_ascii_case("authorization")),
|
||||
"the hook ran after signing"
|
||||
);
|
||||
wire.body["Document"]["Bytes"] = Value::from("cmVkYWN0ZWQ=");
|
||||
Ok(wire)
|
||||
});
|
||||
|
||||
perform_ocr_with(host).await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
let raw = seen.lock().unwrap()[0].clone();
|
||||
assert_eq!(
|
||||
request_body(&raw),
|
||||
json!({"Document": {"Bytes": "cmVkYWN0ZWQ="}})
|
||||
);
|
||||
assert_eq!(
|
||||
header(&raw, "authorization"),
|
||||
Some(expected_authorization(&format!("{base}/"), &raw).as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_multi_page_rejection_reaches_the_caller_with_the_single_page_limit() {
|
||||
let (base, _, server) = mock_server(vec![MockResponse {
|
||||
status: 400,
|
||||
headers: vec![],
|
||||
body: json!({
|
||||
"__type": "UnsupportedDocumentException",
|
||||
"Message": "Request has unsupported document format"
|
||||
}),
|
||||
}])
|
||||
.await;
|
||||
|
||||
let error = perform_ocr_with(LocalOcrHost::new(textract_request(&base)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
server.await.unwrap();
|
||||
|
||||
let Error::Provider { status, body, .. } = error else {
|
||||
panic!("expected a provider error, got {error:?}");
|
||||
};
|
||||
assert_eq!(status, 400);
|
||||
assert!(
|
||||
body.contains("multi-page documents are not supported"),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analyze_document_asks_for_layout_and_tables_and_returns_markdown() {
|
||||
let (base, seen, server) = mock_server(vec![MockResponse::json(json!({
|
||||
"DocumentMetadata": {"Pages": 1},
|
||||
"Blocks": [
|
||||
{"Id": "l1", "BlockType": "LINE", "Text": "Quarterly Report"},
|
||||
{"Id": "t", "BlockType": "LAYOUT_TITLE",
|
||||
"Relationships": [{"Type": "CHILD", "Ids": ["l1"]}]}
|
||||
]
|
||||
}))])
|
||||
.await;
|
||||
let request = textract_request_for("aws_textract/analyze-document", &base);
|
||||
|
||||
let response = perform_ocr_with(LocalOcrHost::new(request)).await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
let raw = seen.lock().unwrap()[0].clone();
|
||||
assert_eq!(
|
||||
header(&raw, "x-amz-target"),
|
||||
Some("Textract.AnalyzeDocument")
|
||||
);
|
||||
assert_eq!(
|
||||
request_body(&raw)["FeatureTypes"],
|
||||
json!(["LAYOUT", "TABLES"])
|
||||
);
|
||||
assert_eq!(
|
||||
header(&raw, "authorization"),
|
||||
Some(expected_authorization(&format!("{base}/"), &raw).as_str())
|
||||
);
|
||||
assert_eq!(response.pages[0].markdown, "# Quarterly Report");
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
use litellm_host::event::{CallEvent, MachineEvent};
|
||||
use litellm_llms::base_llm::ocr::error::Error;
|
||||
use litellm_llms::base_llm::ocr::{error::Error, settings::OcrSettings};
|
||||
use rstest::rstest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
test_support::{MockResponse, mock_server, perform_ocr, perform_ocr_with, wire_request},
|
||||
test_support::{
|
||||
MockResponse, mock_server, ocr_client, perform_ocr, perform_ocr_with, wire_request,
|
||||
},
|
||||
wire::{OcrWireRequest, decode_request},
|
||||
};
|
||||
use crate::ocr::route::LocalOcrHost;
|
||||
|
|
@ -200,6 +202,42 @@ async fn immediate_response_normalizes_pages_and_preserves_native() {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_settings_choose_the_api_version_and_the_inch_to_pixel_dpi() {
|
||||
let (base, seen, server) = mock_server(vec![MockResponse::json(json!({
|
||||
"status":"succeeded",
|
||||
"analyzeResult":{"pages":[{"pageNumber":1,"width":8.5,"height":11,"unit":"inch"}]}
|
||||
}))])
|
||||
.await;
|
||||
let client = ocr_client().with_settings(OcrSettings {
|
||||
document_intelligence_api_version: "2099-01-01".into(),
|
||||
document_intelligence_dpi: 72,
|
||||
..OcrSettings::default()
|
||||
});
|
||||
|
||||
let result = crate::ocr::client::perform(
|
||||
&client,
|
||||
wire_request("azure_ai/doc-intelligence/prebuilt-read", &base, json!({})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
let target = seen.lock().unwrap()[0]
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
query_value(&format!("{base}{target}"), "api-version").as_deref(),
|
||||
Some("2099-01-01")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&result.pages[0].dimensions).unwrap(),
|
||||
json!({"width":612,"height":792,"dpi":72})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepted_response_polls_to_success_with_only_credentials() {
|
||||
let operation = json!({"status":"succeeded","analyzeResult":{"pages":[]}});
|
||||
|
|
@ -425,13 +463,19 @@ async fn polling_deadline_bounds_retry_delay() {
|
|||
},
|
||||
])
|
||||
.await;
|
||||
let mut request = wire_request("azure_ai/doc-intelligence/prebuilt-read", &base, json!({}));
|
||||
request.transport.poll_timeout = std::time::Duration::from_millis(100);
|
||||
let request = wire_request("azure_ai/doc-intelligence/prebuilt-read", &base, json!({}));
|
||||
let client = ocr_client().with_settings(OcrSettings {
|
||||
poll_timeout: std::time::Duration::from_millis(100),
|
||||
..OcrSettings::default()
|
||||
});
|
||||
|
||||
let error = tokio::time::timeout(std::time::Duration::from_secs(1), perform_ocr(request))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
let error = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
crate::ocr::client::perform(&client, request),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
server.await.unwrap();
|
||||
assert!(error.to_string().contains("timed out"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ mod transformation {
|
|||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value = serde_json::from_slice(http.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||
let body: Value = serde_json::from_slice(http.body()).unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({
|
||||
|
|
@ -75,7 +75,7 @@ mod transformation {
|
|||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: Value = serde_json::from_slice(http.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||
let body: Value = serde_json::from_slice(http.body()).unwrap();
|
||||
assert_eq!(body["output_format"], "markdown");
|
||||
assert!(body.get("req_format").is_none());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,16 +6,15 @@ use litellm_host::{
|
|||
host::{Host, HostOp, HostResult},
|
||||
machine::{HostFailure, Machine, MachineStep},
|
||||
};
|
||||
use litellm_http::{HttpClientPool, HttpSettings, Resolution};
|
||||
use litellm_llms::{
|
||||
base_llm::ocr::{
|
||||
error::Error as OcrError,
|
||||
transformation::{LiteLLMOcrResponse, OCR_RESPONSE_MAX_BYTES, OcrTransportConfig},
|
||||
},
|
||||
custom_httpx::{
|
||||
llm_http_handler::OcrClient,
|
||||
media::{PublicDnsResolver, UrlPolicy},
|
||||
},
|
||||
use litellm_http::{
|
||||
HttpClientPool, HttpSettings, Resolution,
|
||||
media::{PublicDnsResolver, UrlPolicy},
|
||||
};
|
||||
use litellm_llms::base_llm::ocr::{
|
||||
error::Error as OcrError,
|
||||
handler::OcrClient,
|
||||
settings::OcrSettings,
|
||||
transformation::{LiteLLMOcrResponse, OCR_RESPONSE_MAX_BYTES, OcrTransportConfig},
|
||||
};
|
||||
use rstest::rstest;
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -175,6 +174,43 @@ async fn facade_retains_native_response_when_requested() {
|
|||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::plain_key(&[("MISTRAL_API_KEY", "plain")], "plain")]
|
||||
#[case::azure_key_wins(&[("MISTRAL_AZURE_API_KEY", "azure"), ("MISTRAL_API_KEY", "plain")], "azure")]
|
||||
#[case::empty_azure_key_falls_through(&[("MISTRAL_AZURE_API_KEY", ""), ("MISTRAL_API_KEY", "plain")], "plain")]
|
||||
#[tokio::test]
|
||||
async fn mistral_env_fallbacks_follow_python_through_the_injected_secret_source(
|
||||
#[case] secrets: &'static [(&'static str, &'static str)],
|
||||
#[case] expected_key: &str,
|
||||
) {
|
||||
let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await;
|
||||
let secret_base = base.clone();
|
||||
let client = ocr_client().with_secrets(Arc::new(move |name: &str| match name {
|
||||
"MISTRAL_AZURE_API_BASE" => Some(secret_base.clone()),
|
||||
"MISTRAL_API_BASE" => Some("http://127.0.0.1:9/never-read".into()),
|
||||
_ => secrets
|
||||
.iter()
|
||||
.find(|(key, _)| *key == name)
|
||||
.map(|(_, value)| value.to_string()),
|
||||
}));
|
||||
let request = decode_request(OcrWireRequest {
|
||||
model: "mistral/model".into(),
|
||||
document: json!({"type":"document_url","document_url":"data:application/pdf;base64,YWJj"}),
|
||||
api_key: None,
|
||||
api_base: None,
|
||||
custom_llm_provider: None,
|
||||
extra_headers: None,
|
||||
optional_params: Default::default(),
|
||||
input_sources: Default::default(),
|
||||
timeout_seconds: Some(2.0),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
crate::ocr::client::perform(&client, request).await.unwrap();
|
||||
server.await.unwrap();
|
||||
assert!(seen.lock().unwrap()[0].contains(&format!("authorization: Bearer {expected_key}")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ocr_client_uses_the_injected_http_pool_configuration() {
|
||||
let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await;
|
||||
|
|
@ -187,6 +223,8 @@ async fn ocr_client_uses_the_injected_http_pool_configuration() {
|
|||
&Resolution::from(&settings).config,
|
||||
UrlPolicy::default(),
|
||||
VertexAuth::default(),
|
||||
OcrSettings::default(),
|
||||
Arc::new(litellm_core_utils::settings::ProcessEnvironment),
|
||||
)
|
||||
.unwrap();
|
||||
crate::ocr::client::perform(&client, wire_request("mistral/model", &base, json!({})))
|
||||
|
|
@ -624,7 +662,7 @@ async fn read_bounded_response(response: Vec<u8>, limit: usize) -> Result<bytes:
|
|||
.unwrap();
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
litellm_llms::custom_httpx::llm_http_handler::read_response_bytes(response, limit),
|
||||
litellm_llms::base_llm::ocr::handler::read_response_bytes(response, limit),
|
||||
)
|
||||
.await;
|
||||
server.abort();
|
||||
|
|
@ -676,10 +714,7 @@ async fn oversized_error_retains_http_status_and_bounded_diagnostics_without_dra
|
|||
.await
|
||||
.unwrap_err();
|
||||
match error {
|
||||
OcrError::Transport(litellm_llms::custom_httpx::transport::Error::Http {
|
||||
status,
|
||||
body,
|
||||
}) => {
|
||||
OcrError::Transport(litellm_http::transport::Error::Http { status, body }) => {
|
||||
assert_eq!(status, 429);
|
||||
assert_eq!(body, prefix);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ use std::sync::{Arc, Mutex};
|
|||
|
||||
use futures_util::future::BoxFuture;
|
||||
use litellm_host::event::WireRequest;
|
||||
use litellm_llms::{
|
||||
base_llm::ocr::{error::Error, transformation::LiteLLMOcrResponse},
|
||||
custom_httpx::llm_http_handler::{CallHooks, OcrClient},
|
||||
use litellm_llms::base_llm::ocr::{
|
||||
error::Error,
|
||||
handler::{CallHooks, OcrClient},
|
||||
transformation::LiteLLMOcrResponse,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use litellm_auth::InputSource;
|
||||
use litellm_llms::base_llm::ocr::transformation::OcrResponseFormat;
|
||||
use litellm_llms::base_llm::ocr::{settings::OcrSettings, transformation::OcrResponseFormat};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::test_support::{MockResponse, mock_server, perform_ocr, wire_request};
|
||||
use super::test_support::{MockResponse, mock_server, ocr_client, perform_ocr, wire_request};
|
||||
|
||||
fn request_body(request: &str) -> Value {
|
||||
serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap()
|
||||
|
|
@ -48,6 +48,27 @@ async fn facade_executes_vertex_mistral_with_resolved_project_and_location() {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_project_and_location_apply_when_the_call_sets_neither() {
|
||||
let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await;
|
||||
let client = ocr_client().with_settings(OcrSettings {
|
||||
vertex_project: Some("configured-project".into()),
|
||||
vertex_location: Some("europe-west4".into()),
|
||||
..OcrSettings::default()
|
||||
});
|
||||
|
||||
crate::ocr::client::perform(
|
||||
&client,
|
||||
wire_request("vertex_ai/mistral-ocr-maas", &base, json!({})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
server.await.unwrap();
|
||||
assert!(seen.lock().unwrap()[0].starts_with(
|
||||
"POST /v1/projects/configured-project/locations/europe-west4/publishers/mistralai/models/mistral-ocr-maas:rawPredict "
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supplied_authorization_is_forwarded_without_a_static_token() {
|
||||
let (base, seen, server) = mock_server(vec![MockResponse::json(json!({"pages":[]}))]).await;
|
||||
|
|
@ -139,17 +160,16 @@ async fn adapters_build_complete_requests_and_share_mistral_normalization() {
|
|||
.prepare_request(&vertex, &client, &crate::ocr::test_support::NoHooks)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(direct_http.url().as_str(), "https://mistral.test/v1/ocr");
|
||||
assert_eq!(direct_http.url(), "https://mistral.test/v1/ocr");
|
||||
assert_eq!(
|
||||
vertex_http.url().as_str(),
|
||||
vertex_http.url(),
|
||||
"https://vertex.test/v1/projects/project-1/locations/us-central1/publishers/mistralai/models/mistral-ocr-maas:rawPredict"
|
||||
);
|
||||
for http in [&direct_http, &vertex_http] {
|
||||
assert_eq!(http.method(), reqwest::Method::POST);
|
||||
assert_eq!(http.headers()["authorization"], "Bearer test-key");
|
||||
assert_eq!(http.headers()["content-type"], "application/json");
|
||||
assert_eq!(http.timeout(), Some(&Duration::from_secs(2)));
|
||||
let body: Value = serde_json::from_slice(http.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||
assert_eq!(http.header("authorization").unwrap(), "Bearer test-key");
|
||||
assert_eq!(http.header("content-type").unwrap(), "application/json");
|
||||
assert_eq!(http.timeout(), Some(Duration::from_secs(2)));
|
||||
let body: Value = serde_json::from_slice(http.body()).unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({
|
||||
|
|
@ -229,9 +249,9 @@ mod transformation {
|
|||
.prepare_request(&vertex, &client, &crate::ocr::test_support::NoHooks)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(direct_http.url().as_str(), "https://mistral.test/v1/ocr");
|
||||
assert_eq!(direct_http.url(), "https://mistral.test/v1/ocr");
|
||||
assert_eq!(
|
||||
vertex_http.url().as_str(),
|
||||
vertex_http.url(),
|
||||
"https://vertex.test/v1/projects/project-1/locations/us-central1/publishers/mistralai/models/mistral-ocr-maas:rawPredict"
|
||||
);
|
||||
let http = if use_vertex {
|
||||
|
|
@ -239,11 +259,10 @@ mod transformation {
|
|||
} else {
|
||||
&direct_http
|
||||
};
|
||||
assert_eq!(http.method(), reqwest::Method::POST);
|
||||
assert_eq!(http.headers()["authorization"], "Bearer test-key");
|
||||
assert_eq!(http.headers()["content-type"], "application/json");
|
||||
assert_eq!(http.timeout(), Some(&Duration::from_secs(2)));
|
||||
let body: Value = serde_json::from_slice(http.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||
assert_eq!(http.header("authorization").unwrap(), "Bearer test-key");
|
||||
assert_eq!(http.header("content-type").unwrap(), "application/json");
|
||||
assert_eq!(http.timeout(), Some(Duration::from_secs(2)));
|
||||
let body: Value = serde_json::from_slice(http.body()).unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({
|
||||
|
|
|
|||
|
|
@ -73,13 +73,7 @@ abort = KeyboardInterrupt('cancelled')
|
|||
let wrapped = wrap_failure(py, TEMPLATE, failure(&original)).unwrap_err();
|
||||
assert!(wrapped.is_instance_of::<PyRuntimeError>(py));
|
||||
assert!(wrapped.cause(py).unwrap().value(py).is(&original));
|
||||
assert!(
|
||||
wrapped
|
||||
.value(py)
|
||||
.getattr("__context__")
|
||||
.unwrap()
|
||||
.is(&original)
|
||||
);
|
||||
assert!(wrapped.context(py).unwrap().value(py).is(&original));
|
||||
assert_eq!(
|
||||
wrapped.value(py).str().unwrap().to_str().unwrap(),
|
||||
"Failed to reach the caller: unavailable"
|
||||
|
|
@ -115,13 +109,7 @@ original = Unformattable('cannot render')
|
|||
let original = raised(&locals, "original");
|
||||
let error = wrap_failure(py, TEMPLATE, failure(&original)).unwrap_err();
|
||||
assert!(error.is_instance_of::<pyo3::exceptions::PyValueError>(py));
|
||||
assert!(
|
||||
error
|
||||
.value(py)
|
||||
.getattr("__context__")
|
||||
.unwrap()
|
||||
.is(&original)
|
||||
);
|
||||
assert!(error.context(py).unwrap().value(py).is(&original));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -445,14 +445,8 @@ where
|
|||
Ok(failure) => return failure.into(),
|
||||
Err(classifier_error) => classifier_error,
|
||||
};
|
||||
let attached = classifier_error.value(py).setattr(
|
||||
"__context__",
|
||||
PyRuntimeError::new_err(native).into_value(py),
|
||||
);
|
||||
match attached {
|
||||
Ok(()) => classifier_error,
|
||||
Err(error) => error,
|
||||
}
|
||||
classifier_error.set_context(py, Some(PyRuntimeError::new_err(native)));
|
||||
classifier_error
|
||||
}
|
||||
|
||||
fn succeeded(&mut self, py: Python<'_>, response: Py<PyAny>) -> PyResult<ExecutionStep> {
|
||||
|
|
@ -1071,9 +1065,9 @@ sys.modules.setdefault('litellm.rust_bridge', types.ModuleType('litellm.rust_bri
|
|||
let error = result.unwrap_err();
|
||||
assert!(error.is_instance_of::<pyo3::exceptions::PyTypeError>(py));
|
||||
assert_eq!(error.value(py).to_string(), "classifier failed");
|
||||
let context = error.value(py).getattr("__context__").unwrap();
|
||||
assert!(context.is_instance_of::<PyRuntimeError>());
|
||||
assert_eq!(context.str().unwrap().to_string(), "provider exploded");
|
||||
let context = error.context(py).unwrap();
|
||||
assert!(context.is_instance_of::<PyRuntimeError>(py));
|
||||
assert_eq!(context.value(py).to_string(), "provider exploded");
|
||||
assert_eq!(
|
||||
log,
|
||||
[
|
||||
|
|
@ -1186,20 +1180,12 @@ sys.modules.setdefault('litellm.rust_bridge', types.ModuleType('litellm.rust_bri
|
|||
type Failure = Classified;
|
||||
fn invoke(
|
||||
&mut self,
|
||||
py: Python<'_>,
|
||||
_: Python<'_>,
|
||||
_: &Bound<'_, PyDict>,
|
||||
_: &'static str,
|
||||
) -> Result<String, InvokeError<Error>> {
|
||||
self.0.push("route");
|
||||
Err(PyErr::from_value(
|
||||
py.import("asyncio")
|
||||
.unwrap()
|
||||
.getattr("CancelledError")
|
||||
.unwrap()
|
||||
.call0()
|
||||
.unwrap(),
|
||||
)
|
||||
.into())
|
||||
Err(pyo3::exceptions::asyncio::CancelledError::new_err(()).into())
|
||||
}
|
||||
fn chunk(
|
||||
&mut self,
|
||||
|
|
|
|||
|
|
@ -5,12 +5,20 @@ edition.workspace = true
|
|||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
http.workspace = true
|
||||
litellm-core-utils.workspace = true
|
||||
hyper-util.workspace = true
|
||||
reqwest.workspace = true
|
||||
rustls.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
veil.workspace = true
|
||||
webpki-roots.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use std::{
|
|||
|
||||
use crate::{
|
||||
error::Error,
|
||||
proxy::EnvironmentProxies,
|
||||
settings::{HttpSettings, SslVerify, TcpKeepalive},
|
||||
tls::{CipherSelection, KeyExchangeGroup, Tls12CipherSuite, Unsupported},
|
||||
};
|
||||
|
|
@ -26,7 +27,7 @@ pub struct HttpClientConfig {
|
|||
pub force_ipv4: bool,
|
||||
pub http2: bool,
|
||||
pub user_agent: Option<String>,
|
||||
pub trust_proxy_env: bool,
|
||||
pub proxies: EnvironmentProxies,
|
||||
pub connect_timeout: Duration,
|
||||
pub tcp_keepalive: Option<TcpKeepalive>,
|
||||
pub pool_idle_timeout: Duration,
|
||||
|
|
@ -72,7 +73,11 @@ impl From<&HttpSettings> for Resolution {
|
|||
force_ipv4: settings.force_ipv4,
|
||||
http2: settings.http2,
|
||||
user_agent: settings.user_agent.clone(),
|
||||
trust_proxy_env: settings.trust_proxy_env,
|
||||
proxies: if settings.trust_proxy_env {
|
||||
settings.proxies.clone()
|
||||
} else {
|
||||
EnvironmentProxies::default()
|
||||
},
|
||||
connect_timeout: settings.connect_timeout,
|
||||
tcp_keepalive: settings.tcp_keepalive,
|
||||
pool_idle_timeout: settings.pool_idle_timeout,
|
||||
|
|
@ -111,11 +116,11 @@ impl TryFrom<&HttpClientConfig> for reqwest::ClientBuilder {
|
|||
Some(agent) => with_protocol.user_agent(agent),
|
||||
None => with_protocol,
|
||||
};
|
||||
Ok(if config.trust_proxy_env {
|
||||
with_agent
|
||||
} else {
|
||||
with_agent.no_proxy()
|
||||
})
|
||||
Ok(config
|
||||
.proxies
|
||||
.reqwest_proxies()
|
||||
.into_iter()
|
||||
.fold(with_agent.no_proxy(), reqwest::ClientBuilder::proxy))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -227,6 +232,25 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
fn proxies() -> EnvironmentProxies {
|
||||
EnvironmentProxies::from_environment(&|name: &str| {
|
||||
(name == "HTTPS_PROXY").then(|| "http://proxy.corp:3128".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxies_are_dropped_when_the_transport_does_not_trust_the_environment() {
|
||||
let settings = HttpSettings {
|
||||
trust_proxy_env: false,
|
||||
proxies: proxies(),
|
||||
..HttpSettings::default()
|
||||
};
|
||||
assert_eq!(
|
||||
Resolution::from(&settings).config.proxies,
|
||||
EnvironmentProxies::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_settings_carry_over_unchanged() {
|
||||
let keepalive = TcpKeepalive {
|
||||
|
|
@ -240,6 +264,7 @@ mod tests {
|
|||
http2: true,
|
||||
user_agent: Some("litellm/1.0".into()),
|
||||
trust_proxy_env: true,
|
||||
proxies: proxies(),
|
||||
connect_timeout: Duration::from_secs(7),
|
||||
tcp_keepalive: Some(keepalive),
|
||||
pool_idle_timeout: Duration::from_secs(45),
|
||||
|
|
@ -256,7 +281,7 @@ mod tests {
|
|||
force_ipv4: true,
|
||||
http2: true,
|
||||
user_agent: Some("litellm/1.0".into()),
|
||||
trust_proxy_env: true,
|
||||
proxies: proxies(),
|
||||
connect_timeout: Duration::from_secs(7),
|
||||
tcp_keepalive: Some(keepalive),
|
||||
pool_idle_timeout: Duration::from_secs(45),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ pub enum Error {
|
|||
InvalidPem { path: PathBuf, message: String },
|
||||
#[error("could not build the HTTP client: {0}")]
|
||||
Client(String),
|
||||
#[error("request body could not be serialized: {0}")]
|
||||
RequestBody(String),
|
||||
#[error("request forwards a header the signer computes: {0}")]
|
||||
ComputedHeader(String),
|
||||
#[error("request signing failed: {0}")]
|
||||
Signature(String),
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
mod config;
|
||||
mod error;
|
||||
pub mod media;
|
||||
pub mod outbound;
|
||||
mod pool;
|
||||
mod proxy;
|
||||
pub mod request;
|
||||
mod settings;
|
||||
mod tls;
|
||||
pub mod transport;
|
||||
|
||||
pub use config::{HttpClientConfig, Resolution, Verify};
|
||||
pub use error::Error;
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ use std::{
|
|||
time::Duration,
|
||||
};
|
||||
|
||||
use litellm_http::{ClientVariant, EnvironmentProxies, HttpClientConfig, HttpClientPool};
|
||||
use reqwest::{
|
||||
Url,
|
||||
dns::{Addrs, Name, Resolve, Resolving},
|
||||
};
|
||||
|
||||
use crate::{ClientVariant, HttpClientConfig, HttpClientPool};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("media URL rejected by network policy")]
|
||||
|
|
@ -32,7 +33,7 @@ pub enum Error {
|
|||
#[error("media download timed out")]
|
||||
Timeout,
|
||||
#[error("{0}")]
|
||||
Transport(#[from] crate::custom_httpx::transport::Error),
|
||||
Transport(#[from] crate::transport::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -101,13 +102,8 @@ impl MediaFetcher {
|
|||
pool: &HttpClientPool,
|
||||
config: &HttpClientConfig,
|
||||
url_policy: UrlPolicy,
|
||||
) -> Result<Self, litellm_http::Error> {
|
||||
let uses_proxy: ProxyMatch = if config.trust_proxy_env {
|
||||
let proxies = EnvironmentProxies::from_environment();
|
||||
Arc::new(move |url| proxies.apply_to(url))
|
||||
} else {
|
||||
Arc::new(|_| false)
|
||||
};
|
||||
) -> Result<Self, crate::Error> {
|
||||
let uses_proxy: ProxyMatch = Arc::new(config.proxies.matcher());
|
||||
Self::with_resolution(
|
||||
pool,
|
||||
config,
|
||||
|
|
@ -123,7 +119,7 @@ impl MediaFetcher {
|
|||
url_policy: UrlPolicy,
|
||||
address_resolver: Arc<dyn AddressResolver>,
|
||||
uses_proxy: ProxyMatch,
|
||||
) -> Result<Self, litellm_http::Error> {
|
||||
) -> Result<Self, crate::Error> {
|
||||
Ok(Self {
|
||||
pinned: pool.client(config, ClientVariant::Media)?,
|
||||
unpinned: pool.client(config, ClientVariant::UnpinnedMedia)?,
|
||||
|
|
@ -168,7 +164,7 @@ impl MediaFetcher {
|
|||
.get(url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(crate::custom_httpx::transport::Error::from)?;
|
||||
.map_err(crate::transport::Error::from)?;
|
||||
if response.status().is_redirection() {
|
||||
if redirects_followed == policy.max_redirects {
|
||||
return Err(Error::TooManyRedirects);
|
||||
|
|
@ -199,7 +195,7 @@ impl MediaFetcher {
|
|||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(crate::custom_httpx::transport::Error::from)?
|
||||
.map_err(crate::transport::Error::from)?
|
||||
{
|
||||
enforce_download_size(bytes.len() as u64 + chunk.len() as u64, policy.max_bytes)?;
|
||||
bytes.extend_from_slice(&chunk);
|
||||
|
|
@ -249,7 +245,7 @@ impl MediaFetcher {
|
|||
.address_resolver
|
||||
.resolve(host, port)
|
||||
.await
|
||||
.map_err(|error| crate::custom_httpx::transport::Error::Network(error.to_string()))?;
|
||||
.map_err(|error| crate::transport::Error::Network(error.to_string()))?;
|
||||
validate_addresses(&addresses)
|
||||
}
|
||||
}
|
||||
|
|
@ -350,13 +346,13 @@ impl Resolve for PublicDnsResolver {
|
|||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use litellm_http::{HttpSettings, Resolution};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::{HttpSettings, Resolution};
|
||||
|
||||
async fn serve(response: &'static [u8]) -> (Url, tokio::task::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
|
|
@ -443,10 +439,7 @@ mod tests {
|
|||
url_policy: UrlPolicy,
|
||||
uses_proxy: bool,
|
||||
) -> MediaFetcher {
|
||||
let direct = HttpClientConfig {
|
||||
trust_proxy_env: false,
|
||||
..Resolution::from(&HttpSettings::default()).config
|
||||
};
|
||||
let direct = Resolution::from(&HttpSettings::default()).config;
|
||||
MediaFetcher::with_resolution(
|
||||
&HttpClientPool::new(Arc::new(LoopbackDnsResolver(pinned_address))),
|
||||
&direct,
|
||||
210
litellm-rust/crates/http/src/outbound.rs
Normal file
210
litellm-rust/crates/http/src/outbound.rs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
//! The request a route hands to the transport. The body is serialized once,
|
||||
//! when the request is built, and a [`RequestSigner`] sees those exact bytes.
|
||||
//!
|
||||
//! Host hooks may rewrite the wire request (redaction, guardrails) and a
|
||||
//! signature such as AWS SigV4 covers the body, so a route builds this after
|
||||
//! its hooks ran and cannot change or re-serialize it afterwards.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
request::{HeaderPolicy, has_header, with_headers},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct UnsignedRequest<'a> {
|
||||
pub url: &'a str,
|
||||
pub headers: &'a [(String, String)],
|
||||
pub body: &'a [u8],
|
||||
}
|
||||
|
||||
/// Returns the headers to add to the request; it never sees a mutable request.
|
||||
pub trait RequestSigner: Send + Sync {
|
||||
fn sign(&self, request: UnsignedRequest<'_>) -> Result<Vec<(String, String)>, Error>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OutboundRequest {
|
||||
url: String,
|
||||
headers: Vec<(String, String)>,
|
||||
body: Vec<u8>,
|
||||
timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl OutboundRequest {
|
||||
pub fn json(
|
||||
url: String,
|
||||
headers: Vec<(String, String)>,
|
||||
body: &impl Serialize,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<Self, Error> {
|
||||
Self::build(url, headers, body, timeout, None)
|
||||
}
|
||||
|
||||
pub fn signed_json(
|
||||
url: String,
|
||||
headers: Vec<(String, String)>,
|
||||
body: &impl Serialize,
|
||||
timeout: Option<Duration>,
|
||||
signer: &dyn RequestSigner,
|
||||
) -> Result<Self, Error> {
|
||||
Self::build(url, headers, body, timeout, Some(signer))
|
||||
}
|
||||
|
||||
fn build(
|
||||
url: String,
|
||||
headers: Vec<(String, String)>,
|
||||
body: &impl Serialize,
|
||||
timeout: Option<Duration>,
|
||||
signer: Option<&dyn RequestSigner>,
|
||||
) -> Result<Self, Error> {
|
||||
let body =
|
||||
serde_json::to_vec(body).map_err(|error| Error::RequestBody(error.to_string()))?;
|
||||
let content_type = (!has_header(&headers, "content-type"))
|
||||
.then(|| ("content-type".to_string(), "application/json".to_string()));
|
||||
let unsigned: Vec<(String, String)> = headers.into_iter().chain(content_type).collect();
|
||||
let signature = signer
|
||||
.map(|signer| {
|
||||
signer.sign(UnsignedRequest {
|
||||
url: &url,
|
||||
headers: &unsigned,
|
||||
body: &body,
|
||||
})
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
url,
|
||||
headers: unsigned.into_iter().chain(signature).collect(),
|
||||
body,
|
||||
timeout,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
pub fn headers(&self) -> &[(String, String)] {
|
||||
&self.headers
|
||||
}
|
||||
|
||||
pub fn header(&self, name: &str) -> Option<&str> {
|
||||
self.headers
|
||||
.iter()
|
||||
.find(|(key, _)| key.eq_ignore_ascii_case(name))
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
|
||||
pub fn body(&self) -> &[u8] {
|
||||
&self.body
|
||||
}
|
||||
|
||||
pub fn timeout(&self) -> Option<Duration> {
|
||||
self.timeout
|
||||
}
|
||||
|
||||
pub async fn send(self, client: &reqwest::Client) -> Result<reqwest::Response, reqwest::Error> {
|
||||
let builder = with_headers(
|
||||
client.post(&self.url).body(self.body),
|
||||
&self.headers,
|
||||
HeaderPolicy::All,
|
||||
);
|
||||
match self.timeout {
|
||||
Some(timeout) => builder.timeout(timeout),
|
||||
None => builder,
|
||||
}
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Recording(Mutex<Vec<u8>>);
|
||||
|
||||
impl RequestSigner for Recording {
|
||||
fn sign(&self, request: UnsignedRequest<'_>) -> Result<Vec<(String, String)>, Error> {
|
||||
*self.0.lock().unwrap() = request.body.to_vec();
|
||||
Ok(vec![("authorization".into(), "signed".into())])
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_signer_sees_exactly_the_bytes_that_are_sent() {
|
||||
let signer = Recording::default();
|
||||
let request = OutboundRequest::signed_json(
|
||||
"https://provider.test/".into(),
|
||||
vec![("x-caller".into(), "kept".into())],
|
||||
&json!({"b": 1, "a": [true, null]}),
|
||||
None,
|
||||
&signer,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(request.body(), signer.0.lock().unwrap().as_slice());
|
||||
assert_eq!(request.header("authorization"), Some("signed"));
|
||||
assert_eq!(request.header("x-caller"), Some("kept"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_content_type_is_part_of_what_the_signer_sees() {
|
||||
struct RequiresContentType;
|
||||
impl RequestSigner for RequiresContentType {
|
||||
fn sign(&self, request: UnsignedRequest<'_>) -> Result<Vec<(String, String)>, Error> {
|
||||
has_header(request.headers, "content-type")
|
||||
.then(Vec::new)
|
||||
.ok_or_else(|| Error::Signature("content-type was not signed".into()))
|
||||
}
|
||||
}
|
||||
|
||||
let defaulted = OutboundRequest::signed_json(
|
||||
"u".into(),
|
||||
Vec::new(),
|
||||
&json!({}),
|
||||
None,
|
||||
&RequiresContentType,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(defaulted.header("content-type"), Some("application/json"));
|
||||
|
||||
let provider = OutboundRequest::signed_json(
|
||||
"u".into(),
|
||||
vec![("Content-Type".into(), "application/x-amz-json-1.1".into())],
|
||||
&json!({}),
|
||||
None,
|
||||
&RequiresContentType,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
provider.header("content-type"),
|
||||
Some("application/x-amz-json-1.1")
|
||||
);
|
||||
assert_eq!(provider.headers().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signer_failure_produces_no_request() {
|
||||
struct Refuses;
|
||||
impl RequestSigner for Refuses {
|
||||
fn sign(&self, _request: UnsignedRequest<'_>) -> Result<Vec<(String, String)>, Error> {
|
||||
Err(Error::ComputedHeader("authorization".into()))
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
OutboundRequest::signed_json("u".into(), Vec::new(), &json!({}), None, &Refuses),
|
||||
Err(Error::ComputedHeader("authorization".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ use std::{
|
|||
|
||||
use reqwest::dns::Resolve;
|
||||
|
||||
use crate::{config::HttpClientConfig, error::Error};
|
||||
use crate::{config::HttpClientConfig, error::Error, proxy::EnvironmentProxies};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ClientVariant {
|
||||
|
|
@ -52,7 +52,7 @@ impl HttpClientPool {
|
|||
let effective = match variant {
|
||||
ClientVariant::Media => HttpClientConfig {
|
||||
client_certificate: None,
|
||||
trust_proxy_env: false,
|
||||
proxies: EnvironmentProxies::default(),
|
||||
..config.clone()
|
||||
},
|
||||
ClientVariant::UnpinnedMedia => HttpClientConfig {
|
||||
|
|
@ -138,6 +138,13 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn proxied_through(proxy: &str) -> EnvironmentProxies {
|
||||
let proxy = proxy.to_owned();
|
||||
EnvironmentProxies::from_environment(&move |name: &str| {
|
||||
(name == "HTTP_PROXY").then(|| proxy.clone())
|
||||
})
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
status_line: &'static str,
|
||||
) -> (SocketAddr, Arc<AtomicUsize>, Arc<Mutex<Vec<String>>>) {
|
||||
|
|
@ -202,6 +209,50 @@ mod tests {
|
|||
assert_eq!(connections.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_clients_route_through_the_resolved_proxy_not_the_process_environment() {
|
||||
let (proxy, connections, requests) = serve("HTTP/1.1 204 No Content").await;
|
||||
let config = HttpClientConfig {
|
||||
proxies: proxied_through(&format!("http://user:secret@{proxy}")),
|
||||
..config("a")
|
||||
};
|
||||
let response = get(
|
||||
&pool(),
|
||||
&config,
|
||||
ClientVariant::Provider,
|
||||
"http://upstream.invalid/v1/ocr",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 204);
|
||||
assert_eq!(connections.load(Ordering::SeqCst), 1);
|
||||
let request = requests.lock().unwrap().concat();
|
||||
assert!(request.starts_with("GET http://upstream.invalid/v1/ocr HTTP/1.1"));
|
||||
assert!(request.contains("proxy-authorization: Basic dXNlcjpzZWNyZXQ="));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_proxy_hosts_bypass_the_resolved_proxy() {
|
||||
let (upstream, _, _) = serve("HTTP/1.1 204 No Content").await;
|
||||
let (proxy, proxy_connections, _) = serve("HTTP/1.1 502 Bad Gateway").await;
|
||||
let config = HttpClientConfig {
|
||||
proxies: EnvironmentProxies::from_environment(&move |name: &str| match name {
|
||||
"HTTP_PROXY" => Some(format!("http://{proxy}")),
|
||||
"NO_PROXY" => Some("127.0.0.1".into()),
|
||||
_ => None,
|
||||
}),
|
||||
..config("a")
|
||||
};
|
||||
let response = get(
|
||||
&pool(),
|
||||
&config,
|
||||
ClientVariant::Provider,
|
||||
&format!("http://{upstream}/v1/ocr"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 204);
|
||||
assert_eq!(proxy_connections.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_clients_are_rebuilt() {
|
||||
let (address, connections, _) = serve("HTTP/1.1 204 No Content").await;
|
||||
|
|
@ -220,9 +271,12 @@ mod tests {
|
|||
let (address, connections, _) = serve("HTTP/1.1 204 No Content").await;
|
||||
let pool = HttpClientPool::new(Arc::new(FixedResolver(address)));
|
||||
let url = format!("http://media.invalid:{}/doc", address.port());
|
||||
for trust_proxy_env in [true, false] {
|
||||
for proxies in [
|
||||
proxied_through("http://proxy.invalid:3128"),
|
||||
EnvironmentProxies::default(),
|
||||
] {
|
||||
let config = HttpClientConfig {
|
||||
trust_proxy_env,
|
||||
proxies,
|
||||
..config("a")
|
||||
};
|
||||
get(&pool, &config, ClientVariant::Media, &url).await;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,164 @@
|
|||
use hyper_util::client::proxy::matcher::Matcher;
|
||||
use litellm_core_utils::settings::Lookup;
|
||||
use veil::Redact;
|
||||
|
||||
pub struct EnvironmentProxies(Matcher);
|
||||
#[derive(Clone, Redact, Default, PartialEq, Eq, Hash)]
|
||||
pub struct EnvironmentProxies {
|
||||
#[redact]
|
||||
all: String,
|
||||
#[redact]
|
||||
http: String,
|
||||
#[redact]
|
||||
https: String,
|
||||
no: String,
|
||||
}
|
||||
|
||||
impl EnvironmentProxies {
|
||||
pub fn from_environment() -> Self {
|
||||
Self(Matcher::from_system())
|
||||
pub fn from_environment(env: &impl Lookup) -> Self {
|
||||
Self::resolve(env, cfg!(windows))
|
||||
}
|
||||
|
||||
pub fn apply_to(&self, url: &reqwest::Url) -> bool {
|
||||
url.as_str()
|
||||
.parse::<http::Uri>()
|
||||
.is_ok_and(|uri| self.0.intercept(&uri).is_some())
|
||||
fn resolve(env: &impl Lookup, names_ignore_case: bool) -> Self {
|
||||
let lowercase_first = |upper: Option<&str>, lower: &str| {
|
||||
env.get(lower)
|
||||
.or_else(|| upper.and_then(|name| env.truthy(name)))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let is_cgi = env.get("REQUEST_METHOD").is_some();
|
||||
Self {
|
||||
all: lowercase_first(Some("ALL_PROXY"), "all_proxy"),
|
||||
http: if is_cgi && names_ignore_case {
|
||||
String::new()
|
||||
} else {
|
||||
lowercase_first((!is_cgi).then_some("HTTP_PROXY"), "http_proxy")
|
||||
},
|
||||
https: lowercase_first(Some("HTTPS_PROXY"), "https_proxy"),
|
||||
no: lowercase_first(Some("NO_PROXY"), "no_proxy"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn matcher(&self) -> impl Fn(&reqwest::Url) -> bool + Send + Sync + use<> {
|
||||
let matcher = Matcher::builder()
|
||||
.all(self.all.clone())
|
||||
.http(self.http.clone())
|
||||
.https(self.https.clone())
|
||||
.no(self.no.clone())
|
||||
.build();
|
||||
move |url| {
|
||||
url.as_str()
|
||||
.parse::<http::Uri>()
|
||||
.is_ok_and(|uri| matcher.intercept(&uri).is_some())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reqwest_proxies(&self) -> Vec<reqwest::Proxy> {
|
||||
let no_proxy = reqwest::NoProxy::from_string(&self.no);
|
||||
[
|
||||
reqwest::Proxy::http(self.http.as_str()),
|
||||
reqwest::Proxy::https(self.https.as_str()),
|
||||
reqwest::Proxy::all(self.all.as_str()),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.map(|proxy| proxy.no_proxy(no_proxy.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn env_of(values: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
|
||||
move |name| {
|
||||
values
|
||||
.iter()
|
||||
.find(|(key, _)| *key == name)
|
||||
.map(|(_, value)| value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn url(value: &str) -> reqwest::Url {
|
||||
reqwest::Url::parse(value).unwrap()
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::http_only(&[("HTTP_PROXY", "http://proxy:3128")], "http://api.test/", true)]
|
||||
#[case::http_proxy_skips_https(&[("HTTP_PROXY", "http://proxy:3128")], "https://api.test/", false)]
|
||||
#[case::all_covers_https(&[("ALL_PROXY", "http://proxy:3128")], "https://api.test/", true)]
|
||||
#[case::lowercase(&[("https_proxy", "http://proxy:3128")], "https://api.test/", true)]
|
||||
#[case::no_proxy_bypass(&[("HTTPS_PROXY", "http://proxy:3128"), ("NO_PROXY", "api.test")], "https://api.test/", false)]
|
||||
fn proxies_follow_the_injected_environment(
|
||||
#[case] env: &'static [(&'static str, &'static str)],
|
||||
#[case] target: &str,
|
||||
#[case] expected: bool,
|
||||
) {
|
||||
let proxies = EnvironmentProxies::from_environment(&env_of(env));
|
||||
assert_eq!(proxies.matcher()(&url(target)), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::lowercase_wins(&[("HTTPS_PROXY", "http://upper:3128"), ("https_proxy", "http://lower:3128")], &[("https_proxy", "http://lower:3128")])]
|
||||
#[case::empty_uppercase_falls_through_to_lowercase(&[("HTTPS_PROXY", ""), ("https_proxy", "http://lower:3128")], &[("https_proxy", "http://lower:3128")])]
|
||||
#[case::empty_uppercase_alone_is_unset(&[("HTTPS_PROXY", "")], &[])]
|
||||
#[case::empty_lowercase_clears_the_uppercase_value(&[("HTTPS_PROXY", "http://upper:3128"), ("https_proxy", "")], &[])]
|
||||
#[case::lowercase_no_proxy_wins(&[("NO_PROXY", "upper.test"), ("no_proxy", "lower.test")], &[("no_proxy", "lower.test")])]
|
||||
#[case::cgi_forgets_the_client_settable_http_proxy(&[("REQUEST_METHOD", "GET"), ("HTTP_PROXY", "http://attacker:3128")], &[])]
|
||||
#[case::cgi_keeps_lowercase_http_proxy(&[("REQUEST_METHOD", "GET"), ("HTTP_PROXY", "http://attacker:3128"), ("http_proxy", "http://lower:3128")], &[("http_proxy", "http://lower:3128")])]
|
||||
#[case::cgi_keeps_every_other_variable(&[("REQUEST_METHOD", "GET"), ("HTTPS_PROXY", "http://proxy:3128"), ("ALL_PROXY", "http://all:3128"), ("NO_PROXY", "internal.test")], &[("HTTPS_PROXY", "http://proxy:3128"), ("ALL_PROXY", "http://all:3128"), ("NO_PROXY", "internal.test")])]
|
||||
fn variables_resolve_like_urllib_getproxies_environment(
|
||||
#[case] env: &'static [(&'static str, &'static str)],
|
||||
#[case] equivalent: &'static [(&'static str, &'static str)],
|
||||
) {
|
||||
assert_eq!(
|
||||
EnvironmentProxies::from_environment(&env_of(env)),
|
||||
EnvironmentProxies::from_environment(&env_of(equivalent))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cgi_drops_http_proxy_entirely_where_variable_names_ignore_case() {
|
||||
let windows_env = |name: &str| match name.to_ascii_uppercase().as_str() {
|
||||
"REQUEST_METHOD" => Some("GET".to_string()),
|
||||
"HTTP_PROXY" => Some("http://attacker:3128".to_string()),
|
||||
"HTTPS_PROXY" => Some("http://proxy:3128".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let proxies = EnvironmentProxies::resolve(&windows_env, true);
|
||||
assert!(!proxies.matcher()(&url("http://api.test/")));
|
||||
assert!(proxies.matcher()(&url("https://api.test/")));
|
||||
assert!(EnvironmentProxies::resolve(&windows_env, false).matcher()(
|
||||
&url("http://api.test/")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cgi_request_still_proxies_https_through_the_configured_proxy() {
|
||||
let proxies = EnvironmentProxies::from_environment(&env_of(&[
|
||||
("REQUEST_METHOD", "GET"),
|
||||
("HTTPS_PROXY", "http://proxy:3128"),
|
||||
]));
|
||||
assert!(proxies.matcher()(&url("https://api.test/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_output_hides_proxy_credentials_but_shows_which_variables_are_set() {
|
||||
let proxies = EnvironmentProxies::from_environment(&env_of(&[
|
||||
("HTTPS_PROXY", "http://operator:hunter2@proxy.corp:3128"),
|
||||
("NO_PROXY", "internal.test"),
|
||||
]));
|
||||
let debug = format!("{proxies:?}");
|
||||
assert!(!debug.contains("hunter2") && !debug.contains("operator"));
|
||||
assert!(debug.contains("internal.test"));
|
||||
assert_ne!(debug, format!("{:?}", EnvironmentProxies::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_environment_proxies_nothing() {
|
||||
let proxies = EnvironmentProxies::from_environment(&env_of(&[]));
|
||||
assert_eq!(proxies, EnvironmentProxies::default());
|
||||
assert!(proxies.reqwest_proxies().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,20 +13,12 @@ use serde_json::{Map, Value};
|
|||
/// before truncation, so provider bodies are bounded and data-minimized.
|
||||
const UPSTREAM_ERROR_BODY_MAX_CHARS: usize = 256;
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "used by the OCR architecture in the next stacked PR"
|
||||
)]
|
||||
pub enum HeaderPolicy<'a> {
|
||||
All,
|
||||
Only(&'a [&'a str]),
|
||||
Except(&'a [&'a str]),
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "used by the OCR architecture in the next stacked PR"
|
||||
)]
|
||||
pub fn with_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
headers: &[(String, String)],
|
||||
|
|
@ -107,18 +99,6 @@ pub fn has_bearer_auth(headers: &[(String, String)]) -> bool {
|
|||
})
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "used by the OCR architecture in the next stacked PR"
|
||||
)]
|
||||
pub fn deserialize_optional_param<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: serde::Deserialize<'de>,
|
||||
{
|
||||
<Option<T> as serde::Deserialize>::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
|
@ -3,6 +3,10 @@ use std::{
|
|||
time::Duration,
|
||||
};
|
||||
|
||||
use litellm_core_utils::settings::{Layer, Lookup, merge};
|
||||
|
||||
use crate::proxy::EnvironmentProxies;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum SslVerify {
|
||||
Enabled,
|
||||
|
|
@ -42,41 +46,41 @@ pub struct HttpSettingsLayer {
|
|||
pub user_agent: Option<String>,
|
||||
pub tcp_keepalive: Option<TcpKeepalive>,
|
||||
pub pool_idle_timeout: Option<Duration>,
|
||||
pub proxies: Option<EnvironmentProxies>,
|
||||
}
|
||||
|
||||
impl HttpSettingsLayer {
|
||||
pub fn from_environment(env: &(dyn Fn(&str) -> Option<String> + Sync)) -> Self {
|
||||
let enabled = |name: &str| {
|
||||
env(name)
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("true"))
|
||||
.then_some(true)
|
||||
};
|
||||
let number = |name: &str| env(name).and_then(|value| value.trim().parse::<u32>().ok());
|
||||
pub fn from_environment(env: &impl Lookup) -> Self {
|
||||
let seconds = |name: &str, default: u32| {
|
||||
Duration::from_secs(u64::from(number(name).unwrap_or(default)))
|
||||
Duration::from_secs(u64::from(env.parsed::<u32>(name).unwrap_or(default)))
|
||||
};
|
||||
Self {
|
||||
ssl_verify: env("SSL_VERIFY").map(|value| SslVerify::parse(&value)),
|
||||
ssl_cert_file: env("SSL_CERT_FILE").map(PathBuf::from),
|
||||
ssl_certificate: env("SSL_CERTIFICATE").map(PathBuf::from),
|
||||
ssl_security_level: env("SSL_SECURITY_LEVEL"),
|
||||
ssl_ecdh_curve: env("SSL_ECDH_CURVE"),
|
||||
ssl_verify: env.get("SSL_VERIFY").map(|value| SslVerify::parse(&value)),
|
||||
ssl_cert_file: env.truthy("SSL_CERT_FILE").map(PathBuf::from),
|
||||
ssl_certificate: env.get("SSL_CERTIFICATE").map(PathBuf::from),
|
||||
ssl_security_level: env.get("SSL_SECURITY_LEVEL"),
|
||||
ssl_ecdh_curve: env.get("SSL_ECDH_CURVE"),
|
||||
force_ipv4: None,
|
||||
http2: enabled("LITELLM_HTTP2"),
|
||||
aiohttp_trust_env: enabled("AIOHTTP_TRUST_ENV"),
|
||||
disable_aiohttp_trust_env: enabled("DISABLE_AIOHTTP_TRUST_ENV"),
|
||||
disable_aiohttp_transport: enabled("DISABLE_AIOHTTP_TRANSPORT"),
|
||||
user_agent: env("LITELLM_USER_AGENT"),
|
||||
tcp_keepalive: enabled("AIOHTTP_SO_KEEPALIVE").map(|_| TcpKeepalive {
|
||||
http2: env.enabled("LITELLM_HTTP2"),
|
||||
aiohttp_trust_env: env.enabled("AIOHTTP_TRUST_ENV"),
|
||||
disable_aiohttp_trust_env: env.enabled("DISABLE_AIOHTTP_TRUST_ENV"),
|
||||
disable_aiohttp_transport: env.enabled("DISABLE_AIOHTTP_TRANSPORT"),
|
||||
user_agent: env.get("LITELLM_USER_AGENT"),
|
||||
tcp_keepalive: env.enabled("AIOHTTP_SO_KEEPALIVE").map(|_| TcpKeepalive {
|
||||
idle: seconds("AIOHTTP_TCP_KEEPIDLE", 60),
|
||||
interval: seconds("AIOHTTP_TCP_KEEPINTVL", 30),
|
||||
retries: number("AIOHTTP_TCP_KEEPCNT").unwrap_or(5),
|
||||
retries: env.parsed("AIOHTTP_TCP_KEEPCNT").unwrap_or(5),
|
||||
}),
|
||||
pool_idle_timeout: number("AIOHTTP_KEEPALIVE_TIMEOUT")
|
||||
pool_idle_timeout: env
|
||||
.parsed::<u32>("AIOHTTP_KEEPALIVE_TIMEOUT")
|
||||
.map(|timeout| Duration::from_secs(u64::from(timeout))),
|
||||
proxies: Some(EnvironmentProxies::from_environment(env))
|
||||
.filter(|proxies| *proxies != EnvironmentProxies::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Layer for HttpSettingsLayer {
|
||||
fn or(self, lower: Self) -> Self {
|
||||
Self {
|
||||
ssl_verify: self.ssl_verify.or(lower.ssl_verify),
|
||||
|
|
@ -96,6 +100,7 @@ impl HttpSettingsLayer {
|
|||
user_agent: self.user_agent.or(lower.user_agent),
|
||||
tcp_keepalive: self.tcp_keepalive.or(lower.tcp_keepalive),
|
||||
pool_idle_timeout: self.pool_idle_timeout.or(lower.pool_idle_timeout),
|
||||
proxies: self.proxies.or(lower.proxies),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -111,6 +116,7 @@ pub struct HttpSettings {
|
|||
pub http2: bool,
|
||||
pub user_agent: Option<String>,
|
||||
pub trust_proxy_env: bool,
|
||||
pub proxies: EnvironmentProxies,
|
||||
pub connect_timeout: Duration,
|
||||
pub tcp_keepalive: Option<TcpKeepalive>,
|
||||
pub pool_idle_timeout: Duration,
|
||||
|
|
@ -128,6 +134,7 @@ impl Default for HttpSettings {
|
|||
http2: false,
|
||||
user_agent: None,
|
||||
trust_proxy_env: true,
|
||||
proxies: EnvironmentProxies::default(),
|
||||
connect_timeout: Duration::from_secs(10),
|
||||
tcp_keepalive: None,
|
||||
pool_idle_timeout: Duration::from_secs(120),
|
||||
|
|
@ -139,10 +146,7 @@ impl HttpSettings {
|
|||
pub fn from_layers(
|
||||
highest_precedence_first: impl IntoIterator<Item = HttpSettingsLayer>,
|
||||
) -> Self {
|
||||
let merged = highest_precedence_first
|
||||
.into_iter()
|
||||
.reduce(HttpSettingsLayer::or)
|
||||
.unwrap_or_default();
|
||||
let merged = merge(highest_precedence_first);
|
||||
let defaults = Self::default();
|
||||
let http2 = merged.http2.unwrap_or(defaults.http2);
|
||||
Self {
|
||||
|
|
@ -164,6 +168,7 @@ impl HttpSettings {
|
|||
pool_idle_timeout: merged
|
||||
.pool_idle_timeout
|
||||
.unwrap_or(defaults.pool_idle_timeout),
|
||||
proxies: merged.proxies.unwrap_or_default(),
|
||||
..defaults
|
||||
}
|
||||
}
|
||||
|
|
@ -190,9 +195,7 @@ mod tests {
|
|||
None
|
||||
}
|
||||
|
||||
fn env_of(
|
||||
values: &'static [(&'static str, &'static str)],
|
||||
) -> impl Fn(&str) -> Option<String> + Sync {
|
||||
fn env_of(values: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
|
||||
move |name| {
|
||||
values
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -46,11 +46,8 @@ mod tests {
|
|||
.send()
|
||||
.await
|
||||
.expect_err("invalid port");
|
||||
let error = crate::custom_httpx::transport::Error::from_reqwest_before_dispatch(error);
|
||||
assert!(matches!(
|
||||
error,
|
||||
crate::custom_httpx::transport::Error::Connect(_)
|
||||
));
|
||||
let error = crate::transport::Error::from_reqwest_before_dispatch(error);
|
||||
assert!(matches!(error, crate::transport::Error::Connect(_)));
|
||||
assert!(!error.to_string().contains("secret"));
|
||||
assert!(!error.to_string().contains("private"));
|
||||
}
|
||||
|
|
@ -76,7 +73,7 @@ mod tests {
|
|||
.await
|
||||
.expect_err("nothing listens on the port");
|
||||
let root_cause = root_cause(&error).expect("reqwest reports a cause");
|
||||
let message = crate::custom_httpx::transport::Error::from(error).to_string();
|
||||
let message = crate::transport::Error::from(error).to_string();
|
||||
assert!(message.contains(&root_cause), "{message}");
|
||||
assert!(!message.contains("secret"));
|
||||
}
|
||||
|
|
@ -105,8 +102,8 @@ mod tests {
|
|||
let error = response.expect_err("server does not respond");
|
||||
assert!(error.is_timeout());
|
||||
assert!(matches!(
|
||||
crate::custom_httpx::transport::Error::from_reqwest_before_dispatch(error),
|
||||
crate::custom_httpx::transport::Error::Network(_)
|
||||
crate::transport::Error::from_reqwest_before_dispatch(error),
|
||||
crate::transport::Error::Network(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
litellm-llms mirrors `litellm/llms/`: base config traits, provider transformations, and the `custom_httpx` handlers. See `../core/AGENTS.md` for how the crates layer.
|
||||
litellm-llms mirrors `litellm/llms/`: base config traits, provider transformations, and the OCR request handler in `base_llm/ocr/handler.rs`. Transport code (clients, media fetching, header helpers, transport errors) lives in `litellm-http`. See `../core/AGENTS.md` for how the crates layer.
|
||||
|
||||
## Python/Rust transformation pairs
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
test-support = ["litellm-http/test-support"]
|
||||
|
||||
[dependencies]
|
||||
litellm-types.workspace = true
|
||||
|
|
@ -27,6 +27,7 @@ serde.workspace = true
|
|||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
serde_path_to_error = "0.1"
|
||||
serde_with.workspace = true
|
||||
strum.workspace = true
|
||||
thiserror.workspace = true
|
||||
time.workspace = true
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
|
|
|
|||
|
|
@ -428,7 +428,7 @@ fn resolves_the_messages_url_and_x_api_key_auth() {
|
|||
config
|
||||
.auth(Some("sk-x"), "claude-sonnet-4-5", &Map::new(), &|_| None)
|
||||
.expect("auth resolves"),
|
||||
ChatCompletionsAuth::Header {
|
||||
RequestAuth::Header {
|
||||
name: "x-api-key",
|
||||
value: "sk-x".to_string()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use crate::{
|
|||
},
|
||||
},
|
||||
base_llm::chat::transformation::{
|
||||
BaseConfig, ChatCompletionsAuth, Error, ProviderChatRequestData, ProviderChatResponseData,
|
||||
BaseConfig, Error, ProviderChatRequestData, ProviderChatResponseData, RequestAuth,
|
||||
Unsupported, unsupported_message, unsupported_param,
|
||||
},
|
||||
};
|
||||
|
|
@ -137,8 +137,8 @@ impl BaseConfig for AnthropicConfig {
|
|||
_model: &str,
|
||||
_optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<ChatCompletionsAuth, Error> {
|
||||
Ok(ChatCompletionsAuth::Header {
|
||||
) -> Result<RequestAuth, Error> {
|
||||
Ok(RequestAuth::Header {
|
||||
name: "x-api-key",
|
||||
value: resolve_anthropic_api_key(api_key, env_lookup)?,
|
||||
})
|
||||
|
|
|
|||
1
litellm-rust/crates/llms/src/aws_textract/mod.rs
Normal file
1
litellm-rust/crates/llms/src/aws_textract/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod ocr;
|
||||
12
litellm-rust/crates/llms/src/aws_textract/ocr/AGENTS.md
Normal file
12
litellm-rust/crates/llms/src/aws_textract/ocr/AGENTS.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
- https://docs.aws.amazon.com/textract/latest/APIReference/Welcome.md
|
||||
- https://docs.aws.amazon.com/textract/latest/APIReference/API_Operations.md
|
||||
- https://docs.aws.amazon.com/textract/latest/APIReference/API_DetectDocumentText.md
|
||||
- https://docs.aws.amazon.com/textract/latest/APIReference/API_AnalyzeDocument.md
|
||||
- https://docs.aws.amazon.com/textract/latest/APIReference/API_StartDocumentTextDetection.md
|
||||
- https://docs.aws.amazon.com/textract/latest/APIReference/API_Document.md
|
||||
- https://docs.aws.amazon.com/textract/latest/APIReference/API_Block.md
|
||||
- https://docs.aws.amazon.com/textract/latest/dg/what-is.md
|
||||
- https://docs.aws.amazon.com/textract/latest/dg/sync.md
|
||||
- https://docs.aws.amazon.com/textract/latest/dg/async.md
|
||||
- https://docs.aws.amazon.com/textract/latest/dg/how-it-works-document-layout.md
|
||||
- https://docs.aws.amazon.com/textract/latest/dg/limits.md
|
||||
|
|
@ -0,0 +1,479 @@
|
|||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use litellm_core_utils::call_arguments::{CallArguments, parse_options};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::common_utils::{
|
||||
Block, BlockType, FeatureType, LayoutType, TextractDocument, TextractEnvironment,
|
||||
TextractOperation, TextractResponse, document_bytes, endpoint, environment, error_class,
|
||||
health_check_document, inline_document, lines_by_page, ocr_response,
|
||||
};
|
||||
use crate::base_llm::ocr::{
|
||||
error::Error,
|
||||
handler::OcrClient,
|
||||
transformation::{
|
||||
BaseOcrConfig, LiteLLMOcrResponse, OcrDocument, OcrRequestContext, OcrResponseFormat,
|
||||
PreparedOcrRequest, decode_and_normalize_response,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_FEATURE_TYPES: [FeatureType; 2] = [FeatureType::Layout, FeatureType::Tables];
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
pub struct AnalyzeDocumentOptions {
|
||||
pub feature_types: Option<Vec<FeatureType>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct AnalyzeDocumentRequest {
|
||||
#[serde(rename = "Document")]
|
||||
pub document: TextractDocument,
|
||||
#[serde(rename = "FeatureTypes")]
|
||||
pub feature_types: Vec<FeatureType>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct TextractAnalyzeDocumentConfig;
|
||||
|
||||
impl BaseOcrConfig for TextractAnalyzeDocumentConfig {
|
||||
type OcrParams = AnalyzeDocumentOptions;
|
||||
type ProviderRequest = AnalyzeDocumentRequest;
|
||||
type Environment = TextractEnvironment;
|
||||
|
||||
fn get_supported_ocr_params(&self, _model: &str) -> &'static [&'static str] {
|
||||
&["feature_types"]
|
||||
}
|
||||
|
||||
fn get_health_check_document(&self) -> OcrDocument {
|
||||
health_check_document()
|
||||
}
|
||||
|
||||
fn map_ocr_params(
|
||||
&self,
|
||||
non_default_params: &CallArguments,
|
||||
_model: &str,
|
||||
) -> Result<AnalyzeDocumentOptions, Error> {
|
||||
Ok(parse_options(non_default_params)?)
|
||||
}
|
||||
|
||||
async fn validate_environment(
|
||||
&self,
|
||||
request: &PreparedOcrRequest,
|
||||
_client: &OcrClient,
|
||||
) -> Result<TextractEnvironment, Error> {
|
||||
environment(request, TextractOperation::AnalyzeDocument).await
|
||||
}
|
||||
|
||||
fn get_complete_url(
|
||||
&self,
|
||||
request: &PreparedOcrRequest,
|
||||
_optional_params: &AnalyzeDocumentOptions,
|
||||
environment: &TextractEnvironment,
|
||||
) -> Result<String, Error> {
|
||||
Ok(endpoint(request, environment))
|
||||
}
|
||||
|
||||
fn transform_ocr_request(
|
||||
&self,
|
||||
_model: &str,
|
||||
document: OcrDocument,
|
||||
optional_params: &AnalyzeDocumentOptions,
|
||||
_headers: &[(String, String)],
|
||||
) -> Result<AnalyzeDocumentRequest, Error> {
|
||||
Ok(AnalyzeDocumentRequest {
|
||||
document: document_bytes(&document)?,
|
||||
feature_types: optional_params
|
||||
.feature_types
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_FEATURE_TYPES.to_vec()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn async_transform_ocr_request(
|
||||
&self,
|
||||
model: &str,
|
||||
document: OcrDocument,
|
||||
optional_params: &AnalyzeDocumentOptions,
|
||||
headers: &[(String, String)],
|
||||
context: OcrRequestContext<'_>,
|
||||
) -> Result<AnalyzeDocumentRequest, Error> {
|
||||
let document = inline_document(document, context).await?;
|
||||
self.transform_ocr_request(model, document, optional_params, headers)
|
||||
}
|
||||
|
||||
fn transform_ocr_response(
|
||||
&self,
|
||||
model: &str,
|
||||
raw_response: &[u8],
|
||||
request_format: OcrResponseFormat,
|
||||
) -> Result<LiteLLMOcrResponse, Error> {
|
||||
decode_and_normalize_response(model, raw_response, request_format, normalize_response)
|
||||
}
|
||||
|
||||
fn get_error_class(
|
||||
&self,
|
||||
error_message: String,
|
||||
status_code: u16,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Error {
|
||||
error_class(error_message, status_code, headers)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_response(
|
||||
model: &str,
|
||||
response: TextractResponse,
|
||||
) -> Result<LiteLLMOcrResponse, Error> {
|
||||
let blocks = &response.blocks;
|
||||
let has_layout = blocks
|
||||
.iter()
|
||||
.any(|block| block.block_type.layout().is_some());
|
||||
let page_markdown: Vec<(i64, String)> = if has_layout {
|
||||
let by_id: HashMap<&str, &Block> = blocks
|
||||
.iter()
|
||||
.map(|block| (block.id.as_str(), block))
|
||||
.collect();
|
||||
let pages: BTreeSet<i64> = blocks.iter().map(Block::page).collect();
|
||||
pages
|
||||
.into_iter()
|
||||
.map(|page| (page, layout_markdown(blocks, page, &by_id)))
|
||||
.filter(|(_, markdown)| !markdown.is_empty())
|
||||
.collect()
|
||||
} else {
|
||||
lines_by_page(blocks)
|
||||
};
|
||||
Ok(ocr_response(
|
||||
model,
|
||||
page_markdown,
|
||||
response.document_metadata,
|
||||
))
|
||||
}
|
||||
|
||||
/// Layout blocks arrive in reading order. A list's items are repeated as
|
||||
/// top-level `LAYOUT_TEXT` blocks. A `LAYOUT_TABLE` that links to its `TABLE`
|
||||
/// renders it; one that only links to the table's lines takes the `TABLE` at
|
||||
/// the same position on the page.
|
||||
fn layout_markdown(blocks: &[Block], page: i64, by_id: &HashMap<&str, &Block>) -> String {
|
||||
let on_page = || blocks.iter().filter(move |block| block.page() == page);
|
||||
let list_items: BTreeSet<&str> = on_page()
|
||||
.filter(|block| block.block_type == BlockType::LayoutList)
|
||||
.flat_map(Block::children)
|
||||
.collect();
|
||||
let tables: Vec<&Block> = on_page()
|
||||
.filter(|block| block.block_type == BlockType::Table)
|
||||
.collect();
|
||||
let table_ordinal: HashMap<&str, usize> = on_page()
|
||||
.filter(|block| block.block_type == BlockType::LayoutTable)
|
||||
.enumerate()
|
||||
.map(|(ordinal, block)| (block.id.as_str(), ordinal))
|
||||
.collect();
|
||||
let table_of = |layout_table: &Block| {
|
||||
layout_table
|
||||
.children()
|
||||
.filter_map(|id| by_id.get(id).copied())
|
||||
.find(|child| child.block_type == BlockType::Table)
|
||||
.or_else(|| {
|
||||
table_ordinal
|
||||
.get(layout_table.id.as_str())
|
||||
.and_then(|ordinal| tables.get(*ordinal).copied())
|
||||
})
|
||||
};
|
||||
let sections: Vec<String> = on_page()
|
||||
.filter(|block| !list_items.contains(block.id.as_str()))
|
||||
.filter_map(|block| Some((block, block.block_type.layout()?)))
|
||||
.map(|(block, layout)| match layout {
|
||||
LayoutType::Title => format!("# {}", text_of(block, by_id, " ")),
|
||||
LayoutType::SectionHeader => format!("## {}", text_of(block, by_id, " ")),
|
||||
LayoutType::List => block
|
||||
.children()
|
||||
.filter_map(|id| by_id.get(id))
|
||||
.map(|item| format!("- {}", strip_bullet(&text_of(item, by_id, " "))))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
LayoutType::Table => match table_of(block) {
|
||||
Some(table) => table_markdown(table, by_id),
|
||||
None => text_of(block, by_id, "\n"),
|
||||
},
|
||||
LayoutType::KeyValue => text_of(block, by_id, "\n"),
|
||||
LayoutType::Figure => String::new(),
|
||||
LayoutType::Text | LayoutType::Header | LayoutType::Footer | LayoutType::PageNumber => {
|
||||
text_of(block, by_id, " ")
|
||||
}
|
||||
})
|
||||
.filter(|section| !section.trim().is_empty())
|
||||
.collect();
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
fn text_of(block: &Block, by_id: &HashMap<&str, &Block>, separator: &str) -> String {
|
||||
match &block.text {
|
||||
Some(text) => text.clone(),
|
||||
None => block
|
||||
.children()
|
||||
.filter_map(|id| by_id.get(id))
|
||||
.map(|child| text_of(child, by_id, separator))
|
||||
.filter(|text| !text.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(separator),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_bullet(item: &str) -> &str {
|
||||
item.trim_start_matches(['-', '*', '\u{2022}', '\u{00b7}'])
|
||||
.trim_start()
|
||||
}
|
||||
|
||||
fn table_markdown(table: &Block, by_id: &HashMap<&str, &Block>) -> String {
|
||||
let cells: BTreeMap<(usize, usize), String> = table
|
||||
.children()
|
||||
.filter_map(|id| by_id.get(id))
|
||||
.filter(|cell| cell.block_type == BlockType::Cell)
|
||||
.filter_map(|cell| {
|
||||
Some((
|
||||
(cell.row_index?, cell.column_index?),
|
||||
text_of(cell, by_id, " ").replace('|', "\\|"),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let columns = cells.keys().map(|(_, column)| *column).max().unwrap_or(0);
|
||||
let rows: BTreeSet<usize> = cells.keys().map(|(row, _)| *row).collect();
|
||||
let render = |row: usize| {
|
||||
let values: Vec<&str> = (1..=columns)
|
||||
.map(|column| cells.get(&(row, column)).map_or("", String::as_str))
|
||||
.collect();
|
||||
format!("| {} |", values.join(" | "))
|
||||
};
|
||||
let divider = format!("|{}", " --- |".repeat(columns));
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(position, row)| {
|
||||
std::iter::once(render(*row)).chain((position == 0).then(|| divider.clone()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::{fixture, rstest};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
const MODEL: &str = "analyze-document";
|
||||
|
||||
#[fixture]
|
||||
fn document() -> OcrDocument {
|
||||
OcrDocument::ImageUrl {
|
||||
image_url: "data:image/png;base64,aGk=".into(),
|
||||
extra_fields: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn child(ids: &[&str]) -> Value {
|
||||
json!([{"Type": "CHILD", "Ids": ids}])
|
||||
}
|
||||
|
||||
fn line(id: &str, text: &str) -> Value {
|
||||
json!({"Id": id, "BlockType": "LINE", "Text": text})
|
||||
}
|
||||
|
||||
fn word(id: &str, text: &str) -> Value {
|
||||
json!({"Id": id, "BlockType": "WORD", "Text": text})
|
||||
}
|
||||
|
||||
fn layout(id: &str, block_type: &str, children: &[&str]) -> Value {
|
||||
json!({"Id": id, "BlockType": block_type, "Relationships": child(children)})
|
||||
}
|
||||
|
||||
fn table(id: &str, cells: &[&str]) -> Value {
|
||||
json!({"Id": id, "BlockType": "TABLE", "Relationships": child(cells)})
|
||||
}
|
||||
|
||||
fn cell(id: &str, row: usize, column: usize, words: &[&str]) -> Value {
|
||||
json!({"Id": id, "BlockType": "CELL", "RowIndex": row, "ColumnIndex": column,
|
||||
"Relationships": child(words)})
|
||||
}
|
||||
|
||||
fn on_page(page: i64, mut block: Value) -> Value {
|
||||
block["Page"] = json!(page);
|
||||
block
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::headings_paragraphs_and_a_list_without_repeating_its_items(
|
||||
json!([
|
||||
line("l1", "Quarterly Report"),
|
||||
line("l2", "This report lists"),
|
||||
line("l3", "the invoices."),
|
||||
line("l4", "Line items"),
|
||||
line("l5", "- Pay within 30 days"),
|
||||
line("l6", "\u{2022} Quote the number"),
|
||||
layout("t", "LAYOUT_TITLE", &["l1"]),
|
||||
layout("p", "LAYOUT_TEXT", &["l2", "l3"]),
|
||||
layout("h", "LAYOUT_SECTION_HEADER", &["l4"]),
|
||||
layout("ul", "LAYOUT_LIST", &["i1", "i2"]),
|
||||
layout("i1", "LAYOUT_TEXT", &["l5"]),
|
||||
layout("i2", "LAYOUT_TEXT", &["l6"])
|
||||
]),
|
||||
vec![(
|
||||
0,
|
||||
"# Quarterly Report\n\nThis report lists the invoices.\n\n## Line items\n\n- Pay within 30 days\n- Quote the number"
|
||||
)]
|
||||
)]
|
||||
#[case::header_footer_and_page_number_stay_in_reading_order(
|
||||
json!([
|
||||
line("l1", "ACME Corp"), line("l2", "Body"), line("l3", "Confidential"), line("l4", "3"),
|
||||
layout("hd", "LAYOUT_HEADER", &["l1"]),
|
||||
layout("p", "LAYOUT_TEXT", &["l2"]),
|
||||
layout("ft", "LAYOUT_FOOTER", &["l3"]),
|
||||
layout("pn", "LAYOUT_PAGE_NUMBER", &["l4"])
|
||||
]),
|
||||
vec![(0, "ACME Corp\n\nBody\n\nConfidential\n\n3")]
|
||||
)]
|
||||
#[case::a_table_is_rendered_from_its_cells_in_row_and_column_order(
|
||||
json!([
|
||||
line("l1", "Invoice"), line("l2", "Total"), line("l3", "12345"), line("l4", "a|b"),
|
||||
word("w1", "Invoice"), word("w2", "Total"), word("w3", "12345"), word("w4", "a|b"),
|
||||
{"Id": "tb", "BlockType": "TABLE", "Relationships": [
|
||||
{"Type": "CHILD", "Ids": ["c4", "c1", "c3", "c2"]},
|
||||
{"Type": "TABLE_TITLE", "Ids": ["title"]}
|
||||
]},
|
||||
cell("c1", 1, 1, &["w1"]), cell("c2", 1, 2, &["w2"]),
|
||||
cell("c3", 2, 1, &["w3"]), cell("c4", 2, 2, &["w4"]),
|
||||
layout("lt", "LAYOUT_TABLE", &["l1", "l2", "l3", "l4"])
|
||||
]),
|
||||
vec![(0, "| Invoice | Total |\n| --- | --- |\n| 12345 | a\\|b |")]
|
||||
)]
|
||||
#[case::a_layout_table_that_links_its_table_renders_that_one(
|
||||
json!([
|
||||
word("w1", "first"), word("w2", "second"),
|
||||
table("tb1", &["c1"]), cell("c1", 1, 1, &["w1"]),
|
||||
table("tb2", &["c2"]), cell("c2", 1, 1, &["w2"]),
|
||||
layout("lt", "LAYOUT_TABLE", &["tb2"])
|
||||
]),
|
||||
vec![(0, "| second |\n| --- |")]
|
||||
)]
|
||||
#[case::a_missing_cell_leaves_an_empty_column(
|
||||
json!([
|
||||
word("w1", "a"), word("w2", "b"), word("w3", "c"),
|
||||
table("tb", &["c1", "c2", "c3"]),
|
||||
cell("c1", 1, 1, &["w1"]), cell("c2", 1, 2, &["w2"]), cell("c3", 2, 2, &["w3"]),
|
||||
layout("lt", "LAYOUT_TABLE", &[])
|
||||
]),
|
||||
vec![(0, "| a | b |\n| --- | --- |\n| | c |")]
|
||||
)]
|
||||
#[case::a_layout_table_without_table_blocks_keeps_its_lines(
|
||||
json!([
|
||||
line("l1", "Invoice Total"),
|
||||
line("l2", "12345 67.89"),
|
||||
layout("lt", "LAYOUT_TABLE", &["l1", "l2"])
|
||||
]),
|
||||
vec![(0, "Invoice Total\n12345 67.89")]
|
||||
)]
|
||||
#[case::key_values_keep_one_line_each(
|
||||
json!([
|
||||
line("l1", "Name: Ana"),
|
||||
line("l2", "Date: 2024-01-01"),
|
||||
layout("kv", "LAYOUT_KEY_VALUE", &["l1", "l2"])
|
||||
]),
|
||||
vec![(0, "Name: Ana\nDate: 2024-01-01")]
|
||||
)]
|
||||
#[case::a_figure_has_no_markdown(
|
||||
json!([
|
||||
line("l1", "Caption"),
|
||||
layout("f", "LAYOUT_FIGURE", &[]),
|
||||
layout("p", "LAYOUT_TEXT", &["l1"])
|
||||
]),
|
||||
vec![(0, "Caption")]
|
||||
)]
|
||||
#[case::a_block_type_added_later_is_ignored(
|
||||
json!([
|
||||
line("l1", "Body"),
|
||||
layout("new", "LAYOUT_SIDEBAR", &["l1"]),
|
||||
layout("p", "LAYOUT_TEXT", &["l1"])
|
||||
]),
|
||||
vec![(0, "Body")]
|
||||
)]
|
||||
#[case::without_layout_blocks_lines_are_used(
|
||||
json!([line("l1", "first"), word("w1", "first"), line("l2", "second")]),
|
||||
vec![(0, "first\nsecond")]
|
||||
)]
|
||||
#[case::each_page_gets_its_own_markdown_and_its_own_tables(
|
||||
json!([
|
||||
on_page(1, line("a", "one")),
|
||||
on_page(2, line("b", "two")),
|
||||
on_page(2, word("w", "cell")),
|
||||
on_page(1, layout("t1", "LAYOUT_TEXT", &["a"])),
|
||||
on_page(2, table("tb", &["c"])),
|
||||
on_page(2, cell("c", 1, 1, &["w"])),
|
||||
on_page(2, layout("lt", "LAYOUT_TABLE", &["b"]))
|
||||
]),
|
||||
vec![(0, "one"), (1, "| cell |\n| --- |")]
|
||||
)]
|
||||
fn blocks_become_markdown_pages(#[case] blocks: Value, #[case] expected: Vec<(i64, &str)>) {
|
||||
let response = TextractAnalyzeDocumentConfig
|
||||
.transform_ocr_response(
|
||||
MODEL,
|
||||
&serde_json::to_vec(&json!({"DocumentMetadata": {"Pages": 1}, "Blocks": blocks}))
|
||||
.unwrap(),
|
||||
OcrResponseFormat::Litellm,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pages: Vec<(i64, &str)> = response
|
||||
.pages
|
||||
.iter()
|
||||
.map(|page| (page.index, page.markdown.as_str()))
|
||||
.collect();
|
||||
assert_eq!(pages, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::hyphen("- item", "item")]
|
||||
#[case::asterisk("* item", "item")]
|
||||
#[case::bullet("\u{2022} item", "item")]
|
||||
#[case::middle_dot("\u{00b7}item", "item")]
|
||||
#[case::no_bullet("item - with a dash", "item - with a dash")]
|
||||
fn list_items_lose_their_own_bullet(#[case] item: &str, #[case] expected: &str) {
|
||||
assert_eq!(strip_bullet(item), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::defaults_to_layout_and_tables(json!({}), json!(["LAYOUT", "TABLES"]))]
|
||||
#[case::overridden(json!({"feature_types": ["FORMS", "SIGNATURES"]}), json!(["FORMS", "SIGNATURES"]))]
|
||||
#[case::explicit_null_uses_the_default(json!({"feature_types": null}), json!(["LAYOUT", "TABLES"]))]
|
||||
fn feature_types_reach_the_request(
|
||||
document: OcrDocument,
|
||||
#[case] arguments: Value,
|
||||
#[case] expected: Value,
|
||||
) {
|
||||
let arguments: CallArguments = serde_json::from_value(arguments).unwrap();
|
||||
let params = TextractAnalyzeDocumentConfig
|
||||
.map_ocr_params(&arguments, MODEL)
|
||||
.unwrap();
|
||||
|
||||
let request = TextractAnalyzeDocumentConfig
|
||||
.transform_ocr_request(MODEL, document, ¶ms, &[])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).unwrap(),
|
||||
json!({"Document": {"Bytes": "aGk="}, "FeatureTypes": expected})
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::undocumented_feature(json!({"feature_types": ["HANDWRITING"]}))]
|
||||
#[case::lowercase_feature(json!({"feature_types": ["layout"]}))]
|
||||
#[case::not_a_list(json!({"feature_types": "LAYOUT"}))]
|
||||
fn feature_types_outside_the_documented_values_are_refused(#[case] arguments: Value) {
|
||||
let arguments: CallArguments = serde_json::from_value(arguments).unwrap();
|
||||
|
||||
assert!(
|
||||
TextractAnalyzeDocumentConfig
|
||||
.map_ocr_params(&arguments, MODEL)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
678
litellm-rust/crates/llms/src/aws_textract/ocr/common_utils.rs
Normal file
678
litellm-rust/crates/llms/src/aws_textract/ocr/common_utils.rs
Normal file
|
|
@ -0,0 +1,678 @@
|
|||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use litellm_auth_aws::{SigV4Signer, resolve_aws_region};
|
||||
use litellm_http::outbound::RequestSigner;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::{EnumString, IntoStaticStr, VariantNames};
|
||||
|
||||
use crate::base_llm::ocr::{
|
||||
document::{InlineDocument, inline_remote_document},
|
||||
error::Error,
|
||||
transformation::{
|
||||
LiteLLMOcrResponse, OcrDocument, OcrEnvironment, OcrPage, OcrRequestContext, OcrUsageInfo,
|
||||
PreparedOcrRequest,
|
||||
},
|
||||
};
|
||||
|
||||
const TEXTRACT_SERVICE: &str = "textract";
|
||||
const AWS_JSON_CONTENT_TYPE: &str = "application/x-amz-json-1.1";
|
||||
const TARGET_HEADER: &str = "X-Amz-Target";
|
||||
const CONTENT_TYPE_HEADER: &str = "Content-Type";
|
||||
const UNSUPPORTED_DOCUMENT: &str = "UnsupportedDocumentException";
|
||||
const SYNC_DOCUMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
|
||||
|
||||
const HEALTH_CHECK_IMAGE_DATA_URI: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC";
|
||||
|
||||
/// Textract has operations rather than models; the model slot of
|
||||
/// `aws_textract/<model>` names the one to call.
|
||||
#[derive(Clone, Copy, Debug, EnumString, IntoStaticStr, VariantNames, PartialEq, Eq)]
|
||||
#[strum(serialize_all = "kebab-case", ascii_case_insensitive)]
|
||||
pub enum TextractOperation {
|
||||
DetectDocumentText,
|
||||
AnalyzeDocument,
|
||||
}
|
||||
|
||||
impl TextractOperation {
|
||||
pub const PROVIDER: &'static str = "aws_textract";
|
||||
|
||||
pub fn from_model(model: &str) -> Result<Self, Error> {
|
||||
model.parse().map_err(|_| Error::InvalidModel {
|
||||
provider: Self::PROVIDER,
|
||||
model: model.to_string(),
|
||||
supported: Self::VARIANTS,
|
||||
})
|
||||
}
|
||||
|
||||
fn target(self) -> &'static str {
|
||||
match self {
|
||||
Self::DetectDocumentText => "Textract.DetectDocumentText",
|
||||
Self::AnalyzeDocument => "Textract.AnalyzeDocument",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TextractDocument {
|
||||
#[serde(rename = "Bytes")]
|
||||
pub bytes: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum FeatureType {
|
||||
Tables,
|
||||
Forms,
|
||||
Queries,
|
||||
Signatures,
|
||||
Layout,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub(super) enum BlockType {
|
||||
KeyValueSet,
|
||||
Page,
|
||||
Line,
|
||||
Word,
|
||||
Table,
|
||||
Cell,
|
||||
SelectionElement,
|
||||
MergedCell,
|
||||
Title,
|
||||
Query,
|
||||
QueryResult,
|
||||
Signature,
|
||||
TableTitle,
|
||||
TableFooter,
|
||||
LayoutText,
|
||||
LayoutTitle,
|
||||
LayoutHeader,
|
||||
LayoutFooter,
|
||||
LayoutSectionHeader,
|
||||
LayoutPageNumber,
|
||||
LayoutList,
|
||||
LayoutFigure,
|
||||
LayoutTable,
|
||||
LayoutKeyValue,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum LayoutType {
|
||||
Text,
|
||||
Title,
|
||||
Header,
|
||||
Footer,
|
||||
SectionHeader,
|
||||
PageNumber,
|
||||
List,
|
||||
Figure,
|
||||
Table,
|
||||
KeyValue,
|
||||
}
|
||||
|
||||
impl BlockType {
|
||||
pub fn layout(self) -> Option<LayoutType> {
|
||||
match self {
|
||||
Self::LayoutText => Some(LayoutType::Text),
|
||||
Self::LayoutTitle => Some(LayoutType::Title),
|
||||
Self::LayoutHeader => Some(LayoutType::Header),
|
||||
Self::LayoutFooter => Some(LayoutType::Footer),
|
||||
Self::LayoutSectionHeader => Some(LayoutType::SectionHeader),
|
||||
Self::LayoutPageNumber => Some(LayoutType::PageNumber),
|
||||
Self::LayoutList => Some(LayoutType::List),
|
||||
Self::LayoutFigure => Some(LayoutType::Figure),
|
||||
Self::LayoutTable => Some(LayoutType::Table),
|
||||
Self::LayoutKeyValue => Some(LayoutType::KeyValue),
|
||||
Self::KeyValueSet
|
||||
| Self::Page
|
||||
| Self::Line
|
||||
| Self::Word
|
||||
| Self::Table
|
||||
| Self::Cell
|
||||
| Self::SelectionElement
|
||||
| Self::MergedCell
|
||||
| Self::Title
|
||||
| Self::Query
|
||||
| Self::QueryResult
|
||||
| Self::Signature
|
||||
| Self::TableTitle
|
||||
| Self::TableFooter
|
||||
| Self::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub(super) enum RelationshipType {
|
||||
Value,
|
||||
Child,
|
||||
ComplexFeatures,
|
||||
MergedCell,
|
||||
Title,
|
||||
Answer,
|
||||
Table,
|
||||
TableTitle,
|
||||
TableFooter,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub(super) struct Block {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
pub block_type: BlockType,
|
||||
pub text: Option<String>,
|
||||
pub page: Option<i64>,
|
||||
pub row_index: Option<usize>,
|
||||
pub column_index: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub relationships: Vec<Relationship>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub(super) struct Relationship {
|
||||
pub r#type: RelationshipType,
|
||||
#[serde(default)]
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Block {
|
||||
pub fn page(&self) -> i64 {
|
||||
self.page.unwrap_or(1)
|
||||
}
|
||||
|
||||
pub fn children(&self) -> impl Iterator<Item = &str> {
|
||||
self.relationships
|
||||
.iter()
|
||||
.filter(|relationship| relationship.r#type == RelationshipType::Child)
|
||||
.flat_map(|relationship| relationship.ids.iter().map(String::as_str))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub(super) struct DocumentMetadata {
|
||||
pub pages: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct TextractResponse {
|
||||
#[serde(default)]
|
||||
pub(super) blocks: Vec<Block>,
|
||||
pub(super) document_metadata: Option<DocumentMetadata>,
|
||||
}
|
||||
|
||||
pub struct TextractEnvironment {
|
||||
headers: Vec<(String, String)>,
|
||||
region: String,
|
||||
signer: SigV4Signer,
|
||||
}
|
||||
|
||||
impl OcrEnvironment for TextractEnvironment {
|
||||
fn headers(&self) -> &[(String, String)] {
|
||||
&self.headers
|
||||
}
|
||||
|
||||
fn signer(&self) -> Option<&dyn RequestSigner> {
|
||||
Some(&self.signer)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn health_check_document() -> OcrDocument {
|
||||
OcrDocument::ImageUrl {
|
||||
image_url: HEALTH_CHECK_IMAGE_DATA_URI.into(),
|
||||
extra_fields: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn environment(
|
||||
request: &PreparedOcrRequest,
|
||||
operation: TextractOperation,
|
||||
) -> Result<TextractEnvironment, Error> {
|
||||
let env_lookup = |name: &str| request.connection.secret(name);
|
||||
let region =
|
||||
resolve_aws_region(None, &request.optional_params, &env_lookup).ok_or_else(|| {
|
||||
Error::InvalidRequest(
|
||||
"Missing AWS region - pass aws_region_name or set AWS_REGION_NAME or AWS_REGION"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let signer = SigV4Signer::resolve(
|
||||
region.clone(),
|
||||
TEXTRACT_SERVICE,
|
||||
&request.optional_params,
|
||||
&env_lookup,
|
||||
)
|
||||
.await
|
||||
.map_err(litellm_auth::Error::from)?;
|
||||
Ok(TextractEnvironment {
|
||||
headers: operation_headers(&request.connection.extra_headers, operation),
|
||||
region,
|
||||
signer,
|
||||
})
|
||||
}
|
||||
|
||||
/// A caller's copy of an operation header would reach the wire next to ours
|
||||
/// while the signature covers only one value, which Textract rejects.
|
||||
fn operation_headers(
|
||||
extra_headers: &[(String, String)],
|
||||
operation: TextractOperation,
|
||||
) -> Vec<(String, String)> {
|
||||
let operation = [
|
||||
(TARGET_HEADER, operation.target()),
|
||||
(CONTENT_TYPE_HEADER, AWS_JSON_CONTENT_TYPE),
|
||||
];
|
||||
extra_headers
|
||||
.iter()
|
||||
.filter(|(name, _)| {
|
||||
!operation
|
||||
.iter()
|
||||
.any(|(operation_name, _)| name.eq_ignore_ascii_case(operation_name))
|
||||
})
|
||||
.cloned()
|
||||
.chain(
|
||||
operation
|
||||
.iter()
|
||||
.map(|(name, value)| (name.to_string(), value.to_string())),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn endpoint(request: &PreparedOcrRequest, environment: &TextractEnvironment) -> String {
|
||||
request
|
||||
.connection
|
||||
.api_base
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("https://textract.{}.amazonaws.com/", environment.region))
|
||||
}
|
||||
|
||||
pub(super) fn document_bytes(document: &OcrDocument) -> Result<TextractDocument, Error> {
|
||||
let inline = InlineDocument::parse(document.source())?.ok_or(Error::InvalidDataUri)?;
|
||||
Ok(TextractDocument {
|
||||
bytes: STANDARD.encode(inline.decode(SYNC_DOCUMENT_MAX_BYTES)?),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn inline_document(
|
||||
document: OcrDocument,
|
||||
context: OcrRequestContext<'_>,
|
||||
) -> Result<OcrDocument, Error> {
|
||||
inline_remote_document(
|
||||
context.client.document_fetcher(),
|
||||
document,
|
||||
context.connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AwsError {
|
||||
#[serde(rename = "__type", default)]
|
||||
kind: String,
|
||||
#[serde(rename = "Message", alias = "message", default)]
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Textract answers both an unsupported format and a multi-page PDF or TIFF
|
||||
/// with a bare "unsupported document format", which reads like a corrupt file.
|
||||
/// Say what the synchronous API accepts.
|
||||
pub(super) fn error_class(body: String, status: u16, headers: Vec<(String, String)>) -> Error {
|
||||
let unsupported = serde_json::from_str::<AwsError>(&body)
|
||||
.ok()
|
||||
.filter(|error| error.kind.ends_with(UNSUPPORTED_DOCUMENT));
|
||||
Error::Provider {
|
||||
status,
|
||||
body: match unsupported {
|
||||
Some(error) => format!(
|
||||
"{UNSUPPORTED_DOCUMENT}: {}. aws_textract uses Textract's synchronous API, which reads a JPEG, PNG, or a single-page PDF or TIFF; other formats and multi-page documents are not supported",
|
||||
error.message
|
||||
),
|
||||
None => body,
|
||||
},
|
||||
headers,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn lines_by_page(blocks: &[Block]) -> Vec<(i64, String)> {
|
||||
let pages: std::collections::BTreeSet<i64> = blocks.iter().map(Block::page).collect();
|
||||
pages
|
||||
.into_iter()
|
||||
.map(|page| {
|
||||
let lines: Vec<&str> = blocks
|
||||
.iter()
|
||||
.filter(|block| block.block_type == BlockType::Line && block.page() == page)
|
||||
.filter_map(|block| block.text.as_deref())
|
||||
.collect();
|
||||
(page, lines.join("\n"))
|
||||
})
|
||||
.filter(|(_, markdown)| !markdown.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn ocr_response(
|
||||
model: &str,
|
||||
page_markdown: Vec<(i64, String)>,
|
||||
document_metadata: Option<DocumentMetadata>,
|
||||
) -> LiteLLMOcrResponse {
|
||||
let pages: Vec<OcrPage> = page_markdown
|
||||
.into_iter()
|
||||
.map(|(page, markdown)| OcrPage {
|
||||
index: page - 1,
|
||||
markdown,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let pages_processed = document_metadata
|
||||
.and_then(|metadata| metadata.pages)
|
||||
.or_else(|| i64::try_from(pages.len()).ok());
|
||||
LiteLLMOcrResponse {
|
||||
usage_info: Some(OcrUsageInfo {
|
||||
pages_processed,
|
||||
..Default::default()
|
||||
}),
|
||||
..LiteLLMOcrResponse::new(model, pages)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
const HINT: &str = "other formats and multi-page documents are not supported";
|
||||
|
||||
fn blocks(value: Value) -> Vec<Block> {
|
||||
serde_json::from_value(value).unwrap()
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::detect("detect-document-text", TextractOperation::DetectDocumentText)]
|
||||
#[case::analyze("analyze-document", TextractOperation::AnalyzeDocument)]
|
||||
#[case::any_case("Analyze-Document", TextractOperation::AnalyzeDocument)]
|
||||
fn a_model_names_its_operation(#[case] model: &str, #[case] expected: TextractOperation) {
|
||||
assert_eq!(TextractOperation::from_model(model).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::misspelled("analyse-document")]
|
||||
#[case::operation_name_from_the_api("AnalyzeDocument")]
|
||||
#[case::operation_litellm_does_not_call("analyze-expense")]
|
||||
#[case::empty("")]
|
||||
fn a_model_outside_the_operations_is_refused_with_the_supported_names(#[case] model: &str) {
|
||||
let error = TextractOperation::from_model(model).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"invalid model: aws_textract has no model {model:?} - use one of: detect-document-text, analyze-document"
|
||||
)
|
||||
);
|
||||
assert_eq!(error.http_status_code(), Some(400));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::line("LINE", BlockType::Line)]
|
||||
#[case::key_value_set("KEY_VALUE_SET", BlockType::KeyValueSet)]
|
||||
#[case::layout_section_header("LAYOUT_SECTION_HEADER", BlockType::LayoutSectionHeader)]
|
||||
#[case::layout_key_value("LAYOUT_KEY_VALUE", BlockType::LayoutKeyValue)]
|
||||
#[case::added_by_textract_later("LAYOUT_SIDEBAR", BlockType::Unknown)]
|
||||
fn block_type_reads_the_documented_names(#[case] wire: &str, #[case] expected: BlockType) {
|
||||
let block: Block = serde_json::from_value(json!({"BlockType": wire})).unwrap();
|
||||
|
||||
assert_eq!(block.block_type, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::layout_title(BlockType::LayoutTitle, Some(LayoutType::Title))]
|
||||
#[case::layout_table(BlockType::LayoutTable, Some(LayoutType::Table))]
|
||||
#[case::table_is_not_layout(BlockType::Table, None)]
|
||||
#[case::title_is_not_layout(BlockType::Title, None)]
|
||||
#[case::unknown_is_not_layout(BlockType::Unknown, None)]
|
||||
fn only_layout_block_types_have_a_layout_type(
|
||||
#[case] block_type: BlockType,
|
||||
#[case] expected: Option<LayoutType>,
|
||||
) {
|
||||
assert_eq!(block_type.layout(), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::child_only(json!([{"Type": "CHILD", "Ids": ["a", "b"]}]), vec!["a", "b"])]
|
||||
#[case::other_relationships_are_skipped(
|
||||
json!([
|
||||
{"Type": "TABLE_TITLE", "Ids": ["t"]},
|
||||
{"Type": "CHILD", "Ids": ["a"]},
|
||||
{"Type": "MERGED_CELL", "Ids": ["m"]},
|
||||
{"Type": "ADDED_LATER", "Ids": ["x"]},
|
||||
{"Type": "CHILD", "Ids": ["b"]}
|
||||
]),
|
||||
vec!["a", "b"]
|
||||
)]
|
||||
#[case::no_relationships(json!([]), vec![])]
|
||||
fn children_are_the_ids_of_child_relationships(
|
||||
#[case] relationships: Value,
|
||||
#[case] expected: Vec<&str>,
|
||||
) {
|
||||
let block: Block =
|
||||
serde_json::from_value(json!({"BlockType": "LINE", "Relationships": relationships}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(block.children().collect::<Vec<_>>(), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::tables("TABLES", Some(FeatureType::Tables))]
|
||||
#[case::forms("FORMS", Some(FeatureType::Forms))]
|
||||
#[case::queries("QUERIES", Some(FeatureType::Queries))]
|
||||
#[case::signatures("SIGNATURES", Some(FeatureType::Signatures))]
|
||||
#[case::layout("LAYOUT", Some(FeatureType::Layout))]
|
||||
#[case::lowercase_is_not_a_feature("layout", None)]
|
||||
#[case::undocumented("HANDWRITING", None)]
|
||||
fn feature_type_accepts_only_the_documented_values(
|
||||
#[case] wire: &str,
|
||||
#[case] expected: Option<FeatureType>,
|
||||
) {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<FeatureType>(json!(wire)).ok(),
|
||||
expected
|
||||
);
|
||||
if let Some(feature) = expected {
|
||||
assert_eq!(serde_json::to_value(feature).unwrap(), json!(wire));
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::image_url(
|
||||
OcrDocument::ImageUrl {
|
||||
image_url: "data:image/png;base64,aGVsbG8=".into(),
|
||||
extra_fields: Default::default(),
|
||||
},
|
||||
"aGVsbG8="
|
||||
)]
|
||||
#[case::document_url(
|
||||
OcrDocument::DocumentUrl {
|
||||
document_url: "data:application/pdf;base64,YWJj".into(),
|
||||
extra_fields: Default::default(),
|
||||
},
|
||||
"YWJj"
|
||||
)]
|
||||
#[case::percent_encoded_data_uri_is_re_encoded_as_base64(
|
||||
OcrDocument::DocumentUrl {
|
||||
document_url: "data:,abc".into(),
|
||||
extra_fields: Default::default(),
|
||||
},
|
||||
"YWJj"
|
||||
)]
|
||||
fn document_bytes_are_the_base64_payload_without_the_data_uri_envelope(
|
||||
#[case] document: OcrDocument,
|
||||
#[case] expected: &str,
|
||||
) {
|
||||
assert_eq!(document_bytes(&document).unwrap().bytes, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::remote_url("https://example.com/a.pdf".to_string(), Error::InvalidDataUri)]
|
||||
#[case::invalid_base64("data:image/png;base64,@@@".to_string(), Error::InvalidDataUri)]
|
||||
#[case::over_the_sync_limit(
|
||||
format!("data:,{}", "a".repeat(SYNC_DOCUMENT_MAX_BYTES + 1)),
|
||||
Error::InlineDocumentTooLarge
|
||||
)]
|
||||
fn document_bytes_refuse_what_the_sync_api_cannot_take(
|
||||
#[case] document_url: String,
|
||||
#[case] expected: Error,
|
||||
) {
|
||||
let error = document_bytes(&OcrDocument::DocumentUrl {
|
||||
document_url,
|
||||
extra_fields: Default::default(),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&error),
|
||||
std::mem::discriminant(&expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::bare_type(
|
||||
r#"{"__type":"UnsupportedDocumentException","Message":"Request has unsupported document format"}"#,
|
||||
Some("Request has unsupported document format")
|
||||
)]
|
||||
#[case::namespaced_type(
|
||||
r#"{"__type":"com.amazonaws.textract#UnsupportedDocumentException","Message":"bad"}"#,
|
||||
Some("bad")
|
||||
)]
|
||||
#[case::lowercase_message(
|
||||
r#"{"__type":"UnsupportedDocumentException","message":"bad"}"#,
|
||||
Some("bad")
|
||||
)]
|
||||
#[case::other_exception(r#"{"__type":"AccessDeniedException","Message":"no"}"#, None)]
|
||||
#[case::json_without_a_type(r#"{"Message":"no"}"#, None)]
|
||||
#[case::not_json("<html>bad gateway</html>", None)]
|
||||
fn only_an_unsupported_document_gains_the_sync_api_hint(
|
||||
#[case] body: &str,
|
||||
#[case] hinted_message: Option<&str>,
|
||||
) {
|
||||
let response_headers = vec![("x-amzn-requestid".to_string(), "abc".to_string())];
|
||||
|
||||
let Error::Provider {
|
||||
status,
|
||||
body: reported,
|
||||
headers,
|
||||
} = error_class(body.into(), 400, response_headers.clone())
|
||||
else {
|
||||
panic!("expected a provider error");
|
||||
};
|
||||
|
||||
assert_eq!(status, 400);
|
||||
assert_eq!(headers, response_headers);
|
||||
match hinted_message {
|
||||
Some(message) => {
|
||||
assert!(reported.contains(message), "{reported}");
|
||||
assert!(reported.contains(HINT), "{reported}");
|
||||
}
|
||||
None => assert_eq!(reported, body),
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::no_caller_headers(vec![], vec![])]
|
||||
#[case::unrelated_headers_are_kept(vec![("x-trace", "1")], vec![("x-trace", "1")])]
|
||||
#[case::a_caller_content_type_is_replaced(
|
||||
vec![("content-type", "application/json"), ("x-trace", "1")],
|
||||
vec![("x-trace", "1")]
|
||||
)]
|
||||
#[case::a_caller_target_is_replaced(
|
||||
vec![("X-AMZ-TARGET", "Textract.AnalyzeDocument")],
|
||||
vec![]
|
||||
)]
|
||||
fn operation_headers_are_sent_once(
|
||||
#[case] extra_headers: Vec<(&str, &str)>,
|
||||
#[case] kept: Vec<(&str, &str)>,
|
||||
) {
|
||||
let owned = |headers: Vec<(&str, &str)>| -> Vec<(String, String)> {
|
||||
headers
|
||||
.into_iter()
|
||||
.map(|(name, value)| (name.to_string(), value.to_string()))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let headers =
|
||||
operation_headers(&owned(extra_headers), TextractOperation::DetectDocumentText);
|
||||
|
||||
let mut expected = owned(kept);
|
||||
expected.extend(owned(vec![
|
||||
("X-Amz-Target", "Textract.DetectDocumentText"),
|
||||
("Content-Type", "application/x-amz-json-1.1"),
|
||||
]));
|
||||
assert_eq!(headers, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::words_are_not_repeated(
|
||||
json!([
|
||||
{"BlockType": "PAGE"},
|
||||
{"BlockType": "LINE", "Text": "Invoice 12345"},
|
||||
{"BlockType": "WORD", "Text": "Invoice"},
|
||||
{"BlockType": "WORD", "Text": "12345"},
|
||||
{"BlockType": "LINE", "Text": "total 67.89"}
|
||||
]),
|
||||
vec![(1, "Invoice 12345\ntotal 67.89")]
|
||||
)]
|
||||
#[case::pages_are_sorted_and_keep_line_order(
|
||||
json!([
|
||||
{"BlockType": "LINE", "Text": "second", "Page": 2},
|
||||
{"BlockType": "LINE", "Text": "first", "Page": 1},
|
||||
{"BlockType": "LINE", "Text": "also second", "Page": 2}
|
||||
]),
|
||||
vec![(1, "first"), (2, "second\nalso second")]
|
||||
)]
|
||||
#[case::a_page_without_lines_is_dropped(
|
||||
json!([
|
||||
{"BlockType": "PAGE", "Page": 1},
|
||||
{"BlockType": "LINE", "Text": "only", "Page": 2}
|
||||
]),
|
||||
vec![(2, "only")]
|
||||
)]
|
||||
#[case::no_blocks(json!([]), vec![])]
|
||||
fn lines_are_grouped_by_page(#[case] input: Value, #[case] expected: Vec<(i64, &str)>) {
|
||||
let pages = lines_by_page(&blocks(input));
|
||||
|
||||
let pages: Vec<(i64, &str)> = pages
|
||||
.iter()
|
||||
.map(|(page, markdown)| (*page, markdown.as_str()))
|
||||
.collect();
|
||||
assert_eq!(pages, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::metadata_wins(Some(3), Some(3))]
|
||||
#[case::metadata_without_pages_falls_back_to_the_page_count(None, Some(2))]
|
||||
fn pages_are_zero_indexed_and_usage_reports_pages_processed(
|
||||
#[case] metadata_pages: Option<i64>,
|
||||
#[case] expected: Option<i64>,
|
||||
) {
|
||||
let response = ocr_response(
|
||||
"detect-document-text",
|
||||
vec![(1, "first".into()), (3, "third".into())],
|
||||
Some(DocumentMetadata {
|
||||
pages: metadata_pages,
|
||||
}),
|
||||
);
|
||||
|
||||
let pages: Vec<(i64, &str)> = response
|
||||
.pages
|
||||
.iter()
|
||||
.map(|page| (page.index, page.markdown.as_str()))
|
||||
.collect();
|
||||
assert_eq!(pages, vec![(0, "first"), (2, "third")]);
|
||||
assert_eq!(response.usage_info.unwrap().pages_processed, expected);
|
||||
}
|
||||
}
|
||||
3
litellm-rust/crates/llms/src/aws_textract/ocr/mod.rs
Normal file
3
litellm-rust/crates/llms/src/aws_textract/ocr/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod analyze_transformation;
|
||||
pub mod common_utils;
|
||||
pub mod transformation;
|
||||
240
litellm-rust/crates/llms/src/aws_textract/ocr/transformation.rs
Normal file
240
litellm-rust/crates/llms/src/aws_textract/ocr/transformation.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
use litellm_core_utils::call_arguments::CallArguments;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::common_utils::{
|
||||
TextractDocument, TextractEnvironment, TextractOperation, TextractResponse, document_bytes,
|
||||
endpoint, environment, error_class, health_check_document, inline_document, lines_by_page,
|
||||
ocr_response,
|
||||
};
|
||||
use crate::base_llm::ocr::{
|
||||
error::Error,
|
||||
handler::OcrClient,
|
||||
transformation::{
|
||||
BaseOcrConfig, LiteLLMOcrResponse, OcrDocument, OcrRequestContext, OcrResponseFormat,
|
||||
PreparedOcrRequest, decode_and_normalize_response,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct DetectDocumentTextRequest {
|
||||
#[serde(rename = "Document")]
|
||||
pub document: TextractDocument,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct TextractDetectTextConfig;
|
||||
|
||||
impl BaseOcrConfig for TextractDetectTextConfig {
|
||||
type OcrParams = ();
|
||||
type ProviderRequest = DetectDocumentTextRequest;
|
||||
type Environment = TextractEnvironment;
|
||||
|
||||
fn get_health_check_document(&self) -> OcrDocument {
|
||||
health_check_document()
|
||||
}
|
||||
|
||||
fn map_ocr_params(
|
||||
&self,
|
||||
_non_default_params: &CallArguments,
|
||||
_model: &str,
|
||||
) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_environment(
|
||||
&self,
|
||||
request: &PreparedOcrRequest,
|
||||
_client: &OcrClient,
|
||||
) -> Result<TextractEnvironment, Error> {
|
||||
environment(request, TextractOperation::DetectDocumentText).await
|
||||
}
|
||||
|
||||
fn get_complete_url(
|
||||
&self,
|
||||
request: &PreparedOcrRequest,
|
||||
_optional_params: &(),
|
||||
environment: &TextractEnvironment,
|
||||
) -> Result<String, Error> {
|
||||
Ok(endpoint(request, environment))
|
||||
}
|
||||
|
||||
fn transform_ocr_request(
|
||||
&self,
|
||||
_model: &str,
|
||||
document: OcrDocument,
|
||||
_optional_params: &(),
|
||||
_headers: &[(String, String)],
|
||||
) -> Result<DetectDocumentTextRequest, Error> {
|
||||
Ok(DetectDocumentTextRequest {
|
||||
document: document_bytes(&document)?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn async_transform_ocr_request(
|
||||
&self,
|
||||
model: &str,
|
||||
document: OcrDocument,
|
||||
optional_params: &(),
|
||||
headers: &[(String, String)],
|
||||
context: OcrRequestContext<'_>,
|
||||
) -> Result<DetectDocumentTextRequest, Error> {
|
||||
let document = inline_document(document, context).await?;
|
||||
self.transform_ocr_request(model, document, optional_params, headers)
|
||||
}
|
||||
|
||||
fn transform_ocr_response(
|
||||
&self,
|
||||
model: &str,
|
||||
raw_response: &[u8],
|
||||
request_format: OcrResponseFormat,
|
||||
) -> Result<LiteLLMOcrResponse, Error> {
|
||||
decode_and_normalize_response(model, raw_response, request_format, normalize_response)
|
||||
}
|
||||
|
||||
fn get_error_class(
|
||||
&self,
|
||||
error_message: String,
|
||||
status_code: u16,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Error {
|
||||
error_class(error_message, status_code, headers)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_response(
|
||||
model: &str,
|
||||
response: TextractResponse,
|
||||
) -> Result<LiteLLMOcrResponse, Error> {
|
||||
Ok(ocr_response(
|
||||
model,
|
||||
lines_by_page(&response.blocks),
|
||||
response.document_metadata,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::{fixture, rstest};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
const MODEL: &str = "detect-document-text";
|
||||
|
||||
#[fixture]
|
||||
fn document(#[default("data:image/png;base64,aGVsbG8=")] source: &str) -> OcrDocument {
|
||||
OcrDocument::DocumentUrl {
|
||||
document_url: source.into(),
|
||||
extra_fields: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::one_page_without_page_numbers(
|
||||
json!({
|
||||
"DetectDocumentTextModelVersion": "1.0",
|
||||
"DocumentMetadata": {"Pages": 1},
|
||||
"Blocks": [
|
||||
{"BlockType": "PAGE"},
|
||||
{"BlockType": "LINE", "Text": "Invoice 12345"},
|
||||
{"BlockType": "WORD", "Text": "Invoice"},
|
||||
{"BlockType": "WORD", "Text": "12345"},
|
||||
{"BlockType": "LINE", "Text": "total 67.89"}
|
||||
]
|
||||
}),
|
||||
vec![(0, "Invoice 12345\ntotal 67.89")],
|
||||
Some(1)
|
||||
)]
|
||||
#[case::pages_out_of_order(
|
||||
json!({
|
||||
"DocumentMetadata": {"Pages": 2},
|
||||
"Blocks": [
|
||||
{"BlockType": "LINE", "Text": "second", "Page": 2},
|
||||
{"BlockType": "LINE", "Text": "first", "Page": 1},
|
||||
{"BlockType": "LINE", "Text": "also second", "Page": 2}
|
||||
]
|
||||
}),
|
||||
vec![(0, "first"), (1, "second\nalso second")],
|
||||
Some(2)
|
||||
)]
|
||||
#[case::missing_metadata_counts_the_pages_with_text(
|
||||
json!({"Blocks": [{"BlockType": "LINE", "Text": "only"}]}),
|
||||
vec![(0, "only")],
|
||||
Some(1)
|
||||
)]
|
||||
#[case::blank_document(json!({"DocumentMetadata": {"Pages": 1}}), vec![], Some(1))]
|
||||
fn response_lines_become_one_markdown_page_per_document_page(
|
||||
#[case] raw_response: Value,
|
||||
#[case] expected_pages: Vec<(i64, &str)>,
|
||||
#[case] expected_pages_processed: Option<i64>,
|
||||
) {
|
||||
let response = TextractDetectTextConfig
|
||||
.transform_ocr_response(
|
||||
MODEL,
|
||||
&serde_json::to_vec(&raw_response).unwrap(),
|
||||
OcrResponseFormat::Litellm,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pages: Vec<(i64, &str)> = response
|
||||
.pages
|
||||
.iter()
|
||||
.map(|page| (page.index, page.markdown.as_str()))
|
||||
.collect();
|
||||
assert_eq!(pages, expected_pages);
|
||||
assert_eq!(response.model, MODEL);
|
||||
assert_eq!(
|
||||
response.usage_info.unwrap().pages_processed,
|
||||
expected_pages_processed
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn the_request_is_only_the_document_bytes(document: OcrDocument) {
|
||||
let request = TextractDetectTextConfig
|
||||
.transform_ocr_request(MODEL, document, &(), &[])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).unwrap(),
|
||||
json!({"Document": {"Bytes": "aGVsbG8="}})
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn a_remote_url_is_refused_by_the_sync_transform(
|
||||
#[with("https://example.com/a.pdf")] document: OcrDocument,
|
||||
) {
|
||||
let error = TextractDetectTextConfig
|
||||
.transform_ocr_request(MODEL, document, &(), &[])
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, Error::InvalidDataUri));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn the_health_check_document_is_an_inline_image_the_request_accepts() {
|
||||
let document = TextractDetectTextConfig.get_health_check_document();
|
||||
|
||||
assert!(
|
||||
TextractDetectTextConfig
|
||||
.transform_ocr_request(MODEL, document, &(), &[])
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn provider_errors_go_through_the_shared_textract_error_class() {
|
||||
let error = TextractDetectTextConfig.get_error_class(
|
||||
r#"{"__type":"UnsupportedDocumentException","Message":"Request has unsupported document format"}"#.into(),
|
||||
400,
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("multi-page documents are not supported")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ use crate::{
|
|||
base_llm::ocr::{
|
||||
document::{inline_remote_document, validate_inline_document},
|
||||
error::Error,
|
||||
handler::OcrClient,
|
||||
transformation::{
|
||||
BaseOcrConfig, LiteLLMOcrResponse, OcrDocument, OcrRequestContext, OcrResponseFormat,
|
||||
PreparedOcrRequest,
|
||||
|
|
@ -13,7 +14,6 @@ use crate::{
|
|||
cohere::ocr::transformation::{
|
||||
CohereOptions, CohereParseConfig, CohereRequest, validate_document,
|
||||
},
|
||||
custom_httpx::llm_http_handler::OcrClient,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -53,7 +53,7 @@ impl BaseOcrConfig for AzureAICohereParseConfig {
|
|||
) -> Result<String, Error> {
|
||||
let base = super::transformation::AzureAiOcrConfig::resolve_api_base(
|
||||
request.connection.api_base.as_deref(),
|
||||
&crate::base_llm::ocr::transformation::credential_env,
|
||||
&|name: &str| request.connection.secret(name),
|
||||
)?;
|
||||
self.get_complete_url(&base)
|
||||
}
|
||||
|
|
@ -108,7 +108,7 @@ impl BaseOcrConfig for AzureAICohereParseConfig {
|
|||
}
|
||||
|
||||
fn validate_request_body(&self, body: &Value) -> Result<(), Error> {
|
||||
let document = crate::custom_httpx::llm_http_handler::body_document(body)?;
|
||||
let document = crate::base_llm::ocr::handler::body_document(body)?;
|
||||
validate_document(&document)?;
|
||||
validate_inline_document(&document)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,21 @@ use std::sync::OnceLock;
|
|||
use litellm_auth::{InputSource, Sourced};
|
||||
use litellm_auth_azure::{AzureAuthInputs, AzureAuthService};
|
||||
|
||||
use crate::base_llm::ocr::{error::Error, transformation::OcrConnection};
|
||||
use crate::base_llm::ocr::{
|
||||
error::Error,
|
||||
transformation::{OcrConnection, PreparedOcrRequest},
|
||||
};
|
||||
|
||||
pub(crate) fn azure_auth_inputs(request: &PreparedOcrRequest) -> Result<AzureAuthInputs, Error> {
|
||||
Ok(AzureAuthInputs {
|
||||
azure_ad_token_provider: request.azure_ad_token_provider.clone(),
|
||||
..AzureAuthInputs::from_sourced_optional_params(
|
||||
&request.optional_params,
|
||||
&request.input_sources,
|
||||
)?
|
||||
}
|
||||
.or_configured_token_refresh(request.connection.settings.enable_azure_ad_token_refresh))
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_entra(
|
||||
config: &AzureAuthInputs,
|
||||
|
|
|
|||
|
|
@ -14,24 +14,20 @@ use serde_json::{Map, Value};
|
|||
use serde_with::serde_as;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::{
|
||||
base_llm::ocr::{
|
||||
document::InlineDocument,
|
||||
error::Error,
|
||||
transformation::{
|
||||
BaseOcrConfig, DecodedOcrResponse, LiteLLMOcrResponse, OCR_INLINE_MAX_BYTES,
|
||||
OCR_POLL_RETRY_SECS, OcrConnection, OcrCredentialInputs, OcrDocument, OcrPage,
|
||||
OcrPageDimensions, OcrResponseContext, OcrResponseFormat, OcrUsageInfo,
|
||||
PreparedOcrRequest, ResolvedOcrCredentials, credential_env,
|
||||
decode_and_normalize_response, decode_response,
|
||||
},
|
||||
use crate::base_llm::ocr::{
|
||||
document::InlineDocument,
|
||||
error::Error,
|
||||
handler::{CallHooks, OcrClient, read_json_response},
|
||||
settings::OcrSettings,
|
||||
transformation::{
|
||||
BaseOcrConfig, DecodedOcrResponse, LiteLLMOcrResponse, OCR_INLINE_MAX_BYTES,
|
||||
OCR_POLL_RETRY_SECS, OcrConnection, OcrCredentialInputs, OcrDocument, OcrPage,
|
||||
OcrPageDimensions, OcrResponseContext, OcrResponseFormat, OcrUsageInfo, PreparedOcrRequest,
|
||||
ResolvedOcrCredentials, decode_and_normalize_response, decode_response,
|
||||
},
|
||||
custom_httpx::llm_http_handler::{CallHooks, OcrClient, read_json_response},
|
||||
};
|
||||
|
||||
const AZURE_DI_API_VERSION: &str = "2024-11-30";
|
||||
const AZURE_DI_SUBSCRIPTION_HEADER: &str = "Ocp-Apim-Subscription-Key";
|
||||
const AZURE_DI_DEFAULT_DPI: i64 = 96;
|
||||
const AZURE_DI_DEFAULT_WIDTH: f64 = 8.5;
|
||||
const AZURE_DI_DEFAULT_HEIGHT: f64 = 11.0;
|
||||
|
||||
|
|
@ -178,15 +174,11 @@ impl BaseOcrConfig for AzureDocumentIntelligenceOcrConfig {
|
|||
request: &PreparedOcrRequest,
|
||||
_client: &OcrClient,
|
||||
) -> Result<Self::Environment, Error> {
|
||||
let config = AzureAuthInputs {
|
||||
azure_ad_token_provider: request.azure_ad_token_provider.clone(),
|
||||
..AzureAuthInputs::from_sourced_optional_params(
|
||||
&request.optional_params,
|
||||
&request.input_sources,
|
||||
)?
|
||||
};
|
||||
self.resolve_headers(&request.connection, &config, &credential_env)
|
||||
.await
|
||||
let config = crate::azure_ai::ocr::common_utils::azure_auth_inputs(request)?;
|
||||
self.resolve_headers(&request.connection, &config, &|name: &str| {
|
||||
request.connection.secret(name)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn get_complete_url(
|
||||
|
|
@ -196,9 +188,17 @@ impl BaseOcrConfig for AzureDocumentIntelligenceOcrConfig {
|
|||
_environment: &Self::Environment,
|
||||
) -> Result<String, Error> {
|
||||
let endpoint = nonblank(request.connection.api_base.clone())
|
||||
.or_else(|| nonblank(credential_env(AZURE_DI_ENDPOINT_ENV)))
|
||||
.or_else(|| nonblank(request.connection.secret(AZURE_DI_ENDPOINT_ENV)))
|
||||
.ok_or_else(|| Error::Auth(litellm_auth::Error::ProviderAuthentication("Missing Azure Document Intelligence API Base - Set AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT or pass api_base".into())))?;
|
||||
self.build_ocr_url(&endpoint, &request.model, optional_params)
|
||||
self.build_ocr_url(
|
||||
&endpoint,
|
||||
&request.model,
|
||||
optional_params,
|
||||
&request
|
||||
.connection
|
||||
.settings
|
||||
.document_intelligence_api_version,
|
||||
)
|
||||
}
|
||||
|
||||
fn transform_ocr_request(
|
||||
|
|
@ -217,12 +217,13 @@ impl BaseOcrConfig for AzureDocumentIntelligenceOcrConfig {
|
|||
raw_response: &[u8],
|
||||
request_format: OcrResponseFormat,
|
||||
) -> Result<LiteLLMOcrResponse, Error> {
|
||||
decode_and_normalize_response(
|
||||
model,
|
||||
raw_response,
|
||||
request_format,
|
||||
transform_completed_response,
|
||||
)
|
||||
decode_and_normalize_response(model, raw_response, request_format, |model, response| {
|
||||
transform_completed_response(
|
||||
model,
|
||||
response,
|
||||
OcrSettings::default().document_intelligence_dpi,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn async_transform_ocr_response(
|
||||
|
|
@ -243,7 +244,11 @@ impl BaseOcrConfig for AzureDocumentIntelligenceOcrConfig {
|
|||
.await?;
|
||||
Ok(LiteLLMOcrResponse {
|
||||
provider_native_response: decoded.native,
|
||||
..transform_completed_response(model, decoded.data)?
|
||||
..transform_completed_response(
|
||||
model,
|
||||
decoded.data,
|
||||
context.connection.settings.document_intelligence_dpi,
|
||||
)?
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -356,6 +361,7 @@ fn build_request(document: OcrDocument) -> Result<DocumentIntelligenceRequest, E
|
|||
fn transform_completed_response(
|
||||
model: &str,
|
||||
response: AzureDocumentIntelligenceOperation,
|
||||
dpi: i64,
|
||||
) -> Result<LiteLLMOcrResponse, Error> {
|
||||
if response.status != Some(OperationStatus::Succeeded) {
|
||||
return Err(Error::OperationStatus(
|
||||
|
|
@ -369,7 +375,7 @@ fn transform_completed_response(
|
|||
let pages = result
|
||||
.pages
|
||||
.into_iter()
|
||||
.map(transform_azure_page)
|
||||
.map(|page| transform_azure_page(page, dpi))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let pages_processed = i64::try_from(pages.len()).map_err(|_| Error::NumericRange("pages"))?;
|
||||
Ok(LiteLLMOcrResponse {
|
||||
|
|
@ -384,7 +390,7 @@ fn transform_completed_response(
|
|||
})
|
||||
}
|
||||
|
||||
fn transform_azure_page(page: AzureDocumentIntelligencePage) -> Result<OcrPage, Error> {
|
||||
fn transform_azure_page(page: AzureDocumentIntelligencePage, dpi: i64) -> Result<OcrPage, Error> {
|
||||
let index = page
|
||||
.page_number
|
||||
.unwrap_or(1)
|
||||
|
|
@ -394,6 +400,7 @@ fn transform_azure_page(page: AzureDocumentIntelligencePage) -> Result<OcrPage,
|
|||
page.width.unwrap_or(AZURE_DI_DEFAULT_WIDTH),
|
||||
page.height.unwrap_or(AZURE_DI_DEFAULT_HEIGHT),
|
||||
page.unit.as_deref().unwrap_or("inch"),
|
||||
dpi,
|
||||
)?;
|
||||
let markdown = page
|
||||
.lines
|
||||
|
|
@ -409,16 +416,17 @@ fn transform_azure_page(page: AzureDocumentIntelligencePage) -> Result<OcrPage,
|
|||
})
|
||||
}
|
||||
|
||||
fn convert_dimensions(width: f64, height: f64, unit: &str) -> Result<OcrPageDimensions, Error> {
|
||||
let scale = if unit == "inch" {
|
||||
AZURE_DI_DEFAULT_DPI as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
fn convert_dimensions(
|
||||
width: f64,
|
||||
height: f64,
|
||||
unit: &str,
|
||||
dpi: i64,
|
||||
) -> Result<OcrPageDimensions, Error> {
|
||||
let scale = if unit == "inch" { dpi as f64 } else { 1.0 };
|
||||
Ok(OcrPageDimensions {
|
||||
width: Some(pixel_dimension(width, scale, "page.width")?),
|
||||
height: Some(pixel_dimension(height, scale, "page.height")?),
|
||||
dpi: Some(AZURE_DI_DEFAULT_DPI),
|
||||
dpi: Some(dpi),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -440,7 +448,7 @@ async fn read_operation_response(
|
|||
hooks: &dyn CallHooks<Error>,
|
||||
) -> Result<DecodedOcrResponse<AzureDocumentIntelligenceOperation>, Error> {
|
||||
if response.status() != reqwest::StatusCode::ACCEPTED {
|
||||
let bytes = crate::custom_httpx::llm_http_handler::read_response_bytes(
|
||||
let bytes = crate::base_llm::ocr::handler::read_response_bytes(
|
||||
response,
|
||||
connection.max_response_bytes,
|
||||
)
|
||||
|
|
@ -462,11 +470,9 @@ async fn read_operation_response(
|
|||
{
|
||||
return Err(Error::PollOrigin);
|
||||
}
|
||||
let bytes = crate::custom_httpx::llm_http_handler::read_response_bytes(
|
||||
response,
|
||||
connection.max_response_bytes,
|
||||
)
|
||||
.await?;
|
||||
let bytes =
|
||||
crate::base_llm::ocr::handler::read_response_bytes(response, connection.max_response_bytes)
|
||||
.await?;
|
||||
hooks.response_received(&bytes).await?;
|
||||
poll_operation(http_client, operation, headers, connection, native, hooks).await
|
||||
}
|
||||
|
|
@ -480,7 +486,7 @@ async fn poll_operation(
|
|||
hooks: &dyn CallHooks<Error>,
|
||||
) -> Result<DecodedOcrResponse<AzureDocumentIntelligenceOperation>, Error> {
|
||||
let deadline = Instant::now()
|
||||
.checked_add(connection.poll_timeout)
|
||||
.checked_add(connection.settings.poll_timeout)
|
||||
.ok_or(Error::PollTimeout)?;
|
||||
|
||||
loop {
|
||||
|
|
@ -491,21 +497,19 @@ async fn poll_operation(
|
|||
let builder = http_client
|
||||
.get(url.clone())
|
||||
.timeout(remaining.min(connection.timeout));
|
||||
let builder = crate::custom_httpx::http_handler::with_headers(
|
||||
let builder = litellm_http::request::with_headers(
|
||||
builder,
|
||||
headers,
|
||||
crate::custom_httpx::http_handler::HeaderPolicy::Only(&[
|
||||
litellm_http::request::HeaderPolicy::Only(&[
|
||||
AZURE_DI_SUBSCRIPTION_HEADER,
|
||||
"authorization",
|
||||
]),
|
||||
);
|
||||
let response = tokio::time::timeout_at(
|
||||
deadline,
|
||||
crate::custom_httpx::http_handler::http_request(builder),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Error::PollTimeout)?
|
||||
.map_err(crate::custom_httpx::transport::Error::from)?;
|
||||
let response =
|
||||
tokio::time::timeout_at(deadline, litellm_http::request::http_request(builder))
|
||||
.await
|
||||
.map_err(|_| Error::PollTimeout)?
|
||||
.map_err(litellm_http::transport::Error::from)?;
|
||||
let retry = response
|
||||
.headers()
|
||||
.get(reqwest::header::RETRY_AFTER)
|
||||
|
|
@ -551,13 +555,14 @@ impl AzureDocumentIntelligenceOcrConfig {
|
|||
endpoint: &str,
|
||||
model: &str,
|
||||
params: &DocumentIntelligenceParams,
|
||||
api_version: &str,
|
||||
) -> Result<String, Error> {
|
||||
let model = format!("{}:analyze", model_id(model)?);
|
||||
ApiUrl::parse(endpoint)
|
||||
.and_then(|url| url.complete_path(&["documentintelligence", "documentModels", &model]))
|
||||
.map(|url| {
|
||||
url.append_query_pairs(
|
||||
[("api-version", AZURE_DI_API_VERSION)]
|
||||
[("api-version", api_version)]
|
||||
.into_iter()
|
||||
.chain(params.pages.iter().map(|pages| ("pages", pages.as_str())))
|
||||
.chain(
|
||||
|
|
@ -580,8 +585,8 @@ impl AzureDocumentIntelligenceOcrConfig {
|
|||
config: &AzureAuthInputs,
|
||||
env_lookup: &(dyn Fn(&str) -> Option<String> + Sync),
|
||||
) -> Result<Vec<(String, String)>, Error> {
|
||||
if crate::custom_httpx::http_handler::has_header(&connection.extra_headers, "authorization")
|
||||
|| crate::custom_httpx::http_handler::has_header(
|
||||
if litellm_http::request::has_header(&connection.extra_headers, "authorization")
|
||||
|| litellm_http::request::has_header(
|
||||
&connection.extra_headers,
|
||||
AZURE_DI_SUBSCRIPTION_HEADER,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ use crate::{
|
|||
base_llm::ocr::{
|
||||
document::{inline_remote_document, validate_inline_document},
|
||||
error::Error,
|
||||
handler::OcrClient,
|
||||
transformation::{
|
||||
BaseOcrConfig, LiteLLMOcrResponse, OcrConnection, OcrDocument, OcrRequestContext,
|
||||
OcrResponseFormat, PreparedOcrRequest, credential_env,
|
||||
OcrResponseFormat, PreparedOcrRequest,
|
||||
},
|
||||
},
|
||||
custom_httpx::llm_http_handler::OcrClient,
|
||||
mistral::ocr::transformation::{MistralOcrConfig, MistralOcrRequest},
|
||||
};
|
||||
|
||||
|
|
@ -50,15 +50,11 @@ impl BaseOcrConfig for AzureAiOcrConfig {
|
|||
request: &PreparedOcrRequest,
|
||||
_client: &OcrClient,
|
||||
) -> Result<Self::Environment, Error> {
|
||||
let config = AzureAuthInputs {
|
||||
azure_ad_token_provider: request.azure_ad_token_provider.clone(),
|
||||
..AzureAuthInputs::from_sourced_optional_params(
|
||||
&request.optional_params,
|
||||
&request.input_sources,
|
||||
)?
|
||||
};
|
||||
self.resolve_headers(&request.connection, &config, &credential_env)
|
||||
.await
|
||||
let config = crate::azure_ai::ocr::common_utils::azure_auth_inputs(request)?;
|
||||
self.resolve_headers(&request.connection, &config, &|name: &str| {
|
||||
request.connection.secret(name)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn get_complete_url(
|
||||
|
|
@ -67,7 +63,9 @@ impl BaseOcrConfig for AzureAiOcrConfig {
|
|||
_optional_params: &Self::OcrParams,
|
||||
_environment: &Self::Environment,
|
||||
) -> Result<String, Error> {
|
||||
self.build_ocr_url(request.connection.api_base.as_deref(), &credential_env)
|
||||
self.build_ocr_url(request.connection.api_base.as_deref(), &|name: &str| {
|
||||
request.connection.secret(name)
|
||||
})
|
||||
}
|
||||
|
||||
fn transform_ocr_request(
|
||||
|
|
@ -107,7 +105,7 @@ impl BaseOcrConfig for AzureAiOcrConfig {
|
|||
}
|
||||
|
||||
fn validate_request_body(&self, body: &Value) -> Result<(), Error> {
|
||||
validate_inline_document(&crate::custom_httpx::llm_http_handler::body_document(body)?)
|
||||
validate_inline_document(&crate::base_llm::ocr::handler::body_document(body)?)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,8 +132,7 @@ impl AzureAiOcrConfig {
|
|||
env_lookup: &(dyn Fn(&str) -> Option<String> + Sync),
|
||||
) -> Result<Vec<(String, String)>, Error> {
|
||||
Self::resolve_api_base(connection.api_base.as_deref(), env_lookup)?;
|
||||
if crate::custom_httpx::http_handler::has_header(&connection.extra_headers, "authorization")
|
||||
{
|
||||
if litellm_http::request::has_header(&connection.extra_headers, "authorization") {
|
||||
if config.azure_ad_token_provider.is_some() {
|
||||
super::common_utils::resolve_entra(config, env_lookup).await?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,14 +21,7 @@ impl AudioTranscriptionResponseData {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AudioTranscriptionAuth {
|
||||
Bearer,
|
||||
AwsSigV4 {
|
||||
region: String,
|
||||
service: &'static str,
|
||||
},
|
||||
}
|
||||
pub use litellm_auth::RequestAuth;
|
||||
|
||||
pub trait BaseAudioTranscriptionConfig: Sync {
|
||||
fn get_supported_openai_params(&self) -> &'static [&'static str];
|
||||
|
|
@ -70,5 +63,5 @@ pub trait BaseAudioTranscriptionConfig: Sync {
|
|||
model: &str,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<AudioTranscriptionAuth, Error>;
|
||||
) -> Result<RequestAuth, Error>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,14 +41,7 @@ pub const STREAM_PARAM: &str = "stream";
|
|||
/// presence does not make a request untranslatable.
|
||||
const IGNORABLE_MESSAGE_FIELDS: &[&str] = &["name"];
|
||||
|
||||
/// How the upstream call is authenticated. API-key strategies are resolved in
|
||||
/// `prepare`; SigV4 needs the serialized body, so the handler signs it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ChatCompletionsAuth {
|
||||
Header { name: &'static str, value: String },
|
||||
Bearer { token: String },
|
||||
AwsSigV4 { region: String },
|
||||
}
|
||||
pub use litellm_auth::RequestAuth;
|
||||
|
||||
/// Why a request cannot be served by the Rust path.
|
||||
///
|
||||
|
|
@ -91,7 +84,7 @@ pub trait BaseConfig: Sync {
|
|||
model: &str,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<ChatCompletionsAuth, Error>;
|
||||
) -> Result<RequestAuth, Error>;
|
||||
|
||||
fn default_headers(&self) -> &'static [(&'static str, &'static str)] {
|
||||
&[("content-type", "application/json")]
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use data_url::{DataUrl, DataUrlError, forgiving_base64::DecodeError, mime::Mime};
|
||||
use litellm_http::{
|
||||
media::{DownloadPolicy, Error as MediaError, MediaFetcher},
|
||||
transport::Error as TransportError,
|
||||
};
|
||||
use reqwest::Url;
|
||||
|
||||
use crate::{
|
||||
base_llm::ocr::{
|
||||
error::Error,
|
||||
transformation::{
|
||||
OCR_INLINE_MAX_BYTES, OCR_MAX_FETCH_REDIRECTS, OcrConnection, OcrDocument,
|
||||
},
|
||||
},
|
||||
custom_httpx::{
|
||||
media::{DownloadPolicy, Error as MediaError, MediaFetcher},
|
||||
transport::Error as TransportError,
|
||||
},
|
||||
use crate::base_llm::ocr::{
|
||||
error::Error,
|
||||
transformation::{OCR_INLINE_MAX_BYTES, OCR_MAX_FETCH_REDIRECTS, OcrConnection, OcrDocument},
|
||||
};
|
||||
|
||||
pub struct InlineDocument<'a>(DataUrl<'a>);
|
||||
|
|
@ -72,7 +68,7 @@ pub async fn inline_remote_document(
|
|||
url,
|
||||
DownloadPolicy {
|
||||
timeout: connection.timeout,
|
||||
max_bytes: connection.max_download_bytes,
|
||||
max_bytes: connection.settings.max_download_bytes,
|
||||
max_redirects: OCR_MAX_FETCH_REDIRECTS,
|
||||
},
|
||||
)
|
||||
|
|
@ -196,10 +192,8 @@ mod tests {
|
|||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
let client = crate::custom_httpx::llm_http_handler::OcrClient::for_test(
|
||||
provider_http,
|
||||
document_http,
|
||||
);
|
||||
let client =
|
||||
crate::base_llm::ocr::handler::OcrClient::for_test(provider_http, document_http);
|
||||
let converted = inline_remote_document(
|
||||
client.document_fetcher(),
|
||||
OcrDocument::ImageUrl {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,12 @@ pub enum Error {
|
|||
Unsupported(&'static str),
|
||||
#[error("invalid provider: {0}")]
|
||||
InvalidProvider(String),
|
||||
#[error("invalid model: {provider} has no model {model:?} - use one of: {}", supported.join(", "))]
|
||||
InvalidModel {
|
||||
provider: &'static str,
|
||||
model: String,
|
||||
supported: &'static [&'static str],
|
||||
},
|
||||
#[error("invalid request: {0}")]
|
||||
InvalidRequest(String),
|
||||
#[error("invalid response: {0}")]
|
||||
|
|
@ -95,11 +101,13 @@ pub enum Error {
|
|||
#[error(transparent)]
|
||||
Auth(#[from] litellm_auth::Error),
|
||||
#[error(transparent)]
|
||||
Transport(#[from] crate::custom_httpx::transport::Error),
|
||||
Transport(#[from] litellm_http::transport::Error),
|
||||
#[error(transparent)]
|
||||
Params(#[from] litellm_core_utils::params::Error),
|
||||
#[error(transparent)]
|
||||
Headers(#[from] crate::custom_httpx::http_handler::HeaderError),
|
||||
Headers(#[from] litellm_http::request::HeaderError),
|
||||
#[error(transparent)]
|
||||
Http(#[from] litellm_http::Error),
|
||||
}
|
||||
|
||||
impl From<litellm_host::machine::MachineFault> for Error {
|
||||
|
|
@ -125,9 +133,7 @@ impl Error {
|
|||
pub fn http_status_code(&self) -> Option<u16> {
|
||||
match self {
|
||||
Self::Provider { status, .. }
|
||||
| Self::Transport(crate::custom_httpx::transport::Error::Http { status, .. }) => {
|
||||
Some(*status)
|
||||
}
|
||||
| Self::Transport(litellm_http::transport::Error::Http { status, .. }) => Some(*status),
|
||||
error if error.is_request() => Some(400),
|
||||
_ => None,
|
||||
}
|
||||
|
|
@ -155,8 +161,10 @@ impl Error {
|
|||
| Self::DotModel
|
||||
| Self::InvalidRequest(_)
|
||||
| Self::InvalidProvider(_)
|
||||
| Self::InvalidModel { .. }
|
||||
| Self::Params(_)
|
||||
| Self::Headers(_)
|
||||
| Self::Http(_)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,22 +2,21 @@ use bytes::{Bytes, BytesMut};
|
|||
use futures_util::future::BoxFuture;
|
||||
use litellm_auth_gcp::VertexAuth;
|
||||
use litellm_host::event::WireRequest;
|
||||
use litellm_http::{ClientVariant, HttpClientConfig, HttpClientPool};
|
||||
use litellm_http::{
|
||||
ClientVariant, HttpClientConfig, HttpClientPool,
|
||||
media::{MediaFetcher, UrlPolicy},
|
||||
outbound::{OutboundRequest, RequestSigner},
|
||||
transport,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
base_llm::ocr::{
|
||||
error::Error,
|
||||
transformation::{
|
||||
BaseOcrConfig, DecodedOcrResponse, LiteLLMOcrResponse, OcrDocument, OcrResponseContext,
|
||||
PreparedOcrRequest, decode_request_value, decode_response,
|
||||
},
|
||||
},
|
||||
custom_httpx::{
|
||||
http_handler::{HeaderPolicy, execute_http_request, with_headers},
|
||||
media::{MediaFetcher, UrlPolicy},
|
||||
transport,
|
||||
use crate::base_llm::ocr::{
|
||||
error::Error,
|
||||
settings::{OcrSettings, Secrets},
|
||||
transformation::{
|
||||
BaseOcrConfig, DecodedOcrResponse, LiteLLMOcrResponse, OcrDocument, OcrResponseContext,
|
||||
PreparedOcrRequest, decode_request_value, decode_response,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -35,6 +34,8 @@ pub struct OcrClient {
|
|||
polling_http: reqwest::Client,
|
||||
document_fetcher: MediaFetcher,
|
||||
vertex_auth: VertexAuth,
|
||||
settings: OcrSettings,
|
||||
secrets: Secrets,
|
||||
}
|
||||
|
||||
impl OcrClient {
|
||||
|
|
@ -43,12 +44,16 @@ impl OcrClient {
|
|||
config: &HttpClientConfig,
|
||||
url_policy: UrlPolicy,
|
||||
vertex_auth: VertexAuth,
|
||||
settings: OcrSettings,
|
||||
secrets: Secrets,
|
||||
) -> Result<Self, litellm_http::Error> {
|
||||
Ok(Self {
|
||||
provider_http: pool.client(config, ClientVariant::Provider)?,
|
||||
polling_http: pool.client(config, ClientVariant::NoRedirect)?,
|
||||
document_fetcher: MediaFetcher::new(pool, config, url_policy)?,
|
||||
vertex_auth,
|
||||
settings,
|
||||
secrets,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +73,14 @@ impl OcrClient {
|
|||
&self.vertex_auth
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> &OcrSettings {
|
||||
&self.settings
|
||||
}
|
||||
|
||||
pub fn secrets(&self) -> &Secrets {
|
||||
&self.secrets
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn for_test(provider_http: reqwest::Client, document_http: reqwest::Client) -> Self {
|
||||
Self {
|
||||
|
|
@ -78,8 +91,20 @@ impl OcrClient {
|
|||
.expect("test polling client builds"),
|
||||
document_fetcher: MediaFetcher::for_test(document_http),
|
||||
vertex_auth: VertexAuth::default(),
|
||||
settings: OcrSettings::default(),
|
||||
secrets: std::sync::Arc::new(litellm_core_utils::settings::ProcessEnvironment),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn with_settings(self, settings: OcrSettings) -> Self {
|
||||
Self { settings, ..self }
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn with_secrets(self, secrets: Secrets) -> Self {
|
||||
Self { secrets, ..self }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rust counterpart of `BaseLLMHTTPHandler.async_ocr`: prepare the provider request,
|
||||
|
|
@ -92,8 +117,9 @@ pub async fn ocr<C: BaseOcrConfig>(
|
|||
) -> Result<LiteLLMOcrResponse, Error> {
|
||||
let http = config.prepare_request(request, client, hooks).await?;
|
||||
let url = http.url().to_string();
|
||||
let headers = request_headers(&http)?;
|
||||
let response = execute_http_request(client.provider_http(), http)
|
||||
let headers = http.headers().to_vec();
|
||||
let response = http
|
||||
.send(client.provider_http())
|
||||
.await
|
||||
.map_err(transport_error)?;
|
||||
if !response.status().is_success() {
|
||||
|
|
@ -128,21 +154,6 @@ pub async fn ocr<C: BaseOcrConfig>(
|
|||
.await
|
||||
}
|
||||
|
||||
fn request_headers(request: &reqwest::Request) -> Result<Vec<(String, String)>, Error> {
|
||||
request
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
value
|
||||
.to_str()
|
||||
.map(|value| (name.to_string(), value.to_string()))
|
||||
.map_err(|_| Error::RequestField {
|
||||
path: "headers".into(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn read_json_response<T: DeserializeOwned>(
|
||||
response: reqwest::Response,
|
||||
native: bool,
|
||||
|
|
@ -197,13 +208,13 @@ pub fn transport_error(error: reqwest::Error) -> Error {
|
|||
|
||||
pub async fn transform_request_body<C: BaseOcrConfig, B: Serialize>(
|
||||
config: &C,
|
||||
client: &OcrClient,
|
||||
request: &PreparedOcrRequest,
|
||||
url: &str,
|
||||
headers: &[(String, String)],
|
||||
body: B,
|
||||
signer: Option<&dyn RequestSigner>,
|
||||
hooks: &dyn CallHooks<Error>,
|
||||
) -> Result<reqwest::Request, Error> {
|
||||
) -> Result<OutboundRequest, Error> {
|
||||
let composed = litellm_core_utils::call_arguments::compose_body(
|
||||
&request.optional_params,
|
||||
&body,
|
||||
|
|
@ -219,7 +230,17 @@ pub async fn transform_request_body<C: BaseOcrConfig, B: Serialize>(
|
|||
});
|
||||
}
|
||||
config.validate_request_body(&changed.body)?;
|
||||
build_http_request(client, request, url, &changed.headers, &changed.body)
|
||||
let timeout = Some(request.connection.timeout);
|
||||
Ok(match signer {
|
||||
Some(signer) => OutboundRequest::signed_json(
|
||||
url.into(),
|
||||
changed.headers,
|
||||
&changed.body,
|
||||
timeout,
|
||||
signer,
|
||||
),
|
||||
None => OutboundRequest::json(url.into(), changed.headers, &changed.body, timeout),
|
||||
}?)
|
||||
}
|
||||
|
||||
fn wire_request(url: &str, headers: &[(String, String)], body: Value) -> WireRequest {
|
||||
|
|
@ -230,22 +251,18 @@ fn wire_request(url: &str, headers: &[(String, String)], body: Value) -> WireReq
|
|||
}
|
||||
}
|
||||
|
||||
pub fn build_http_request<B: Serialize>(
|
||||
client: &OcrClient,
|
||||
pub fn build_http_request(
|
||||
request: &PreparedOcrRequest,
|
||||
url: &str,
|
||||
headers: &[(String, String)],
|
||||
body: &B,
|
||||
) -> Result<reqwest::Request, Error> {
|
||||
let builder = client
|
||||
.provider_http()
|
||||
.post(url)
|
||||
.json(body)
|
||||
.timeout(request.connection.timeout);
|
||||
with_headers(builder, headers, HeaderPolicy::All)
|
||||
.build()
|
||||
.map_err(transport::Error::from)
|
||||
.map_err(Error::from)
|
||||
url: String,
|
||||
headers: Vec<(String, String)>,
|
||||
body: &impl Serialize,
|
||||
) -> Result<OutboundRequest, Error> {
|
||||
Ok(OutboundRequest::json(
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
Some(request.connection.timeout),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub async fn guardrail_document(
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
pub mod document;
|
||||
pub mod error;
|
||||
pub mod handler;
|
||||
pub mod settings;
|
||||
pub mod transformation;
|
||||
|
|
|
|||
147
litellm-rust/crates/llms/src/base_llm/ocr/settings.rs
Normal file
147
litellm-rust/crates/llms/src/base_llm/ocr/settings.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use litellm_core_utils::settings::Lookup;
|
||||
|
||||
pub type Secrets = Arc<dyn Lookup + Send + Sync>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OcrSettings {
|
||||
pub request_timeout: Duration,
|
||||
pub max_download_bytes: u64,
|
||||
pub poll_timeout: Duration,
|
||||
pub document_intelligence_api_version: String,
|
||||
pub document_intelligence_dpi: i64,
|
||||
pub vertex_project: Option<String>,
|
||||
pub vertex_location: Option<String>,
|
||||
pub enable_azure_ad_token_refresh: bool,
|
||||
}
|
||||
|
||||
impl Default for OcrSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
request_timeout: Duration::from_secs(6000),
|
||||
max_download_bytes: megabytes(50.0),
|
||||
poll_timeout: Duration::from_secs(120),
|
||||
document_intelligence_api_version: "2024-11-30".into(),
|
||||
document_intelligence_dpi: 96,
|
||||
vertex_project: None,
|
||||
vertex_location: None,
|
||||
enable_azure_ad_token_refresh: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OcrSettings {
|
||||
pub fn from_environment(env: &impl Lookup) -> Self {
|
||||
let defaults = Self::default();
|
||||
Self {
|
||||
request_timeout: env
|
||||
.parsed::<f64>("REQUEST_TIMEOUT")
|
||||
.and_then(|seconds| Duration::try_from_secs_f64(seconds).ok())
|
||||
.unwrap_or(defaults.request_timeout),
|
||||
max_download_bytes: env
|
||||
.parsed::<f64>("MAX_IMAGE_URL_DOWNLOAD_SIZE_MB")
|
||||
.filter(|size| size.is_finite())
|
||||
.map_or(defaults.max_download_bytes, megabytes),
|
||||
poll_timeout: env
|
||||
.parsed::<i64>("AZURE_OPERATION_POLLING_TIMEOUT")
|
||||
.map_or(defaults.poll_timeout, |seconds| {
|
||||
Duration::from_secs(seconds.max(0).unsigned_abs())
|
||||
}),
|
||||
document_intelligence_api_version: env
|
||||
.get("AZURE_DOCUMENT_INTELLIGENCE_API_VERSION")
|
||||
.unwrap_or(defaults.document_intelligence_api_version),
|
||||
document_intelligence_dpi: env
|
||||
.parsed("AZURE_DOCUMENT_INTELLIGENCE_DEFAULT_DPI")
|
||||
.unwrap_or(defaults.document_intelligence_dpi),
|
||||
..defaults
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn megabytes(size: f64) -> u64 {
|
||||
(size * 1024.0 * 1024.0) as u64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn env_of(values: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
|
||||
move |name| {
|
||||
values
|
||||
.iter()
|
||||
.find(|(key, _)| *key == name)
|
||||
.map(|(_, value)| value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_environment_keeps_the_python_defaults() {
|
||||
assert_eq!(
|
||||
OcrSettings::from_environment(&env_of(&[])),
|
||||
OcrSettings::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_setting_follows_its_environment_variable() {
|
||||
let settings = OcrSettings::from_environment(&env_of(&[
|
||||
("REQUEST_TIMEOUT", "30.5"),
|
||||
("MAX_IMAGE_URL_DOWNLOAD_SIZE_MB", "0.5"),
|
||||
("AZURE_OPERATION_POLLING_TIMEOUT", " 600 "),
|
||||
("AZURE_DOCUMENT_INTELLIGENCE_API_VERSION", "2025-01-01"),
|
||||
("AZURE_DOCUMENT_INTELLIGENCE_DEFAULT_DPI", "72"),
|
||||
]));
|
||||
assert_eq!(
|
||||
settings,
|
||||
OcrSettings {
|
||||
request_timeout: Duration::from_millis(30_500),
|
||||
max_download_bytes: 512 * 1024,
|
||||
poll_timeout: Duration::from_secs(600),
|
||||
document_intelligence_api_version: "2025-01-01".into(),
|
||||
document_intelligence_dpi: 72,
|
||||
..OcrSettings::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::zero_disables_downloads("0", 0)]
|
||||
#[case::negative_rejects_every_download("-1", 0)]
|
||||
#[case::fraction_truncates_like_int("0.0000001", 0)]
|
||||
#[case::unparsable_keeps_the_default("big", 50 * 1024 * 1024)]
|
||||
fn download_size_converts_megabytes_like_python(
|
||||
#[case] value: &'static str,
|
||||
#[case] bytes: u64,
|
||||
) {
|
||||
let env =
|
||||
move |name: &str| (name == "MAX_IMAGE_URL_DOWNLOAD_SIZE_MB").then(|| value.to_string());
|
||||
assert_eq!(
|
||||
OcrSettings::from_environment(&env).max_download_bytes,
|
||||
bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_polling_timeout_expires_immediately() {
|
||||
let env =
|
||||
|name: &str| (name == "AZURE_OPERATION_POLLING_TIMEOUT").then(|| "-5".to_string());
|
||||
assert_eq!(
|
||||
OcrSettings::from_environment(&env).poll_timeout,
|
||||
Duration::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_api_version_is_sent_as_is_like_python_str_of_getenv() {
|
||||
let env =
|
||||
|name: &str| (name == "AZURE_DOCUMENT_INTELLIGENCE_API_VERSION").then(String::new);
|
||||
assert_eq!(
|
||||
OcrSettings::from_environment(&env).document_intelligence_api_version,
|
||||
""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
use std::{collections::BTreeMap, future::Future, time::Duration};
|
||||
use std::{collections::BTreeMap, future::Future, sync::Arc, time::Duration};
|
||||
|
||||
use litellm_auth::{InputSource, SecretValue, Sourced, TokenProviderHandle};
|
||||
use litellm_core_utils::{
|
||||
call_arguments::CallArguments,
|
||||
serde_compat::{FiniteF64, LaxI64},
|
||||
settings::ProcessEnvironment,
|
||||
};
|
||||
use litellm_http::outbound::{OutboundRequest, RequestSigner};
|
||||
use serde::{
|
||||
Deserialize, Serialize,
|
||||
de::{DeserializeOwned, IntoDeserializer},
|
||||
|
|
@ -12,19 +14,15 @@ use serde::{
|
|||
use serde_json::{Map, Value};
|
||||
use serde_with::serde_as;
|
||||
|
||||
use crate::{
|
||||
base_llm::ocr::error::Error,
|
||||
custom_httpx::llm_http_handler::{
|
||||
CallHooks, OcrClient, read_response_bytes, transform_request_body,
|
||||
},
|
||||
use crate::base_llm::ocr::{
|
||||
error::Error,
|
||||
handler::{CallHooks, OcrClient, read_response_bytes, transform_request_body},
|
||||
settings::{OcrSettings, Secrets},
|
||||
};
|
||||
|
||||
pub const OCR_RESPONSE_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
pub const OCR_HTTP_TIMEOUT_SECS: u64 = 600;
|
||||
pub const OCR_INLINE_MAX_BYTES: usize = 50 * 1024 * 1024;
|
||||
pub const OCR_DOWNLOAD_MAX_BYTES: u64 = 50 * 1024 * 1024;
|
||||
pub const OCR_MAX_FETCH_REDIRECTS: usize = 10;
|
||||
pub const OCR_POLL_TIMEOUT_SECS: u64 = 120;
|
||||
pub const OCR_POLL_RETRY_SECS: u64 = 2;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -116,10 +114,8 @@ impl OcrCredentialInputs {
|
|||
pub struct OcrTransportConfig {
|
||||
pub extra_headers: Vec<(String, String)>,
|
||||
pub extra_headers_source: InputSource,
|
||||
pub timeout: Duration,
|
||||
pub max_download_bytes: u64,
|
||||
pub timeout: Option<Duration>,
|
||||
pub max_response_bytes: usize,
|
||||
pub poll_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for OcrTransportConfig {
|
||||
|
|
@ -127,10 +123,8 @@ impl Default for OcrTransportConfig {
|
|||
Self {
|
||||
extra_headers: Vec::new(),
|
||||
extra_headers_source: InputSource::Deployment,
|
||||
timeout: Duration::from_secs(OCR_HTTP_TIMEOUT_SECS),
|
||||
max_download_bytes: OCR_DOWNLOAD_MAX_BYTES,
|
||||
timeout: None,
|
||||
max_response_bytes: OCR_RESPONSE_MAX_BYTES,
|
||||
poll_timeout: Duration::from_secs(OCR_POLL_TIMEOUT_SECS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -145,7 +139,7 @@ impl OcrTransportConfig {
|
|||
Self {
|
||||
extra_headers,
|
||||
extra_headers_source,
|
||||
timeout: timeout.unwrap_or(self.timeout),
|
||||
timeout: timeout.or(self.timeout),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
|
@ -166,13 +160,18 @@ pub struct OcrConnection {
|
|||
pub extra_headers: Vec<(String, String)>,
|
||||
pub extra_headers_source: InputSource,
|
||||
pub timeout: Duration,
|
||||
pub max_download_bytes: u64,
|
||||
pub max_response_bytes: usize,
|
||||
pub poll_timeout: Duration,
|
||||
pub settings: OcrSettings,
|
||||
pub secrets: Secrets,
|
||||
}
|
||||
|
||||
impl OcrConnection {
|
||||
pub fn new(credentials: ResolvedOcrCredentials, transport: OcrTransportConfig) -> Self {
|
||||
pub fn new(
|
||||
credentials: ResolvedOcrCredentials,
|
||||
transport: OcrTransportConfig,
|
||||
settings: OcrSettings,
|
||||
secrets: Secrets,
|
||||
) -> Self {
|
||||
let api_key_source = credentials
|
||||
.api_key
|
||||
.as_ref()
|
||||
|
|
@ -190,12 +189,19 @@ impl OcrConnection {
|
|||
api_base_source,
|
||||
extra_headers: transport.extra_headers,
|
||||
extra_headers_source: transport.extra_headers_source,
|
||||
timeout: transport.timeout,
|
||||
max_download_bytes: transport.max_download_bytes,
|
||||
timeout: transport
|
||||
.timeout
|
||||
.filter(|timeout| !timeout.is_zero())
|
||||
.unwrap_or(settings.request_timeout),
|
||||
max_response_bytes: transport.max_response_bytes,
|
||||
poll_timeout: transport.poll_timeout,
|
||||
settings,
|
||||
secrets,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secret(&self, name: &str) -> Option<String> {
|
||||
self.secrets.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OcrConnection {
|
||||
|
|
@ -203,6 +209,8 @@ impl Default for OcrConnection {
|
|||
Self::new(
|
||||
ResolvedOcrCredentials::default(),
|
||||
OcrTransportConfig::default(),
|
||||
OcrSettings::default(),
|
||||
Arc::new(ProcessEnvironment),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -387,6 +395,10 @@ const HEALTH_CHECK_PDF_DATA_URI: &str = "data:application/pdf;base64,JVBERi0xLjQ
|
|||
/// (headers at minimum; Vertex also carries the project id).
|
||||
pub trait OcrEnvironment: Send + Sync {
|
||||
fn headers(&self) -> &[(String, String)];
|
||||
|
||||
fn signer(&self) -> Option<&dyn RequestSigner> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl OcrEnvironment for Vec<(String, String)> {
|
||||
|
|
@ -529,7 +541,7 @@ pub trait BaseOcrConfig: Send + Sync + Sized + 'static {
|
|||
request: &PreparedOcrRequest,
|
||||
client: &OcrClient,
|
||||
hooks: &dyn CallHooks<Error>,
|
||||
) -> impl Future<Output = Result<reqwest::Request, Error>> + Send {
|
||||
) -> impl Future<Output = Result<OutboundRequest, Error>> + Send {
|
||||
async move {
|
||||
let params = self.map_ocr_params(&request.optional_params, &request.model)?;
|
||||
let environment = self.validate_environment(request, client).await?;
|
||||
|
|
@ -547,7 +559,16 @@ pub trait BaseOcrConfig: Send + Sync + Sized + 'static {
|
|||
},
|
||||
)
|
||||
.await?;
|
||||
transform_request_body(self, client, request, &url, headers, body, hooks).await
|
||||
transform_request_body(
|
||||
self,
|
||||
request,
|
||||
&url,
|
||||
headers,
|
||||
body,
|
||||
environment.signer(),
|
||||
hooks,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -565,16 +586,38 @@ pub fn decode_and_normalize_response<T: DeserializeOwned>(
|
|||
})
|
||||
}
|
||||
|
||||
pub fn credential_env(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connection_timeout_falls_back_to_the_request_timeout_setting_like_a_python_or() {
|
||||
let settings = OcrSettings {
|
||||
request_timeout: Duration::from_secs(42),
|
||||
..OcrSettings::default()
|
||||
};
|
||||
let timeout = |call: Option<Duration>| {
|
||||
OcrConnection::new(
|
||||
ResolvedOcrCredentials::default(),
|
||||
OcrTransportConfig {
|
||||
timeout: call,
|
||||
..OcrTransportConfig::default()
|
||||
},
|
||||
settings.clone(),
|
||||
Arc::new(ProcessEnvironment),
|
||||
)
|
||||
.timeout
|
||||
};
|
||||
assert_eq!(timeout(None), Duration::from_secs(42));
|
||||
assert_eq!(timeout(Some(Duration::ZERO)), Duration::from_secs(42));
|
||||
assert_eq!(
|
||||
timeout(Some(Duration::from_secs(5))),
|
||||
Duration::from_secs(5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_response_rejects_invalid_shared_fields() {
|
||||
for fields in [
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ use serde_json::{Map, Value, json};
|
|||
|
||||
use crate::base_llm::{
|
||||
audio_transcription::transformation::{
|
||||
AudioTranscriptionAuth, AudioTranscriptionRequestData, AudioTranscriptionResponseData,
|
||||
BaseAudioTranscriptionConfig,
|
||||
AudioTranscriptionRequestData, AudioTranscriptionResponseData,
|
||||
BaseAudioTranscriptionConfig, RequestAuth,
|
||||
},
|
||||
chat::transformation::Error,
|
||||
};
|
||||
|
|
@ -136,9 +136,9 @@ impl BaseAudioTranscriptionConfig for BedrockAudioTranscriptionConfig {
|
|||
model: &str,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<AudioTranscriptionAuth, Error> {
|
||||
) -> Result<RequestAuth, Error> {
|
||||
let (_, model_region) = bedrock_model_id_and_region(model);
|
||||
Ok(AudioTranscriptionAuth::AwsSigV4 {
|
||||
Ok(RequestAuth::AwsSigV4 {
|
||||
region: resolve_bedrock_region(model_region.as_deref(), optional_params, env_lookup),
|
||||
service: BEDROCK_SERVICE,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use litellm_auth_aws::{
|
||||
bedrock_model_id_and_region,
|
||||
constants::{AWS_BEARER_TOKEN_BEDROCK, BEDROCK_RUNTIME_ENDPOINT_TEMPLATE},
|
||||
constants::{AWS_BEARER_TOKEN_BEDROCK, BEDROCK_RUNTIME_ENDPOINT_TEMPLATE, BEDROCK_SERVICE},
|
||||
resolve_bedrock_region,
|
||||
};
|
||||
use litellm_core_utils::{
|
||||
|
|
@ -17,8 +17,8 @@ use litellm_types::{
|
|||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::base_llm::chat::transformation::{
|
||||
BaseConfig, ChatCompletionsAuth, Error, ProviderChatRequestData, ProviderChatResponseData,
|
||||
Unsupported, unsupported_message, unsupported_param,
|
||||
BaseConfig, Error, ProviderChatRequestData, ProviderChatResponseData, RequestAuth, Unsupported,
|
||||
unsupported_message, unsupported_param,
|
||||
};
|
||||
|
||||
/// Converse parameter names, post `map_openai_params`, that the Rust path can
|
||||
|
|
@ -186,7 +186,7 @@ impl BaseConfig for AmazonConverseConfig {
|
|||
model: &str,
|
||||
optional_params: &Map<String, Value>,
|
||||
env_lookup: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Result<ChatCompletionsAuth, Error> {
|
||||
) -> Result<RequestAuth, Error> {
|
||||
// Python reads `api_key` as the Bedrock bearer token and consults the
|
||||
// env only when the caller passed none, so a caller-supplied empty key
|
||||
// falls through to SigV4 without reaching for the environment. An
|
||||
|
|
@ -199,11 +199,12 @@ impl BaseConfig for AmazonConverseConfig {
|
|||
}
|
||||
.filter(|token| !token.is_empty());
|
||||
if let Some(token) = bearer {
|
||||
return Ok(ChatCompletionsAuth::Bearer { token });
|
||||
return Ok(RequestAuth::Bearer { token });
|
||||
}
|
||||
let (_, model_region) = bedrock_model_id_and_region(model);
|
||||
Ok(ChatCompletionsAuth::AwsSigV4 {
|
||||
Ok(RequestAuth::AwsSigV4 {
|
||||
region: resolve_bedrock_region(model_region.as_deref(), optional_params, env_lookup),
|
||||
service: BEDROCK_SERVICE,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -281,8 +281,9 @@ fn signs_with_sigv4_in_the_resolved_region() {
|
|||
&|_| None
|
||||
)
|
||||
.expect("auth resolves"),
|
||||
ChatCompletionsAuth::AwsSigV4 {
|
||||
region: "eu-central-1".to_string()
|
||||
RequestAuth::AwsSigV4 {
|
||||
region: "eu-central-1".to_string(),
|
||||
service: "bedrock",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -306,11 +307,12 @@ fn a_bearer_token_outranks_sigv4_the_way_python_resolves_it() {
|
|||
)
|
||||
.expect("auth resolves")
|
||||
};
|
||||
let bearer = |token: &str| ChatCompletionsAuth::Bearer {
|
||||
let bearer = |token: &str| RequestAuth::Bearer {
|
||||
token: token.to_string(),
|
||||
};
|
||||
let sigv4 = ChatCompletionsAuth::AwsSigV4 {
|
||||
let sigv4 = RequestAuth::AwsSigV4 {
|
||||
region: "eu-central-1".to_string(),
|
||||
service: "bedrock",
|
||||
};
|
||||
|
||||
// A caller-supplied key is the bearer token, and outranks the env.
|
||||
|
|
|
|||
|
|
@ -7,17 +7,15 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::{Map, Value};
|
||||
use serde_with::serde_as;
|
||||
|
||||
use crate::{
|
||||
base_llm::ocr::{
|
||||
document::InlineDocument,
|
||||
error::Error,
|
||||
transformation::{
|
||||
BaseOcrConfig, LiteLLMOcrResponse, OCR_INLINE_MAX_BYTES, OcrConnection, OcrDocument,
|
||||
OcrPage, OcrPageImage, OcrResponseFormat, OcrUsageInfo, PreparedOcrRequest,
|
||||
credential_env, decode_and_normalize_response, decode_response_value,
|
||||
},
|
||||
use crate::base_llm::ocr::{
|
||||
document::InlineDocument,
|
||||
error::Error,
|
||||
handler::OcrClient,
|
||||
transformation::{
|
||||
BaseOcrConfig, LiteLLMOcrResponse, OCR_INLINE_MAX_BYTES, OcrConnection, OcrDocument,
|
||||
OcrPage, OcrPageImage, OcrResponseFormat, OcrUsageInfo, PreparedOcrRequest,
|
||||
decode_and_normalize_response, decode_response_value,
|
||||
},
|
||||
custom_httpx::llm_http_handler::OcrClient,
|
||||
};
|
||||
|
||||
const COHERE_PARSE_API_BASE: &str = "https://api.cohere.com";
|
||||
|
|
@ -124,7 +122,9 @@ impl BaseOcrConfig for CohereParseConfig {
|
|||
request: &PreparedOcrRequest,
|
||||
_client: &OcrClient,
|
||||
) -> Result<Self::Environment, Error> {
|
||||
self.resolve_headers(&request.connection, &credential_env)
|
||||
self.resolve_headers(&request.connection, &|name: &str| {
|
||||
request.connection.secret(name)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_complete_url(
|
||||
|
|
@ -163,7 +163,7 @@ impl BaseOcrConfig for CohereParseConfig {
|
|||
}
|
||||
|
||||
fn validate_request_body(&self, body: &Value) -> Result<(), Error> {
|
||||
validate_document(&crate::custom_httpx::llm_http_handler::body_document(body)?)
|
||||
validate_document(&crate::base_llm::ocr::handler::body_document(body)?)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,8 +173,7 @@ impl CohereParseConfig {
|
|||
connection: &OcrConnection,
|
||||
env_lookup: &(dyn Fn(&str) -> Option<String> + Sync),
|
||||
) -> Result<Vec<(String, String)>, Error> {
|
||||
if crate::custom_httpx::http_handler::has_header(&connection.extra_headers, "authorization")
|
||||
{
|
||||
if litellm_http::request::has_header(&connection.extra_headers, "authorization") {
|
||||
return Ok(connection.extra_headers.clone());
|
||||
}
|
||||
let key = connection
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
pub mod http_handler;
|
||||
pub mod llm_http_handler;
|
||||
pub mod media;
|
||||
pub mod transport;
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
pub mod anthropic;
|
||||
pub mod aws_textract;
|
||||
pub mod azure_ai;
|
||||
pub mod base_llm;
|
||||
pub mod bedrock;
|
||||
pub mod cohere;
|
||||
pub mod custom_httpx;
|
||||
pub mod mistral;
|
||||
pub mod openai;
|
||||
pub mod reducto;
|
||||
|
|
|
|||
|
|
@ -2,16 +2,13 @@ use litellm_core_utils::{call_arguments::CallArguments, params::OpaqueParams, ur
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
base_llm::ocr::{
|
||||
error::Error,
|
||||
transformation::{
|
||||
BaseOcrConfig, LiteLLMOcrResponse, OcrConnection, OcrDocument, OcrPage,
|
||||
OcrResponseFormat, OcrUsageInfo, PreparedOcrRequest, credential_env,
|
||||
decode_and_normalize_response,
|
||||
},
|
||||
use crate::base_llm::ocr::{
|
||||
error::Error,
|
||||
handler::OcrClient,
|
||||
transformation::{
|
||||
BaseOcrConfig, LiteLLMOcrResponse, OcrConnection, OcrDocument, OcrPage, OcrResponseFormat,
|
||||
OcrUsageInfo, PreparedOcrRequest, decode_and_normalize_response,
|
||||
},
|
||||
custom_httpx::llm_http_handler::OcrClient,
|
||||
};
|
||||
|
||||
const MISTRAL_OCR_API_BASE: &str = "https://api.mistral.ai/v1";
|
||||
|
|
@ -87,7 +84,9 @@ impl BaseOcrConfig for MistralOcrConfig {
|
|||
request: &PreparedOcrRequest,
|
||||
_client: &OcrClient,
|
||||
) -> Result<Self::Environment, Error> {
|
||||
self.resolve_headers(&request.connection, &credential_env)
|
||||
self.resolve_headers(&request.connection, &|name: &str| {
|
||||
request.connection.secret(name)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_complete_url(
|
||||
|
|
@ -129,8 +128,7 @@ impl MistralOcrConfig {
|
|||
connection: &OcrConnection,
|
||||
env_lookup: &(dyn Fn(&str) -> Option<String> + Sync),
|
||||
) -> Result<Vec<(String, String)>, Error> {
|
||||
if crate::custom_httpx::http_handler::has_header(&connection.extra_headers, "authorization")
|
||||
{
|
||||
if litellm_http::request::has_header(&connection.extra_headers, "authorization") {
|
||||
return Ok(connection.extra_headers.clone());
|
||||
}
|
||||
let api_key = connection
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue