mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge pull request #29372 from BerriAI/litellm_internal_staging
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CodSpeed Benchmarks / benchmarks (push) Has been cancelled
Helm unit test / unit-test (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Has been cancelled
Unit Tests: Security / security (push) Has been cancelled
GitHub Actions Security Analysis / zizmor (push) Has been cancelled
Unit Tests: Proxy DB Operations / auth-checks (push) Has been cancelled
Unit Tests: Proxy DB Operations / budgets (push) Has been cancelled
Unit Tests: Proxy DB Operations / custom-logging (push) Has been cancelled
Unit Tests: Proxy DB Operations / db-and-spend (push) Has been cancelled
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Has been cancelled
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Has been cancelled
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Has been cancelled
Unit Tests: Proxy DB Operations / key-generation (push) Has been cancelled
Unit Tests: Proxy DB Operations / logging-misc (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-runtime (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-server-core (push) Has been cancelled
Unit Tests: Proxy DB Operations / schema-migration (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-utils (push) Has been cancelled
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CodSpeed Benchmarks / benchmarks (push) Has been cancelled
Helm unit test / unit-test (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Has been cancelled
Unit Tests: Security / security (push) Has been cancelled
GitHub Actions Security Analysis / zizmor (push) Has been cancelled
Unit Tests: Proxy DB Operations / auth-checks (push) Has been cancelled
Unit Tests: Proxy DB Operations / budgets (push) Has been cancelled
Unit Tests: Proxy DB Operations / custom-logging (push) Has been cancelled
Unit Tests: Proxy DB Operations / db-and-spend (push) Has been cancelled
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Has been cancelled
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Has been cancelled
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Has been cancelled
Unit Tests: Proxy DB Operations / key-generation (push) Has been cancelled
Unit Tests: Proxy DB Operations / logging-misc (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-runtime (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-server-core (push) Has been cancelled
Unit Tests: Proxy DB Operations / schema-migration (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-utils (push) Has been cancelled
chore(ci): promote internal staging to main
This commit is contained in:
commit
5be0797d24
924 changed files with 48636 additions and 14662 deletions
|
|
@ -2400,6 +2400,11 @@ jobs:
|
|||
environment:
|
||||
DATABASE_URL: "postgresql://e2euser:e2epassword@localhost:5432/litellm_e2e"
|
||||
CI: "true"
|
||||
# Boot the proxy with an external logout URL so proxyLogoutUrl.spec.ts can
|
||||
# assert the redirect. Set at job level so both the proxy boot step and the
|
||||
# Playwright step (whose skip guard reads this) see the same value. Safe for
|
||||
# the rest of the suite: nothing else performs a logout.
|
||||
PROXY_LOGOUT_URL: "https://www.example.com"
|
||||
steps:
|
||||
- checkout
|
||||
- setup_google_dns
|
||||
|
|
@ -2476,7 +2481,8 @@ jobs:
|
|||
MOCK_LLM_URL: "http://127.0.0.1:8090/v1"
|
||||
DISABLE_SCHEMA_UPDATE: "true"
|
||||
SERVER_ROOT_PATH: ""
|
||||
PROXY_LOGOUT_URL: ""
|
||||
# PROXY_LOGOUT_URL is inherited from the job-level environment so the
|
||||
# proxy and proxyLogoutUrl.spec.ts agree on the logout target.
|
||||
# LITELLM_LICENSE is forwarded from the project env so premium-gated
|
||||
# UI flows can be exercised. license.spec.ts asserts the resulting
|
||||
# JWT carries premium_user=true; if it ever stops being passed, that
|
||||
|
|
|
|||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
|
|
@ -10,9 +10,9 @@
|
|||
|
||||
**Please complete all items before asking a LiteLLM maintainer to review your PR**
|
||||
|
||||
- [ ] I have Added testing in the [`tests/test_litellm/`](https://github.com/BerriAI/litellm/tree/main/tests/test_litellm) directory, **Adding at least 1 test is a hard requirement** - [see details](https://docs.litellm.ai/docs/extras/contributing_code)
|
||||
- [ ] I have added meaningful tests
|
||||
- [ ] My PR passes all unit tests on [`make test-unit`](https://docs.litellm.ai/docs/extras/contributing_code)
|
||||
- [ ] My PR's scope is as isolated as possible, it only solves 1 specific problem
|
||||
- [ ] My PR's scope is as isolated as possible; it only solves 1 specific problem
|
||||
- [ ] I have requested a Greptile review by commenting `@greptileai` and received a **Confidence Score of at least 4/5** before requesting a maintainer review
|
||||
|
||||
## Delays in PR merge?
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ jobs:
|
|||
tests/test_litellm/proxy/image_endpoints
|
||||
tests/test_litellm/proxy/vector_store_endpoints
|
||||
tests/test_litellm/proxy/agent_endpoints
|
||||
tests/test_litellm/proxy/a2a
|
||||
tests/test_litellm/proxy/discovery_endpoints
|
||||
tests/test_litellm/proxy/health_endpoints
|
||||
tests/test_litellm/proxy/public_endpoints
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -28,6 +28,8 @@ litellm/tests/config_*.yaml
|
|||
litellm/tests/langfuse.log
|
||||
langfuse.log
|
||||
.langfuse.log
|
||||
.pin_list.txt
|
||||
.cov_new.xml
|
||||
litellm/tests/test_custom_logger.py
|
||||
litellm/tests/langfuse.log
|
||||
litellm/tests/dynamo*.log
|
||||
|
|
@ -120,4 +122,5 @@ crash.log
|
|||
crash.*.log
|
||||
# .terraform.lock.hcl is intentionally NOT ignored — it pins provider versions
|
||||
# and should be committed.
|
||||
.vscode
|
||||
.vscode
|
||||
.pin_list.txt
|
||||
|
|
|
|||
307
AGENTS.md
307
AGENTS.md
|
|
@ -1,306 +1 @@
|
|||
# INSTRUCTIONS FOR LITELLM
|
||||
|
||||
This document provides comprehensive instructions for AI agents working in the LiteLLM repository.
|
||||
|
||||
## Confidentiality: Customer and Company Names in Code
|
||||
|
||||
The codebase is public. Before writing **any** third-party organization name into this repository — in source code, file or directory names, docstrings, comments, tests, fixtures, mock payloads, error messages, log lines, commit messages, or PR descriptions — pause and check:
|
||||
|
||||
**Already in the codebase** (OpenAI, Anthropic, Google, Azure, Bedrock, Fireworks, and other established LLM providers / integrations) — fine to use. Quick check: `git grep -i "<name>"` — if it returns hits in real code (not just your current diff), the name is established.
|
||||
|
||||
**Anything else** — customers, prospects, partners, new vendor integrations, observability tools, infra vendors, or any organization name that does not already appear in the repo. STOP and surface it to the user. Ask for explicit consent before writing the name into any file, commit message, or PR description. Do not write it speculatively and clean up later. Do not substitute a placeholder and proceed. Do not assume it is safe because it "looks like" a public company. The user must approve first.
|
||||
|
||||
**What to do instead of a customer-specific reference:**
|
||||
- If you find yourself reaching for a customer name — real or fake — step back. The code shouldn't be customer-specific in the first place. Generalize the feature, or capture the customer motivation in internal docs (Notion / Linear / the internal staging PR description), never in the repo.
|
||||
- Frame changes by the capability they add, not the customer who asked for it ("add per-team Bedrock guardrail routing", not "add routing for $CUSTOMER").
|
||||
- Standard "fake value" markers (`example.com`, `localhost`, `127.0.0.1`, `test@example.com`) and abstract identifiers (`team_a`, `user_1`, `tenant_x`) are fine — those are not customer stand-ins.
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
LiteLLM is a unified interface for 100+ LLMs that:
|
||||
- Translates inputs to provider-specific completion, embedding, and image generation endpoints
|
||||
- Provides consistent OpenAI-format output across all providers
|
||||
- Includes retry/fallback logic across multiple deployments (Router)
|
||||
- Offers a proxy server (LLM Gateway) with budgets, rate limits, and authentication
|
||||
- Supports advanced features like function calling, streaming, caching, and observability
|
||||
|
||||
## REPOSITORY STRUCTURE
|
||||
|
||||
### Core Components
|
||||
- `litellm/` - Main library code
|
||||
- `llms/` - Provider-specific implementations (OpenAI, Anthropic, Azure, etc.)
|
||||
- `proxy/` - Proxy server implementation (LLM Gateway)
|
||||
- `router_utils/` - Load balancing and fallback logic
|
||||
- `types/` - Type definitions and schemas
|
||||
- `integrations/` - Third-party integrations (observability, caching, etc.)
|
||||
|
||||
### Key Directories
|
||||
- `tests/` - Comprehensive test suites
|
||||
- `ui/litellm-dashboard/` - Admin dashboard UI
|
||||
- `enterprise/` - Enterprise-specific features
|
||||
|
||||
Documentation lives in the separate [BerriAI/litellm-docs](https://github.com/BerriAI/litellm-docs) repository and is served at [docs.litellm.ai](https://docs.litellm.ai).
|
||||
|
||||
## DEVELOPMENT GUIDELINES
|
||||
|
||||
### MAKING CODE CHANGES
|
||||
|
||||
1. **Provider Implementations**: When adding/modifying LLM providers:
|
||||
- Follow existing patterns in `litellm/llms/{provider}/`
|
||||
- Implement proper transformation classes that inherit from `BaseConfig`
|
||||
- Support both sync and async operations
|
||||
- Handle streaming responses appropriately
|
||||
- Include proper error handling with provider-specific exceptions
|
||||
|
||||
2. **Type Safety**:
|
||||
- Use proper type hints throughout
|
||||
- Update type definitions in `litellm/types/`
|
||||
- Ensure compatibility with both Pydantic v1 and v2
|
||||
|
||||
3. **Testing**:
|
||||
- Add tests in appropriate `tests/` subdirectories
|
||||
- Include both unit tests and integration tests
|
||||
- Test provider-specific functionality thoroughly
|
||||
- Consider adding load tests for performance-critical changes
|
||||
|
||||
### MAKING CODE CHANGES FOR THE UI (IGNORE FOR BACKEND)
|
||||
|
||||
1. **Always use `antd` for new UI components — Tremor is DEPRECATED**
|
||||
- We are migrating off of `@tremor/react`. Do not introduce new `Badge`, `Text`, `Card`, `Grid`, `Title`, or other imports from `@tremor/react` in any new or modified file.
|
||||
- Use `antd` equivalents: `Tag` for labels, plain `<span>`/`<div>` with Tailwind classes (or `Typography.Text`) for text, `Card` from `antd`, etc. Note that `antd` has no `"yellow"` Tag color — use `"gold"` for amber/yellow.
|
||||
- The only exception is the Tremor Table component and its required Tremor Table sub components.
|
||||
|
||||
2. **Use Common Components as much as possible**:
|
||||
- These are usually defined in the `common_components` directory
|
||||
- Use these components as much as possible and avoid building new components unless needed
|
||||
|
||||
3. **Testing**:
|
||||
- The codebase uses **Vitest** and **React Testing Library**
|
||||
- **Query Priority Order**: Use query methods in this order: `getByRole`, `getByLabelText`, `getByPlaceholderText`, `getByText`, `getByTestId`
|
||||
- **Always use `screen`** instead of destructuring from `render()` (e.g., use `screen.getByText()` not `getByText`)
|
||||
- **Wrap user interactions in `act()`**: Always wrap `fireEvent` calls with `act()` to ensure React state updates are properly handled
|
||||
- **Use `query` methods for absence checks**: Use `queryBy*` methods (not `getBy*`) when expecting an element to NOT be present
|
||||
- **Test names must start with "should"**: All test names should follow the pattern `it("should ...")`
|
||||
- **Mock external dependencies**: Check `setupTests.ts` for global mocks and mock child components/networking calls as needed
|
||||
- **Structure tests properly**:
|
||||
- First test should verify the component renders successfully
|
||||
- Subsequent tests should focus on functionality and user interactions
|
||||
- Use `waitFor` for async operations that aren't already awaited
|
||||
- **Avoid using `querySelector`**: Prefer React Testing Library queries over direct DOM manipulation
|
||||
|
||||
### IMPORTANT PATTERNS
|
||||
|
||||
1. **Function/Tool Calling**:
|
||||
- LiteLLM standardizes tool calling across providers
|
||||
- OpenAI format is the standard, with transformations for other providers
|
||||
- See `litellm/llms/anthropic/chat/transformation.py` for complex tool handling
|
||||
|
||||
2. **Streaming**:
|
||||
- All providers should support streaming where possible
|
||||
- Use consistent chunk formatting across providers
|
||||
- Handle both sync and async streaming
|
||||
|
||||
3. **Error Handling**:
|
||||
- Use provider-specific exception classes
|
||||
- Maintain consistent error formats across providers
|
||||
- Include proper retry logic and fallback mechanisms
|
||||
|
||||
4. **Configuration**:
|
||||
- Support both environment variables and programmatic configuration
|
||||
- Use `BaseConfig` classes for provider configurations
|
||||
- Allow dynamic parameter passing
|
||||
|
||||
## PROXY SERVER (LLM GATEWAY)
|
||||
|
||||
The proxy server is a critical component that provides:
|
||||
- Authentication and authorization
|
||||
- Rate limiting and budget management
|
||||
- Load balancing across multiple models/deployments
|
||||
- Observability and logging
|
||||
- Admin dashboard UI
|
||||
- Enterprise features
|
||||
|
||||
Key files:
|
||||
- `litellm/proxy/proxy_server.py` - Main server implementation
|
||||
- `litellm/proxy/auth/` - Authentication logic
|
||||
- `litellm/proxy/management_endpoints/` - Admin API endpoints
|
||||
|
||||
**Database (proxy)**: Use Prisma model methods (`prisma_client.db.<model>.upsert`, `.find_many`, `.find_unique`, etc.), not raw SQL (`execute_raw`/`query_raw`). See COMMON PITFALLS for details.
|
||||
|
||||
## MCP (MODEL CONTEXT PROTOCOL) SUPPORT
|
||||
|
||||
LiteLLM supports MCP for agent workflows:
|
||||
- MCP server integration for tool calling
|
||||
- Transformation between OpenAI and MCP tool formats
|
||||
- Support for external MCP servers (Zapier, Jira, Linear, etc.)
|
||||
- See `litellm/experimental_mcp_client/` and `litellm/proxy/_experimental/mcp_server/`
|
||||
|
||||
## RUNNING SCRIPTS
|
||||
|
||||
Use `uv run python script.py` to run Python scripts in the project environment (for non-test files).
|
||||
|
||||
## GITHUB TEMPLATES
|
||||
|
||||
When opening issues or pull requests, follow these templates:
|
||||
|
||||
### Bug Reports (`.github/ISSUE_TEMPLATE/bug_report.yml`)
|
||||
- Describe what happened vs. expected behavior
|
||||
- Include relevant log output
|
||||
- Specify LiteLLM version
|
||||
- Indicate if you're part of an ML Ops team (helps with prioritization)
|
||||
|
||||
### Feature Requests (`.github/ISSUE_TEMPLATE/feature_request.yml`)
|
||||
- Clearly describe the feature
|
||||
- Explain motivation and use case with concrete examples
|
||||
|
||||
### Pull Requests (`.github/pull_request_template.md`)
|
||||
- Add at least 1 test in `tests/litellm/`
|
||||
- Ensure `make test-unit` passes
|
||||
|
||||
|
||||
## TESTING CONSIDERATIONS
|
||||
|
||||
1. **Provider Tests**: Test against real provider APIs when possible
|
||||
2. **Proxy Tests**: Include authentication, rate limiting, and routing tests
|
||||
3. **Performance Tests**: Load testing for high-throughput scenarios
|
||||
4. **Integration Tests**: End-to-end workflows including tool calling
|
||||
|
||||
## DOCUMENTATION
|
||||
|
||||
- Keep documentation in sync with code changes
|
||||
- Update provider documentation when adding new providers
|
||||
- Include code examples for new features
|
||||
- Update changelog and release notes
|
||||
|
||||
## SECURITY CONSIDERATIONS
|
||||
|
||||
- Handle API keys securely
|
||||
- Validate all inputs, especially for proxy endpoints
|
||||
- Consider rate limiting and abuse prevention
|
||||
- Follow security best practices for authentication
|
||||
|
||||
## ENTERPRISE FEATURES
|
||||
|
||||
- Some features are enterprise-only
|
||||
- Check `enterprise/` directory for enterprise-specific code
|
||||
- Maintain compatibility between open-source and enterprise versions
|
||||
|
||||
## COMMON PITFALLS TO AVOID
|
||||
|
||||
1. **Breaking Changes**: LiteLLM has many users - avoid breaking existing APIs
|
||||
2. **Provider Specifics**: Each provider has unique quirks - handle them properly
|
||||
3. **Rate Limits**: Respect provider rate limits in tests
|
||||
4. **Memory Usage**: Be mindful of memory usage in streaming scenarios
|
||||
5. **Dependencies**: Keep dependencies minimal and well-justified
|
||||
6. **UI/Backend Contract Mismatch**: When adding a new entity type to the UI, always check whether the backend endpoint accepts a single value or an array. Match the UI control accordingly (single-select vs. multi-select) to avoid silently dropping user selections
|
||||
7. **Missing Tests for New Entity Types**: When adding a new entity type (e.g., in `EntityUsage`, `UsageViewSelect`), always add corresponding tests in the existing test files and update any icon/component mocks
|
||||
8. **Raw SQL in proxy DB code**: Do not use `execute_raw` or `query_raw` for proxy database access. Use Prisma model methods (e.g. `prisma_client.db.litellm_tooltable.upsert()`, `.find_many()`, `.find_unique()`) so behavior stays consistent with the schema, the client stays mockable in tests, and you avoid the pitfalls of hand-written SQL (parameter ordering, type casting, schema drift)
|
||||
|
||||
8. **Do not hardcode model-specific flags**: Put model-specific capability flags in `model_prices_and_context_window.json` and read them via `get_model_info` (or existing helpers like `supports_reasoning`). This prevents users from needing to upgrade LiteLLM each time a new model supports a feature.
|
||||
|
||||
**Example of BAD** (hardcoded model checks):
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
def _is_effort_supported_model(model: str) -> bool:
|
||||
"""Check if the model supports the output_config.effort parameter..."""
|
||||
model_lower = model.lower()
|
||||
if AnthropicConfig._is_claude_4_6_model(model):
|
||||
return True
|
||||
return any(
|
||||
v in model_lower for v in ("opus-4-5", "opus_4_5", "opus-4.5", "opus_4.5")
|
||||
)
|
||||
```
|
||||
|
||||
**Example of GOOD** (config-driven or helper that reads from config):
|
||||
|
||||
```python
|
||||
if (
|
||||
"claude-3-7-sonnet" in model
|
||||
or AnthropicConfig._is_claude_4_6_model(model)
|
||||
or supports_reasoning(
|
||||
model=model,
|
||||
custom_llm_provider=self.custom_llm_provider,
|
||||
)
|
||||
):
|
||||
...
|
||||
```
|
||||
|
||||
Using helpers like `supports_reasoning` (which read from `model_prices_and_context_window.json` / `get_model_info`) allows future model updates to "just work" without code changes.
|
||||
|
||||
9. **Never close HTTP/SDK clients on cache eviction**: Do not add `close()`, `aclose()`, or `create_task(close_fn())` inside `LLMClientCache._remove_key()` or any cache eviction path. Evicted clients may still be held by in-flight requests; closing them causes `RuntimeError: Cannot send a request, as the client has been closed.` in production after the cache TTL (1 hour) expires. Connection cleanup is handled at shutdown by `close_litellm_async_clients()`. See PR #22247 for the full incident history.
|
||||
|
||||
## HELPFUL RESOURCES
|
||||
|
||||
- Main documentation: https://docs.litellm.ai/ (source: [BerriAI/litellm-docs](https://github.com/BerriAI/litellm-docs))
|
||||
- Provider-specific docs: https://docs.litellm.ai/docs/providers/
|
||||
- Admin UI for testing proxy features
|
||||
|
||||
## WHEN IN DOUBT
|
||||
|
||||
- Follow existing patterns in the codebase
|
||||
- Check similar provider implementations
|
||||
- Ensure comprehensive test coverage
|
||||
- Update documentation appropriately
|
||||
- Consider backward compatibility impact
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
### Environment
|
||||
|
||||
- uv is installed in `~/.local/bin`; the update script ensures it is on `PATH`.
|
||||
- Python 3.12, Node 22 are pre-installed.
|
||||
- The project virtual environment lives under `.venv/`.
|
||||
|
||||
### Running the proxy server
|
||||
|
||||
Create a minimal config file and start the proxy:
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
model_list:
|
||||
- model_name: fake-openai-endpoint
|
||||
litellm_params:
|
||||
model: openai/fake-model
|
||||
api_key: fake-key
|
||||
api_base: https://fake-api.example.com
|
||||
|
||||
general_settings:
|
||||
master_key: sk-1234
|
||||
|
||||
litellm_settings:
|
||||
drop_params: True
|
||||
telemetry: False
|
||||
```
|
||||
|
||||
```bash
|
||||
uv run litellm --config config.yaml --port 4000
|
||||
```
|
||||
|
||||
The proxy takes ~15-20 seconds to fully start (it runs Prisma migrations on boot). Wait for `/health` to return before sending requests. Without a PostgreSQL `DATABASE_URL`, the proxy connects to a default Neon dev database embedded in the `litellm-proxy-extras` package.
|
||||
|
||||
### Running tests
|
||||
|
||||
See `CLAUDE.md` and the `Makefile` for standard commands. Key notes:
|
||||
|
||||
- `uv sync --group proxy-dev --extra proxy` installs the Prisma and proxy-side test dependencies used by the standard local workflow.
|
||||
- The `--timeout` pytest flag is NOT available; don't pass it.
|
||||
- Unit tests: `uv run pytest tests/test_litellm/ -x -vv -n 4`
|
||||
- **Before committing, always run `uv run black .` to format your code.** Black formatting is enforced in CI.
|
||||
- If `uv sync` fails because the lockfile is outdated, run `uv lock` and retry.
|
||||
|
||||
### Lint
|
||||
|
||||
```bash
|
||||
cd litellm && uv run ruff check .
|
||||
```
|
||||
|
||||
Ruff is the primary fast linter. For the full lint suite (including mypy, black, circular imports), run `make lint` per `CLAUDE.md`.
|
||||
|
||||
### UI Dashboard development
|
||||
|
||||
- The UI is at `ui/litellm-dashboard/`. Run `npm run dev` from that directory for the Next.js dev server on port 3000.
|
||||
- The proxy at port 4000 serves a **pre-built** static UI from `litellm/proxy/_experimental/out/`. After making UI code changes, you must run `npm run build` in the dashboard directory and copy the output: `cp -r ui/litellm-dashboard/out/* litellm/proxy/_experimental/out/` for the proxy to serve the updated UI.
|
||||
- SVGs used as provider logos (loaded via `<img>` tags) must NOT use `fill="currentColor"` — replace with an explicit color like `#000000` or use the `-color` variant from lobehub icons, since CSS color inheritance does not work inside `<img>` elements.
|
||||
- Provider logos live in `ui/litellm-dashboard/public/assets/logos/` (source) and `litellm/proxy/_experimental/out/assets/logos/` (pre-built). Both locations must have the file for it to work in dev and proxy-served modes.
|
||||
- UI Vitest tests: `cd ui/litellm-dashboard && npx vitest run`
|
||||
Read @CLAUDE.md for coding guidelines
|
||||
|
|
|
|||
214
CLAUDE.md
214
CLAUDE.md
|
|
@ -1,194 +1,70 @@
|
|||
# CLAUDE.md
|
||||
Do not write comments unless they are absolutely necessary to explain some very complex business logic. Please clean up if there are comments that are not absolutely necessary. Do not remove comments that are unrelated to the addition of the code of this PR
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Explanation: code comments are, in a way, a violation of DRY code. You must update logic in two locations to change the code and "hard to change" is literally the definition of tech debt. We should instead aim to write code that is intuitive to the reader, while being both easy to maintain and high performance
|
||||
|
||||
## Confidentiality: Customer and Company Names in Code
|
||||
Don't assume that the existing code is correct or the right way of doing things / good coding patterns. In fact, there are a lot of bad coding practices, overly complex code, code smells, etc. If something doesn't look right, speak up. Feel free to break existing patterns or question weird existing code to make new code high quality, as in:
|
||||
- correct
|
||||
- secure
|
||||
- performant
|
||||
- readable
|
||||
- easy to maintain/change
|
||||
- modern
|
||||
In that order of importance
|
||||
|
||||
The codebase is public. Before writing **any** third-party organization name into this repository — in source code, file or directory names, docstrings, comments, tests, fixtures, mock payloads, error messages, log lines, commit messages, or PR descriptions — pause and check:
|
||||
When adding new features, add meaningful tests. Don't add tests that don't check anything substantial and is there just to make the code coverage pass. Yes, code coverage is important, but I'd rather have no signal whether the code is working than tests that don't fail when code is broken. The goal is to have tests that would fail before the feature was added/if the code was mutated in a way that breaks the feature and succeed only when the feature is fully working. I should run mutation testing and see > 90% kill rate
|
||||
|
||||
**Already in the codebase** (OpenAI, Anthropic, Google, Azure, Bedrock, Fireworks, and other established LLM providers / integrations) — fine to use. Quick check: `git grep -i "<name>"` — if it returns hits in real code (not just your current diff), the name is established.
|
||||
Same thing for bug fixes. The tests should make it so that this specific bug can never happen again without failing tests (i.e., regression)
|
||||
|
||||
**Anything else** — customers, prospects, partners, new vendor integrations, observability tools, infra vendors, or any organization name that does not already appear in the repo. STOP and surface it to the user. Ask for explicit consent before writing the name into any file, commit message, or PR description. Do not write it speculatively and clean up later. Do not substitute a placeholder and proceed. Do not assume it is safe because it "looks like" a public company. The user must approve first.
|
||||
When creating PRs, don't set base to `main`. `litellm_internal_staging` serves that purpose
|
||||
|
||||
**What to do instead of a customer-specific reference:**
|
||||
- If you find yourself reaching for a customer name — real or fake — step back. The code shouldn't be customer-specific in the first place. Generalize the feature, or capture the customer motivation in internal docs (Notion / Linear / the internal staging PR description), never in the repo.
|
||||
- Frame changes by the capability they add, not the customer who asked for it ("add per-team Bedrock guardrail routing", not "add routing for $CUSTOMER").
|
||||
- Standard "fake value" markers (`example.com`, `localhost`, `127.0.0.1`, `test@example.com`) and abstract identifiers (`team_a`, `user_1`, `tenant_x`) are fine — those are not customer stand-ins.
|
||||
Always use @.github/pull_request_template.md as a guide for your PR body
|
||||
|
||||
## Documentation
|
||||
Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR
|
||||
|
||||
Documentation lives in a separate repository: [BerriAI/litellm-docs](https://github.com/BerriAI/litellm-docs). It is served at [docs.litellm.ai](https://docs.litellm.ai). Do not create or edit documentation files in this repository — open doc PRs against `BerriAI/litellm-docs` instead.
|
||||
If you ever make public-facing PR descriptions, comments, issues, commit messages, etc., always follow these guidelines to sound less AI-y:
|
||||
- don't use emojis
|
||||
- don't use "—". Instead, reach for ";", ".", etc.
|
||||
- don't use the pattern "It's not X, it's Y", "You're not X, you're Y", etc.
|
||||
- don't use bulleted or numbered lists unless it would be nonsensical not to. Instead, prefer prose
|
||||
|
||||
## Development Commands
|
||||
Don't hesitate to use values in .env to get needed API keys and other secrets, as long as you never add them to conversation history, commit them, or include them in GitHub issues / PRs
|
||||
|
||||
### Installation
|
||||
- `make install-dev` - Install core development dependencies
|
||||
- `make install-proxy-dev` - Install proxy development dependencies with full feature set
|
||||
- `make install-test-deps` - Install the full local test environment and generate the Prisma client
|
||||
Run tests, format your code, and lint your code before each commit
|
||||
|
||||
### Testing
|
||||
- `make test` - Run all tests
|
||||
- `make test-unit` - Run unit tests (tests/test_litellm) with 4 parallel workers
|
||||
- `make test-integration` - Run integration tests (excludes unit tests)
|
||||
- `pytest tests/` - Direct pytest execution
|
||||
Ask to commit and push your work when you're done (or if you're confident that your code is good and works, just do it)
|
||||
|
||||
### Code Quality
|
||||
- `make lint` - Run all linting (Ruff, MyPy, Black, circular imports, import safety)
|
||||
- `make format` - Apply Black code formatting
|
||||
- `make lint-ruff` - Run Ruff linting only
|
||||
- `make lint-mypy` - Run MyPy type checking only
|
||||
- **Before committing, always run `uv run black .` to format your code.** Black formatting is enforced in CI.
|
||||
When you must use real LLM models to, for example, write e2e tests, write a QA runbook, etc., make sure to use the latest models (doesn't have to be smartest, can also be a modern small, fast one. No strong preference for smart vs fast here, just use something modern) as of the year and month of the current date. Do a web search as necessary to figure that out
|
||||
|
||||
### Single Test Files
|
||||
- `uv run pytest tests/path/to/test_file.py -v` - Run specific test file
|
||||
- `uv run pytest tests/path/to/test_file.py::test_function -v` - Run specific test
|
||||
If you're an internal contributor, when creating a new PR, the typical flow is to branch off litellm_internal_staging and create a branch prefixed with litellm_. Do not create a branch prefixed with claude/ and generally do not have / in your branch names
|
||||
|
||||
### Running Scripts
|
||||
- `uv run python script.py` - Run Python scripts (use for non-test files)
|
||||
Do not add `Co-Authored-By: Claude` or any Claude attribution to commit messages. Never use a `claude/` prefix or put a `/` in a branch name. Do not add "Generated with Claude Code" (or any similar attribution) to PR descriptions. Do not create a new PR/branch off the existing PR to fix/add something that is related and could've just been committed directly to the existing PR's branch
|
||||
|
||||
### GitHub Issue & PR Templates
|
||||
When contributing to the project, use the appropriate templates:
|
||||
When working on a PR, keep the PR description in sync with new commits being made
|
||||
|
||||
**Bug Reports** (`.github/ISSUE_TEMPLATE/bug_report.yml`):
|
||||
- Describe what happened vs. what you expected
|
||||
- Include relevant log output
|
||||
- Specify your LiteLLM version
|
||||
Monkeypatching attributes of a class to do testing is an anti-pattern. Prefer dependency-injecting things into classes. That way, at unit test time, you can pass a mocked dependency in
|
||||
|
||||
**Feature Requests** (`.github/ISSUE_TEMPLATE/feature_request.yml`):
|
||||
- Describe the feature clearly
|
||||
- Explain the motivation and use case
|
||||
Do not put names of customers or customer company names in code, PRs, and issues. The codebase is public
|
||||
|
||||
**Pull Requests** (`.github/pull_request_template.md`):
|
||||
- Add at least 1 test in `tests/litellm/`
|
||||
- Ensure `make test-unit` passes
|
||||
CI supply-chain safety: Never pipe a remote script into a shell (`curl ... | bash`, `wget ... | sh`); download the artifact to a file, verify its SHA-256 checksum, then install. Pin every external tool to a specific version with a full URL (not `latest` or `stable`). Verify checksums for all downloaded binaries, using the provider's official `.sha256` / `.sha256sum` sidecar when available. These rules apply to every download in CI
|
||||
|
||||
## Architecture Overview
|
||||
## Think Before Coding
|
||||
|
||||
LiteLLM is a unified interface for 100+ LLM providers with two main components:
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
### Core Library (`litellm/`)
|
||||
- **Main entry point**: `litellm/main.py` - Contains core completion() function
|
||||
- **Provider implementations**: `litellm/llms/` - Each provider has its own subdirectory
|
||||
- **Router system**: `litellm/router.py` + `litellm/router_utils/` - Load balancing and fallback logic
|
||||
- **Type definitions**: `litellm/types/` - Pydantic models and type hints
|
||||
- **Integrations**: `litellm/integrations/` - Third-party observability, caching, logging
|
||||
- **Caching**: `litellm/caching/` - Multiple cache backends (Redis, in-memory, S3, etc.)
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them. Don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
### Proxy Server (`litellm/proxy/`)
|
||||
- **Main server**: `proxy_server.py` - FastAPI application
|
||||
- **Authentication**: `auth/` - API key management, JWT, OAuth2
|
||||
- **Database**: `db/` - Prisma ORM with PostgreSQL/SQLite support
|
||||
- **Management endpoints**: `management_endpoints/` - Admin APIs for keys, teams, models
|
||||
- **Pass-through endpoints**: `pass_through_endpoints/` - Provider-specific API forwarding
|
||||
- **Guardrails**: `guardrails/` - Safety and content filtering hooks
|
||||
- **UI Dashboard**: Served from `_experimental/out/` (Next.js build)
|
||||
## Simplicity First
|
||||
|
||||
## Key Patterns
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
### Provider Implementation
|
||||
- Providers inherit from base classes in `litellm/llms/base.py`
|
||||
- Each provider has transformation functions for input/output formatting
|
||||
- Support both sync and async operations
|
||||
- Handle streaming responses and function calling
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
### Error Handling
|
||||
- Provider-specific exceptions mapped to OpenAI-compatible errors
|
||||
- Fallback logic handled by Router system
|
||||
- Comprehensive logging through `litellm/_logging.py`
|
||||
|
||||
### Configuration
|
||||
- YAML config files for proxy server (see `proxy/example_config_yaml/`)
|
||||
- Environment variables for API keys and settings
|
||||
- Database schema managed via Prisma (`proxy/schema.prisma`)
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Code Style
|
||||
- Uses Black formatter, Ruff linter, MyPy type checker
|
||||
- Pydantic v2 for data validation
|
||||
- Async/await patterns throughout
|
||||
- Type hints required for all public APIs
|
||||
- **Avoid imports within methods** — place all imports at the top of the file (module-level). Inline imports inside functions/methods make dependencies harder to trace and hurt readability. The only exception is avoiding circular imports where absolutely necessary.
|
||||
- **Use dict spread for immutable copies** — prefer `{**original, "key": new_value}` over `dict(obj)` + mutation. The spread produces the final dict in one step and makes intent clear.
|
||||
- **Guard at resolution time** — when resolving an optional value through a fallback chain (`a or b or ""`), raise immediately if the resolved result being empty is an error. Don't pass empty strings or sentinel values downstream for the callee to deal with.
|
||||
- **Extract complex comprehensions to named helpers** — a set/dict comprehension that calls into the DB or manager (e.g. "which of these server IDs are OAuth2?") belongs in a named helper function, not inline in the caller.
|
||||
- **FastAPI parameter declarations** — mark required query/form params with `= Query(...)` / `= Form(...)` explicitly when other params in the same handler are optional. Mixing `str` (required) with `Optional[str] = None` in the same signature causes silent 422s when the required param is missing.
|
||||
|
||||
### Testing Strategy
|
||||
- Unit tests in `tests/test_litellm/`
|
||||
- Integration tests for each provider in `tests/llm_translation/`
|
||||
- Proxy tests in `tests/proxy_unit_tests/`
|
||||
- Load tests in `tests/load_tests/`
|
||||
- **Always add tests when adding new entity types or features** — if the existing test file covers other entity types, add corresponding tests for the new one
|
||||
- **Keep monkeypatch stubs in sync with real signatures** — when a function gains a new optional parameter, update every `fake_*` / `stub_*` in tests that patch it to also accept that kwarg (even as `**kwargs`). Stale stubs fail with `unexpected keyword argument` and mask real bugs.
|
||||
- **Test all branches of name→ID resolution** — when adding server/resource lookup that resolves names to UUIDs, test: (1) name resolves and UUID is allowed, (2) name resolves but UUID is not allowed, (3) name does not resolve at all. The silent-fallback path is where access-control bugs hide.
|
||||
|
||||
### UI / Backend Consistency
|
||||
- When wiring a new UI entity type to an existing backend endpoint, verify the backend API contract (single value vs. array, required vs. optional params) and ensure the UI controls match — e.g., use a single-select dropdown when the backend accepts a single value, not a multi-select
|
||||
|
||||
### UI Component Library
|
||||
- **Always use `antd` for new UI components** — we are migrating off of `@tremor/react`. Do not introduce new `Badge`, `Text`, `Card`, `Grid`, `Title`, or other imports from `@tremor/react` in any new or modified file. Use `antd` equivalents: `Tag` for labels, `Typography.Text` / `Typography.Title` / `Typography.Paragraph` for textual content (avoid plain text-only `<span>`, `<p>`, `<h*>` when Typography fits), and `Card` from `antd`. Note that `antd` has no `"yellow"` Tag color — use `"gold"` for amber/yellow.
|
||||
|
||||
### MCP OAuth / OpenAPI Transport Mapping
|
||||
- **`available_on_public_internet: false` with `delegate_auth_to_upstream: true` (oauth2, interactive — not `client_credentials`)** — LiteLLM still allows the anonymous upstream PKCE path (no proxy API key for `/authorize` and matching MCP routes). The internal-only flag mainly affects other surfaces (e.g. IP-based discovery). Rely on the upstream IdP and network policy; the dashboard shows a warning when both are set, and the proxy logs a warning when the server is loaded from config or the database.
|
||||
- `TRANSPORT.OPENAPI` is a UI-only concept. The backend only accepts `"http"`, `"sse"`, or `"stdio"`. Always map it to `"http"` before any API call (including pre-OAuth temp-session calls).
|
||||
- FastAPI validation errors return `detail` as an array of `{loc, msg, type}` objects. Error extractors must handle: array (map `.msg`), string, nested `{error: string}`, and fallback.
|
||||
- When an MCP server already has `authorization_url` stored, skip OAuth discovery (`_discovery_metadata`) — the server URL for OpenAPI MCPs is the spec file, not the API base, and fetching it causes timeouts.
|
||||
- `client_id` should be optional in the `/authorize` endpoint — if the server has a stored `client_id` in credentials, use that. Never require callers to re-supply it.
|
||||
|
||||
### MCP Credential Storage
|
||||
- OAuth credentials and BYOK credentials share the `litellm_mcpusercredentials` table, distinguished by a `"type"` field in the JSON payload (`"oauth2"` vs plain string).
|
||||
- When deleting OAuth credentials, check type before deleting to avoid accidentally deleting a BYOK credential for the same `(user_id, server_id)` pair.
|
||||
- Always pass the raw `expires_at` timestamp to the client — never set it to `None` for expired credentials. Let the frontend compute the "Expired" display state from the timestamp.
|
||||
- Use `RecordNotFoundError` (not bare `except Exception`) when catching "already deleted" in credential delete endpoints.
|
||||
|
||||
### Browser Storage Safety (UI)
|
||||
- Never write LiteLLM access tokens or API keys to `localStorage` — use `sessionStorage` only. `localStorage` survives browser close and is readable by any injected script (XSS).
|
||||
- Shared utility functions (e.g. `extractErrorMessage`) belong in `src/utils/` — never define them inline in hooks or duplicate them across files.
|
||||
|
||||
### Database Migrations
|
||||
- Prisma handles schema migrations
|
||||
- Migration files auto-generated with `prisma migrate dev`
|
||||
- Always test migrations against both PostgreSQL and SQLite
|
||||
|
||||
### Proxy database access
|
||||
- **Do not write raw SQL** for proxy DB operations. Use Prisma model methods instead of `execute_raw` / `query_raw`.
|
||||
- Use the generated client: `prisma_client.db.<model>` (e.g. `litellm_tooltable`, `litellm_usertable`) with `.upsert()`, `.find_many()`, `.find_unique()`, `.update()`, `.update_many()` as appropriate. This avoids schema/client drift, keeps code testable with simple mocks, and matches patterns used in spend logs and other proxy code.
|
||||
- **No N+1 queries.** Never query the DB inside a loop. Batch-fetch with `{"in": ids}` and distribute in-memory.
|
||||
- **Batch writes.** Use `create_many`/`update_many`/`delete_many` instead of individual calls (these return counts only; `update_many`/`delete_many` no-op silently on missing rows). When multiple separate writes target the same table (e.g. in `batch_()`), order by primary key to avoid deadlocks.
|
||||
- **Push work to the DB.** Filter, sort, group, and aggregate in SQL, not Python. Verify Prisma generates the expected SQL — e.g. prefer `group_by` over `find_many(distinct=...)` which does client-side processing.
|
||||
- **Bound large result sets.** Prisma materializes full results in memory. For results over ~10 MB, paginate with `take`/`skip` or `cursor`/`take`, always with an explicit `order`. Prefer cursor-based pagination (`skip` is O(n)). Don't paginate naturally small result sets.
|
||||
- **Limit fetched columns on wide tables.** Use `select` to fetch only needed fields — returns a partial object, so downstream code must not access unselected fields.
|
||||
- **Check index coverage.** For new or modified queries, check `schema.prisma` for a supporting index. Prefer extending an existing index (e.g. `@@index([a])` → `@@index([a, b])`) over adding a new one, unless it's a `@@unique`. Only add indexes for large/frequent queries.
|
||||
- **Keep schema files in sync.** Apply schema changes to all `schema.prisma` copies (`schema.prisma`, `litellm/proxy/`, `litellm-proxy-extras/`) with a migration under `litellm-proxy-extras/litellm_proxy_extras/migrations/`.
|
||||
|
||||
### Setup Wizard (`litellm/setup_wizard.py`)
|
||||
- The wizard is implemented as a single `SetupWizard` class with `@staticmethod` methods — keep it that way. No module-level functions except `run_setup_wizard()` (the public entrypoint) and pure helpers (color, ANSI).
|
||||
- Use `litellm.utils.check_valid_key(model, api_key)` for credential validation — never roll a custom completion call.
|
||||
- Do not hardcode provider env-key names or model lists that already exist in the codebase. Add a `test_model` field to each provider entry to drive `check_valid_key`; set it to `None` for providers that can't be validated with a single API key (Azure, Bedrock, Ollama).
|
||||
|
||||
### Enterprise Features
|
||||
- Enterprise-specific code in `enterprise/` directory
|
||||
- Optional features enabled via environment variables
|
||||
- Separate licensing and authentication for enterprise features
|
||||
|
||||
### CI Supply-Chain Safety
|
||||
- **Never pipe a remote script into a shell** (`curl ... | bash`, `wget ... | sh`). Download the artifact to a file, verify its SHA-256 checksum, then install.
|
||||
- **Pin every external tool to a specific version** with a full URL (not `latest` or `stable`). Unversioned downloads silently change under you.
|
||||
- **Verify checksums for all downloaded binaries.** Use the provider's official `.sha256` / `.sha256sum` sidecar file when available; otherwise compute and hardcode the digest.
|
||||
- **Prefer reusable CircleCI commands** (`commands:` section) so a tool is installed and verified in exactly one place, then referenced everywhere with `- install_<tool>` or `- wait_for_service`.
|
||||
- **Don't add tools just because they were there before.** Audit whether an external dependency is still needed. If it can be replaced with a shell one-liner or a tool already in the image, remove it.
|
||||
- These rules apply to every download in CI: binaries, install scripts, language version managers, package repos. No exceptions.
|
||||
|
||||
### HTTP Client Cache Safety
|
||||
- **Never close HTTP/SDK clients on cache eviction.** `LLMClientCache._remove_key()` must not call `close()`/`aclose()` on evicted clients — they may still be used by in-flight requests. Doing so causes `RuntimeError: Cannot send a request, as the client has been closed.` after the 1-hour TTL expires. Cleanup happens at shutdown via `close_litellm_async_clients()`.
|
||||
|
||||
### Troubleshooting: DB schema out of sync after proxy restart
|
||||
`litellm-proxy-extras` runs `prisma migrate deploy` on startup using **its own** bundled migration files, which may lag behind schema changes in the current worktree. Symptoms: `Unknown column`, `Invalid prisma invocation`, or missing data on new fields.
|
||||
|
||||
**Diagnose:** Run `\d "TableName"` in psql and compare against `schema.prisma` — missing columns confirm the issue.
|
||||
|
||||
**Fix options:**
|
||||
1. **Create a Prisma migration** (permanent) — run `prisma migrate dev --name <description>` in the worktree. The generated file will be picked up by `prisma migrate deploy` on next startup.
|
||||
2. **Apply manually for local dev** — `psql -d litellm -c "ALTER TABLE ... ADD COLUMN IF NOT EXISTS ..."` after each proxy start. Fine for dev, not for production.
|
||||
3. **Update litellm-proxy-extras** — if the package is installed from PyPI, its migration directory must include the new file. Either update the package or run the migration manually until the next release ships it.
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
|
|
|||
109
GEMINI.md
109
GEMINI.md
|
|
@ -1,108 +1 @@
|
|||
# GEMINI.md
|
||||
|
||||
This file provides guidance to Gemini when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Installation
|
||||
- `make install-dev` - Install core development dependencies
|
||||
- `make install-proxy-dev` - Install proxy development dependencies with full feature set
|
||||
- `make install-test-deps` - Install all test dependencies
|
||||
|
||||
### Testing
|
||||
- `make test` - Run all tests
|
||||
- `make test-unit` - Run unit tests (tests/test_litellm) with 4 parallel workers
|
||||
- `make test-integration` - Run integration tests (excludes unit tests)
|
||||
- `pytest tests/` - Direct pytest execution
|
||||
|
||||
### Code Quality
|
||||
- `make lint` - Run all linting (Ruff, MyPy, Black, circular imports, import safety)
|
||||
- `make format` - Apply Black code formatting
|
||||
- `make lint-ruff` - Run Ruff linting only
|
||||
- `make lint-mypy` - Run MyPy type checking only
|
||||
|
||||
### Single Test Files
|
||||
- `uv run pytest tests/path/to/test_file.py -v` - Run specific test file
|
||||
- `uv run pytest tests/path/to/test_file.py::test_function -v` - Run specific test
|
||||
|
||||
### Running Scripts
|
||||
- `uv run python script.py` - Run Python scripts (use for non-test files)
|
||||
|
||||
### GitHub Issue & PR Templates
|
||||
When contributing to the project, use the appropriate templates:
|
||||
|
||||
**Bug Reports** (`.github/ISSUE_TEMPLATE/bug_report.yml`):
|
||||
- Describe what happened vs. what you expected
|
||||
- Include relevant log output
|
||||
- Specify your LiteLLM version
|
||||
|
||||
**Feature Requests** (`.github/ISSUE_TEMPLATE/feature_request.yml`):
|
||||
- Describe the feature clearly
|
||||
- Explain the motivation and use case
|
||||
|
||||
**Pull Requests** (`.github/pull_request_template.md`):
|
||||
- Add at least 1 test in `tests/litellm/`
|
||||
- Ensure `make test-unit` passes
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
LiteLLM is a unified interface for 100+ LLM providers with two main components:
|
||||
|
||||
### Core Library (`litellm/`)
|
||||
- **Main entry point**: `litellm/main.py` - Contains core completion() function
|
||||
- **Provider implementations**: `litellm/llms/` - Each provider has its own subdirectory
|
||||
- **Router system**: `litellm/router.py` + `litellm/router_utils/` - Load balancing and fallback logic
|
||||
- **Type definitions**: `litellm/types/` - Pydantic models and type hints
|
||||
- **Integrations**: `litellm/integrations/` - Third-party observability, caching, logging
|
||||
- **Caching**: `litellm/caching/` - Multiple cache backends (Redis, in-memory, S3, etc.)
|
||||
|
||||
### Proxy Server (`litellm/proxy/`)
|
||||
- **Main server**: `proxy_server.py` - FastAPI application
|
||||
- **Authentication**: `auth/` - API key management, JWT, OAuth2
|
||||
- **Database**: `db/` - Prisma ORM with PostgreSQL/SQLite support
|
||||
- **Management endpoints**: `management_endpoints/` - Admin APIs for keys, teams, models
|
||||
- **Pass-through endpoints**: `pass_through_endpoints/` - Provider-specific API forwarding
|
||||
- **Guardrails**: `guardrails/` - Safety and content filtering hooks
|
||||
- **UI Dashboard**: Served from `_experimental/out/` (Next.js build)
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Provider Implementation
|
||||
- Providers inherit from base classes in `litellm/llms/base.py`
|
||||
- Each provider has transformation functions for input/output formatting
|
||||
- Support both sync and async operations
|
||||
- Handle streaming responses and function calling
|
||||
|
||||
### Error Handling
|
||||
- Provider-specific exceptions mapped to OpenAI-compatible errors
|
||||
- Fallback logic handled by Router system
|
||||
- Comprehensive logging through `litellm/_logging.py`
|
||||
|
||||
### Configuration
|
||||
- YAML config files for proxy server (see `proxy/example_config_yaml/`)
|
||||
- Environment variables for API keys and settings
|
||||
- Database schema managed via Prisma (`proxy/schema.prisma`)
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Code Style
|
||||
- Uses Black formatter, Ruff linter, MyPy type checker
|
||||
- Pydantic v2 for data validation
|
||||
- Async/await patterns throughout
|
||||
- Type hints required for all public APIs
|
||||
|
||||
### Testing Strategy
|
||||
- Unit tests in `tests/test_litellm/`
|
||||
- Integration tests for each provider in `tests/llm_translation/`
|
||||
- Proxy tests in `tests/proxy_unit_tests/`
|
||||
- Load tests in `tests/load_tests/`
|
||||
|
||||
### Database Migrations
|
||||
- Prisma handles schema migrations
|
||||
- Migration files auto-generated with `prisma migrate dev`
|
||||
- Always test migrations against both PostgreSQL and SQLite
|
||||
|
||||
### Enterprise Features
|
||||
- Enterprise-specific code in `enterprise/` directory
|
||||
- Optional features enabled via environment variables
|
||||
- Separate licensing and authentication for enterprise features
|
||||
Read @CLAUDE.md for coding guidelines
|
||||
|
|
|
|||
|
|
@ -19,12 +19,26 @@ RESEND_API_ENDPOINT = "https://api.resend.com/emails"
|
|||
|
||||
|
||||
class ResendEmailLogger(BaseEmailLogger):
|
||||
"""
|
||||
Send emails using Resend's API.
|
||||
|
||||
Required env vars:
|
||||
- RESEND_API_KEY
|
||||
|
||||
Optional env vars:
|
||||
- RESEND_FROM_EMAIL: Override the default sender address. Must be on a
|
||||
domain verified in your Resend account. When unset, falls back to the
|
||||
`from_email` argument passed by the caller (which defaults to
|
||||
`notifications@alerts.litellm.ai` and only works on LiteLLM Cloud).
|
||||
"""
|
||||
|
||||
def __init__(self, internal_usage_cache=None, **kwargs):
|
||||
super().__init__(internal_usage_cache=internal_usage_cache, **kwargs)
|
||||
self.async_httpx_client = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.LoggingCallback
|
||||
)
|
||||
self.resend_api_key = os.getenv("RESEND_API_KEY")
|
||||
self.resend_from_email = os.getenv("RESEND_FROM_EMAIL")
|
||||
|
||||
async def send_email(
|
||||
self,
|
||||
|
|
@ -33,13 +47,14 @@ class ResendEmailLogger(BaseEmailLogger):
|
|||
subject: str,
|
||||
html_body: str,
|
||||
):
|
||||
sender_email = self.resend_from_email or from_email
|
||||
verbose_logger.debug(
|
||||
f"Sending email from {from_email} to {to_email} with subject {subject}"
|
||||
f"Sending email from {sender_email} to {to_email} with subject {subject}"
|
||||
)
|
||||
response = await self.async_httpx_client.post(
|
||||
url=RESEND_API_ENDPOINT,
|
||||
json={
|
||||
"from": from_email,
|
||||
"from": sender_email,
|
||||
"to": to_email,
|
||||
"subject": subject,
|
||||
"html": html_body,
|
||||
|
|
|
|||
|
|
@ -658,7 +658,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
if isinstance(content, str):
|
||||
continue
|
||||
for c in content:
|
||||
if c["type"] == "file":
|
||||
if c.get("type") == "file":
|
||||
file_object = cast(ChatCompletionFileObject, c)
|
||||
file_object_file_field = file_object["file"]
|
||||
file_id = file_object_file_field.get("file_id")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.41"
|
||||
version = "0.1.42"
|
||||
description = "Package for LiteLLM Enterprise features"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.1.41"
|
||||
version = "0.1.42"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-enterprise==",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,22 @@ else:
|
|||
UserAPIKeyAuth = Any
|
||||
|
||||
|
||||
def _get_otel_v2_class() -> Optional[type]:
|
||||
"""Return the ``OpenTelemetryV2`` class, or ``None`` if the OTel SDK is absent.
|
||||
|
||||
Imported lazily: ``litellm.integrations.otel.logger`` imports the OpenTelemetry
|
||||
SDK at module scope, so importing it eagerly would break installs without the
|
||||
SDK. The V2 logger only exists when ``LITELLM_OTEL_V2`` is enabled (which
|
||||
requires the SDK), so a failed import simply means "no V2 logger in play".
|
||||
"""
|
||||
try:
|
||||
from litellm.integrations.otel.logger import OpenTelemetryV2
|
||||
|
||||
return OpenTelemetryV2
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class ServiceLogging(CustomLogger):
|
||||
"""
|
||||
Separate class used for monitoring health of litellm-adjacent services (redis/postgres).
|
||||
|
|
@ -38,6 +54,37 @@ class ServiceLogging(CustomLogger):
|
|||
if "prometheus_system" in litellm.service_callback:
|
||||
self.prometheusServicesLogger = PrometheusServicesLogger()
|
||||
|
||||
def _resolve_otel_service_logger(self, callback: Any) -> Optional[Any]:
|
||||
"""Resolve the OTel logger (legacy or V2) to emit a service span on.
|
||||
|
||||
Returns the logger instance whose ``async_service_*_hook`` should fire for
|
||||
this ``callback``, or ``None`` when ``callback`` is not an OTel callback.
|
||||
|
||||
The V2 ``OpenTelemetryV2`` logger is a plain ``CustomLogger`` and is NOT a
|
||||
subclass of the legacy ``OpenTelemetry``, so the legacy ``isinstance``
|
||||
check alone misses it — which is why redis/postgres service spans never
|
||||
showed up under ``LITELLM_OTEL_V2``. Match both the legacy and V2 types,
|
||||
whether the callback is the logger instance itself or the ``"otel"`` string
|
||||
(which routes to the proxy's registered ``open_telemetry_logger``).
|
||||
"""
|
||||
otel_v2_cls = _get_otel_v2_class()
|
||||
|
||||
def _is_otel_logger(obj: Any) -> bool:
|
||||
if isinstance(obj, OpenTelemetry):
|
||||
return True
|
||||
return otel_v2_cls is not None and isinstance(obj, otel_v2_cls)
|
||||
|
||||
if _is_otel_logger(callback):
|
||||
return callback
|
||||
if callback == "otel":
|
||||
from litellm.proxy.proxy_server import open_telemetry_logger
|
||||
|
||||
if open_telemetry_logger is not None and _is_otel_logger(
|
||||
open_telemetry_logger
|
||||
):
|
||||
return open_telemetry_logger
|
||||
return None
|
||||
|
||||
def service_success_hook(
|
||||
self,
|
||||
service: ServiceTypes,
|
||||
|
|
@ -129,6 +176,13 @@ class ServiceLogging(CustomLogger):
|
|||
event_metadata=event_metadata,
|
||||
)
|
||||
|
||||
# OTel loggers already fired this event. ``service_callback`` can hold more
|
||||
# than one reference that resolves to the *same* logger — the ``"otel"``
|
||||
# string AND the registered instance both map to ``open_telemetry_logger``
|
||||
# (the V2 logger self-registers its instance even when the string is
|
||||
# present, unlike V1). Without this guard each such reference emits its own
|
||||
# span, so a single DB call shows up as duplicate ``postgres ...`` spans.
|
||||
emitted_otel_logger_ids: set = set()
|
||||
for callback in litellm.service_callback:
|
||||
if callback == "prometheus_system":
|
||||
await self.init_prometheus_services_logger_if_none()
|
||||
|
|
@ -144,19 +198,18 @@ class ServiceLogging(CustomLogger):
|
|||
end_time=end_time,
|
||||
event_metadata=event_metadata,
|
||||
)
|
||||
elif callback == "otel" or isinstance(callback, OpenTelemetry):
|
||||
_otel_logger_to_use: Optional[OpenTelemetry] = None
|
||||
if isinstance(callback, OpenTelemetry):
|
||||
_otel_logger_to_use = callback
|
||||
else:
|
||||
from litellm.proxy.proxy_server import open_telemetry_logger
|
||||
|
||||
if open_telemetry_logger is not None and isinstance(
|
||||
open_telemetry_logger, OpenTelemetry
|
||||
):
|
||||
_otel_logger_to_use = open_telemetry_logger
|
||||
|
||||
if _otel_logger_to_use is not None and parent_otel_span is not None:
|
||||
else:
|
||||
_otel_logger_to_use = self._resolve_otel_service_logger(callback)
|
||||
# No ``parent_otel_span is not None`` gate: a background service
|
||||
# call (no request on the stack) has no parent, and dropping it
|
||||
# here is what hid those calls from traces entirely. The OTel
|
||||
# logger decides what to do with a missing parent — legacy V1
|
||||
# no-ops, V2 emits a root span (and skips metrics-only pings).
|
||||
if (
|
||||
_otel_logger_to_use is not None
|
||||
and id(_otel_logger_to_use) not in emitted_otel_logger_ids
|
||||
):
|
||||
emitted_otel_logger_ids.add(id(_otel_logger_to_use))
|
||||
await _otel_logger_to_use.async_service_success_hook(
|
||||
payload=payload,
|
||||
parent_otel_span=parent_otel_span,
|
||||
|
|
@ -238,6 +291,9 @@ class ServiceLogging(CustomLogger):
|
|||
event_metadata=event_metadata,
|
||||
)
|
||||
|
||||
# Dedupe OTel loggers per event — see ``async_service_success_hook`` for why
|
||||
# the same logger can be referenced twice in ``service_callback``.
|
||||
emitted_otel_logger_ids: set = set()
|
||||
for callback in litellm.service_callback:
|
||||
if callback == "prometheus_system":
|
||||
await self.init_prometheus_services_logger_if_none()
|
||||
|
|
@ -255,22 +311,19 @@ class ServiceLogging(CustomLogger):
|
|||
end_time=end_time,
|
||||
event_metadata=event_metadata,
|
||||
)
|
||||
elif callback == "otel" or isinstance(callback, OpenTelemetry):
|
||||
_otel_logger_to_use: Optional[OpenTelemetry] = None
|
||||
if isinstance(callback, OpenTelemetry):
|
||||
_otel_logger_to_use = callback
|
||||
else:
|
||||
from litellm.proxy.proxy_server import open_telemetry_logger
|
||||
|
||||
if open_telemetry_logger is not None and isinstance(
|
||||
open_telemetry_logger, OpenTelemetry
|
||||
):
|
||||
_otel_logger_to_use = open_telemetry_logger
|
||||
else:
|
||||
_otel_logger_to_use = self._resolve_otel_service_logger(callback)
|
||||
|
||||
if not isinstance(error, str):
|
||||
error = str(error)
|
||||
|
||||
if _otel_logger_to_use is not None and parent_otel_span is not None:
|
||||
# See the success hook: no parent gate, so background failures
|
||||
# are traced too. V1 no-ops without a parent; V2 emits a root.
|
||||
if (
|
||||
_otel_logger_to_use is not None
|
||||
and id(_otel_logger_to_use) not in emitted_otel_logger_ids
|
||||
):
|
||||
emitted_otel_logger_ids.add(id(_otel_logger_to_use))
|
||||
await _otel_logger_to_use.async_service_failure_hook(
|
||||
payload=payload,
|
||||
error=error,
|
||||
|
|
|
|||
|
|
@ -107,6 +107,14 @@ class A2ACompletionBridgeHandler:
|
|||
if k not in ("model", "custom_llm_provider") and k not in _AGENT_ONLY_PARAMS
|
||||
}
|
||||
completion_params.update(litellm_params_to_add)
|
||||
# Apply forward metadata AFTER the litellm_params merge so the helper
|
||||
# sees any agent-owner-configured ``extra_body.metadata`` and can keep
|
||||
# those keys authoritative over the client-supplied A2A metadata.
|
||||
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
|
||||
completion_params=completion_params,
|
||||
a2a_message=message,
|
||||
params=params,
|
||||
)
|
||||
|
||||
# Call litellm.acompletion
|
||||
response = await litellm.acompletion(**completion_params)
|
||||
|
|
@ -214,6 +222,14 @@ class A2ACompletionBridgeHandler:
|
|||
if k not in ("model", "custom_llm_provider") and k not in _AGENT_ONLY_PARAMS
|
||||
}
|
||||
completion_params.update(litellm_params_to_add)
|
||||
# Apply forward metadata AFTER the litellm_params merge so the helper
|
||||
# sees any agent-owner-configured ``extra_body.metadata`` and can keep
|
||||
# those keys authoritative over the client-supplied A2A metadata.
|
||||
A2ACompletionBridgeTransformation.apply_forward_metadata_to_completion_params(
|
||||
completion_params=completion_params,
|
||||
a2a_message=message,
|
||||
params=params,
|
||||
)
|
||||
|
||||
# 1. Emit initial task event (kind: "task", status: "submitted")
|
||||
task_event = A2ACompletionBridgeTransformation.create_task_event(ctx)
|
||||
|
|
|
|||
|
|
@ -45,10 +45,80 @@ class A2ACompletionBridgeTransformation:
|
|||
Static methods for transforming between A2A and OpenAI message formats.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _extract_text_from_a2a_parts(parts: List[Dict[str, Any]]) -> str:
|
||||
"""Extract text from A2A parts (with or without explicit ``kind``)."""
|
||||
content_parts: List[str] = []
|
||||
for part in parts:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
kind = part.get("kind")
|
||||
text = part.get("text")
|
||||
if text is None:
|
||||
continue
|
||||
if kind in (None, "", "text"):
|
||||
content_parts.append(str(text))
|
||||
return "\n".join(content_parts)
|
||||
|
||||
@staticmethod
|
||||
def get_forward_metadata(
|
||||
a2a_message: Dict[str, Any],
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Merge A2A metadata from MessageSendParams and the message for downstream providers.
|
||||
|
||||
Forwarded once on the LangGraph run payload (``metadata``), not duplicated on
|
||||
each input message — see ``apply_forward_metadata_to_completion_params``.
|
||||
"""
|
||||
merged: Dict[str, Any] = {}
|
||||
if params and isinstance(params.get("metadata"), dict):
|
||||
merged.update(params["metadata"])
|
||||
message_metadata = a2a_message.get("metadata")
|
||||
if isinstance(message_metadata, dict):
|
||||
merged.update(message_metadata)
|
||||
return merged or None
|
||||
|
||||
@staticmethod
|
||||
def apply_forward_metadata_to_completion_params(
|
||||
completion_params: Dict[str, Any],
|
||||
a2a_message: Dict[str, Any],
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Attach A2A metadata to completion kwargs for provider bridges (e.g. LangGraph).
|
||||
|
||||
Uses ``extra_body`` so we do not collide with LiteLLM's spend-log ``metadata`` kwarg.
|
||||
"""
|
||||
forward_metadata = A2ACompletionBridgeTransformation.get_forward_metadata(
|
||||
a2a_message=a2a_message,
|
||||
params=params,
|
||||
)
|
||||
if not forward_metadata:
|
||||
return
|
||||
|
||||
extra_body = completion_params.get("extra_body")
|
||||
if not isinstance(extra_body, dict):
|
||||
extra_body = {}
|
||||
# Layer client-supplied A2A metadata under any agent-owner-configured
|
||||
# ``extra_body.metadata`` so the configured keys remain authoritative
|
||||
# and an A2A caller cannot overwrite server-set run metadata.
|
||||
existing_metadata = extra_body.get("metadata")
|
||||
existing_dict: Dict[str, Any] = (
|
||||
existing_metadata if isinstance(existing_metadata, dict) else {}
|
||||
)
|
||||
merged_metadata: Dict[str, Any] = {**forward_metadata, **existing_dict}
|
||||
extra_body = {**extra_body, "metadata": merged_metadata}
|
||||
completion_params["extra_body"] = extra_body
|
||||
|
||||
verbose_logger.debug(
|
||||
f"A2A -> completion forward metadata keys={list(forward_metadata.keys())}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def a2a_message_to_openai_messages(
|
||||
a2a_message: Dict[str, Any],
|
||||
) -> List[Dict[str, str]]:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Transform an A2A message to OpenAI message format.
|
||||
|
||||
|
|
@ -70,21 +140,20 @@ class A2ACompletionBridgeTransformation:
|
|||
elif role == "system":
|
||||
openai_role = "system"
|
||||
|
||||
# Extract text content from parts
|
||||
content_parts = []
|
||||
for part in parts:
|
||||
kind = part.get("kind", "")
|
||||
if kind == "text":
|
||||
text = part.get("text", "")
|
||||
content_parts.append(text)
|
||||
if not isinstance(parts, list):
|
||||
parts = []
|
||||
|
||||
content = "\n".join(content_parts) if content_parts else ""
|
||||
content = A2ACompletionBridgeTransformation._extract_text_from_a2a_parts(parts)
|
||||
|
||||
# Do not attach A2A message.metadata here — the completion bridge forwards it
|
||||
# once at run level via extra_body.metadata (LangGraph POST /runs/wait shape).
|
||||
openai_message: Dict[str, Any] = {"role": openai_role, "content": content}
|
||||
|
||||
verbose_logger.debug(
|
||||
f"A2A -> OpenAI transform: role={role} -> {openai_role}, content_length={len(content)}"
|
||||
)
|
||||
|
||||
return [{"role": openai_role, "content": content}]
|
||||
return [openai_message]
|
||||
|
||||
@staticmethod
|
||||
def openai_response_to_a2a_response(
|
||||
|
|
@ -110,6 +179,7 @@ class A2ACompletionBridgeTransformation:
|
|||
|
||||
# Build A2A message
|
||||
a2a_message = {
|
||||
"kind": "message",
|
||||
"role": "agent",
|
||||
"parts": [{"kind": "text", "text": content}],
|
||||
"messageId": uuid4().hex,
|
||||
|
|
@ -119,9 +189,7 @@ class A2ACompletionBridgeTransformation:
|
|||
a2a_response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"message": a2a_message,
|
||||
},
|
||||
"result": a2a_message,
|
||||
}
|
||||
|
||||
verbose_logger.debug(f"OpenAI -> A2A transform: content_length={len(content)}")
|
||||
|
|
@ -235,50 +303,3 @@ class A2ACompletionBridgeTransformation:
|
|||
"taskId": ctx.task_id,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def openai_chunk_to_a2a_chunk(
|
||||
chunk: Any,
|
||||
request_id: Optional[str] = None,
|
||||
is_final: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Transform a LiteLLM streaming chunk to A2A streaming format.
|
||||
|
||||
NOTE: This method is deprecated for streaming. Use the event-based
|
||||
methods (create_task_event, create_status_update_event,
|
||||
create_artifact_update_event) instead for proper A2A streaming.
|
||||
|
||||
Args:
|
||||
chunk: LiteLLM ModelResponse chunk
|
||||
request_id: Original A2A request ID
|
||||
is_final: Whether this is the final chunk
|
||||
|
||||
Returns:
|
||||
A2A streaming chunk dict or None if no content
|
||||
"""
|
||||
# Extract delta content
|
||||
content = ""
|
||||
if chunk is not None and hasattr(chunk, "choices") and chunk.choices:
|
||||
choice = chunk.choices[0]
|
||||
if hasattr(choice, "delta") and choice.delta:
|
||||
content = choice.delta.content or ""
|
||||
|
||||
if not content and not is_final:
|
||||
return None
|
||||
|
||||
# Build A2A streaming chunk (legacy format)
|
||||
a2a_chunk = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"message": {
|
||||
"role": "agent",
|
||||
"parts": [{"kind": "text", "text": content}],
|
||||
"messageId": uuid4().hex,
|
||||
},
|
||||
"final": is_final,
|
||||
},
|
||||
}
|
||||
|
||||
return a2a_chunk
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
# A2A to LiteLLM Completion Bridge
|
||||
|
||||
Routes A2A protocol requests through `litellm.acompletion`, enabling any LiteLLM-supported provider to be invoked via A2A.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
A2A Request → Transform → litellm.acompletion → Transform → A2A Response
|
||||
```
|
||||
|
||||
## SDK Usage
|
||||
|
||||
Use the existing `asend_message` and `asend_message_streaming` functions with `litellm_params`:
|
||||
|
||||
```python
|
||||
from litellm.a2a_protocol import asend_message, asend_message_streaming
|
||||
from a2a.types import SendMessageRequest, SendStreamingMessageRequest, MessageSendParams
|
||||
from uuid import uuid4
|
||||
|
||||
# Non-streaming
|
||||
request = SendMessageRequest(
|
||||
id=str(uuid4()),
|
||||
params=MessageSendParams(
|
||||
message={"role": "user", "parts": [{"kind": "text", "text": "Hello!"}], "messageId": uuid4().hex}
|
||||
)
|
||||
)
|
||||
response = await asend_message(
|
||||
request=request,
|
||||
api_base="http://localhost:2024",
|
||||
litellm_params={"custom_llm_provider": "langgraph", "model": "agent"},
|
||||
)
|
||||
|
||||
# Streaming
|
||||
stream_request = SendStreamingMessageRequest(
|
||||
id=str(uuid4()),
|
||||
params=MessageSendParams(
|
||||
message={"role": "user", "parts": [{"kind": "text", "text": "Hello!"}], "messageId": uuid4().hex}
|
||||
)
|
||||
)
|
||||
async for chunk in asend_message_streaming(
|
||||
request=stream_request,
|
||||
api_base="http://localhost:2024",
|
||||
litellm_params={"custom_llm_provider": "langgraph", "model": "agent"},
|
||||
):
|
||||
print(chunk)
|
||||
```
|
||||
|
||||
## Proxy Usage
|
||||
|
||||
Configure an agent with `custom_llm_provider` in `litellm_params`:
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
- agent_name: my-langgraph-agent
|
||||
agent_card_params:
|
||||
name: "LangGraph Agent"
|
||||
url: "http://localhost:2024" # Used as api_base
|
||||
litellm_params:
|
||||
custom_llm_provider: langgraph
|
||||
model: agent
|
||||
```
|
||||
|
||||
When an A2A request hits `/a2a/{agent_id}/message/send`, the bridge:
|
||||
|
||||
1. Detects `custom_llm_provider` in agent's `litellm_params`
|
||||
2. Transforms A2A message → OpenAI messages
|
||||
3. Calls `litellm.acompletion(model="langgraph/agent", api_base="http://localhost:2024")`
|
||||
4. Transforms response → A2A format
|
||||
|
||||
## Classes
|
||||
|
||||
- `A2ACompletionBridgeTransformation` - Static methods for message format conversion
|
||||
- `A2ACompletionBridgeHandler` - Static methods for handling requests (streaming/non-streaming)
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
"""
|
||||
LiteLLM Completion bridge provider for A2A protocol.
|
||||
|
||||
Routes A2A requests through litellm.acompletion based on custom_llm_provider.
|
||||
"""
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
"""
|
||||
Handler for A2A to LiteLLM completion bridge.
|
||||
|
||||
Routes A2A requests through litellm.acompletion based on custom_llm_provider.
|
||||
|
||||
A2A Streaming Events (in order):
|
||||
1. Task event (kind: "task") - Initial task creation with status "submitted"
|
||||
2. Status update (kind: "status-update") - Status change to "working"
|
||||
3. Artifact update (kind: "artifact-update") - Content/artifact delivery
|
||||
4. Status update (kind: "status-update") - Final status "completed" with final=true
|
||||
"""
|
||||
|
||||
from typing import Any, AsyncIterator, Dict, Optional
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.a2a_protocol.litellm_completion_bridge.pydantic_ai_transformation import (
|
||||
PydanticAITransformation,
|
||||
)
|
||||
from litellm.a2a_protocol.litellm_completion_bridge.transformation import (
|
||||
A2ACompletionBridgeTransformation,
|
||||
A2AStreamingContext,
|
||||
)
|
||||
|
||||
|
||||
class A2ACompletionBridgeHandler:
|
||||
"""
|
||||
Static methods for handling A2A requests via LiteLLM completion.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def handle_non_streaming(
|
||||
request_id: str,
|
||||
params: Dict[str, Any],
|
||||
litellm_params: Dict[str, Any],
|
||||
api_base: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle non-streaming A2A request via litellm.acompletion.
|
||||
|
||||
Args:
|
||||
request_id: A2A JSON-RPC request ID
|
||||
params: A2A MessageSendParams containing the message
|
||||
litellm_params: Agent's litellm_params (custom_llm_provider, model, etc.)
|
||||
api_base: API base URL from agent_card_params
|
||||
|
||||
Returns:
|
||||
A2A SendMessageResponse dict
|
||||
"""
|
||||
# Check if this is a Pydantic AI agent request
|
||||
custom_llm_provider = litellm_params.get("custom_llm_provider")
|
||||
if custom_llm_provider == "pydantic_ai_agents":
|
||||
if api_base is None:
|
||||
raise ValueError("api_base is required for Pydantic AI agents")
|
||||
|
||||
verbose_logger.info(
|
||||
f"Pydantic AI: Routing to Pydantic AI agent at {api_base}"
|
||||
)
|
||||
|
||||
# Send request directly to Pydantic AI agent
|
||||
response_data = await PydanticAITransformation.send_non_streaming_request(
|
||||
api_base=api_base,
|
||||
request_id=request_id,
|
||||
params=params,
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
# Extract message from params
|
||||
message = params.get("message", {})
|
||||
|
||||
# Transform A2A message to OpenAI format
|
||||
openai_messages = (
|
||||
A2ACompletionBridgeTransformation.a2a_message_to_openai_messages(message)
|
||||
)
|
||||
|
||||
# Get completion params
|
||||
custom_llm_provider = litellm_params.get("custom_llm_provider")
|
||||
model = litellm_params.get("model", "agent")
|
||||
|
||||
# Build full model string if provider specified
|
||||
# Skip prepending if model already starts with the provider prefix
|
||||
if custom_llm_provider and not model.startswith(f"{custom_llm_provider}/"):
|
||||
full_model = f"{custom_llm_provider}/{model}"
|
||||
else:
|
||||
full_model = model
|
||||
|
||||
verbose_logger.info(
|
||||
f"A2A completion bridge: model={full_model}, api_base={api_base}"
|
||||
)
|
||||
|
||||
# Build completion params dict
|
||||
completion_params = {
|
||||
"model": full_model,
|
||||
"messages": openai_messages,
|
||||
"api_base": api_base,
|
||||
"stream": False,
|
||||
}
|
||||
# Add litellm_params (contains api_key, client_id, client_secret, tenant_id, etc.)
|
||||
litellm_params_to_add = {
|
||||
k: v
|
||||
for k, v in litellm_params.items()
|
||||
if k not in ("model", "custom_llm_provider")
|
||||
}
|
||||
completion_params.update(litellm_params_to_add)
|
||||
|
||||
# Call litellm.acompletion
|
||||
response = await litellm.acompletion(**completion_params)
|
||||
|
||||
# Transform response to A2A format
|
||||
a2a_response = (
|
||||
A2ACompletionBridgeTransformation.openai_response_to_a2a_response(
|
||||
response=response,
|
||||
request_id=request_id,
|
||||
)
|
||||
)
|
||||
|
||||
verbose_logger.info(f"A2A completion bridge completed: request_id={request_id}")
|
||||
|
||||
return a2a_response
|
||||
|
||||
@staticmethod
|
||||
async def handle_streaming(
|
||||
request_id: str,
|
||||
params: Dict[str, Any],
|
||||
litellm_params: Dict[str, Any],
|
||||
api_base: Optional[str] = None,
|
||||
) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
Handle streaming A2A request via litellm.acompletion with stream=True.
|
||||
|
||||
Emits proper A2A streaming events:
|
||||
1. Task event (kind: "task") - Initial task with status "submitted"
|
||||
2. Status update (kind: "status-update") - Status "working"
|
||||
3. Artifact update (kind: "artifact-update") - Content delivery
|
||||
4. Status update (kind: "status-update") - Final "completed" status
|
||||
|
||||
Args:
|
||||
request_id: A2A JSON-RPC request ID
|
||||
params: A2A MessageSendParams containing the message
|
||||
litellm_params: Agent's litellm_params (custom_llm_provider, model, etc.)
|
||||
api_base: API base URL from agent_card_params
|
||||
|
||||
Yields:
|
||||
A2A streaming response events
|
||||
"""
|
||||
# Check if this is a Pydantic AI agent request
|
||||
custom_llm_provider = litellm_params.get("custom_llm_provider")
|
||||
if custom_llm_provider == "pydantic_ai_agents":
|
||||
if api_base is None:
|
||||
raise ValueError("api_base is required for Pydantic AI agents")
|
||||
|
||||
verbose_logger.info(
|
||||
f"Pydantic AI: Faking streaming for Pydantic AI agent at {api_base}"
|
||||
)
|
||||
|
||||
# Get non-streaming response first
|
||||
response_data = await PydanticAITransformation.send_non_streaming_request(
|
||||
api_base=api_base,
|
||||
request_id=request_id,
|
||||
params=params,
|
||||
)
|
||||
|
||||
# Convert to fake streaming
|
||||
async for chunk in PydanticAITransformation.fake_streaming_from_response(
|
||||
response_data=response_data,
|
||||
request_id=request_id,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
return
|
||||
|
||||
# Extract message from params
|
||||
message = params.get("message", {})
|
||||
|
||||
# Create streaming context
|
||||
ctx = A2AStreamingContext(
|
||||
request_id=request_id,
|
||||
input_message=message,
|
||||
)
|
||||
|
||||
# Transform A2A message to OpenAI format
|
||||
openai_messages = (
|
||||
A2ACompletionBridgeTransformation.a2a_message_to_openai_messages(message)
|
||||
)
|
||||
|
||||
# Get completion params
|
||||
custom_llm_provider = litellm_params.get("custom_llm_provider")
|
||||
model = litellm_params.get("model", "agent")
|
||||
|
||||
# Build full model string if provider specified
|
||||
# Skip prepending if model already starts with the provider prefix
|
||||
if custom_llm_provider and not model.startswith(f"{custom_llm_provider}/"):
|
||||
full_model = f"{custom_llm_provider}/{model}"
|
||||
else:
|
||||
full_model = model
|
||||
|
||||
verbose_logger.info(
|
||||
f"A2A completion bridge streaming: model={full_model}, api_base={api_base}"
|
||||
)
|
||||
|
||||
# Build completion params dict
|
||||
completion_params = {
|
||||
"model": full_model,
|
||||
"messages": openai_messages,
|
||||
"api_base": api_base,
|
||||
"stream": True,
|
||||
}
|
||||
# Add litellm_params (contains api_key, client_id, client_secret, tenant_id, etc.)
|
||||
litellm_params_to_add = {
|
||||
k: v
|
||||
for k, v in litellm_params.items()
|
||||
if k not in ("model", "custom_llm_provider")
|
||||
}
|
||||
completion_params.update(litellm_params_to_add)
|
||||
|
||||
# 1. Emit initial task event (kind: "task", status: "submitted")
|
||||
task_event = A2ACompletionBridgeTransformation.create_task_event(ctx)
|
||||
yield task_event
|
||||
|
||||
# 2. Emit status update (kind: "status-update", status: "working")
|
||||
working_event = A2ACompletionBridgeTransformation.create_status_update_event(
|
||||
ctx=ctx,
|
||||
state="working",
|
||||
final=False,
|
||||
message_text="Processing request...",
|
||||
)
|
||||
yield working_event
|
||||
|
||||
# Call litellm.acompletion with streaming
|
||||
response = await litellm.acompletion(**completion_params)
|
||||
|
||||
# 3. Accumulate content and emit artifact update
|
||||
accumulated_text = ""
|
||||
chunk_count = 0
|
||||
async for chunk in response: # type: ignore[union-attr]
|
||||
chunk_count += 1
|
||||
|
||||
# Extract delta content
|
||||
content = ""
|
||||
if chunk is not None and hasattr(chunk, "choices") and chunk.choices:
|
||||
choice = chunk.choices[0]
|
||||
if hasattr(choice, "delta") and choice.delta:
|
||||
content = choice.delta.content or ""
|
||||
|
||||
if content:
|
||||
accumulated_text += content
|
||||
|
||||
# Emit artifact update with accumulated content
|
||||
if accumulated_text:
|
||||
artifact_event = (
|
||||
A2ACompletionBridgeTransformation.create_artifact_update_event(
|
||||
ctx=ctx,
|
||||
text=accumulated_text,
|
||||
)
|
||||
)
|
||||
yield artifact_event
|
||||
|
||||
# 4. Emit final status update (kind: "status-update", status: "completed", final: true)
|
||||
completed_event = A2ACompletionBridgeTransformation.create_status_update_event(
|
||||
ctx=ctx,
|
||||
state="completed",
|
||||
final=True,
|
||||
)
|
||||
yield completed_event
|
||||
|
||||
verbose_logger.info(
|
||||
f"A2A completion bridge streaming completed: request_id={request_id}, chunks={chunk_count}"
|
||||
)
|
||||
|
||||
|
||||
# Convenience functions that delegate to the class methods
|
||||
async def handle_a2a_completion(
|
||||
request_id: str,
|
||||
params: Dict[str, Any],
|
||||
litellm_params: Dict[str, Any],
|
||||
api_base: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Convenience function for non-streaming A2A completion."""
|
||||
return await A2ACompletionBridgeHandler.handle_non_streaming(
|
||||
request_id=request_id,
|
||||
params=params,
|
||||
litellm_params=litellm_params,
|
||||
api_base=api_base,
|
||||
)
|
||||
|
||||
|
||||
async def handle_a2a_completion_streaming(
|
||||
request_id: str,
|
||||
params: Dict[str, Any],
|
||||
litellm_params: Dict[str, Any],
|
||||
api_base: Optional[str] = None,
|
||||
) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Convenience function for streaming A2A completion."""
|
||||
async for chunk in A2ACompletionBridgeHandler.handle_streaming(
|
||||
request_id=request_id,
|
||||
params=params,
|
||||
litellm_params=litellm_params,
|
||||
api_base=api_base,
|
||||
):
|
||||
yield chunk
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
"""
|
||||
Transformation utilities for A2A <-> OpenAI message format conversion.
|
||||
|
||||
A2A Message Format:
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [{"kind": "text", "text": "Hello!"}],
|
||||
"messageId": "abc123"
|
||||
}
|
||||
|
||||
OpenAI Message Format:
|
||||
{"role": "user", "content": "Hello!"}
|
||||
|
||||
A2A Streaming Events:
|
||||
- Task event (kind: "task") - Initial task creation with status "submitted"
|
||||
- Status update (kind: "status-update") - Status changes (working, completed)
|
||||
- Artifact update (kind: "artifact-update") - Content/artifact delivery
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
||||
|
||||
class A2AStreamingContext:
|
||||
"""
|
||||
Context holder for A2A streaming state.
|
||||
Tracks task_id, context_id, and message accumulation.
|
||||
"""
|
||||
|
||||
def __init__(self, request_id: str, input_message: Dict[str, Any]):
|
||||
self.request_id = request_id
|
||||
self.task_id = str(uuid4())
|
||||
self.context_id = str(uuid4())
|
||||
self.input_message = input_message
|
||||
self.accumulated_text = ""
|
||||
self.has_emitted_task = False
|
||||
self.has_emitted_working = False
|
||||
|
||||
|
||||
class A2ACompletionBridgeTransformation:
|
||||
"""
|
||||
Static methods for transforming between A2A and OpenAI message formats.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def a2a_message_to_openai_messages(
|
||||
a2a_message: Dict[str, Any],
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Transform an A2A message to OpenAI message format.
|
||||
|
||||
Args:
|
||||
a2a_message: A2A message with role, parts, and messageId
|
||||
|
||||
Returns:
|
||||
List of OpenAI-format messages
|
||||
"""
|
||||
role = a2a_message.get("role", "user")
|
||||
parts = a2a_message.get("parts", [])
|
||||
|
||||
# Map A2A roles to OpenAI roles
|
||||
openai_role = role
|
||||
if role == "user":
|
||||
openai_role = "user"
|
||||
elif role == "assistant":
|
||||
openai_role = "assistant"
|
||||
elif role == "system":
|
||||
openai_role = "system"
|
||||
|
||||
# Extract text content from parts
|
||||
content_parts = []
|
||||
for part in parts:
|
||||
kind = part.get("kind", "")
|
||||
if kind == "text":
|
||||
text = part.get("text", "")
|
||||
content_parts.append(text)
|
||||
|
||||
content = "\n".join(content_parts) if content_parts else ""
|
||||
|
||||
verbose_logger.debug(
|
||||
f"A2A -> OpenAI transform: role={role} -> {openai_role}, content_length={len(content)}"
|
||||
)
|
||||
|
||||
return [{"role": openai_role, "content": content}]
|
||||
|
||||
@staticmethod
|
||||
def openai_response_to_a2a_response(
|
||||
response: Any,
|
||||
request_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Transform a LiteLLM ModelResponse to A2A SendMessageResponse format.
|
||||
|
||||
Args:
|
||||
response: LiteLLM ModelResponse object
|
||||
request_id: Original A2A request ID
|
||||
|
||||
Returns:
|
||||
A2A SendMessageResponse dict
|
||||
"""
|
||||
# Extract content from response
|
||||
content = ""
|
||||
if hasattr(response, "choices") and response.choices:
|
||||
choice = response.choices[0]
|
||||
if hasattr(choice, "message") and choice.message:
|
||||
content = choice.message.content or ""
|
||||
|
||||
# Build A2A message
|
||||
a2a_message = {
|
||||
"role": "agent",
|
||||
"parts": [{"kind": "text", "text": content}],
|
||||
"messageId": uuid4().hex,
|
||||
}
|
||||
|
||||
# Build A2A response
|
||||
a2a_response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"message": a2a_message,
|
||||
},
|
||||
}
|
||||
|
||||
verbose_logger.debug(f"OpenAI -> A2A transform: content_length={len(content)}")
|
||||
|
||||
return a2a_response
|
||||
|
||||
@staticmethod
|
||||
def _get_timestamp() -> str:
|
||||
"""Get current timestamp in ISO format with timezone."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@staticmethod
|
||||
def create_task_event(
|
||||
ctx: A2AStreamingContext,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create the initial task event with status 'submitted'.
|
||||
|
||||
This is the first event emitted in an A2A streaming response.
|
||||
"""
|
||||
return {
|
||||
"id": ctx.request_id,
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"contextId": ctx.context_id,
|
||||
"history": [
|
||||
{
|
||||
"contextId": ctx.context_id,
|
||||
"kind": "message",
|
||||
"messageId": ctx.input_message.get("messageId", uuid4().hex),
|
||||
"parts": ctx.input_message.get("parts", []),
|
||||
"role": ctx.input_message.get("role", "user"),
|
||||
"taskId": ctx.task_id,
|
||||
}
|
||||
],
|
||||
"id": ctx.task_id,
|
||||
"kind": "task",
|
||||
"status": {
|
||||
"state": "submitted",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_status_update_event(
|
||||
ctx: A2AStreamingContext,
|
||||
state: str,
|
||||
final: bool = False,
|
||||
message_text: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a status update event.
|
||||
|
||||
Args:
|
||||
ctx: Streaming context
|
||||
state: Status state ('working', 'completed')
|
||||
final: Whether this is the final event
|
||||
message_text: Optional message text for 'working' status
|
||||
"""
|
||||
status: Dict[str, Any] = {
|
||||
"state": state,
|
||||
"timestamp": A2ACompletionBridgeTransformation._get_timestamp(),
|
||||
}
|
||||
|
||||
# Add message for 'working' status
|
||||
if state == "working" and message_text:
|
||||
status["message"] = {
|
||||
"contextId": ctx.context_id,
|
||||
"kind": "message",
|
||||
"messageId": str(uuid4()),
|
||||
"parts": [{"kind": "text", "text": message_text}],
|
||||
"role": "agent",
|
||||
"taskId": ctx.task_id,
|
||||
}
|
||||
|
||||
return {
|
||||
"id": ctx.request_id,
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"contextId": ctx.context_id,
|
||||
"final": final,
|
||||
"kind": "status-update",
|
||||
"status": status,
|
||||
"taskId": ctx.task_id,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_artifact_update_event(
|
||||
ctx: A2AStreamingContext,
|
||||
text: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create an artifact update event with content.
|
||||
|
||||
Args:
|
||||
ctx: Streaming context
|
||||
text: The text content for the artifact
|
||||
"""
|
||||
return {
|
||||
"id": ctx.request_id,
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"artifact": {
|
||||
"artifactId": str(uuid4()),
|
||||
"name": "response",
|
||||
"parts": [{"kind": "text", "text": text}],
|
||||
},
|
||||
"contextId": ctx.context_id,
|
||||
"kind": "artifact-update",
|
||||
"taskId": ctx.task_id,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def openai_chunk_to_a2a_chunk(
|
||||
chunk: Any,
|
||||
request_id: Optional[str] = None,
|
||||
is_final: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Transform a LiteLLM streaming chunk to A2A streaming format.
|
||||
|
||||
NOTE: This method is deprecated for streaming. Use the event-based
|
||||
methods (create_task_event, create_status_update_event,
|
||||
create_artifact_update_event) instead for proper A2A streaming.
|
||||
|
||||
Args:
|
||||
chunk: LiteLLM ModelResponse chunk
|
||||
request_id: Original A2A request ID
|
||||
is_final: Whether this is the final chunk
|
||||
|
||||
Returns:
|
||||
A2A streaming chunk dict or None if no content
|
||||
"""
|
||||
# Extract delta content
|
||||
content = ""
|
||||
if chunk is not None and hasattr(chunk, "choices") and chunk.choices:
|
||||
choice = chunk.choices[0]
|
||||
if hasattr(choice, "delta") and choice.delta:
|
||||
content = choice.delta.content or ""
|
||||
|
||||
if not content and not is_final:
|
||||
return None
|
||||
|
||||
# Build A2A streaming chunk (legacy format)
|
||||
a2a_chunk = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"message": {
|
||||
"role": "agent",
|
||||
"parts": [{"kind": "text", "text": content}],
|
||||
"messageId": uuid4().hex,
|
||||
},
|
||||
"final": is_final,
|
||||
},
|
||||
}
|
||||
|
||||
return a2a_chunk
|
||||
|
|
@ -289,16 +289,16 @@ class PydanticAITransformation:
|
|||
Transform Pydantic AI task response to standard A2A non-streaming format.
|
||||
|
||||
Pydantic AI returns a task with history/artifacts, but the standard A2A
|
||||
non-streaming format expects:
|
||||
non-streaming format expects ``result`` to be the Message directly
|
||||
(``kind="message"``), per the A2A spec / ``SendMessageResponse``:
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "...",
|
||||
"result": {
|
||||
"message": {
|
||||
"role": "agent",
|
||||
"parts": [{"kind": "text", "text": "..."}],
|
||||
"messageId": "..."
|
||||
}
|
||||
"kind": "message",
|
||||
"role": "agent",
|
||||
"parts": [{"kind": "text", "text": "..."}],
|
||||
"messageId": "..."
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -316,6 +316,7 @@ class PydanticAITransformation:
|
|||
|
||||
# Build standard A2A message
|
||||
a2a_message = {
|
||||
"kind": "message",
|
||||
"role": "agent",
|
||||
"parts": parts if parts else [{"kind": "text", "text": full_text}],
|
||||
"messageId": message_id,
|
||||
|
|
@ -325,9 +326,7 @@ class PydanticAITransformation:
|
|||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"message": a2a_message,
|
||||
},
|
||||
"result": a2a_message,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -60,6 +60,12 @@ class A2ARequestUtils:
|
|||
if not isinstance(result, dict):
|
||||
return ""
|
||||
|
||||
# Direct message format (A2A spec): detect by explicit kind tag only.
|
||||
# The "parts" heuristic is too broad and would match any future result
|
||||
# type that happens to include a "parts" field.
|
||||
if result.get("kind") == "message":
|
||||
return A2ARequestUtils.extract_text_from_message(result)
|
||||
|
||||
message = result.get("message", {})
|
||||
return A2ARequestUtils.extract_text_from_message(message)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ This is an __init__.py file to allow the following interface
|
|||
|
||||
"""
|
||||
|
||||
from typing import Any, AsyncIterator, Coroutine, Dict, List, Optional, Union
|
||||
from typing import Any, AsyncIterator, Coroutine, Dict, Iterator, List, Optional, Union
|
||||
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
|
||||
anthropic_messages as _async_anthropic_messages,
|
||||
|
|
@ -100,8 +100,11 @@ def create(
|
|||
**kwargs,
|
||||
) -> Union[
|
||||
AnthropicMessagesResponse,
|
||||
Iterator[bytes],
|
||||
AsyncIterator[Any],
|
||||
Coroutine[Any, Any, Union[AnthropicMessagesResponse, AsyncIterator[Any]]],
|
||||
Coroutine[
|
||||
Any, Any, Union[AnthropicMessagesResponse, AsyncIterator[Any], Iterator[bytes]]
|
||||
],
|
||||
]:
|
||||
"""
|
||||
Async wrapper for Anthropic's messages API
|
||||
|
|
|
|||
|
|
@ -1409,6 +1409,13 @@ LITELLM_INTERNAL_JOBS_SERVICE_ACCOUNT_NAME = "litellm_internal_jobs"
|
|||
# Prometheus metrics, audit trails, or any other downstream consumer.
|
||||
LITELLM_PROXY_MASTER_KEY_ALIAS = "litellm_proxy_master_key"
|
||||
|
||||
# Marker placed in ``model_call_details`` on a synthetic ``Logging`` object that
|
||||
# records a proxy-gate error (auth/rate-limit rejection) for a request that never
|
||||
# reached an upstream provider. Tracing callbacks key off it to avoid fabricating
|
||||
# an LLM-call span for a call that did not happen. See
|
||||
# ``ProxyLogging._handle_logging_proxy_only_error``.
|
||||
LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL = "litellm_no_upstream_llm_call"
|
||||
|
||||
# Key Rotation Constants
|
||||
LITELLM_KEY_ROTATION_ENABLED = os.getenv("LITELLM_KEY_ROTATION_ENABLED", "false")
|
||||
LITELLM_KEY_ROTATION_CHECK_INTERVAL_SECONDS = int(
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ HTTP_RESPONSE_STATUS_CODE_ATTRIBUTE = "http.response.status_code"
|
|||
HTTP_ROUTE_ATTRIBUTE = "http.route"
|
||||
URL_PATH_ATTRIBUTE = "url.path"
|
||||
PREPROCESSING_DURATION_MS_ATTRIBUTE = "litellm.preprocessing.duration_ms"
|
||||
TEAM_METADATA_ATTRIBUTE = "litellm.team.metadata"
|
||||
MODEL_GROUP_ATTRIBUTE = "litellm.model_group"
|
||||
PROVIDER_MODEL_ATTRIBUTE = "litellm.provider.model"
|
||||
# Remove the hardcoded LITELLM_RESOURCE dictionary - we'll create it properly later
|
||||
RAW_REQUEST_SPAN_NAME = "raw_gen_ai_request"
|
||||
LITELLM_REQUEST_SPAN_NAME = "litellm_request"
|
||||
|
|
@ -1213,6 +1216,68 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
|||
):
|
||||
self._set_team_attributes_from_kwargs(proxy_span, kwargs)
|
||||
|
||||
def _set_inference_identity_attributes(
|
||||
self,
|
||||
span: Span,
|
||||
standard_logging_payload: StandardLoggingPayload,
|
||||
litellm_params: dict,
|
||||
) -> None:
|
||||
"""Stamp request-identity attributes onto an inference span so every
|
||||
LLM-call span is filterable by the route it came in on, the team's
|
||||
metadata, and both the user-facing (model_group alias) and the
|
||||
dispatched (provider) model names. Empty/absent values are skipped.
|
||||
"""
|
||||
metadata = standard_logging_payload.get("metadata") or {}
|
||||
|
||||
http_route = metadata.get("user_api_key_request_route")
|
||||
if http_route:
|
||||
self.safe_set_attribute(
|
||||
span=span, key=HTTP_ROUTE_ATTRIBUTE, value=http_route
|
||||
)
|
||||
|
||||
# ``user_api_key_team_metadata`` is dropped from the standard logging
|
||||
# payload metadata, so read it from the raw request metadata in kwargs.
|
||||
# ``metadata`` and ``litellm_metadata`` are alternate names for the same
|
||||
# full metadata dict (the name varies by endpoint), so first-truthy wins.
|
||||
raw_metadata = (
|
||||
litellm_params.get("metadata")
|
||||
or litellm_params.get("litellm_metadata")
|
||||
or {}
|
||||
)
|
||||
team_metadata = self._team_metadata_json(
|
||||
raw_metadata.get("user_api_key_team_metadata")
|
||||
)
|
||||
if team_metadata:
|
||||
self.safe_set_attribute(
|
||||
span=span, key=TEAM_METADATA_ATTRIBUTE, value=team_metadata
|
||||
)
|
||||
|
||||
model_group = standard_logging_payload.get("model_group")
|
||||
if model_group:
|
||||
self.safe_set_attribute(
|
||||
span=span, key=MODEL_GROUP_ATTRIBUTE, value=model_group
|
||||
)
|
||||
|
||||
hidden_params = standard_logging_payload.get("hidden_params") or {}
|
||||
provider_model = hidden_params.get(
|
||||
"litellm_model_name"
|
||||
) or standard_logging_payload.get("model")
|
||||
if provider_model:
|
||||
self.safe_set_attribute(
|
||||
span=span, key=PROVIDER_MODEL_ATTRIBUTE, value=provider_model
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _team_metadata_json(value: Any) -> Optional[str]:
|
||||
"""JSON-serialize a team's metadata dict for a single span attribute.
|
||||
|
||||
Returns ``None`` for a missing, non-dict, or empty mapping so the
|
||||
empty case is dropped rather than stamping a useless ``"{}"``.
|
||||
"""
|
||||
if not isinstance(value, dict) or not value:
|
||||
return None
|
||||
return safe_dumps(value)
|
||||
|
||||
def _record_metrics(self, kwargs, response_obj, start_time, end_time):
|
||||
duration_s = (end_time - start_time).total_seconds()
|
||||
params = kwargs.get("litellm_params") or {}
|
||||
|
|
@ -2023,6 +2088,12 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
|
|||
key="hidden_params",
|
||||
value=safe_dumps(hidden_params),
|
||||
)
|
||||
|
||||
self._set_inference_identity_attributes(
|
||||
span=span,
|
||||
standard_logging_payload=standard_logging_payload,
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
# Cost breakdown tracking
|
||||
cost_breakdown: Optional[CostBreakdown] = standard_logging_payload.get(
|
||||
"cost_breakdown"
|
||||
|
|
|
|||
258
litellm/integrations/otel/README.md
Normal file
258
litellm/integrations/otel/README.md
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
# OpenTelemetry instrumentation
|
||||
|
||||
This package produces OpenTelemetry traces for LiteLLM. It is enabled by the
|
||||
`LITELLM_OTEL_V2` environment variable (`is_otel_v2_enabled()` in
|
||||
[`config.py`](./model/config.py)); when unset, nothing in this package runs.
|
||||
|
||||
## What gets traced
|
||||
|
||||
A traced proxy request produces one trace with two kinds of spans:
|
||||
|
||||
```
|
||||
SERVER span "POST /v1/chat/completions" ← FastAPI instrumentation
|
||||
├── INTERNAL span "auth /v1/chat/completions" ← auth phase ┐
|
||||
│ ├── CLIENT span "postgres get_key_object" ← datastore call │
|
||||
│ └── CLIENT span "postgres get_team_membership" │
|
||||
├── INTERNAL span "execute_guardrail …" ← guardrail │ this package
|
||||
├── CLIENT span "chat gpt-4o" ← LLM call │
|
||||
└── CLIENT span "batch_write_to_db …" ← spend write ┘
|
||||
```
|
||||
|
||||
The gen-ai spans are siblings under the server span. In particular the guardrail
|
||||
span is a sibling of the LLM call, not a child of it: pre/during/post-call
|
||||
guardrail hooks are part of the request lifecycle (a pre-call guardrail runs
|
||||
before the LLM call even starts), so they belong directly under the server span,
|
||||
alongside the LLM call.
|
||||
|
||||
Request-level spans (LLM call, guardrail) parent to the server span via an
|
||||
**explicit anchor** — `context.set_request_root_span` captures the server span
|
||||
once at request entry, and `resolve_request_span_context` reads it — rather than
|
||||
to whatever span is momentarily active. Ambient-only parenting was wrong at two
|
||||
boundaries: inside the live `auth` phase span the active span is `auth` (so the
|
||||
span would nest under auth), and a pass-through request closes its span from a
|
||||
detached `asyncio.create_task` where the server span is no longer active (so the
|
||||
span orphaned into its own trace). The anchor — a contextvar inherited by those
|
||||
child tasks — gives a stable parent in both cases. DB/service spans keep ambient
|
||||
parenting so an auth DB lookup still nests under `auth`.
|
||||
|
||||
**Which service calls become spans (`spans.span_role_for_service`).** LiteLLM's
|
||||
service-logging layer instruments many internal functions, but only some are
|
||||
traceable units of work:
|
||||
|
||||
- **`DB_CALL` (CLIENT)** — outbound datastore calls (redis, postgres,
|
||||
`batch_write_to_db`), carrying `db.system.name` / `db.operation.name` semconv.
|
||||
- **`SERVICE` (INTERNAL)** — genuine internal work worth a span (background
|
||||
budget/reset jobs, pod-lock manager).
|
||||
- **metrics-only (no span)** — `self` (the `track_llm_api_timing` wrapper, which
|
||||
duplicates the LLM-call span), `router` (duplicates the request), and
|
||||
`proxy_pre_call` (a guardrail's real span is `execute_guardrail …`). These
|
||||
still feed Prometheus/Datadog through their own hooks; they just never enter
|
||||
the trace. `auth` is also excluded here because it gets a **live phase span**
|
||||
instead (see below).
|
||||
|
||||
Spans are named `"{service} {call_type}"` (e.g. `"redis set"`) so repeated calls
|
||||
to one service stay distinguishable. Like every other span they parent to the
|
||||
**ambient** context, falling back to the threaded `litellm_parent_otel_span` only
|
||||
when ambient has no live span; a background job with neither starts its own root
|
||||
trace. Caller-supplied `event_metadata` is **sanitized** before it reaches a span
|
||||
(primitives only, no live objects, no secrets/headers, bounded) — see
|
||||
`payloads.sanitize_event_metadata`.
|
||||
|
||||
**Live phase spans.** `auth` is wrapped in a real, active span
|
||||
(`logger.phase_span`) for the duration of authentication, so the DB lookups it
|
||||
triggers nest **under** it instead of flattening onto the server span. Identity
|
||||
Baggage (team/key/user) is seeded once the key resolves, so every post-auth span
|
||||
inherits it; auth-internal DB lookups that run before the key is known stay
|
||||
unlabeled, which is correct.
|
||||
|
||||
**Status.** On success a span's status is left `UNSET` (the semconv default,
|
||||
matching the FastAPI server span); only a genuine error sets `ERROR`.
|
||||
|
||||
- **Server spans** (one per HTTP route) are created by the
|
||||
`opentelemetry-instrumentation-fastapi` package. It stamps `http.*` attributes
|
||||
and extracts inbound `traceparent` headers. This package does **not** create
|
||||
or modify server spans — request routes never touch spans.
|
||||
- **Gen-AI spans** (LLM calls, guardrails, internal service calls) are created
|
||||
by this package from LiteLLM's logging callbacks. Request-level spans parent to
|
||||
the server span via the captured anchor; DB/service spans parent to the active
|
||||
span (ambient) so they nest under the request phase that triggered them.
|
||||
|
||||
Both kinds share a single `TracerProvider`, so they belong to the same trace
|
||||
and export through the same configured exporters. FastAPI middleware can only be
|
||||
added before the app starts serving, so the app is instrumented at
|
||||
import time **without** a provider — it binds to the OTel global
|
||||
`ProxyTracerProvider`. Once config (and the callbacks) is loaded, the proxy
|
||||
publishes the chosen logger's `TracerProvider` as the global via
|
||||
`trace.set_tracer_provider(...)`, and the server spans delegate to it. When a
|
||||
preset callback (`arize`, `langfuse_otel`, …) is configured, its provider
|
||||
becomes the global, so server spans export to that backend too.
|
||||
|
||||
## How a request flows
|
||||
|
||||
1. **App creation** (`proxy_server` import): when the gate is on,
|
||||
`mount.instrument_fastapi_app(app)` calls `FastAPIInstrumentor.instrument_app`
|
||||
with no provider (the middleware stack is frozen once the app serves, so this
|
||||
can't wait for startup). It binds to the OTel global `ProxyTracerProvider`. Noisy
|
||||
non-LLM routes are excluded by default (`mount._DEFAULT_EXCLUDED_ROUTES`): health
|
||||
checks (`/health*`), the Prometheus scrape (`/metrics`), and static UI/docs assets
|
||||
(`/litellm-asset-prefix`, `/_next`, `/ui`, `/swagger`, `/docs`, `/redoc`,
|
||||
`/openapi.json`, favicons, `/.well-known`) — so load-balancer polling, metric
|
||||
scrapes, and asset fetches don't flood traces. Entries are substring-matched, so
|
||||
`/metrics` also drops the `/model/metrics` admin-analytics spans. Set
|
||||
`OTEL_PYTHON_FASTAPI_EXCLUDED_URLS` to override the whole set (e.g. `""` to trace
|
||||
everything, or your own comma-separated path list).
|
||||
2. **Startup** (`proxy_server.proxy_startup_event`): after the config (and
|
||||
callbacks) is loaded, the already-registered preset `OpenTelemetryV2` logger
|
||||
is reused — or a generic one reading `OTEL_*` envs is built when no preset is
|
||||
configured — and its `TracerProvider` is published as the OTel global with
|
||||
`trace.set_tracer_provider(...)`. The proxy tracer then delegates to it, so
|
||||
server spans and gen-ai spans share one provider and the same trace.
|
||||
3. **Request**: the FastAPI instrumentation starts the server span and makes it
|
||||
the active context for the request task. The proxy's first call into the V2
|
||||
logger (`create_litellm_proxy_request_started_span`, at the auth boundary)
|
||||
**captures it as the request anchor** (`set_request_root_span`), so every later
|
||||
request-level span has a stable explicit parent regardless of what is active
|
||||
when it emits.
|
||||
4. **LLM call span (born at the boundary)**: `OpenTelemetryV2.log_pre_api_call`
|
||||
runs synchronously in the request task, just before the upstream call, and
|
||||
**opens** the LLM-call span there, parented to the anchored server span
|
||||
(`resolve_request_span_context`). The open span is held in a bounded cache keyed
|
||||
by `litellm_call_id` (a primitive the callback kwargs carry at both `pre_call`
|
||||
and close), so no live `Span` ever travels through a `litellm_params` metadata
|
||||
dict. For the boundary hook to fire at all, the logger is registered into
|
||||
`litellm.input_callback` — the list `Logging.pre_call` iterates. The async
|
||||
success/failure callback later
|
||||
**closes** it: it builds an `LLMCallSpanData` from the typed
|
||||
`standard_logging_object` (token usage and cost are computed only by then),
|
||||
stamps the attributes, sets status, and ends the span. The sync callback is a
|
||||
no-op (closing is async-only). When `pre_call` runs off the request task — a
|
||||
sync-only provider driven through a thread pool, where contextvars (and so the
|
||||
anchor) don't follow — no parent is visible there, so creation is **deferred**
|
||||
to the async callback, whose worker context was copied from the request task at
|
||||
enqueue and so still carries the anchor. **Pass-through** endpoints call
|
||||
`logging_obj.pre_call` in the request task too, then close from a detached
|
||||
`asyncio.create_task`; the anchor (not the by-then-inactive server span) keeps
|
||||
their LLM-call span in the request's trace. `pre_call` is litellm's generic
|
||||
"log the attempt" hook, so it also fires for synthetic proxy-gate error logs
|
||||
(auth/rate-limit rejections); those carry `LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL`
|
||||
and are skipped, so a request rejected before reaching a provider never produces
|
||||
a phantom CLIENT span.
|
||||
5. **Guardrails / services**: the post-call and service hooks emit guardrail and
|
||||
service spans the same way — typed data → engine → span. Service spans
|
||||
(Redis/Postgres) are dispatched by `litellm/_service_logger.py`, which
|
||||
recognizes the V2 `OpenTelemetryV2` logger (a plain `CustomLogger`, not a
|
||||
subclass of the legacy `OpenTelemetry`). It hands every service call to the
|
||||
logger — including calls with no parent span — and the V2 adapter decides the
|
||||
role (`DB_CALL` vs `SERVICE`), the parent (ambient → threaded → root), and
|
||||
whether the call is a traceable operation or a metrics-only ping. Guardrail
|
||||
span data is built from the typed, provider-agnostic
|
||||
`StandardLoggingGuardrailInformation` — no single provider's field shape is
|
||||
assumed.
|
||||
6. **Export**: each span ends and is handed to the provider's span processors,
|
||||
which export to the configured backends (OTLP, console, in-memory, …).
|
||||
|
||||
## Components
|
||||
|
||||
### Sources of truth (`model/`, no OpenTelemetry import)
|
||||
|
||||
These define the shape of a span without depending on the OTel SDK, so they can
|
||||
be imported anywhere. They live in [`model/`](./model) and form a closed set —
|
||||
nothing here imports outside it:
|
||||
|
||||
- [`semconv.py`](./model/semconv.py) — attribute-key constants (`gen_ai.*`, `http.*`,
|
||||
`litellm.*`), the GenAI operation/provider enums, and the functions that map
|
||||
LiteLLM provider/call-type strings onto convention values.
|
||||
- [`spans.py`](./model/spans.py) — the span registry: every span role, its OTel span
|
||||
kind, its place in the hierarchy, and its name builder.
|
||||
- [`payloads.py`](./model/payloads.py) — frozen dataclasses (`LLMCallSpanData`,
|
||||
`GuardrailSpanData`, `ServiceSpanData`, …) built from heterogeneous logging
|
||||
payloads via `from_*` classmethods.
|
||||
- [`config.py`](./model/config.py) — `OpenTelemetryV2Config`, a pydantic-settings
|
||||
model that reads `OTEL_*` / `LITELLM_OTEL_*` env vars, plus the feature gate.
|
||||
`capture_span_content` gates whether prompt/response bodies may be written as
|
||||
span attributes; it defaults **off** (`no_content`). The Baggage allowlists are
|
||||
configurable, not hard-coded: set `LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS` /
|
||||
`LITELLM_OTEL_BAGGAGE_METADATA_KEYS` (comma-separated) as env vars, or
|
||||
`baggage_promoted_keys` / `baggage_metadata_keys` (YAML lists) under
|
||||
`callback_settings.otel` in `config.yaml` — the latter reach the config through
|
||||
the logger's constructor kwargs.
|
||||
- [`baggage.py`](./model/baggage.py) — the single definition of which request-identity
|
||||
values are promoted into Baggage (so child spans inherit them) and under which
|
||||
attribute keys.
|
||||
- [`utils.py`](./model/utils.py) — value coercion, JSON serialization, and
|
||||
extractor-table application, shared across the package.
|
||||
|
||||
### Engine
|
||||
|
||||
- [`emitter.py`](./emitter.py) — `SpanEmitter.emit(role, data)`: dedupe → start
|
||||
the span → run the mapper chain to stamp attributes → set status → end. It
|
||||
owns no attribute keys. The dedupe set (which coalesces the sync+async firing
|
||||
of one request) is a bounded LRU so it can't grow without limit.
|
||||
- [`mappers/`](./mappers) — each mapper turns typed span data into a flat
|
||||
`{attribute key: value}` dict. They compose: listing several mapper names in
|
||||
the config layers multiple attribute vocabularies onto the same span.
|
||||
- `genai` — the canonical OpenTelemetry GenAI vocabulary, always present.
|
||||
- `legacy` — an additional vocabulary using the older semconv-ai / Traceloop
|
||||
attribute key names, for backends that read those.
|
||||
- `openinference`, `langfuse`, `weave`, `langtrace` — vendor vocabularies.
|
||||
- `resolve_mappers(names)` turns config names into mapper instances.
|
||||
|
||||
### Plumbing (`plumbing/`)
|
||||
|
||||
The OTel-SDK wiring. Everything here imports only `model/` and each other; it
|
||||
lives in [`plumbing/`](./plumbing):
|
||||
|
||||
- [`providers.py`](./plumbing/providers.py) — builds the `TracerProvider`, its exporters
|
||||
(from `ExporterSpec`s), and the span processor that copies allowlisted Baggage
|
||||
entries onto every span. `register_exporter_factory(kind, factory)` lets a
|
||||
preset contribute a custom exporter `kind` (e.g. one that fetches an auth
|
||||
token lazily) without coupling this module to any vendor.
|
||||
- [`context.py`](./plumbing/context.py) — trace-context and Baggage read/write helpers.
|
||||
- [`routing.py`](./plumbing/routing.py) — `TenantTracerCache`: when a request carries
|
||||
team/key-scoped vendor credentials, route its spans through a credential-keyed
|
||||
`TracerProvider` so one logger serves many tenants. The cache is a bounded LRU
|
||||
that flushes + shuts down evicted providers, since the key derives from
|
||||
request-supplied credentials and must not grow (or leak threads) without limit.
|
||||
- [`metrics.py`](./plumbing/metrics.py) — GenAI client metric instruments.
|
||||
|
||||
### Adapter
|
||||
|
||||
- [`logger.py`](./logger.py) — `OpenTelemetryV2`, a `CustomLogger` that
|
||||
translates LiteLLM's logging callbacks into typed span data and hands them to
|
||||
the engine. The LLM-call span is opened at the `log_pre_api_call` boundary
|
||||
(parented to the live server span via ambient context) and closed at the async
|
||||
success/failure callback; the open span is held in a bounded cache keyed by
|
||||
`litellm_call_id`, never threaded through a metadata dict. The logger registers
|
||||
itself into `litellm.input_callback` so `Logging.pre_call` fires the boundary
|
||||
hook.
|
||||
- [`mount.py`](./mount.py) — `instrument_fastapi_app(app)`, the single call site
|
||||
that attaches `opentelemetry-instrumentation-fastapi` for SERVER spans. It owns
|
||||
the health-check exclusion default (`OTEL_PYTHON_FASTAPI_EXCLUDED_URLS`) and the
|
||||
passthrough span-naming hook (`PASSTHROUGH_PREFIXES`) so `proxy_server` carries
|
||||
no OTel detail. A safe no-op when the gate is off or the instrumentation package
|
||||
is absent; must be called at app-creation time (the middleware stack freezes
|
||||
once the app serves).
|
||||
|
||||
### Presets
|
||||
|
||||
- [`presets/`](./presets) — each preset reads one integration's env vars and
|
||||
returns an `OpenTelemetryV2Config` (exporter destination + mapper vocabularies
|
||||
+ resource attributes). `PRESET_BY_CALLBACK` maps a callback name (`"arize"`,
|
||||
`"langfuse_otel"`, …) to its preset. Integrations that support team/key-scoped
|
||||
credentials also provide a per-request OTLP header builder
|
||||
(`DYNAMIC_HEADERS_BY_CALLBACK`). Presets do **no** network I/O at build time:
|
||||
AgentOps, for example, mints its JWT lazily inside a custom exporter on the
|
||||
first export (in the `BatchSpanProcessor` worker thread), never on the event
|
||||
loop.
|
||||
|
||||
## Extending
|
||||
|
||||
- **A new attribute vocabulary for a backend**: add a mapper in `mappers/`
|
||||
(a class with a `map(data) -> AttributeMap` method, typically built from
|
||||
`key -> extractor` tables) and register it in `mappers/__init__._MAPPER_BY_NAME`.
|
||||
- **A new integration**: add a preset in `presets/` that returns an
|
||||
`OpenTelemetryV2Config`, and register it in `presets/__init__.PRESET_BY_CALLBACK`.
|
||||
If it supports dynamic credentials, add a header builder to
|
||||
`DYNAMIC_HEADERS_BY_CALLBACK`.
|
||||
- **A new span kind**: add a role to `spans.py` (registry entry + name builder),
|
||||
a payload dataclass in `payloads.py`, and a branch in the relevant mapper(s).
|
||||
102
litellm/integrations/otel/__init__.py
Normal file
102
litellm/integrations/otel/__init__.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Typed, semconv-aligned OpenTelemetry instrumentation for LiteLLM.
|
||||
|
||||
The three sources of truth — attribute keys (:mod:`semconv`), the span and
|
||||
hierarchy registry (:mod:`spans`), and the typed span-data inputs
|
||||
(:mod:`payloads`) — plus :mod:`config` are exported here and are free of any
|
||||
``opentelemetry`` import. The engine layer (``emitter``, ``providers``,
|
||||
``context``, ``metrics``) and the ``CustomLogger`` adapter (``logger``) are
|
||||
reached via their submodule paths so that importing this package never
|
||||
requires the OTel SDK.
|
||||
|
||||
The ``LITELLM_OTEL_V2`` env var gates whether the factory in
|
||||
``litellm_core_utils.litellm_logging`` constructs the ``OpenTelemetryV2``
|
||||
class (from :mod:`logger`).
|
||||
"""
|
||||
|
||||
from litellm.integrations.otel.model.config import (
|
||||
OTEL_V2_ENV,
|
||||
OpenTelemetryV2Config,
|
||||
is_otel_v2_enabled,
|
||||
)
|
||||
from litellm.integrations.otel.model.baggage import (
|
||||
BAGGAGE_PROMOTED_KEYS,
|
||||
DEFAULT_BAGGAGE_METADATA_KEYS,
|
||||
promoted_baggage,
|
||||
)
|
||||
from litellm.integrations.otel.model.metadata import (
|
||||
RequestContext,
|
||||
RequestIdentity,
|
||||
)
|
||||
from litellm.integrations.otel.model.payloads import (
|
||||
GuardrailSpanData,
|
||||
LLMCallSpanData,
|
||||
LLMRequestParams,
|
||||
LLMUsage,
|
||||
ProxyRequestSpanData,
|
||||
ServerInfo,
|
||||
ServiceSpanData,
|
||||
SpanError,
|
||||
)
|
||||
from litellm.integrations.otel.model.semconv import (
|
||||
DB,
|
||||
Error,
|
||||
GenAI,
|
||||
GenAIOperation,
|
||||
GenAIProvider,
|
||||
HTTP,
|
||||
LiteLLM,
|
||||
Metric,
|
||||
Server,
|
||||
resolve_operation,
|
||||
resolve_provider,
|
||||
)
|
||||
from litellm.integrations.otel.model.spans import (
|
||||
SPAN_REGISTRY,
|
||||
LiteLLMSpanKind,
|
||||
SpanRole,
|
||||
SpanSpec,
|
||||
db_system,
|
||||
span_role_for_service,
|
||||
validate_registry,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# config
|
||||
"OTEL_V2_ENV",
|
||||
"OpenTelemetryV2Config",
|
||||
"is_otel_v2_enabled",
|
||||
# semconv
|
||||
"BAGGAGE_PROMOTED_KEYS",
|
||||
"DB",
|
||||
"DEFAULT_BAGGAGE_METADATA_KEYS",
|
||||
"Error",
|
||||
"GenAI",
|
||||
"GenAIOperation",
|
||||
"GenAIProvider",
|
||||
"HTTP",
|
||||
"LiteLLM",
|
||||
"Metric",
|
||||
"Server",
|
||||
"resolve_operation",
|
||||
"resolve_provider",
|
||||
# spans
|
||||
"SPAN_REGISTRY",
|
||||
"LiteLLMSpanKind",
|
||||
"SpanRole",
|
||||
"SpanSpec",
|
||||
"db_system",
|
||||
"span_role_for_service",
|
||||
"validate_registry",
|
||||
# payloads
|
||||
"GuardrailSpanData",
|
||||
"LLMCallSpanData",
|
||||
"LLMRequestParams",
|
||||
"LLMUsage",
|
||||
"ProxyRequestSpanData",
|
||||
"RequestContext",
|
||||
"RequestIdentity",
|
||||
"ServerInfo",
|
||||
"ServiceSpanData",
|
||||
"SpanError",
|
||||
"promoted_baggage",
|
||||
]
|
||||
175
litellm/integrations/otel/emitter.py
Normal file
175
litellm/integrations/otel/emitter.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""The span engine: dedup, start, run the mapper chain, set status, end."""
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Callable, Sequence
|
||||
|
||||
from opentelemetry.context import Context
|
||||
from opentelemetry.trace import Span, Tracer
|
||||
from opentelemetry.trace.status import Status, StatusCode
|
||||
|
||||
from litellm.integrations.otel.model.config import OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.mappers import resolve_mappers
|
||||
from litellm.integrations.otel.mappers.base import AttributeMapper, SpanData
|
||||
from litellm.integrations.otel.model.payloads import (
|
||||
GuardrailSpanData,
|
||||
LLMCallSpanData,
|
||||
ServiceSpanData,
|
||||
)
|
||||
from litellm.integrations.otel.plumbing.providers import to_otel_span_kind
|
||||
from litellm.integrations.otel.model.semconv import Error
|
||||
from litellm.integrations.otel.model.spans import (
|
||||
SPAN_REGISTRY,
|
||||
SpanRole,
|
||||
guardrail_span_name,
|
||||
llm_call_span_name,
|
||||
service_span_name,
|
||||
)
|
||||
|
||||
# Roles emit() knows how to name and emit. PROXY_REQUEST and the management
|
||||
# routes are SERVER spans owned by the mounted FastAPI instrumentor, so they
|
||||
# have no builder here.
|
||||
_NAME_BUILDERS: dict[SpanRole, Callable[..., str]] = {
|
||||
SpanRole.LLM_CALL: llm_call_span_name,
|
||||
SpanRole.GUARDRAIL: guardrail_span_name,
|
||||
# DB_CALL and SERVICE are both built from ServiceSpanData; they differ only in
|
||||
# span kind (CLIENT vs INTERNAL) and attribute vocabulary, not in naming.
|
||||
SpanRole.DB_CALL: service_span_name,
|
||||
SpanRole.SERVICE: service_span_name,
|
||||
}
|
||||
|
||||
# Cap on the dedup cache. It only needs to coalesce the sync+async firing window
|
||||
# of a single in-flight request, so a bounded LRU keeps memory flat on a
|
||||
# long-running proxy while still covering every concurrently-open call.
|
||||
_DEDUP_CACHE_MAX = 10_000
|
||||
|
||||
|
||||
class SpanEmitter:
|
||||
def __init__(
|
||||
self,
|
||||
tracer: Tracer,
|
||||
config: OpenTelemetryV2Config,
|
||||
mappers: Sequence[AttributeMapper] | None = None,
|
||||
) -> None:
|
||||
self._tracer = tracer
|
||||
self._config = config
|
||||
# The mapper chain is the sole source of span attributes. When not
|
||||
# passed in, resolve it from the config so there's one source of truth.
|
||||
self._mappers: list[AttributeMapper] = (
|
||||
list(mappers)
|
||||
if mappers is not None
|
||||
else resolve_mappers(config.mapper_names)
|
||||
)
|
||||
# Bounded LRU (ordered by insertion / most-recent touch). Storing keys
|
||||
# only — the value is unused — so it behaves like a capped set.
|
||||
self._emitted: "OrderedDict[tuple[str, SpanRole], None]" = OrderedDict()
|
||||
|
||||
# -- low-level helpers --------------------------------------------------- #
|
||||
|
||||
def start_span(
|
||||
self,
|
||||
role: SpanRole,
|
||||
name: str,
|
||||
parent_context: Context | None = None,
|
||||
start_time_ns: int | None = None,
|
||||
*,
|
||||
tracer: Tracer | None = None,
|
||||
) -> Span:
|
||||
"""Start a span for ``role`` without dedup or attribute mapping.
|
||||
|
||||
For callers that own and manage their own span lifecycle. ``tracer``
|
||||
overrides the bound tracer for this span only, used for per-request
|
||||
multi-tenant credential routing.
|
||||
"""
|
||||
return (tracer or self._tracer).start_span(
|
||||
name,
|
||||
context=parent_context,
|
||||
kind=to_otel_span_kind(SPAN_REGISTRY[role].kind),
|
||||
start_time=start_time_ns,
|
||||
)
|
||||
|
||||
def _seen(self, dedup_key: str | None, role: SpanRole) -> bool:
|
||||
"""Return True once a ``(dedup_key, role)`` pair has been emitted.
|
||||
|
||||
Guards against emitting the same span twice when a streaming call
|
||||
fires both a sync and an async logging callback.
|
||||
"""
|
||||
if not dedup_key:
|
||||
return False
|
||||
marker = (dedup_key, role)
|
||||
if marker in self._emitted:
|
||||
self._emitted.move_to_end(marker)
|
||||
return True
|
||||
self._emitted[marker] = None
|
||||
if len(self._emitted) > _DEDUP_CACHE_MAX:
|
||||
self._emitted.popitem(last=False) # evict least-recently-used
|
||||
return False
|
||||
|
||||
# -- the engine ---------------------------------------------------------- #
|
||||
|
||||
def emit(
|
||||
self,
|
||||
role: SpanRole,
|
||||
data: SpanData,
|
||||
parent_context: Context | None = None,
|
||||
*,
|
||||
start_time_ns: int | None = None,
|
||||
end_time_ns: int | None = None,
|
||||
tracer: Tracer | None = None,
|
||||
) -> Span | None:
|
||||
"""Emit one complete span: dedup, start, map attributes, status, end.
|
||||
|
||||
Return the span, or ``None`` if it was deduplicated away. ``tracer``
|
||||
overrides the bound tracer for this span, used for per-request routing.
|
||||
"""
|
||||
# Only LLM-call spans carry a dedup key; LLM-call and service spans
|
||||
# carry an ``error`` field. ``isinstance`` narrows the type for mypy and
|
||||
# keeps the engine free of duck-typed attribute reads.
|
||||
dedup_key = data.identity.call_id if isinstance(data, LLMCallSpanData) else None
|
||||
if self._seen(dedup_key, role):
|
||||
return None
|
||||
span = self.start_span(
|
||||
role,
|
||||
_NAME_BUILDERS[role](data),
|
||||
parent_context=parent_context,
|
||||
start_time_ns=start_time_ns,
|
||||
tracer=tracer,
|
||||
)
|
||||
self.finish_span(role, span, data, end_time_ns=end_time_ns)
|
||||
return span
|
||||
|
||||
def finish_span(
|
||||
self,
|
||||
role: SpanRole,
|
||||
span: Span,
|
||||
data: SpanData,
|
||||
*,
|
||||
end_time_ns: int | None = None,
|
||||
) -> None:
|
||||
"""Stamp attributes + status on an already-started ``span`` and end it.
|
||||
|
||||
The counterpart to :meth:`start_span` for callers that own a span's
|
||||
lifecycle — the LLM-call span is opened at the request's ``pre_call``
|
||||
boundary (so it parents to the live server span via real ambient context,
|
||||
never a span threaded through a metadata dict) and closed here once the
|
||||
typed payload is available. The span name is (re)built from the now-known
|
||||
data, since the boundary opener only has a provisional name.
|
||||
"""
|
||||
span.update_name(_NAME_BUILDERS[role](data))
|
||||
for mapper in self._mappers:
|
||||
for key, value in mapper.map(data).items():
|
||||
span.set_attribute(key, value)
|
||||
error = (
|
||||
data.error
|
||||
if isinstance(data, (LLMCallSpanData, ServiceSpanData, GuardrailSpanData))
|
||||
else None
|
||||
)
|
||||
if error and (error.error_type or error.message):
|
||||
span.set_attribute(Error.TYPE, error.error_type or "error")
|
||||
span.set_status(
|
||||
Status(StatusCode.ERROR, error.message or error.error_type or "error")
|
||||
)
|
||||
# On success leave the status UNSET (the semconv default) rather than
|
||||
# forcing OK — that matches the FastAPI server span and avoids implying a
|
||||
# span-level health signal litellm doesn't actually evaluate. Only a
|
||||
# genuine error sets a status.
|
||||
span.end(end_time=end_time_ns)
|
||||
495
litellm/integrations/otel/logger.py
Normal file
495
litellm/integrations/otel/logger.py
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
"""``CustomLogger`` adapter on the OpenTelemetry span engine."""
|
||||
|
||||
from collections import OrderedDict
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Iterator, Mapping, cast
|
||||
|
||||
from opentelemetry.context import attach, get_current
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.trace import Span, Tracer, get_current_span, use_span
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.otel.model.baggage import promoted_baggage
|
||||
from litellm.integrations.otel.model.config import OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.plumbing.context import (
|
||||
is_recordable_span,
|
||||
resolve_parent_context,
|
||||
resolve_request_span_context,
|
||||
set_request_baggage,
|
||||
set_request_root_span,
|
||||
)
|
||||
from litellm.integrations.otel.emitter import SpanEmitter
|
||||
from litellm.integrations.otel.mappers import resolve_mappers
|
||||
from litellm.integrations.otel.model.metadata import (
|
||||
LLMCallEvent,
|
||||
RequestIdentity,
|
||||
guardrail_entries_from_request_data,
|
||||
model_from_request_data,
|
||||
)
|
||||
from litellm.integrations.otel.model.payloads import (
|
||||
GuardrailSpanData,
|
||||
LLMCallSpanData,
|
||||
ServiceSpanData,
|
||||
SpanError,
|
||||
)
|
||||
from litellm.integrations.otel.plumbing.providers import (
|
||||
build_tracer_provider,
|
||||
get_tracer,
|
||||
)
|
||||
from litellm.integrations.otel.plumbing.routing import TenantTracerCache
|
||||
from litellm.integrations.otel.model.spans import SpanRole, span_role_for_service
|
||||
from litellm.integrations.otel.model.utils import to_ns
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.utils import StandardLoggingGuardrailInformation
|
||||
|
||||
LITELLM_TRACER_NAME = "litellm"
|
||||
|
||||
# Any callback whose class belongs to one of these modules is "the OTel
|
||||
# callback" for proxy-global-registration purposes.
|
||||
_OTEL_MODULES = (
|
||||
"litellm.integrations.otel",
|
||||
"litellm.integrations.opentelemetry",
|
||||
)
|
||||
|
||||
|
||||
# Cap on the open-call carrier map. A span opened at ``pre_call`` that never
|
||||
# reaches a success/failure callback (e.g. a stream that only fires stream
|
||||
# events) would otherwise linger; bounding the map evicts the oldest so memory
|
||||
# stays flat on a long-running proxy while covering every concurrent in-flight
|
||||
# call.
|
||||
_OPEN_CALLS_MAX = 10_000
|
||||
|
||||
|
||||
class _LLMCallSpan:
|
||||
"""The state carried from the ``pre_call`` boundary to span close.
|
||||
|
||||
``span`` is the live span when it could be opened at the boundary (the server
|
||||
span was ambient), or ``None`` when creation was deferred because no ambient
|
||||
parent was visible — in which case the async callback creates it against its
|
||||
own (worker-copied) ambient context using ``start_time_ns``. The presence of
|
||||
a carrier for a call at all is the proof that ``pre_call`` ran, i.e. that an
|
||||
upstream call was actually attempted.
|
||||
"""
|
||||
|
||||
__slots__ = ("span", "start_time_ns")
|
||||
|
||||
def __init__(self, span: "Span | None", start_time_ns: int | None) -> None:
|
||||
self.span = span
|
||||
self.start_time_ns = start_time_ns
|
||||
|
||||
|
||||
class OpenTelemetryV2(CustomLogger):
|
||||
"""The ``CustomLogger`` for OpenTelemetry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenTelemetryV2Config | None = None,
|
||||
callback_name: str | None = None,
|
||||
tracer_provider: TracerProvider | None = None,
|
||||
logger_provider: Any | None = None, # reserved for OTel logs
|
||||
meter_provider: Any | None = None, # reserved for metrics
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.config: OpenTelemetryV2Config = config or OpenTelemetryV2Config(**kwargs)
|
||||
self.callback_name = callback_name
|
||||
self._tracer_provider: TracerProvider = (
|
||||
tracer_provider
|
||||
if tracer_provider is not None
|
||||
else build_tracer_provider(self.config)
|
||||
)
|
||||
self.tracer: Tracer = get_tracer(self._tracer_provider, LITELLM_TRACER_NAME)
|
||||
self._emitter = SpanEmitter(
|
||||
self.tracer, self.config, mappers=resolve_mappers(self.config.mapper_names)
|
||||
)
|
||||
self._tenant_tracers = TenantTracerCache(
|
||||
self.config, callback_name, LITELLM_TRACER_NAME
|
||||
)
|
||||
self._open_llm_calls: "OrderedDict[str, _LLMCallSpan]" = OrderedDict()
|
||||
self._init_otel_logger_on_litellm_proxy()
|
||||
|
||||
# ====================================================================== #
|
||||
# Proxy global registration
|
||||
# ====================================================================== #
|
||||
|
||||
def _register_in_callback_list(self, callbacks: list) -> None:
|
||||
already_otel = any(
|
||||
cb.__class__.__module__.startswith(_OTEL_MODULES)
|
||||
for cb in callbacks
|
||||
if hasattr(cb, "__class__")
|
||||
)
|
||||
if not already_otel:
|
||||
callbacks.append(self)
|
||||
|
||||
def _init_otel_logger_on_litellm_proxy(self) -> None:
|
||||
try:
|
||||
from litellm.proxy import proxy_server
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
self._register_in_callback_list(litellm.service_callback)
|
||||
self._register_in_callback_list(litellm.input_callback)
|
||||
self._register_in_callback_list(litellm._async_success_callback)
|
||||
self._register_in_callback_list(litellm._async_failure_callback)
|
||||
except Exception:
|
||||
pass
|
||||
if getattr(proxy_server, "open_telemetry_logger", None) is None:
|
||||
setattr(proxy_server, "open_telemetry_logger", self)
|
||||
|
||||
# ====================================================================== #
|
||||
# LLM-call callbacks — the span is opened at the ``pre_call`` boundary and
|
||||
# closed here. See ``log_pre_api_call``.
|
||||
# ====================================================================== #
|
||||
|
||||
def log_pre_api_call(self, model, messages, kwargs):
|
||||
"""Open the LLM-call span at the call boundary.
|
||||
|
||||
Runs synchronously inside the request task, before the upstream call —
|
||||
the one place where the live server span is genuinely the ambient OTel
|
||||
context — so the span parents to it natively, with no span threaded
|
||||
through a metadata dict. The open span is stashed on the per-request
|
||||
``LiteLLMLoggingObj`` (a typed object) and closed in the async callback.
|
||||
|
||||
When no recordable parent is visible (``pre_call`` was driven from a thread
|
||||
pool for a sync-only provider, where contextvars — and so the anchor —
|
||||
don't follow), creation is deferred: only the start time is recorded, and
|
||||
the async callback — whose worker context was copied from the request task
|
||||
and so still carries the anchor — creates the span then.
|
||||
|
||||
Synthetic proxy-gate error logs (auth/rate-limit rejections) also fire this
|
||||
hook but never made an upstream call; they are tagged and skipped so no
|
||||
phantom LLM-call span is produced.
|
||||
"""
|
||||
call = LLMCallEvent.from_dict(kwargs)
|
||||
if call.is_no_upstream_call:
|
||||
return
|
||||
call_id = call.call_id
|
||||
if call_id is None:
|
||||
return
|
||||
# Idempotent: a retried call may re-enter ``pre_call`` with the same
|
||||
# call id; keep the first span so its start time is the true one.
|
||||
if call_id in self._open_llm_calls:
|
||||
return
|
||||
start_time_ns = to_ns(datetime.now())
|
||||
span: Span | None = None
|
||||
# Parent to the request's anchored root span (stable across the request),
|
||||
# falling back to ambient on the SDK path. Open the span live only when
|
||||
# that resolves to a recordable parent; otherwise defer to the close
|
||||
# callback (the thread-pool case, where the anchor isn't visible here).
|
||||
parent_context = resolve_request_span_context()
|
||||
if is_recordable_span(get_current_span(parent_context)):
|
||||
span = self._emitter.start_span(
|
||||
SpanRole.LLM_CALL,
|
||||
call.provisional_span_name,
|
||||
parent_context=parent_context,
|
||||
start_time_ns=start_time_ns,
|
||||
tracer=self._tenant_tracers.tracer_for(
|
||||
self.tracer, call.dynamic_params
|
||||
),
|
||||
)
|
||||
self._open_llm_calls[call_id] = _LLMCallSpan(
|
||||
span=span, start_time_ns=start_time_ns
|
||||
)
|
||||
# Evict the oldest open call if the map is over budget. A call that opens
|
||||
# but never closes (a stream that only fires stream events) would linger
|
||||
# otherwise; the evicted span is simply dropped (never exported).
|
||||
if len(self._open_llm_calls) > _OPEN_CALLS_MAX:
|
||||
self._open_llm_calls.popitem(last=False)
|
||||
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self._close_llm_call(kwargs, start_time, end_time)
|
||||
|
||||
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self._close_llm_call(kwargs, start_time, end_time)
|
||||
|
||||
def _close_llm_call(
|
||||
self,
|
||||
kwargs: Mapping[str, Any],
|
||||
start_time: datetime | float | None,
|
||||
end_time: datetime | float | None,
|
||||
) -> Span | None:
|
||||
"""Finish the LLM-call span opened at ``pre_call`` (or create it deferred).
|
||||
|
||||
No carrier for this call id means ``pre_call`` never ran — the request was
|
||||
rejected at the gate or blocked by a pre-call guardrail before any upstream
|
||||
call — so there is nothing to record and no phantom span.
|
||||
"""
|
||||
call = LLMCallEvent.from_dict(kwargs)
|
||||
call_id = call.call_id
|
||||
# ``pop`` is the dedup: this method runs from both the success and failure
|
||||
# paths, and whichever fires first removes the carrier and closes the span.
|
||||
carrier = self._open_llm_calls.pop(call_id, None) if call_id else None
|
||||
if carrier is None:
|
||||
return None
|
||||
payload = call.payload
|
||||
if payload is None:
|
||||
if carrier.span is not None:
|
||||
# Opened at the boundary but the payload never materialized — end
|
||||
# it (named provisionally) so it isn't leaked as an open span.
|
||||
carrier.span.end(end_time=to_ns(end_time))
|
||||
return None
|
||||
data = LLMCallSpanData.from_standard_logging_payload(
|
||||
payload, capture_content=self.config.capture_span_content
|
||||
)
|
||||
end_time_ns = to_ns(end_time)
|
||||
if carrier.span is not None:
|
||||
# Born at the boundary: stamp attributes from the typed payload, set
|
||||
# status, and end it. Its parent (the server span) was captured at
|
||||
# creation from real ambient context.
|
||||
self._emitter.finish_span(
|
||||
SpanRole.LLM_CALL, carrier.span, data, end_time_ns=end_time_ns
|
||||
)
|
||||
return carrier.span
|
||||
# Deferred: ``pre_call`` saw no recordable parent, so create the span now.
|
||||
# The worker copied the request task's context, which carries the anchored
|
||||
# root span — parent to it (ambient fallback on the SDK path). Seed identity
|
||||
# Baggage so the span — and the SDK path, which has none — is labeled
|
||||
# consistently.
|
||||
parent_ctx = resolve_request_span_context()
|
||||
bag = promoted_baggage(
|
||||
data.identity,
|
||||
data.request_model,
|
||||
promoted_keys=tuple(self.config.baggage_promoted_keys),
|
||||
metadata_keys=tuple(self.config.baggage_metadata_keys),
|
||||
)
|
||||
if bag:
|
||||
parent_ctx = set_request_baggage(bag, context=parent_ctx)
|
||||
return self._emitter.emit(
|
||||
SpanRole.LLM_CALL,
|
||||
data,
|
||||
parent_context=parent_ctx,
|
||||
start_time_ns=carrier.start_time_ns,
|
||||
end_time_ns=end_time_ns,
|
||||
tracer=self._tenant_tracers.tracer_for(self.tracer, call.dynamic_params),
|
||||
)
|
||||
|
||||
# ====================================================================== #
|
||||
# Service hooks
|
||||
# ====================================================================== #
|
||||
|
||||
async def async_service_success_hook(
|
||||
self,
|
||||
payload: Any,
|
||||
parent_otel_span: Span | None = None,
|
||||
start_time: datetime | float | None = None,
|
||||
end_time: datetime | float | None = None,
|
||||
event_metadata: dict | None = None,
|
||||
) -> None:
|
||||
self._emit_service(
|
||||
payload,
|
||||
parent_otel_span=parent_otel_span,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_metadata=event_metadata,
|
||||
error_override=None,
|
||||
)
|
||||
|
||||
async def async_service_failure_hook(
|
||||
self,
|
||||
payload: Any,
|
||||
error: str | None = "",
|
||||
parent_otel_span: Span | None = None,
|
||||
start_time: datetime | float | None = None,
|
||||
end_time: datetime | float | None = None,
|
||||
event_metadata: dict | None = None,
|
||||
) -> None:
|
||||
self._emit_service(
|
||||
payload,
|
||||
parent_otel_span=parent_otel_span,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_metadata=event_metadata,
|
||||
error_override=error or "error",
|
||||
)
|
||||
|
||||
def _emit_service(
|
||||
self,
|
||||
payload: Any,
|
||||
*,
|
||||
parent_otel_span: Span | None,
|
||||
start_time: datetime | float | None,
|
||||
end_time: datetime | float | None,
|
||||
event_metadata: dict | None,
|
||||
error_override: str | None,
|
||||
) -> Span | None:
|
||||
data = ServiceSpanData.from_payload(payload, event_metadata=event_metadata)
|
||||
# Decide whether this service call is a span at all, and of what kind.
|
||||
# ``None`` means metrics-only (framework instrumentation that duplicates a
|
||||
# gen-AI span — ``self``/``router``/``proxy_pre_call`` — or ``auth``, which
|
||||
# gets a live phase span instead). Those still feed Prometheus/Datadog via
|
||||
# their own hooks; they just never enter the trace.
|
||||
role = span_role_for_service(data.service_name)
|
||||
if role is None:
|
||||
return None
|
||||
# A metrics-only ping with neither timing nor a parent (in-memory queue
|
||||
# gauges) is not a traceable operation; a span for it would be a
|
||||
# zero-duration root with no context, so skip it. Real background work
|
||||
# (budget/reset jobs, spend flush) passes start/end times and still emits
|
||||
# as a root; anything with a parent emits regardless.
|
||||
if (
|
||||
error_override is None
|
||||
and start_time is None
|
||||
and end_time is None
|
||||
and parent_otel_span is None
|
||||
):
|
||||
return None
|
||||
if error_override is not None and data.error is None:
|
||||
data = ServiceSpanData(
|
||||
service_name=data.service_name,
|
||||
call_type=data.call_type,
|
||||
error=SpanError(message=error_override),
|
||||
event_metadata=data.event_metadata,
|
||||
)
|
||||
# Parent like every other span: ambient context first (so identity Baggage
|
||||
# rides along and the call nests under whatever request phase is active —
|
||||
# e.g. a DB lookup under the live ``auth`` span), falling back to the
|
||||
# server span the proxy threaded as ``parent_otel_span``. A background
|
||||
# service call has neither, so it starts its own root trace.
|
||||
parent_context = resolve_parent_context(threaded=parent_otel_span)
|
||||
return self._emitter.emit(
|
||||
role,
|
||||
data,
|
||||
parent_context=parent_context,
|
||||
start_time_ns=to_ns(start_time),
|
||||
end_time_ns=to_ns(end_time),
|
||||
)
|
||||
|
||||
# ====================================================================== #
|
||||
# async_post_call_* hooks — emit guardrail spans. The server span's status
|
||||
# / errors are the FastAPI instrumentor's job, so we don't touch it here.
|
||||
# ====================================================================== #
|
||||
|
||||
def seed_request_identity(self, user_api_key_dict: Any, model: Any = None) -> None:
|
||||
"""Attach request-identity Baggage to the current context + server span.
|
||||
|
||||
Seeding identity into Baggage makes **every** span emitted afterwards for
|
||||
this request — LLM call, guardrail, DB call — inherit it via
|
||||
``LiteLLMBaggageSpanProcessor``. Called once at the auth boundary (as soon
|
||||
as the key resolves) so post-auth spans are labeled consistently; the
|
||||
Baggage rides the request task's contextvar from there on. Auth-internal
|
||||
DB lookups that run before the key is known stay unlabeled — identity
|
||||
isn't determined yet, which is correct.
|
||||
"""
|
||||
try:
|
||||
identity = RequestIdentity.from_user_api_key_auth(user_api_key_dict)
|
||||
bag = promoted_baggage(
|
||||
identity,
|
||||
model,
|
||||
promoted_keys=tuple(self.config.baggage_promoted_keys),
|
||||
metadata_keys=tuple(self.config.baggage_metadata_keys),
|
||||
)
|
||||
if bag:
|
||||
# Attach (no detach): the contextvar is scoped to this request's
|
||||
# asyncio task and is reclaimed when the task ends.
|
||||
attach(set_request_baggage(bag, context=get_current()))
|
||||
# The server span was started by the instrumentor before this ran,
|
||||
# so the Baggage processor (which only fires at span start) won't
|
||||
# backfill it — stamp identity on it directly.
|
||||
server_span = get_current_span()
|
||||
if is_recordable_span(server_span):
|
||||
# Re-capture the anchor here too: this runs post-auth with the
|
||||
# server span active and covers entrypoints that bypass
|
||||
# ``create_litellm_proxy_request_started_span`` (e.g. the SDK
|
||||
# path's ``async_pre_call_hook``). Idempotent.
|
||||
set_request_root_span(server_span)
|
||||
for key, value in bag.items():
|
||||
server_span.set_attribute(key, value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@contextmanager
|
||||
def start_phase_span(self, name: str) -> "Iterator[Span]":
|
||||
span = self._emitter.start_span(SpanRole.SERVICE, name)
|
||||
with use_span(span, end_on_exit=True):
|
||||
yield span
|
||||
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: Any,
|
||||
cache: Any,
|
||||
data: dict,
|
||||
call_type: Any,
|
||||
) -> dict:
|
||||
self.seed_request_identity(
|
||||
user_api_key_dict,
|
||||
model=model_from_request_data(data),
|
||||
)
|
||||
return data
|
||||
|
||||
async def async_post_call_success_hook(
|
||||
self,
|
||||
data: Mapping[str, Any],
|
||||
user_api_key_dict: Any,
|
||||
response: Any,
|
||||
) -> Any:
|
||||
self._emit_guardrail_spans(data)
|
||||
return response
|
||||
|
||||
async def async_post_call_failure_hook(
|
||||
self,
|
||||
request_data: Mapping[str, Any],
|
||||
original_exception: BaseException | None,
|
||||
user_api_key_dict: Any,
|
||||
traceback_str: str | None = None,
|
||||
) -> None:
|
||||
self._emit_guardrail_spans(request_data)
|
||||
|
||||
def _emit_guardrail_spans(self, request_data: Mapping[str, Any]) -> None:
|
||||
# A guardrail is a sibling of the LLM call under the request's root span,
|
||||
# so parent it to the explicit anchor — not the active span, which on the
|
||||
# failure path can be the live ``auth`` phase span (post-call failure hooks
|
||||
# run from inside it on an auth rejection). Emit with the guardrail's actual
|
||||
# execution window so a pre_call guardrail is placed before the LLM call
|
||||
# rather than at post-call emission time.
|
||||
guardrails = guardrail_entries_from_request_data(request_data)
|
||||
if not guardrails:
|
||||
return
|
||||
parent_ctx = resolve_request_span_context()
|
||||
for entry in guardrails:
|
||||
data = GuardrailSpanData.from_logging_entry(
|
||||
cast("StandardLoggingGuardrailInformation", entry)
|
||||
)
|
||||
self._emitter.emit(
|
||||
SpanRole.GUARDRAIL,
|
||||
data,
|
||||
parent_context=parent_ctx,
|
||||
start_time_ns=to_ns(data.start_time),
|
||||
end_time_ns=to_ns(data.end_time),
|
||||
)
|
||||
|
||||
def create_litellm_proxy_request_started_span(
|
||||
self, start_time: datetime, headers: Mapping[str, str] | None
|
||||
) -> Span | None:
|
||||
span = get_current_span()
|
||||
if not is_recordable_span(span):
|
||||
return None
|
||||
set_request_root_span(span)
|
||||
return span
|
||||
|
||||
|
||||
def _registered_v2_logger() -> "OpenTelemetryV2 | None":
|
||||
try:
|
||||
from litellm.proxy import proxy_server
|
||||
except Exception:
|
||||
return None
|
||||
logger = getattr(proxy_server, "open_telemetry_logger", None)
|
||||
return logger if isinstance(logger, OpenTelemetryV2) else None
|
||||
|
||||
|
||||
def seed_request_identity(user_api_key_dict: Any, model: Any = None) -> None:
|
||||
logger = _registered_v2_logger()
|
||||
if logger is not None:
|
||||
logger.seed_request_identity(user_api_key_dict, model=model)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def phase_span(name: str) -> "Iterator[Span | None]":
|
||||
logger = _registered_v2_logger()
|
||||
if logger is None:
|
||||
yield None
|
||||
return
|
||||
with logger.start_phase_span(name) as span:
|
||||
yield span
|
||||
58
litellm/integrations/otel/mappers/__init__.py
Normal file
58
litellm/integrations/otel/mappers/__init__.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Attribute mappers: pure ``LLMCallSpanData -> {attribute key: value}`` functions.
|
||||
|
||||
Composition over inheritance: vocabularies layer onto the same span. Listing
|
||||
``["genai", "openinference"]`` in ``config.mapper_names`` makes every span
|
||||
carry both the canonical ``gen_ai.*`` keys and the OpenInference (Arize +
|
||||
Phoenix) keys. Add ``"langfuse"`` and it works for all three backends at once.
|
||||
"""
|
||||
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from litellm.integrations.otel.mappers.base import (
|
||||
AttributeMap,
|
||||
AttributeMapper,
|
||||
AttrValue,
|
||||
)
|
||||
from litellm.integrations.otel.mappers.genai import GenAIMapper
|
||||
from litellm.integrations.otel.mappers.langfuse import LangfuseMapper
|
||||
from litellm.integrations.otel.mappers.langtrace import LangtraceMapper
|
||||
from litellm.integrations.otel.mappers.legacy import LegacyMapper
|
||||
from litellm.integrations.otel.mappers.openinference import OpenInferenceMapper
|
||||
from litellm.integrations.otel.mappers.weave import WeaveMapper
|
||||
|
||||
# Registry keyed by ``config.mapper_names`` entries.
|
||||
_MAPPER_BY_NAME: dict[str, Callable[[], AttributeMapper]] = {
|
||||
"genai": GenAIMapper,
|
||||
"legacy": LegacyMapper,
|
||||
"openinference": OpenInferenceMapper,
|
||||
"langfuse": LangfuseMapper,
|
||||
"weave": WeaveMapper,
|
||||
"langtrace": LangtraceMapper,
|
||||
}
|
||||
|
||||
|
||||
def resolve_mappers(names: Iterable[str]) -> list[AttributeMapper]:
|
||||
"""Resolve mapper names to instances. Unknown names raise ``ValueError``."""
|
||||
out: list[AttributeMapper] = []
|
||||
for name in names:
|
||||
factory = _MAPPER_BY_NAME.get(name)
|
||||
if factory is None:
|
||||
raise ValueError(
|
||||
f"unknown mapper name {name!r}; known: " f"{sorted(_MAPPER_BY_NAME)}"
|
||||
)
|
||||
out.append(factory())
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AttributeMap",
|
||||
"AttributeMapper",
|
||||
"AttrValue",
|
||||
"GenAIMapper",
|
||||
"LangfuseMapper",
|
||||
"LangtraceMapper",
|
||||
"LegacyMapper",
|
||||
"OpenInferenceMapper",
|
||||
"WeaveMapper",
|
||||
"resolve_mappers",
|
||||
]
|
||||
36
litellm/integrations/otel/mappers/base.py
Normal file
36
litellm/integrations/otel/mappers/base.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""Mapper protocol and attribute value types."""
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from typing_extensions import Protocol, runtime_checkable
|
||||
|
||||
from litellm.integrations.otel.model.payloads import (
|
||||
GuardrailSpanData,
|
||||
LLMCallSpanData,
|
||||
ServiceSpanData,
|
||||
)
|
||||
|
||||
AttrScalar = str | bool | int | float
|
||||
# Mirrors ``opentelemetry.util.types.AttributeValue`` (homogeneous sequences)
|
||||
# without importing the SDK, so mappers stay OTel-free.
|
||||
AttrValue = (
|
||||
AttrScalar | Sequence[str] | Sequence[bool] | Sequence[int] | Sequence[float]
|
||||
)
|
||||
AttributeMap = dict[str, AttrValue]
|
||||
|
||||
# The closed set of span-data types the engine routes through the mapper chain.
|
||||
# Server spans (PROXY_REQUEST + management routes) belong to the mounted FastAPI
|
||||
# instrumentor, not the mapper chain.
|
||||
SpanData = LLMCallSpanData | GuardrailSpanData | ServiceSpanData
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AttributeMapper(Protocol):
|
||||
"""Maps a typed span input to a flat dict of OTel span attributes.
|
||||
|
||||
One method per mapper, dispatched internally on the ``data`` type. The
|
||||
engine calls this uniformly for every span kind — mappers that don't speak
|
||||
a given type return ``{}``. This is why the engine contains no attribute keys.
|
||||
"""
|
||||
|
||||
def map(self, data: SpanData) -> AttributeMap: ...
|
||||
137
litellm/integrations/otel/mappers/genai.py
Normal file
137
litellm/integrations/otel/mappers/genai.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""Canonical OpenTelemetry GenAI semantic-convention mapper (always active).
|
||||
|
||||
Owns the attribute schema for every span kind the engine emits — LLM call,
|
||||
guardrail, and service — so the engine itself never references attribute keys.
|
||||
|
||||
Each span kind declares its schema as a flat ``attribute key -> extractor``
|
||||
table: one lambda per mapping operation, applied against the typed span data.
|
||||
"""
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
|
||||
from litellm.integrations.otel.mappers.utils import collect, drop_none
|
||||
from litellm.integrations.otel.model.payloads import (
|
||||
GuardrailSpanData,
|
||||
LLMCallSpanData,
|
||||
ServiceSpanData,
|
||||
ToolDefinition,
|
||||
)
|
||||
from litellm.integrations.otel.model.semconv import DB, Error, GenAI, LiteLLM, Server
|
||||
from litellm.integrations.otel.model.spans import db_system
|
||||
|
||||
|
||||
class GenAIMapper:
|
||||
|
||||
_LLM_CALL_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = {
|
||||
GenAI.OPERATION_NAME: lambda d: d.operation.value,
|
||||
GenAI.PROVIDER_NAME: lambda d: d.provider or None,
|
||||
GenAI.REQUEST_MODEL: lambda d: d.request_model or None,
|
||||
GenAI.REQUEST_TEMPERATURE: lambda d: d.request_params.temperature,
|
||||
GenAI.REQUEST_TOP_P: lambda d: d.request_params.top_p,
|
||||
GenAI.REQUEST_TOP_K: lambda d: d.request_params.top_k,
|
||||
GenAI.REQUEST_MAX_TOKENS: lambda d: d.request_params.max_tokens,
|
||||
GenAI.REQUEST_FREQUENCY_PENALTY: lambda d: d.request_params.frequency_penalty,
|
||||
GenAI.REQUEST_PRESENCE_PENALTY: lambda d: d.request_params.presence_penalty,
|
||||
GenAI.REQUEST_STOP_SEQUENCES: lambda d: (
|
||||
list(d.request_params.stop_sequences)
|
||||
if d.request_params.stop_sequences
|
||||
else None
|
||||
),
|
||||
GenAI.REQUEST_SEED: lambda d: d.request_params.seed,
|
||||
GenAI.RESPONSE_MODEL: lambda d: d.response_model,
|
||||
GenAI.RESPONSE_ID: lambda d: d.response_id,
|
||||
GenAI.RESPONSE_FINISH_REASONS: lambda d: (
|
||||
list(d.finish_reasons) if d.finish_reasons else None
|
||||
),
|
||||
GenAI.USAGE_INPUT_TOKENS: lambda d: d.usage.input_tokens,
|
||||
GenAI.USAGE_OUTPUT_TOKENS: lambda d: d.usage.output_tokens,
|
||||
Error.TYPE: lambda d: d.error.error_type if d.error else None,
|
||||
Server.ADDRESS: lambda d: d.server.address if d.server else None,
|
||||
Server.PORT: lambda d: d.server.port if d.server else None,
|
||||
LiteLLM.CALL_ID: lambda d: d.identity.call_id or None,
|
||||
# The provider/underlying model is only known once routing has picked a
|
||||
# deployment, so it can't ride identity Baggage (seeded at auth, before
|
||||
# routing) onto the boundary-born LLM span — stamp it directly here.
|
||||
LiteLLM.PROVIDER_MODEL: lambda d: d.identity.provider_model or None,
|
||||
f"{LiteLLM.COST_PREFIX}total": lambda d: d.response_cost,
|
||||
LiteLLM.REQUEST_STREAMING: lambda d: d.is_streaming,
|
||||
}
|
||||
|
||||
_TOOL_ATTRS: dict[str, Callable[[ToolDefinition], AttrValue | None]] = {
|
||||
"name": lambda t: t.name,
|
||||
"description": lambda t: t.description or None,
|
||||
"parameters": lambda t: t.parameters_json or None,
|
||||
}
|
||||
|
||||
_GUARDRAIL_ATTRS: dict[str, Callable[[GuardrailSpanData], AttrValue | None]] = {
|
||||
LiteLLM.GUARDRAIL_NAME: lambda d: d.guardrail_name,
|
||||
LiteLLM.GUARDRAIL_MODE: lambda d: d.mode,
|
||||
LiteLLM.GUARDRAIL_STATUS: lambda d: d.status,
|
||||
LiteLLM.GUARDRAIL_PROVIDER: lambda d: d.provider,
|
||||
LiteLLM.GUARDRAIL_ACTION: lambda d: d.action,
|
||||
LiteLLM.GUARDRAIL_RESPONSE: lambda d: d.response_json,
|
||||
LiteLLM.GUARDRAIL_VIOLATION_CATEGORIES: lambda d: (
|
||||
list(d.violation_categories) if d.violation_categories else None
|
||||
),
|
||||
LiteLLM.GUARDRAIL_CONFIDENCE_SCORE: lambda d: d.confidence_score,
|
||||
LiteLLM.GUARDRAIL_RISK_SCORE: lambda d: d.risk_score,
|
||||
LiteLLM.GUARDRAIL_MASKED_ENTITY_COUNT: lambda d: d.masked_entity_count,
|
||||
LiteLLM.GUARDRAIL_DURATION: lambda d: d.duration,
|
||||
LiteLLM.GUARDRAIL_ID: lambda d: d.guardrail_id,
|
||||
LiteLLM.GUARDRAIL_POLICY_TEMPLATE: lambda d: d.policy_template,
|
||||
LiteLLM.GUARDRAIL_DETECTION_METHOD: lambda d: d.detection_method,
|
||||
}
|
||||
|
||||
_SERVICE_ATTRS: dict[str, Callable[[ServiceSpanData], AttrValue | None]] = {
|
||||
LiteLLM.SERVICE_NAME: lambda d: d.service_name,
|
||||
LiteLLM.SERVICE_CALL_TYPE: lambda d: d.call_type,
|
||||
}
|
||||
|
||||
def map(self, data: SpanData) -> AttributeMap:
|
||||
match data:
|
||||
case LLMCallSpanData():
|
||||
return self._llm_call(data)
|
||||
case GuardrailSpanData():
|
||||
return self._guardrail(data)
|
||||
case ServiceSpanData():
|
||||
return self._service(data)
|
||||
case _:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _llm_call(cls, data: LLMCallSpanData) -> AttributeMap:
|
||||
attrs = collect(cls._LLM_CALL_ATTRS, data)
|
||||
attrs.update(
|
||||
drop_none(
|
||||
{
|
||||
f"gen_ai.tool.{idx}.{suffix}": extract(tool)
|
||||
for idx, tool in enumerate(data.tools)
|
||||
for suffix, extract in cls._TOOL_ATTRS.items()
|
||||
}
|
||||
)
|
||||
)
|
||||
return attrs
|
||||
|
||||
@classmethod
|
||||
def _guardrail(cls, data: GuardrailSpanData) -> AttributeMap:
|
||||
return collect(cls._GUARDRAIL_ATTRS, data)
|
||||
|
||||
@classmethod
|
||||
def _service(cls, data: ServiceSpanData) -> AttributeMap:
|
||||
attrs = collect(cls._SERVICE_ATTRS, data)
|
||||
# An outbound datastore call (DB_CALL / CLIENT span) also carries db.*
|
||||
# semconv. Internal services (router, budget jobs, …) have no db.system,
|
||||
# so they get only the litellm.service.* keys above.
|
||||
system = db_system(data.service_name)
|
||||
if system is not None:
|
||||
attrs[DB.SYSTEM_NAME] = system
|
||||
if data.call_type:
|
||||
attrs[DB.OPERATION_NAME] = data.call_type
|
||||
attrs.update(
|
||||
{
|
||||
f"{LiteLLM.METADATA_PREFIX}{key}": value
|
||||
for key, value in data.event_metadata.items()
|
||||
}
|
||||
)
|
||||
return attrs
|
||||
84
litellm/integrations/otel/mappers/langfuse.py
Normal file
84
litellm/integrations/otel/mappers/langfuse.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Langfuse OTLP attribute mapper.
|
||||
|
||||
Langfuse ingests OTLP spans and reads from its own vendor namespace
|
||||
(``langfuse.observation.*``, ``langfuse.trace.*``). Compose this mapper after
|
||||
``GenAIMapper`` to send canonical + Langfuse-flavored spans simultaneously.
|
||||
|
||||
Every attribute is declared as a ``key -> extractor`` table entry (one callable
|
||||
per mapping operation): ``_LLM_CALL_ATTRS`` for scalars and ``_BLOB_ATTRS`` for
|
||||
the JSON-serialized payloads. ``_llm_call`` just applies both tables.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Callable
|
||||
|
||||
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
|
||||
from litellm.integrations.otel.mappers.utils import (
|
||||
collect,
|
||||
json_if,
|
||||
output_messages,
|
||||
serialize_messages,
|
||||
)
|
||||
from litellm.integrations.otel.model.payloads import (
|
||||
LLMCallSpanData,
|
||||
LLMRequestParams,
|
||||
LLMUsage,
|
||||
)
|
||||
|
||||
|
||||
class LangfuseMapper:
|
||||
|
||||
_LLM_CALL_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = {
|
||||
"langfuse.observation.type": lambda d: "generation",
|
||||
"langfuse.observation.model.name": lambda d: d.request_model or None,
|
||||
"langfuse.observation.metadata.provider": lambda d: d.provider or None,
|
||||
"langfuse.observation.id": lambda d: d.identity.call_id or None,
|
||||
"langfuse.trace.metadata.team_id": lambda d: d.identity.team_id or None,
|
||||
"langfuse.trace.metadata.team_alias": lambda d: d.identity.team_alias or None,
|
||||
}
|
||||
|
||||
# Sub-tables folded into their respective JSON blobs.
|
||||
_MODEL_PARAMS: dict[str, Callable[[LLMRequestParams], AttrValue | None]] = {
|
||||
"temperature": lambda rp: rp.temperature,
|
||||
"top_p": lambda rp: rp.top_p,
|
||||
"max_tokens": lambda rp: rp.max_tokens,
|
||||
"frequency_penalty": lambda rp: rp.frequency_penalty,
|
||||
"presence_penalty": lambda rp: rp.presence_penalty,
|
||||
"seed": lambda rp: rp.seed,
|
||||
}
|
||||
_USAGE_FIELDS: dict[str, Callable[[LLMUsage], AttrValue | None]] = {
|
||||
"input": lambda u: u.input_tokens,
|
||||
"output": lambda u: u.output_tokens,
|
||||
"total": lambda u: u.total_tokens,
|
||||
}
|
||||
|
||||
# JSON-payload attributes: each builder returns the serialized blob or None.
|
||||
_BLOB_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = {
|
||||
"langfuse.observation.model.parameters": lambda d: json_if(
|
||||
collect(LangfuseMapper._MODEL_PARAMS, d.request_params)
|
||||
),
|
||||
"langfuse.observation.input": lambda d: serialize_messages(d.messages_in),
|
||||
"langfuse.observation.output": lambda d: serialize_messages(output_messages(d)),
|
||||
"langfuse.observation.usage_details": lambda d: json_if(
|
||||
collect(LangfuseMapper._USAGE_FIELDS, d.usage)
|
||||
),
|
||||
"langfuse.observation.cost_details": lambda d: (
|
||||
json.dumps({"total": d.response_cost})
|
||||
if d.response_cost is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def map(self, data: SpanData) -> AttributeMap:
|
||||
match data:
|
||||
case LLMCallSpanData():
|
||||
return self._llm_call(data)
|
||||
case _:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _llm_call(cls, data: LLMCallSpanData) -> AttributeMap:
|
||||
return {
|
||||
**collect(cls._LLM_CALL_ATTRS, data),
|
||||
**collect(cls._BLOB_ATTRS, data),
|
||||
}
|
||||
64
litellm/integrations/otel/mappers/langtrace.py
Normal file
64
litellm/integrations/otel/mappers/langtrace.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Langtrace attribute mapper.
|
||||
|
||||
Produces Langtrace's attribute vocabulary so a span can be ingested by a
|
||||
Langtrace backend. Compose it alongside other mappers like any other
|
||||
vocabulary.
|
||||
|
||||
Scalar attributes are declared as a flat ``key -> extractor`` table (one lambda
|
||||
per mapping operation); the prompt/completion blobs are serialized as a tail.
|
||||
"""
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
|
||||
from litellm.integrations.otel.mappers.utils import (
|
||||
collect,
|
||||
json_or_none,
|
||||
output_messages,
|
||||
)
|
||||
from litellm.integrations.otel.model.payloads import LLMCallSpanData
|
||||
|
||||
|
||||
class LangtraceMapper:
|
||||
|
||||
_LLM_CALL_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = {
|
||||
"gen_ai.operation.name": lambda d: "chat",
|
||||
"langtrace.service.name": lambda d: d.provider or None,
|
||||
"llm.model": lambda d: d.request_model or None,
|
||||
"gen_ai.response.model": lambda d: d.response_model or None,
|
||||
"gen_ai.response_id": lambda d: d.response_id or None,
|
||||
"gen_ai.system_fingerprint": lambda d: d.system_fingerprint or None,
|
||||
"llm.temperature": lambda d: d.request_params.temperature,
|
||||
"llm.top_p": lambda d: d.request_params.top_p,
|
||||
"llm.top_k": lambda d: d.request_params.top_k,
|
||||
"llm.max_tokens": lambda d: d.request_params.max_tokens,
|
||||
"llm.frequency_penalty": lambda d: d.request_params.frequency_penalty,
|
||||
"llm.presence_penalty": lambda d: d.request_params.presence_penalty,
|
||||
"llm.stream": lambda d: d.is_streaming,
|
||||
"llm.token.counts.prompt": lambda d: d.usage.input_tokens,
|
||||
"llm.token.counts.completion": lambda d: d.usage.output_tokens,
|
||||
"llm.token.counts.total": lambda d: d.usage.total_tokens,
|
||||
}
|
||||
|
||||
_BLOB_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = {
|
||||
"llm.prompts": lambda d: (
|
||||
json_or_none(list(d.messages_in)) if d.messages_in else None
|
||||
),
|
||||
"llm.completions": lambda d: (
|
||||
json_or_none(output_messages(d)) if d.choices_out else None
|
||||
),
|
||||
}
|
||||
|
||||
def map(self, data: SpanData) -> AttributeMap:
|
||||
match data:
|
||||
case LLMCallSpanData():
|
||||
return self._llm_call(data)
|
||||
case _:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _llm_call(cls, data: LLMCallSpanData) -> AttributeMap:
|
||||
return {
|
||||
**collect(cls._LLM_CALL_ATTRS, data),
|
||||
**collect(cls._BLOB_ATTRS, data),
|
||||
}
|
||||
97
litellm/integrations/otel/mappers/legacy.py
Normal file
97
litellm/integrations/otel/mappers/legacy.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Mapper for the older semantic-convention attribute vocabulary.
|
||||
|
||||
Emits attributes under the semconv-ai / Traceloop key names (e.g.
|
||||
``gen_ai.system``, ``gen_ai.usage.prompt_tokens``, ``llm.is_streaming``) plus a
|
||||
few bare, unprefixed service keys (``service``, ``call_type``, ``error``), for
|
||||
backends that consume those names.
|
||||
|
||||
Like ``GenAIMapper``, each span kind declares its schema as a flat
|
||||
``attribute key -> extractor`` table: one lambda per mapping operation.
|
||||
"""
|
||||
|
||||
from typing import Callable, Final
|
||||
|
||||
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
|
||||
from litellm.integrations.otel.mappers.utils import collect, drop_none
|
||||
from litellm.integrations.otel.model.payloads import (
|
||||
LLMCallSpanData,
|
||||
ServiceSpanData,
|
||||
ToolDefinition,
|
||||
)
|
||||
|
||||
# Attribute keys in the semconv-ai / Traceloop vocabulary.
|
||||
_LEGACY_SYSTEM: Final = "gen_ai.system"
|
||||
_LEGACY_PROMPT_TOKENS: Final = "gen_ai.usage.prompt_tokens"
|
||||
_LEGACY_COMPLETION_TOKENS: Final = "gen_ai.usage.completion_tokens"
|
||||
_LEGACY_TOTAL_TOKENS: Final = "gen_ai.usage.total_tokens"
|
||||
_LEGACY_IS_STREAMING: Final = "llm.is_streaming"
|
||||
_LEGACY_TOP_K: Final = "llm.top_k"
|
||||
_LEGACY_FREQUENCY_PENALTY: Final = "llm.frequency_penalty"
|
||||
_LEGACY_PRESENCE_PENALTY: Final = "llm.presence_penalty"
|
||||
_LEGACY_STOP_SEQUENCES: Final = "llm.chat.stop_sequences"
|
||||
_LEGACY_SERVICE: Final = "service"
|
||||
_LEGACY_CALL_TYPE: Final = "call_type"
|
||||
_LEGACY_ERROR: Final = "error"
|
||||
|
||||
|
||||
class LegacyMapper:
|
||||
"""Emits LLM-call and service attributes under the older key names."""
|
||||
|
||||
_LLM_CALL_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = {
|
||||
_LEGACY_SYSTEM: lambda d: d.provider or None,
|
||||
_LEGACY_PROMPT_TOKENS: lambda d: d.usage.input_tokens,
|
||||
_LEGACY_COMPLETION_TOKENS: lambda d: d.usage.output_tokens,
|
||||
_LEGACY_TOTAL_TOKENS: lambda d: d.usage.total_tokens,
|
||||
_LEGACY_IS_STREAMING: lambda d: d.is_streaming,
|
||||
_LEGACY_TOP_K: lambda d: d.request_params.top_k,
|
||||
_LEGACY_FREQUENCY_PENALTY: lambda d: d.request_params.frequency_penalty,
|
||||
_LEGACY_PRESENCE_PENALTY: lambda d: d.request_params.presence_penalty,
|
||||
_LEGACY_STOP_SEQUENCES: lambda d: (
|
||||
list(d.request_params.stop_sequences)
|
||||
if d.request_params.stop_sequences
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
_TOOL_ATTRS: dict[str, Callable[[ToolDefinition], AttrValue | None]] = {
|
||||
"name": lambda t: t.name,
|
||||
"description": lambda t: t.description or None,
|
||||
"parameters": lambda t: t.parameters_json or None,
|
||||
}
|
||||
|
||||
_SERVICE_ATTRS: dict[str, Callable[[ServiceSpanData], AttrValue | None]] = {
|
||||
_LEGACY_SERVICE: lambda d: d.service_name,
|
||||
_LEGACY_CALL_TYPE: lambda d: d.call_type,
|
||||
_LEGACY_ERROR: lambda d: (
|
||||
d.error.message if d.error is not None and d.error.message else None
|
||||
),
|
||||
}
|
||||
|
||||
def map(self, data: SpanData) -> AttributeMap:
|
||||
match data:
|
||||
case LLMCallSpanData():
|
||||
return self._llm_call(data)
|
||||
case ServiceSpanData():
|
||||
return self._service(data)
|
||||
case _:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _llm_call(cls, data: LLMCallSpanData) -> AttributeMap:
|
||||
attrs = collect(cls._LLM_CALL_ATTRS, data)
|
||||
attrs.update(
|
||||
drop_none(
|
||||
{
|
||||
f"llm.request.functions.{idx}.{suffix}": extract(tool)
|
||||
for idx, tool in enumerate(data.tools)
|
||||
for suffix, extract in cls._TOOL_ATTRS.items()
|
||||
}
|
||||
)
|
||||
)
|
||||
return attrs
|
||||
|
||||
@classmethod
|
||||
def _service(cls, data: ServiceSpanData) -> AttributeMap:
|
||||
attrs = collect(cls._SERVICE_ATTRS, data)
|
||||
attrs.update(dict(data.event_metadata))
|
||||
return attrs
|
||||
128
litellm/integrations/otel/mappers/openinference.py
Normal file
128
litellm/integrations/otel/mappers/openinference.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""OpenInference attribute mapper (Arize + Arize-Phoenix shared vocabulary).
|
||||
|
||||
Spec: https://github.com/Arize-ai/openinference/tree/main/spec — the standard
|
||||
both Arize and Phoenix consume. Composing this mapper after ``GenAIMapper``
|
||||
gives the same span both vocabularies, so a single trace lights up Arize +
|
||||
Phoenix + any other OpenInference-aware backend simultaneously.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Callable, Sequence
|
||||
|
||||
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
|
||||
from litellm.integrations.otel.mappers.utils import (
|
||||
collect,
|
||||
drop_none,
|
||||
json_if,
|
||||
message_content,
|
||||
output_messages,
|
||||
)
|
||||
from litellm.integrations.otel.model.payloads import (
|
||||
LLMCallSpanData,
|
||||
LLMRequestParams,
|
||||
ToolDefinition,
|
||||
)
|
||||
|
||||
|
||||
class OpenInferenceMapper:
|
||||
"""Emits OpenInference attributes for LLM_CALL spans.
|
||||
|
||||
Key families (per the OpenInference spec):
|
||||
- ``openinference.span.kind`` — discriminator (``"LLM"`` here)
|
||||
- ``llm.model_name`` / ``llm.provider`` / ``llm.invocation_parameters``
|
||||
- ``llm.input_messages.{i}.message.role`` / ``...content``
|
||||
- ``llm.output_messages.{i}.message.role`` / ``...content``
|
||||
- ``llm.token_count.prompt`` / ``...completion`` / ``...total``
|
||||
- ``input.value`` / ``output.value`` — JSON-serialized request / response
|
||||
"""
|
||||
|
||||
_LLM_CALL_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = {
|
||||
"openinference.span.kind": lambda d: "LLM",
|
||||
"llm.model_name": lambda d: d.request_model or None,
|
||||
"llm.provider": lambda d: d.provider or None,
|
||||
"llm.token_count.prompt": lambda d: d.usage.input_tokens,
|
||||
"llm.token_count.completion": lambda d: d.usage.output_tokens,
|
||||
"llm.token_count.total": lambda d: d.usage.total_tokens,
|
||||
}
|
||||
|
||||
# Folded into the ``llm.invocation_parameters`` JSON blob.
|
||||
_INVOCATION_PARAMS: dict[str, Callable[[LLMRequestParams], AttrValue | None]] = {
|
||||
"temperature": lambda rp: rp.temperature,
|
||||
"top_p": lambda rp: rp.top_p,
|
||||
"top_k": lambda rp: rp.top_k,
|
||||
"max_tokens": lambda rp: rp.max_tokens,
|
||||
"frequency_penalty": lambda rp: rp.frequency_penalty,
|
||||
"presence_penalty": lambda rp: rp.presence_penalty,
|
||||
"seed": lambda rp: rp.seed,
|
||||
}
|
||||
|
||||
# Per-tool extractors, keyed by the ``llm.tools.{idx}.*`` suffix.
|
||||
_TOOL_ATTRS: dict[str, Callable[[ToolDefinition], AttrValue | None]] = {
|
||||
"tool.name": lambda t: t.name,
|
||||
"tool.description": lambda t: t.description or None,
|
||||
"tool.json_schema": lambda t: t.parameters_json or None,
|
||||
}
|
||||
|
||||
# JSON-payload attributes: each builder returns the serialized blob or None.
|
||||
_BLOB_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = {
|
||||
"llm.invocation_parameters": lambda d: json_if(
|
||||
collect(OpenInferenceMapper._INVOCATION_PARAMS, d.request_params)
|
||||
),
|
||||
}
|
||||
|
||||
def map(self, data: SpanData) -> AttributeMap:
|
||||
match data:
|
||||
case LLMCallSpanData():
|
||||
return self._llm_call(data)
|
||||
case _:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _llm_call(cls, data: LLMCallSpanData) -> AttributeMap:
|
||||
return {
|
||||
**collect(cls._LLM_CALL_ATTRS, data),
|
||||
**collect(cls._BLOB_ATTRS, data),
|
||||
**cls._messages("llm.input_messages", "input.value", data.messages_in),
|
||||
**cls._messages(
|
||||
"llm.output_messages", "output.value", output_messages(data)
|
||||
),
|
||||
**cls._tools(data),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _messages(
|
||||
prefix: str, value_key: str, messages: Sequence[object]
|
||||
) -> AttributeMap:
|
||||
"""Per-message ``{prefix}.{idx}.message.*`` keys + the ``value_key`` blob."""
|
||||
parsed = [
|
||||
(m.get("role") if isinstance(m, dict) else None, message_content(m))
|
||||
for m in messages
|
||||
]
|
||||
attrs = drop_none(
|
||||
{
|
||||
key: value
|
||||
for idx, (role, content) in enumerate(parsed)
|
||||
for key, value in (
|
||||
(
|
||||
f"{prefix}.{idx}.message.role",
|
||||
role if isinstance(role, str) else None,
|
||||
),
|
||||
(f"{prefix}.{idx}.message.content", content),
|
||||
)
|
||||
}
|
||||
)
|
||||
if parsed:
|
||||
attrs[value_key] = json.dumps(
|
||||
[{"role": role, "content": content} for role, content in parsed]
|
||||
)
|
||||
return attrs
|
||||
|
||||
@classmethod
|
||||
def _tools(cls, data: LLMCallSpanData) -> AttributeMap:
|
||||
return drop_none(
|
||||
{
|
||||
f"llm.tools.{idx}.{suffix}": extract(tool)
|
||||
for idx, tool in enumerate(data.tools)
|
||||
for suffix, extract in cls._TOOL_ATTRS.items()
|
||||
}
|
||||
)
|
||||
76
litellm/integrations/otel/mappers/utils.py
Normal file
76
litellm/integrations/otel/mappers/utils.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Shared helpers for the attribute mappers.
|
||||
|
||||
Small, mapper-agnostic utilities — JSON serialization, message extraction, and
|
||||
extractor-table application — pulled out of the individual mapper modules so
|
||||
they live in one place.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Callable, Mapping, Sequence
|
||||
|
||||
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue
|
||||
from litellm.integrations.otel.model.payloads import LLMCallSpanData
|
||||
|
||||
|
||||
def drop_none(values: Mapping[str, AttrValue | None]) -> AttributeMap:
|
||||
"""Return ``values`` with ``None``-valued entries removed."""
|
||||
return {k: v for k, v in values.items() if v is not None}
|
||||
|
||||
|
||||
def collect(table: Mapping[str, Callable], source: object) -> AttributeMap:
|
||||
"""Apply an extractor table to ``source``, dropping ``None`` results."""
|
||||
return drop_none({key: extract(source) for key, extract in table.items()})
|
||||
|
||||
|
||||
def json_if(payload: Mapping[str, object]) -> str | None:
|
||||
"""JSON-serialize ``payload`` only when it's non-empty; else ``None``."""
|
||||
return json.dumps(payload) if payload else None
|
||||
|
||||
|
||||
def json_or_none(value: object) -> str | None:
|
||||
"""JSON-serialize ``value`` (falling back to ``str``); ``None`` on failure."""
|
||||
try:
|
||||
return json.dumps(value, default=str)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def stringify_message(message: object) -> str | None:
|
||||
"""JSON-serialize a chat message dict; ``None`` if not a dict or on failure."""
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
try:
|
||||
return json.dumps(message, default=str)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def serialize_messages(messages: Sequence[object]) -> str | None:
|
||||
"""Round-trip a sequence of message dicts through ``stringify_message``."""
|
||||
serialized = [
|
||||
json.loads(s) for s in (stringify_message(m) for m in messages) if s is not None
|
||||
]
|
||||
return json.dumps(serialized) if serialized else None
|
||||
|
||||
|
||||
def message_content(message: object) -> str | None:
|
||||
"""Extract the textual ``content`` from a chat message dict."""
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
# multimodal: concatenate text parts only
|
||||
parts = [
|
||||
part.get("text", "")
|
||||
for part in content
|
||||
if isinstance(part, dict) and part.get("type") == "text"
|
||||
]
|
||||
return "".join(p for p in parts if isinstance(p, str)) or None
|
||||
return None
|
||||
|
||||
|
||||
def output_messages(data: LLMCallSpanData) -> list:
|
||||
"""The ``message`` payload of each response choice."""
|
||||
return [c.get("message") for c in data.choices_out if isinstance(c, dict)]
|
||||
48
litellm/integrations/otel/mappers/weave.py
Normal file
48
litellm/integrations/otel/mappers/weave.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Weave (W&B) attribute mapper.
|
||||
|
||||
Weave consumes OpenInference + a small set of Weave-specific keys (display
|
||||
name, thread id, output value). This mapper layers the latter on top of
|
||||
OpenInference's vocabulary — compose ``["genai", "openinference", "weave"]``
|
||||
to feed a Weave backend.
|
||||
"""
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
|
||||
from litellm.integrations.otel.mappers.utils import collect, json_or_none
|
||||
from litellm.integrations.otel.model.payloads import LLMCallSpanData
|
||||
|
||||
|
||||
class WeaveMapper:
|
||||
"""Maps ``LLMCallSpanData`` to Weave's vendor attributes."""
|
||||
|
||||
_LLM_CALL_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = {
|
||||
# ``display_name`` has the form ``"{operation} {model}"``. The span
|
||||
# name already covers that, but Weave reads this attribute too.
|
||||
"weave.display_name": lambda d: (
|
||||
f"{d.operation.value} {d.request_model}" if d.request_model else None
|
||||
),
|
||||
"weave.call_id": lambda d: d.identity.call_id or None,
|
||||
}
|
||||
|
||||
# JSON-payload attributes: each builder returns the serialized blob or None.
|
||||
_BLOB_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = {
|
||||
# Weave treats the response choices as the "output" payload.
|
||||
"weave.output": lambda d: (
|
||||
json_or_none(list(d.choices_out)) if d.choices_out else None
|
||||
),
|
||||
}
|
||||
|
||||
def map(self, data: SpanData) -> AttributeMap:
|
||||
match data:
|
||||
case LLMCallSpanData():
|
||||
return self._llm_call(data)
|
||||
case _:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _llm_call(cls, data: LLMCallSpanData) -> AttributeMap:
|
||||
return {
|
||||
**collect(cls._LLM_CALL_ATTRS, data),
|
||||
**collect(cls._BLOB_ATTRS, data),
|
||||
}
|
||||
0
litellm/integrations/otel/model/__init__.py
Normal file
0
litellm/integrations/otel/model/__init__.py
Normal file
76
litellm/integrations/otel/model/baggage.py
Normal file
76
litellm/integrations/otel/model/baggage.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Baggage promotion: request-identity values carried across child spans.
|
||||
|
||||
A bounded set of identity values is written into OpenTelemetry Baggage on the
|
||||
LLM-call span so that child spans (guardrail, service) inherit them.
|
||||
``providers.LiteLLMBaggageSpanProcessor`` reads Baggage at span start and stamps
|
||||
the allowlisted keys onto every span.
|
||||
|
||||
This module is the single place baggage is defined: ``_PROMOTABLE`` maps each
|
||||
promotable attribute key to how its value is read, and the two ``*_KEYS``
|
||||
defaults select what is promoted unless the config overrides them.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Final
|
||||
|
||||
from litellm.integrations.otel.model.metadata import RequestIdentity
|
||||
from litellm.integrations.otel.model.semconv import GenAI, LiteLLM
|
||||
|
||||
# Attribute key -> value extractor over (identity, request_model). The single
|
||||
# definition of what may be promoted and under which key.
|
||||
_PROMOTABLE: Final[dict[str, Callable[[RequestIdentity, str | None], str | None]]] = {
|
||||
LiteLLM.TEAM_ID: lambda identity, model: identity.team_id,
|
||||
LiteLLM.TEAM_ALIAS: lambda identity, model: identity.team_alias,
|
||||
LiteLLM.TEAM_METADATA: lambda identity, model: identity.team_metadata,
|
||||
LiteLLM.KEY_HASH: lambda identity, model: identity.key_hash,
|
||||
LiteLLM.END_USER: lambda identity, model: identity.end_user,
|
||||
GenAI.REQUEST_MODEL: lambda identity, model: model,
|
||||
LiteLLM.PROVIDER_MODEL: lambda identity, model: identity.provider_model,
|
||||
}
|
||||
|
||||
# Keys promoted by default (a subset of ``_PROMOTABLE``). ``END_USER`` is
|
||||
# promotable but off by default — it identifies an individual user, so stamping
|
||||
# it onto every span is opt-in via ``config.baggage_promoted_keys``.
|
||||
BAGGAGE_PROMOTED_KEYS: Final[tuple[str, ...]] = (
|
||||
LiteLLM.TEAM_ID,
|
||||
LiteLLM.TEAM_ALIAS,
|
||||
LiteLLM.TEAM_METADATA,
|
||||
LiteLLM.KEY_HASH,
|
||||
GenAI.REQUEST_MODEL,
|
||||
LiteLLM.PROVIDER_MODEL,
|
||||
)
|
||||
|
||||
# Metadata sub-keys eligible for promotion under the ``litellm.metadata.*``
|
||||
# namespace. The full metadata blob is never promoted; only this allowlist is.
|
||||
DEFAULT_BAGGAGE_METADATA_KEYS: Final[tuple[str, ...]] = (
|
||||
"user_api_key_org_id",
|
||||
"user_api_key_user_id",
|
||||
"user_api_key_alias",
|
||||
"user_api_key_end_user_id",
|
||||
"requester_ip_address",
|
||||
)
|
||||
|
||||
|
||||
def promoted_baggage(
|
||||
identity: RequestIdentity,
|
||||
request_model: str | None,
|
||||
promoted_keys: tuple[str, ...],
|
||||
metadata_keys: tuple[str, ...] = DEFAULT_BAGGAGE_METADATA_KEYS,
|
||||
) -> dict[str, str]:
|
||||
"""Identity values to write into Baggage, filtered to ``promoted_keys``.
|
||||
|
||||
``promoted_keys`` selects from ``_PROMOTABLE``; ``metadata_keys`` selects
|
||||
sub-keys of ``identity.metadata`` to promote under ``litellm.metadata.*``.
|
||||
Empty values are dropped.
|
||||
"""
|
||||
out: dict[str, str] = {}
|
||||
for key, extract in _PROMOTABLE.items():
|
||||
if key in promoted_keys:
|
||||
value = extract(identity, request_model)
|
||||
if value:
|
||||
out[key] = value
|
||||
for meta_key in metadata_keys:
|
||||
value = identity.metadata.get(meta_key)
|
||||
if value:
|
||||
out[f"{LiteLLM.METADATA_PREFIX}{meta_key}"] = value
|
||||
return out
|
||||
236
litellm/integrations/otel/model/config.py
Normal file
236
litellm/integrations/otel/model/config.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""Typed configuration for the OpenTelemetry instrumentation."""
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
from typing_extensions import Annotated
|
||||
|
||||
from litellm.integrations.otel.model.baggage import (
|
||||
BAGGAGE_PROMOTED_KEYS,
|
||||
DEFAULT_BAGGAGE_METADATA_KEYS,
|
||||
)
|
||||
|
||||
#: Master feature-flag env var. The logger is inert until this is truthy.
|
||||
OTEL_V2_ENV = "LITELLM_OTEL_V2"
|
||||
|
||||
|
||||
class CaptureMessageContent(str):
|
||||
NO_CONTENT = "no_content"
|
||||
SPAN_ONLY = "span_only"
|
||||
EVENT_ONLY = "event_only"
|
||||
SPAN_AND_EVENT = "span_and_event"
|
||||
|
||||
|
||||
class _OTelV2Flag(BaseSettings):
|
||||
model_config = SettingsConfigDict(extra="ignore")
|
||||
|
||||
enabled: bool = Field(default=False, validation_alias=AliasChoices(OTEL_V2_ENV))
|
||||
|
||||
|
||||
def is_otel_v2_enabled() -> bool:
|
||||
return _OTelV2Flag().enabled
|
||||
|
||||
|
||||
class ExporterSpec(BaseModel):
|
||||
"""One span-export destination.
|
||||
|
||||
The shared ``TracerProvider`` attaches one ``SpanProcessor`` per spec, so
|
||||
listing several specs sends every span to all of them at once (e.g. Arize +
|
||||
Phoenix + your own Honeycomb).
|
||||
"""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
kind: str = Field(
|
||||
default="console",
|
||||
description="console | in_memory | otlp_http | otlp_grpc | <factory kind>",
|
||||
)
|
||||
endpoint: str | None = None
|
||||
headers: str | None = None
|
||||
options: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Factory-specific configuration for a custom exporter ``kind`` "
|
||||
"registered via ``providers.register_exporter_factory`` (e.g. an "
|
||||
"API key a lazy-auth exporter fetches a token with). Ignored by the "
|
||||
"built-in console/in_memory/otlp exporters."
|
||||
),
|
||||
)
|
||||
use_simple_processor: bool | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Force SimpleSpanProcessor regardless of exporter kind. Default: "
|
||||
"auto (Simple for console/in_memory, Batch otherwise)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class OpenTelemetryV2Config(BaseSettings):
|
||||
model_config = SettingsConfigDict(populate_by_name=True, extra="ignore")
|
||||
|
||||
# ----- single-destination shorthand, read from standard OTEL_* envs ----- #
|
||||
exporter: str = Field(
|
||||
default="console",
|
||||
validation_alias=AliasChoices("OTEL_EXPORTER", "OTEL_EXPORTER_OTLP_PROTOCOL"),
|
||||
description=(
|
||||
"Exporter kind for the single-destination shorthand. The model "
|
||||
"validator folds this (with ``endpoint`` / ``headers``) into a "
|
||||
"one-entry ``exporters`` list when ``exporters`` is empty; set "
|
||||
"``exporters`` directly for multiple destinations."
|
||||
),
|
||||
)
|
||||
endpoint: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("OTEL_ENDPOINT", "OTEL_EXPORTER_OTLP_ENDPOINT"),
|
||||
)
|
||||
headers: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("OTEL_HEADERS", "OTEL_EXPORTER_OTLP_HEADERS"),
|
||||
)
|
||||
service_name: str = Field(
|
||||
default="litellm", validation_alias=AliasChoices("OTEL_SERVICE_NAME")
|
||||
)
|
||||
deployment_environment: str | None = Field(
|
||||
default=None, validation_alias=AliasChoices("OTEL_ENVIRONMENT_NAME")
|
||||
)
|
||||
|
||||
enable_metrics: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("LITELLM_OTEL_INTEGRATION_ENABLE_METRICS"),
|
||||
)
|
||||
enable_events: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices("LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS"),
|
||||
)
|
||||
capture_message_content: str = Field(
|
||||
default=CaptureMessageContent.NO_CONTENT,
|
||||
validation_alias=AliasChoices(
|
||||
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
|
||||
),
|
||||
)
|
||||
legacy_compat: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("LITELLM_OTEL_LEGACY_COMPAT")
|
||||
)
|
||||
|
||||
# ----- explicit multi-destination / vocabulary configuration ------------ #
|
||||
|
||||
exporters: list[ExporterSpec] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"One destination per spec. The shared TracerProvider attaches a "
|
||||
"SpanProcessor per entry. When empty, the model validator folds "
|
||||
"the ``exporter`` / ``endpoint`` / ``headers`` shorthand into a "
|
||||
"single spec so there is always at least one destination."
|
||||
),
|
||||
)
|
||||
|
||||
mapper_names: Annotated[List[str], NoDecode] = Field(
|
||||
default_factory=lambda: ["genai"],
|
||||
description=(
|
||||
"Ordered attribute vocabularies to emit. ``genai`` is the "
|
||||
"canonical OTel GenAI vocabulary and is always placed first. "
|
||||
"Vendor names: ``openinference`` (Arize + Phoenix), ``langfuse``, "
|
||||
"``weave``, ``langtrace``."
|
||||
),
|
||||
)
|
||||
|
||||
resource_attributes: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description=(
|
||||
"Extra Resource attributes beyond ``service.name`` and "
|
||||
"``deployment.environment`` (e.g. integration-specific markers)."
|
||||
),
|
||||
)
|
||||
|
||||
baggage_promoted_keys: Annotated[List[str], NoDecode] = Field(
|
||||
default_factory=lambda: list(BAGGAGE_PROMOTED_KEYS),
|
||||
validation_alias=AliasChoices(
|
||||
"baggage_promoted_keys", "LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS"
|
||||
),
|
||||
description=(
|
||||
"Identity attribute keys written into Baggage and stamped on every "
|
||||
"child span (e.g. ``litellm.team.id``). Configure via the "
|
||||
"``LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS`` env var (comma-separated) or "
|
||||
"``callback_settings.otel.baggage_promoted_keys`` in config.yaml (a "
|
||||
"YAML list)."
|
||||
),
|
||||
)
|
||||
baggage_metadata_keys: Annotated[List[str], NoDecode] = Field(
|
||||
default_factory=lambda: list(DEFAULT_BAGGAGE_METADATA_KEYS),
|
||||
validation_alias=AliasChoices(
|
||||
"baggage_metadata_keys", "LITELLM_OTEL_BAGGAGE_METADATA_KEYS"
|
||||
),
|
||||
description=(
|
||||
"Metadata sub-keys promoted under the ``litellm.metadata.*`` "
|
||||
"namespace. Configure via the ``LITELLM_OTEL_BAGGAGE_METADATA_KEYS`` "
|
||||
"env var (comma-separated) or "
|
||||
"``callback_settings.otel.baggage_metadata_keys`` in config.yaml."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator(
|
||||
"baggage_promoted_keys",
|
||||
"baggage_metadata_keys",
|
||||
"mapper_names",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def _split_csv(cls, value: Any) -> Any:
|
||||
"""Accept a comma-separated string for list fields.
|
||||
|
||||
Env vars are strings, but these fields are lists. Pydantic-settings would
|
||||
otherwise require JSON for a list env var; splitting on commas here lets
|
||||
an operator write ``LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS=litellm.team.id,litellm.api_key.hash``.
|
||||
YAML lists (from ``callback_settings.otel.*``) and real lists pass through
|
||||
unchanged.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _normalize(self) -> "OpenTelemetryV2Config":
|
||||
# An endpoint with the default exporter kind implies OTLP/HTTP.
|
||||
if self.endpoint and self.exporter == "console":
|
||||
self.exporter = "otlp_http"
|
||||
# When no explicit destinations are given, fold the single-destination
|
||||
# shorthand into one spec so the provider always has a destination.
|
||||
if not self.exporters:
|
||||
self.exporters = [
|
||||
ExporterSpec(
|
||||
kind=self.exporter,
|
||||
endpoint=self.endpoint,
|
||||
headers=self.headers,
|
||||
)
|
||||
]
|
||||
# Ensure ``genai`` is always present and first.
|
||||
names = list(self.mapper_names)
|
||||
if "genai" in names:
|
||||
names = ["genai"] + [n for n in names if n != "genai"]
|
||||
else:
|
||||
names = ["genai"] + names
|
||||
# When enabled, also emit attribute keys under their semconv-ai /
|
||||
# Traceloop names via the ``legacy`` mapper. Append it at the tail so
|
||||
# the canonical ``genai`` keys win on any conflict.
|
||||
if self.legacy_compat and "legacy" not in names:
|
||||
names.append("legacy")
|
||||
self.mapper_names = names
|
||||
return self
|
||||
|
||||
@property
|
||||
def capture_span_content(self) -> bool:
|
||||
"""Whether prompt/response content may be stamped as span attributes.
|
||||
|
||||
Defaults off (``no_content``): an operator must opt in before message
|
||||
bodies leave the process, so a user request can never force its prompt
|
||||
or completion into the configured backend while capture is disabled.
|
||||
"""
|
||||
return self.capture_message_content in (
|
||||
CaptureMessageContent.SPAN_ONLY,
|
||||
CaptureMessageContent.SPAN_AND_EVENT,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "OpenTelemetryV2Config":
|
||||
return cls()
|
||||
315
litellm/integrations/otel/model/metadata.py
Normal file
315
litellm/integrations/otel/model/metadata.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
"""The single translation layer between a request's metadata and the spans.
|
||||
|
||||
Every relevant field litellm exposes about a request — the user-facing model,
|
||||
the model actually dispatched to the provider, the deployment, and the caller's
|
||||
identity (team, key, end-user) — is parsed **once**, here, out of the
|
||||
``StandardLoggingPayload`` (or a ``UserAPIKeyAuth`` at the auth boundary). Span
|
||||
data, baggage promotion, and the mappers then read these typed fields instead of
|
||||
each digging into the raw ``metadata`` / ``hidden_params`` dicts.
|
||||
|
||||
Two models live here because a request's identity is known *before* its model
|
||||
resolution is:
|
||||
|
||||
* :class:`RequestIdentity` — team / key / end-user, seeded into Baggage at the
|
||||
auth boundary (``from_user_api_key_auth``), before routing has picked a
|
||||
deployment. ``provider_model`` is therefore absent from that early seed and is
|
||||
only filled in from the payload once the call closes.
|
||||
* :class:`RequestContext` — the full picture available at close: the resolved
|
||||
request vs. provider model split, plus the response model, model group, model
|
||||
id, and api base, wrapping the :class:`RequestIdentity`.
|
||||
|
||||
The request-vs-provider model split is the subtle part. On the proxy a caller
|
||||
asks for a *model group* (e.g. ``gpt-4o``) that routes to a concrete deployment
|
||||
(e.g. ``azure/my-deployment``); the two are distinct and both worth recording.
|
||||
``StandardLoggingPayload`` exposes them as:
|
||||
|
||||
* ``model_group`` — the user-facing name the caller requested.
|
||||
* ``model`` — already reconstructed (see ``reconstruct_model_name``) to the name
|
||||
litellm dispatched to the provider (the deployment, provider-prefixed).
|
||||
* ``hidden_params.litellm_model_name`` — a secondary source for the dispatched
|
||||
model (populated only on some call paths, e.g. files).
|
||||
|
||||
So ``gen_ai.request.model`` is the *group* (falling back to the call model on the
|
||||
SDK path, which has no group), and ``litellm.provider.model`` is the *dispatched*
|
||||
model. They coincide on the SDK path, which is correct.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Mapping, cast
|
||||
|
||||
from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL
|
||||
from litellm.integrations.otel.model.semconv import resolve_operation
|
||||
from litellm.integrations.otel.model.utils import as_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestIdentity:
|
||||
call_id: str | None = None
|
||||
team_id: str | None = None
|
||||
team_alias: str | None = None
|
||||
# The team's free-form metadata dict, JSON-serialized (empty/missing -> None).
|
||||
team_metadata: str | None = None
|
||||
key_hash: str | None = None
|
||||
end_user: str | None = None
|
||||
# The model litellm dispatched to the provider. Only known once the call
|
||||
# completes (routing has picked a deployment), so it's absent from the
|
||||
# auth-time seed and filled only from the payload.
|
||||
provider_model: str | None = None
|
||||
metadata: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: "StandardLoggingPayload") -> "RequestIdentity":
|
||||
"""Parse caller identity out of a closed request's payload metadata.
|
||||
|
||||
``provider_model`` is resolved here too (see :func:`resolve_provider_model`)
|
||||
so the identity carried into Baggage labels every span with the dispatched
|
||||
model, not just the user-facing one.
|
||||
"""
|
||||
raw_meta = cast(Mapping[str, object], payload.get("metadata") or {})
|
||||
metadata = {
|
||||
key: str(value)
|
||||
for key, value in raw_meta.items()
|
||||
if isinstance(value, (str, bool, int, float))
|
||||
}
|
||||
return cls(
|
||||
call_id=as_str(payload.get("litellm_call_id")) or as_str(payload.get("id")),
|
||||
# StandardLoggingMetadata's canonical key is ``user_api_key_team_id``;
|
||||
# the bare ``team_id`` is a legacy alias and is often empty, so prefer
|
||||
# the canonical key and fall back to the alias.
|
||||
team_id=as_str(raw_meta.get("user_api_key_team_id"))
|
||||
or as_str(raw_meta.get("team_id")),
|
||||
team_alias=as_str(raw_meta.get("user_api_key_team_alias"))
|
||||
or as_str(raw_meta.get("team_alias")),
|
||||
team_metadata=_team_metadata_json(
|
||||
raw_meta.get("user_api_key_team_metadata")
|
||||
),
|
||||
key_hash=as_str(raw_meta.get("user_api_key_hash")),
|
||||
end_user=as_str(payload.get("end_user"))
|
||||
or as_str(raw_meta.get("user_api_key_end_user_id")),
|
||||
provider_model=resolve_provider_model(payload),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_user_api_key_auth(cls, auth: object) -> "RequestIdentity":
|
||||
"""Identity from a ``UserAPIKeyAuth`` (duck-typed to keep this module
|
||||
free of a proxy import).
|
||||
|
||||
Used in the pre-call hook to seed Baggage early — before any LLM,
|
||||
guardrail, or service span is created — so the whole request's spans
|
||||
inherit identity, not just the LLM-call span. Metadata sub-keys use the
|
||||
``user_api_key_*`` names that ``baggage.DEFAULT_BAGGAGE_METADATA_KEYS``
|
||||
promotes.
|
||||
"""
|
||||
get = lambda name: getattr(auth, name, None) # noqa: E731
|
||||
metadata = {
|
||||
meta_key: str(value)
|
||||
for meta_key, attr in (
|
||||
("user_api_key_user_id", "user_id"),
|
||||
("user_api_key_org_id", "org_id"),
|
||||
("user_api_key_alias", "key_alias"),
|
||||
("user_api_key_end_user_id", "end_user_id"),
|
||||
)
|
||||
if (value := get(attr))
|
||||
}
|
||||
return cls(
|
||||
team_id=as_str(get("team_id")),
|
||||
team_alias=as_str(get("team_alias")),
|
||||
team_metadata=_team_metadata_json(get("team_metadata")),
|
||||
key_hash=as_str(get("api_key")),
|
||||
end_user=as_str(get("end_user_id")),
|
||||
# ``provider_model`` is unknown at the auth boundary — routing hasn't
|
||||
# picked a deployment yet — so it's only populated from the payload.
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestContext:
|
||||
"""The fully-resolved view of a closed request, parsed once from the payload.
|
||||
|
||||
``request_model`` is the user-facing requested model and ``provider_model``
|
||||
(on :attr:`identity`) is the model litellm dispatched to the provider; the two
|
||||
differ on the proxy (group vs. deployment) and coincide on the SDK path.
|
||||
"""
|
||||
|
||||
request_model: str
|
||||
response_model: str | None
|
||||
model_group: str | None
|
||||
model_id: str | None
|
||||
api_base: str | None
|
||||
identity: RequestIdentity
|
||||
|
||||
@property
|
||||
def provider_model(self) -> str | None:
|
||||
"""The dispatched-model name, carried on the identity for Baggage."""
|
||||
return self.identity.provider_model
|
||||
|
||||
@classmethod
|
||||
def from_standard_logging_payload(
|
||||
cls, payload: "StandardLoggingPayload"
|
||||
) -> "RequestContext":
|
||||
raw_meta = cast(Mapping[str, object], payload.get("metadata") or {})
|
||||
hidden = cast(Mapping[str, object], payload.get("hidden_params") or {})
|
||||
raw_response = payload.get("response")
|
||||
response = cast(
|
||||
Mapping[str, object], raw_response if isinstance(raw_response, dict) else {}
|
||||
)
|
||||
model_group = as_str(payload.get("model_group")) or as_str(
|
||||
raw_meta.get("model_group")
|
||||
)
|
||||
return cls(
|
||||
# The user asked for the group; fall back to the call model on the SDK
|
||||
# path, which has no group. Empty string (never None) so the span name
|
||||
# builder and the mapper see a plain string.
|
||||
request_model=model_group or as_str(payload.get("model")) or "",
|
||||
response_model=as_str(response.get("model")),
|
||||
model_group=model_group,
|
||||
model_id=as_str(payload.get("model_id"))
|
||||
or _model_info_id(raw_meta.get("model_info")),
|
||||
api_base=as_str(payload.get("api_base")) or as_str(hidden.get("api_base")),
|
||||
identity=RequestIdentity.from_payload(payload),
|
||||
)
|
||||
|
||||
|
||||
# --- live-callback kwargs parsing ------------------------------------------- #
|
||||
#
|
||||
# The model and helpers below parse the *live* callback ``kwargs`` god object (and
|
||||
# the raw pre/post-call ``data`` dicts) — the untyped request state that reaches a
|
||||
# ``CustomLogger`` before, or instead of, a ``StandardLoggingPayload``. They live
|
||||
# here, with the payload/auth parsers, so every read out of a request's raw dicts
|
||||
# is in one place rather than scattered across the ``CustomLogger``.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMCallEvent:
|
||||
"""The typed view of the live callback ``kwargs`` (``model_call_details``).
|
||||
|
||||
litellm hands every callback an untyped ``kwargs`` god object. The fields the
|
||||
OTel logger needs out of it are parsed **once**, here, so the ``CustomLogger``
|
||||
reads typed attributes instead of digging into the dict at each boundary.
|
||||
"""
|
||||
|
||||
# The ``litellm_call_id`` correlating ``pre_call`` with the close callback.
|
||||
# Present in ``model_call_details`` at ``pre_call`` and in both the kwargs and
|
||||
# the ``standard_logging_object`` at success/failure, so it's a stable key for
|
||||
# the open-call carrier — no back-reference to the logging object required (the
|
||||
# object isn't reachable from the callback kwargs at ``pre_call`` time).
|
||||
call_id: str | None
|
||||
# The ``StandardLoggingPayload`` carried on a success/failure callback; ``None``
|
||||
# at ``pre_call``, or when the call closed before any payload materialized (so
|
||||
# there is nothing to stamp on the span).
|
||||
payload: "StandardLoggingPayload | None"
|
||||
# The ``standard_callback_dynamic_params`` routing the call to a per-tenant
|
||||
# tracer (its own exporter/endpoint), or ``None`` when the call isn't scoped.
|
||||
dynamic_params: Any
|
||||
# True for synthetic proxy-gate logs (auth / rate-limit rejections): they fire
|
||||
# the ``pre_call`` hook but never made an upstream call, so they get no span.
|
||||
is_no_upstream_call: bool
|
||||
# A best-effort ``"{operation} {model}"`` name known at ``pre_call`` time. The
|
||||
# span is renamed from the typed payload at close (``finish_span``); this only
|
||||
# needs to be reasonable for a span that never gets closed (a leak).
|
||||
provisional_span_name: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, kwargs: Mapping[str, Any]) -> "LLMCallEvent":
|
||||
raw_payload = kwargs.get("standard_logging_object")
|
||||
payload = cast("StandardLoggingPayload", raw_payload) if raw_payload else None
|
||||
operation = resolve_operation(as_str(kwargs.get("call_type")))
|
||||
model = as_str(kwargs.get("model")) or ""
|
||||
return cls(
|
||||
call_id=_call_id(payload, kwargs),
|
||||
payload=payload,
|
||||
dynamic_params=kwargs.get("standard_callback_dynamic_params"),
|
||||
is_no_upstream_call=bool(kwargs.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL)),
|
||||
provisional_span_name=f"{operation.value} {model}".strip(),
|
||||
)
|
||||
|
||||
|
||||
def _call_id(
|
||||
payload: "StandardLoggingPayload | None", kwargs: Mapping[str, Any]
|
||||
) -> str | None:
|
||||
"""The call id from the payload (when closed) or the bare kwargs (at pre_call)."""
|
||||
if payload is not None:
|
||||
call_id = as_str(payload.get("litellm_call_id")) or as_str(payload.get("id"))
|
||||
if call_id:
|
||||
return call_id
|
||||
return as_str(kwargs.get("litellm_call_id"))
|
||||
|
||||
|
||||
def model_from_request_data(data: object) -> str | None:
|
||||
"""The user-facing ``model`` from a pre-call ``data`` dict (``None`` if absent).
|
||||
|
||||
Read at the auth boundary to label early Baggage before routing has resolved
|
||||
a deployment; ``data`` is duck-typed since it arrives untyped from the proxy.
|
||||
"""
|
||||
if isinstance(data, Mapping):
|
||||
return as_str(data.get("model"))
|
||||
return None
|
||||
|
||||
|
||||
def guardrail_entries_from_request_data(
|
||||
request_data: Mapping[str, Any],
|
||||
) -> list[dict]:
|
||||
"""The guardrail-information dicts buried in ``metadata`` of a post-call dict.
|
||||
|
||||
``standard_logging_guardrail_information`` is stored as either a single dict
|
||||
or a list of them; normalize to a list of dicts (dropping non-dict noise) so
|
||||
the caller just iterates. Empty list when none are present.
|
||||
"""
|
||||
metadata = request_data.get("metadata")
|
||||
if not isinstance(metadata, Mapping):
|
||||
return []
|
||||
info = metadata.get("standard_logging_guardrail_information")
|
||||
if isinstance(info, Mapping):
|
||||
return [cast(dict, info)]
|
||||
if isinstance(info, list):
|
||||
return [entry for entry in info if isinstance(entry, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def resolve_provider_model(payload: "StandardLoggingPayload") -> str | None:
|
||||
"""The model litellm dispatched to the provider, from the payload.
|
||||
|
||||
Prefers the explicit ``hidden_params.litellm_model_name`` (set on call paths
|
||||
that know it, e.g. files), then the top-level ``model`` — which
|
||||
``reconstruct_model_name`` has already resolved to the deployment's
|
||||
provider-prefixed name. Returns ``None`` only when neither is present.
|
||||
"""
|
||||
raw_meta = cast(Mapping[str, object], payload.get("metadata") or {})
|
||||
hidden = cast(Mapping[str, object], payload.get("hidden_params") or {})
|
||||
return (
|
||||
# ``deployment`` survives only on paths that don't strip it from metadata;
|
||||
# harmless (and most precise) to prefer it when present.
|
||||
as_str(raw_meta.get("deployment"))
|
||||
or as_str(hidden.get("litellm_model_name"))
|
||||
or as_str(payload.get("model"))
|
||||
)
|
||||
|
||||
|
||||
def _model_info_id(model_info: object) -> str | None:
|
||||
"""The deployment id from a ``metadata.model_info`` sub-dict, if present."""
|
||||
if isinstance(model_info, Mapping):
|
||||
return as_str(model_info.get("id"))
|
||||
return None
|
||||
|
||||
|
||||
def _team_metadata_json(value: object) -> str | None:
|
||||
"""JSON-serialize a team's metadata dict for a single Baggage value.
|
||||
|
||||
Returns ``None`` for a missing, non-dict, or empty mapping so the empty case
|
||||
is dropped rather than promoting a useless ``"{}"``. Keys are sorted for a
|
||||
stable, diff-friendly serialization.
|
||||
"""
|
||||
if not isinstance(value, Mapping) or not value:
|
||||
return None
|
||||
try:
|
||||
return json.dumps(value, default=str, sort_keys=True)
|
||||
except Exception:
|
||||
return None
|
||||
468
litellm/integrations/otel/model/payloads.py
Normal file
468
litellm/integrations/otel/model/payloads.py
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
"""Typed span-data inputs: frozen dataclasses the engine and mappers consume."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, ClassVar, Mapping, cast
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from litellm.integrations.otel.model.metadata import (
|
||||
RequestContext,
|
||||
RequestIdentity,
|
||||
)
|
||||
from litellm.integrations.otel.model.semconv import (
|
||||
GenAIOperation,
|
||||
resolve_operation,
|
||||
resolve_provider,
|
||||
)
|
||||
from litellm.integrations.otel.model.utils import (
|
||||
as_bool,
|
||||
as_float,
|
||||
as_int,
|
||||
as_str,
|
||||
as_str_tuple,
|
||||
)
|
||||
|
||||
# ``RequestIdentity`` and the request-metadata translation now live in
|
||||
# :mod:`metadata`; re-exported here so existing ``model.payloads`` imports keep
|
||||
# resolving it.
|
||||
__all__ = [
|
||||
"RequestContext",
|
||||
"RequestIdentity",
|
||||
"GuardrailSpanData",
|
||||
"LLMCallSpanData",
|
||||
"LLMRequestParams",
|
||||
"LLMUsage",
|
||||
"ProxyRequestSpanData",
|
||||
"ServerInfo",
|
||||
"ServiceSpanData",
|
||||
"SpanError",
|
||||
"ToolDefinition",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.services import ServiceLoggerPayload
|
||||
from litellm.types.utils import (
|
||||
StandardLoggingGuardrailInformation,
|
||||
StandardLoggingPayload,
|
||||
)
|
||||
|
||||
|
||||
# --- typed sub-structures ---------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMRequestParams:
|
||||
temperature: float | None = None
|
||||
top_p: float | None = None
|
||||
top_k: int | None = None
|
||||
max_tokens: int | None = None
|
||||
frequency_penalty: float | None = None
|
||||
presence_penalty: float | None = None
|
||||
stop_sequences: tuple[str, ...] | None = None
|
||||
seed: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_model_parameters(cls, params: Mapping[str, object]) -> "LLMRequestParams":
|
||||
max_tokens = as_int(params.get("max_tokens"))
|
||||
if max_tokens is None:
|
||||
max_tokens = as_int(params.get("max_completion_tokens"))
|
||||
return cls(
|
||||
temperature=as_float(params.get("temperature")),
|
||||
top_p=as_float(params.get("top_p")),
|
||||
top_k=as_int(params.get("top_k")),
|
||||
max_tokens=max_tokens,
|
||||
frequency_penalty=as_float(params.get("frequency_penalty")),
|
||||
presence_penalty=as_float(params.get("presence_penalty")),
|
||||
stop_sequences=as_str_tuple(params.get("stop")),
|
||||
seed=as_int(params.get("seed")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMUsage:
|
||||
input_tokens: int | None = None
|
||||
output_tokens: int | None = None
|
||||
total_tokens: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpanError:
|
||||
error_type: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerInfo:
|
||||
address: str | None = None
|
||||
port: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_api_base(cls, api_base: str | None) -> ServerInfo | None:
|
||||
if not api_base:
|
||||
return None
|
||||
parsed = urlsplit(api_base if "://" in api_base else f"//{api_base}")
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
return cls(address=parsed.hostname, port=parsed.port)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuardrailSpanData:
|
||||
guardrail_name: str
|
||||
mode: str | None = None
|
||||
status: str | None = None
|
||||
masked_entity_count: int | None = None
|
||||
provider: str | None = None
|
||||
action: str | None = None
|
||||
# The guardrail verdict / provider response (e.g. the moderation result),
|
||||
# JSON-serialized. This is the detail that belongs on the guardrail span.
|
||||
response_json: str | None = None
|
||||
violation_categories: tuple[str, ...] = ()
|
||||
confidence_score: float | None = None
|
||||
risk_score: float | None = None
|
||||
duration: float | None = None
|
||||
# Actual execution window (epoch seconds) from the logging entry, so the span
|
||||
# is placed when the guardrail really ran — a pre_call guardrail before the
|
||||
# LLM call — rather than at post-call emission time.
|
||||
start_time: float | None = None
|
||||
end_time: float | None = None
|
||||
# Provider-agnostic configuration/detection metadata (see
|
||||
# ``StandardLoggingGuardrailInformation``). Present for any guardrail that
|
||||
# populates them, not just one provider's shape.
|
||||
guardrail_id: str | None = None
|
||||
policy_template: str | None = None
|
||||
detection_method: str | None = None
|
||||
# Set when the guardrail intervened/blocked or failed, so the emitter marks
|
||||
# the span ERROR — a blocking guardrail is an error outcome for that span.
|
||||
error: SpanError | None = None
|
||||
|
||||
# Guardrail statuses that mean the guardrail did not pass the request through.
|
||||
_ERROR_STATUSES: ClassVar[frozenset[str]] = frozenset(
|
||||
{"guardrail_intervened", "guardrail_failed_to_respond"}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_logging_entry(
|
||||
cls, entry: "StandardLoggingGuardrailInformation"
|
||||
) -> "GuardrailSpanData":
|
||||
"""Build from one ``standard_logging_guardrail_information`` entry.
|
||||
|
||||
Reads the canonical, provider-agnostic ``StandardLoggingGuardrailInformation``
|
||||
keys only — no guessing at a single provider's field names. Values that are
|
||||
typed as enums or lists (e.g. ``guardrail_mode``) are normalized to a
|
||||
stable string rather than assumed to already be plain strings.
|
||||
"""
|
||||
get = cast(Mapping[str, object], entry).get
|
||||
status = as_str(get("guardrail_status"))
|
||||
response = get("guardrail_response")
|
||||
error = (
|
||||
SpanError(error_type=status, message=as_str(get("guardrail_action")))
|
||||
if status in cls._ERROR_STATUSES
|
||||
else None
|
||||
)
|
||||
return cls(
|
||||
guardrail_name=as_str(get("guardrail_name")) or "guardrail",
|
||||
mode=_guardrail_mode_str(get("guardrail_mode")),
|
||||
status=status,
|
||||
masked_entity_count=_total_masked_entities(get("masked_entity_count")),
|
||||
provider=as_str(get("guardrail_provider")),
|
||||
action=as_str(get("guardrail_action")),
|
||||
response_json=_json_or_none(response) if response is not None else None,
|
||||
violation_categories=as_str_tuple(get("violation_categories")) or (),
|
||||
confidence_score=as_float(get("confidence_score")),
|
||||
risk_score=as_float(get("risk_score")),
|
||||
duration=as_float(get("duration")),
|
||||
start_time=as_float(get("start_time")),
|
||||
end_time=as_float(get("end_time")),
|
||||
guardrail_id=as_str(get("guardrail_id")),
|
||||
policy_template=as_str(get("policy_template")),
|
||||
detection_method=as_str(get("detection_method")),
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceSpanData:
|
||||
service_name: str
|
||||
call_type: str | None = None
|
||||
error: SpanError | None = None
|
||||
# Caller-supplied attributes to stamp on the service span, passed through
|
||||
# from ``async_service_*_hook(event_metadata=...)``. The mapper owns how
|
||||
# these are namespaced: the canonical vocabulary uses ``litellm.metadata.*``
|
||||
# keys, the semconv-ai / Traceloop vocabulary uses the bare key names.
|
||||
event_metadata: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_payload(
|
||||
cls,
|
||||
payload: "ServiceLoggerPayload",
|
||||
event_metadata: Mapping[str, object] | None = None,
|
||||
) -> "ServiceSpanData":
|
||||
# ``payload.service`` is a ``ServiceTypes(str, Enum)`` and ``error`` is
|
||||
# ``Optional[str]`` on the Pydantic model — no defensive reads needed.
|
||||
# ``event_metadata`` is sanitized: the legacy service decorators pass raw
|
||||
# call-site data (live objects, full request metadata, response headers),
|
||||
# none of which belongs on a span.
|
||||
return cls(
|
||||
service_name=payload.service.value,
|
||||
call_type=payload.call_type,
|
||||
error=SpanError(message=payload.error) if payload.error else None,
|
||||
event_metadata=sanitize_event_metadata(event_metadata),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProxyRequestSpanData:
|
||||
http_method: str
|
||||
route: str
|
||||
url_path: str | None = None
|
||||
status_code: int | None = None
|
||||
identity: RequestIdentity | None = None
|
||||
|
||||
|
||||
# --- the primary LLM-call model ---------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolDefinition:
|
||||
"""A single function/tool declared on a chat-completion request."""
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
parameters_json: str | None = (
|
||||
None # JSON-serialized schema (str so it's an AttrValue)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMCallSpanData:
|
||||
operation: GenAIOperation
|
||||
provider: str
|
||||
request_model: str
|
||||
response_model: str | None
|
||||
response_id: str | None
|
||||
request_params: LLMRequestParams
|
||||
usage: LLMUsage
|
||||
finish_reasons: tuple[str, ...]
|
||||
error: SpanError | None
|
||||
response_cost: float | None
|
||||
server: ServerInfo | None
|
||||
identity: RequestIdentity
|
||||
is_streaming: bool | None = None
|
||||
tools: tuple[ToolDefinition, ...] = ()
|
||||
# Raw messages and response, needed by vendor mappers (OpenInference,
|
||||
# Langfuse, Weave) that stamp message-level attributes. ``messages_in`` is
|
||||
# the request payload; ``choices_out`` mirrors ``response.choices`` from
|
||||
# the StandardLoggingPayload. Both are tuples of immutable mappings so the
|
||||
# dataclass stays hashable and frozen.
|
||||
messages_in: tuple[Mapping[str, object], ...] = ()
|
||||
choices_out: tuple[Mapping[str, object], ...] = ()
|
||||
system_fingerprint: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_standard_logging_payload(
|
||||
cls, payload: "StandardLoggingPayload", capture_content: bool = False
|
||||
) -> "LLMCallSpanData":
|
||||
params = cast(Mapping[str, object], payload.get("model_parameters") or {})
|
||||
# The single parse of the request's metadata — the request-vs-provider
|
||||
# model split, the response model, api base, and identity all come from
|
||||
# here rather than being re-derived from the raw payload dicts.
|
||||
context = RequestContext.from_standard_logging_payload(payload)
|
||||
# Normalize ``response`` to a dict once so the content/id reads below are a
|
||||
# plain ``.get`` — no repeated ``isinstance`` guards.
|
||||
raw_response = payload.get("response")
|
||||
response = cast(
|
||||
Mapping[str, object], raw_response if isinstance(raw_response, dict) else {}
|
||||
)
|
||||
choices_out = _dicts(response.get("choices"))
|
||||
# ``finish_reasons`` is metadata, not content, so derive it from
|
||||
# ``choices_out`` before gating. The raw message/choice bodies are only
|
||||
# retained when content capture is enabled (see ``capture_span_content``);
|
||||
# otherwise the content-bearing mappers receive empty sequences and emit
|
||||
# no prompt/response text.
|
||||
finish_reasons = _finish_reasons(choices_out)
|
||||
return cls(
|
||||
operation=resolve_operation(as_str(payload.get("call_type"))),
|
||||
provider=resolve_provider(as_str(payload.get("custom_llm_provider"))),
|
||||
request_model=context.request_model,
|
||||
response_model=context.response_model,
|
||||
response_id=as_str(response.get("id")),
|
||||
request_params=LLMRequestParams.from_model_parameters(params),
|
||||
usage=LLMUsage(
|
||||
input_tokens=as_int(payload.get("prompt_tokens")),
|
||||
output_tokens=as_int(payload.get("completion_tokens")),
|
||||
total_tokens=as_int(payload.get("total_tokens")),
|
||||
),
|
||||
finish_reasons=finish_reasons,
|
||||
error=_parse_error(payload),
|
||||
response_cost=as_float(payload.get("response_cost")),
|
||||
server=ServerInfo.from_api_base(context.api_base),
|
||||
identity=context.identity,
|
||||
is_streaming=as_bool(payload.get("stream")),
|
||||
tools=_extract_tools(params),
|
||||
messages_in=_dicts(payload.get("messages")) if capture_content else (),
|
||||
choices_out=choices_out if capture_content else (),
|
||||
system_fingerprint=as_str(response.get("system_fingerprint")),
|
||||
)
|
||||
|
||||
|
||||
# --- service event_metadata sanitization ------------------------------------ #
|
||||
|
||||
# Substrings (case-insensitive) of keys that must never reach a span: secrets,
|
||||
# tokens, and raw request/response dumps the legacy service decorators pass.
|
||||
_SENSITIVE_METADATA_SUBSTRINGS: tuple[str, ...] = (
|
||||
"api_key",
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"cookie",
|
||||
"authorization",
|
||||
"header",
|
||||
"hidden_params",
|
||||
)
|
||||
# Keys that carry raw call-site internals — live objects, full kwargs/args. The
|
||||
# operation name is already the span's ``call_type``, so ``function_name`` is
|
||||
# redundant.
|
||||
_DROP_METADATA_KEYS: frozenset = frozenset(
|
||||
{"function_kwargs", "function_args", "function_name"}
|
||||
)
|
||||
_MAX_METADATA_VALUE_LEN = 1024
|
||||
_MAX_METADATA_ITEMS = 32
|
||||
|
||||
|
||||
def sanitize_event_metadata(
|
||||
event_metadata: Mapping[str, object] | None,
|
||||
) -> dict[str, str]:
|
||||
"""Reduce caller-supplied ``event_metadata`` to span-safe string attributes.
|
||||
|
||||
Keeps only primitive values (str/int/float/bool) under non-sensitive keys —
|
||||
never ``repr()``-ing objects, dicts, or lists, never stamping secrets/headers,
|
||||
and bounding the count and per-value length. This is the single chokepoint:
|
||||
both the GenAI and legacy mappers read the cleaned result.
|
||||
"""
|
||||
if not event_metadata:
|
||||
return {}
|
||||
clean: dict[str, str] = {}
|
||||
for key, value in event_metadata.items():
|
||||
if len(clean) >= _MAX_METADATA_ITEMS:
|
||||
break
|
||||
if not isinstance(key, str) or key in _DROP_METADATA_KEYS:
|
||||
continue
|
||||
lowered = key.lower()
|
||||
if any(token in lowered for token in _SENSITIVE_METADATA_SUBSTRINGS):
|
||||
continue
|
||||
# ``bool`` is a subclass of ``int``, so it's covered. Non-primitive values
|
||||
# (objects, dicts, lists) are dropped rather than stringified.
|
||||
if isinstance(value, (str, int, float)):
|
||||
clean[key] = str(value)[:_MAX_METADATA_VALUE_LEN]
|
||||
return clean
|
||||
|
||||
|
||||
def _json_or_none(value: object) -> str | None:
|
||||
"""JSON-serialize ``value`` (already-string values pass through). ``None`` on failure."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.dumps(value, default=str)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _guardrail_mode_str(value: object) -> str | None:
|
||||
"""Normalize ``guardrail_mode`` to a stable string.
|
||||
|
||||
``guardrail_mode`` is typed as a ``GuardrailEventHooks`` enum, a list of them,
|
||||
or a ``GuardrailMode`` — not a plain string. Emit the enum *value* (e.g.
|
||||
``"pre_call"``) rather than ``str(enum)`` (``"GuardrailEventHooks.pre_call"``),
|
||||
and join a list of modes so a guardrail that runs at multiple hooks is
|
||||
represented faithfully.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (list, tuple)):
|
||||
parts: list[str] = []
|
||||
for item in value:
|
||||
if item is None:
|
||||
continue
|
||||
part = as_str(item.value) if isinstance(item, Enum) else as_str(item)
|
||||
if part:
|
||||
parts.append(part)
|
||||
return ",".join(parts) or None
|
||||
if isinstance(value, Enum):
|
||||
return as_str(value.value)
|
||||
return as_str(value)
|
||||
|
||||
|
||||
def _total_masked_entities(value: object) -> int | None:
|
||||
"""``masked_entity_count`` is a ``{entity_type: count}`` map — sum to a total."""
|
||||
if isinstance(value, Mapping):
|
||||
total = sum(v for v in value.values() if isinstance(v, int))
|
||||
return total or None
|
||||
return as_int(value)
|
||||
|
||||
|
||||
def _dicts(value: object) -> tuple[Mapping[str, object], ...]:
|
||||
"""The dict items of ``value`` (when it's a list), as a tuple. Else empty."""
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
return tuple(item for item in value if isinstance(item, dict))
|
||||
|
||||
|
||||
def _finish_reasons(choices: tuple[Mapping[str, object], ...]) -> tuple[str, ...]:
|
||||
"""Non-empty ``finish_reason`` of each response choice."""
|
||||
return tuple(r for c in choices if (r := as_str(c.get("finish_reason"))))
|
||||
|
||||
|
||||
def _parse_error(payload: "StandardLoggingPayload") -> SpanError | None:
|
||||
"""A ``SpanError`` for a failed request, or ``None`` on success."""
|
||||
if payload.get("status") != "failure":
|
||||
return None
|
||||
info = cast(Mapping[str, object], payload.get("error_information") or {})
|
||||
return SpanError(
|
||||
error_type=as_str(info.get("error_class")) or as_str(info.get("error_code")),
|
||||
message=as_str(info.get("error_message")) or as_str(payload.get("error_str")),
|
||||
)
|
||||
|
||||
|
||||
def _tool_from_entry(entry: object) -> ToolDefinition | None:
|
||||
"""One ``tools``/``functions`` entry → ``ToolDefinition``, or ``None`` if unusable."""
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
fn = entry.get("function") if "function" in entry else entry
|
||||
if not isinstance(fn, dict):
|
||||
return None
|
||||
name = as_str(fn.get("name"))
|
||||
if not name:
|
||||
return None
|
||||
params = fn.get("parameters")
|
||||
parameters_json: str | None = None
|
||||
if params is not None:
|
||||
try:
|
||||
parameters_json = json.dumps(params, default=str)
|
||||
except Exception:
|
||||
parameters_json = None
|
||||
return ToolDefinition(
|
||||
name=name,
|
||||
description=as_str(fn.get("description")),
|
||||
parameters_json=parameters_json,
|
||||
)
|
||||
|
||||
|
||||
def _extract_tools(
|
||||
model_parameters: Mapping[str, object],
|
||||
) -> tuple[ToolDefinition, ...]:
|
||||
"""Pull declared tools from request params (OpenAI / Anthropic shape).
|
||||
|
||||
Accepts the chat-completion ``tools=[{"type":"function", "function":
|
||||
{...}}, ...]`` shape, and falls back to the ``functions=[...]`` shape.
|
||||
Returns an empty tuple when neither is present.
|
||||
"""
|
||||
raw_tools = model_parameters.get("tools")
|
||||
if not isinstance(raw_tools, list):
|
||||
raw_tools = model_parameters.get("functions") # ``functions`` shape
|
||||
if not isinstance(raw_tools, list):
|
||||
return ()
|
||||
return tuple(t for entry in raw_tools if (t := _tool_from_entry(entry)) is not None)
|
||||
201
litellm/integrations/otel/model/semconv.py
Normal file
201
litellm/integrations/otel/model/semconv.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""
|
||||
Keys follow the OpenTelemetry GenAI semantic conventions (experimental). Anything
|
||||
without a semconv equivalent lives under the ``litellm.*`` vendor namespace.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Final
|
||||
|
||||
|
||||
class GenAIOperation(str, Enum):
|
||||
"""Values for ``gen_ai.operation.name``."""
|
||||
|
||||
CHAT = "chat"
|
||||
TEXT_COMPLETION = "text_completion"
|
||||
EMBEDDINGS = "embeddings"
|
||||
GENERATE_CONTENT = "generate_content"
|
||||
CREATE_AGENT = "create_agent" # reserved for future agent spans
|
||||
INVOKE_AGENT = "invoke_agent" # reserved for future agent spans
|
||||
EXECUTE_TOOL = "execute_tool" # reserved for future tool spans
|
||||
|
||||
|
||||
class GenAIProvider(str, Enum):
|
||||
"""Common values for the ``gen_ai.provider.name`` attribute."""
|
||||
|
||||
OPENAI = "openai"
|
||||
ANTHROPIC = "anthropic"
|
||||
AWS_BEDROCK = "aws.bedrock"
|
||||
AZURE_AI_OPENAI = "azure.ai.openai"
|
||||
AZURE_AI_INFERENCE = "azure.ai.inference"
|
||||
GCP_GEMINI = "gcp.gemini"
|
||||
GCP_VERTEX_AI = "gcp.vertex_ai"
|
||||
COHERE = "cohere"
|
||||
MISTRAL_AI = "mistral_ai"
|
||||
DEEPSEEK = "deepseek"
|
||||
GROQ = "groq"
|
||||
PERPLEXITY = "perplexity"
|
||||
X_AI = "x_ai"
|
||||
IBM_WATSONX_AI = "ibm.watsonx.ai"
|
||||
|
||||
|
||||
class GenAI:
|
||||
"""Canonical OTel GenAI span-attribute keys."""
|
||||
|
||||
# request
|
||||
OPERATION_NAME: Final = "gen_ai.operation.name"
|
||||
PROVIDER_NAME: Final = "gen_ai.provider.name"
|
||||
REQUEST_MODEL: Final = "gen_ai.request.model"
|
||||
REQUEST_TEMPERATURE: Final = "gen_ai.request.temperature"
|
||||
REQUEST_TOP_P: Final = "gen_ai.request.top_p"
|
||||
REQUEST_TOP_K: Final = "gen_ai.request.top_k"
|
||||
REQUEST_MAX_TOKENS: Final = "gen_ai.request.max_tokens"
|
||||
REQUEST_FREQUENCY_PENALTY: Final = "gen_ai.request.frequency_penalty"
|
||||
REQUEST_PRESENCE_PENALTY: Final = "gen_ai.request.presence_penalty"
|
||||
REQUEST_STOP_SEQUENCES: Final = "gen_ai.request.stop_sequences"
|
||||
REQUEST_SEED: Final = "gen_ai.request.seed"
|
||||
REQUEST_CHOICE_COUNT: Final = "gen_ai.request.choice.count"
|
||||
REQUEST_ENCODING_FORMATS: Final = "gen_ai.request.encoding_formats"
|
||||
# response
|
||||
RESPONSE_ID: Final = "gen_ai.response.id"
|
||||
RESPONSE_MODEL: Final = "gen_ai.response.model"
|
||||
RESPONSE_FINISH_REASONS: Final = "gen_ai.response.finish_reasons"
|
||||
# usage
|
||||
USAGE_INPUT_TOKENS: Final = "gen_ai.usage.input_tokens"
|
||||
USAGE_OUTPUT_TOKENS: Final = "gen_ai.usage.output_tokens"
|
||||
# content (opt-in, gated by capture mode)
|
||||
INPUT_MESSAGES: Final = "gen_ai.input.messages"
|
||||
OUTPUT_MESSAGES: Final = "gen_ai.output.messages"
|
||||
SYSTEM_INSTRUCTIONS: Final = "gen_ai.system_instructions"
|
||||
OUTPUT_TYPE: Final = "gen_ai.output.type"
|
||||
CONVERSATION_ID: Final = "gen_ai.conversation.id"
|
||||
# agent / tool (reserved)
|
||||
AGENT_ID: Final = "gen_ai.agent.id"
|
||||
AGENT_NAME: Final = "gen_ai.agent.name"
|
||||
TOOL_NAME: Final = "gen_ai.tool.name"
|
||||
TOOL_CALL_ID: Final = "gen_ai.tool.call.id"
|
||||
|
||||
|
||||
class Error:
|
||||
TYPE: Final = "error.type"
|
||||
|
||||
|
||||
class Server:
|
||||
ADDRESS: Final = "server.address"
|
||||
PORT: Final = "server.port"
|
||||
|
||||
|
||||
class DB:
|
||||
"""Database / cache client-span keys (OTel ``db.*`` semconv).
|
||||
|
||||
Stamped on ``DB_CALL`` spans (redis / postgres), which are CLIENT spans for
|
||||
outbound datastore calls — not on the INTERNAL ``SERVICE`` spans.
|
||||
"""
|
||||
|
||||
SYSTEM_NAME: Final = "db.system.name"
|
||||
OPERATION_NAME: Final = "db.operation.name"
|
||||
|
||||
|
||||
class HTTP:
|
||||
"""HTTP server-span keys. Belong on the SERVER span only (never promoted)."""
|
||||
|
||||
REQUEST_METHOD: Final = "http.request.method"
|
||||
ROUTE: Final = "http.route"
|
||||
RESPONSE_STATUS_CODE: Final = "http.response.status_code"
|
||||
URL_PATH: Final = "url.path"
|
||||
|
||||
|
||||
class LiteLLM:
|
||||
"""Vendor-extension keys (no semconv equivalent). Always ``litellm.*``."""
|
||||
|
||||
CALL_ID: Final = "litellm.call_id"
|
||||
COST_PREFIX: Final = "litellm.cost."
|
||||
METADATA_PREFIX: Final = "litellm.metadata."
|
||||
TEAM_ID: Final = "litellm.team.id"
|
||||
TEAM_ALIAS: Final = "litellm.team.alias"
|
||||
# The team's free-form metadata dict, JSON-serialized into a single value.
|
||||
TEAM_METADATA: Final = "litellm.team.metadata"
|
||||
KEY_HASH: Final = "litellm.api_key.hash"
|
||||
END_USER: Final = "litellm.end_user.id"
|
||||
# The model string litellm actually sent to the provider (the deployment's
|
||||
# ``litellm_params.model``), distinct from the user-facing ``gen_ai.request.model``.
|
||||
PROVIDER_MODEL: Final = "litellm.provider.model"
|
||||
REQUEST_STREAMING: Final = "litellm.request.streaming"
|
||||
GUARDRAIL_NAME: Final = "litellm.guardrail.name"
|
||||
GUARDRAIL_MODE: Final = "litellm.guardrail.mode"
|
||||
GUARDRAIL_STATUS: Final = "litellm.guardrail.status"
|
||||
GUARDRAIL_PROVIDER: Final = "litellm.guardrail.provider"
|
||||
GUARDRAIL_ACTION: Final = "litellm.guardrail.action"
|
||||
GUARDRAIL_RESPONSE: Final = "litellm.guardrail.response"
|
||||
GUARDRAIL_VIOLATION_CATEGORIES: Final = "litellm.guardrail.violation_categories"
|
||||
GUARDRAIL_CONFIDENCE_SCORE: Final = "litellm.guardrail.confidence_score"
|
||||
GUARDRAIL_RISK_SCORE: Final = "litellm.guardrail.risk_score"
|
||||
GUARDRAIL_MASKED_ENTITY_COUNT: Final = "litellm.guardrail.masked_entity_count"
|
||||
GUARDRAIL_DURATION: Final = "litellm.guardrail.duration"
|
||||
GUARDRAIL_ID: Final = "litellm.guardrail.id"
|
||||
GUARDRAIL_POLICY_TEMPLATE: Final = "litellm.guardrail.policy_template"
|
||||
GUARDRAIL_DETECTION_METHOD: Final = "litellm.guardrail.detection_method"
|
||||
SERVICE_NAME: Final = "litellm.service.name"
|
||||
SERVICE_CALL_TYPE: Final = "litellm.service.call_type"
|
||||
PREPROCESSING_MS: Final = "litellm.preprocessing.duration_ms"
|
||||
|
||||
|
||||
class Metric:
|
||||
"""GenAI metric instrument names."""
|
||||
|
||||
TOKEN_USAGE: Final = "gen_ai.client.token.usage"
|
||||
OPERATION_DURATION: Final = "gen_ai.client.operation.duration"
|
||||
|
||||
|
||||
# litellm ``custom_llm_provider`` -> ``gen_ai.provider.name`` value.
|
||||
_PROVIDER_BY_LITELLM: dict[str, GenAIProvider] = {
|
||||
"openai": GenAIProvider.OPENAI,
|
||||
"text-completion-openai": GenAIProvider.OPENAI,
|
||||
"azure": GenAIProvider.AZURE_AI_OPENAI,
|
||||
"azure_ai": GenAIProvider.AZURE_AI_INFERENCE,
|
||||
"anthropic": GenAIProvider.ANTHROPIC,
|
||||
"bedrock": GenAIProvider.AWS_BEDROCK,
|
||||
"bedrock_converse": GenAIProvider.AWS_BEDROCK,
|
||||
"vertex_ai": GenAIProvider.GCP_VERTEX_AI,
|
||||
"vertex_ai_beta": GenAIProvider.GCP_VERTEX_AI,
|
||||
"gemini": GenAIProvider.GCP_GEMINI,
|
||||
"cohere": GenAIProvider.COHERE,
|
||||
"cohere_chat": GenAIProvider.COHERE,
|
||||
"mistral": GenAIProvider.MISTRAL_AI,
|
||||
"deepseek": GenAIProvider.DEEPSEEK,
|
||||
"groq": GenAIProvider.GROQ,
|
||||
"perplexity": GenAIProvider.PERPLEXITY,
|
||||
"xai": GenAIProvider.X_AI,
|
||||
"watsonx": GenAIProvider.IBM_WATSONX_AI,
|
||||
}
|
||||
|
||||
# litellm ``call_type`` -> ``gen_ai.operation.name``.
|
||||
_OPERATION_BY_CALL_TYPE: dict[str, GenAIOperation] = {
|
||||
"completion": GenAIOperation.CHAT,
|
||||
"acompletion": GenAIOperation.CHAT,
|
||||
"completion_with_retries": GenAIOperation.CHAT,
|
||||
"text_completion": GenAIOperation.TEXT_COMPLETION,
|
||||
"atext_completion": GenAIOperation.TEXT_COMPLETION,
|
||||
"embedding": GenAIOperation.EMBEDDINGS,
|
||||
"aembedding": GenAIOperation.EMBEDDINGS,
|
||||
"responses": GenAIOperation.CHAT,
|
||||
"aresponses": GenAIOperation.CHAT,
|
||||
}
|
||||
|
||||
|
||||
def resolve_provider(custom_llm_provider: str | None) -> str:
|
||||
"""Map a litellm provider string to a ``gen_ai.provider.name`` value.
|
||||
|
||||
Unknown providers pass through verbatim — the convention explicitly allows
|
||||
provider-specific values, so an unmapped name is still valid.
|
||||
"""
|
||||
if not custom_llm_provider:
|
||||
return ""
|
||||
mapped = _PROVIDER_BY_LITELLM.get(custom_llm_provider.lower())
|
||||
return mapped.value if mapped is not None else custom_llm_provider
|
||||
|
||||
|
||||
def resolve_operation(call_type: str | None) -> GenAIOperation:
|
||||
"""Map a litellm ``call_type`` to a ``gen_ai.operation.name`` value."""
|
||||
if not call_type:
|
||||
return GenAIOperation.CHAT
|
||||
return _OPERATION_BY_CALL_TYPE.get(call_type.lower(), GenAIOperation.CHAT)
|
||||
203
litellm/integrations/otel/model/spans.py
Normal file
203
litellm/integrations/otel/model/spans.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""
|
||||
This module declares every span the instrumentation can emit and the hierarchy.
|
||||
|
||||
Span-name patterns live here as typed builder functions.
|
||||
|
||||
Canonical hierarchy::
|
||||
|
||||
PROXY_REQUEST (SERVER, root) # owned by the FastAPI instrumentor
|
||||
├── SERVICE (INTERNAL) # auth phase span (live; see logger.phase_span)
|
||||
│ └── DB_CALL (CLIENT) # its key/user/team lookups nest here
|
||||
├── GUARDRAIL (INTERNAL) # request-lifecycle hook, sibling of LLM_CALL
|
||||
├── LLM_CALL (CLIENT)
|
||||
└── DB_CALL (CLIENT) # e.g. the spend-log write
|
||||
|
||||
Guardrails parent to PROXY_REQUEST, not LLM_CALL: pre/during/post-call guardrail
|
||||
hooks are orchestrated by the request lifecycle (a pre-call guardrail runs
|
||||
before the LLM call even starts), so a guardrail is a sibling of the LLM call,
|
||||
not a child of it. The emitter parents every span to the ambient OTel context
|
||||
(the active server span), which matches this.
|
||||
|
||||
Not every service call becomes a span — :func:`span_role_for_service` decides:
|
||||
|
||||
- ``DB_CALL`` (CLIENT) — outbound datastores (redis, postgres,
|
||||
``batch_write_to_db``), carrying ``db.*`` semconv.
|
||||
- ``SERVICE`` (INTERNAL) — genuine internal work worth a span (background
|
||||
budget/reset jobs, pod-lock manager).
|
||||
- ``None`` (metrics-only) — framework instrumentation that duplicates a gen-AI
|
||||
span (``self`` = the ``track_llm_api_timing`` wrapper, ``router``,
|
||||
``proxy_pre_call``) or ``auth`` (which gets a live phase span instead). These
|
||||
still feed Prometheus/Datadog; they just never enter the trace.
|
||||
|
||||
``DB_CALL`` and ``SERVICE`` are built from the same ``ServiceSpanData``; only the
|
||||
role (hence span kind and attribute vocabulary) differs. A service call can fire
|
||||
outside any request (a background job), in which case it parents to no server
|
||||
span and starts its own root trace rather than being dropped.
|
||||
|
||||
Management/admin endpoints are ordinary FastAPI routes — their SERVER spans are
|
||||
owned by the instrumentor too, so they don't appear as a role here.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.integrations.otel.model.payloads import (
|
||||
GuardrailSpanData,
|
||||
LLMCallSpanData,
|
||||
ProxyRequestSpanData,
|
||||
ServiceSpanData,
|
||||
)
|
||||
|
||||
|
||||
class SpanRole(str, Enum):
|
||||
PROXY_REQUEST = "proxy_request"
|
||||
LLM_CALL = "llm_call"
|
||||
GUARDRAIL = "guardrail"
|
||||
DB_CALL = "db_call"
|
||||
SERVICE = "service"
|
||||
|
||||
|
||||
class LiteLLMSpanKind(str, Enum):
|
||||
SERVER = "server"
|
||||
CLIENT = "client"
|
||||
INTERNAL = "internal"
|
||||
PRODUCER = "producer"
|
||||
CONSUMER = "consumer"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpanSpec:
|
||||
role: SpanRole
|
||||
kind: LiteLLMSpanKind
|
||||
parent: SpanRole | None
|
||||
|
||||
|
||||
SPAN_REGISTRY: dict[SpanRole, SpanSpec] = {
|
||||
SpanRole.PROXY_REQUEST: SpanSpec(
|
||||
SpanRole.PROXY_REQUEST, LiteLLMSpanKind.SERVER, parent=None
|
||||
),
|
||||
SpanRole.LLM_CALL: SpanSpec(
|
||||
SpanRole.LLM_CALL, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST
|
||||
),
|
||||
SpanRole.GUARDRAIL: SpanSpec(
|
||||
SpanRole.GUARDRAIL, LiteLLMSpanKind.INTERNAL, parent=SpanRole.PROXY_REQUEST
|
||||
),
|
||||
SpanRole.DB_CALL: SpanSpec(
|
||||
SpanRole.DB_CALL, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST
|
||||
),
|
||||
SpanRole.SERVICE: SpanSpec(
|
||||
SpanRole.SERVICE, LiteLLMSpanKind.INTERNAL, parent=SpanRole.PROXY_REQUEST
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ``ServiceTypes`` value -> ``db.system.name``. These are outbound datastore
|
||||
# calls and become CLIENT ``DB_CALL`` spans; ``redis_``-prefixed names cover the
|
||||
# redis-backed spend queues. Any service not mapped here is litellm-internal work
|
||||
# and stays an INTERNAL ``SERVICE`` span. This table is the single source of
|
||||
# datastore knowledge — both the role classifier and the mapper read it.
|
||||
_DB_SYSTEM_BY_SERVICE: dict[str, str] = {
|
||||
"redis": "redis",
|
||||
"postgres": "postgresql",
|
||||
"batch_write_to_db": "postgresql",
|
||||
}
|
||||
|
||||
|
||||
def db_system(service_name: str) -> str | None:
|
||||
"""The ``db.system.name`` for a datastore service, else ``None``.
|
||||
|
||||
``None`` means the service is not an outbound datastore call. Redis-backed
|
||||
spend queues (``redis_*``) map to ``redis``.
|
||||
"""
|
||||
if service_name in _DB_SYSTEM_BY_SERVICE:
|
||||
return _DB_SYSTEM_BY_SERVICE[service_name]
|
||||
if service_name.startswith("redis_"):
|
||||
return "redis"
|
||||
return None
|
||||
|
||||
|
||||
# ``ServiceTypes`` values that are NOT emitted as spans — they are framework
|
||||
# instrumentation that either duplicates a gen-AI span or has a better home as a
|
||||
# Prometheus/Datadog metric. They still flow to those metric backends via their
|
||||
# own hooks; the v2 logger just does not put them in the trace:
|
||||
#
|
||||
# - ``self`` — ``track_llm_api_timing`` wraps the LLM call; the
|
||||
# ``chat {model}`` CLIENT span already represents it.
|
||||
# - ``router`` — wraps the whole request; duplicates the server span.
|
||||
# - ``proxy_pre_call`` — per-callback pre-call timing; a guardrail's real span
|
||||
# is ``execute_guardrail {name}``.
|
||||
# - ``auth`` — emitted instead as a live phase span (see
|
||||
# ``logger.phase_span``) so its DB lookups nest under it,
|
||||
# not as a flat post-hoc service span.
|
||||
_METRICS_ONLY_SERVICES: frozenset[str] = frozenset(
|
||||
{"self", "router", "proxy_pre_call", "auth"}
|
||||
)
|
||||
|
||||
|
||||
def span_role_for_service(service_name: str) -> SpanRole | None:
|
||||
"""The span role for a service call, or ``None`` when it must not be a span.
|
||||
|
||||
``DB_CALL`` for outbound datastores, ``SERVICE`` for genuine internal work
|
||||
worth a span (background jobs), and ``None`` for framework instrumentation
|
||||
that duplicates a gen-AI span or belongs in metrics only
|
||||
(see ``_METRICS_ONLY_SERVICES``).
|
||||
"""
|
||||
if service_name in _METRICS_ONLY_SERVICES:
|
||||
return None
|
||||
return SpanRole.DB_CALL if db_system(service_name) is not None else SpanRole.SERVICE
|
||||
|
||||
|
||||
# --- span name builders (the naming convention, per role) ------------------- #
|
||||
|
||||
|
||||
# The name the FastAPI instrumentor gives the root server span. V2 never creates
|
||||
# this span (the instrumentor owns it), but it anchors request-level spans to it
|
||||
# and tests assert against it by name, so the literal lives here with the rest of
|
||||
# the span vocabulary rather than being duplicated at each call site.
|
||||
LITELLM_PROXY_REQUEST_SPAN_NAME = "Received Proxy Server Request"
|
||||
|
||||
|
||||
def llm_call_span_name(data: "LLMCallSpanData") -> str:
|
||||
"""``"{operation} {model}"`` e.g. ``"chat gpt-4o"`` (GenAI semconv)."""
|
||||
model = data.request_model or ""
|
||||
return f"{data.operation.value} {model}".strip()
|
||||
|
||||
|
||||
def proxy_request_span_name(data: "ProxyRequestSpanData") -> str:
|
||||
"""``"{method} {route}"`` (HTTP semconv)."""
|
||||
return f"{data.http_method} {data.route}".strip()
|
||||
|
||||
|
||||
def guardrail_span_name(data: "GuardrailSpanData") -> str:
|
||||
return f"execute_guardrail {data.guardrail_name}".strip()
|
||||
|
||||
|
||||
def service_span_name(data: "ServiceSpanData") -> str:
|
||||
"""``"{service} {call_type}"`` e.g. ``"redis set"`` — service name alone when
|
||||
no call type is known, so identically-named calls stay distinguishable."""
|
||||
return f"{data.service_name} {data.call_type or ''}".strip()
|
||||
|
||||
|
||||
def root_roles() -> list[SpanRole]:
|
||||
"""Roles that start a new trace (no in-process parent)."""
|
||||
return [role for role, spec in SPAN_REGISTRY.items() if spec.parent is None]
|
||||
|
||||
|
||||
def child_roles(parent: SpanRole) -> list[SpanRole]:
|
||||
return [role for role, spec in SPAN_REGISTRY.items() if spec.parent == parent]
|
||||
|
||||
|
||||
def validate_registry(
|
||||
registry: dict[SpanRole, SpanSpec] | None = None,
|
||||
) -> None:
|
||||
reg = registry if registry is not None else SPAN_REGISTRY
|
||||
for role, spec in reg.items():
|
||||
if spec.role is not role:
|
||||
raise ValueError(f"SPAN_REGISTRY[{role}] has mismatched role {spec.role}")
|
||||
if spec.parent is not None and spec.parent not in reg:
|
||||
raise ValueError(f"span role {role} declares unknown parent {spec.parent}")
|
||||
missing = [role for role in SpanRole if role not in reg]
|
||||
if missing:
|
||||
raise ValueError(f"SPAN_REGISTRY is missing roles: {missing}")
|
||||
103
litellm/integrations/otel/model/utils.py
Normal file
103
litellm/integrations/otel/model/utils.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Shared, OpenTelemetry-free helpers for the otel integration.
|
||||
|
||||
Generic value coercion (for reading heterogeneous logging-payload dicts), time
|
||||
conversion, and header parsing — pulled out of the individual modules so they
|
||||
live in one place. Deliberately free of any ``opentelemetry`` import so the
|
||||
OTel-free sources of truth (payloads, semconv, spans, config) can use it too.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def as_str(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def as_int(value: object) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return int(value)
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def as_float(value: object) -> float | None:
|
||||
if isinstance(value, bool):
|
||||
return float(value)
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def as_bool(value: object) -> bool | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return bool(value)
|
||||
|
||||
|
||||
def as_str_tuple(value: object) -> tuple[str, ...] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return (value,)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return tuple(str(v) for v in value)
|
||||
return None
|
||||
|
||||
|
||||
def to_ns(value: datetime | float | int | None) -> int | None:
|
||||
"""Coerce a datetime / epoch value to integer nanoseconds."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return int(value.timestamp() * 1e9)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return int(float(value) * 1e9)
|
||||
return None
|
||||
|
||||
|
||||
def to_seconds(value: datetime | float | int | str | None) -> float | None:
|
||||
"""Coerce a datetime / epoch / formatted-string value to epoch seconds."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.timestamp()
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(value, fmt).timestamp()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def parse_headers(raw: str | None) -> dict[str, str]:
|
||||
"""Parse an OTLP ``"k=v,k=v"`` header string into a dict."""
|
||||
headers: dict[str, str] = {}
|
||||
if not raw:
|
||||
return headers
|
||||
for pair in raw.split(","):
|
||||
if "=" in pair:
|
||||
key, _, value = pair.partition("=")
|
||||
headers[key.strip()] = value.strip()
|
||||
return headers
|
||||
130
litellm/integrations/otel/mount.py
Normal file
130
litellm/integrations/otel/mount.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""FastAPI server-span instrumentation — the proxy mounts this at app creation.
|
||||
|
||||
``opentelemetry-instrumentation-fastapi`` creates the SERVER span for each HTTP
|
||||
route and extracts inbound ``traceparent`` headers. This module owns the one call
|
||||
site that attaches it to the proxy app, plus the passthrough span-naming hook, so
|
||||
``proxy_server`` stays free of OTel details.
|
||||
|
||||
The ``FastAPIInstrumentor`` import is kept lazy (inside :func:`instrument_fastapi_app`,
|
||||
after the gate check) so importing this module never requires the optional
|
||||
``opentelemetry-instrumentation-fastapi`` package and pulls in nothing OTel-related
|
||||
when the feature gate is off.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.otel.model.config import is_otel_v2_enabled
|
||||
|
||||
# Routes excluded from server-span tracing by default: high-frequency pollers and
|
||||
# static UI/docs assets, none of which are LLM traffic. Entries are substring-matched
|
||||
# against the request path (unanchored, so they survive a ``server_root_path`` prefix
|
||||
# and each entry also covers everything beneath it — e.g. ``/health`` covers
|
||||
# ``/health/readiness``). Operators override the whole set via the standard
|
||||
# ``OTEL_PYTHON_FASTAPI_EXCLUDED_URLS`` env var (set "" to trace everything).
|
||||
_DEFAULT_EXCLUDED_ROUTES = (
|
||||
"/health", # load-balancer liveness/readiness polling
|
||||
"/metrics", # Prometheus scrape (also drops the /model/metrics admin analytics)
|
||||
"/litellm-asset-prefix", # hashed UI asset bundles
|
||||
"/_next", # Next.js static JS/CSS chunks (root-level mount)
|
||||
"/ui", # admin UI single-page app
|
||||
"/swagger", # static Swagger UI assets
|
||||
"/docs", # FastAPI Swagger docs page
|
||||
"/redoc", # FastAPI ReDoc docs page
|
||||
"/openapi.json", # OpenAPI schema
|
||||
"favicon", # /favicon.ico + /get_favicon
|
||||
"/.well-known", # UI config discovery
|
||||
)
|
||||
_DEFAULT_EXCLUDED_URLS = ",".join(_DEFAULT_EXCLUDED_ROUTES)
|
||||
|
||||
# Passthrough routes are catch-alls (e.g. "/openai/{endpoint:path}"), so the
|
||||
# default OTel server-span name "{method} {route}" collapses every upstream
|
||||
# endpoint into "POST /openai/{endpoint:path}". The hook below renames those spans
|
||||
# to the real request path so each endpoint is distinguishable. Non-catch-all
|
||||
# routes keep their low-cardinality template name.
|
||||
PASSTHROUGH_PREFIXES = frozenset(
|
||||
{
|
||||
"openai",
|
||||
"openai_passthrough",
|
||||
"anthropic",
|
||||
"azure",
|
||||
"azure_ai",
|
||||
"bedrock",
|
||||
"cohere",
|
||||
"cursor",
|
||||
"gemini",
|
||||
"mistral",
|
||||
"vllm",
|
||||
"vertex_ai",
|
||||
"vertex-ai",
|
||||
"assemblyai",
|
||||
"eu.assemblyai",
|
||||
"milvus",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _passthrough_span_name_hook(span: Any, scope: dict) -> None:
|
||||
"""FastAPI ``server_request_hook``: give passthrough server spans a useful name.
|
||||
|
||||
The instrumentation matches the route at span creation, so both the span name
|
||||
and ``http.route`` are set to the catch-all template (``/openai/{endpoint:path}``)
|
||||
before this hook runs. Rewrite both to the real request path so each upstream
|
||||
endpoint is distinguishable. (The ASGI ``http receive``/``http send`` sub-spans
|
||||
can't be renamed from here — their name is captured at creation — so they are
|
||||
dropped via ``exclude_spans`` at instrumentation time.)
|
||||
"""
|
||||
try:
|
||||
if span is None or not span.is_recording():
|
||||
return
|
||||
path = scope.get("path") or ""
|
||||
method = scope.get("method") or ""
|
||||
first_segment = path.lstrip("/").split("/", 1)[0]
|
||||
if first_segment in PASSTHROUGH_PREFIXES:
|
||||
span.update_name(f"{method} {path}".strip())
|
||||
span.set_attribute("http.route", path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def instrument_fastapi_app(app: Any) -> None:
|
||||
"""Attach OTel server-span instrumentation to the proxy FastAPI app.
|
||||
|
||||
Safe no-op when the V2 gate is off or ``opentelemetry-instrumentation-fastapi``
|
||||
is unavailable. This MUST be called at app-creation time — once the lifespan
|
||||
runs, the middleware stack is frozen and ``instrument_app`` raises "Cannot add
|
||||
middleware after an application has started".
|
||||
|
||||
No ``TracerProvider`` is passed, so the instrumentation binds to the OTel global
|
||||
``ProxyTracerProvider``; the proxy publishes the real provider as the global
|
||||
after config load (see ``proxy_startup_event``), and the proxy delegates to it.
|
||||
That way server spans and gen-ai spans share one provider and the same trace.
|
||||
"""
|
||||
try:
|
||||
if not is_otel_v2_enabled():
|
||||
return
|
||||
|
||||
# Lazy: only the V2-enabled path needs the optional
|
||||
# ``opentelemetry-instrumentation-fastapi`` package, which is not part of the
|
||||
# base ``litellm[proxy]`` install. Importing it at module top would make
|
||||
# ``proxy_server``'s unconditional ``import`` of this module crash when the
|
||||
# package is absent, even with the gate off.
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||
|
||||
excluded_urls = (
|
||||
os.environ.get("OTEL_PYTHON_FASTAPI_EXCLUDED_URLS")
|
||||
if "OTEL_PYTHON_FASTAPI_EXCLUDED_URLS" in os.environ
|
||||
else _DEFAULT_EXCLUDED_URLS
|
||||
)
|
||||
FastAPIInstrumentor.instrument_app(
|
||||
app,
|
||||
excluded_urls=excluded_urls,
|
||||
server_request_hook=_passthrough_span_name_hook,
|
||||
# Drop the ASGI "http receive"/"http send" lifecycle sub-spans: they
|
||||
# are low-value noise and (for passthrough) carry the catch-all route
|
||||
# template in their name, which can't be rewritten from a hook.
|
||||
exclude_spans=["receive", "send"],
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.debug("Skipping OTel V2 FastAPI instrumentation: %s", e)
|
||||
0
litellm/integrations/otel/plumbing/__init__.py
Normal file
0
litellm/integrations/otel/plumbing/__init__.py
Normal file
127
litellm/integrations/otel/plumbing/context.py
Normal file
127
litellm/integrations/otel/plumbing/context.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""Trace-context + Baggage helpers."""
|
||||
|
||||
from contextvars import ContextVar
|
||||
from typing import Mapping
|
||||
|
||||
from opentelemetry import baggage
|
||||
from opentelemetry.context import Context, get_current
|
||||
from opentelemetry.trace import Span, get_current_span, set_span_in_context
|
||||
from opentelemetry.trace.propagation.tracecontext import (
|
||||
TraceContextTextMapPropagator,
|
||||
)
|
||||
|
||||
_PROPAGATOR = TraceContextTextMapPropagator()
|
||||
|
||||
# The request's root span — the FastAPI-owned SERVER span — captured ONCE when the
|
||||
# proxy first resolves it, so request-level spans (the LLM call, guardrails) can
|
||||
# parent to it EXPLICITLY instead of to whatever span happens to be active at the
|
||||
# instant they are emitted. Ambient-only parenting (``get_current_span()``) is
|
||||
# wrong at two boundaries:
|
||||
# * inside the ``auth`` phase span the active span is the auth span, so an LLM /
|
||||
# guardrail span emitted there would nest under auth instead of being its
|
||||
# sibling; and
|
||||
# * in a detached success task (pass-through logs success from a fire-and-forget
|
||||
# ``asyncio.create_task``) the server span may not be active at all, orphaning
|
||||
# the span into a brand-new trace.
|
||||
# A ``ContextVar`` (not a request attribute) so it rides the request task's context
|
||||
# and is inherited by ``asyncio.create_task`` children — i.e. the async logging
|
||||
# callbacks that close the span. It is never reset: the contextvar dies with the
|
||||
# request task, so there is nothing to leak.
|
||||
_request_root_span: "ContextVar[Span | None]" = ContextVar(
|
||||
"litellm_otel_request_root_span", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_request_root_span(span: Span) -> None:
|
||||
"""Anchor the request's root (server) span for explicit child parenting.
|
||||
|
||||
No-ops for a non-recordable span so a bad capture can never replace a good one
|
||||
with a phantom parent. Idempotent — the proxy captures the same server span at
|
||||
more than one entry point.
|
||||
"""
|
||||
if is_recordable_span(span):
|
||||
_request_root_span.set(span)
|
||||
|
||||
|
||||
def request_root_span() -> "Span | None":
|
||||
"""The anchored request root span, or ``None`` outside a proxy request."""
|
||||
span = _request_root_span.get()
|
||||
return span if is_recordable_span(span) else None
|
||||
|
||||
|
||||
def set_request_baggage(
|
||||
values: Mapping[str, str], context: Context | None = None
|
||||
) -> Context:
|
||||
"""Return a context with ``values`` written into Baggage."""
|
||||
ctx = context
|
||||
for key, value in values.items():
|
||||
ctx = baggage.set_baggage(key, value, context=ctx)
|
||||
return ctx if ctx is not None else (context or get_current())
|
||||
|
||||
|
||||
def get_baggage_attributes(context: Context | None = None) -> dict[str, str]:
|
||||
"""All Baggage entries on ``context`` as strings."""
|
||||
return {key: str(value) for key, value in baggage.get_all(context).items()}
|
||||
|
||||
|
||||
def context_from_span(span: Span, context: Context | None = None) -> Context:
|
||||
"""A context with ``span`` as the active span (for explicit parenting)."""
|
||||
return set_span_in_context(span, context=context)
|
||||
|
||||
|
||||
def resolve_parent_context(threaded: Span | None = None) -> Context:
|
||||
"""The context a child span should parent under.
|
||||
|
||||
Ambient-first: parent to the active OTel context (the server span, restored
|
||||
by the logging worker or active in the request task), falling back to a span
|
||||
passed explicitly (``threaded``) only when the ambient context has no
|
||||
recordable span — e.g. a background service call with no request on the
|
||||
stack. When neither is recordable the ambient context is returned unchanged,
|
||||
so the span starts a new root trace.
|
||||
|
||||
Only service/DB spans pass ``threaded`` (the ``parent_otel_span`` handed to
|
||||
the service hook). Request-level spans — the LLM call and guardrails — are
|
||||
created where the server span is genuinely ambient, so they never need it.
|
||||
"""
|
||||
ctx = get_current()
|
||||
if is_recordable_span(threaded) and not is_recordable_span(get_current_span(ctx)):
|
||||
ctx = context_from_span(threaded, context=ctx) # type: ignore[arg-type]
|
||||
return ctx
|
||||
|
||||
|
||||
def resolve_request_span_context() -> Context:
|
||||
"""The parent context for a request-level span (the LLM call, a guardrail).
|
||||
|
||||
These are direct children of the request's root server span — siblings of the
|
||||
``auth`` phase span and of each other, never nested under whatever span is
|
||||
momentarily active. So prefer the explicitly anchored root span; fall back to
|
||||
ambient context only when there is no anchor (the SDK / no-proxy path), where
|
||||
the span legitimately starts its own root trace.
|
||||
|
||||
Unlike :func:`resolve_parent_context` (used by DB/service spans, which DO want
|
||||
to nest under the active phase span, e.g. an auth DB lookup under ``auth``),
|
||||
this never returns the active span when an anchor exists.
|
||||
"""
|
||||
root = request_root_span()
|
||||
if root is not None:
|
||||
return context_from_span(root)
|
||||
return get_current()
|
||||
|
||||
|
||||
def is_recordable_span(obj: object) -> bool:
|
||||
"""True if ``obj`` is a live span with a valid context (safe to parent under)."""
|
||||
if not isinstance(obj, Span):
|
||||
return False
|
||||
try:
|
||||
ctx = obj.get_span_context()
|
||||
except Exception:
|
||||
return False
|
||||
return ctx is not None and ctx.is_valid
|
||||
|
||||
|
||||
def extract_traceparent(headers: Mapping[str, str]) -> Context | None:
|
||||
"""Extract a remote parent context from incoming HTTP headers, if present."""
|
||||
if not any(key.lower() == "traceparent" for key in headers):
|
||||
return None
|
||||
carrier = {str(key).lower(): value for key, value in headers.items()}
|
||||
return _PROPAGATOR.extract(carrier)
|
||||
28
litellm/integrations/otel/plumbing/metrics.py
Normal file
28
litellm/integrations/otel/plumbing/metrics.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""GenAI client metrics (token usage + operation duration histograms)."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from opentelemetry.metrics import Histogram, Meter
|
||||
|
||||
from litellm.integrations.otel.model.semconv import Metric
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenAIMetrics:
|
||||
token_usage: Histogram
|
||||
operation_duration: Histogram
|
||||
|
||||
|
||||
def create_genai_metrics(meter: Meter) -> GenAIMetrics:
|
||||
return GenAIMetrics(
|
||||
token_usage=meter.create_histogram(
|
||||
name=Metric.TOKEN_USAGE,
|
||||
unit="{token}",
|
||||
description="Number of tokens used per GenAI request.",
|
||||
),
|
||||
operation_duration=meter.create_histogram(
|
||||
name=Metric.OPERATION_DURATION,
|
||||
unit="s",
|
||||
description="GenAI operation duration.",
|
||||
),
|
||||
)
|
||||
220
litellm/integrations/otel/plumbing/providers.py
Normal file
220
litellm/integrations/otel/plumbing/providers.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""Provider / exporter factory + the Baggage span processor."""
|
||||
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from opentelemetry import baggage
|
||||
from opentelemetry.context import Context
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider
|
||||
from opentelemetry.sdk.trace.export import (
|
||||
BatchSpanProcessor,
|
||||
ConsoleSpanExporter,
|
||||
SimpleSpanProcessor,
|
||||
SpanExporter,
|
||||
)
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
|
||||
InMemorySpanExporter,
|
||||
)
|
||||
from opentelemetry.trace import Span, SpanKind, Tracer
|
||||
|
||||
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.model.semconv import LiteLLM
|
||||
from litellm.integrations.otel.model.spans import LiteLLMSpanKind
|
||||
|
||||
# Re-exported so ``providers.parse_headers`` remains a stable entry point.
|
||||
from litellm.integrations.otel.model.utils import parse_headers as parse_headers
|
||||
|
||||
_SPAN_KIND_BY_ROLE_KIND: dict[LiteLLMSpanKind, SpanKind] = {
|
||||
LiteLLMSpanKind.SERVER: SpanKind.SERVER,
|
||||
LiteLLMSpanKind.CLIENT: SpanKind.CLIENT,
|
||||
LiteLLMSpanKind.INTERNAL: SpanKind.INTERNAL,
|
||||
LiteLLMSpanKind.PRODUCER: SpanKind.PRODUCER,
|
||||
LiteLLMSpanKind.CONSUMER: SpanKind.CONSUMER,
|
||||
}
|
||||
|
||||
|
||||
def to_otel_span_kind(kind: LiteLLMSpanKind) -> SpanKind:
|
||||
return _SPAN_KIND_BY_ROLE_KIND[kind]
|
||||
|
||||
|
||||
# Custom exporter factories keyed by ``ExporterSpec.kind``. A preset registers
|
||||
# one here when its destination needs construction logic the built-in kinds
|
||||
# can't express — e.g. an exporter that fetches an auth token lazily on its
|
||||
# first export (off the event loop) instead of blocking at config-build time.
|
||||
# Keeping the registry here lets this module stay vendor-agnostic: the factory
|
||||
# lives with the integration that needs it.
|
||||
_EXPORTER_FACTORIES: dict[str, Callable[[ExporterSpec], SpanExporter]] = {}
|
||||
|
||||
|
||||
def register_exporter_factory(
|
||||
kind: str, factory: Callable[[ExporterSpec], SpanExporter]
|
||||
) -> None:
|
||||
"""Register a custom exporter ``factory`` for the exporter ``kind``."""
|
||||
_EXPORTER_FACTORIES[kind.lower()] = factory
|
||||
|
||||
|
||||
class LiteLLMBaggageSpanProcessor(SpanProcessor):
|
||||
"""Stamps an allowlisted set of Baggage entries onto every span at start."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allowed_keys: Iterable[str],
|
||||
allowed_prefixes: tuple[str, ...] = (LiteLLM.METADATA_PREFIX,),
|
||||
) -> None:
|
||||
self._allowed_keys = frozenset(allowed_keys)
|
||||
self._allowed_prefixes = tuple(allowed_prefixes)
|
||||
|
||||
def _is_allowed(self, key: str) -> bool:
|
||||
return key in self._allowed_keys or any(
|
||||
key.startswith(prefix) for prefix in self._allowed_prefixes
|
||||
)
|
||||
|
||||
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
|
||||
for key, value in baggage.get_all(parent_context).items():
|
||||
if self._is_allowed(key) and isinstance(value, (str, bool, int, float)):
|
||||
span.set_attribute(key, value)
|
||||
|
||||
def on_end(self, span: ReadableSpan) -> None: # noqa: D401 - no-op
|
||||
return None
|
||||
|
||||
def shutdown(self) -> None:
|
||||
return None
|
||||
|
||||
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _otlp_traces_endpoint(endpoint: str | None) -> str | None:
|
||||
"""Point an OTLP/HTTP base endpoint at the ``/v1/traces`` signal path.
|
||||
|
||||
``OTEL_EXPORTER_OTLP_ENDPOINT`` is a base URL (e.g. ``http://host:4318``).
|
||||
The OTLP/HTTP exporter only appends the ``/v1/traces`` path when it reads
|
||||
that env var itself; when an endpoint is passed explicitly it is used
|
||||
verbatim, so a base URL would POST to the root and the collector returns
|
||||
404. Append the signal path here (leaving an already-correct path intact).
|
||||
"""
|
||||
if not endpoint:
|
||||
return endpoint
|
||||
endpoint = endpoint.rstrip("/")
|
||||
# Splunk Observability uses ``/v2/trace/otlp``; never rewrite it.
|
||||
if endpoint.endswith("/v1/traces") or "/v2/trace/otlp" in endpoint:
|
||||
return endpoint
|
||||
for other_signal in ("/v1/logs", "/v1/metrics"):
|
||||
if endpoint.endswith(other_signal):
|
||||
return endpoint[: -len(other_signal)] + "/v1/traces"
|
||||
return endpoint + "/v1/traces"
|
||||
|
||||
|
||||
def _exporter_from_spec(spec: ExporterSpec) -> SpanExporter:
|
||||
kind = (spec.kind or "console").lower()
|
||||
factory = _EXPORTER_FACTORIES.get(kind)
|
||||
if factory is not None:
|
||||
return factory(spec)
|
||||
if kind in ("in_memory", "inmemory", "memory"):
|
||||
return InMemorySpanExporter()
|
||||
if kind in ("otlp_http", "http", "http/protobuf", "http/json"):
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||||
OTLPSpanExporter as HTTPExporter,
|
||||
)
|
||||
|
||||
return HTTPExporter(
|
||||
endpoint=_otlp_traces_endpoint(spec.endpoint),
|
||||
headers=parse_headers(spec.headers),
|
||||
)
|
||||
if kind in ("otlp_grpc", "grpc"):
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
||||
OTLPSpanExporter as GRPCExporter,
|
||||
)
|
||||
|
||||
return GRPCExporter(endpoint=spec.endpoint, headers=parse_headers(spec.headers))
|
||||
return ConsoleSpanExporter()
|
||||
|
||||
|
||||
def _processor_for(exporter: SpanExporter, use_simple: bool | None) -> SpanProcessor:
|
||||
"""Pick a Simple or Batch span processor for ``exporter``.
|
||||
|
||||
When ``use_simple`` is unset, default to Simple for console and in-memory
|
||||
exporters (spans export synchronously, which tests rely on) and Batch for
|
||||
everything else (the right export semantics for production).
|
||||
"""
|
||||
if use_simple is None:
|
||||
use_simple = isinstance(exporter, (ConsoleSpanExporter, InMemorySpanExporter))
|
||||
return SimpleSpanProcessor(exporter) if use_simple else BatchSpanProcessor(exporter)
|
||||
|
||||
|
||||
def build_span_exporter(config: OpenTelemetryV2Config) -> SpanExporter:
|
||||
"""Build a single exporter from the top-level config fields.
|
||||
|
||||
Convenience for the common single-exporter case (and for tests): reads the
|
||||
``exporter`` / ``endpoint`` / ``headers`` fields. To configure multiple
|
||||
exporters, populate ``config.exporters`` directly.
|
||||
"""
|
||||
return _exporter_from_spec(
|
||||
ExporterSpec(
|
||||
kind=config.exporter, endpoint=config.endpoint, headers=config.headers
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_resource(config: OpenTelemetryV2Config) -> Resource:
|
||||
attributes: dict[str, str] = {"service.name": config.service_name}
|
||||
if config.deployment_environment:
|
||||
attributes["deployment.environment"] = config.deployment_environment
|
||||
attributes.update(config.resource_attributes)
|
||||
return Resource.create(attributes)
|
||||
|
||||
|
||||
def build_tracer_provider(
|
||||
config: OpenTelemetryV2Config,
|
||||
exporter: SpanExporter | None = None,
|
||||
baggage_processor: SpanProcessor | None = None,
|
||||
use_simple_processor: bool | None = None,
|
||||
) -> TracerProvider:
|
||||
"""Build the shared :class:`TracerProvider`.
|
||||
|
||||
Attach the Baggage processor first (so identity attributes land on each
|
||||
span before any export decision), then add one ``SpanProcessor`` per
|
||||
``config.exporters`` entry — this is what fans spans out to multiple
|
||||
backends. ``exporter`` and ``use_simple_processor`` are explicit overrides:
|
||||
pass a single exporter to attach exactly that one (used by tests).
|
||||
"""
|
||||
provider = TracerProvider(resource=build_resource(config))
|
||||
if baggage_processor is None:
|
||||
baggage_processor = LiteLLMBaggageSpanProcessor(
|
||||
allowed_keys=config.baggage_promoted_keys
|
||||
)
|
||||
provider.add_span_processor(baggage_processor)
|
||||
|
||||
if exporter is not None:
|
||||
provider.add_span_processor(_processor_for(exporter, use_simple_processor))
|
||||
return provider
|
||||
|
||||
# ``config._normalize`` guarantees at least one spec (it folds the top-level
|
||||
# ``exporter``/``endpoint``/``headers`` fields in when ``exporters`` is empty).
|
||||
for spec in config.exporters:
|
||||
exp = _exporter_from_spec(spec)
|
||||
provider.add_span_processor(
|
||||
_processor_for(
|
||||
exp,
|
||||
(
|
||||
spec.use_simple_processor
|
||||
if spec.use_simple_processor is not None
|
||||
else use_simple_processor
|
||||
),
|
||||
)
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def get_tracer(provider: TracerProvider, name: str = "litellm") -> Tracer:
|
||||
return provider.get_tracer(name)
|
||||
|
||||
|
||||
def in_memory_provider(
|
||||
config: OpenTelemetryV2Config | None = None,
|
||||
) -> tuple[TracerProvider, InMemorySpanExporter]:
|
||||
"""Convenience for tests: a provider exporting to an in-memory buffer."""
|
||||
cfg = config or OpenTelemetryV2Config(exporter="in_memory")
|
||||
exporter = InMemorySpanExporter()
|
||||
provider = build_tracer_provider(cfg, exporter=exporter)
|
||||
return provider, exporter
|
||||
101
litellm/integrations/otel/plumbing/routing.py
Normal file
101
litellm/integrations/otel/plumbing/routing.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Per-request multi-tenant tracer routing.
|
||||
|
||||
When a request carries team/key vendor credentials in
|
||||
``standard_callback_dynamic_params``, its spans must export through a
|
||||
``TracerProvider`` whose OTLP headers carry those credentials.
|
||||
``TenantTracerCache`` builds and caches one provider per distinct credential
|
||||
set, and otherwise hands back the logger's default tracer. This lets a single
|
||||
logger fan requests out to many tenants without needing a logger per tenant.
|
||||
"""
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Mapping
|
||||
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.trace import Tracer
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.otel.model.config import OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.presets import dynamic_otlp_headers
|
||||
from litellm.integrations.otel.plumbing.providers import (
|
||||
build_tracer_provider,
|
||||
get_tracer,
|
||||
)
|
||||
|
||||
# Exporter kinds that ignore headers — never rewritten with dynamic credentials.
|
||||
_NON_OTLP_KINDS = ("console", "in_memory", "inmemory", "memory")
|
||||
|
||||
# Cap on distinct credential-scoped providers held at once. ``dynamic_params``
|
||||
# can be populated from request metadata, so an unbounded cache lets a caller
|
||||
# spawn one ``TracerProvider`` (plus its ``BatchSpanProcessor`` background
|
||||
# thread) per unique credential set and exhaust the proxy. The LRU bound keeps
|
||||
# the working set of active tenants resident while flushing and shutting down
|
||||
# evicted providers so their threads are reclaimed.
|
||||
_MAX_CACHED_PROVIDERS = 256
|
||||
|
||||
|
||||
def _shutdown_provider(provider: TracerProvider) -> None:
|
||||
"""Flush + stop an evicted provider's processors (reclaims their threads).
|
||||
|
||||
``TracerProvider.shutdown`` force-flushes each ``SpanProcessor`` before
|
||||
stopping it, so any spans already handed to a ``BatchSpanProcessor`` are
|
||||
exported rather than dropped. Best-effort: a shutdown failure must not break
|
||||
the request that triggered the eviction.
|
||||
"""
|
||||
try:
|
||||
provider.shutdown()
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
verbose_logger.debug("OTel V2: error shutting down evicted provider: %s", e)
|
||||
|
||||
|
||||
class TenantTracerCache:
|
||||
"""Credential-scoped ``TracerProvider`` cache keyed by the dynamic headers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenTelemetryV2Config,
|
||||
callback_name: str | None,
|
||||
tracer_name: str,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._callback_name = callback_name
|
||||
self._tracer_name = tracer_name
|
||||
self._providers: "OrderedDict[tuple[tuple[str, str], ...], TracerProvider]" = (
|
||||
OrderedDict()
|
||||
)
|
||||
|
||||
def tracer_for(self, default: Tracer, dynamic_params: Any) -> Tracer:
|
||||
"""Return the tracer for this request.
|
||||
|
||||
Use ``default`` unless the request's dynamic credentials require a
|
||||
credential-scoped tracer, in which case build (or reuse) one. The cache
|
||||
is a bounded LRU: the least-recently-used provider is flushed and shut
|
||||
down on overflow so its exporter threads don't accumulate.
|
||||
"""
|
||||
headers = dynamic_otlp_headers(self._callback_name, dynamic_params)
|
||||
if not headers:
|
||||
return default
|
||||
cache_key = tuple(sorted(headers.items()))
|
||||
provider = self._providers.get(cache_key)
|
||||
if provider is not None:
|
||||
self._providers.move_to_end(cache_key)
|
||||
else:
|
||||
provider = build_tracer_provider(self._config_with_headers(headers))
|
||||
self._providers[cache_key] = provider
|
||||
if len(self._providers) > _MAX_CACHED_PROVIDERS:
|
||||
_, evicted = self._providers.popitem(last=False)
|
||||
_shutdown_provider(evicted)
|
||||
return get_tracer(provider, self._tracer_name)
|
||||
|
||||
def _config_with_headers(self, headers: Mapping[str, str]) -> OpenTelemetryV2Config:
|
||||
"""Clone the config, replacing OTLP exporter headers with ``headers``."""
|
||||
header_str = ",".join(f"{key}={value}" for key, value in headers.items())
|
||||
exporters = [
|
||||
(
|
||||
spec
|
||||
if spec.kind.lower() in _NON_OTLP_KINDS
|
||||
else spec.model_copy(update={"headers": header_str})
|
||||
)
|
||||
for spec in self._config.exporters
|
||||
]
|
||||
return self._config.model_copy(update={"exporters": exporters})
|
||||
78
litellm/integrations/otel/presets/__init__.py
Normal file
78
litellm/integrations/otel/presets/__init__.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Integration presets — each one returns an :class:`OpenTelemetryV2Config`.
|
||||
|
||||
A preset is a callable that reads an integration's env vars and returns an
|
||||
``OpenTelemetryV2Config`` describing the exporter destination, the mapper
|
||||
vocabularies to apply, and any resource attributes. ``PRESET_BY_CALLBACK``
|
||||
maps a callback name (``"arize"``, ``"langfuse_otel"``, ...) to its preset so
|
||||
the factory in ``litellm_logging`` can resolve a name and build a single
|
||||
``OpenTelemetryV2`` instance from the result.
|
||||
"""
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from litellm.integrations.otel.presets.agentops import agentops_preset
|
||||
from litellm.integrations.otel.presets.arize import arize_dynamic_headers, arize_preset
|
||||
from litellm.integrations.otel.presets.base import Preset
|
||||
from litellm.integrations.otel.presets.langfuse import (
|
||||
langfuse_dynamic_headers,
|
||||
langfuse_preset,
|
||||
)
|
||||
from litellm.integrations.otel.presets.langtrace import langtrace_preset
|
||||
from litellm.integrations.otel.presets.levo import levo_preset
|
||||
from litellm.integrations.otel.presets.phoenix import phoenix_preset
|
||||
from litellm.integrations.otel.presets.weave import weave_dynamic_headers, weave_preset
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
|
||||
#: Callback name → preset. The ``Preset`` annotation makes mypy verify every
|
||||
#: registered value matches the preset interface.
|
||||
PRESET_BY_CALLBACK: dict[str, Preset] = {
|
||||
"agentops": agentops_preset,
|
||||
"arize": arize_preset,
|
||||
"arize_phoenix": phoenix_preset,
|
||||
"langfuse_otel": langfuse_preset,
|
||||
"langtrace": langtrace_preset,
|
||||
"levo": levo_preset,
|
||||
"weave_otel": weave_preset,
|
||||
}
|
||||
|
||||
#: Callback name → per-request OTLP header builder (team/key multi-tenant
|
||||
#: routing). Only integrations that support dynamic credentials appear here —
|
||||
#: Arize-Phoenix/Langtrace/Levo/AgentOps don't, so they use the logger's
|
||||
#: default tracer.
|
||||
DYNAMIC_HEADERS_BY_CALLBACK: dict[
|
||||
str, Callable[[StandardCallbackDynamicParams], dict[str, str]]
|
||||
] = {
|
||||
"arize": arize_dynamic_headers,
|
||||
"langfuse_otel": langfuse_dynamic_headers,
|
||||
"weave_otel": weave_dynamic_headers,
|
||||
}
|
||||
|
||||
|
||||
def dynamic_otlp_headers(
|
||||
callback_name: str | None,
|
||||
dynamic_params: StandardCallbackDynamicParams | None,
|
||||
) -> dict[str, str] | None:
|
||||
"""Per-request OTLP headers for ``callback_name``, or ``None`` if N/A.
|
||||
|
||||
``None`` means "no per-request routing" — the caller uses its default tracer.
|
||||
"""
|
||||
builder = DYNAMIC_HEADERS_BY_CALLBACK.get(callback_name or "")
|
||||
if builder is None or not dynamic_params:
|
||||
return None
|
||||
headers = builder(dynamic_params)
|
||||
return headers or None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PRESET_BY_CALLBACK",
|
||||
"DYNAMIC_HEADERS_BY_CALLBACK",
|
||||
"Preset",
|
||||
"dynamic_otlp_headers",
|
||||
"agentops_preset",
|
||||
"arize_preset",
|
||||
"langfuse_preset",
|
||||
"langtrace_preset",
|
||||
"levo_preset",
|
||||
"phoenix_preset",
|
||||
"weave_preset",
|
||||
]
|
||||
139
litellm/integrations/otel/presets/agentops.py
Normal file
139
litellm/integrations/otel/presets/agentops.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""AgentOps preset — OTLP/HTTP to AgentOps' endpoint with a lazily-fetched JWT.
|
||||
|
||||
AgentOps authenticates with a short-lived JWT minted from the API key. Fetching
|
||||
it is blocking network I/O, so it must never run on the event loop: callback
|
||||
construction (where presets are built) can run inside the proxy's async startup
|
||||
or, in the SDK, on the first request. Instead of fetching at config-build time,
|
||||
this preset registers a custom exporter (``kind="agentops"``) that mints the JWT
|
||||
**on its first export** — which the ``BatchSpanProcessor`` runs in its own
|
||||
worker thread, off any event loop — and caches it for the process lifetime.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.plumbing.providers import register_exporter_factory
|
||||
|
||||
_AGENTOPS_ENDPOINT = "https://otlp.agentops.cloud/v1/traces"
|
||||
_AGENTOPS_AUTH_ENDPOINT = "https://api.agentops.ai/v3/auth/token"
|
||||
_AGENTOPS_EXPORTER_KIND = "agentops"
|
||||
|
||||
|
||||
class _AgentOpsSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore")
|
||||
|
||||
api_key: str | None = Field(default=None, validation_alias="AGENTOPS_API_KEY")
|
||||
service_name: str = Field(
|
||||
default="agentops", validation_alias="AGENTOPS_SERVICE_NAME"
|
||||
)
|
||||
environment: str | None = Field(
|
||||
default=None, validation_alias="AGENTOPS_ENVIRONMENT"
|
||||
)
|
||||
|
||||
|
||||
def agentops_preset(
|
||||
*,
|
||||
config_overrides: OpenTelemetryV2Config | None = None,
|
||||
) -> OpenTelemetryV2Config:
|
||||
"""Build the AgentOps config without any network I/O.
|
||||
|
||||
The ``agentops`` exporter mints (and caches) the JWT lazily on its first
|
||||
export, so this stays non-blocking. ``project.id`` is therefore not a
|
||||
resource attribute — it is encoded in the JWT, which AgentOps uses to route
|
||||
the trace to the right project.
|
||||
"""
|
||||
settings = _AgentOpsSettings()
|
||||
base = config_overrides or OpenTelemetryV2Config()
|
||||
return base.model_copy(
|
||||
update={
|
||||
"exporters": [
|
||||
*base.exporters,
|
||||
ExporterSpec(
|
||||
kind=_AGENTOPS_EXPORTER_KIND,
|
||||
endpoint=_AGENTOPS_ENDPOINT,
|
||||
options=(
|
||||
{"api_key": settings.api_key} if settings.api_key else None
|
||||
),
|
||||
),
|
||||
],
|
||||
"resource_attributes": {
|
||||
**base.resource_attributes,
|
||||
"service.name": settings.service_name,
|
||||
"telemetry.sdk.name": "agentops",
|
||||
**(
|
||||
{"deployment.environment": settings.environment}
|
||||
if settings.environment
|
||||
else {}
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _build_agentops_exporter(spec: ExporterSpec) -> Any:
|
||||
"""Factory for the ``agentops`` exporter kind: a lazy-auth OTLP/HTTP exporter."""
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||||
OTLPSpanExporter,
|
||||
)
|
||||
|
||||
class _LazyAuthAgentOpsExporter(OTLPSpanExporter):
|
||||
"""OTLP/HTTP exporter that mints the AgentOps JWT on its first export.
|
||||
|
||||
``export`` runs in the ``BatchSpanProcessor`` worker thread, so the
|
||||
blocking token fetch never touches an event loop. The result is cached
|
||||
after the first attempt (success or failure) so it runs at most once.
|
||||
"""
|
||||
|
||||
def __init__(self, *, endpoint: str | None, api_key: str | None) -> None:
|
||||
super().__init__(endpoint=endpoint)
|
||||
self._agentops_api_key = api_key
|
||||
self._auth_resolved = False
|
||||
|
||||
def _ensure_authenticated(self) -> None:
|
||||
if self._auth_resolved:
|
||||
return
|
||||
self._auth_resolved = True
|
||||
if not self._agentops_api_key:
|
||||
return
|
||||
try:
|
||||
token = _fetch_agentops_jwt(self._agentops_api_key).get("token")
|
||||
if token:
|
||||
# ``_session`` is the requests.Session the base exporter
|
||||
# POSTs through; updating its Authorization header is how the
|
||||
# minted JWT reaches every subsequent export.
|
||||
self._session.headers["Authorization"] = f"Bearer {token}"
|
||||
except Exception as e:
|
||||
verbose_logger.debug("AgentOps JWT fetch failed: %s", e)
|
||||
|
||||
def export(self, spans: Any) -> Any:
|
||||
self._ensure_authenticated()
|
||||
return super().export(spans)
|
||||
|
||||
options = spec.options or {}
|
||||
return _LazyAuthAgentOpsExporter(
|
||||
endpoint=spec.endpoint, api_key=options.get("api_key")
|
||||
)
|
||||
|
||||
|
||||
def _fetch_agentops_jwt(api_key: str) -> dict[str, Any]:
|
||||
# Own a short-lived client rather than ``_get_httpx_client()``: that returns
|
||||
# a process-wide cached ``HTTPHandler`` whose connection pool is shared by
|
||||
# every caller, so closing it here would break concurrent/subsequent
|
||||
# requests. This one-shot auth call gets its own client to close.
|
||||
with httpx.Client(timeout=10) as client:
|
||||
response = client.post(
|
||||
url=_AGENTOPS_AUTH_ENDPOINT,
|
||||
headers={"Content-Type": "application/json", "Connection": "keep-alive"},
|
||||
json={"api_key": api_key},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"Failed to fetch AgentOps token: {response.text}")
|
||||
return response.json()
|
||||
|
||||
|
||||
register_exporter_factory(_AGENTOPS_EXPORTER_KIND, _build_agentops_exporter)
|
||||
75
litellm/integrations/otel/presets/arize.py
Normal file
75
litellm/integrations/otel/presets/arize.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Arize preset — OTLP exporter to Arize + OpenInference vocabulary."""
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from litellm.integrations.arize.arize import ArizeLogger as _V1ArizeLogger
|
||||
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.presets.utils import ensure_mappers
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
|
||||
|
||||
class _ArizeSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore")
|
||||
|
||||
# Standard OTLP headers env var, used as the fallback when no Arize
|
||||
# credentials are configured.
|
||||
otlp_traces_headers: str | None = Field(
|
||||
default=None, validation_alias="OTEL_EXPORTER_OTLP_TRACES_HEADERS"
|
||||
)
|
||||
|
||||
|
||||
def arize_preset(
|
||||
*,
|
||||
config_overrides: OpenTelemetryV2Config | None = None,
|
||||
) -> OpenTelemetryV2Config:
|
||||
arize_cfg = _V1ArizeLogger.get_arize_config()
|
||||
headers = _arize_headers(arize_cfg)
|
||||
base = config_overrides or OpenTelemetryV2Config()
|
||||
return base.model_copy(
|
||||
update={
|
||||
"exporters": [
|
||||
*base.exporters,
|
||||
ExporterSpec(
|
||||
kind=arize_cfg.protocol or "otlp_grpc",
|
||||
endpoint=arize_cfg.endpoint or "https://otlp.arize.com/v1",
|
||||
headers=headers,
|
||||
),
|
||||
],
|
||||
"mapper_names": ensure_mappers(base.mapper_names, "openinference"),
|
||||
"resource_attributes": {
|
||||
**base.resource_attributes,
|
||||
**(
|
||||
{"model_id": arize_cfg.project_name}
|
||||
if arize_cfg.project_name
|
||||
else {}
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _arize_headers(arize_cfg) -> str | None:
|
||||
pieces = []
|
||||
if arize_cfg.space_id or arize_cfg.space_key:
|
||||
pieces.append(f"space_id={arize_cfg.space_id or arize_cfg.space_key}")
|
||||
if arize_cfg.api_key:
|
||||
pieces.append(f"api_key={arize_cfg.api_key}")
|
||||
if not pieces:
|
||||
# Fall back to the standard OTLP headers env var when no Arize
|
||||
# credentials are configured.
|
||||
return _ArizeSettings().otlp_traces_headers
|
||||
return ",".join(pieces)
|
||||
|
||||
|
||||
def arize_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str, str]:
|
||||
"""Per-request Arize OTLP headers from team/key dynamic params."""
|
||||
headers: dict[str, str] = {}
|
||||
# ``arize_space_key`` is the suggested param and wins over ``arize_space_id``.
|
||||
space = params.get("arize_space_key") or params.get("arize_space_id")
|
||||
if space:
|
||||
headers["arize-space-id"] = space
|
||||
api_key = params.get("arize_api_key")
|
||||
if api_key:
|
||||
headers["api_key"] = api_key
|
||||
return headers
|
||||
25
litellm/integrations/otel/presets/base.py
Normal file
25
litellm/integrations/otel/presets/base.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Preset interface.
|
||||
|
||||
A preset is a callable that reads its integration's env vars and produces an
|
||||
:class:`OpenTelemetryV2Config` (exporter list + mapper-name list + resource
|
||||
attributes). This ``Protocol`` pins that contract so ``PRESET_BY_CALLBACK`` and
|
||||
the factory in ``litellm_logging`` are type-checked structurally against it,
|
||||
matching the ``AttributeMapper`` protocol the mappers use.
|
||||
"""
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from litellm.integrations.otel.model.config import OpenTelemetryV2Config
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Preset(Protocol):
|
||||
"""Reads an integration's env config and returns an ``OpenTelemetryV2Config``.
|
||||
|
||||
``config_overrides`` lets one preset layer onto another's config (or onto
|
||||
test-supplied defaults); the factory calls presets with no arguments.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self, *, config_overrides: OpenTelemetryV2Config | None = None
|
||||
) -> OpenTelemetryV2Config: ...
|
||||
43
litellm/integrations/otel/presets/langfuse.py
Normal file
43
litellm/integrations/otel/presets/langfuse.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Langfuse-OTEL preset."""
|
||||
|
||||
from litellm.integrations.langfuse.langfuse_otel import (
|
||||
LangfuseOtelLogger as _V1Langfuse,
|
||||
)
|
||||
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.presets.utils import ensure_mappers
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
|
||||
|
||||
def langfuse_preset(
|
||||
*,
|
||||
config_overrides: OpenTelemetryV2Config | None = None,
|
||||
) -> OpenTelemetryV2Config:
|
||||
cfg = _V1Langfuse.get_langfuse_otel_config()
|
||||
kind = cfg.exporter if isinstance(cfg.exporter, str) else "otlp_http"
|
||||
base = config_overrides or OpenTelemetryV2Config()
|
||||
return base.model_copy(
|
||||
update={
|
||||
"exporters": [
|
||||
*base.exporters,
|
||||
ExporterSpec(
|
||||
kind=kind,
|
||||
endpoint=cfg.endpoint,
|
||||
headers=cfg.headers,
|
||||
),
|
||||
],
|
||||
"mapper_names": ensure_mappers(base.mapper_names, "langfuse"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def langfuse_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str, str]:
|
||||
"""Per-request Langfuse OTLP headers from team/key dynamic params."""
|
||||
public_key = params.get("langfuse_public_key")
|
||||
secret_key = params.get("langfuse_secret_key")
|
||||
if public_key and secret_key:
|
||||
return {
|
||||
"Authorization": _V1Langfuse._get_langfuse_authorization_header(
|
||||
public_key=public_key, secret_key=secret_key
|
||||
)
|
||||
}
|
||||
return {}
|
||||
22
litellm/integrations/otel/presets/langtrace.py
Normal file
22
litellm/integrations/otel/presets/langtrace.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""Langtrace preset — Langtrace consumes generic OTLP + a vendor mapper."""
|
||||
|
||||
from litellm.integrations.otel.model.config import OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.presets.utils import ensure_mappers
|
||||
|
||||
|
||||
def langtrace_preset(
|
||||
*,
|
||||
config_overrides: OpenTelemetryV2Config | None = None,
|
||||
) -> OpenTelemetryV2Config:
|
||||
"""Compose the Langtrace mapper on top of the customer's OTLP destination.
|
||||
|
||||
Unlike Arize / Phoenix / Langfuse, Langtrace doesn't ship its own endpoint
|
||||
— users point their existing OTLP collector at Langtrace and just
|
||||
need the vendor attribute schema applied to outgoing spans.
|
||||
"""
|
||||
base = config_overrides or OpenTelemetryV2Config()
|
||||
return base.model_copy(
|
||||
update={
|
||||
"mapper_names": ensure_mappers(base.mapper_names, "langtrace"),
|
||||
}
|
||||
)
|
||||
24
litellm/integrations/otel/presets/levo.py
Normal file
24
litellm/integrations/otel/presets/levo.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Levo preset — OTLP/HTTP to a Levo collector with org+workspace headers."""
|
||||
|
||||
from litellm.integrations.levo.levo import LevoLogger as _V1Levo
|
||||
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
|
||||
|
||||
|
||||
def levo_preset(
|
||||
*,
|
||||
config_overrides: OpenTelemetryV2Config | None = None,
|
||||
) -> OpenTelemetryV2Config:
|
||||
cfg = _V1Levo.get_levo_config()
|
||||
base = config_overrides or OpenTelemetryV2Config()
|
||||
return base.model_copy(
|
||||
update={
|
||||
"exporters": [
|
||||
*base.exporters,
|
||||
ExporterSpec(
|
||||
kind="otlp_http",
|
||||
endpoint=cfg.endpoint,
|
||||
headers=cfg.otlp_auth_headers,
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
48
litellm/integrations/otel/presets/phoenix.py
Normal file
48
litellm/integrations/otel/presets/phoenix.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Arize-Phoenix preset."""
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from litellm.integrations.arize.arize_phoenix import (
|
||||
ArizePhoenixLogger as _V1Phoenix,
|
||||
)
|
||||
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.presets.utils import ensure_mappers
|
||||
|
||||
|
||||
class _PhoenixSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore")
|
||||
|
||||
project_name: str = Field(
|
||||
default="default",
|
||||
validation_alias=AliasChoices(
|
||||
"PHOENIX_PROJECT_NAME", "PHOENIX_COLLECTOR_PROJECT_NAME"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def phoenix_preset(
|
||||
*,
|
||||
config_overrides: OpenTelemetryV2Config | None = None,
|
||||
) -> OpenTelemetryV2Config:
|
||||
cfg = _V1Phoenix.get_arize_phoenix_config()
|
||||
headers = cfg.otlp_auth_headers if hasattr(cfg, "otlp_auth_headers") else None
|
||||
project_name = _PhoenixSettings().project_name
|
||||
base = config_overrides or OpenTelemetryV2Config()
|
||||
return base.model_copy(
|
||||
update={
|
||||
"exporters": [
|
||||
*base.exporters,
|
||||
ExporterSpec(
|
||||
kind=cfg.protocol if hasattr(cfg, "protocol") else "otlp_http",
|
||||
endpoint=cfg.endpoint,
|
||||
headers=headers,
|
||||
),
|
||||
],
|
||||
"mapper_names": ensure_mappers(base.mapper_names, "openinference"),
|
||||
"resource_attributes": {
|
||||
**base.resource_attributes,
|
||||
"openinference.project.name": project_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
16
litellm/integrations/otel/presets/utils.py
Normal file
16
litellm/integrations/otel/presets/utils.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""Shared helpers for the integration presets."""
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
def ensure_mappers(mapper_names: Iterable[str], *names: str) -> list[str]:
|
||||
"""Return ``mapper_names`` with each of ``names`` appended if not already present.
|
||||
|
||||
Order is preserved and duplicates are skipped, so composing several presets
|
||||
(or re-applying one) never double-adds a vocabulary.
|
||||
"""
|
||||
result = list(mapper_names)
|
||||
for name in names:
|
||||
if name not in result:
|
||||
result.append(name)
|
||||
return result
|
||||
43
litellm/integrations/otel/presets/weave.py
Normal file
43
litellm/integrations/otel/presets/weave.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Weave (W&B) preset."""
|
||||
|
||||
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.presets.utils import ensure_mappers
|
||||
from litellm.integrations.weave.weave_otel import (
|
||||
_get_weave_authorization_header,
|
||||
get_weave_otel_config,
|
||||
)
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
|
||||
|
||||
def weave_preset(
|
||||
*,
|
||||
config_overrides: OpenTelemetryV2Config | None = None,
|
||||
) -> OpenTelemetryV2Config:
|
||||
weave_cfg = get_weave_otel_config()
|
||||
base = config_overrides or OpenTelemetryV2Config()
|
||||
return base.model_copy(
|
||||
update={
|
||||
"exporters": [
|
||||
*base.exporters,
|
||||
ExporterSpec(
|
||||
kind=weave_cfg.protocol or "otlp_http",
|
||||
endpoint=weave_cfg.endpoint,
|
||||
headers=weave_cfg.otlp_auth_headers,
|
||||
),
|
||||
],
|
||||
# Weave consumes OpenInference + a small Weave-specific overlay.
|
||||
"mapper_names": ensure_mappers(base.mapper_names, "openinference", "weave"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def weave_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str, str]:
|
||||
"""Per-request Weave OTLP headers from team/key dynamic params."""
|
||||
headers: dict[str, str] = {}
|
||||
api_key = params.get("wandb_api_key")
|
||||
if api_key:
|
||||
headers["Authorization"] = _get_weave_authorization_header(api_key=api_key)
|
||||
project_id = params.get("weave_project_id")
|
||||
if project_id:
|
||||
headers["project_id"] = project_id
|
||||
return headers
|
||||
38
litellm/integrations/otel/runtime.py
Normal file
38
litellm/integrations/otel/runtime.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""SDK-free entrypoints for proxy-core call sites (auth, …).
|
||||
|
||||
Proxy code may run without the OpenTelemetry SDK installed, so it must not import
|
||||
``litellm.integrations.otel.logger`` (which imports the SDK at module scope) at
|
||||
module load. These wrappers import it lazily and no-op when the SDK is absent or
|
||||
V2 is not the active logger — so a call site can wrap a request phase or seed
|
||||
identity unconditionally.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Iterator
|
||||
|
||||
|
||||
@contextmanager
|
||||
def phase_span(name: str) -> "Iterator[Any]":
|
||||
"""Run a request phase inside a live active span so its DB/service calls nest.
|
||||
|
||||
Yields ``None`` (a plain no-op) when the OTel SDK is unavailable or V2 is not
|
||||
the active logger.
|
||||
"""
|
||||
try:
|
||||
from litellm.integrations.otel.logger import phase_span as _phase_span
|
||||
except Exception:
|
||||
yield None
|
||||
return
|
||||
with _phase_span(name) as span:
|
||||
yield span
|
||||
|
||||
|
||||
def seed_request_identity(user_api_key_dict: Any, model: Any = None) -> None:
|
||||
"""Seed request-identity Baggage at the auth boundary (no-op without V2)."""
|
||||
try:
|
||||
from litellm.integrations.otel.logger import (
|
||||
seed_request_identity as _seed_request_identity,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
_seed_request_identity(user_api_key_dict, model=model)
|
||||
|
|
@ -1612,6 +1612,90 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
) -> Optional[float]:
|
||||
return self._response_cost_calculator(result=result, cache_hit=cache_hit)
|
||||
|
||||
@staticmethod
|
||||
def _is_sync_litellm_request(litellm_params: dict) -> bool:
|
||||
"""True for sync SDK entrypoints (``completion``), false for async (``acompletion``, etc.)."""
|
||||
return (
|
||||
litellm_params.get(CallTypes.acompletion.value, False) is not True
|
||||
and litellm_params.get(CallTypes.aresponses.value, False) is not True
|
||||
and litellm_params.get(CallTypes.aembedding.value, False) is not True
|
||||
and litellm_params.get(CallTypes.aimage_generation.value, False) is not True
|
||||
and litellm_params.get(CallTypes.atranscription.value, False) is not True
|
||||
)
|
||||
|
||||
def _is_assembled_stream_success(self, result=None) -> bool:
|
||||
"""Final assembled stream export (not a per-chunk success call).
|
||||
|
||||
Per-chunk callers pass a ``ModelResponseStream`` (or ``None``); the
|
||||
final assembled response is any other non-``None`` value (typically a
|
||||
``ModelResponse``). Treating a chunk as the assembled response would
|
||||
prematurely set the ``has_dispatched_final_stream_success`` dedup
|
||||
guard and silently suppress the real final stream log.
|
||||
"""
|
||||
if self.stream is not True:
|
||||
return False
|
||||
if result is not None and not isinstance(result, ModelResponseStream):
|
||||
return True
|
||||
return (
|
||||
"async_complete_streaming_response" in self.model_call_details
|
||||
or self.model_call_details.get("complete_streaming_response") is not None
|
||||
)
|
||||
|
||||
async def dispatch_success_handlers(
|
||||
self,
|
||||
result=None,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
cache_hit=None,
|
||||
prefer_async_handlers: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Route success logging to async and/or sync handlers for this request.
|
||||
|
||||
``prefer_async_handlers`` only bypasses the sync-SDK-only shortcut (e.g.
|
||||
``async for`` on a stream from ``completion()``). Legacy string callbacks
|
||||
still run via ``executor.submit(success_handler)`` when configured.
|
||||
"""
|
||||
from litellm.litellm_core_utils.thread_pool_executor import executor
|
||||
|
||||
if self._is_assembled_stream_success(result):
|
||||
if self.model_call_details.get("has_dispatched_final_stream_success"):
|
||||
return
|
||||
self.model_call_details["has_dispatched_final_stream_success"] = True
|
||||
|
||||
litellm_params = self.model_call_details.get("litellm_params", {}) or {}
|
||||
sync_sdk = self._is_sync_litellm_request(litellm_params)
|
||||
passthrough = self.call_type == CallTypes.pass_through.value
|
||||
if sync_sdk and not prefer_async_handlers and not passthrough:
|
||||
self.success_handler(
|
||||
result,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
cache_hit=cache_hit,
|
||||
**kwargs,
|
||||
)
|
||||
return
|
||||
|
||||
await self.async_success_handler(
|
||||
result,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
cache_hit=cache_hit,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if not self._should_run_sync_callbacks_for_async_calls():
|
||||
return
|
||||
|
||||
executor.submit(
|
||||
self.success_handler,
|
||||
result,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
cache_hit=cache_hit,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def should_run_logging(
|
||||
self,
|
||||
event_type: Literal[
|
||||
|
|
@ -2034,13 +2118,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
standard_logging_object=kwargs.get("standard_logging_object", None),
|
||||
)
|
||||
litellm_params = self.model_call_details.get("litellm_params", {})
|
||||
is_sync_request = (
|
||||
litellm_params.get(CallTypes.acompletion.value, False) is not True
|
||||
and litellm_params.get(CallTypes.aresponses.value, False) is not True
|
||||
and litellm_params.get(CallTypes.aembedding.value, False) is not True
|
||||
and litellm_params.get(CallTypes.aimage_generation.value, False) is not True
|
||||
and litellm_params.get(CallTypes.atranscription.value, False) is not True
|
||||
)
|
||||
is_sync_request = self._is_sync_litellm_request(litellm_params)
|
||||
try:
|
||||
## BUILD COMPLETE STREAMED RESPONSE
|
||||
complete_streaming_response: Optional[
|
||||
|
|
@ -2496,9 +2574,11 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
print_verbose(
|
||||
"Logging Details LiteLLM-Async Success Call, cache_hit={}".format(cache_hit)
|
||||
)
|
||||
if not self.should_run_logging(
|
||||
if not self._is_assembled_stream_success(
|
||||
result
|
||||
) and not self.should_run_logging(
|
||||
event_type="async_success"
|
||||
): # prevent double logging
|
||||
): # prevent double logging (non-streaming)
|
||||
return
|
||||
|
||||
## CALCULATE COST FOR BATCH JOBS
|
||||
|
|
@ -2948,13 +3028,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
): # prevent double logging
|
||||
return
|
||||
litellm_params = self.model_call_details.get("litellm_params", {})
|
||||
is_sync_request = (
|
||||
litellm_params.get(CallTypes.acompletion.value, False) is not True
|
||||
and litellm_params.get(CallTypes.aresponses.value, False) is not True
|
||||
and litellm_params.get(CallTypes.aembedding.value, False) is not True
|
||||
and litellm_params.get(CallTypes.aimage_generation.value, False) is not True
|
||||
and litellm_params.get(CallTypes.atranscription.value, False) is not True
|
||||
)
|
||||
is_sync_request = self._is_sync_litellm_request(litellm_params)
|
||||
|
||||
try:
|
||||
start_time, end_time = self._failure_handler_helper_fn(
|
||||
|
|
@ -3718,6 +3792,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
try:
|
||||
custom_logger_init_args = custom_logger_init_args or {}
|
||||
if logging_integration == "agentops": # Add AgentOps initialization
|
||||
_v2 = _maybe_construct_otel_v2("agentops", _in_memory_loggers)
|
||||
if _v2 is not None:
|
||||
return _v2 # type: ignore
|
||||
for callback in _in_memory_loggers:
|
||||
if isinstance(callback, AgentOps):
|
||||
return callback # type: ignore
|
||||
|
|
@ -3870,6 +3947,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
_in_memory_loggers.append(_opik_logger)
|
||||
return _opik_logger # type: ignore
|
||||
elif logging_integration == "arize":
|
||||
_v2 = _maybe_construct_otel_v2("arize", _in_memory_loggers)
|
||||
if _v2 is not None:
|
||||
return _v2 # type: ignore
|
||||
from litellm.integrations.opentelemetry import (
|
||||
OpenTelemetry,
|
||||
OpenTelemetryConfig,
|
||||
|
|
@ -3899,6 +3979,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
_in_memory_loggers.append(_arize_otel_logger)
|
||||
return _arize_otel_logger # type: ignore
|
||||
elif logging_integration == "arize_phoenix":
|
||||
_v2 = _maybe_construct_otel_v2("arize_phoenix", _in_memory_loggers)
|
||||
if _v2 is not None:
|
||||
return _v2 # type: ignore
|
||||
from litellm.integrations.opentelemetry import (
|
||||
OpenTelemetry,
|
||||
OpenTelemetryConfig,
|
||||
|
|
@ -3929,6 +4012,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
_in_memory_loggers.append(_arize_phoenix_otel_logger)
|
||||
return _arize_phoenix_otel_logger # type: ignore
|
||||
elif logging_integration == "levo":
|
||||
_v2 = _maybe_construct_otel_v2("levo", _in_memory_loggers)
|
||||
if _v2 is not None:
|
||||
return _v2 # type: ignore
|
||||
from litellm.integrations.levo.levo import LevoLogger
|
||||
from litellm.integrations.opentelemetry import (
|
||||
OpenTelemetry,
|
||||
|
|
@ -3954,6 +4040,28 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
_in_memory_loggers.append(_levo_otel_logger)
|
||||
return _levo_otel_logger # type: ignore
|
||||
elif logging_integration == "otel":
|
||||
# Gate the new typed V2 adapter behind LITELLM_OTEL_V2. When off,
|
||||
# the legacy 3,227-line god-class is used unchanged. The two are
|
||||
# never registered simultaneously — the dedup loop below treats
|
||||
# any module under ``litellm.integrations.otel`` or
|
||||
# ``litellm.integrations.opentelemetry`` as "the OTel callback".
|
||||
from litellm.integrations.otel.model.config import is_otel_v2_enabled
|
||||
|
||||
if is_otel_v2_enabled():
|
||||
from litellm.integrations.otel.logger import OpenTelemetryV2
|
||||
|
||||
for callback in _in_memory_loggers:
|
||||
if type(callback) is OpenTelemetryV2:
|
||||
return callback # type: ignore
|
||||
otel_logger_v2 = OpenTelemetryV2(
|
||||
**_get_custom_logger_settings_from_proxy_server(
|
||||
callback_name=logging_integration
|
||||
)
|
||||
)
|
||||
_in_memory_loggers.append(otel_logger_v2)
|
||||
_maybe_auto_initialize_arize_phoenix(_in_memory_loggers)
|
||||
return otel_logger_v2 # type: ignore
|
||||
|
||||
from litellm.integrations.opentelemetry import OpenTelemetry
|
||||
|
||||
for callback in _in_memory_loggers:
|
||||
|
|
@ -4092,6 +4200,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
elif logging_integration == "langtrace":
|
||||
if "LANGTRACE_API_KEY" not in os.environ:
|
||||
raise ValueError("LANGTRACE_API_KEY not found in environment variables")
|
||||
_v2 = _maybe_construct_otel_v2("langtrace", _in_memory_loggers)
|
||||
if _v2 is not None:
|
||||
return _v2 # type: ignore
|
||||
|
||||
from litellm.integrations.opentelemetry import (
|
||||
OpenTelemetry,
|
||||
|
|
@ -4132,6 +4243,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
_in_memory_loggers.append(langfuse_logger)
|
||||
return langfuse_logger # type: ignore
|
||||
elif logging_integration == "langfuse_otel":
|
||||
_v2 = _maybe_construct_otel_v2("langfuse_otel", _in_memory_loggers)
|
||||
if _v2 is not None:
|
||||
return _v2 # type: ignore
|
||||
from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
|
||||
|
||||
for callback in _in_memory_loggers:
|
||||
|
|
@ -4148,6 +4262,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
_in_memory_loggers.append(_otel_logger)
|
||||
return _otel_logger # type: ignore
|
||||
elif logging_integration == "weave_otel":
|
||||
_v2 = _maybe_construct_otel_v2("weave_otel", _in_memory_loggers)
|
||||
if _v2 is not None:
|
||||
return _v2 # type: ignore
|
||||
from litellm.integrations.opentelemetry import OpenTelemetryConfig
|
||||
from litellm.integrations.weave.weave_otel import (
|
||||
WeaveOtelLogger,
|
||||
|
|
@ -4296,6 +4413,42 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
|||
return None
|
||||
|
||||
|
||||
def _maybe_construct_otel_v2(
|
||||
callback_name: str, _in_memory_loggers: list
|
||||
) -> Optional[Any]:
|
||||
"""If ``LITELLM_OTEL_V2`` is on, build (or reuse) a single ``OpenTelemetryV2``
|
||||
instance configured via the preset for ``callback_name``.
|
||||
|
||||
Returns ``None`` when V2 is off OR when there's no preset registered for
|
||||
``callback_name`` — callers should then fall through to the legacy path.
|
||||
"""
|
||||
from litellm.integrations.otel.model.config import is_otel_v2_enabled
|
||||
|
||||
if not is_otel_v2_enabled():
|
||||
return None
|
||||
from litellm.integrations.otel.logger import OpenTelemetryV2
|
||||
from litellm.integrations.otel.presets import PRESET_BY_CALLBACK
|
||||
|
||||
preset_fn = PRESET_BY_CALLBACK.get(callback_name)
|
||||
if preset_fn is None:
|
||||
return None
|
||||
for callback in _in_memory_loggers:
|
||||
if (
|
||||
isinstance(callback, OpenTelemetryV2)
|
||||
and getattr(callback, "callback_name", None) == callback_name
|
||||
):
|
||||
return callback
|
||||
try:
|
||||
config = preset_fn()
|
||||
except Exception:
|
||||
# If env vars are missing or the preset raises, defer to the legacy path
|
||||
# so customers get the same error story they had before V2 landed.
|
||||
return None
|
||||
v2_logger = OpenTelemetryV2(config=config, callback_name=callback_name)
|
||||
_in_memory_loggers.append(v2_logger)
|
||||
return v2_logger
|
||||
|
||||
|
||||
def _maybe_auto_initialize_arize_phoenix(_in_memory_loggers: list) -> None:
|
||||
"""
|
||||
Auto-initialize ArizePhoenixLogger when Phoenix env vars are detected.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ Common utility functions used for translating messages across providers
|
|||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
from os import PathLike
|
||||
|
|
@ -132,6 +133,39 @@ def strip_none_values_from_message(message: AllMessageValues) -> AllMessageValue
|
|||
return cast(AllMessageValues, {k: v for k, v in message.items() if v is not None})
|
||||
|
||||
|
||||
def extract_search_results_text(search_results: object) -> str:
|
||||
"""
|
||||
Extract model-visible text from OpenAI tool-message ``search_results``.
|
||||
|
||||
Used by token estimators and TPM limiters so large search result payloads
|
||||
cannot bypass preflight checks via a small ``content`` field.
|
||||
|
||||
Counts every string field forwarded on Bedrock ``SearchResultBlock``:
|
||||
``source``, ``title``, ``content[].text``, and ``citations``.
|
||||
"""
|
||||
if not isinstance(search_results, list):
|
||||
return ""
|
||||
texts = ""
|
||||
for result in search_results:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
for key in ("source", "title"):
|
||||
value = result.get(key)
|
||||
if isinstance(value, str):
|
||||
texts += value
|
||||
content = result.get("content")
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
texts += text
|
||||
citations = result.get("citations")
|
||||
if citations is not None:
|
||||
texts += json.dumps(citations, separators=(",", ":"))
|
||||
return texts
|
||||
|
||||
|
||||
def convert_content_list_to_str(
|
||||
message: Union[AllMessageValues, ChatCompletionResponseMessage],
|
||||
) -> str:
|
||||
|
|
@ -152,6 +186,7 @@ def convert_content_list_to_str(
|
|||
elif message_content is not None and isinstance(message_content, str):
|
||||
texts = message_content
|
||||
|
||||
texts += extract_search_results_text(message.get("search_results"))
|
||||
return texts
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3658,6 +3658,7 @@ from litellm.types.llms.bedrock import (
|
|||
ToolInputSchemaBlock as BedrockToolInputSchemaBlock,
|
||||
)
|
||||
from litellm.types.llms.bedrock import ToolJsonSchemaBlock as BedrockToolJsonSchemaBlock
|
||||
from litellm.types.llms.bedrock import SearchResultBlock
|
||||
from litellm.types.llms.bedrock import ToolResultBlock as BedrockToolResultBlock
|
||||
from litellm.types.llms.bedrock import (
|
||||
ToolResultContentBlock as BedrockToolResultContentBlock,
|
||||
|
|
@ -4063,6 +4064,122 @@ def _convert_to_bedrock_tool_call_invoke(
|
|||
)
|
||||
|
||||
|
||||
def _append_bedrock_tool_result_media_block(
|
||||
tool_result_content_blocks: List[BedrockToolResultContentBlock],
|
||||
processed_block: BedrockContentBlock,
|
||||
content: dict,
|
||||
content_type: str,
|
||||
) -> None:
|
||||
if "image" in processed_block:
|
||||
tool_result_content_blocks.append(
|
||||
BedrockToolResultContentBlock(image=processed_block["image"])
|
||||
)
|
||||
elif "document" in processed_block:
|
||||
tool_result_content_blocks.append(
|
||||
BedrockToolResultContentBlock(document=processed_block["document"])
|
||||
)
|
||||
else:
|
||||
verbose_logger.warning(
|
||||
"Bedrock Converse: unrecognized BedrockContentBlock keys "
|
||||
"%s for %s tool-result block %s; dropping.",
|
||||
list(processed_block.keys()),
|
||||
content_type,
|
||||
content,
|
||||
)
|
||||
|
||||
|
||||
def _append_bedrock_tool_result_image_url_block(
|
||||
tool_result_content_blocks: List[BedrockToolResultContentBlock],
|
||||
content: dict,
|
||||
) -> None:
|
||||
format: Optional[str] = None
|
||||
if isinstance(content["image_url"], dict):
|
||||
image_url = content["image_url"]["url"]
|
||||
format = content["image_url"].get("format")
|
||||
else:
|
||||
image_url = content["image_url"]
|
||||
processed_block = BedrockImageProcessor.process_image_sync(
|
||||
image_url=image_url,
|
||||
format=format,
|
||||
)
|
||||
_append_bedrock_tool_result_media_block(
|
||||
tool_result_content_blocks, processed_block, content, "image_url"
|
||||
)
|
||||
|
||||
|
||||
def _append_bedrock_tool_result_file_block(
|
||||
tool_result_content_blocks: List[BedrockToolResultContentBlock],
|
||||
content: dict,
|
||||
) -> None:
|
||||
# Match the user-message path (_process_file_message): accept either
|
||||
# file_data (base64 data URI) or file_id (server-side reference / URL).
|
||||
file_obj = content.get("file") or {}
|
||||
file_data = file_obj.get("file_data")
|
||||
file_id = file_obj.get("file_id")
|
||||
if file_data is None and file_id is None:
|
||||
raise litellm.BadRequestError(
|
||||
message="file_data and file_id cannot both be None. Got={}".format(content),
|
||||
model="",
|
||||
llm_provider="bedrock",
|
||||
)
|
||||
processed_block = BedrockImageProcessor.process_image_sync(
|
||||
image_url=cast(str, file_id or file_data),
|
||||
format=file_obj.get("format"),
|
||||
)
|
||||
_append_bedrock_tool_result_media_block(
|
||||
tool_result_content_blocks, processed_block, content, "file"
|
||||
)
|
||||
|
||||
|
||||
def _parse_bedrock_tool_result_content_list(
|
||||
content_list: List,
|
||||
) -> List[BedrockToolResultContentBlock]:
|
||||
tool_result_content_blocks: List[BedrockToolResultContentBlock] = []
|
||||
for content in content_list:
|
||||
if content["type"] == "text":
|
||||
tool_result_content_blocks.append(
|
||||
BedrockToolResultContentBlock(text=content["text"])
|
||||
)
|
||||
elif content["type"] == "image_url":
|
||||
_append_bedrock_tool_result_image_url_block(
|
||||
tool_result_content_blocks, content
|
||||
)
|
||||
elif content["type"] == "file":
|
||||
_append_bedrock_tool_result_file_block(tool_result_content_blocks, content)
|
||||
return tool_result_content_blocks
|
||||
|
||||
|
||||
def _build_bedrock_tool_result_content_blocks(
|
||||
message: Union[ChatCompletionToolMessage, ChatCompletionFunctionMessage],
|
||||
) -> tuple[List[BedrockToolResultContentBlock], bool]:
|
||||
# Optional OpenAI tool-message extension:
|
||||
# allow structured Bedrock search results on tool messages and map them
|
||||
# directly to toolResult.content[].searchResult for Converse API.
|
||||
#
|
||||
# If `search_results` is present, we intentionally prefer it over `content`
|
||||
# to avoid generating mixed text + searchResult blocks.
|
||||
search_results = message.get("search_results")
|
||||
if isinstance(search_results, list):
|
||||
tool_result_content_blocks: List[BedrockToolResultContentBlock] = []
|
||||
for result in search_results:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
tool_result_content_blocks.append(
|
||||
BedrockToolResultContentBlock(
|
||||
searchResult=cast(SearchResultBlock, result)
|
||||
)
|
||||
)
|
||||
if tool_result_content_blocks:
|
||||
return tool_result_content_blocks, True
|
||||
|
||||
message_content = message["content"]
|
||||
if isinstance(message_content, str):
|
||||
return [BedrockToolResultContentBlock(text=message_content)], False
|
||||
if isinstance(message_content, List):
|
||||
return _parse_bedrock_tool_result_content_list(message_content), False
|
||||
return [], False
|
||||
|
||||
|
||||
def _convert_to_bedrock_tool_call_result(
|
||||
message: Union[ChatCompletionToolMessage, ChatCompletionFunctionMessage],
|
||||
) -> BedrockContentBlock:
|
||||
|
|
@ -4106,90 +4223,18 @@ def _convert_to_bedrock_tool_call_result(
|
|||
"""
|
||||
-
|
||||
"""
|
||||
tool_result_content_blocks: List[BedrockToolResultContentBlock] = []
|
||||
if isinstance(message["content"], str):
|
||||
tool_result_content_blocks.append(
|
||||
BedrockToolResultContentBlock(text=message["content"])
|
||||
)
|
||||
elif isinstance(message["content"], List):
|
||||
content_list = message["content"]
|
||||
for content in content_list:
|
||||
if content["type"] == "text":
|
||||
tool_result_content_blocks.append(
|
||||
BedrockToolResultContentBlock(text=content["text"])
|
||||
)
|
||||
elif content["type"] == "image_url":
|
||||
format: Optional[str] = None
|
||||
if isinstance(content["image_url"], dict):
|
||||
image_url = content["image_url"]["url"]
|
||||
format = content["image_url"].get("format")
|
||||
else:
|
||||
image_url = content["image_url"]
|
||||
_block: BedrockContentBlock = BedrockImageProcessor.process_image_sync(
|
||||
image_url=image_url,
|
||||
format=format,
|
||||
)
|
||||
if "image" in _block:
|
||||
tool_result_content_blocks.append(
|
||||
BedrockToolResultContentBlock(image=_block["image"])
|
||||
)
|
||||
elif "document" in _block:
|
||||
tool_result_content_blocks.append(
|
||||
BedrockToolResultContentBlock(document=_block["document"])
|
||||
)
|
||||
else:
|
||||
verbose_logger.warning(
|
||||
"Bedrock Converse: unrecognized BedrockContentBlock keys "
|
||||
"%s for image_url tool-result block %s; dropping.",
|
||||
list(_block.keys()),
|
||||
content,
|
||||
)
|
||||
elif content["type"] == "file":
|
||||
# Match the user-message path (_process_file_message): accept
|
||||
# either file_data (base64 data URI) or file_id (server-side
|
||||
# reference / URL) and hand off to BedrockImageProcessor. Raise
|
||||
# BadRequestError on both-None rather than silently dropping.
|
||||
file_obj = content.get("file") or {}
|
||||
file_data = file_obj.get("file_data")
|
||||
file_id = file_obj.get("file_id")
|
||||
if file_data is None and file_id is None:
|
||||
raise litellm.BadRequestError(
|
||||
message="file_data and file_id cannot both be None. Got={}".format(
|
||||
content
|
||||
),
|
||||
model="",
|
||||
llm_provider="bedrock",
|
||||
)
|
||||
file_format = file_obj.get("format")
|
||||
_file_block: BedrockContentBlock = (
|
||||
BedrockImageProcessor.process_image_sync(
|
||||
image_url=cast(str, file_id or file_data),
|
||||
format=file_format,
|
||||
)
|
||||
)
|
||||
if "document" in _file_block:
|
||||
tool_result_content_blocks.append(
|
||||
BedrockToolResultContentBlock(document=_file_block["document"])
|
||||
)
|
||||
elif "image" in _file_block:
|
||||
tool_result_content_blocks.append(
|
||||
BedrockToolResultContentBlock(image=_file_block["image"])
|
||||
)
|
||||
else:
|
||||
verbose_logger.warning(
|
||||
"Bedrock Converse: unrecognized BedrockContentBlock keys "
|
||||
"%s for file tool-result block %s; dropping.",
|
||||
list(_file_block.keys()),
|
||||
content,
|
||||
)
|
||||
tool_result_content_blocks, used_search_results = (
|
||||
_build_bedrock_tool_result_content_blocks(message)
|
||||
)
|
||||
|
||||
message.get("name", "")
|
||||
id = str(message.get("tool_call_id", str(uuid.uuid4())))
|
||||
|
||||
tool_result = BedrockToolResultBlock(
|
||||
content=tool_result_content_blocks,
|
||||
toolUseId=id,
|
||||
content=tool_result_content_blocks, toolUseId=id
|
||||
)
|
||||
if used_search_results:
|
||||
tool_result["status"] = cast(Literal["success"], "success")
|
||||
|
||||
content_block = BedrockContentBlock(toolResult=tool_result)
|
||||
|
||||
|
|
|
|||
|
|
@ -1835,8 +1835,10 @@ class CustomStreamWrapper:
|
|||
processed_chunk, None, None, cache_hit
|
||||
)
|
||||
)
|
||||
## SYNC LOGGING
|
||||
self.logging_obj.success_handler(processed_chunk, None, None, cache_hit)
|
||||
## SYNC LOGGING — only for sync SDK entrypoints; async proxy paths export via async_success_handler
|
||||
litellm_params = self.logging_obj.model_call_details.get("litellm_params", {})
|
||||
if self.logging_obj._is_sync_litellm_request(litellm_params):
|
||||
self.logging_obj.success_handler(processed_chunk, None, None, cache_hit)
|
||||
|
||||
def finish_reason_handler(self):
|
||||
model_response = self.model_response_creator()
|
||||
|
|
@ -2231,23 +2233,19 @@ class CustomStreamWrapper:
|
|||
cache_hit,
|
||||
)
|
||||
else:
|
||||
# prefer_async_handlers routes CustomLogger to async_success_handler
|
||||
# when consumers use ``async for`` on sync-SDK streams. Legacy string
|
||||
# callbacks still run via executor.submit inside dispatch_success_handlers.
|
||||
asyncio.create_task(
|
||||
self.logging_obj.async_success_handler(
|
||||
self.logging_obj.dispatch_success_handlers(
|
||||
complete_streaming_response,
|
||||
cache_hit=cache_hit,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
prefer_async_handlers=True,
|
||||
)
|
||||
)
|
||||
|
||||
executor.submit(
|
||||
self.logging_obj.success_handler,
|
||||
complete_streaming_response,
|
||||
cache_hit=cache_hit,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
)
|
||||
|
||||
raise StopAsyncIteration # Re-raise StopIteration
|
||||
else:
|
||||
self.sent_last_chunk = True
|
||||
|
|
|
|||
|
|
@ -486,6 +486,14 @@ def _count_messages(
|
|||
use_default_image_token_count,
|
||||
default_token_count,
|
||||
)
|
||||
elif key == "search_results" and isinstance(value, list):
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
extract_search_results_text,
|
||||
)
|
||||
|
||||
search_results_text = extract_search_results_text(value)
|
||||
if search_results_text:
|
||||
num_tokens += params.count_function(search_results_text)
|
||||
else:
|
||||
# Skip unsupported keys instead of raising an error
|
||||
continue
|
||||
|
|
@ -764,11 +772,29 @@ def _format_function_definitions(tools):
|
|||
lines.append("namespace functions {")
|
||||
lines.append("")
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
function = tool.get("function")
|
||||
if not isinstance(function, dict):
|
||||
# Anthropic tool shape → OpenAI function dict for token counting.
|
||||
params = tool.get("input_schema") or tool.get("parameters") or {}
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
function = {
|
||||
"name": tool.get("name"),
|
||||
"description": tool.get("description"),
|
||||
"parameters": params,
|
||||
}
|
||||
function_name = function.get("name")
|
||||
if not function_name:
|
||||
# Skip malformed tools missing a name to avoid emitting
|
||||
# ``type None = ...`` which would produce inaccurate token counts.
|
||||
continue
|
||||
if function_description := function.get("description"):
|
||||
lines.append(f"// {function_description}")
|
||||
function_name = function.get("name")
|
||||
parameters = function.get("parameters", {})
|
||||
parameters = function.get("parameters") or {}
|
||||
if not isinstance(parameters, dict):
|
||||
parameters = {}
|
||||
properties = parameters.get("properties")
|
||||
if properties and properties.keys():
|
||||
lines.append(f"type {function_name} = (_: {{")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from typing import (
|
|||
AsyncIterator,
|
||||
Coroutine,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
|
|
@ -12,9 +13,16 @@ from typing import (
|
|||
)
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.asyncify import run_async_function
|
||||
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
|
||||
AnthropicAdapter,
|
||||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.context_management import (
|
||||
AnthropicContextManagementError,
|
||||
PolyfillResult,
|
||||
apply_context_management,
|
||||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.utils import (
|
||||
is_reasoning_auto_summary_enabled,
|
||||
)
|
||||
|
|
@ -28,15 +36,266 @@ if TYPE_CHECKING:
|
|||
pass
|
||||
|
||||
|
||||
# Anthropic-only fields that the translator above already maps into the
|
||||
# OpenAI-format completion_kwargs (output_config → reasoning_effort /
|
||||
# response_format, etc.). They must be filtered out of the raw
|
||||
# extra_kwargs re-merge below or non-Anthropic backends reject the call
|
||||
# with 400 "Extra inputs are not permitted". Add new entries here when
|
||||
# extending AnthropicMessagesRequestOptionalParams with another Anthropic-
|
||||
# specific key.
|
||||
# Anthropic-only keys already mapped by the translator; strip on extra_kwargs re-merge.
|
||||
ANTHROPIC_ONLY_REQUEST_KEYS: frozenset[str] = frozenset({"output_config"})
|
||||
|
||||
|
||||
def _messages_have_compaction_block(messages: List[Dict]) -> bool:
|
||||
"""Return True when any message carries a ``compaction`` content block."""
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "compaction":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _extract_proxy_litellm_metadata(kwargs: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Return ``kwargs["litellm_metadata"]`` when it's a dict; ``None`` otherwise.
|
||||
|
||||
The proxy attaches its auth/spend-attribution fields (``user_api_key``,
|
||||
``user_api_key_team_id``, ``litellm_call_id``, the full ``UserAPIKeyAuth``
|
||||
object under ``user_api_key_auth``, ...) to ``data["litellm_metadata"]``
|
||||
for ``/v1/messages`` (see
|
||||
``LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata`` and
|
||||
``LITELLM_METADATA_ROUTES``). The Anthropic-shape ``metadata`` arg only
|
||||
carries ``user_id`` and must not be conflated. Returns ``None`` for SDK
|
||||
callers that bypass the proxy entirely.
|
||||
"""
|
||||
litellm_metadata = kwargs.get("litellm_metadata")
|
||||
if not isinstance(litellm_metadata, dict):
|
||||
return None
|
||||
return litellm_metadata
|
||||
|
||||
|
||||
async def _prepare_context_managed_request(
|
||||
*,
|
||||
model: str,
|
||||
messages: List[Dict],
|
||||
tools: Optional[List[Dict]],
|
||||
system: Optional[Any],
|
||||
context_management_spec: Any,
|
||||
litellm_metadata: Optional[Dict],
|
||||
drop_params: Optional[bool],
|
||||
llm_router: Any,
|
||||
user_api_key_auth: Any = None,
|
||||
) -> Optional[PolyfillResult]:
|
||||
"""Apply client compaction history, then optional context_management polyfill."""
|
||||
from litellm.llms.anthropic.experimental_pass_through.context_management.editors.compact import (
|
||||
apply_client_compaction_block_history,
|
||||
)
|
||||
|
||||
# Skip the client-history pre-processing when a ``compact_20260112``
|
||||
# polyfill spec will run: that editor already slices around any client-sent
|
||||
# compaction block in its Phase A (and uses the full post-compaction tail
|
||||
# for its token-threshold check). Pre-collapsing to just the latest user
|
||||
# question here would starve the polyfill of conversation context and
|
||||
# silently drop intermediate turns.
|
||||
polyfill_will_run = _polyfill_will_run(
|
||||
context_management_spec=context_management_spec,
|
||||
drop_params=drop_params,
|
||||
)
|
||||
|
||||
if polyfill_will_run:
|
||||
history_result: Optional[PolyfillResult] = None
|
||||
working_messages: List[Dict] = messages
|
||||
working_system: Optional[Any] = system
|
||||
else:
|
||||
history_result = apply_client_compaction_block_history(
|
||||
messages=cast(List[Dict[str, Any]], messages),
|
||||
system=system,
|
||||
)
|
||||
working_messages = (
|
||||
history_result.messages if history_result is not None else messages
|
||||
)
|
||||
working_system = history_result.system if history_result is not None else system
|
||||
|
||||
polyfill_result = await _run_polyfill_if_enabled(
|
||||
model=model,
|
||||
messages=working_messages,
|
||||
tools=tools,
|
||||
system=working_system,
|
||||
context_management_spec=context_management_spec,
|
||||
litellm_metadata=litellm_metadata,
|
||||
drop_params=drop_params,
|
||||
llm_router=llm_router,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
)
|
||||
|
||||
if polyfill_result is not None:
|
||||
return polyfill_result
|
||||
|
||||
# Safety net: if we skipped client-history pre-processing because a
|
||||
# ``compact_20260112`` polyfill was expected to handle the compaction
|
||||
# block itself but the polyfill ultimately did not produce a result
|
||||
# (e.g. it crashed and was best-effort swallowed in
|
||||
# ``_run_polyfill_if_enabled``), apply the slice-only fallback now so
|
||||
# Anthropic-specific ``compaction`` content blocks don't leak through
|
||||
# to non-Anthropic backends that would reject them.
|
||||
if polyfill_will_run and history_result is None:
|
||||
history_result = apply_client_compaction_block_history(
|
||||
messages=cast(List[Dict[str, Any]], messages),
|
||||
system=system,
|
||||
)
|
||||
return history_result
|
||||
|
||||
|
||||
def _polyfill_will_run(
|
||||
*,
|
||||
context_management_spec: Any,
|
||||
drop_params: Optional[bool],
|
||||
) -> bool:
|
||||
"""Return True when ``compact_20260112`` will run via the polyfill dispatcher.
|
||||
|
||||
Mirrors the gating in ``_run_polyfill_if_enabled``: an empty spec or
|
||||
effective ``drop_params`` short-circuits the polyfill. The pre-processing
|
||||
skip only applies when the dispatcher will actually invoke
|
||||
``apply_compact_20260112`` (which has its own compaction-block slicing).
|
||||
"""
|
||||
edits = _normalize_spec_edits(
|
||||
context_management_spec=context_management_spec,
|
||||
drop_params=drop_params,
|
||||
)
|
||||
if edits is None:
|
||||
return False
|
||||
|
||||
from litellm.llms.anthropic.experimental_pass_through.context_management.constants import (
|
||||
COMPACT_EDIT_TYPE,
|
||||
)
|
||||
|
||||
return any(
|
||||
isinstance(edit, dict) and edit.get("type") == COMPACT_EDIT_TYPE
|
||||
for edit in edits
|
||||
)
|
||||
|
||||
|
||||
def _spec_has_non_compact_edits(
|
||||
*,
|
||||
context_management_spec: Any,
|
||||
drop_params: Optional[bool],
|
||||
) -> bool:
|
||||
"""Return True when the spec includes edits other than ``compact_20260112``.
|
||||
|
||||
Used to decide whether a polyfill failure can be silently swallowed
|
||||
(compact-only specs have a safe compaction-block slicing fallback) or
|
||||
must be surfaced (other editors like ``clear_tool_uses_20250919`` have
|
||||
no slice-only fallback and would otherwise be dropped without notice).
|
||||
"""
|
||||
edits = _normalize_spec_edits(
|
||||
context_management_spec=context_management_spec,
|
||||
drop_params=drop_params,
|
||||
)
|
||||
if edits is None:
|
||||
return False
|
||||
|
||||
from litellm.llms.anthropic.experimental_pass_through.context_management.constants import (
|
||||
COMPACT_EDIT_TYPE,
|
||||
)
|
||||
|
||||
return any(
|
||||
isinstance(edit, dict)
|
||||
and isinstance(edit.get("type"), str)
|
||||
and edit.get("type") != COMPACT_EDIT_TYPE
|
||||
for edit in edits
|
||||
)
|
||||
|
||||
|
||||
def _normalize_spec_edits(
|
||||
*,
|
||||
context_management_spec: Any,
|
||||
drop_params: Optional[bool],
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Return the normalized ``edits`` list, or ``None`` if the polyfill won't run.
|
||||
|
||||
Delegates spec-shape normalization to the dispatcher's ``_normalize_spec``
|
||||
so the prediction here can't drift from what the dispatcher actually does.
|
||||
"""
|
||||
if not context_management_spec:
|
||||
return None
|
||||
|
||||
effective_drop_params = (
|
||||
drop_params if drop_params is not None else litellm.drop_params
|
||||
)
|
||||
if effective_drop_params:
|
||||
return None
|
||||
|
||||
from litellm.llms.anthropic.experimental_pass_through.context_management.dispatcher import (
|
||||
_normalize_spec,
|
||||
)
|
||||
|
||||
try:
|
||||
return _normalize_spec(context_management_spec)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _run_polyfill_if_enabled(
|
||||
*,
|
||||
model: str,
|
||||
messages: List[Dict],
|
||||
tools: Optional[List[Dict]],
|
||||
system: Optional[Any],
|
||||
context_management_spec: Any,
|
||||
litellm_metadata: Optional[Dict],
|
||||
drop_params: Optional[bool],
|
||||
llm_router: Any,
|
||||
user_api_key_auth: Any = None,
|
||||
) -> Optional[PolyfillResult]:
|
||||
"""Run the async context_management polyfill if a spec is present.
|
||||
|
||||
Returns ``None`` when the spec is empty or drop_params is on. Raises
|
||||
``AnthropicContextManagementError`` so the /v1/messages endpoint can
|
||||
emit an Anthropic-format 400. All other exceptions are best-effort
|
||||
swallowed (matches v0 behavior).
|
||||
"""
|
||||
if not context_management_spec:
|
||||
return None
|
||||
|
||||
effective_drop_params = (
|
||||
drop_params if drop_params is not None else litellm.drop_params
|
||||
)
|
||||
if effective_drop_params:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await apply_context_management(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
system=system,
|
||||
context_management_spec=context_management_spec,
|
||||
litellm_metadata=litellm_metadata,
|
||||
llm_router=llm_router,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
)
|
||||
except AnthropicContextManagementError:
|
||||
# Surface validation errors so the endpoint can emit an Anthropic-format
|
||||
# 400. Other exception types fall into the best-effort branch below.
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_logger.exception(
|
||||
"context_management polyfill: skipping edits due to error: %s", e
|
||||
)
|
||||
# Best-effort swallow is only safe for compact-only specs, where the
|
||||
# caller's compaction-block-slicing safety net produces a correct
|
||||
# (if degraded) result. When the spec also requested non-compact
|
||||
# edits (e.g. ``clear_tool_uses_20250919``), the safety net does
|
||||
# NOT re-run those editors, so silently returning ``None`` would
|
||||
# drop them with no error surface. Raise instead so the endpoint
|
||||
# emits an Anthropic-format error.
|
||||
if _spec_has_non_compact_edits(
|
||||
context_management_spec=context_management_spec,
|
||||
drop_params=drop_params,
|
||||
):
|
||||
raise AnthropicContextManagementError(
|
||||
status_code=500,
|
||||
message=f"context_management polyfill failed: {e}",
|
||||
) from e
|
||||
return None
|
||||
|
||||
|
||||
########################################################
|
||||
# init adapter
|
||||
ANTHROPIC_ADAPTER = AnthropicAdapter()
|
||||
|
|
@ -163,7 +422,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
metadata: Optional[Dict] = None,
|
||||
stop_sequences: Optional[List[str]] = None,
|
||||
stream: Optional[bool] = False,
|
||||
system: Optional[str] = None,
|
||||
system: Optional[Union[str, List[Dict[str, Any]]]] = None,
|
||||
temperature: Optional[float] = None,
|
||||
thinking: Optional[Dict] = None,
|
||||
tool_choice: Optional[Dict] = None,
|
||||
|
|
@ -307,19 +566,56 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
top_p: Optional[float] = None,
|
||||
output_format: Optional[Dict] = None,
|
||||
**kwargs,
|
||||
) -> Union[AnthropicMessagesResponse, AsyncIterator]:
|
||||
) -> Union[AnthropicMessagesResponse, AsyncIterator[Any], Iterator[bytes]]:
|
||||
"""Handle non-Anthropic models asynchronously using the adapter"""
|
||||
context_management = kwargs.pop("context_management", None)
|
||||
drop_params: Optional[bool] = kwargs.get("drop_params", None)
|
||||
litellm_router = kwargs.pop("litellm_router", None)
|
||||
if litellm_router is None:
|
||||
try:
|
||||
from litellm.proxy.proxy_server import llm_router as _proxy_router
|
||||
|
||||
litellm_router = _proxy_router
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
proxy_litellm_metadata = _extract_proxy_litellm_metadata(kwargs)
|
||||
user_api_key_auth = (
|
||||
proxy_litellm_metadata.get("user_api_key_auth")
|
||||
if proxy_litellm_metadata is not None
|
||||
else None
|
||||
)
|
||||
|
||||
polyfill_result = await _prepare_context_managed_request(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
system=system,
|
||||
context_management_spec=context_management,
|
||||
litellm_metadata=proxy_litellm_metadata,
|
||||
drop_params=drop_params,
|
||||
llm_router=litellm_router,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
)
|
||||
|
||||
effective_messages = (
|
||||
polyfill_result.messages if polyfill_result is not None else messages
|
||||
)
|
||||
effective_system = (
|
||||
polyfill_result.system if polyfill_result is not None else system
|
||||
)
|
||||
|
||||
(
|
||||
completion_kwargs,
|
||||
tool_name_mapping,
|
||||
) = LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs(
|
||||
max_tokens=max_tokens,
|
||||
messages=messages,
|
||||
messages=effective_messages,
|
||||
model=model,
|
||||
metadata=metadata,
|
||||
stop_sequences=stop_sequences,
|
||||
stream=stream,
|
||||
system=system,
|
||||
system=effective_system,
|
||||
temperature=temperature,
|
||||
thinking=thinking,
|
||||
tool_choice=tool_choice,
|
||||
|
|
@ -338,6 +634,8 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
completion_response,
|
||||
model=model,
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
polyfill_result=polyfill_result,
|
||||
is_async=True,
|
||||
)
|
||||
)
|
||||
if transformed_stream is not None:
|
||||
|
|
@ -347,6 +645,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
anthropic_response = ANTHROPIC_ADAPTER.translate_completion_output_params(
|
||||
cast(ModelResponse, completion_response),
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
polyfill_result=polyfill_result,
|
||||
)
|
||||
if anthropic_response is not None:
|
||||
return anthropic_response
|
||||
|
|
@ -372,8 +671,13 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
**kwargs,
|
||||
) -> Union[
|
||||
AnthropicMessagesResponse,
|
||||
Iterator[bytes],
|
||||
AsyncIterator[Any],
|
||||
Coroutine[Any, Any, Union[AnthropicMessagesResponse, AsyncIterator[Any]]],
|
||||
Coroutine[
|
||||
Any,
|
||||
Any,
|
||||
Union[AnthropicMessagesResponse, AsyncIterator[Any], Iterator[bytes]],
|
||||
],
|
||||
]:
|
||||
"""Handle non-Anthropic models using the adapter."""
|
||||
if _is_async is True:
|
||||
|
|
@ -395,17 +699,72 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
**kwargs,
|
||||
)
|
||||
|
||||
# Run the context_management polyfill on the sync path too so that
|
||||
# ``litellm.messages.create()`` callers don't silently lose edits like
|
||||
# ``clear_tool_uses_20250919``. The dispatcher is async (so the
|
||||
# ``compact_20260112`` editor can ``await`` the summarization model);
|
||||
# bridge to it via ``run_async_function``.
|
||||
context_management = kwargs.pop("context_management", None)
|
||||
drop_params: Optional[bool] = kwargs.get("drop_params", None)
|
||||
# Deliberately do NOT auto-attach the proxy ``llm_router`` here:
|
||||
# ``run_async_function`` spawns a new event loop in a worker thread
|
||||
# to bridge to the async dispatcher, but the proxy router's httpx
|
||||
# ``AsyncClient`` instances are bound to the proxy's main event loop.
|
||||
# Reusing them from the new thread's loop violates httpx's single-loop
|
||||
# invariant and can raise ``RuntimeError: Event loop is closed`` or
|
||||
# produce stalled connections. The summary editor falls back to
|
||||
# ``litellm.acompletion`` (which creates a fresh client per call) when
|
||||
# ``llm_router`` is ``None``, which is safe to call from the bridged
|
||||
# loop. The async ``async_anthropic_messages_handler`` path is
|
||||
# unaffected because it ``await``s within the original event loop.
|
||||
litellm_router = kwargs.pop("litellm_router", None)
|
||||
|
||||
# Skip the async bridge entirely when there is nothing for either the
|
||||
# polyfill or the client-history slice-only fallback to do. The vast
|
||||
# majority of sync ``litellm.messages.create()`` requests carry no
|
||||
# ``context_management`` spec and no client-sent ``compaction`` block,
|
||||
# and bridging through a worker-thread event loop just to discover
|
||||
# there is no work is pure overhead.
|
||||
if context_management is None and not _messages_have_compaction_block(messages):
|
||||
polyfill_result: Optional[PolyfillResult] = None
|
||||
else:
|
||||
proxy_litellm_metadata = _extract_proxy_litellm_metadata(kwargs)
|
||||
user_api_key_auth = (
|
||||
proxy_litellm_metadata.get("user_api_key_auth")
|
||||
if proxy_litellm_metadata is not None
|
||||
else None
|
||||
)
|
||||
polyfill_result = run_async_function(
|
||||
_prepare_context_managed_request,
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
system=system,
|
||||
context_management_spec=context_management,
|
||||
litellm_metadata=proxy_litellm_metadata,
|
||||
drop_params=drop_params,
|
||||
llm_router=litellm_router,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
)
|
||||
|
||||
effective_messages = (
|
||||
polyfill_result.messages if polyfill_result is not None else messages
|
||||
)
|
||||
effective_system = (
|
||||
polyfill_result.system if polyfill_result is not None else system
|
||||
)
|
||||
|
||||
(
|
||||
completion_kwargs,
|
||||
tool_name_mapping,
|
||||
) = LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs(
|
||||
max_tokens=max_tokens,
|
||||
messages=messages,
|
||||
messages=effective_messages,
|
||||
model=model,
|
||||
metadata=metadata,
|
||||
stop_sequences=stop_sequences,
|
||||
stream=stream,
|
||||
system=system,
|
||||
system=effective_system,
|
||||
temperature=temperature,
|
||||
thinking=thinking,
|
||||
tool_choice=tool_choice,
|
||||
|
|
@ -424,6 +783,8 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
completion_response,
|
||||
model=model,
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
polyfill_result=polyfill_result,
|
||||
is_async=False,
|
||||
)
|
||||
)
|
||||
if transformed_stream is not None:
|
||||
|
|
@ -433,6 +794,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
|||
anthropic_response = ANTHROPIC_ADAPTER.translate_completion_output_params(
|
||||
cast(ModelResponse, completion_response),
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
polyfill_result=polyfill_result,
|
||||
)
|
||||
if anthropic_response is not None:
|
||||
return anthropic_response
|
||||
|
|
|
|||
|
|
@ -3,11 +3,26 @@
|
|||
import json
|
||||
import traceback
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, Iterator, Literal, Optional
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
)
|
||||
|
||||
from litellm import verbose_logger
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.types.llms.anthropic import UsageDelta
|
||||
from litellm.types.llms.anthropic import (
|
||||
AppliedEdit,
|
||||
CompactionBlock,
|
||||
ContextManagementResponse,
|
||||
UsageDelta,
|
||||
UsageIteration,
|
||||
)
|
||||
from litellm.types.utils import AdapterCompletionStreamWrapper
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -37,22 +52,208 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
holding_stop_reason_chunk: Optional[Any] = None
|
||||
queued_usage_chunk: bool = False
|
||||
current_content_block_index: int = 0
|
||||
current_content_block_start: ContentBlockContentBlockDict = TextBlock(
|
||||
type="text",
|
||||
text="",
|
||||
)
|
||||
chunk_queue: deque = deque() # Queue for buffering multiple chunks
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
completion_stream: Any,
|
||||
model: str,
|
||||
tool_name_mapping: Optional[Dict[str, str]] = None,
|
||||
applied_edits: Optional[List[AppliedEdit]] = None,
|
||||
compaction_block: Optional[CompactionBlock] = None,
|
||||
iterations_usage: Optional[List[UsageIteration]] = None,
|
||||
):
|
||||
super().__init__(completion_stream)
|
||||
self.model = model
|
||||
# Mapping of truncated tool names to original names (for OpenAI's 64-char limit)
|
||||
self.tool_name_mapping = tool_name_mapping or {}
|
||||
# Polyfill applied_edits on final message_delta.
|
||||
self.applied_edits: List[AppliedEdit] = list(applied_edits or [])
|
||||
# Synthesized compaction block from compact_20260112 polyfill (streaming).
|
||||
self.compaction_block = compaction_block
|
||||
self.iterations_usage = iterations_usage
|
||||
self.sent_compaction_block: bool = False
|
||||
# Per-phase flags so the compaction block's start/delta/stop events
|
||||
# are emitted (and the public state machine is advanced) in
|
||||
# lock-step with the caller actually consuming each event. Pre-
|
||||
# queuing all three would set ``sent_content_block_finish=True``
|
||||
# before the client received ``content_block_stop``, leaving the
|
||||
# observable state inconsistent during the drain window.
|
||||
self.sent_compaction_block_start: bool = False
|
||||
self.sent_compaction_block_delta: bool = False
|
||||
# Per-instance queue for buffering multiple chunks. Must be initialized
|
||||
# here (not at class level) so concurrent streams don't share the same
|
||||
# deque and corrupt each other's SSE event order.
|
||||
self.chunk_queue: deque = deque()
|
||||
# Per-instance default content block. Must be initialized here (not at
|
||||
# class level) so concurrent streams don't share the same mutable dict
|
||||
# — `_should_start_new_content_block` mutates `tool_block["name"]` in
|
||||
# place, which would otherwise leak across streams.
|
||||
self.current_content_block_start: (
|
||||
"AnthropicStreamWrapper.ContentBlockContentBlockDict"
|
||||
) = self.TextBlock(
|
||||
type="text",
|
||||
text="",
|
||||
)
|
||||
|
||||
def _merge_usage_into_held_stop_reason_chunk(self, chunk: Any) -> Dict[str, Any]:
|
||||
"""Merge usage data from ``chunk`` into the held ``message_delta`` chunk.
|
||||
|
||||
Shared by both the sync ``__next__`` and async ``__anext__`` paths so
|
||||
the subtle hold-and-merge logic (cache tokens, ``context_management``
|
||||
attachment, ``UsageDelta`` shape) lives in exactly one place.
|
||||
|
||||
Caller is responsible for managing ``self.holding_stop_reason_chunk``
|
||||
and ``self.queued_usage_chunk`` state and for queuing the returned
|
||||
merged chunk.
|
||||
"""
|
||||
assert self.holding_stop_reason_chunk is not None
|
||||
merged_chunk = self.holding_stop_reason_chunk.copy()
|
||||
if "delta" not in merged_chunk:
|
||||
merged_chunk["delta"] = {}
|
||||
|
||||
uncached_input_tokens = chunk.usage.prompt_tokens or 0
|
||||
if (
|
||||
hasattr(chunk.usage, "prompt_tokens_details")
|
||||
and chunk.usage.prompt_tokens_details
|
||||
):
|
||||
cached_tokens = (
|
||||
getattr(chunk.usage.prompt_tokens_details, "cached_tokens", 0) or 0
|
||||
)
|
||||
uncached_input_tokens -= cached_tokens
|
||||
|
||||
usage_dict: UsageDelta = {
|
||||
"input_tokens": uncached_input_tokens,
|
||||
"output_tokens": chunk.usage.completion_tokens or 0,
|
||||
}
|
||||
if (
|
||||
hasattr(chunk.usage, "_cache_creation_input_tokens")
|
||||
and chunk.usage._cache_creation_input_tokens > 0
|
||||
):
|
||||
usage_dict["cache_creation_input_tokens"] = (
|
||||
chunk.usage._cache_creation_input_tokens
|
||||
)
|
||||
if (
|
||||
hasattr(chunk.usage, "_cache_read_input_tokens")
|
||||
and chunk.usage._cache_read_input_tokens > 0
|
||||
):
|
||||
usage_dict["cache_read_input_tokens"] = chunk.usage._cache_read_input_tokens
|
||||
merged_chunk["usage"] = usage_dict
|
||||
if self.applied_edits and "context_management" not in merged_chunk:
|
||||
merged_chunk["context_management"] = ContextManagementResponse(
|
||||
applied_edits=list(self.applied_edits)
|
||||
)
|
||||
return self._augment_message_delta_usage(merged_chunk)
|
||||
|
||||
def _ensure_context_management_attached(
|
||||
self, message_delta_chunk: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Attach ``context_management`` to a ``message_delta`` chunk if
|
||||
``self.applied_edits`` is non-empty and the chunk does not already
|
||||
carry it. Returns the (possibly new) chunk dict.
|
||||
|
||||
Centralizing this guard ensures every ``message_delta`` emission
|
||||
path (merge-with-usage and direct-flush-of-held) consistently
|
||||
surfaces ``applied_edits`` to the client.
|
||||
"""
|
||||
if not self.applied_edits or "context_management" in message_delta_chunk:
|
||||
return message_delta_chunk
|
||||
augmented = message_delta_chunk.copy()
|
||||
augmented["context_management"] = ContextManagementResponse(
|
||||
applied_edits=list(self.applied_edits)
|
||||
)
|
||||
return augmented
|
||||
|
||||
def _augment_message_delta_usage(
|
||||
self, message_delta_chunk: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Attach polyfill compaction iteration usage to the final message_delta.
|
||||
|
||||
Also defensively re-attaches ``context_management`` so the direct
|
||||
held-chunk flush path stays in sync with the merge path's guarantee
|
||||
when ``self.applied_edits`` is non-empty.
|
||||
"""
|
||||
message_delta_chunk = self._ensure_context_management_attached(
|
||||
message_delta_chunk
|
||||
)
|
||||
if self.iterations_usage is None:
|
||||
return message_delta_chunk
|
||||
usage = message_delta_chunk.get("usage")
|
||||
if not isinstance(usage, dict) or "iterations" in usage:
|
||||
return message_delta_chunk
|
||||
|
||||
input_tokens = usage.get("input_tokens", 0) or 0
|
||||
output_tokens = usage.get("output_tokens", 0) or 0
|
||||
augmented = message_delta_chunk.copy()
|
||||
augmented_usage = dict(usage)
|
||||
iterations: List[UsageIteration] = list(self.iterations_usage)
|
||||
# Only emit a ``message`` iteration when we have real token data.
|
||||
# Without a separate usage chunk (e.g. provider sent finish_reason
|
||||
# alone), the held ``message_delta`` carries placeholder zeros from
|
||||
# the translate step; reporting a zero-token iteration would be
|
||||
# misleading and inconsistent with the non-streaming path.
|
||||
if input_tokens > 0 or output_tokens > 0:
|
||||
message_iteration: UsageIteration = {
|
||||
"type": "message",
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
iterations.append(message_iteration)
|
||||
augmented_usage["iterations"] = iterations # type: ignore[typeddict-unknown-key]
|
||||
augmented["usage"] = augmented_usage
|
||||
return augmented
|
||||
|
||||
def _next_compaction_event(self) -> Optional[Dict[str, Any]]:
|
||||
"""Return the next compaction content-block SSE event, or ``None``.
|
||||
|
||||
Anthropic delivers compaction as a single delta (no token-by-token
|
||||
streaming), but we still surface it as a proper
|
||||
start → delta → stop trio. Each call returns exactly one event so
|
||||
the state machine (``sent_content_block_finish``,
|
||||
``current_content_block_index``) is advanced *only* when the
|
||||
terminal stop event is actually handed back to the caller. This
|
||||
prevents an observable window where the flags claim the block is
|
||||
finished while the stop event is still buffered.
|
||||
"""
|
||||
if self.compaction_block is None or self.sent_compaction_block:
|
||||
return None
|
||||
|
||||
compaction_index = self.current_content_block_index
|
||||
|
||||
if not self.sent_compaction_block_start:
|
||||
self.sent_compaction_block_start = True
|
||||
return {
|
||||
"type": "content_block_start",
|
||||
"index": compaction_index,
|
||||
# Mirror the text-block shape ({"type": "text", "text": ""}):
|
||||
# send an empty ``content`` field so clients that introspect
|
||||
# ``content_block_start`` see the full block schema. The
|
||||
# actual summary text arrives via the ``content_block_delta``
|
||||
# below.
|
||||
"content_block": {"type": "compaction", "content": ""},
|
||||
}
|
||||
|
||||
if not self.sent_compaction_block_delta:
|
||||
self.sent_compaction_block_delta = True
|
||||
summary_content = self.compaction_block.get("content") or ""
|
||||
return {
|
||||
"type": "content_block_delta",
|
||||
"index": compaction_index,
|
||||
"delta": {"type": "compaction_delta", "content": summary_content},
|
||||
}
|
||||
|
||||
stop_event = {
|
||||
"type": "content_block_stop",
|
||||
"index": compaction_index,
|
||||
}
|
||||
# Don't touch ``sent_content_block_finish`` here: that flag is the
|
||||
# state machine for the regular text/tool_use/thinking block and is
|
||||
# independent of the synthetic compaction block lifecycle. Conflating
|
||||
# them would let outside observers (subclass overrides, introspection
|
||||
# hooks, exception paths) see ``sent_content_block_finish=True``
|
||||
# without any regular content block ever having started.
|
||||
self._increment_content_block_index()
|
||||
self.sent_compaction_block = True
|
||||
return stop_event
|
||||
|
||||
def _create_initial_usage_delta(self) -> UsageDelta:
|
||||
"""
|
||||
|
|
@ -75,7 +276,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
cache_read_input_tokens=0,
|
||||
)
|
||||
|
||||
def __next__(self):
|
||||
def __next__(self): # noqa: PLR0915
|
||||
from .transformation import LiteLLMAnthropicMessagesAdapter
|
||||
|
||||
try:
|
||||
|
|
@ -103,8 +304,17 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
)
|
||||
return self.chunk_queue.popleft()
|
||||
|
||||
if (
|
||||
self.sent_compaction_block is False
|
||||
and self.compaction_block is not None
|
||||
):
|
||||
compaction_event = self._next_compaction_event()
|
||||
if compaction_event is not None:
|
||||
return compaction_event
|
||||
|
||||
if self.sent_content_block_start is False:
|
||||
self.sent_content_block_start = True
|
||||
self.sent_content_block_finish = False
|
||||
self.chunk_queue.append(
|
||||
{
|
||||
"type": "content_block_start",
|
||||
|
|
@ -122,11 +332,45 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
if should_start_new_block:
|
||||
self._increment_content_block_index()
|
||||
|
||||
# applied_edits only needs to flow to the final message_delta
|
||||
# (when finish_reason is set); skip threading it through every
|
||||
# intermediate chunk. For the hold-and-merge path below,
|
||||
# context_management is attached directly to the merged chunk,
|
||||
# so the translated ``processed_chunk`` would be discarded —
|
||||
# skip the applied_edits attachment in that case to avoid
|
||||
# allocating a throwaway ``MessageBlockDelta``.
|
||||
will_merge_into_held = (
|
||||
self.holding_stop_reason_chunk is not None
|
||||
and getattr(chunk, "usage", None) is not None
|
||||
)
|
||||
is_final_chunk = chunk.choices[0].finish_reason is not None
|
||||
processed_chunk = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_response_to_anthropic(
|
||||
response=chunk,
|
||||
current_content_block_index=self.current_content_block_index,
|
||||
applied_edits=(
|
||||
self.applied_edits
|
||||
if is_final_chunk and not will_merge_into_held
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
# Check if this is a usage chunk and we have a held stop_reason chunk
|
||||
if will_merge_into_held:
|
||||
merged_chunk = self._merge_usage_into_held_stop_reason_chunk(chunk)
|
||||
self.chunk_queue.append(merged_chunk)
|
||||
self.queued_usage_chunk = True
|
||||
self.holding_stop_reason_chunk = None
|
||||
return self.chunk_queue.popleft()
|
||||
|
||||
if self.queued_usage_chunk:
|
||||
# Usage has already been merged + emitted. Any trailing
|
||||
# provider events would violate Anthropic SSE ordering
|
||||
# (no chunks may follow the final ``message_delta``), so
|
||||
# silently drop them — matches the async ``__anext__``
|
||||
# behavior where the block-handling logic is gated on
|
||||
# ``not self.queued_usage_chunk``.
|
||||
continue
|
||||
|
||||
if should_start_new_block and not self.sent_content_block_finish:
|
||||
# Queue the sequence: content_block_stop -> content_block_start
|
||||
# For text blocks the trigger chunk is not emitted as a separate
|
||||
|
|
@ -178,20 +422,64 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
}
|
||||
)
|
||||
self.sent_content_block_finish = True
|
||||
self.chunk_queue.append(processed_chunk)
|
||||
if processed_chunk.get("delta", {}).get("stop_reason") is not None:
|
||||
self.holding_stop_reason_chunk = processed_chunk
|
||||
else:
|
||||
processed_chunk = self._augment_message_delta_usage(
|
||||
processed_chunk
|
||||
)
|
||||
self.chunk_queue.append(processed_chunk)
|
||||
return self.chunk_queue.popleft()
|
||||
elif self.holding_chunk is not None:
|
||||
self.chunk_queue.append(self.holding_chunk)
|
||||
if processed_chunk.get("type") == "message_delta":
|
||||
processed_chunk = self._augment_message_delta_usage(
|
||||
processed_chunk
|
||||
)
|
||||
self.chunk_queue.append(processed_chunk)
|
||||
self.holding_chunk = None
|
||||
return self.chunk_queue.popleft()
|
||||
else:
|
||||
if processed_chunk.get("type") == "message_delta":
|
||||
processed_chunk = self._augment_message_delta_usage(
|
||||
processed_chunk
|
||||
)
|
||||
self.chunk_queue.append(processed_chunk)
|
||||
return self.chunk_queue.popleft()
|
||||
|
||||
# Handle any remaining held chunks after stream ends
|
||||
if self.holding_chunk is not None:
|
||||
self.chunk_queue.append(self.holding_chunk)
|
||||
# Handle any remaining held chunks after stream ends. The
|
||||
# buffered ``holding_chunk`` (a ``content_block_delta``) must
|
||||
# precede the final ``message_delta`` so Anthropic SSE event
|
||||
# ordering is preserved. When ``queued_usage_chunk`` is True,
|
||||
# the final ``message_delta`` has already been emitted; any
|
||||
# buffered content delta is dropped rather than emitted after
|
||||
# ``message_delta`` (which would violate SSE ordering and may
|
||||
# confuse strict Anthropic SDK clients).
|
||||
if not self.queued_usage_chunk:
|
||||
if self.holding_chunk is not None:
|
||||
self.chunk_queue.append(self.holding_chunk)
|
||||
self.holding_chunk = None
|
||||
if self.holding_stop_reason_chunk is not None:
|
||||
# A final ``message_delta`` must be preceded by
|
||||
# ``content_block_stop`` so the emitted SSE stays in
|
||||
# valid Anthropic order (... -> content_block_stop ->
|
||||
# message_delta). Emit ``content_block_stop`` here if
|
||||
# the active content block was not already closed.
|
||||
if not self.sent_content_block_finish:
|
||||
self.chunk_queue.append(
|
||||
{
|
||||
"type": "content_block_stop",
|
||||
"index": self.current_content_block_index,
|
||||
}
|
||||
)
|
||||
self.sent_content_block_finish = True
|
||||
self.chunk_queue.append(
|
||||
self._augment_message_delta_usage(
|
||||
self.holding_stop_reason_chunk
|
||||
)
|
||||
)
|
||||
self.holding_stop_reason_chunk = None
|
||||
else:
|
||||
self.holding_chunk = None
|
||||
|
||||
if not self.sent_last_message:
|
||||
|
|
@ -205,6 +493,26 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
except StopIteration:
|
||||
if self.chunk_queue:
|
||||
return self.chunk_queue.popleft()
|
||||
# Handle any held stop_reason chunk. Emit ``content_block_stop``
|
||||
# first if the active content block was not already closed, so
|
||||
# Anthropic SSE ordering is preserved (content_block_stop ->
|
||||
# message_delta).
|
||||
if self.holding_stop_reason_chunk is not None:
|
||||
if not self.sent_content_block_finish:
|
||||
self.sent_content_block_finish = True
|
||||
self.chunk_queue.append(
|
||||
self._augment_message_delta_usage(
|
||||
self.holding_stop_reason_chunk
|
||||
)
|
||||
)
|
||||
self.holding_stop_reason_chunk = None
|
||||
return {
|
||||
"type": "content_block_stop",
|
||||
"index": self.current_content_block_index,
|
||||
}
|
||||
held = self._augment_message_delta_usage(self.holding_stop_reason_chunk)
|
||||
self.holding_stop_reason_chunk = None
|
||||
return held
|
||||
if self.sent_last_message is False:
|
||||
self.sent_last_message = True
|
||||
return {"type": "message_stop"}
|
||||
|
|
@ -213,7 +521,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
verbose_logger.error(
|
||||
"Anthropic Adapter - {}\n{}".format(e, traceback.format_exc())
|
||||
)
|
||||
raise StopAsyncIteration
|
||||
raise StopIteration
|
||||
|
||||
async def __anext__(self): # noqa: PLR0915
|
||||
from .transformation import LiteLLMAnthropicMessagesAdapter
|
||||
|
|
@ -243,8 +551,17 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
)
|
||||
return self.chunk_queue.popleft()
|
||||
|
||||
if (
|
||||
self.sent_compaction_block is False
|
||||
and self.compaction_block is not None
|
||||
):
|
||||
compaction_event = self._next_compaction_event()
|
||||
if compaction_event is not None:
|
||||
return compaction_event
|
||||
|
||||
if self.sent_content_block_start is False:
|
||||
self.sent_content_block_start = True
|
||||
self.sent_content_block_finish = False
|
||||
self.chunk_queue.append(
|
||||
{
|
||||
"type": "content_block_start",
|
||||
|
|
@ -263,57 +580,31 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
if should_start_new_block:
|
||||
self._increment_content_block_index()
|
||||
|
||||
# applied_edits only needs to flow to the final message_delta
|
||||
# (when finish_reason is set); skip threading it through every
|
||||
# intermediate chunk. For the hold-and-merge path below,
|
||||
# context_management is attached directly to the merged chunk,
|
||||
# so the translated ``processed_chunk`` would be discarded —
|
||||
# skip the applied_edits attachment in that case to avoid
|
||||
# allocating a throwaway ``MessageBlockDelta``.
|
||||
will_merge_into_held = (
|
||||
self.holding_stop_reason_chunk is not None
|
||||
and getattr(chunk, "usage", None) is not None
|
||||
)
|
||||
is_final_chunk = chunk.choices[0].finish_reason is not None
|
||||
processed_chunk = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_response_to_anthropic(
|
||||
response=chunk,
|
||||
current_content_block_index=self.current_content_block_index,
|
||||
applied_edits=(
|
||||
self.applied_edits
|
||||
if is_final_chunk and not will_merge_into_held
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
# Check if this is a usage chunk and we have a held stop_reason chunk
|
||||
if (
|
||||
self.holding_stop_reason_chunk is not None
|
||||
and getattr(chunk, "usage", None) is not None
|
||||
):
|
||||
# Merge usage into the held stop_reason chunk
|
||||
merged_chunk = self.holding_stop_reason_chunk.copy()
|
||||
if "delta" not in merged_chunk:
|
||||
merged_chunk["delta"] = {}
|
||||
|
||||
# Add usage to the held chunk
|
||||
uncached_input_tokens = chunk.usage.prompt_tokens or 0
|
||||
if (
|
||||
hasattr(chunk.usage, "prompt_tokens_details")
|
||||
and chunk.usage.prompt_tokens_details
|
||||
):
|
||||
cached_tokens = (
|
||||
getattr(
|
||||
chunk.usage.prompt_tokens_details, "cached_tokens", 0
|
||||
)
|
||||
or 0
|
||||
)
|
||||
uncached_input_tokens -= cached_tokens
|
||||
|
||||
usage_dict: UsageDelta = {
|
||||
"input_tokens": uncached_input_tokens,
|
||||
"output_tokens": chunk.usage.completion_tokens or 0,
|
||||
}
|
||||
# Add cache tokens if available (for prompt caching support)
|
||||
if (
|
||||
hasattr(chunk.usage, "_cache_creation_input_tokens")
|
||||
and chunk.usage._cache_creation_input_tokens > 0
|
||||
):
|
||||
usage_dict["cache_creation_input_tokens"] = (
|
||||
chunk.usage._cache_creation_input_tokens
|
||||
)
|
||||
if (
|
||||
hasattr(chunk.usage, "_cache_read_input_tokens")
|
||||
and chunk.usage._cache_read_input_tokens > 0
|
||||
):
|
||||
usage_dict["cache_read_input_tokens"] = (
|
||||
chunk.usage._cache_read_input_tokens
|
||||
)
|
||||
merged_chunk["usage"] = usage_dict
|
||||
|
||||
# Queue the merged chunk and reset
|
||||
if will_merge_into_held:
|
||||
merged_chunk = self._merge_usage_into_held_stop_reason_chunk(chunk)
|
||||
self.chunk_queue.append(merged_chunk)
|
||||
self.queued_usage_chunk = True
|
||||
self.holding_stop_reason_chunk = None
|
||||
|
|
@ -379,28 +670,63 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
):
|
||||
self.holding_stop_reason_chunk = processed_chunk
|
||||
else:
|
||||
processed_chunk = self._augment_message_delta_usage(
|
||||
processed_chunk
|
||||
)
|
||||
self.chunk_queue.append(processed_chunk)
|
||||
return self.chunk_queue.popleft()
|
||||
elif self.holding_chunk is not None:
|
||||
# Queue both chunks
|
||||
self.chunk_queue.append(self.holding_chunk)
|
||||
if processed_chunk.get("type") == "message_delta":
|
||||
processed_chunk = self._augment_message_delta_usage(
|
||||
processed_chunk
|
||||
)
|
||||
self.chunk_queue.append(processed_chunk)
|
||||
self.holding_chunk = None
|
||||
return self.chunk_queue.popleft()
|
||||
else:
|
||||
# Queue the current chunk
|
||||
if processed_chunk.get("type") == "message_delta":
|
||||
processed_chunk = self._augment_message_delta_usage(
|
||||
processed_chunk
|
||||
)
|
||||
self.chunk_queue.append(processed_chunk)
|
||||
return self.chunk_queue.popleft()
|
||||
|
||||
# Handle any remaining held chunks after stream ends
|
||||
# Handle any remaining held chunks after stream ends. The
|
||||
# buffered ``holding_chunk`` (a ``content_block_delta``) must
|
||||
# precede the final ``message_delta`` so Anthropic SSE event
|
||||
# ordering is preserved. When ``queued_usage_chunk`` is True,
|
||||
# the final ``message_delta`` has already been emitted; any
|
||||
# buffered content delta is dropped rather than emitted after
|
||||
# ``message_delta`` (which would violate SSE ordering and may
|
||||
# confuse strict Anthropic SDK clients).
|
||||
if not self.queued_usage_chunk:
|
||||
if self.holding_stop_reason_chunk is not None:
|
||||
self.chunk_queue.append(self.holding_stop_reason_chunk)
|
||||
self.holding_stop_reason_chunk = None
|
||||
|
||||
if self.holding_chunk is not None:
|
||||
self.chunk_queue.append(self.holding_chunk)
|
||||
self.holding_chunk = None
|
||||
if self.holding_stop_reason_chunk is not None:
|
||||
# A final ``message_delta`` must be preceded by
|
||||
# ``content_block_stop`` so the emitted SSE stays in
|
||||
# valid Anthropic order (... -> content_block_stop ->
|
||||
# message_delta). Emit ``content_block_stop`` here if
|
||||
# the active content block was not already closed.
|
||||
if not self.sent_content_block_finish:
|
||||
self.chunk_queue.append(
|
||||
{
|
||||
"type": "content_block_stop",
|
||||
"index": self.current_content_block_index,
|
||||
}
|
||||
)
|
||||
self.sent_content_block_finish = True
|
||||
self.chunk_queue.append(
|
||||
self._augment_message_delta_usage(
|
||||
self.holding_stop_reason_chunk
|
||||
)
|
||||
)
|
||||
self.holding_stop_reason_chunk = None
|
||||
else:
|
||||
self.holding_chunk = None
|
||||
|
||||
if not self.sent_last_message:
|
||||
self.sent_last_message = True
|
||||
|
|
@ -416,9 +742,28 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
|
|||
# Handle any remaining queued chunks before stopping
|
||||
if self.chunk_queue:
|
||||
return self.chunk_queue.popleft()
|
||||
# Handle any held stop_reason chunk
|
||||
# Handle any held stop_reason chunk — clear after capturing so a
|
||||
# subsequent ``__anext__`` call doesn't re-emit the same chunk
|
||||
# (matches the sync ``__next__`` path). Emit ``content_block_stop``
|
||||
# first if the active content block was not already closed, so
|
||||
# Anthropic SSE ordering is preserved (content_block_stop ->
|
||||
# message_delta).
|
||||
if self.holding_stop_reason_chunk is not None:
|
||||
return self.holding_stop_reason_chunk
|
||||
if not self.sent_content_block_finish:
|
||||
self.sent_content_block_finish = True
|
||||
self.chunk_queue.append(
|
||||
self._augment_message_delta_usage(
|
||||
self.holding_stop_reason_chunk
|
||||
)
|
||||
)
|
||||
self.holding_stop_reason_chunk = None
|
||||
return {
|
||||
"type": "content_block_stop",
|
||||
"index": self.current_content_block_index,
|
||||
}
|
||||
held = self._augment_message_delta_usage(self.holding_stop_reason_chunk)
|
||||
self.holding_stop_reason_chunk = None
|
||||
return held
|
||||
if not self.sent_last_message:
|
||||
self.sent_last_message = True
|
||||
return {"type": "message_stop"}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from typing import (
|
|||
Any,
|
||||
AsyncIterator,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
|
|
@ -75,6 +76,9 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
THOUGHT_SIGNATURE_SEPARATOR,
|
||||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.context_management import (
|
||||
PolyfillResult,
|
||||
)
|
||||
from litellm.types.llms.anthropic import (
|
||||
ANTHROPIC_HOSTED_TOOLS,
|
||||
AllAnthropicToolsValues,
|
||||
|
|
@ -87,14 +91,17 @@ from litellm.types.llms.anthropic import (
|
|||
AnthropicResponseContentBlockText,
|
||||
AnthropicResponseContentBlockThinking,
|
||||
AnthropicResponseContentBlockToolUse,
|
||||
AppliedEdit,
|
||||
ContentBlockDelta,
|
||||
ContentJsonBlockDelta,
|
||||
ContentTextBlockDelta,
|
||||
ContentThinkingBlockDelta,
|
||||
ContentThinkingSignatureBlockDelta,
|
||||
ContextManagementResponse,
|
||||
MessageBlockDelta,
|
||||
MessageDelta,
|
||||
UsageDelta,
|
||||
UsageIteration,
|
||||
)
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import (
|
||||
AnthropicMessagesResponse,
|
||||
|
|
@ -195,6 +202,7 @@ class AnthropicAdapter:
|
|||
self,
|
||||
response: ModelResponse,
|
||||
tool_name_mapping: Optional[Dict[str, str]] = None,
|
||||
polyfill_result: Optional[PolyfillResult] = None,
|
||||
) -> Optional[AnthropicMessagesResponse]:
|
||||
"""
|
||||
Translate OpenAI response to Anthropic format.
|
||||
|
|
@ -204,10 +212,12 @@ class AnthropicAdapter:
|
|||
tool_name_mapping: Optional mapping of truncated tool names to original names.
|
||||
Used to restore original names for tools that exceeded
|
||||
OpenAI's 64-char limit.
|
||||
polyfill_result: PolyfillResult from context_management polyfill.
|
||||
"""
|
||||
return LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
|
||||
response=response,
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
polyfill_result=polyfill_result,
|
||||
)
|
||||
|
||||
def translate_completion_output_params_streaming(
|
||||
|
|
@ -215,7 +225,9 @@ class AnthropicAdapter:
|
|||
completion_stream: Any,
|
||||
model: str,
|
||||
tool_name_mapping: Optional[Dict[str, str]] = None,
|
||||
) -> Union[AsyncIterator[bytes], None]:
|
||||
polyfill_result: Optional[PolyfillResult] = None,
|
||||
is_async: bool = True,
|
||||
) -> Union[AsyncIterator[bytes], Iterator[bytes], None]:
|
||||
"""
|
||||
Translate OpenAI streaming response to Anthropic format.
|
||||
|
||||
|
|
@ -223,14 +235,35 @@ class AnthropicAdapter:
|
|||
completion_stream: The OpenAI streaming response
|
||||
model: The model name
|
||||
tool_name_mapping: Optional mapping of truncated tool names to original names.
|
||||
polyfill_result: PolyfillResult from context_management polyfill.
|
||||
is_async: When ``True`` (default, for back-compat with existing
|
||||
async callers) returns an ``AsyncIterator[bytes]``. When
|
||||
``False`` returns a sync ``Iterator[bytes]`` so sync callers
|
||||
(e.g. ``litellm.anthropic.messages.create(stream=True)`` via
|
||||
the sync handler) don't get back an async iterator they
|
||||
can't iterate without an event loop.
|
||||
"""
|
||||
applied_edits = (
|
||||
polyfill_result.applied_edits_for_response() if polyfill_result else None
|
||||
)
|
||||
compaction_block = (
|
||||
polyfill_result.compaction_block if polyfill_result is not None else None
|
||||
)
|
||||
iterations_usage = (
|
||||
polyfill_result.iterations_usage if polyfill_result is not None else None
|
||||
)
|
||||
anthropic_wrapper = AnthropicStreamWrapper(
|
||||
completion_stream=completion_stream,
|
||||
model=model,
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
applied_edits=applied_edits,
|
||||
compaction_block=compaction_block,
|
||||
iterations_usage=iterations_usage,
|
||||
)
|
||||
# Return the SSE-wrapped version for proper event formatting
|
||||
return anthropic_wrapper.async_anthropic_sse_wrapper()
|
||||
# Return the SSE-wrapped version for proper event formatting.
|
||||
if is_async:
|
||||
return anthropic_wrapper.async_anthropic_sse_wrapper()
|
||||
return anthropic_wrapper.anthropic_sse_wrapper()
|
||||
|
||||
|
||||
class LiteLLMAnthropicMessagesAdapter:
|
||||
|
|
@ -1342,6 +1375,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
self,
|
||||
response: ModelResponse,
|
||||
tool_name_mapping: Optional[Dict[str, str]] = None,
|
||||
polyfill_result: Optional[PolyfillResult] = None,
|
||||
) -> AnthropicMessagesResponse:
|
||||
"""
|
||||
Translate OpenAI response to Anthropic format.
|
||||
|
|
@ -1351,12 +1385,17 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
tool_name_mapping: Optional mapping of truncated tool names to original names.
|
||||
Used to restore original names for tools that exceeded
|
||||
OpenAI's 64-char limit.
|
||||
polyfill_result: PolyfillResult from context_management polyfill.
|
||||
"""
|
||||
## translate content block
|
||||
anthropic_content = self._translate_openai_content_to_anthropic(
|
||||
choices=response.choices, # type: ignore
|
||||
tool_name_mapping=tool_name_mapping,
|
||||
)
|
||||
|
||||
if polyfill_result is not None and polyfill_result.compaction_block is not None:
|
||||
anthropic_content.insert(0, polyfill_result.compaction_block) # type: ignore[arg-type]
|
||||
|
||||
## extract finish reason
|
||||
anthropic_finish_reason = self._translate_openai_finish_reason_to_anthropic(
|
||||
openai_finish_reason=response.choices[0].finish_reason # type: ignore
|
||||
|
|
@ -1385,6 +1424,14 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
if cached_tokens > 0:
|
||||
anthropic_usage["cache_read_input_tokens"] = cached_tokens
|
||||
|
||||
if polyfill_result is not None and polyfill_result.iterations_usage is not None:
|
||||
message_iteration: UsageIteration = {
|
||||
"type": "message",
|
||||
"input_tokens": uncached_input_tokens,
|
||||
"output_tokens": usage.completion_tokens or 0,
|
||||
}
|
||||
anthropic_usage["iterations"] = list(polyfill_result.iterations_usage) + [message_iteration] # type: ignore[typeddict-unknown-key]
|
||||
|
||||
translated_obj = AnthropicMessagesResponse(
|
||||
id=response.id,
|
||||
type="message",
|
||||
|
|
@ -1396,6 +1443,14 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
stop_reason=anthropic_finish_reason,
|
||||
)
|
||||
|
||||
applied_edits = (
|
||||
polyfill_result.applied_edits_for_response() if polyfill_result else None
|
||||
)
|
||||
if applied_edits:
|
||||
translated_obj["context_management"] = ContextManagementResponse(
|
||||
applied_edits=list(applied_edits)
|
||||
)
|
||||
|
||||
return translated_obj
|
||||
|
||||
def _translate_streaming_openai_chunk_to_anthropic_content_block(
|
||||
|
|
@ -1528,7 +1583,10 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
return "text_delta", ContentTextBlockDelta(type="text_delta", text=text)
|
||||
|
||||
def translate_streaming_openai_response_to_anthropic(
|
||||
self, response: ModelResponse, current_content_block_index: int
|
||||
self,
|
||||
response: ModelResponse,
|
||||
current_content_block_index: int,
|
||||
applied_edits: Optional[List[AppliedEdit]] = None,
|
||||
) -> Union[ContentBlockDelta, MessageBlockDelta]:
|
||||
## base case - final chunk w/ finish reason
|
||||
if response.choices[0].finish_reason is not None:
|
||||
|
|
@ -1578,9 +1636,14 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
usage_delta["cache_read_input_tokens"] = cached_tokens
|
||||
else:
|
||||
usage_delta = UsageDelta(input_tokens=0, output_tokens=0)
|
||||
return MessageBlockDelta(
|
||||
message_block = MessageBlockDelta(
|
||||
type="message_delta", delta=delta, usage=usage_delta # type: ignore
|
||||
)
|
||||
if applied_edits:
|
||||
message_block["context_management"] = ContextManagementResponse(
|
||||
applied_edits=list(applied_edits)
|
||||
)
|
||||
return message_block
|
||||
(
|
||||
type_of_content,
|
||||
content_block_delta,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
from .constants import CLEARED_TOOL_RESULT_PLACEHOLDER
|
||||
from .dispatcher import apply_context_management
|
||||
from .errors import AnthropicContextManagementError
|
||||
from .result import PolyfillResult
|
||||
|
||||
__all__ = [
|
||||
"apply_context_management",
|
||||
"AnthropicContextManagementError",
|
||||
"CLEARED_TOOL_RESULT_PLACEHOLDER",
|
||||
"PolyfillResult",
|
||||
]
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"""Constants for the in-gateway context-management polyfill."""
|
||||
|
||||
CLEAR_TOOL_USES_EDIT_TYPE = "clear_tool_uses_20250919"
|
||||
|
||||
DEFAULT_INPUT_TOKENS_TRIGGER = 100_000
|
||||
DEFAULT_KEEP_TOOL_USES = 3
|
||||
|
||||
CLEARED_TOOL_RESULT_PLACEHOLDER = "[Cleared by context management]"
|
||||
|
||||
# compact_20260112
|
||||
COMPACT_EDIT_TYPE = "compact_20260112"
|
||||
COMPACT_DEFAULT_TRIGGER_TOKENS = 150_000
|
||||
COMPACT_MIN_TRIGGER_TOKENS = 50_000
|
||||
# Default ``max_tokens`` for the summary call. Required by providers like
|
||||
# Anthropic that reject requests without it; safely accepted by providers that
|
||||
# don't strictly require it. Chosen to comfortably fit a long structured
|
||||
# summary. Operators can override via
|
||||
# ``general_settings.context_management_summary_max_tokens``.
|
||||
COMPACT_SUMMARY_MAX_TOKENS = 4096
|
||||
COMPACT_SUMMARY_MAX_TOKENS_SETTING_KEY = "context_management_summary_max_tokens"
|
||||
# Wall-clock bound for the summary sub-call. Without this a slow or
|
||||
# unresponsive summary model would hang the parent ``/v1/messages`` request
|
||||
# with no escape hatch; on timeout the editor falls into the standard
|
||||
# ``summary_call_failed`` path and forwards the request without compaction.
|
||||
COMPACT_SUMMARY_TIMEOUT_SECONDS = 60.0
|
||||
COMPACT_SUMMARY_MODEL_SETTING_KEY = "context_management_summary_model"
|
||||
COMPACT_SUMMARY_SYSTEM_PREFIX = "Previous conversation summary: "
|
||||
|
||||
# Default summarization prompt from the Anthropic spec.
|
||||
COMPACT_DEFAULT_INSTRUCTIONS = (
|
||||
"You have written a partial transcript for the initial task above. Please "
|
||||
"write a summary of the transcript. The purpose of this summary is to "
|
||||
"provide continuity so you can continue to make progress towards solving "
|
||||
"the task in a future context, where the raw history above may not be "
|
||||
"accessible and will be replaced with this summary. Write down anything "
|
||||
"that would be helpful, including the state, next steps, learnings etc. "
|
||||
"You must wrap your summary in a <summary></summary> block."
|
||||
)
|
||||
|
||||
# Appended to the default prompt when ``tools`` are present and the caller
|
||||
# did not supply custom ``instructions``. Matches the guidance in the
|
||||
# Anthropic docs under "Compaction might fail when tools are defined".
|
||||
COMPACT_NO_TOOL_CALLS_SUFFIX = (
|
||||
" Do not call any tools while writing this summary; respond with text only."
|
||||
)
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
"""Dispatch ``context_management`` edits to registered polyfill editors."""
|
||||
|
||||
import inspect
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union, cast
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.types.llms.anthropic import AppliedEdit
|
||||
|
||||
from .constants import CLEAR_TOOL_USES_EDIT_TYPE, COMPACT_EDIT_TYPE
|
||||
from .editors import apply_clear_tool_uses_20250919, apply_compact_20260112
|
||||
from .result import PolyfillResult
|
||||
|
||||
EditorFn = Callable[..., Any]
|
||||
|
||||
_EDITOR_REGISTRY: Dict[str, EditorFn] = {
|
||||
CLEAR_TOOL_USES_EDIT_TYPE: apply_clear_tool_uses_20250919,
|
||||
COMPACT_EDIT_TYPE: apply_compact_20260112,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_spec(
|
||||
spec: Union[Dict[str, Any], List[Dict[str, Any]], None],
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Accept Anthropic-native dict form or OpenAI list form; return edits list."""
|
||||
if isinstance(spec, list):
|
||||
# Local import to avoid an import cycle at module load.
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
|
||||
spec = AnthropicConfig.map_openai_context_management_to_anthropic(spec)
|
||||
|
||||
edits = spec.get("edits") if isinstance(spec, dict) else None
|
||||
if not edits or not isinstance(edits, list):
|
||||
return None
|
||||
return [edit for edit in edits if isinstance(edit, dict)]
|
||||
|
||||
|
||||
def _wrap_editor_return(raw: Any, *, fallback_system: Any) -> PolyfillResult:
|
||||
"""Coerce an editor's native return shape into a ``PolyfillResult``.
|
||||
|
||||
v0 sync editors (e.g. ``clear_tool_uses_20250919``) return a 2-tuple
|
||||
``(messages, Optional[AppliedEdit])``. The new async ``compact_20260112``
|
||||
editor returns a ``PolyfillResult`` directly.
|
||||
"""
|
||||
if isinstance(raw, PolyfillResult):
|
||||
return raw
|
||||
# Legacy 2-tuple return — sync editors don't mutate ``system``, so
|
||||
# carry the caller's value forward.
|
||||
messages, applied = cast(Tuple[List[Dict[str, Any]], Any], raw)
|
||||
return PolyfillResult(
|
||||
messages=messages,
|
||||
system=fallback_system,
|
||||
applied_edits=[applied] if applied is not None else [],
|
||||
)
|
||||
|
||||
|
||||
async def apply_context_management(
|
||||
*,
|
||||
model: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Optional[List[Dict[str, Any]]],
|
||||
system: Any,
|
||||
context_management_spec: Union[Dict[str, Any], List[Dict[str, Any]], None],
|
||||
litellm_metadata: Optional[Dict[str, Any]] = None,
|
||||
llm_router: Any = None,
|
||||
user_api_key_auth: Any = None,
|
||||
) -> PolyfillResult:
|
||||
"""Run edits in order; return a single ``PolyfillResult``.
|
||||
|
||||
The dispatcher is async so async editors (``compact_20260112``) can
|
||||
``await`` the configured summarization model. Sync editors are called
|
||||
inline — ``inspect.iscoroutinefunction`` decides how each editor is
|
||||
invoked.
|
||||
"""
|
||||
edits = _normalize_spec(context_management_spec)
|
||||
if not edits:
|
||||
return PolyfillResult(messages=messages, system=system, applied_edits=[])
|
||||
|
||||
current_messages = messages
|
||||
current_system = system
|
||||
aggregated_applied: List[AppliedEdit] = []
|
||||
aggregated_compaction_block = None
|
||||
aggregated_iterations_usage = None
|
||||
|
||||
for edit_spec in edits:
|
||||
edit_type = edit_spec.get("type")
|
||||
editor = _EDITOR_REGISTRY.get(edit_type) if isinstance(edit_type, str) else None
|
||||
if editor is None:
|
||||
verbose_logger.debug(
|
||||
"context_management polyfill: unknown edit type '%s' — skipping",
|
||||
edit_type,
|
||||
)
|
||||
continue
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": current_messages,
|
||||
"tools": tools,
|
||||
"system": current_system,
|
||||
"edit_spec": edit_spec,
|
||||
}
|
||||
# Only async editors accept these — passing them to sync v0 editors
|
||||
# would break their signature.
|
||||
if inspect.iscoroutinefunction(editor):
|
||||
kwargs["litellm_metadata"] = litellm_metadata
|
||||
kwargs["llm_router"] = llm_router
|
||||
kwargs["user_api_key_auth"] = user_api_key_auth
|
||||
raw_result = await cast(Callable[..., Awaitable[Any]], editor)(**kwargs)
|
||||
else:
|
||||
raw_result = editor(**kwargs)
|
||||
|
||||
result = _wrap_editor_return(raw_result, fallback_system=current_system)
|
||||
|
||||
current_messages = result.messages
|
||||
current_system = result.system
|
||||
aggregated_applied.extend(result.applied_edits)
|
||||
if result.compaction_block is not None:
|
||||
aggregated_compaction_block = result.compaction_block
|
||||
if result.iterations_usage is not None:
|
||||
aggregated_iterations_usage = result.iterations_usage
|
||||
|
||||
return PolyfillResult(
|
||||
messages=current_messages,
|
||||
system=current_system,
|
||||
applied_edits=aggregated_applied,
|
||||
compaction_block=aggregated_compaction_block,
|
||||
iterations_usage=aggregated_iterations_usage,
|
||||
)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from .clear_tool_uses import apply_clear_tool_uses_20250919
|
||||
from .compact import apply_compact_20260112
|
||||
|
||||
__all__ = ["apply_clear_tool_uses_20250919", "apply_compact_20260112"]
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
"""``clear_tool_uses_20250919`` polyfill (v0: ``trigger`` and ``keep`` only)."""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple, cast
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.types.llms.anthropic import AppliedEdit
|
||||
|
||||
from ..constants import (
|
||||
CLEAR_TOOL_USES_EDIT_TYPE,
|
||||
DEFAULT_INPUT_TOKENS_TRIGGER,
|
||||
DEFAULT_KEEP_TOOL_USES,
|
||||
)
|
||||
from ..placeholders import build_cleared_tool_result_content
|
||||
|
||||
|
||||
def _count_tool_uses(messages: List[Dict[str, Any]]) -> int:
|
||||
"""Return the number of tool_use content blocks across all messages.
|
||||
|
||||
Only counts blocks with a string ``id`` to stay consistent with
|
||||
:func:`_collect_tool_use_ids_in_order`, which is the source of truth for
|
||||
which blocks are clearable.
|
||||
"""
|
||||
count = 0
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_use":
|
||||
if isinstance(block.get("id"), str):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _collect_tool_use_ids_in_order(messages: List[Dict[str, Any]]) -> List[str]:
|
||||
"""Return tool_use ids in the chronological order they appear in messages."""
|
||||
ids: List[str] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_use":
|
||||
block_id = block.get("id")
|
||||
if isinstance(block_id, str):
|
||||
ids.append(block_id)
|
||||
return ids
|
||||
|
||||
|
||||
def _trigger_met(
|
||||
trigger: Dict[str, Any],
|
||||
model: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Optional[List[Dict[str, Any]]],
|
||||
) -> Tuple[bool, Optional[int]]:
|
||||
"""Return (trigger_met, input_tokens if counted for reuse)."""
|
||||
trigger_type = trigger.get("type", "input_tokens")
|
||||
threshold = trigger.get("value")
|
||||
|
||||
if trigger_type == "tool_uses":
|
||||
if not isinstance(threshold, int):
|
||||
return False, None
|
||||
return _count_tool_uses(messages) > threshold, None
|
||||
|
||||
if not isinstance(threshold, int):
|
||||
threshold = DEFAULT_INPUT_TOKENS_TRIGGER
|
||||
current_tokens = litellm.token_counter(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=cast(Any, tools),
|
||||
)
|
||||
verbose_logger.debug(
|
||||
f"context_management polyfill: current_tokens: {current_tokens}"
|
||||
)
|
||||
verbose_logger.debug(f"context_management polyfill: threshold: {threshold}")
|
||||
return current_tokens > threshold, current_tokens
|
||||
|
||||
|
||||
def _resolve_keep_count(keep: Dict[str, Any]) -> int:
|
||||
keep_type = keep.get("type", "tool_uses")
|
||||
if keep_type != "tool_uses":
|
||||
return DEFAULT_KEEP_TOOL_USES
|
||||
value = keep.get("value")
|
||||
if not isinstance(value, int) or value < 0:
|
||||
return DEFAULT_KEEP_TOOL_USES
|
||||
return value
|
||||
|
||||
|
||||
def _last_completed_tool_use_id(
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> Optional[str]:
|
||||
"""Latest completed tool_result id; never cleared."""
|
||||
last_id: Optional[str] = None
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
block_id = block.get("tool_use_id")
|
||||
if isinstance(block_id, str):
|
||||
last_id = block_id
|
||||
return last_id
|
||||
|
||||
|
||||
def _clear_tool_results(
|
||||
messages: List[Dict[str, Any]], ids_to_clear: set
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""Clear matching tool_result content; return (messages, cleared_count)."""
|
||||
cleared = 0
|
||||
new_messages: List[Dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
new_messages.append(msg)
|
||||
continue
|
||||
|
||||
new_blocks: List[Any] = []
|
||||
mutated = False
|
||||
for block in content:
|
||||
if (
|
||||
isinstance(block, dict)
|
||||
and block.get("type") == "tool_result"
|
||||
and block.get("tool_use_id") in ids_to_clear
|
||||
):
|
||||
new_block = {
|
||||
**block,
|
||||
"content": build_cleared_tool_result_content(block.get("content")),
|
||||
}
|
||||
new_blocks.append(new_block)
|
||||
mutated = True
|
||||
cleared += 1
|
||||
else:
|
||||
new_blocks.append(block)
|
||||
|
||||
if mutated:
|
||||
new_messages.append({**msg, "content": new_blocks})
|
||||
else:
|
||||
new_messages.append(msg)
|
||||
|
||||
return new_messages, cleared
|
||||
|
||||
|
||||
def apply_clear_tool_uses_20250919(
|
||||
*,
|
||||
model: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Optional[List[Dict[str, Any]]],
|
||||
system: Any,
|
||||
edit_spec: Dict[str, Any],
|
||||
) -> Tuple[List[Dict[str, Any]], Optional[AppliedEdit]]:
|
||||
"""Apply clear_tool_uses; return (messages, AppliedEdit or None)."""
|
||||
ignored_knobs = [
|
||||
knob
|
||||
for knob in ("clear_at_least", "exclude_tools", "clear_tool_inputs")
|
||||
if knob in edit_spec
|
||||
]
|
||||
for ignored_knob in ignored_knobs:
|
||||
verbose_logger.warning(
|
||||
"context_management polyfill: ignoring '%s' on %s "
|
||||
"(supported only on Anthropic-family forwarding path in v0)",
|
||||
ignored_knob,
|
||||
CLEAR_TOOL_USES_EDIT_TYPE,
|
||||
)
|
||||
|
||||
trigger = edit_spec.get("trigger") or {
|
||||
"type": "input_tokens",
|
||||
"value": DEFAULT_INPUT_TOKENS_TRIGGER,
|
||||
}
|
||||
keep = edit_spec.get("keep") or {
|
||||
"type": "tool_uses",
|
||||
"value": DEFAULT_KEEP_TOOL_USES,
|
||||
}
|
||||
|
||||
met, tokens_before = _trigger_met(trigger, model, messages, tools)
|
||||
if not met:
|
||||
return messages, None
|
||||
|
||||
keep_count = _resolve_keep_count(keep)
|
||||
tool_use_ids = _collect_tool_use_ids_in_order(messages)
|
||||
if len(tool_use_ids) <= keep_count:
|
||||
return messages, None
|
||||
|
||||
ids_to_clear = set(tool_use_ids[: len(tool_use_ids) - keep_count])
|
||||
|
||||
# Never clear the latest completed tool_result (reply context).
|
||||
last_completed_id = _last_completed_tool_use_id(messages)
|
||||
if last_completed_id is not None:
|
||||
ids_to_clear.discard(last_completed_id)
|
||||
|
||||
edited, cleared_count = _clear_tool_results(messages, ids_to_clear)
|
||||
verbose_logger.debug("context_management polyfill: edited: %s", edited)
|
||||
if cleared_count == 0:
|
||||
return messages, None
|
||||
|
||||
if tokens_before is None:
|
||||
tokens_before = litellm.token_counter(
|
||||
model=model, messages=messages, tools=cast(Any, tools)
|
||||
)
|
||||
tokens_after = litellm.token_counter(
|
||||
model=model, messages=edited, tools=cast(Any, tools)
|
||||
)
|
||||
cleared_input_tokens = max(tokens_before - tokens_after, 0)
|
||||
|
||||
applied: AppliedEdit = {
|
||||
"type": CLEAR_TOOL_USES_EDIT_TYPE,
|
||||
"cleared_tool_uses": cleared_count,
|
||||
"cleared_input_tokens": cleared_input_tokens,
|
||||
}
|
||||
if ignored_knobs:
|
||||
applied["warnings"] = [f"{knob}_ignored" for knob in ignored_knobs]
|
||||
return edited, applied
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,14 @@
|
|||
"""Exceptions raised by the context_management polyfill."""
|
||||
|
||||
|
||||
class AnthropicContextManagementError(Exception):
|
||||
"""Validation error from the polyfill, surfaced as an Anthropic-format 4xx.
|
||||
|
||||
The `/v1/messages` endpoint catches this in its exception handler and
|
||||
emits an Anthropic-shaped error body instead of the default OpenAI shape.
|
||||
"""
|
||||
|
||||
def __init__(self, *, status_code: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"""Placeholder content for cleared ``tool_result`` blocks (string or block list)."""
|
||||
|
||||
from typing import Any, List, Union
|
||||
|
||||
from .constants import CLEARED_TOOL_RESULT_PLACEHOLDER
|
||||
|
||||
|
||||
def build_cleared_tool_result_content(
|
||||
original_content: Any,
|
||||
) -> Union[str, List[dict]]:
|
||||
"""Return a string or single text block list, matching ``original_content`` shape."""
|
||||
if isinstance(original_content, list):
|
||||
return [{"type": "text", "text": CLEARED_TOOL_RESULT_PLACEHOLDER}]
|
||||
return CLEARED_TOOL_RESULT_PLACEHOLDER
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"""``PolyfillResult`` — the shape returned by the context-management dispatcher.
|
||||
|
||||
Threaded from the dispatcher through ``async_anthropic_messages_handler`` into
|
||||
the adapter so it can prepend the ``compaction`` block to the response and
|
||||
attach ``iterations`` to ``usage``.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from litellm.types.llms.anthropic import (
|
||||
AppliedEdit,
|
||||
CompactionBlock,
|
||||
UsageIteration,
|
||||
)
|
||||
|
||||
from .constants import COMPACT_EDIT_TYPE
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolyfillResult:
|
||||
messages: List[Dict[str, Any]]
|
||||
system: Optional[Union[str, List[Dict[str, Any]]]]
|
||||
applied_edits: List[AppliedEdit] = field(default_factory=list)
|
||||
compaction_block: Optional[CompactionBlock] = None
|
||||
iterations_usage: Optional[List[UsageIteration]] = None
|
||||
|
||||
def applied_edits_for_response(self) -> Optional[List[AppliedEdit]]:
|
||||
"""``applied_edits`` to attach on the client-visible response.
|
||||
|
||||
``compact_20260112`` is included when a new compaction block was
|
||||
synthesized (success), when the edit carries an ``error`` field
|
||||
(``summary_model_not_configured``, ``summary_call_failed``,
|
||||
``summary_extraction_failed``), or when the edit carries
|
||||
``warnings`` (e.g. ``unsupported_trigger_type_X_using_input_tokens``,
|
||||
``pause_after_compaction_ignored``) — operators and clients need to
|
||||
see why compaction was requested but not applied as expected.
|
||||
Slice-only / under-threshold paths that produced no edit at all
|
||||
(no block, no error, no warnings) are omitted. Other edit types are
|
||||
included when the editor returned an ``AppliedEdit``.
|
||||
"""
|
||||
visible: List[AppliedEdit] = []
|
||||
for edit in self.applied_edits:
|
||||
if edit.get("type") == COMPACT_EDIT_TYPE:
|
||||
if (
|
||||
self.compaction_block is not None
|
||||
or edit.get("error")
|
||||
or edit.get("warnings")
|
||||
):
|
||||
visible.append(edit)
|
||||
else:
|
||||
visible.append(edit)
|
||||
return visible or None
|
||||
|
|
@ -8,7 +8,17 @@
|
|||
import asyncio
|
||||
import contextvars
|
||||
from functools import partial
|
||||
from typing import Any, AsyncIterator, Coroutine, Dict, List, Optional, Union, cast
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Coroutine,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
|
@ -189,7 +199,7 @@ async def anthropic_messages(
|
|||
client: Optional[AsyncHTTPHandler] = None,
|
||||
custom_llm_provider: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Union[AnthropicMessagesResponse, AsyncIterator]:
|
||||
) -> Union[AnthropicMessagesResponse, Iterator[bytes], AsyncIterator[Any]]:
|
||||
"""
|
||||
Async: Make llm api request in Anthropic /messages API spec.
|
||||
|
||||
|
|
@ -346,8 +356,11 @@ def anthropic_messages_handler(
|
|||
**kwargs,
|
||||
) -> Union[
|
||||
AnthropicMessagesResponse,
|
||||
Iterator[bytes],
|
||||
AsyncIterator[Any],
|
||||
Coroutine[Any, Any, Union[AnthropicMessagesResponse, AsyncIterator[Any]]],
|
||||
Coroutine[
|
||||
Any, Any, Union[AnthropicMessagesResponse, AsyncIterator[Any], Iterator[bytes]]
|
||||
],
|
||||
]:
|
||||
"""
|
||||
Makes Anthropic `/v1/messages` API calls In the Anthropic API Spec
|
||||
|
|
@ -456,9 +469,14 @@ def anthropic_messages_handler(
|
|||
return LiteLLMMessagesToResponsesAPIHandler.anthropic_messages_handler(
|
||||
**_shared_kwargs
|
||||
)
|
||||
|
||||
# The in-gateway context_management polyfill runs inside
|
||||
# ``async_anthropic_messages_handler`` so it can ``await`` the
|
||||
# summarization model for ``compact_20260112``. ``context_management``
|
||||
# is passed through as a regular kwarg.
|
||||
return (
|
||||
LiteLLMMessagesToCompletionTransformationHandler.anthropic_messages_handler(
|
||||
**_shared_kwargs
|
||||
**_shared_kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -230,6 +230,8 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
"""Translate legacy ``thinking.type=enabled`` to adaptive for 4.6/4.7.
|
||||
Caller-provided ``output_config.effort`` is never overridden.
|
||||
"""
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
|
||||
if not AnthropicModelInfo._is_adaptive_thinking_model(model):
|
||||
return
|
||||
thinking = optional_params.get("thinking")
|
||||
|
|
@ -237,7 +239,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
return
|
||||
|
||||
budget = int(thinking.get("budget_tokens") or 0)
|
||||
if budget >= 24000:
|
||||
if budget >= 24000 and AnthropicConfig._supports_effort_level(model, "xhigh"):
|
||||
effort = "xhigh"
|
||||
elif budget >= 10000:
|
||||
effort = "high"
|
||||
|
|
|
|||
|
|
@ -24,10 +24,12 @@ from .utils import (
|
|||
generate_unified_id_string,
|
||||
is_base64_encoded_unified_id,
|
||||
parse_unified_id,
|
||||
resolve_passthrough_managed_id_provider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BaseManagedResource",
|
||||
"resolve_passthrough_managed_id_provider",
|
||||
"is_base64_encoded_unified_id",
|
||||
"extract_target_model_names_from_unified_id",
|
||||
"extract_resource_type_from_unified_id",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,40 @@ different managed resource types (files, vector stores, etc.).
|
|||
|
||||
import base64
|
||||
import re
|
||||
from typing import List, Optional, Union, Literal
|
||||
from typing import Any, List, Literal, Optional, Union
|
||||
|
||||
PASSTHROUGH_MANAGED_ID_AZURE_PROVIDERS = ("azure", "azure_ai")
|
||||
|
||||
|
||||
def resolve_passthrough_managed_id_provider(
|
||||
custom_llm_provider: Any,
|
||||
) -> Optional[str]:
|
||||
"""Map a pass-through ``custom_llm_provider`` to the provider scope that
|
||||
namespaces passthrough managed object IDs, or ``None`` when the route is not
|
||||
an OpenAI/Azure pass-through and managed IDs must not apply.
|
||||
|
||||
Scoping is keyed on the explicit provider that the pass-through route
|
||||
forwards (``openai``, ``azure``, ``azure_ai``), not on the upstream URL, so
|
||||
a third-party OpenAI-compatible endpoint never triggers managed-ID minting.
|
||||
|
||||
``azure`` and ``azure_ai`` deliberately collapse to one ``"azure"`` scope:
|
||||
they expose the same Azure OpenAI files/batches surface, so an ID minted
|
||||
while routing as one must still resolve while routing as the other.
|
||||
Splitting them would make a managed ID minted on ``azure`` fail to resolve
|
||||
when replayed on ``azure_ai`` and vice versa.
|
||||
"""
|
||||
provider = str(
|
||||
getattr(custom_llm_provider, "value", custom_llm_provider) or ""
|
||||
).lower()
|
||||
if not provider:
|
||||
return None
|
||||
if provider in PASSTHROUGH_MANAGED_ID_AZURE_PROVIDERS or provider.endswith(
|
||||
(".azure", ".azure_ai")
|
||||
):
|
||||
return "azure"
|
||||
if provider == "openai" or provider.endswith(".openai"):
|
||||
return "openai"
|
||||
return None
|
||||
|
||||
|
||||
def is_base64_encoded_unified_id(
|
||||
|
|
|
|||
|
|
@ -157,8 +157,8 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
|
|||
def _get_agent_runtime_arn(self, model: str) -> str:
|
||||
"""
|
||||
Extract ARN from model string
|
||||
model = "agentcore/arn:aws:bedrock-agentcore:us-west-2:941277531214:runtime/hosted_agent_r9jvp-Rq79QFC2fp"
|
||||
returns: "arn:aws:bedrock-agentcore:us-west-2:941277531214:runtime/hosted_agent_r9jvp-Rq79QFC2fp"
|
||||
model = "agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/hosted_agent_r9jvp-3ySZuRHjLC"
|
||||
returns: "arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/hosted_agent_r9jvp-3ySZuRHjLC"
|
||||
"""
|
||||
parts = model.split("/", 1)
|
||||
if len(parts) != 2 or parts[0] != "agentcore":
|
||||
|
|
@ -170,7 +170,7 @@ class AmazonAgentCoreConfig(BaseConfig, BaseAWSLLM):
|
|||
def _extract_region_from_arn(self, arn: str) -> str:
|
||||
"""
|
||||
Extract region from ARN
|
||||
arn:aws:bedrock-agentcore:us-west-2:941277531214:runtime/hosted_agent_r9jvp-Rq79QFC2fp
|
||||
arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/hosted_agent_r9jvp-3ySZuRHjLC
|
||||
returns: us-west-2
|
||||
"""
|
||||
parts = arn.split(":")
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMExcepti
|
|||
from litellm.types.llms.bedrock import *
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
ChatCompletionAnnotation,
|
||||
ChatCompletionAssistantMessage,
|
||||
ChatCompletionRedactedThinkingBlock,
|
||||
ChatCompletionResponseMessage,
|
||||
|
|
@ -585,6 +586,9 @@ class AmazonConverseConfig(BaseConfig):
|
|||
):
|
||||
supported_params.append("thinking")
|
||||
supported_params.append("reasoning_effort")
|
||||
|
||||
if base_model.startswith("anthropic"):
|
||||
supported_params.append("context_management")
|
||||
return supported_params
|
||||
|
||||
def map_tool_choice_values(
|
||||
|
|
@ -946,10 +950,10 @@ class AmazonConverseConfig(BaseConfig):
|
|||
self._handle_reasoning_effort_parameter(
|
||||
model=model, reasoning_effort=value, optional_params=optional_params
|
||||
)
|
||||
elif param == "context_management" and isinstance(value, (dict, list)):
|
||||
self._map_context_management_param(value, optional_params)
|
||||
if param == "requestMetadata":
|
||||
if value is not None and isinstance(value, dict):
|
||||
self._validate_request_metadata(value) # type: ignore
|
||||
optional_params["requestMetadata"] = value
|
||||
self._map_request_metadata_param(value, optional_params)
|
||||
if param == "service_tier" and isinstance(value, str):
|
||||
self._map_service_tier_param(value, optional_params)
|
||||
|
||||
|
|
@ -982,6 +986,32 @@ class AmazonConverseConfig(BaseConfig):
|
|||
|
||||
return optional_params
|
||||
|
||||
def _map_request_metadata_param(self, value: Any, optional_params: dict) -> None:
|
||||
if value is not None and isinstance(value, dict):
|
||||
self._validate_request_metadata(value) # type: ignore
|
||||
optional_params["requestMetadata"] = value
|
||||
|
||||
def _map_context_management_param(
|
||||
self, value: Union[dict, list], optional_params: dict
|
||||
) -> None:
|
||||
# Match the dispatcher's ``_normalize_spec`` behavior: only run the
|
||||
# OpenAI→Anthropic mapper for list inputs. Dict inputs are already in
|
||||
# Anthropic-native shape (``{"edits": [...]}``) and should pass
|
||||
# through unchanged so an Anthropic-format ``context_management``
|
||||
# value isn't silently dropped when the mapper can't classify it.
|
||||
if isinstance(value, list):
|
||||
mapped = AnthropicConfig.map_openai_context_management_to_anthropic(
|
||||
cast(Union[dict, list], value)
|
||||
)
|
||||
else:
|
||||
mapped = value
|
||||
# Skip when the mapper returned None for malformed input — leaving the
|
||||
# key out is safer than passing `context_management: null` downstream,
|
||||
# which Bedrock would reject and which can confuse intermediate checks
|
||||
# before the final _filter_context_management_for_bedrock_converse step.
|
||||
if mapped is not None:
|
||||
optional_params["context_management"] = mapped
|
||||
|
||||
def _map_service_tier_param(self, value: str, optional_params: dict) -> None:
|
||||
"""Map OpenAI service_tier (string) to Bedrock serviceTier (object).
|
||||
|
||||
|
|
@ -1487,6 +1517,11 @@ class AmazonConverseConfig(BaseConfig):
|
|||
if ANTHROPIC_EFFORT_BETA_HEADER not in anthropic_beta_list:
|
||||
anthropic_beta_list.append(ANTHROPIC_EFFORT_BETA_HEADER)
|
||||
|
||||
# Bedrock Converse: compact_20260112 edits only (+ beta header).
|
||||
AmazonConverseConfig._filter_context_management_for_bedrock_converse(
|
||||
additional_request_params, anthropic_beta_list
|
||||
)
|
||||
|
||||
# Set anthropic_beta in additional_request_params if we have any beta features
|
||||
# ONLY apply to Anthropic/Claude models - other models (e.g., Qwen, Llama) don't support this field
|
||||
if anthropic_beta_list and base_model.startswith("anthropic"):
|
||||
|
|
@ -1494,6 +1529,42 @@ class AmazonConverseConfig(BaseConfig):
|
|||
|
||||
return bedrock_tools, anthropic_beta_list
|
||||
|
||||
@staticmethod
|
||||
def _filter_context_management_for_bedrock_converse(
|
||||
additional_request_params: dict,
|
||||
anthropic_beta_list: list,
|
||||
) -> None:
|
||||
"""Keep only compact_20260112 edits for Bedrock; add beta header or drop field."""
|
||||
from litellm.llms.anthropic.experimental_pass_through.context_management.constants import (
|
||||
COMPACT_EDIT_TYPE,
|
||||
)
|
||||
from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES
|
||||
|
||||
cm = additional_request_params.get("context_management")
|
||||
if not isinstance(cm, dict):
|
||||
additional_request_params.pop("context_management", None)
|
||||
return
|
||||
edits = cm.get("edits")
|
||||
if not isinstance(edits, list):
|
||||
additional_request_params.pop("context_management", None)
|
||||
return
|
||||
|
||||
compact_edits = [
|
||||
e
|
||||
for e in edits
|
||||
if isinstance(e, dict) and e.get("type") == COMPACT_EDIT_TYPE
|
||||
]
|
||||
if compact_edits:
|
||||
compact_beta = ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_01_12.value
|
||||
if compact_beta not in anthropic_beta_list:
|
||||
anthropic_beta_list.append(compact_beta)
|
||||
additional_request_params["context_management"] = {
|
||||
**cm,
|
||||
"edits": compact_edits,
|
||||
}
|
||||
else:
|
||||
additional_request_params.pop("context_management", None)
|
||||
|
||||
def _transform_request_helper(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -1944,6 +2015,75 @@ class AmazonConverseConfig(BaseConfig):
|
|||
|
||||
return content_str, tools, reasoningContentBlocks, citationsContentBlocks
|
||||
|
||||
@staticmethod
|
||||
def _transform_citations_to_annotations(
|
||||
citations_content_blocks: Optional[List[CitationsContentBlock]],
|
||||
) -> Tuple[Optional[str], Optional[List[ChatCompletionAnnotation]]]:
|
||||
"""
|
||||
Convert Bedrock citationsContent blocks into OpenAI-style annotations.
|
||||
|
||||
Returns:
|
||||
citations_text: concatenated text from citationsContent.content
|
||||
annotations: OpenAI URL citation annotations
|
||||
"""
|
||||
if not citations_content_blocks:
|
||||
return None, None
|
||||
|
||||
annotations: List[ChatCompletionAnnotation] = []
|
||||
citations_text_parts: List[str] = []
|
||||
content_offset = 0
|
||||
|
||||
for citations_block in citations_content_blocks:
|
||||
block_text = ""
|
||||
raw_content = citations_block.get("content")
|
||||
if isinstance(raw_content, list):
|
||||
for content_part in raw_content:
|
||||
if isinstance(content_part, dict):
|
||||
_text = content_part.get("text")
|
||||
if isinstance(_text, str):
|
||||
block_text += _text
|
||||
|
||||
block_offset = content_offset
|
||||
if block_text:
|
||||
citations_text_parts.append(block_text)
|
||||
content_offset += len(block_text)
|
||||
|
||||
raw_citations = citations_block.get("citations")
|
||||
if not isinstance(raw_citations, list):
|
||||
continue
|
||||
|
||||
for citation in raw_citations:
|
||||
if not isinstance(citation, dict):
|
||||
continue
|
||||
|
||||
location = citation.get("location")
|
||||
if not isinstance(location, dict):
|
||||
continue
|
||||
|
||||
search_location = location.get("searchResultLocation")
|
||||
if not isinstance(search_location, dict):
|
||||
continue
|
||||
|
||||
start = search_location.get("start")
|
||||
end = search_location.get("end")
|
||||
if not isinstance(start, int) or not isinstance(end, int):
|
||||
continue
|
||||
|
||||
annotations.append(
|
||||
ChatCompletionAnnotation(
|
||||
type="url_citation",
|
||||
url_citation={
|
||||
"start_index": block_offset + start,
|
||||
"end_index": block_offset + end,
|
||||
"title": str(citation.get("title") or ""),
|
||||
"url": str(citation.get("source") or ""),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
citations_text = "".join(citations_text_parts) if citations_text_parts else None
|
||||
return citations_text, annotations or None
|
||||
|
||||
@staticmethod
|
||||
def _unwrap_bedrock_properties(json_str: str) -> str:
|
||||
"""
|
||||
|
|
@ -2126,6 +2266,24 @@ class AmazonConverseConfig(BaseConfig):
|
|||
provider_specific_fields
|
||||
)
|
||||
|
||||
citations_text, annotations = self._transform_citations_to_annotations(
|
||||
citationsContentBlocks
|
||||
)
|
||||
citations_included_in_content = False
|
||||
if citations_text:
|
||||
stripped_content = content_str.strip()
|
||||
if not stripped_content:
|
||||
content_str = citations_text
|
||||
citations_included_in_content = True
|
||||
elif not any(char.isalnum() for char in stripped_content):
|
||||
# Bedrock may emit the cited sentence in citationsContent and only
|
||||
# punctuation in the text blocks; stitch citations_text in front so
|
||||
# its annotation span indices stay aligned with the final content.
|
||||
content_str = citations_text + content_str
|
||||
citations_included_in_content = True
|
||||
if annotations and citations_included_in_content:
|
||||
chat_completion_message["annotations"] = annotations
|
||||
|
||||
if reasoningContentBlocks is not None:
|
||||
chat_completion_message["reasoning_content"] = (
|
||||
self._transform_reasoning_content(reasoningContentBlocks)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,10 @@ from litellm.llms.bedrock.common_utils import (
|
|||
pop_bedrock_invoke_output_config_format,
|
||||
remove_custom_field_from_tools,
|
||||
)
|
||||
from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER
|
||||
from litellm.types.llms.anthropic import (
|
||||
ANTHROPIC_BETA_HEADER_VALUES,
|
||||
ANTHROPIC_TOOL_SEARCH_BETA_HEADER,
|
||||
)
|
||||
from litellm.types.llms.bedrock import BedrockInvokeAnthropicMessagesRequest
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
|
@ -445,7 +448,7 @@ class AmazonAnthropicClaudeMessagesConfig(
|
|||
if isinstance(e, dict) and e.get("type") == "compact_20260112"
|
||||
]
|
||||
if compact_edits:
|
||||
beta_set.add("compact-2026-01-12")
|
||||
beta_set.add(ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_01_12.value)
|
||||
anthropic_messages_request["context_management"] = {
|
||||
**cm,
|
||||
"edits": compact_edits,
|
||||
|
|
|
|||
|
|
@ -139,14 +139,16 @@ class LangGraphConfig(BaseConfig):
|
|||
|
||||
def _convert_messages_to_langgraph_format(
|
||||
self, messages: List[AllMessageValues]
|
||||
) -> List[Dict[str, str]]:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Convert OpenAI-format messages to LangGraph format.
|
||||
|
||||
OpenAI format: {"role": "user", "content": "..."}
|
||||
LangGraph format: {"role": "human", "content": "..."}
|
||||
|
||||
Preserves per-message ``metadata`` when present (e.g. A2A ``skillId``).
|
||||
"""
|
||||
langgraph_messages: List[Dict[str, str]] = []
|
||||
langgraph_messages: List[Dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
|
|
@ -169,7 +171,15 @@ class LangGraphConfig(BaseConfig):
|
|||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
|
||||
langgraph_messages.append({"role": langgraph_role, "content": content})
|
||||
langgraph_message: Dict[str, Any] = {
|
||||
"role": langgraph_role,
|
||||
"content": content,
|
||||
}
|
||||
message_metadata = msg.get("metadata")
|
||||
if isinstance(message_metadata, dict) and message_metadata:
|
||||
langgraph_message["metadata"] = message_metadata
|
||||
|
||||
langgraph_messages.append(langgraph_message)
|
||||
|
||||
return langgraph_messages
|
||||
|
||||
|
|
|
|||
1
litellm/proxy/_experimental/mcp_server/CLAUDE.md
Normal file
1
litellm/proxy/_experimental/mcp_server/CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
MCP note: **`available_on_public_internet: false` with `delegate_auth_to_upstream: true` (oauth2, interactive - not `client_credentials`)** - LiteLLM still allows the anonymous upstream PKCE path (no proxy API key for `/authorize` and matching MCP routes). The internal-only flag mainly affects other surfaces (e.g. IP-based discovery). Rely on the upstream IdP and network policy; the dashboard shows a warning when both are set, and the proxy logs a warning when the server is loaded from config or the database
|
||||
|
|
@ -20,7 +20,7 @@ class MCPAuthenticatedUser(AuthenticatedUser):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth],
|
||||
mcp_auth_header: Optional[str] = None,
|
||||
mcp_servers: Optional[List[str]] = None,
|
||||
mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
|
|
|
|||
|
|
@ -566,7 +566,7 @@ class MCPRequestHandler:
|
|||
)
|
||||
)
|
||||
|
||||
key_access_group_extras = (
|
||||
key_access_group_grants = (
|
||||
await MCPRequestHandler._get_key_access_group_mcp_server_extras(
|
||||
user_api_key_auth
|
||||
)
|
||||
|
|
@ -577,11 +577,11 @@ class MCPRequestHandler:
|
|||
#########################################################
|
||||
key_set = set(allowed_mcp_servers_for_key)
|
||||
team_set = set(allowed_mcp_servers_for_team)
|
||||
extras_set = set(key_access_group_extras)
|
||||
grants_set = set(key_access_group_grants)
|
||||
|
||||
has_lower_level_mcp_restrictions = bool(key_set or team_set or extras_set)
|
||||
has_lower_level_mcp_restrictions = bool(key_set or team_set or grants_set)
|
||||
|
||||
# 1. Team-gated base scope.
|
||||
# 1. Key/team ceiling. An empty set means "this level does not restrict".
|
||||
if not team_set:
|
||||
base = key_set # no team restriction
|
||||
elif not key_set:
|
||||
|
|
@ -589,9 +589,10 @@ class MCPRequestHandler:
|
|||
else:
|
||||
base = key_set & team_set # both restrict → intersect
|
||||
|
||||
# 2. Extend with access-group extras (LIT-3189 — bypasses team
|
||||
# ceiling, gated by group's assigned_team_ids / assigned_key_ids).
|
||||
allowed_mcp_servers: List[str] = list(base | extras_set)
|
||||
# 2. Add the key's access-group grants on top. These are additive:
|
||||
# attaching a group to the key grants its servers regardless of the
|
||||
# team ceiling.
|
||||
allowed_mcp_servers: List[str] = list(base | grants_set)
|
||||
|
||||
#########################################################
|
||||
# Check end_user permissions if end_user_id is set
|
||||
|
|
@ -890,11 +891,12 @@ class MCPRequestHandler:
|
|||
) -> List[str]:
|
||||
"""
|
||||
Resolve the key's unified `access_group_ids` (LiteLLM_AccessGroupTable) to
|
||||
MCP server IDs, gated by the access group's `assigned_team_ids` /
|
||||
`assigned_key_ids`. These servers extend the team's MCP scope rather
|
||||
than being capped by it. Tag-style `mcp_access_groups` (per-server tags)
|
||||
are intentionally not handled here — they have no assignment fields and
|
||||
remain subject to the team ceiling.
|
||||
MCP server IDs as additive grants: a group attached to the key extends the
|
||||
key's allowed servers on top of the key/team ceiling rather than being
|
||||
capped by the team. Attaching the group to the key is itself the grant —
|
||||
no `assigned_key_ids` / `assigned_team_ids` re-check. Tag-style
|
||||
`mcp_access_groups` (per-server tags) live in the key's object_permission
|
||||
scope, not here.
|
||||
"""
|
||||
if user_api_key_auth is None:
|
||||
return []
|
||||
|
|
@ -903,13 +905,19 @@ class MCPRequestHandler:
|
|||
global_mcp_server_manager,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
get_authorized_resources_from_key_access_groups,
|
||||
_get_mcp_server_ids_from_access_groups,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
raw_server_ids = await get_authorized_resources_from_key_access_groups(
|
||||
valid_token=user_api_key_auth,
|
||||
team_object=None,
|
||||
resource_field="access_mcp_server_ids",
|
||||
raw_server_ids = await _get_mcp_server_ids_from_access_groups(
|
||||
access_group_ids=user_api_key_auth.access_group_ids or [],
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if not raw_server_ids:
|
||||
return []
|
||||
|
|
@ -917,7 +925,7 @@ class MCPRequestHandler:
|
|||
return global_mcp_server_manager.expand_permission_list(raw_server_ids)
|
||||
except Exception as e:
|
||||
verbose_logger.warning(
|
||||
f"Failed to get key access group MCP server extras: {str(e)}"
|
||||
f"Failed to get key access group MCP server grants: {str(e)}"
|
||||
)
|
||||
return []
|
||||
|
||||
|
|
@ -925,39 +933,50 @@ class MCPRequestHandler:
|
|||
async def _get_allowed_mcp_servers_for_key(
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Get the key's own MCP ceiling from its object_permission
|
||||
(mcp_servers, tag-style mcp_access_groups, mcp_tool_permissions).
|
||||
|
||||
Unified key.access_group_ids are NOT resolved here — they are additive
|
||||
grants handled by _get_key_access_group_mcp_server_extras and unioned on
|
||||
top of the key/team ceiling, so they must not enter this scope (which is
|
||||
intersected against the team).
|
||||
"""
|
||||
if user_api_key_auth is None:
|
||||
return []
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
get_object_permission,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
# Get key object permission (already loaded in main auth flow, or fetch from DB)
|
||||
key_object_permission = MCPRequestHandler._get_key_object_permission(
|
||||
user_api_key_auth
|
||||
)
|
||||
if (
|
||||
key_object_permission is None
|
||||
and user_api_key_auth
|
||||
and user_api_key_auth.object_permission_id
|
||||
and prisma_client is not None
|
||||
):
|
||||
from litellm.proxy.auth.auth_checks import get_object_permission
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
key_object_permission = await get_object_permission(
|
||||
object_permission_id=user_api_key_auth.object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=user_api_key_auth.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
if prisma_client is not None:
|
||||
key_object_permission = await get_object_permission(
|
||||
object_permission_id=user_api_key_auth.object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=user_api_key_auth.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if key_object_permission is None:
|
||||
return []
|
||||
|
||||
# Permission entries may be server_ids OR names/aliases — expand to ids.
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
direct_mcp_servers = global_mcp_server_manager.expand_permission_list(
|
||||
key_object_permission.mcp_servers or []
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ from litellm.types.mcp import MCPCredentials
|
|||
|
||||
def _prepare_mcp_server_data(
|
||||
data: Union[NewMCPServerRequest, UpdateMCPServerRequest],
|
||||
exclude_unset: bool = False,
|
||||
fields_set: Optional[Set[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Helper function to prepare MCP server data for database operations.
|
||||
|
|
@ -37,17 +39,39 @@ def _prepare_mcp_server_data(
|
|||
|
||||
Args:
|
||||
data: NewMCPServerRequest or UpdateMCPServerRequest object
|
||||
exclude_unset: When True, only fields the caller explicitly provided are
|
||||
included. Used for partial updates (PUT /v1/mcp/server) so omitted
|
||||
fields keep their existing DB value instead of being silently reset
|
||||
to a Pydantic schema default. ``exclude_none`` is not enough here:
|
||||
non-Optional fields (e.g. ``transport=MCPTransport.sse``,
|
||||
``mcp_access_groups=[]``, ``allow_all_keys=False``) are backfilled
|
||||
with their default when omitted, and a non-None default survives the
|
||||
``exclude_none`` filter and overwrites the row.
|
||||
|
||||
Returns:
|
||||
Dict with properly serialized JSON fields
|
||||
"""
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
|
||||
# Convert model to dict
|
||||
data_dict = data.model_dump(exclude_none=True)
|
||||
# Ensure alias is always present in the dict (even if None)
|
||||
if "alias" not in data_dict:
|
||||
data_dict["alias"] = getattr(data, "alias", None)
|
||||
# Convert model to dict.
|
||||
# - Partial update (exclude_unset): only caller-provided keys are emitted, so
|
||||
# omitted fields are never written and keep their existing DB value.
|
||||
# - Create (exclude_none): drop None-valued fields and let DB defaults apply.
|
||||
if exclude_unset:
|
||||
if fields_set is None:
|
||||
fields_set = data.fields_set()
|
||||
data_dict = data.model_dump(exclude_unset=True)
|
||||
# ``validate_and_normalize_mcp_server_payload`` always assigns ``alias``
|
||||
# on the payload, which marks it as set even when the caller omitted it.
|
||||
# Drop it only when the original request omitted alias; an explicit
|
||||
# ``alias=None`` is a valid request to clear the stored alias.
|
||||
if data_dict.get("alias") is None and "alias" not in fields_set:
|
||||
data_dict.pop("alias", None)
|
||||
else:
|
||||
data_dict = data.model_dump(exclude_none=True)
|
||||
# Ensure alias is always present in the dict (even if None)
|
||||
if "alias" not in data_dict:
|
||||
data_dict["alias"] = getattr(data, "alias", None)
|
||||
|
||||
# Handle credentials serialization
|
||||
credentials = data_dict.get("credentials")
|
||||
|
|
@ -57,33 +81,33 @@ def _prepare_mcp_server_data(
|
|||
)
|
||||
data_dict["credentials"] = safe_dumps(data_dict["credentials"])
|
||||
|
||||
# Handle static_headers serialization
|
||||
if data.static_headers is not None:
|
||||
data_dict["static_headers"] = safe_dumps(data.static_headers)
|
||||
# Serialize JSON fields from ``data_dict`` (not ``data``) so the
|
||||
# exclude_unset filter is respected. Reading back from ``data`` would
|
||||
# reintroduce defaults (e.g. ``env={}``) for fields the caller never set.
|
||||
if data_dict.get("static_headers") is not None:
|
||||
data_dict["static_headers"] = safe_dumps(data_dict["static_headers"])
|
||||
|
||||
# Handle mcp_info serialization
|
||||
if data.mcp_info is not None:
|
||||
data_dict["mcp_info"] = safe_dumps(data.mcp_info)
|
||||
if data_dict.get("mcp_info") is not None:
|
||||
data_dict["mcp_info"] = safe_dumps(data_dict["mcp_info"])
|
||||
|
||||
# Handle env serialization
|
||||
if data.env is not None:
|
||||
data_dict["env"] = safe_dumps(data.env)
|
||||
if data_dict.get("env") is not None:
|
||||
data_dict["env"] = safe_dumps(data_dict["env"])
|
||||
|
||||
# Handle tool name override serialization
|
||||
if data.tool_name_to_display_name is not None:
|
||||
if data_dict.get("tool_name_to_display_name") is not None:
|
||||
data_dict["tool_name_to_display_name"] = safe_dumps(
|
||||
data.tool_name_to_display_name
|
||||
data_dict["tool_name_to_display_name"]
|
||||
)
|
||||
if data.tool_name_to_description is not None:
|
||||
if data_dict.get("tool_name_to_description") is not None:
|
||||
data_dict["tool_name_to_description"] = safe_dumps(
|
||||
data.tool_name_to_description
|
||||
data_dict["tool_name_to_description"]
|
||||
)
|
||||
|
||||
# mcp_access_groups is already List[str], no serialization needed
|
||||
|
||||
# Force include is_byok even when False (exclude_none=True would not drop it,
|
||||
# but be explicit to ensure a False value is always written to the DB).
|
||||
data_dict["is_byok"] = getattr(data, "is_byok", False)
|
||||
# On create, force is_byok so a False value is always written to the DB. On
|
||||
# partial update, only write it when the caller explicitly provided it.
|
||||
if not exclude_unset:
|
||||
data_dict["is_byok"] = getattr(data, "is_byok", False)
|
||||
|
||||
return data_dict
|
||||
|
||||
|
|
@ -398,7 +422,10 @@ async def create_mcp_server(
|
|||
|
||||
|
||||
async def update_mcp_server(
|
||||
prisma_client: PrismaClient, data: UpdateMCPServerRequest, touched_by: str
|
||||
prisma_client: PrismaClient,
|
||||
data: UpdateMCPServerRequest,
|
||||
touched_by: str,
|
||||
fields_set: Optional[Set[str]] = None,
|
||||
) -> LiteLLM_MCPServerTable:
|
||||
"""
|
||||
Update a new mcp server record in the db
|
||||
|
|
@ -407,8 +434,13 @@ async def update_mcp_server(
|
|||
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
|
||||
# Use helper to prepare data with proper JSON serialization
|
||||
data_dict = _prepare_mcp_server_data(data)
|
||||
# Use helper to prepare data with proper JSON serialization.
|
||||
# exclude_unset=True makes this a true partial update: fields the caller did
|
||||
# not provide are not written, so they keep their existing DB value instead
|
||||
# of being reset to a schema default (transport=sse, allow_all_keys=False...).
|
||||
data_dict = _prepare_mcp_server_data(
|
||||
data, exclude_unset=True, fields_set=fields_set
|
||||
)
|
||||
|
||||
# Pre-fetch existing record once if we need it for auth_type or credential logic
|
||||
existing = None
|
||||
|
|
|
|||
|
|
@ -892,6 +892,7 @@ class MCPServerManager:
|
|||
is_byok=bool(getattr(mcp_server, "is_byok", False)),
|
||||
byok_description=getattr(mcp_server, "byok_description", None) or [],
|
||||
byok_api_key_help_url=getattr(mcp_server, "byok_api_key_help_url", None),
|
||||
source_url=getattr(mcp_server, "source_url", None),
|
||||
# AWS SigV4 fields
|
||||
aws_access_key_id=aws_creds.get("aws_access_key_id"),
|
||||
aws_secret_access_key=aws_creds.get("aws_secret_access_key"),
|
||||
|
|
@ -3750,6 +3751,7 @@ class MCPServerManager:
|
|||
is_byok=server.is_byok,
|
||||
byok_description=server.byok_description,
|
||||
byok_api_key_help_url=server.byok_api_key_help_url,
|
||||
source_url=server.source_url,
|
||||
instructions=server.instructions,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ LiteLLM MCP Server Routes
|
|||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import types
|
||||
import traceback
|
||||
|
|
@ -28,7 +30,7 @@ from fastapi import FastAPI, HTTPException
|
|||
from pydantic import AnyUrl, ConfigDict
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import Receive, Scope, Send
|
||||
from starlette.types import Message, Receive, Scope, Send
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import MAXIMUM_TRACEBACK_LINES_TO_LOG
|
||||
|
|
@ -74,6 +76,19 @@ from litellm.utils import Rules, client, function_setup
|
|||
_byok_cred_cache: Dict[Tuple[str, str], Tuple[Optional[str], float]] = {}
|
||||
_BYOK_CRED_CACHE_TTL = 60 # seconds
|
||||
_BYOK_CRED_CACHE_MAX_SIZE = 4096 # cap to prevent unbounded growth
|
||||
_STATEFUL_SESSION_IDLE_TIMEOUT_SECONDS = 30 * 60
|
||||
# Upper bound on concurrent stateful sessions a single caller may hold. Each
|
||||
# `initialize` creates a session that survives until the idle timeout, so
|
||||
# without a cap an authenticated client could spam `initialize` and exhaust
|
||||
# memory. The caller's own oldest idle sessions are evicted to make room; if
|
||||
# the cap is still hit (every session in flight), the new `initialize` is
|
||||
# rejected with 429.
|
||||
_MAX_STATEFUL_SESSIONS_PER_OWNER = 100
|
||||
# Maximum bytes to peek when sniffing the JSON-RPC method on a POST.
|
||||
# An `initialize` envelope is a few hundred bytes; capping the peek
|
||||
# prevents an authenticated client from forcing the proxy to buffer an
|
||||
# arbitrarily large body just to make a routing decision.
|
||||
_MCP_ROUTING_PEEK_MAX_BYTES = 4096
|
||||
|
||||
|
||||
def _invalidate_byok_cred_cache(user_id: str, server_id: str) -> None:
|
||||
|
|
@ -242,13 +257,45 @@ if MCP_AVAILABLE:
|
|||
sse: SseServerTransport = SseServerTransport("/mcp/sse/messages")
|
||||
|
||||
# Create session managers
|
||||
session_manager = StreamableHTTPSessionManager(
|
||||
session_manager_stateless = StreamableHTTPSessionManager(
|
||||
app=server,
|
||||
event_store=None,
|
||||
json_response=False, # enables SSE streaming
|
||||
stateless=True,
|
||||
)
|
||||
|
||||
session_manager_stateful = StreamableHTTPSessionManager(
|
||||
app=server,
|
||||
event_store=None, # TODO: Add EventStore for reconnection/event replay if needed
|
||||
json_response=False, # enables SSE streaming
|
||||
stateless=False,
|
||||
)
|
||||
_stateful_session_auth_contexts: Dict[str, MCPAuthenticatedUser] = {}
|
||||
_stateful_session_auth_context_last_seen: Dict[str, float] = {}
|
||||
# Maps session_id -> owner identifier (hashed API key/token) so we can
|
||||
# reject requests that supply a session_id created by a different caller.
|
||||
# Without this, a leaked mcp-session-id could be driven (or terminated)
|
||||
# by any other authenticated proxy user.
|
||||
_stateful_session_owners: Dict[str, str] = {}
|
||||
# Per-session lock that serializes ``handle_request`` for the same
|
||||
# mcp-session-id. The stored ``MCPAuthenticatedUser`` is mutated in place
|
||||
# by ``_update_auth_context`` each request; without this lock, two
|
||||
# concurrent requests on the same session would clobber each other's
|
||||
# auth headers / mcp_servers / oauth state while in-flight callbacks are
|
||||
# still reading the shared object.
|
||||
_stateful_session_locks: Dict[str, asyncio.Lock] = {}
|
||||
_stateful_session_active_request_counts: Dict[str, int] = {}
|
||||
|
||||
def _remove_stateful_session_tracking(session_id: str) -> None:
|
||||
_stateful_session_auth_contexts.pop(session_id, None)
|
||||
_stateful_session_auth_context_last_seen.pop(session_id, None)
|
||||
_stateful_session_owners.pop(session_id, None)
|
||||
_stateful_session_locks.pop(session_id, None)
|
||||
_stateful_session_active_request_counts.pop(session_id, None)
|
||||
|
||||
# Keep this alias so existing references to session_manager still work
|
||||
session_manager = session_manager_stateless
|
||||
|
||||
# Create SSE session manager
|
||||
sse_session_manager = StreamableHTTPSessionManager(
|
||||
app=server,
|
||||
|
|
@ -259,11 +306,100 @@ if MCP_AVAILABLE:
|
|||
|
||||
# Context managers for proper lifecycle management
|
||||
_session_manager_cm = None
|
||||
_session_manager_stateful_cm = None
|
||||
_sse_session_manager_cm = None
|
||||
_stateful_auth_context_cleanup_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def _purge_expired_stateful_session_auth_contexts(
|
||||
now: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Terminate expired stateful sessions and drop their auth contexts."""
|
||||
now = time.monotonic() if now is None else now
|
||||
server_instances = getattr(session_manager_stateful, "_server_instances", {})
|
||||
expired_session_ids = []
|
||||
for session_id, last_seen in _stateful_session_auth_context_last_seen.items():
|
||||
if _stateful_session_active_request_counts.get(session_id, 0) > 0:
|
||||
continue
|
||||
if (
|
||||
now - last_seen >= _STATEFUL_SESSION_IDLE_TIMEOUT_SECONDS
|
||||
or session_id not in server_instances
|
||||
):
|
||||
expired_session_ids.append(session_id)
|
||||
|
||||
for session_id in expired_session_ids:
|
||||
# Re-check the active-request count immediately before tearing
|
||||
# the session down. ``await transport.terminate()`` yields to
|
||||
# the event loop, so a request that started after the first
|
||||
# collection pass could otherwise observe its transport being
|
||||
# ripped out from under it mid-flight.
|
||||
if _stateful_session_active_request_counts.get(session_id, 0) > 0:
|
||||
continue
|
||||
# Pop transport + terminate BEFORE removing owner/auth tracking.
|
||||
# Reversing the order avoids a window where ``_stateful_session_owners``
|
||||
# is empty but ``server_instances`` still serves the session — a
|
||||
# concurrent request in that window would observe ``expected_owner
|
||||
# is None`` and bypass the owner-binding check.
|
||||
transport = server_instances.pop(session_id, None)
|
||||
if transport is not None:
|
||||
await transport.terminate()
|
||||
_remove_stateful_session_tracking(session_id)
|
||||
|
||||
for session_id in list(_stateful_session_auth_context_last_seen):
|
||||
if session_id not in _stateful_session_auth_contexts:
|
||||
_remove_stateful_session_tracking(session_id)
|
||||
|
||||
async def _enforce_stateful_session_cap_for_owner(owner: str) -> bool:
|
||||
"""
|
||||
Bound the number of concurrent stateful sessions a single caller holds
|
||||
before routing a new ``initialize`` to the stateful manager.
|
||||
|
||||
Evicts the caller's *own* oldest idle sessions (no in-flight requests)
|
||||
to make room, so a busy-but-legitimate client keeps its newest sessions
|
||||
and other callers are never affected. Returns ``True`` if the new
|
||||
session may proceed, or ``False`` when the caller is already at the cap
|
||||
with every session in flight (the new ``initialize`` should be rejected).
|
||||
"""
|
||||
server_instances = getattr(session_manager_stateful, "_server_instances", {})
|
||||
|
||||
def _owned_live_session_ids() -> List[str]:
|
||||
return [
|
||||
session_id
|
||||
for session_id, session_owner in _stateful_session_owners.items()
|
||||
if session_owner == owner and session_id in server_instances
|
||||
]
|
||||
|
||||
owned = _owned_live_session_ids()
|
||||
if len(owned) < _MAX_STATEFUL_SESSIONS_PER_OWNER:
|
||||
return True
|
||||
|
||||
for session_id in sorted(
|
||||
owned,
|
||||
key=lambda sid: _stateful_session_auth_context_last_seen.get(sid, 0.0),
|
||||
):
|
||||
if len(_owned_live_session_ids()) < _MAX_STATEFUL_SESSIONS_PER_OWNER:
|
||||
break
|
||||
if _stateful_session_active_request_counts.get(session_id, 0) > 0:
|
||||
continue
|
||||
transport = server_instances.pop(session_id, None)
|
||||
if transport is not None:
|
||||
await transport.terminate()
|
||||
_remove_stateful_session_tracking(session_id)
|
||||
|
||||
return len(_owned_live_session_ids()) < _MAX_STATEFUL_SESSIONS_PER_OWNER
|
||||
|
||||
async def _cleanup_expired_stateful_session_auth_contexts() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(_STATEFUL_SESSION_IDLE_TIMEOUT_SECONDS)
|
||||
try:
|
||||
await _purge_expired_stateful_session_auth_contexts()
|
||||
except Exception as e:
|
||||
verbose_logger.exception(
|
||||
f"Error cleaning up expired MCP stateful sessions: {e}"
|
||||
)
|
||||
|
||||
async def initialize_session_managers():
|
||||
"""Initialize the session managers. Can be called from main app lifespan."""
|
||||
global _SESSION_MANAGERS_INITIALIZED, _session_manager_cm, _sse_session_manager_cm
|
||||
global _SESSION_MANAGERS_INITIALIZED, _session_manager_cm, _session_manager_stateful_cm, _sse_session_manager_cm, _stateful_auth_context_cleanup_task
|
||||
|
||||
# Use async lock to prevent concurrent initialization
|
||||
async with _INITIALIZATION_LOCK:
|
||||
|
|
@ -273,12 +409,17 @@ if MCP_AVAILABLE:
|
|||
verbose_logger.info("Initializing MCP session managers...")
|
||||
|
||||
# Start the session managers with context managers
|
||||
_session_manager_cm = session_manager.run()
|
||||
_session_manager_cm = session_manager_stateless.run()
|
||||
_session_manager_stateful_cm = session_manager_stateful.run()
|
||||
_sse_session_manager_cm = sse_session_manager.run()
|
||||
|
||||
# Enter the context managers
|
||||
await _session_manager_cm.__aenter__()
|
||||
await _session_manager_stateful_cm.__aenter__()
|
||||
await _sse_session_manager_cm.__aenter__()
|
||||
_stateful_auth_context_cleanup_task = asyncio.create_task(
|
||||
_cleanup_expired_stateful_session_auth_contexts()
|
||||
)
|
||||
|
||||
_SESSION_MANAGERS_INITIALIZED = True
|
||||
verbose_logger.info(
|
||||
|
|
@ -287,21 +428,29 @@ if MCP_AVAILABLE:
|
|||
|
||||
async def shutdown_session_managers():
|
||||
"""Shutdown the session managers."""
|
||||
global _SESSION_MANAGERS_INITIALIZED, _session_manager_cm, _sse_session_manager_cm
|
||||
global _SESSION_MANAGERS_INITIALIZED, _session_manager_cm, _session_manager_stateful_cm, _sse_session_manager_cm, _stateful_auth_context_cleanup_task
|
||||
|
||||
if _SESSION_MANAGERS_INITIALIZED:
|
||||
verbose_logger.info("Shutting down MCP session managers...")
|
||||
|
||||
try:
|
||||
if _stateful_auth_context_cleanup_task:
|
||||
_stateful_auth_context_cleanup_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await _stateful_auth_context_cleanup_task
|
||||
if _session_manager_cm:
|
||||
await _session_manager_cm.__aexit__(None, None, None)
|
||||
if _session_manager_stateful_cm:
|
||||
await _session_manager_stateful_cm.__aexit__(None, None, None)
|
||||
if _sse_session_manager_cm:
|
||||
await _sse_session_manager_cm.__aexit__(None, None, None)
|
||||
except Exception as e:
|
||||
verbose_logger.exception(f"Error during session manager shutdown: {e}")
|
||||
|
||||
_session_manager_cm = None
|
||||
_session_manager_stateful_cm = None
|
||||
_sse_session_manager_cm = None
|
||||
_stateful_auth_context_cleanup_task = None
|
||||
_SESSION_MANAGERS_INITIALIZED = False
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
|
|
@ -366,7 +515,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
@server.call_tool()
|
||||
async def mcp_server_tool_call(
|
||||
name: str, arguments: Dict[str, Any] | None
|
||||
name: str, arguments: Optional[Dict[str, Any]]
|
||||
) -> CallToolResult:
|
||||
"""
|
||||
Call a specific tool with the provided arguments
|
||||
|
|
@ -409,7 +558,7 @@ if MCP_AVAILABLE:
|
|||
if host_token and hasattr(host_ctx, "session") and host_ctx.session:
|
||||
host_session = host_ctx.session
|
||||
|
||||
async def forward_progress(progress: float, total: float | None):
|
||||
async def forward_progress(progress: float, total: Optional[float]):
|
||||
"""Forward progress notifications from external MCP to Host"""
|
||||
try:
|
||||
await host_session.send_progress_notification(
|
||||
|
|
@ -551,7 +700,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
@server.get_prompt()
|
||||
async def get_prompt(
|
||||
name: str, arguments: dict[str, str] | None
|
||||
name: str, arguments: Optional[Dict[str, str]]
|
||||
) -> GetPromptResult:
|
||||
"""
|
||||
Get a specific prompt with the provided arguments
|
||||
|
|
@ -2697,6 +2846,144 @@ if MCP_AVAILABLE:
|
|||
raw_headers,
|
||||
)
|
||||
|
||||
def _get_session_id_from_scope(scope: Scope) -> Optional[str]:
|
||||
"""
|
||||
Extract mcp-session-id from ASGI scope headers.
|
||||
Returns None if not present.
|
||||
"""
|
||||
for header_name, header_value in scope.get("headers", []):
|
||||
name = (
|
||||
header_name if isinstance(header_name, bytes) else header_name.encode()
|
||||
)
|
||||
if name.lower() == b"mcp-session-id":
|
||||
return (
|
||||
header_value.decode()
|
||||
if isinstance(header_value, bytes)
|
||||
else str(header_value)
|
||||
)
|
||||
return None
|
||||
|
||||
def _owner_fingerprint_for(
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth],
|
||||
oauth2_headers: Optional[Dict[str, str]] = None,
|
||||
client_ip: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Stable, non-reversible identifier for the caller used to bind an
|
||||
mcp-session-id to its creator. Hash the resolved credential before
|
||||
using it so custom key formats are never stored in cleartext.
|
||||
|
||||
For OAuth2 passthrough (``UserAPIKeyAuth()`` with no key/user_id),
|
||||
the caller's identity is the upstream OAuth bearer; hash it so two
|
||||
OAuth callers with different tokens don't both fingerprint to
|
||||
``anonymous`` and end up sharing a session.
|
||||
|
||||
When no caller-identifying credentials are available at all
|
||||
(e.g. proxy running without master key, or an unauthenticated
|
||||
passthrough path), fall back to the client IP so two unrelated
|
||||
anonymous callers from different sources do not collapse to a
|
||||
single ``anonymous`` owner and end up able to drive each other's
|
||||
stateful sessions. Note: when even client IP is unavailable
|
||||
(exotic deployments without trusted X-Forwarded-For and direct
|
||||
socket info), the fingerprint degrades to the ``anonymous``
|
||||
sentinel and cannot meaningfully protect against another
|
||||
unauthenticated caller who learns the session id — owner-binding
|
||||
is best-effort in that mode.
|
||||
"""
|
||||
|
||||
def _bytes_for_hash(value: Any) -> Optional[bytes]:
|
||||
"""Only hash str/bytes secrets; skip mocks and other unexpected types."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return bytes(value)
|
||||
if isinstance(value, str):
|
||||
return value.encode("utf-8")
|
||||
return None
|
||||
|
||||
if user_api_key_auth is not None:
|
||||
key_material = _bytes_for_hash(getattr(user_api_key_auth, "api_key", None))
|
||||
if key_material:
|
||||
api_key_hash = hashlib.sha256(key_material).hexdigest()
|
||||
return f"key:{api_key_hash}"
|
||||
uid_material = _bytes_for_hash(getattr(user_api_key_auth, "user_id", None))
|
||||
if uid_material:
|
||||
user_id_hash = hashlib.sha256(uid_material).hexdigest()
|
||||
return f"user:{user_id_hash}"
|
||||
if oauth2_headers:
|
||||
authz = oauth2_headers.get("Authorization") or oauth2_headers.get(
|
||||
"authorization"
|
||||
)
|
||||
authz_bytes = _bytes_for_hash(authz)
|
||||
if authz_bytes:
|
||||
return f"oauth:{hashlib.sha256(authz_bytes).hexdigest()}"
|
||||
if client_ip and isinstance(client_ip, str):
|
||||
return f"ip:{hashlib.sha256(client_ip.encode('utf-8')).hexdigest()}"
|
||||
return "anonymous"
|
||||
|
||||
def _is_initialize_request(body: bytes) -> bool:
|
||||
"""
|
||||
Check if the request body is a JSON-RPC initialize method.
|
||||
Returns True if method is "initialize", False otherwise or on parse error.
|
||||
"""
|
||||
if not body:
|
||||
return False
|
||||
try:
|
||||
data = json.loads(body)
|
||||
return isinstance(data, dict) and data.get("method") == "initialize"
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return False
|
||||
|
||||
async def _read_request_body_for_routing(
|
||||
receive: Receive,
|
||||
) -> Tuple[List[Message], bytes]:
|
||||
"""
|
||||
Read just enough of the request body to decide whether this is a
|
||||
JSON-RPC ``initialize`` call. Returns the consumed ASGI messages so
|
||||
the caller can replay them faithfully to the downstream handler, and
|
||||
the peeked body bytes (capped at ``_MCP_ROUTING_PEEK_MAX_BYTES``).
|
||||
|
||||
Stops reading from the wire as soon as either (a) we have peeked
|
||||
``_MCP_ROUTING_PEEK_MAX_BYTES`` of body, or (b) the body is complete.
|
||||
The remainder of an oversized body is streamed lazily through
|
||||
``wrapped_receive`` in the caller — so an authenticated client cannot
|
||||
force the proxy to buffer an arbitrarily large payload just to make a
|
||||
routing decision.
|
||||
"""
|
||||
consumed_messages: List[Message] = []
|
||||
body_chunks: List[bytes] = []
|
||||
peeked_bytes = 0
|
||||
|
||||
while True:
|
||||
message = await receive()
|
||||
consumed_messages.append(message)
|
||||
|
||||
if message.get("type") != "http.request":
|
||||
break
|
||||
|
||||
body = message.get("body", b"") or b""
|
||||
if body:
|
||||
# Only retain up to the remaining peek budget for sniffing.
|
||||
# The full ``message`` is already in memory (delivered by
|
||||
# the ASGI server) and must round-trip to the downstream
|
||||
# handler via ``consumed_messages``, but ``body_chunks`` is
|
||||
# purely for the JSON-RPC method check — there is no reason
|
||||
# to copy a large body frame into a second buffer.
|
||||
remaining = _MCP_ROUTING_PEEK_MAX_BYTES - peeked_bytes
|
||||
if remaining > 0:
|
||||
body_chunks.append(body[:remaining])
|
||||
peeked_bytes += min(len(body), remaining)
|
||||
|
||||
if not message.get("more_body", False):
|
||||
break
|
||||
|
||||
if peeked_bytes >= _MCP_ROUTING_PEEK_MAX_BYTES:
|
||||
# Stop draining; downstream replay will pull remaining chunks
|
||||
# directly from the original `receive` via wrapped_receive.
|
||||
break
|
||||
|
||||
return consumed_messages, b"".join(body_chunks)
|
||||
|
||||
async def _handle_stale_mcp_session(
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
|
|
@ -2760,6 +3047,7 @@ if MCP_AVAILABLE:
|
|||
method = scope.get("method", "").upper()
|
||||
|
||||
if method == "DELETE":
|
||||
_remove_stateful_session_tracking(_session_id)
|
||||
verbose_logger.info(
|
||||
"DELETE request for non-existent MCP session '%s'. "
|
||||
"Returning success (idempotent DELETE).",
|
||||
|
|
@ -2993,7 +3281,7 @@ if MCP_AVAILABLE:
|
|||
detail="Forbidden",
|
||||
)
|
||||
|
||||
async def handle_streamable_http_mcp(
|
||||
async def handle_streamable_http_mcp( # noqa: PLR0915
|
||||
scope: Scope, receive: Receive, send: Send
|
||||
) -> None:
|
||||
"""Handle MCP requests through StreamableHTTP."""
|
||||
|
|
@ -3086,38 +3374,215 @@ if MCP_AVAILABLE:
|
|||
if _debug_headers:
|
||||
send = MCPDebug.wrap_send_with_debug_headers(send, _debug_headers)
|
||||
|
||||
# Set the auth context variable for easy access in MCP functions
|
||||
set_auth_context(
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_servers=mcp_servers,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
client_ip=_client_ip,
|
||||
)
|
||||
|
||||
# Ensure session managers are initialized
|
||||
if not _SESSION_MANAGERS_INITIALIZED:
|
||||
await initialize_session_managers()
|
||||
# Give it a moment to start up
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Handle stale session IDs - either strip them for reconnection
|
||||
# or return success for idempotent DELETE operations
|
||||
handled = await _handle_stale_mcp_session(
|
||||
scope, receive, send, session_manager
|
||||
)
|
||||
if handled:
|
||||
# Request was fully handled (e.g., DELETE on non-existent session)
|
||||
return
|
||||
# Route based on mcp-session-id and request method:
|
||||
# - Has session ID → stateful (Claude Code, Cursor, VSCode)
|
||||
# - No session ID + initialize → stateful (so client gets mcp-session-id)
|
||||
# - No session ID + other → stateless (curl, Inspector, Notion)
|
||||
session_id = _get_session_id_from_scope(scope)
|
||||
is_initialize = False
|
||||
consumed_messages: List[Message] = []
|
||||
|
||||
async with _gateway_initialize_instructions_request_scope(
|
||||
user_api_key_auth,
|
||||
mcp_servers,
|
||||
_client_ip,
|
||||
):
|
||||
await session_manager.handle_request(scope, receive, send)
|
||||
# Owner-binding: a live stateful session may only be driven by the
|
||||
# caller that created it. Reject mismatches with 403 so a leaked
|
||||
# mcp-session-id cannot be hijacked by another authenticated user.
|
||||
#
|
||||
# Run before ``_handle_stale_mcp_session`` so a non-owner cannot
|
||||
# force-clean another caller's residual tracking entries via a
|
||||
# stale DELETE, and before peeking the request body so the 403
|
||||
# response sees a pristine ``receive`` channel.
|
||||
if session_id:
|
||||
expected_owner = _stateful_session_owners.get(session_id)
|
||||
request_owner = _owner_fingerprint_for(
|
||||
user_api_key_auth, oauth2_headers, _client_ip
|
||||
)
|
||||
if expected_owner is not None and expected_owner != request_owner:
|
||||
verbose_logger.warning(
|
||||
"Rejecting MCP request: session '%s' owner mismatch.",
|
||||
session_id,
|
||||
)
|
||||
forbidden_response = JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "Forbidden",
|
||||
"details": "mcp-session-id is bound to a different caller.",
|
||||
},
|
||||
)
|
||||
await forbidden_response(scope, receive, send)
|
||||
return
|
||||
|
||||
# Handle stale session IDs before choosing a target manager. Stale
|
||||
# non-DELETE requests have their session header stripped and should
|
||||
# be routed as no-session requests.
|
||||
if session_id:
|
||||
handled = await _handle_stale_mcp_session(
|
||||
scope, receive, send, session_manager_stateful
|
||||
)
|
||||
if handled:
|
||||
# Request was fully handled (e.g., DELETE on non-existent session)
|
||||
return
|
||||
session_id = _get_session_id_from_scope(scope)
|
||||
|
||||
if scope.get("method") == "POST":
|
||||
consumed_messages, body = await _read_request_body_for_routing(receive)
|
||||
is_initialize = _is_initialize_request(body)
|
||||
|
||||
use_stateful = bool(session_id or is_initialize)
|
||||
target_manager = (
|
||||
session_manager_stateful if use_stateful else session_manager_stateless
|
||||
)
|
||||
|
||||
verbose_logger.debug(
|
||||
f"MCP routing to {'stateful' if use_stateful else 'stateless'} manager"
|
||||
+ (f" (session={session_id[:8]}...)" if session_id else "")
|
||||
+ (" (initialize)" if is_initialize else "")
|
||||
)
|
||||
|
||||
# A new `initialize` (no session id) is about to create a stateful
|
||||
# session. Cap how many a single caller can hold so an authenticated
|
||||
# client cannot spam `initialize` and exhaust memory.
|
||||
if is_initialize and not session_id:
|
||||
request_owner = _owner_fingerprint_for(
|
||||
user_api_key_auth, oauth2_headers, _client_ip
|
||||
)
|
||||
if not await _enforce_stateful_session_cap_for_owner(request_owner):
|
||||
verbose_logger.warning(
|
||||
"Rejecting MCP initialize: caller already holds the maximum "
|
||||
"number of active stateful sessions."
|
||||
)
|
||||
too_many_response = JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"error": "Too Many Requests",
|
||||
"details": "Too many active MCP sessions for this caller.",
|
||||
},
|
||||
)
|
||||
await too_many_response(scope, receive, send)
|
||||
return
|
||||
|
||||
# Replay body messages if we consumed them for peeking
|
||||
original_receive = receive
|
||||
if consumed_messages:
|
||||
|
||||
async def wrapped_receive():
|
||||
if consumed_messages:
|
||||
return consumed_messages.pop(0)
|
||||
return await original_receive()
|
||||
|
||||
receive = wrapped_receive
|
||||
|
||||
# Serialize requests on the same stateful session so concurrent
|
||||
# callers don't clobber each other's auth context mid-flight.
|
||||
#
|
||||
# Skip the lock for streaming GETs (SSE channels held open for the
|
||||
# life of the session): holding a per-session lock for a long-lived
|
||||
# stream would block every subsequent POST on the same session.
|
||||
# POST/DELETE are the methods that actually mutate the shared
|
||||
# auth context, so serializing those is sufficient for the
|
||||
# clobbering race between concurrent JSON-RPC calls.
|
||||
session_lock: Optional[asyncio.Lock] = None
|
||||
request_method = (scope.get("method") or "").upper()
|
||||
if use_stateful and session_id and request_method in ("POST", "DELETE"):
|
||||
session_lock = _stateful_session_locks.setdefault(
|
||||
session_id, asyncio.Lock()
|
||||
)
|
||||
|
||||
active_request_session_ids: List[str] = []
|
||||
|
||||
def _increment_active_request_session(session_id_to_track: str) -> None:
|
||||
if session_id_to_track in active_request_session_ids:
|
||||
return
|
||||
active_request_session_ids.append(session_id_to_track)
|
||||
_stateful_session_active_request_counts[session_id_to_track] = (
|
||||
_stateful_session_active_request_counts.get(session_id_to_track, 0)
|
||||
+ 1
|
||||
)
|
||||
|
||||
if use_stateful and session_id:
|
||||
_increment_active_request_session(session_id)
|
||||
|
||||
def _track_initialized_stateful_session(
|
||||
initialized_session_id: str,
|
||||
) -> None:
|
||||
_increment_active_request_session(initialized_session_id)
|
||||
|
||||
async def _dispatch() -> None:
|
||||
auth_user = _set_or_update_auth_context(
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_servers=mcp_servers,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
client_ip=_client_ip,
|
||||
session_id=session_id if use_stateful else None,
|
||||
touch_last_seen=(scope.get("method") or "").upper() != "DELETE",
|
||||
copy_existing_session_auth_context=is_initialize,
|
||||
)
|
||||
local_send = send
|
||||
if use_stateful and is_initialize:
|
||||
local_send = _wrap_send_with_stateful_session_auth_context(
|
||||
local_send,
|
||||
auth_user,
|
||||
_owner_fingerprint_for(
|
||||
user_api_key_auth, oauth2_headers, _client_ip
|
||||
),
|
||||
_track_initialized_stateful_session,
|
||||
)
|
||||
|
||||
async with _gateway_initialize_instructions_request_scope(
|
||||
user_api_key_auth,
|
||||
mcp_servers,
|
||||
_client_ip,
|
||||
):
|
||||
await target_manager.handle_request(scope, receive, local_send)
|
||||
if use_stateful and session_id and scope.get("method") == "DELETE":
|
||||
_remove_stateful_session_tracking(session_id)
|
||||
|
||||
try:
|
||||
if session_lock is not None:
|
||||
async with session_lock:
|
||||
await _dispatch()
|
||||
else:
|
||||
await _dispatch()
|
||||
finally:
|
||||
for active_request_session_id in active_request_session_ids:
|
||||
active_request_count = (
|
||||
_stateful_session_active_request_counts.get(
|
||||
active_request_session_id, 0
|
||||
)
|
||||
- 1
|
||||
)
|
||||
if active_request_count > 0:
|
||||
_stateful_session_active_request_counts[
|
||||
active_request_session_id
|
||||
] = active_request_count
|
||||
else:
|
||||
_stateful_session_active_request_counts.pop(
|
||||
active_request_session_id, None
|
||||
)
|
||||
|
||||
if (
|
||||
scope.get("method") != "DELETE"
|
||||
and active_request_session_id in _stateful_session_auth_contexts
|
||||
):
|
||||
_stateful_session_auth_context_last_seen[
|
||||
active_request_session_id
|
||||
] = time.monotonic()
|
||||
|
||||
# Periodic cleanup iterates _stateful_session_auth_context_last_seen,
|
||||
# so locks for untracked sessions must be dropped here.
|
||||
if (
|
||||
active_request_count <= 0
|
||||
and active_request_session_id
|
||||
not in _stateful_session_auth_contexts
|
||||
):
|
||||
_stateful_session_locks.pop(active_request_session_id, None)
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions to preserve status codes and details
|
||||
raise
|
||||
|
|
@ -3125,7 +3590,6 @@ if MCP_AVAILABLE:
|
|||
verbose_logger.exception(f"Error handling MCP request: {e}")
|
||||
# Try to send a graceful error response for non-HTTP exceptions
|
||||
try:
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
error_response = JSONResponse(
|
||||
|
|
@ -3231,8 +3695,9 @@ if MCP_AVAILABLE:
|
|||
############ Auth Context Functions ####################
|
||||
########################################################
|
||||
|
||||
def set_auth_context(
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
def _update_auth_context(
|
||||
auth_user: MCPAuthenticatedUser,
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth],
|
||||
mcp_auth_header: Optional[str] = None,
|
||||
mcp_servers: Optional[List[str]] = None,
|
||||
mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
|
|
@ -3240,6 +3705,23 @@ if MCP_AVAILABLE:
|
|||
raw_headers: Optional[Dict[str, str]] = None,
|
||||
client_ip: Optional[str] = None,
|
||||
) -> None:
|
||||
auth_user.user_api_key_auth = user_api_key_auth
|
||||
auth_user.mcp_auth_header = mcp_auth_header
|
||||
auth_user.mcp_servers = mcp_servers
|
||||
auth_user.mcp_server_auth_headers = mcp_server_auth_headers or {}
|
||||
auth_user.oauth2_headers = oauth2_headers
|
||||
auth_user.raw_headers = raw_headers
|
||||
auth_user.client_ip = client_ip
|
||||
|
||||
def set_auth_context(
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth],
|
||||
mcp_auth_header: Optional[str] = None,
|
||||
mcp_servers: Optional[List[str]] = None,
|
||||
mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
oauth2_headers: Optional[Dict[str, str]] = None,
|
||||
raw_headers: Optional[Dict[str, str]] = None,
|
||||
client_ip: Optional[str] = None,
|
||||
) -> MCPAuthenticatedUser:
|
||||
"""
|
||||
Set the UserAPIKeyAuth in the auth context variable.
|
||||
|
||||
|
|
@ -3260,6 +3742,84 @@ if MCP_AVAILABLE:
|
|||
client_ip=client_ip,
|
||||
)
|
||||
auth_context_var.set(auth_user)
|
||||
return auth_user
|
||||
|
||||
def _set_or_update_auth_context(
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth],
|
||||
mcp_auth_header: Optional[str] = None,
|
||||
mcp_servers: Optional[List[str]] = None,
|
||||
mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
oauth2_headers: Optional[Dict[str, str]] = None,
|
||||
raw_headers: Optional[Dict[str, str]] = None,
|
||||
client_ip: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
touch_last_seen: bool = True,
|
||||
copy_existing_session_auth_context: bool = False,
|
||||
) -> MCPAuthenticatedUser:
|
||||
auth_user = (
|
||||
_stateful_session_auth_contexts.get(session_id) if session_id else None
|
||||
)
|
||||
if auth_user is not None and session_id is not None:
|
||||
if touch_last_seen:
|
||||
_stateful_session_auth_context_last_seen[session_id] = time.monotonic()
|
||||
if copy_existing_session_auth_context:
|
||||
return set_auth_context(
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_servers=mcp_servers,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
_update_auth_context(
|
||||
auth_user=auth_user,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_servers=mcp_servers,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
auth_context_var.set(auth_user)
|
||||
return auth_user
|
||||
return set_auth_context(
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_servers=mcp_servers,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
|
||||
def _wrap_send_with_stateful_session_auth_context(
|
||||
send: Send,
|
||||
auth_user: MCPAuthenticatedUser,
|
||||
owner_fingerprint: str,
|
||||
on_session_registered: Optional[Callable[[str], None]] = None,
|
||||
) -> Send:
|
||||
async def wrapped_send(message: Message) -> None:
|
||||
if message.get("type") == "http.response.start":
|
||||
for key, value in message.get("headers", []):
|
||||
header_name = key if isinstance(key, bytes) else str(key).encode()
|
||||
if header_name.lower() == b"mcp-session-id":
|
||||
session_id = (
|
||||
value.decode() if isinstance(value, bytes) else str(value)
|
||||
)
|
||||
if on_session_registered is not None:
|
||||
on_session_registered(session_id)
|
||||
auth_context_var.set(auth_user)
|
||||
_stateful_session_auth_contexts[session_id] = auth_user
|
||||
_stateful_session_auth_context_last_seen[session_id] = (
|
||||
time.monotonic()
|
||||
)
|
||||
_stateful_session_owners[session_id] = owner_fingerprint
|
||||
break
|
||||
await send(message)
|
||||
|
||||
return wrapped_send
|
||||
|
||||
def get_auth_context() -> Tuple[
|
||||
Optional[UserAPIKeyAuth],
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
1
litellm/proxy/_experimental/out/404/index.html
Normal file
1
litellm/proxy/_experimental/out/404/index.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1,30 +1,10 @@
|
|||
1:"$Sreact.fragment"
|
||||
2:I[347257,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"ClientPageRoot"]
|
||||
3:I[952683,["/litellm-asset-prefix/_next/static/chunks/9e09de50158b3159.js","/litellm-asset-prefix/_next/static/chunks/7e5fe5584502da06.js","/litellm-asset-prefix/_next/static/chunks/0493aafc4891dd29.js","/litellm-asset-prefix/_next/static/chunks/f7e1d08418645368.js","/litellm-asset-prefix/_next/static/chunks/b3d198d6c56a21b8.js","/litellm-asset-prefix/_next/static/chunks/c847ecdf8c790b0b.js","/litellm-asset-prefix/_next/static/chunks/ee5f9a39a526e423.js","/litellm-asset-prefix/_next/static/chunks/adb8beb738574863.js","/litellm-asset-prefix/_next/static/chunks/0549bc9afa7d4888.js","/litellm-asset-prefix/_next/static/chunks/0b470ffc60999bf4.js","/litellm-asset-prefix/_next/static/chunks/0b3d09ff6c6e4335.js","/litellm-asset-prefix/_next/static/chunks/e099566e8bd4ee4e.js","/litellm-asset-prefix/_next/static/chunks/403c4d96324c23a6.js","/litellm-asset-prefix/_next/static/chunks/b1c98cc932a0ab19.js","/litellm-asset-prefix/_next/static/chunks/4e17b625d75327a7.js","/litellm-asset-prefix/_next/static/chunks/7b788dd93ad868b3.js","/litellm-asset-prefix/_next/static/chunks/a06cc76a774dd182.js","/litellm-asset-prefix/_next/static/chunks/fcdf7322b0aa3e2e.js","/litellm-asset-prefix/_next/static/chunks/0974abc09c5e7ada.js","/litellm-asset-prefix/_next/static/chunks/ae625aa52246581e.js","/litellm-asset-prefix/_next/static/chunks/7a9066dcd4a390ff.js","/litellm-asset-prefix/_next/static/chunks/baadbd26839e7b66.js","/litellm-asset-prefix/_next/static/chunks/2971c4658f1bcd7d.js","/litellm-asset-prefix/_next/static/chunks/134f728fa7099e3e.js","/litellm-asset-prefix/_next/static/chunks/679dbd657c8b5aef.js","/litellm-asset-prefix/_next/static/chunks/88001a7ecaf7b1af.js","/litellm-asset-prefix/_next/static/chunks/cbc99c8fae110c02.js","/litellm-asset-prefix/_next/static/chunks/4e06277331e725da.js","/litellm-asset-prefix/_next/static/chunks/3b30ab8eaa03bc21.js","/litellm-asset-prefix/_next/static/chunks/1c881baaaa68b7a5.js","/litellm-asset-prefix/_next/static/chunks/9955c118354ef6cc.js","/litellm-asset-prefix/_next/static/chunks/496b84010c33cf69.js","/litellm-asset-prefix/_next/static/chunks/a09028cd611c08ef.js","/litellm-asset-prefix/_next/static/chunks/99cf9cf99df5ccfc.js","/litellm-asset-prefix/_next/static/chunks/ad02f56c287539eb.js","/litellm-asset-prefix/_next/static/chunks/5181a28310842d3d.js","/litellm-asset-prefix/_next/static/chunks/0a65da2cd24e2ab6.js","/litellm-asset-prefix/_next/static/chunks/16a1651c0b3e7c8e.js","/litellm-asset-prefix/_next/static/chunks/a8f7c8c5eeb6e042.js","/litellm-asset-prefix/_next/static/chunks/4980372eaa37b78b.js","/litellm-asset-prefix/_next/static/chunks/d3ac82723ec9e30d.js","/litellm-asset-prefix/_next/static/chunks/631b1874cba557c9.js","/litellm-asset-prefix/_next/static/chunks/1bc2898be56acd1b.js","/litellm-asset-prefix/_next/static/chunks/908828a91f602d8b.js","/litellm-asset-prefix/_next/static/chunks/878832edb30e99a4.js","/litellm-asset-prefix/_next/static/chunks/7e417dd24c8becd0.js","/litellm-asset-prefix/_next/static/chunks/003f1ffc5817ab83.js","/litellm-asset-prefix/_next/static/chunks/e1f23fd814ac3500.js","/litellm-asset-prefix/_next/static/chunks/88c74f8b4b20d25a.js","/litellm-asset-prefix/_next/static/chunks/23bfdf9b0544f0b1.js","/litellm-asset-prefix/_next/static/chunks/ca5fbafaf3826374.js","/litellm-asset-prefix/_next/static/chunks/726bebeef472c6cb.js","/litellm-asset-prefix/_next/static/chunks/8f3bf592254c6c3b.js","/litellm-asset-prefix/_next/static/chunks/659ce28f2cb74401.js","/litellm-asset-prefix/_next/static/chunks/16c0e58809eaf2b5.js"],"default"]
|
||||
1a:I[897367,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"OutletBoundary"]
|
||||
1b:"$Sreact.suspense"
|
||||
3:I[952683,["/litellm-asset-prefix/_next/static/chunks/aefca6f40ea185cd.js","/litellm-asset-prefix/_next/static/chunks/632b4c8e836bd956.js","/litellm-asset-prefix/_next/static/chunks/52c4ecc57f72065e.js","/litellm-asset-prefix/_next/static/chunks/4bb663ff806dc32f.js","/litellm-asset-prefix/_next/static/chunks/ee8f89c672745c59.js","/litellm-asset-prefix/_next/static/chunks/2954392b7a60a6a1.js","/litellm-asset-prefix/_next/static/chunks/04711b0f8ffa7bbd.js","/litellm-asset-prefix/_next/static/chunks/9710770c6333a72f.js","/litellm-asset-prefix/_next/static/chunks/88c74f8b4b20d25a.js","/litellm-asset-prefix/_next/static/chunks/d734cb3d5659b0da.js","/litellm-asset-prefix/_next/static/chunks/a1ef280b7ad5ae6a.js","/litellm-asset-prefix/_next/static/chunks/09c1f51da7e82268.js","/litellm-asset-prefix/_next/static/chunks/542a1a209eb732c6.js","/litellm-asset-prefix/_next/static/chunks/d028f8c28935d281.js","/litellm-asset-prefix/_next/static/chunks/61aa637257592262.js","/litellm-asset-prefix/_next/static/chunks/bf962cd5264be987.js","/litellm-asset-prefix/_next/static/chunks/37f229ef9335f8c3.js","/litellm-asset-prefix/_next/static/chunks/36d1c027ba991a4f.js","/litellm-asset-prefix/_next/static/chunks/786e88f4abdd5c58.js","/litellm-asset-prefix/_next/static/chunks/43c3db1352241a8b.js","/litellm-asset-prefix/_next/static/chunks/31275eb5c6f6332f.js","/litellm-asset-prefix/_next/static/chunks/0ac09b227f50edb4.js","/litellm-asset-prefix/_next/static/chunks/4c848b12d4ecda3d.js","/litellm-asset-prefix/_next/static/chunks/91c828abd7c0aff5.js","/litellm-asset-prefix/_next/static/chunks/08c348f8e09a5cb0.js","/litellm-asset-prefix/_next/static/chunks/7e417dd24c8becd0.js","/litellm-asset-prefix/_next/static/chunks/b323e0ef008e6348.js","/litellm-asset-prefix/_next/static/chunks/4ac3235460262f36.js","/litellm-asset-prefix/_next/static/chunks/51494a4a4b6fc437.js","/litellm-asset-prefix/_next/static/chunks/878832edb30e99a4.js","/litellm-asset-prefix/_next/static/chunks/9f1486622270556b.js"],"default"]
|
||||
6:I[897367,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"OutletBoundary"]
|
||||
7:"$Sreact.suspense"
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/3f3fa56b5786d58c.css","style"]
|
||||
0:{"buildId":"wnL6e5S6xaG1UdkxtYrTo","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/3f3fa56b5786d58c.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0493aafc4891dd29.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/f7e1d08418645368.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/b3d198d6c56a21b8.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/c847ecdf8c790b0b.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/ee5f9a39a526e423.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/adb8beb738574863.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0549bc9afa7d4888.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/0b470ffc60999bf4.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/0b3d09ff6c6e4335.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/e099566e8bd4ee4e.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/403c4d96324c23a6.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/b1c98cc932a0ab19.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/4e17b625d75327a7.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/7b788dd93ad868b3.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/a06cc76a774dd182.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/fcdf7322b0aa3e2e.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/0974abc09c5e7ada.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/ae625aa52246581e.js","async":true}],["$","script","script-18",{"src":"/litellm-asset-prefix/_next/static/chunks/7a9066dcd4a390ff.js","async":true}],["$","script","script-19",{"src":"/litellm-asset-prefix/_next/static/chunks/baadbd26839e7b66.js","async":true}],["$","script","script-20",{"src":"/litellm-asset-prefix/_next/static/chunks/2971c4658f1bcd7d.js","async":true}],["$","script","script-21",{"src":"/litellm-asset-prefix/_next/static/chunks/134f728fa7099e3e.js","async":true}],["$","script","script-22",{"src":"/litellm-asset-prefix/_next/static/chunks/679dbd657c8b5aef.js","async":true}],["$","script","script-23",{"src":"/litellm-asset-prefix/_next/static/chunks/88001a7ecaf7b1af.js","async":true}],["$","script","script-24",{"src":"/litellm-asset-prefix/_next/static/chunks/cbc99c8fae110c02.js","async":true}],["$","script","script-25",{"src":"/litellm-asset-prefix/_next/static/chunks/4e06277331e725da.js","async":true}],["$","script","script-26",{"src":"/litellm-asset-prefix/_next/static/chunks/3b30ab8eaa03bc21.js","async":true}],["$","script","script-27",{"src":"/litellm-asset-prefix/_next/static/chunks/1c881baaaa68b7a5.js","async":true}],["$","script","script-28",{"src":"/litellm-asset-prefix/_next/static/chunks/9955c118354ef6cc.js","async":true}],["$","script","script-29",{"src":"/litellm-asset-prefix/_next/static/chunks/496b84010c33cf69.js","async":true}],["$","script","script-30",{"src":"/litellm-asset-prefix/_next/static/chunks/a09028cd611c08ef.js","async":true}],["$","script","script-31",{"src":"/litellm-asset-prefix/_next/static/chunks/99cf9cf99df5ccfc.js","async":true}],["$","script","script-32",{"src":"/litellm-asset-prefix/_next/static/chunks/ad02f56c287539eb.js","async":true}],["$","script","script-33",{"src":"/litellm-asset-prefix/_next/static/chunks/5181a28310842d3d.js","async":true}],"$L6","$L7","$L8","$L9","$La","$Lb","$Lc","$Ld","$Le","$Lf","$L10","$L11","$L12","$L13","$L14","$L15","$L16","$L17","$L18"],"$L19"]}],"loading":null,"isPartial":false}
|
||||
0:{"buildId":"TrcGiQpTupSbDYFFfkFHY","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/3f3fa56b5786d58c.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/52c4ecc57f72065e.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/4bb663ff806dc32f.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/ee8f89c672745c59.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/2954392b7a60a6a1.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/04711b0f8ffa7bbd.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/9710770c6333a72f.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/88c74f8b4b20d25a.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/d734cb3d5659b0da.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/a1ef280b7ad5ae6a.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/09c1f51da7e82268.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/542a1a209eb732c6.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/d028f8c28935d281.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/61aa637257592262.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/bf962cd5264be987.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/37f229ef9335f8c3.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/36d1c027ba991a4f.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/786e88f4abdd5c58.js","async":true}],["$","script","script-17",{"src":"/litellm-asset-prefix/_next/static/chunks/43c3db1352241a8b.js","async":true}],["$","script","script-18",{"src":"/litellm-asset-prefix/_next/static/chunks/31275eb5c6f6332f.js","async":true}],["$","script","script-19",{"src":"/litellm-asset-prefix/_next/static/chunks/0ac09b227f50edb4.js","async":true}],["$","script","script-20",{"src":"/litellm-asset-prefix/_next/static/chunks/4c848b12d4ecda3d.js","async":true}],["$","script","script-21",{"src":"/litellm-asset-prefix/_next/static/chunks/91c828abd7c0aff5.js","async":true}],["$","script","script-22",{"src":"/litellm-asset-prefix/_next/static/chunks/08c348f8e09a5cb0.js","async":true}],["$","script","script-23",{"src":"/litellm-asset-prefix/_next/static/chunks/7e417dd24c8becd0.js","async":true}],["$","script","script-24",{"src":"/litellm-asset-prefix/_next/static/chunks/b323e0ef008e6348.js","async":true}],["$","script","script-25",{"src":"/litellm-asset-prefix/_next/static/chunks/4ac3235460262f36.js","async":true}],["$","script","script-26",{"src":"/litellm-asset-prefix/_next/static/chunks/51494a4a4b6fc437.js","async":true}],["$","script","script-27",{"src":"/litellm-asset-prefix/_next/static/chunks/878832edb30e99a4.js","async":true}],["$","script","script-28",{"src":"/litellm-asset-prefix/_next/static/chunks/9f1486622270556b.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"loading":null,"isPartial":false}
|
||||
4:{}
|
||||
5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
|
||||
6:["$","script","script-34",{"src":"/litellm-asset-prefix/_next/static/chunks/0a65da2cd24e2ab6.js","async":true}]
|
||||
7:["$","script","script-35",{"src":"/litellm-asset-prefix/_next/static/chunks/16a1651c0b3e7c8e.js","async":true}]
|
||||
8:["$","script","script-36",{"src":"/litellm-asset-prefix/_next/static/chunks/a8f7c8c5eeb6e042.js","async":true}]
|
||||
9:["$","script","script-37",{"src":"/litellm-asset-prefix/_next/static/chunks/4980372eaa37b78b.js","async":true}]
|
||||
a:["$","script","script-38",{"src":"/litellm-asset-prefix/_next/static/chunks/d3ac82723ec9e30d.js","async":true}]
|
||||
b:["$","script","script-39",{"src":"/litellm-asset-prefix/_next/static/chunks/631b1874cba557c9.js","async":true}]
|
||||
c:["$","script","script-40",{"src":"/litellm-asset-prefix/_next/static/chunks/1bc2898be56acd1b.js","async":true}]
|
||||
d:["$","script","script-41",{"src":"/litellm-asset-prefix/_next/static/chunks/908828a91f602d8b.js","async":true}]
|
||||
e:["$","script","script-42",{"src":"/litellm-asset-prefix/_next/static/chunks/878832edb30e99a4.js","async":true}]
|
||||
f:["$","script","script-43",{"src":"/litellm-asset-prefix/_next/static/chunks/7e417dd24c8becd0.js","async":true}]
|
||||
10:["$","script","script-44",{"src":"/litellm-asset-prefix/_next/static/chunks/003f1ffc5817ab83.js","async":true}]
|
||||
11:["$","script","script-45",{"src":"/litellm-asset-prefix/_next/static/chunks/e1f23fd814ac3500.js","async":true}]
|
||||
12:["$","script","script-46",{"src":"/litellm-asset-prefix/_next/static/chunks/88c74f8b4b20d25a.js","async":true}]
|
||||
13:["$","script","script-47",{"src":"/litellm-asset-prefix/_next/static/chunks/23bfdf9b0544f0b1.js","async":true}]
|
||||
14:["$","script","script-48",{"src":"/litellm-asset-prefix/_next/static/chunks/ca5fbafaf3826374.js","async":true}]
|
||||
15:["$","script","script-49",{"src":"/litellm-asset-prefix/_next/static/chunks/726bebeef472c6cb.js","async":true}]
|
||||
16:["$","script","script-50",{"src":"/litellm-asset-prefix/_next/static/chunks/8f3bf592254c6c3b.js","async":true}]
|
||||
17:["$","script","script-51",{"src":"/litellm-asset-prefix/_next/static/chunks/659ce28f2cb74401.js","async":true}]
|
||||
18:["$","script","script-52",{"src":"/litellm-asset-prefix/_next/static/chunks/16c0e58809eaf2b5.js","async":true}]
|
||||
19:["$","$L1a",null,{"children":["$","$1b",null,{"name":"Next.MetadataOutlet","children":"$@1c"}]}]
|
||||
1c:null
|
||||
8:null
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,4 +3,4 @@
|
|||
3:I[897367,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"MetadataBoundary"]
|
||||
4:"$Sreact.suspense"
|
||||
5:I[27201,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"IconMark"]
|
||||
0:{"buildId":"wnL6e5S6xaG1UdkxtYrTo","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.1d32c690.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"./favicon.ico"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"loading":null,"isPartial":false}
|
||||
0:{"buildId":"TrcGiQpTupSbDYFFfkFHY","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.1d32c690.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"./favicon.ico"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"loading":null,"isPartial":false}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
1:"$Sreact.fragment"
|
||||
2:I[867271,["/litellm-asset-prefix/_next/static/chunks/9e09de50158b3159.js","/litellm-asset-prefix/_next/static/chunks/7e5fe5584502da06.js"],"default"]
|
||||
3:I[71195,["/litellm-asset-prefix/_next/static/chunks/9e09de50158b3159.js","/litellm-asset-prefix/_next/static/chunks/7e5fe5584502da06.js"],"default"]
|
||||
4:I[339756,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"default"]
|
||||
5:I[837457,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"default"]
|
||||
2:I[867271,["/litellm-asset-prefix/_next/static/chunks/aefca6f40ea185cd.js","/litellm-asset-prefix/_next/static/chunks/632b4c8e836bd956.js"],"default"]
|
||||
3:I[71195,["/litellm-asset-prefix/_next/static/chunks/aefca6f40ea185cd.js","/litellm-asset-prefix/_next/static/chunks/632b4c8e836bd956.js"],"default"]
|
||||
4:I[557951,["/litellm-asset-prefix/_next/static/chunks/aefca6f40ea185cd.js","/litellm-asset-prefix/_next/static/chunks/632b4c8e836bd956.js"],"AuthProvider"]
|
||||
5:I[339756,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"default"]
|
||||
6:I[837457,["/litellm-asset-prefix/_next/static/chunks/d96012bcfc98706a.js","/litellm-asset-prefix/_next/static/chunks/dbca964212122d58.js"],"default"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/4e20891f2fd03463.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/91037395c95e366d.css","style"]
|
||||
0:{"buildId":"wnL6e5S6xaG1UdkxtYrTo","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/4e20891f2fd03463.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/91037395c95e366d.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/9e09de50158b3159.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/7e5fe5584502da06.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]}]]}],"loading":null,"isPartial":false}
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/1bcca3c38c9deb02.css","style"]
|
||||
0:{"buildId":"TrcGiQpTupSbDYFFfkFHY","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/4e20891f2fd03463.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/1bcca3c38c9deb02.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/aefca6f40ea185cd.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/632b4c8e836bd956.js","async":true}]],["$","html",null,{"lang":"en","children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","template":["$","$L6",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}]}]}]}]}]]}],"loading":null,"isPartial":false}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
:HL["/litellm-asset-prefix/_next/static/chunks/4e20891f2fd03463.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/91037395c95e366d.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/1bcca3c38c9deb02.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/media/83afe278b6a6bb3c-s.p.3a6ba036.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/3f3fa56b5786d58c.css","style"]
|
||||
0:{"buildId":"wnL6e5S6xaG1UdkxtYrTo","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
|
||||
0:{"buildId":"TrcGiQpTupSbDYFFfkFHY","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue