mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
Merge remote-tracking branch 'origin/main' into litellm_any_sweep_20261020
# Conflicts: # litellm/images/utils.py
This commit is contained in:
commit
2ddd7a34e3
287 changed files with 13253 additions and 1337 deletions
|
|
@ -1,6 +1,8 @@
|
|||
---
|
||||
name: database-access
|
||||
description: Add database queries, extract existing Prisma calls into repositories, or explicitly port queries to Rust and SQLx without expanding migration scope
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Database access
|
||||
|
|
|
|||
4
.github/workflows/spend-logs-guard.yml
vendored
4
.github/workflows/spend-logs-guard.yml
vendored
|
|
@ -4,10 +4,6 @@ on: # zizmor: ignore[dangerous-triggers] runs the base branch's code only; the P
|
|||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
pull_request_review:
|
||||
types:
|
||||
- submitted
|
||||
- dismissed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
|
|||
3
.github/workflows/test-linting.yml
vendored
3
.github/workflows/test-linting.yml
vendored
|
|
@ -561,6 +561,9 @@ jobs:
|
|||
- name: router_code_coverage
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/router_code_coverage.py
|
||||
|
||||
- name: router_surface_check
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/router_surface_check.py
|
||||
|
||||
- name: test_chat_completion_imports
|
||||
run: uv run --no-sync python ./tests/code_coverage_tests/test_chat_completion_imports.py
|
||||
|
||||
|
|
|
|||
2
.github/workflows/test-unit.yml
vendored
2
.github/workflows/test-unit.yml
vendored
|
|
@ -137,6 +137,7 @@ jobs:
|
|||
include:
|
||||
- shard: core-utils
|
||||
test-path: |-
|
||||
tests/unit/accounting
|
||||
tests/unit/batch_completion
|
||||
tests/unit/decisions
|
||||
tests/unit/extraction
|
||||
|
|
@ -244,6 +245,7 @@ jobs:
|
|||
tests/unit/llms/clarifai
|
||||
tests/unit/llms/claude_code
|
||||
tests/unit/llms/cloudflare
|
||||
tests/unit/llms/codestral
|
||||
tests/unit/llms/codex
|
||||
tests/unit/llms/cohere
|
||||
tests/unit/llms/cometapi
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
needs an approving review from the spend-log owner before it can merge.
|
||||
|
||||
CODEOWNERS can only match file paths, and every schema change edits the same schema.prisma copies,
|
||||
so a path rule would put every migration on the owner. This gate reads the diff instead: when no
|
||||
added line in the watched files mentions the table it passes at once, otherwise it passes only once
|
||||
the owner's latest review on the pull request is an approval. The workflow re-runs on every review.
|
||||
so a path rule would put every migration on the owner. This gate reads the diff instead: a schema.prisma
|
||||
hunk counts when it adds or removes a line inside the `model LiteLLM_SpendLogs { ... }` block, a migration
|
||||
hunk counts when an added line names the table. Without such a hunk it passes at once, otherwise it passes
|
||||
only once the owner's latest review on the pull request is an approval that still covers every spend-log line
|
||||
in the head (approvals survive pushes, so a later commit may not add spend-log lines the owner never saw).
|
||||
Reviews do not trigger the workflow, so the check is re-run by hand after approving.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -13,8 +16,10 @@ import argparse
|
|||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter
|
||||
|
|
@ -25,7 +30,10 @@ WATCHED: Final = (
|
|||
"litellm-proxy-extras/litellm_proxy_extras/schema.prisma",
|
||||
)
|
||||
MIGRATIONS_PREFIX: Final = "litellm-proxy-extras/litellm_proxy_extras/migrations/"
|
||||
TABLE: Final = re.compile(r"(?<![A-Za-z0-9])LiteLLM_SpendLogs", re.IGNORECASE)
|
||||
TABLE: Final = re.compile(r"(?<![A-Za-z0-9])LiteLLM_SpendLogs(?![A-Za-z0-9])", re.IGNORECASE)
|
||||
DECIDING_STATES: Final = frozenset({"APPROVED", "CHANGES_REQUESTED", "DISMISSED"})
|
||||
MODEL_START: Final = re.compile(r"^model\s+LiteLLM_SpendLogs\s*\{")
|
||||
HUNK: Final = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@[^\n]*\n", re.MULTILINE)
|
||||
|
||||
|
||||
class Reviewer(BaseModel):
|
||||
|
|
@ -37,31 +45,77 @@ class Review(BaseModel):
|
|||
model_config = ConfigDict(frozen=True, extra="ignore")
|
||||
state: str
|
||||
user: Reviewer
|
||||
commit_id: str | None = None
|
||||
|
||||
|
||||
_REVIEWS: Final = TypeAdapter(list[Review])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Block:
|
||||
"""Lines of `model LiteLLM_SpendLogs { ... }` in one schema.prisma copy at the merge base and at the head."""
|
||||
|
||||
base: range
|
||||
head: range
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Hunk:
|
||||
removed: tuple[tuple[int, str], ...]
|
||||
added: tuple[tuple[int, str], ...]
|
||||
|
||||
|
||||
def watched(path: str) -> bool:
|
||||
return path in WATCHED or (path.startswith(MIGRATIONS_PREFIX) and path.endswith("/migration.sql"))
|
||||
|
||||
|
||||
def _added_lines(section: str) -> tuple[str, ...]:
|
||||
body: Final = section.split("\n@@", 1)
|
||||
return tuple(line[1:] for line in body[1].splitlines() if line.startswith("+")) if len(body) == 2 else ()
|
||||
def model_block(schema: str) -> range:
|
||||
"""1-based line range of `model LiteLLM_SpendLogs { ... }` including both braces, empty when the schema has none."""
|
||||
lines: Final = schema.splitlines()
|
||||
starts: Final = tuple(number for number, line in enumerate(lines, 1) if MODEL_START.match(line))
|
||||
if not starts:
|
||||
return range(0)
|
||||
ends: Final = tuple(number for number, line in enumerate(lines, 1) if number > starts[0] and line.startswith("}"))
|
||||
return range(starts[0], (ends[0] if ends else len(lines)) + 1)
|
||||
|
||||
|
||||
def spend_log_hits(diff: str) -> tuple[str, ...]:
|
||||
"""`path: added line` for every added line in a watched file that names the table."""
|
||||
def _hunks(section: str) -> tuple[Hunk, ...]:
|
||||
headers: Final = tuple(HUNK.finditer(section))
|
||||
ends: Final = tuple(header.start() for header in headers[1:]) + (len(section),)
|
||||
return tuple(_hunk(header, section[header.end() : end]) for header, end in zip(headers, ends))
|
||||
|
||||
|
||||
def _hunk(header: re.Match[str], body: str) -> Hunk:
|
||||
lines: Final = body.splitlines()
|
||||
return Hunk(
|
||||
removed=tuple(enumerate((line[1:] for line in lines if line.startswith("-")), int(header.group(1)))),
|
||||
added=tuple(enumerate((line[1:] for line in lines if line.startswith("+")), int(header.group(2)))),
|
||||
)
|
||||
|
||||
|
||||
def spend_log_hits(diff: str, blocks: Mapping[str, Block]) -> tuple[str, ...]:
|
||||
"""`path: +line` or `path: -line` for every changed line that touches the table.
|
||||
|
||||
A schema.prisma line counts when its number falls inside that copy's `blocks` entry (head for added lines,
|
||||
merge base for removed ones), a migration line when it is added and names the table."""
|
||||
sections: Final = re.split(r"^diff --git [^\n]*\n", diff, flags=re.MULTILINE)
|
||||
return tuple(f"{path}: {line.strip()}" for path, line in _watched_added_lines(sections) if TABLE.search(line))
|
||||
|
||||
|
||||
def _watched_added_lines(sections: Sequence[str]) -> tuple[tuple[str, str], ...]:
|
||||
pairs: Final = tuple((_path_of(section), section) for section in sections)
|
||||
paths: Final = tuple((_path_of(section), section) for section in sections)
|
||||
return tuple(
|
||||
(path, line) for path, section in pairs if watched(path) for line in _added_lines(section)
|
||||
) # comprehension-ok: path then its lines
|
||||
hit for path, section in paths if watched(path) for hit in _section_hits(path, _hunks(section), blocks)
|
||||
) # comprehension-ok: path then its hits
|
||||
|
||||
|
||||
def _section_hits(path: str, hunks: Sequence[Hunk], blocks: Mapping[str, Block]) -> tuple[str, ...]:
|
||||
if path not in blocks:
|
||||
return tuple(f"{path}: +{line.strip()}" for hunk in hunks for _, line in hunk.added if TABLE.search(line))
|
||||
block: Final = blocks[path]
|
||||
removed: Final = tuple(
|
||||
f"{path}: -{line.strip()}" for hunk in hunks for number, line in hunk.removed if number in block.base
|
||||
) # comprehension-ok: hunk then its lines
|
||||
added: Final = tuple(
|
||||
f"{path}: +{line.strip()}" for hunk in hunks for number, line in hunk.added if number in block.head
|
||||
) # comprehension-ok: hunk then its lines
|
||||
return removed + added
|
||||
|
||||
|
||||
def _path_of(section: str) -> str:
|
||||
|
|
@ -70,11 +124,33 @@ def _path_of(section: str) -> str:
|
|||
|
||||
|
||||
def approved_by(reviews: Sequence[Review], login: str) -> bool:
|
||||
"""True when `login`'s most recent review on the pull request is an approval."""
|
||||
states: Final = tuple(review.state for review in reviews if review.user.login == login)
|
||||
"""True when `login`'s most recent review that changes GitHub's approval state is an approval.
|
||||
|
||||
COMMENTED and PENDING reviews leave an earlier approval in force on GitHub, so they are skipped here too.
|
||||
"""
|
||||
own: Final = tuple(review.state for review in reviews if review.user.login == login)
|
||||
states: Final = tuple(state for state in own if state in DECIDING_STATES)
|
||||
return bool(states) and states[-1] == "APPROVED"
|
||||
|
||||
|
||||
def latest_approval(reviews: Sequence[Review], login: str) -> Review | None:
|
||||
"""`login`'s most recent APPROVED review on the pull request, the one GitHub keeps in force after pushes."""
|
||||
own: Final = tuple(review for review in reviews if review.user.login == login and review.state == "APPROVED")
|
||||
return own[-1] if own else None
|
||||
|
||||
|
||||
def approval_covers(
|
||||
approval: Review, head: str, hits: Sequence[str], hits_at: Callable[[str], Sequence[str] | None]
|
||||
) -> bool:
|
||||
"""True when the approval is on the head, or the head adds no spend-log line the approved commit did not."""
|
||||
if approval.commit_id is None:
|
||||
return False
|
||||
if approval.commit_id == head:
|
||||
return True
|
||||
reviewed: Final = hits_at(approval.commit_id)
|
||||
return reviewed is not None and set(hits) <= set(reviewed)
|
||||
|
||||
|
||||
def added_diff(base: str, head: str) -> str:
|
||||
result: Final = subprocess.run(
|
||||
("git", "diff", "--unified=0", "--no-color", f"{base}..{head}", "--", *WATCHED, MIGRATIONS_PREFIX),
|
||||
|
|
@ -85,6 +161,27 @@ def added_diff(base: str, head: str) -> str:
|
|||
return result.stdout
|
||||
|
||||
|
||||
def file_at(revision: str, path: str) -> str:
|
||||
"""Contents of `path` at `revision`, empty when the file does not exist there."""
|
||||
result: Final = subprocess.run(("git", "show", f"{revision}:{path}"), check=False, capture_output=True, text=True)
|
||||
return result.stdout if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def schema_blocks(base: str, head: str) -> Mapping[str, Block]:
|
||||
return MappingProxyType(
|
||||
{path: Block(base=model_block(file_at(base, path)), head=model_block(file_at(head, path))) for path in WATCHED}
|
||||
)
|
||||
|
||||
|
||||
def fetched(commit: str) -> bool:
|
||||
result: Final = subprocess.run(("git", "fetch", "--no-tags", "--depth=1", "origin", commit), check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def reviewed_hits(base: str, commit: str) -> tuple[str, ...] | None:
|
||||
return spend_log_hits(added_diff(base, commit), schema_blocks(base, commit)) if fetched(commit) else None
|
||||
|
||||
|
||||
class Arguments(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
base: str
|
||||
|
|
@ -100,18 +197,28 @@ def main(argv: Sequence[str]) -> int:
|
|||
parser.add_argument("--approver", required=True, help="GitHub login whose approval unlocks the merge")
|
||||
parser.add_argument("--reviews", required=True, help="JSON file with the pull request's reviews")
|
||||
args: Final = Arguments.model_validate(vars(parser.parse_args(argv)))
|
||||
hits: Final = spend_log_hits(added_diff(args.base, args.head))
|
||||
hits: Final = spend_log_hits(added_diff(args.base, args.head), schema_blocks(args.base, args.head))
|
||||
if not hits:
|
||||
sys.stdout.write("No added line in schema.prisma or a migration names LiteLLM_SpendLogs; nothing to review.\n")
|
||||
sys.stdout.write(
|
||||
"No change inside model LiteLLM_SpendLogs in schema.prisma and no added migration line names it; "
|
||||
"nothing to review.\n"
|
||||
)
|
||||
return 0
|
||||
if approved_by(_REVIEWS.validate_json(args.reviews.read_bytes()), args.approver):
|
||||
reviews: Final = _REVIEWS.validate_json(args.reviews.read_bytes())
|
||||
approval: Final = latest_approval(reviews, args.approver) if approved_by(reviews, args.approver) else None
|
||||
if approval is not None and approval_covers(
|
||||
approval, args.head, hits, lambda commit: reviewed_hits(args.base, commit)
|
||||
):
|
||||
sys.stdout.write(f"LiteLLM_SpendLogs change approved by {args.approver}: {', '.join(hits)}\n")
|
||||
return 0
|
||||
listed: Final = "".join(f" {hit}\n" for hit in hits)
|
||||
sys.stdout.write(
|
||||
reason: Final = (
|
||||
f"This pull request changes LiteLLM_SpendLogs and needs an approving review from @{args.approver}:\n"
|
||||
f"{listed}The check re-runs when a review is submitted.\n"
|
||||
if approval is None
|
||||
else f"LiteLLM_SpendLogs lines changed after @{args.approver} approved {approval.commit_id}, "
|
||||
f"a new approving review is needed:\n"
|
||||
)
|
||||
sys.stdout.write(f"{reason}{listed}After approving, re-run this job.\n")
|
||||
return 1
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from litellm.integrations.custom_guardrail import (
|
|||
CustomGuardrail,
|
||||
log_guardrail_information,
|
||||
)
|
||||
from litellm.llms.base_llm.guardrail_translation.utils import effective_skip_tool_message_for_guardrail
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails._content_utils import iter_tool_json_fields, map_tool_json_text, walk_user_text
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
|
|
@ -753,7 +754,12 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail):
|
|||
_masked_entity_count.set({})
|
||||
|
||||
# Covers multimodal list content + Responses-API input.
|
||||
inspected = walk_user_text(data, self.redact_text, self.redact_tool_payload) + self._redact_prompt(data)
|
||||
inspected = walk_user_text(
|
||||
data,
|
||||
self.redact_text,
|
||||
self.redact_tool_payload,
|
||||
skip_tool_message=effective_skip_tool_message_for_guardrail(self),
|
||||
) + self._redact_prompt(data)
|
||||
|
||||
if inspected == 0:
|
||||
# Image-only, empty-text, and unsupported payloads inspected
|
||||
|
|
|
|||
|
|
@ -343,11 +343,16 @@ def btree_max_entry_bytes(block_size: int) -> int:
|
|||
|
||||
|
||||
def _value_bytes(column: ColumnType, identifier: "sql.Identifier") -> "sql.Composed":
|
||||
"""The bytes a value takes in the index entry: its length, or its stored size when
|
||||
Postgres compressed it, since `index_form_tuple` keeps the compressed bytes."""
|
||||
from psycopg import sql
|
||||
|
||||
if column.type_name in _OCTET_LENGTH_TYPES:
|
||||
return sql.SQL("octet_length({})").format(identifier)
|
||||
return sql.SQL("octet_length({}::text)").format(identifier)
|
||||
length: Final = (
|
||||
sql.SQL("octet_length({})").format(identifier)
|
||||
if column.type_name in _OCTET_LENGTH_TYPES
|
||||
else sql.SQL("octet_length({}::text)").format(identifier)
|
||||
)
|
||||
return sql.SQL("least({}, pg_column_size({}))").format(length, identifier)
|
||||
|
||||
|
||||
def _aligned(offset: "sql.Composable", align: str) -> "sql.Composed":
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
SET LOCAL lock_timeout = '10s';
|
||||
|
||||
CREATE TYPE "AccountingEventKind" AS ENUM ('inbound_usage', 'outbound_usage', 'cost');
|
||||
|
||||
CREATE TYPE "AccountingDeliveryState" AS ENUM ('pending', 'applied', 'parked');
|
||||
|
||||
CREATE TYPE "AccountingDeliveryTarget" AS ENUM ('LiteLLM_VerificationToken', 'LiteLLM_UserTable', 'LiteLLM_TeamTable', 'LiteLLM_TeamMembership', 'LiteLLM_OrganizationTable', 'LiteLLM_OrganizationMembership', 'LiteLLM_EndUserTable', 'LiteLLM_AgentsTable', 'LiteLLM_TagTable', 'LiteLLM_ProjectTable', 'LiteLLM_ModelAccessGroupBudgetTable', 'LiteLLM_BudgetTable', 'LiteLLM_BudgetWindowSpend', 'LiteLLM_DailyUserSpend', 'LiteLLM_DailyTeamSpend', 'LiteLLM_DailyOrganizationSpend', 'LiteLLM_DailyEndUserSpend', 'LiteLLM_DailyAgentSpend', 'LiteLLM_DailyTagSpend', 'LiteLLM_DailyModelUsage', 'LiteLLM_DailyToolSpend', 'LiteLLM_AutoRouterDailySpend');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "LiteLLM_AccountingEvent" (
|
||||
"event_id" TEXT NOT NULL,
|
||||
"kind" "AccountingEventKind" NOT NULL,
|
||||
"source_event_id" TEXT,
|
||||
"schema_version" INTEGER NOT NULL,
|
||||
"payload_hash" TEXT NOT NULL,
|
||||
"occurred_at" TIMESTAMP(3) NOT NULL,
|
||||
"recorded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"payload" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "LiteLLM_AccountingEvent_schema_version_check" CHECK ("schema_version" > 0),
|
||||
CONSTRAINT "LiteLLM_AccountingEvent_pkey" PRIMARY KEY ("event_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "LiteLLM_AccountingDelivery" (
|
||||
"event_id" TEXT NOT NULL,
|
||||
"target" "AccountingDeliveryTarget" NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"state" "AccountingDeliveryState" NOT NULL DEFAULT 'pending',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"next_attempt_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"applied_at" TIMESTAMP(3),
|
||||
"last_error" TEXT,
|
||||
|
||||
CONSTRAINT "LiteLLM_AccountingDelivery_version_check" CHECK ("version" > 0),
|
||||
CONSTRAINT "LiteLLM_AccountingDelivery_attempts_check" CHECK ("attempts" >= 0),
|
||||
CONSTRAINT "LiteLLM_AccountingDelivery_pkey" PRIMARY KEY ("event_id","target","version")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "LiteLLM_AccountingEvent_recorded_at_idx" ON "LiteLLM_AccountingEvent"("recorded_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "LiteLLM_AccountingEvent_kind_source_event_id_idx" ON "LiteLLM_AccountingEvent"("kind", "source_event_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "LiteLLM_AccountingDelivery_target_state_next_attempt_at_idx" ON "LiteLLM_AccountingDelivery"("target", "state", "next_attempt_at");
|
||||
|
|
@ -2061,3 +2061,73 @@ model LiteLLM_TelemetryReport {
|
|||
@@index([window_end])
|
||||
@@index([created_at])
|
||||
}
|
||||
|
||||
enum AccountingEventKind {
|
||||
inbound_usage
|
||||
outbound_usage
|
||||
cost
|
||||
}
|
||||
|
||||
enum AccountingDeliveryState {
|
||||
pending
|
||||
applied
|
||||
parked
|
||||
}
|
||||
|
||||
enum AccountingDeliveryTarget {
|
||||
LiteLLM_VerificationToken
|
||||
LiteLLM_UserTable
|
||||
LiteLLM_TeamTable
|
||||
LiteLLM_TeamMembership
|
||||
LiteLLM_OrganizationTable
|
||||
LiteLLM_OrganizationMembership
|
||||
LiteLLM_EndUserTable
|
||||
LiteLLM_AgentsTable
|
||||
LiteLLM_TagTable
|
||||
LiteLLM_ProjectTable
|
||||
LiteLLM_ModelAccessGroupBudgetTable
|
||||
LiteLLM_BudgetTable
|
||||
LiteLLM_BudgetWindowSpend
|
||||
LiteLLM_DailyUserSpend
|
||||
LiteLLM_DailyTeamSpend
|
||||
LiteLLM_DailyOrganizationSpend
|
||||
LiteLLM_DailyEndUserSpend
|
||||
LiteLLM_DailyAgentSpend
|
||||
LiteLLM_DailyTagSpend
|
||||
LiteLLM_DailyModelUsage
|
||||
LiteLLM_DailyToolSpend
|
||||
LiteLLM_AutoRouterDailySpend
|
||||
}
|
||||
|
||||
// Durable accounting ledger. One row per usage or cost event; payload holds the
|
||||
// private event envelope versioned by schema_version. Usage rows retain request-scoped
|
||||
// pricing context; cost rows include resolved rates and the finalized pricing snapshot.
|
||||
model LiteLLM_AccountingEvent {
|
||||
event_id String @id
|
||||
kind AccountingEventKind
|
||||
source_event_id String?
|
||||
schema_version Int
|
||||
payload_hash String
|
||||
occurred_at DateTime
|
||||
recorded_at DateTime @default(now())
|
||||
payload Json
|
||||
|
||||
@@index([recorded_at])
|
||||
@@index([kind, source_event_id])
|
||||
}
|
||||
|
||||
// One row per (event, target table, version): pending work for the projector and,
|
||||
// once applied in the same transaction as the target write, the receipt that stops a retry.
|
||||
model LiteLLM_AccountingDelivery {
|
||||
event_id String
|
||||
target AccountingDeliveryTarget
|
||||
version Int
|
||||
state AccountingDeliveryState @default(pending)
|
||||
attempts Int @default(0)
|
||||
next_attempt_at DateTime @default(now())
|
||||
applied_at DateTime?
|
||||
last_error String?
|
||||
|
||||
@@id([event_id, target, version])
|
||||
@@index([target, state, next_attempt_at])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
---
|
||||
name: rust-error
|
||||
description: Define, wrap, convert, classify, report or test Rust error types in litellm-rust with thiserror. Use when adding an error type or variant, mapping one crate's error into another, turning an error into a gateway response or Python exception, deciding retry or fault from an error, or replacing call sites that build errors from strings
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Rust errors
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
---
|
||||
name: rust-gateway
|
||||
description: Add or change an HTTP route, extractor, response envelope, stream or router mount in the litellm-rust gateway crates (crates/gateway and crates/gateway-*). Use when adding an endpoint, parsing a request body, choosing an error envelope for an API surface, relaying SSE, or writing a router-level test
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Rust gateway
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
---
|
||||
name: rust-string-enums
|
||||
description: Define or refactor Rust string-valued enums and their Serde adapters in litellm-rust, using Strum and serde_with while preserving parsing, wire values, and schemas
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Rust string enums
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
---
|
||||
name: rust-tracing
|
||||
description: Add or change Rust diagnostic tracing in litellm-rust, including route spans, subscriber layers, and Python logger delivery
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Rust tracing
|
||||
|
|
|
|||
4
litellm-rust/Cargo.lock
generated
4
litellm-rust/Cargo.lock
generated
|
|
@ -4393,6 +4393,10 @@ dependencies = [
|
|||
"rstest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-router-core"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "litellm-router-types"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const FINISH_REASONS: &[(&str, &str)] = &[
|
|||
("max_tokens", "length"),
|
||||
("refusal", "content_filter"),
|
||||
("compaction", "length"),
|
||||
("model_context_window_exceeded", "length"),
|
||||
("guardrail_intervened", "content_filter"),
|
||||
("content_filtered", "content_filter"),
|
||||
("content_filter", "content_filter"),
|
||||
|
|
@ -74,6 +75,7 @@ mod tests {
|
|||
#[case::end_turn("end_turn", "stop")]
|
||||
#[case::stop_sequence("stop_sequence", "stop")]
|
||||
#[case::max_tokens("max_tokens", "length")]
|
||||
#[case::model_context_window_exceeded("model_context_window_exceeded", "length")]
|
||||
#[case::refusal("refusal", "content_filter")]
|
||||
#[case::guardrail_intervened("guardrail_intervened", "content_filter")]
|
||||
#[case::content_filtered("content_filtered", "content_filter")]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,36 @@
|
|||
//! The model catalog: per-model pricing, context limits and capabilities.
|
||||
//!
|
||||
//! This is the Rust form of `model_prices_and_context_window.json`, the map Python reads as
|
||||
//! `litellm.model_cost` and through `litellm.get_model_info`. It answers "what does this model
|
||||
//! cost and what can it do"; it knows nothing about configured deployments. The router's view of
|
||||
//! deployments (groups, aliases, wildcard patterns, team mappings) is the router registry, defined
|
||||
//! in `litellm-router-core`, which reads this catalog through its dynamic config.
|
||||
//!
|
||||
//! # Structure
|
||||
//! - `Catalog`: the parsed map. `Catalog::parse` takes the JSON body and a `Provenance` (where
|
||||
//! the body came from: the bundled copy in the repo or the remote copy, with revision and etag),
|
||||
//! and keeps every entry in file order with its raw fields.
|
||||
//! - `ModelEntry`: one model. `ModelEntry::fields` is the raw JSON object, so unknown fields are
|
||||
//! never lost; `ModelEntry::info` is the typed mirror `ModelInfo` (pricing in `pricing`,
|
||||
//! modalities and modes in `capabilities`).
|
||||
//! - Aliases: an entry may list `aliases`; each alias resolves to its canonical key. Problems
|
||||
//! found while indexing them (bad lists, collisions) are kept as `AliasIssue`s instead of
|
||||
//! failing the parse.
|
||||
//! - `FallbackGeneralizations` / `FallbackRule`: regex rules that map unknown model ids onto known
|
||||
//! families. They are data in the file, not entries.
|
||||
//! - Validation: `Catalog::validate` checks a catalog against `IntegrityLimits` (a remote copy that
|
||||
//! lost most of its models is refused), and `validate_registry` checks every entry against the
|
||||
//! checked-in contract.
|
||||
//!
|
||||
//! # Lookups
|
||||
//! `Catalog::lookup` resolves a key exactly first (entry or alias), then case-insensitively, and
|
||||
//! returns a `ModelMatch` with both the key that matched and the canonical key of the entry. Provider
|
||||
//! prefixes are not stripped here; callers decide which keys to try.
|
||||
//!
|
||||
//! # Readers
|
||||
//! Cost tracking, the router (through `RouterDynamicConfig`) and the gateway. Nothing in this crate
|
||||
//! performs I/O; loading and refreshing the file is the caller's job.
|
||||
|
||||
mod capabilities;
|
||||
mod catalog;
|
||||
mod error;
|
||||
|
|
|
|||
9
litellm-rust/crates/router-core/Cargo.toml
Normal file
9
litellm-rust/crates/router-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "litellm-router-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "The router core behind litellm.Router: registry, settings, health, selection and the call loop"
|
||||
|
||||
[dependencies]
|
||||
63
litellm-rust/crates/router-core/src/lib.rs
Normal file
63
litellm-rust/crates/router-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
//! Routing for LiteLLM: decide which deployment serves a request, and drive retries, fallbacks
|
||||
//! and cooldowns around the attempts.
|
||||
//!
|
||||
//! This crate defines the boundary `litellm.Router` is moving behind. Today the Python router in
|
||||
//! `litellm/router.py` implements all of it; this crate is where the Rust core will implement the
|
||||
//! same interface. Until then it holds this overview and nothing else, so the interface is written
|
||||
//! down in code before either side changes.
|
||||
//!
|
||||
//! # The public surface
|
||||
//! Code outside the router uses `litellm.Router` through two kinds of members and nothing else:
|
||||
//! - The endpoint methods (`acompletion`, `aembedding`, `aresponses`, `aanthropic_messages`, the
|
||||
//! files, batches, assistants and vector store wrappers, 37 in all). Each is a thin wrapper over
|
||||
//! `call(op, request)`.
|
||||
//! - Eight entry points: `registry()`, `settings()`, `health()`, `select()`, `apply()`,
|
||||
//! `observe()`, `register_plugin()` and `retire()`.
|
||||
//!
|
||||
//! Every other member that outside code uses today has a named replacement among those. The old
|
||||
//! names stay available for about a month through a `DeprecatedRouterMembers` mixin, which
|
||||
//! implements them over the entry points and warns on use. A CI check
|
||||
//! (`tests/code_coverage_tests/router_surface_check.py`) keeps an allowlist of the members outside
|
||||
//! code touches; the list may only shrink.
|
||||
//!
|
||||
//! # Structure
|
||||
//! - `Registry`: the configured deployments, indexed by id, model group, alias, wildcard pattern
|
||||
//! and team. Immutable and versioned. Seven queries: `deployment(id)`, `deployments(filter)`,
|
||||
//! `names(filter)`, `resolve(name, scope)`, `group(name)`, `access_groups()` and
|
||||
//! `credentials(id)`. "Registry" is the router's view of what is configured; per-model pricing
|
||||
//! and capabilities are the model catalog (`litellm-model-catalog`), which the router reads
|
||||
//! through its dynamic config.
|
||||
//! - `RouterConfig` / `RouterDynamicConfig`: everything the router needs to know, passed in. The
|
||||
//! router never reads process globals. Static config (model list, strategy, retry, timeout and
|
||||
//! cooldown policy, Redis settings, plugins) is fixed for the router's life. Dynamic config
|
||||
//! (pricing, model aliases, fallbacks, retry counts, callbacks) is a versioned snapshot read
|
||||
//! once per call.
|
||||
//! - `Settings`: one typed, frozen view of the configuration a caller may read.
|
||||
//! - `Health`: cooldowns, blocked deployments and usage, kept in shared cache keys so Python and
|
||||
//! Rust workers see the same state. `report(id, outcome)` records outcomes the router did not
|
||||
//! see itself (a health check, an interceptor that marks a deployment failed).
|
||||
//! - `select(model, request)`: resolve a name, filter eligible deployments, pick one with the
|
||||
//! configured strategy, without calling it.
|
||||
//! - `apply(changes)`: one `ChangeSet` (upserts, deletes, settings), applied atomically, producing
|
||||
//! a new registry version.
|
||||
//! - `call(op, request)`: select, run the attempt, and retry or fall back until it succeeds or the
|
||||
//! policy is exhausted.
|
||||
//!
|
||||
//! # Execution
|
||||
//! Each attempt resolves an `Executor` after a deployment is picked. A Rust-native operation runs
|
||||
//! its `inference-*` route machine in-process; anything else goes through `Host::invoke`. A
|
||||
//! started attempt is never replayed on a different executor.
|
||||
//!
|
||||
//! # Hosts
|
||||
//! `Host` is what the embedding process provides: `invoke` for non-native operations, `select`
|
||||
//! for strategies and sub-routers not yet ported, `allow_fallback` for access and budget checks,
|
||||
//! `sleep`, `observe` for routing events, and `cache`, a DualCache-style store that holds all
|
||||
//! routing state under the same keys, encodings and TTLs the Python router uses. `PythonHost`
|
||||
//! serves them from the existing Python code when the router runs inside Python; `NativeHost`
|
||||
//! serves them in the Rust server, where an operation without a Rust implementation makes the
|
||||
//! config fail at build time, never partway through a request.
|
||||
//!
|
||||
//! # Plugins
|
||||
//! Routing strategies, pre-call checks and sub-routers (auto, complexity, quality, adaptive)
|
||||
//! register through `register_plugin` and see a `RoutingContext` (registry, settings, health and
|
||||
//! the current request), never the router itself.
|
||||
|
|
@ -19,6 +19,7 @@ from litellm.a2a_protocol.utils import (
|
|||
)
|
||||
from litellm.exceptions import BadRequestError
|
||||
from litellm.llms.bedrock.chat.agentcore.transformation import AmazonAgentCoreConfig
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines
|
||||
|
||||
RUNTIME_SESSION_ID_MIN_LENGTH: Final = 33
|
||||
RUNTIME_SESSION_ID_MAX_LENGTH: Final = 256
|
||||
|
|
@ -50,7 +51,7 @@ _RESERVED_PREFIX_HEADERS: Final[tuple[str, ...]] = (
|
|||
class _SSELineSource(Protocol):
|
||||
"""Minimal streaming-response surface used to read SSE lines."""
|
||||
|
||||
def aiter_lines(self) -> AsyncIterator[str]: ...
|
||||
def aiter_text(self) -> AsyncIterator[str]: ...
|
||||
|
||||
|
||||
def _filter_reserved_headers(
|
||||
|
|
@ -244,7 +245,7 @@ class BedrockAgentCoreA2ATransformation:
|
|||
Yields:
|
||||
Parsed JSON dicts from SSE data lines
|
||||
"""
|
||||
async for line in response.aiter_lines():
|
||||
async for line in aiter_sse_lines(response.aiter_text()):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
AsyncHTTPHandler,
|
||||
get_async_httpx_client,
|
||||
)
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
||||
_IBM_CLOUD_IAM_URL: Final = "https://iam.cloud.ibm.com/identity/token"
|
||||
|
|
@ -74,7 +75,7 @@ class _WXORun(TypedDict, total=False):
|
|||
|
||||
|
||||
class _SSELineSource(Protocol):
|
||||
def aiter_lines(self) -> AsyncIterator[str]: ...
|
||||
def aiter_text(self) -> AsyncIterator[str]: ...
|
||||
|
||||
|
||||
class _WXOView(TypedDict, total=False):
|
||||
|
|
@ -231,7 +232,7 @@ class WatsonxOrchestrateHandler:
|
|||
async def _accumulate_wxo_sse_text(response: _SSELineSource) -> str:
|
||||
source: Final[_WXOView] = {"sse_source": response}
|
||||
accumulated_text = ""
|
||||
async for line in source["sse_source"].aiter_lines():
|
||||
async for line in aiter_sse_lines(source["sse_source"].aiter_text()):
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data_str = line[5:].strip()
|
||||
|
|
|
|||
62
litellm/accounting/__init__.py
Normal file
62
litellm/accounting/__init__.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Public accounting contracts. Importing these values does not enable accounting."""
|
||||
|
||||
from litellm.accounting.measurements import measurements_for
|
||||
from litellm.accounting.protocols import BudgetEnforcer, CostAggregator, CostCompiler, UsageMeter
|
||||
from litellm.accounting.types import (
|
||||
AdmissionDecision,
|
||||
AggregateQuery,
|
||||
AggregateRow,
|
||||
CostEvent,
|
||||
DeliveryState,
|
||||
DeliveryTarget,
|
||||
Dimension,
|
||||
DimensionFilter,
|
||||
DimensionName,
|
||||
EventKind,
|
||||
Granularity,
|
||||
InboundUsageEvent,
|
||||
Measurement,
|
||||
Metric,
|
||||
MetricName,
|
||||
Multiplier,
|
||||
NamedCost,
|
||||
OutboundUsageEvent,
|
||||
Outcome,
|
||||
PricedQuantity,
|
||||
PricingSnapshot,
|
||||
Rate,
|
||||
Unpriced,
|
||||
validate_aggregate_rows,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AdmissionDecision",
|
||||
"AggregateQuery",
|
||||
"AggregateRow",
|
||||
"BudgetEnforcer",
|
||||
"CostAggregator",
|
||||
"CostCompiler",
|
||||
"CostEvent",
|
||||
"DeliveryState",
|
||||
"DeliveryTarget",
|
||||
"Dimension",
|
||||
"DimensionFilter",
|
||||
"DimensionName",
|
||||
"EventKind",
|
||||
"Granularity",
|
||||
"InboundUsageEvent",
|
||||
"Measurement",
|
||||
"Metric",
|
||||
"MetricName",
|
||||
"Multiplier",
|
||||
"NamedCost",
|
||||
"OutboundUsageEvent",
|
||||
"Outcome",
|
||||
"PricedQuantity",
|
||||
"PricingSnapshot",
|
||||
"Rate",
|
||||
"Unpriced",
|
||||
"UsageMeter",
|
||||
"measurements_for",
|
||||
"validate_aggregate_rows",
|
||||
]
|
||||
13
litellm/accounting/measurements.py
Normal file
13
litellm/accounting/measurements.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from typing import Final
|
||||
|
||||
from litellm.accounting.types import CostEvent, InboundUsageEvent, Measurement, OutboundUsageEvent
|
||||
|
||||
|
||||
def measurements_for(event: InboundUsageEvent | OutboundUsageEvent | CostEvent) -> tuple[Measurement, ...]:
|
||||
"""Return the complete contribution set for one event, ready for one apply call."""
|
||||
metrics: Final = event.charges if isinstance(event, CostEvent) else event.metrics
|
||||
occurred_at: Final = event.occurred_at if isinstance(event, CostEvent) else event.started_at
|
||||
return tuple(
|
||||
Measurement(source_event_id=event.event_id, occurred_at=occurred_at, dimensions=event.dimensions, metric=metric)
|
||||
for metric in metrics or ()
|
||||
)
|
||||
85
litellm/accounting/protocols.py
Normal file
85
litellm/accounting/protocols.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Contracts only. Implementations own persistence, pricing, projections and live budgets.
|
||||
|
||||
The event row's ``payload`` is a private envelope versioned by the row's ``schema_version``.
|
||||
Usage envelopes retain the public event and request-scoped pricing context: quantities with units,
|
||||
model/provider, requested and billed tier/region when known, and per-request/deployment overrides.
|
||||
These inputs must survive request teardown. Cost-map rates are resolved at compilation, not capture.
|
||||
Cost envelopes retain the public CostEvent, including its required finalized pricing snapshot.
|
||||
T5/T6 own envelope serialization; this public API does not introduce a second persisted schema.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from litellm.accounting.types import (
|
||||
AdmissionDecision,
|
||||
AggregateQuery,
|
||||
AggregateRow,
|
||||
CostEvent,
|
||||
Dimension,
|
||||
InboundUsageEvent,
|
||||
Measurement,
|
||||
Metric,
|
||||
OutboundUsageEvent,
|
||||
Unpriced,
|
||||
)
|
||||
|
||||
|
||||
class UsageMeter(Protocol):
|
||||
async def record(self, event: InboundUsageEvent | OutboundUsageEvent) -> None:
|
||||
"""Acknowledge commit, no-op identical IDs and reject conflicting content.
|
||||
|
||||
Persist the event and its request-scoped pricing context before acknowledging.
|
||||
HTTP acknowledgement is a separate policy. Unknown usage remains None.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class CostCompiler(Protocol):
|
||||
async def compile(self, event: OutboundUsageEvent) -> CostEvent | Unpriced:
|
||||
"""Reuse the committed charge or finalize one charge and its delivery intent.
|
||||
|
||||
Resolve captured request-scoped context by event identity and cost-map rates
|
||||
at compilation. Persist the resolved inputs in the required snapshot. Replay
|
||||
reuses the committed result and never consumes free allowance again.
|
||||
Missing pricing is Unpriced, never an inferred zero.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class CostAggregator(Protocol):
|
||||
async def apply(self, measurements: tuple[Measurement, ...]) -> None:
|
||||
"""Apply one source event's complete contribution set per target/version.
|
||||
|
||||
Each target commits all contributions and its receipt atomically. A receipt
|
||||
for the first metric must not suppress the rest. Usage need not wait for cost.
|
||||
"""
|
||||
...
|
||||
|
||||
async def query(self, query: AggregateQuery) -> tuple[AggregateRow, ...]:
|
||||
"""Return disjoint [start, end) rows per group using existing daily projections.
|
||||
|
||||
Filters combine with AND, values within a filter with IN. DAY emits daily
|
||||
buckets; None emits whole-interval totals. Reject unsupported metric/scope
|
||||
combinations. Different groups may overlap, including non-additive tags.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class BudgetEnforcer(Protocol):
|
||||
async def reserve(
|
||||
self, reservation_id: str, dimensions: tuple[Dimension, ...], estimated: tuple[Metric, ...]
|
||||
) -> AdmissionDecision:
|
||||
"""Apply existing admission policies and estimates; duplicate IDs add no hold."""
|
||||
...
|
||||
|
||||
async def settle(self, reservation_id: str | None, cost_event: CostEvent) -> None:
|
||||
"""Settle the committed charge with atomic deduplication and hold mutation.
|
||||
|
||||
Preserve scope/window attribution and retry unfinished settlement after
|
||||
restart. SQL projection receipts do not provide live-counter atomicity.
|
||||
"""
|
||||
...
|
||||
|
||||
async def release(self, reservation_id: str) -> None:
|
||||
"""Idempotently release unused exposure at the request boundary, preserving charges."""
|
||||
...
|
||||
428
litellm/accounting/types.py
Normal file
428
litellm/accounting/types.py
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
"""Accounting values use UTC instants, decimal strings on the wire and USD charges."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Annotated, Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import AfterValidator, AwareDatetime, BaseModel, ConfigDict, Field, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from litellm.types.utils import CallTypes
|
||||
|
||||
_CONFIG: Final = ConfigDict(frozen=True, extra="forbid", validate_default=True)
|
||||
Identifier: TypeAlias = Annotated[str, Field(strict=True, min_length=1, pattern=r"\S")]
|
||||
Quantity: TypeAlias = Annotated[Decimal, Field(strict=True, ge=0, allow_inf_nan=False)]
|
||||
|
||||
|
||||
def _utc(value: datetime) -> datetime:
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
Instant: TypeAlias = Annotated[AwareDatetime, AfterValidator(_utc)]
|
||||
|
||||
|
||||
class MetricName(str, Enum):
|
||||
"""Reasoning tokens are included in completion tokens; above-1hr writes are included in cache creation tokens."""
|
||||
|
||||
REQUESTS = "requests"
|
||||
SUCCESSFUL_REQUESTS = "successful_requests"
|
||||
FAILED_REQUESTS = "failed_requests"
|
||||
PROMPT_TOKENS = "prompt_tokens"
|
||||
COMPLETION_TOKENS = "completion_tokens"
|
||||
REASONING_TOKENS = "reasoning_tokens"
|
||||
TOTAL_TOKENS = "total_tokens"
|
||||
CACHE_READ_INPUT_TOKENS = "cache_read_input_tokens"
|
||||
CACHE_CREATION_INPUT_TOKENS = "cache_creation_input_tokens"
|
||||
CACHE_CREATION_INPUT_TOKENS_ABOVE_1HR = "cache_creation_input_tokens_above_1hr"
|
||||
SPEND = "spend"
|
||||
|
||||
|
||||
class DimensionName(str, Enum):
|
||||
KEY = "key"
|
||||
USER = "user"
|
||||
TEAM = "team"
|
||||
ORGANIZATION = "organization"
|
||||
END_USER = "end_user"
|
||||
MODEL = "model"
|
||||
MODEL_GROUP = "model_group"
|
||||
DEPLOYMENT = "deployment"
|
||||
PROVIDER = "provider"
|
||||
TOOL = "tool"
|
||||
AGENT = "agent"
|
||||
PROJECT = "project"
|
||||
MODEL_ACCESS_GROUP = "model_access_group"
|
||||
API_BASE = "api_base"
|
||||
SERVICE_TIER = "service_tier"
|
||||
TAG = "tag"
|
||||
|
||||
|
||||
class Outcome(str, Enum):
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class Granularity(str, Enum):
|
||||
DAY = "day"
|
||||
|
||||
|
||||
class EventKind(str, Enum):
|
||||
INBOUND_USAGE = "inbound_usage"
|
||||
OUTBOUND_USAGE = "outbound_usage"
|
||||
COST = "cost"
|
||||
|
||||
|
||||
class DeliveryState(str, Enum):
|
||||
PENDING = "pending"
|
||||
APPLIED = "applied"
|
||||
PARKED = "parked"
|
||||
|
||||
|
||||
class DeliveryTarget(str, Enum):
|
||||
"""One member per existing table the projector writes; each is applied and receipted independently."""
|
||||
|
||||
KEY_SPEND = "LiteLLM_VerificationToken"
|
||||
USER_SPEND = "LiteLLM_UserTable"
|
||||
TEAM_SPEND = "LiteLLM_TeamTable"
|
||||
TEAM_MEMBER_SPEND = "LiteLLM_TeamMembership"
|
||||
ORGANIZATION_SPEND = "LiteLLM_OrganizationTable"
|
||||
ORGANIZATION_MEMBER_SPEND = "LiteLLM_OrganizationMembership"
|
||||
END_USER_SPEND = "LiteLLM_EndUserTable"
|
||||
AGENT_SPEND = "LiteLLM_AgentsTable"
|
||||
TAG_SPEND = "LiteLLM_TagTable"
|
||||
PROJECT_SPEND = "LiteLLM_ProjectTable"
|
||||
MODEL_ACCESS_GROUP_SPEND = "LiteLLM_ModelAccessGroupBudgetTable"
|
||||
BUDGET_SPEND = "LiteLLM_BudgetTable"
|
||||
BUDGET_WINDOW_SPEND = "LiteLLM_BudgetWindowSpend"
|
||||
DAILY_USER_SPEND = "LiteLLM_DailyUserSpend"
|
||||
DAILY_TEAM_SPEND = "LiteLLM_DailyTeamSpend"
|
||||
DAILY_ORGANIZATION_SPEND = "LiteLLM_DailyOrganizationSpend"
|
||||
DAILY_END_USER_SPEND = "LiteLLM_DailyEndUserSpend"
|
||||
DAILY_AGENT_SPEND = "LiteLLM_DailyAgentSpend"
|
||||
DAILY_TAG_SPEND = "LiteLLM_DailyTagSpend"
|
||||
DAILY_MODEL_USAGE = "LiteLLM_DailyModelUsage"
|
||||
DAILY_TOOL_SPEND = "LiteLLM_DailyToolSpend"
|
||||
AUTOROUTER_DAILY_SPEND = "LiteLLM_AutoRouterDailySpend"
|
||||
|
||||
|
||||
_SUBSET_PAIRS: Final = (
|
||||
(MetricName.REASONING_TOKENS, MetricName.COMPLETION_TOKENS),
|
||||
(MetricName.CACHE_CREATION_INPUT_TOKENS_ABOVE_1HR, MetricName.CACHE_CREATION_INPUT_TOKENS),
|
||||
)
|
||||
|
||||
|
||||
def _check_subsets(values: Mapping[str, Decimal]) -> None:
|
||||
for part, whole in _SUBSET_PAIRS:
|
||||
if part.value in values and whole.value in values and values[part.value] > values[whole.value]:
|
||||
raise ValueError(f"{part.value} cannot exceed {whole.value}")
|
||||
|
||||
|
||||
class Metric(BaseModel):
|
||||
model_config = _CONFIG
|
||||
|
||||
name: MetricName
|
||||
value: Quantity
|
||||
unit: Literal["count", "USD"] = "count"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unit(self) -> Self:
|
||||
if (self.name is MetricName.SPEND) != (self.unit == "USD"):
|
||||
raise ValueError("SPEND requires USD; usage metrics require count")
|
||||
if self.unit == "count" and self.value != self.value.to_integral_value():
|
||||
raise ValueError("Count metrics require integral values")
|
||||
return self
|
||||
|
||||
|
||||
class Dimension(BaseModel):
|
||||
model_config = _CONFIG
|
||||
|
||||
name: DimensionName
|
||||
value: Identifier
|
||||
|
||||
|
||||
_REPEATABLE_DIMENSIONS: Final = frozenset({DimensionName.TAG, DimensionName.MODEL_ACCESS_GROUP})
|
||||
|
||||
|
||||
def _dimensions(values: tuple[Dimension, ...]) -> tuple[Dimension, ...]:
|
||||
if len(frozenset(values)) != len(values):
|
||||
raise ValueError("Duplicate dimension values")
|
||||
single_names: Final = tuple(value.name for value in values if value.name not in _REPEATABLE_DIMENSIONS)
|
||||
if len(frozenset(single_names)) != len(single_names):
|
||||
raise ValueError("Only tag and model access group dimensions may have multiple values")
|
||||
return values
|
||||
|
||||
|
||||
Dimensions: TypeAlias = Annotated[tuple[Dimension, ...], AfterValidator(_dimensions)]
|
||||
_CALLER_METRICS: Final = frozenset((MetricName.REQUESTS, MetricName.SUCCESSFUL_REQUESTS, MetricName.FAILED_REQUESTS))
|
||||
|
||||
|
||||
def _metrics(values: tuple[Metric, ...]) -> tuple[Metric, ...]:
|
||||
if len(frozenset(value.name for value in values)) != len(values):
|
||||
raise ValueError("Duplicate metric names")
|
||||
return values
|
||||
|
||||
|
||||
Metrics: TypeAlias = Annotated[tuple[Metric, ...], AfterValidator(_metrics)]
|
||||
|
||||
|
||||
def _interval(start: datetime, end: datetime, *, allow_empty: bool = False) -> None:
|
||||
if end < start or (end == start and not allow_empty):
|
||||
raise ValueError("End must follow start")
|
||||
|
||||
|
||||
class InboundUsageEvent(BaseModel):
|
||||
model_config = _CONFIG
|
||||
|
||||
event_id: Identifier
|
||||
request_id: Identifier
|
||||
call_type: CallTypes
|
||||
started_at: Instant
|
||||
ended_at: Instant
|
||||
outcome: Outcome
|
||||
dimensions: Dimensions
|
||||
metrics: Metrics
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_caller_activity(self) -> Self:
|
||||
_interval(self.started_at, self.ended_at, allow_empty=True)
|
||||
if any(metric.name not in _CALLER_METRICS for metric in self.metrics):
|
||||
raise ValueError("Inbound events own only caller request metrics")
|
||||
if not any(metric.name is MetricName.REQUESTS and metric.value == 1 for metric in self.metrics):
|
||||
raise ValueError("Inbound events count exactly one caller request")
|
||||
for metric in self.metrics:
|
||||
if metric.name is MetricName.SUCCESSFUL_REQUESTS and metric.value != int(self.outcome is Outcome.SUCCEEDED):
|
||||
raise ValueError("Successful request count must match outcome")
|
||||
if metric.name is MetricName.FAILED_REQUESTS and metric.value != int(self.outcome is not Outcome.SUCCEEDED):
|
||||
raise ValueError("Failed request count must match outcome")
|
||||
return self
|
||||
|
||||
|
||||
class OutboundUsageEvent(BaseModel):
|
||||
model_config = _CONFIG
|
||||
|
||||
event_id: Identifier
|
||||
attempt_id: Identifier
|
||||
request_id: Identifier | None
|
||||
retry_of_attempt_id: Identifier | None
|
||||
call_type: CallTypes
|
||||
started_at: Instant
|
||||
ended_at: Instant
|
||||
outcome: Outcome
|
||||
dimensions: Dimensions
|
||||
metrics: Metrics | None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_attempt_usage(self) -> Self:
|
||||
_interval(self.started_at, self.ended_at, allow_empty=True)
|
||||
if self.retry_of_attempt_id == self.attempt_id:
|
||||
raise ValueError("An attempt cannot retry itself")
|
||||
if self.metrics is not None and any(
|
||||
metric.name in _CALLER_METRICS or metric.name is MetricName.SPEND for metric in self.metrics
|
||||
):
|
||||
raise ValueError("Outbound events own only attempt usage metrics")
|
||||
if self.metrics is not None:
|
||||
_check_subsets({metric.name.value: metric.value for metric in self.metrics})
|
||||
return self
|
||||
|
||||
|
||||
PricingUnit: TypeAlias = Literal["token", "second", "character", "image", "request"]
|
||||
|
||||
|
||||
class Rate(BaseModel):
|
||||
model_config = _CONFIG
|
||||
|
||||
measure: Identifier
|
||||
unit: PricingUnit
|
||||
usd_per_unit: Quantity
|
||||
|
||||
|
||||
class PricedQuantity(BaseModel):
|
||||
"""A pricing input in its billing unit. Only seconds may be fractional."""
|
||||
|
||||
model_config = _CONFIG
|
||||
|
||||
measure: Identifier
|
||||
unit: PricingUnit
|
||||
value: Quantity
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_discrete_quantity(self) -> Self:
|
||||
if self.unit != "second" and self.value != self.value.to_integral_value():
|
||||
raise ValueError("Discrete priced quantities require integral values")
|
||||
return self
|
||||
|
||||
|
||||
class Multiplier(BaseModel):
|
||||
"""A regional or tier factor applied on top of the priced key, e.g. data_residency=eu or vertex_location=us-east5."""
|
||||
|
||||
model_config = _CONFIG
|
||||
|
||||
name: Identifier
|
||||
value: Identifier
|
||||
factor: Annotated[Decimal, Field(strict=True, gt=0, allow_inf_nan=False)]
|
||||
|
||||
|
||||
class NamedCost(BaseModel):
|
||||
"""A free-form extra charge by name, e.g. azure_model_router_flat_cost."""
|
||||
|
||||
model_config = _CONFIG
|
||||
|
||||
name: Identifier
|
||||
amount: Quantity
|
||||
|
||||
|
||||
class PricingSnapshot(BaseModel):
|
||||
"""Retained inputs and result of authoritative compilation, reproducible after cost-map rates change.
|
||||
|
||||
Request-scoped inputs and overrides come from the usage row's versioned payload. Cost-map rates are
|
||||
resolved at compilation, not frozen at capture. Retries reuse the committed charge and snapshot.
|
||||
Component names match ``CostBreakdown``: reasoning_cost is part of output_cost, not an additional charge.
|
||||
Component sums and float tolerances belong to reconciliation, not this structural contract.
|
||||
"""
|
||||
|
||||
model_config = _CONFIG
|
||||
|
||||
schema_version: Literal[1] = 1
|
||||
calculator_version: Identifier
|
||||
model: Identifier
|
||||
provider: Identifier | None
|
||||
pricing_source: Literal["cost_map", "deployment", "per_request", "upstream_header"]
|
||||
priced_model_key: Identifier
|
||||
multipliers: tuple[Multiplier, ...] = ()
|
||||
rates: tuple[Rate, ...] = ()
|
||||
usage: tuple[PricedQuantity, ...] = ()
|
||||
input_cost: Quantity | None = None
|
||||
output_cost: Quantity | None = None
|
||||
cache_read_cost: Quantity | None = None
|
||||
cache_creation_cost: Quantity | None = None
|
||||
reasoning_cost: Quantity | None = None
|
||||
tool_usage_cost: Quantity | None = None
|
||||
guardrail_cost: Quantity | None = None
|
||||
additional_costs: tuple[NamedCost, ...] = ()
|
||||
original_cost: Quantity | None = None
|
||||
discount_percent: Quantity | None = None
|
||||
discount_amount: Quantity | None = None
|
||||
margin_percent: Quantity | None = None
|
||||
margin_fixed_amount: Quantity | None = None
|
||||
margin_total_amount: Quantity | None = None
|
||||
upstream_reported_cost: Quantity | None = None
|
||||
total_cost: Quantity
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_snapshot(self) -> Self:
|
||||
rate_keys: Final = frozenset((rate.measure, rate.unit) for rate in self.rates)
|
||||
quantity_keys: Final = frozenset((quantity.measure, quantity.unit) for quantity in self.usage)
|
||||
if len(rate_keys) != len(self.rates):
|
||||
raise ValueError("Duplicate rate measures and units")
|
||||
if len(quantity_keys) != len(self.usage):
|
||||
raise ValueError("Duplicate priced quantity measures and units")
|
||||
if not rate_keys.issubset(quantity_keys):
|
||||
raise ValueError("Every rate requires one priced quantity with the same measure and unit")
|
||||
if len(frozenset(cost.name for cost in self.additional_costs)) != len(self.additional_costs):
|
||||
raise ValueError("Duplicate additional cost names")
|
||||
if len(frozenset(multiplier.name for multiplier in self.multipliers)) != len(self.multipliers):
|
||||
raise ValueError("Duplicate multiplier names")
|
||||
_check_subsets({quantity.measure: quantity.value for quantity in self.usage if quantity.unit == "token"})
|
||||
return self
|
||||
|
||||
|
||||
class CostEvent(BaseModel):
|
||||
model_config = _CONFIG
|
||||
|
||||
event_id: Identifier
|
||||
usage_event_id: Identifier
|
||||
occurred_at: Instant
|
||||
dimensions: Dimensions
|
||||
charges: Metrics
|
||||
snapshot: PricingSnapshot
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_charge(self) -> Self:
|
||||
if len(self.charges) != 1 or self.charges[0].name is not MetricName.SPEND:
|
||||
raise ValueError("A cost event requires one authoritative USD spend metric")
|
||||
if self.event_id == self.usage_event_id:
|
||||
raise ValueError("Cost and usage events require distinct identities")
|
||||
if self.snapshot.total_cost != self.charges[0].value:
|
||||
raise ValueError("Snapshot total must equal the authoritative spend")
|
||||
return self
|
||||
|
||||
|
||||
class Unpriced(BaseModel):
|
||||
model_config = _CONFIG
|
||||
|
||||
usage_event_id: Identifier
|
||||
reason: Identifier
|
||||
|
||||
|
||||
class Measurement(BaseModel):
|
||||
model_config = _CONFIG
|
||||
|
||||
source_event_id: Identifier
|
||||
occurred_at: Instant
|
||||
dimensions: Dimensions
|
||||
metric: Metric
|
||||
|
||||
|
||||
class DimensionFilter(BaseModel):
|
||||
model_config = _CONFIG
|
||||
|
||||
name: DimensionName
|
||||
values: Annotated[tuple[Identifier, ...], Field(min_length=1)]
|
||||
|
||||
|
||||
class AggregateQuery(BaseModel):
|
||||
model_config = _CONFIG
|
||||
|
||||
metric: MetricName
|
||||
filters: tuple[DimensionFilter, ...]
|
||||
group_by: tuple[DimensionName, ...]
|
||||
start: Instant
|
||||
end: Instant
|
||||
granularity: Granularity | None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query(self) -> Self:
|
||||
_interval(self.start, self.end)
|
||||
if len(frozenset(item.name for item in self.filters)) != len(self.filters):
|
||||
raise ValueError("Use one IN filter per dimension; filters combine with AND")
|
||||
if len(frozenset(self.group_by)) != len(self.group_by):
|
||||
raise ValueError("Duplicate group-by dimensions")
|
||||
if any(
|
||||
(instant.hour, instant.minute, instant.second, instant.microsecond) != (0, 0, 0, 0)
|
||||
for instant in (self.start, self.end)
|
||||
):
|
||||
raise ValueError("Initial reporting queries require UTC day boundaries")
|
||||
return self
|
||||
|
||||
|
||||
class AggregateRow(BaseModel):
|
||||
model_config = _CONFIG
|
||||
|
||||
start: Instant
|
||||
end: Instant
|
||||
dimensions: Dimensions
|
||||
value: Quantity
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_bounds(self) -> Self:
|
||||
_interval(self.start, self.end)
|
||||
return self
|
||||
|
||||
|
||||
def validate_aggregate_rows(rows: tuple[AggregateRow, ...]) -> tuple[AggregateRow, ...]:
|
||||
"""Validate [start, end) buckets per group; overlapping tag groups are not additive."""
|
||||
groups: Final = frozenset(frozenset(row.dimensions) for row in rows)
|
||||
for group in groups:
|
||||
ordered = sorted((row for row in rows if frozenset(row.dimensions) == group), key=lambda row: row.start)
|
||||
if any(left.end > right.start for left, right in zip(ordered, ordered[1:])):
|
||||
raise ValueError("Aggregate buckets overlap within a dimension group")
|
||||
return rows
|
||||
|
||||
|
||||
class AdmissionDecision(BaseModel):
|
||||
model_config = _CONFIG
|
||||
|
||||
allowed: bool
|
||||
reason: Identifier | None = None
|
||||
|
|
@ -274,7 +274,9 @@ class InMemoryCache(BaseCache):
|
|||
) -> list[float] | None:
|
||||
results: Final = []
|
||||
for increment in increment_list:
|
||||
result = await self.async_increment(increment["key"], increment["increment_value"], **kwargs)
|
||||
result = await self.async_increment(
|
||||
increment["key"], increment["increment_value"], ttl=increment["ttl"], **kwargs
|
||||
)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
|
|||
from litellm.llms.base_llm.bridges.completion_transformation import (
|
||||
CompletionTransformationBridge,
|
||||
)
|
||||
from litellm.llms.custom_httpx.sse_lines import split_sse_lines
|
||||
from litellm.responses.litellm_completion_transformation.tool_search import is_anthropic_tool_search_chat_tool
|
||||
from litellm.responses.sse_output_recovery import (
|
||||
parse_sse_json_chunk,
|
||||
|
|
@ -1351,7 +1352,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
recovered_output_items: Final[dict[int, dict[str, object]]] = {}
|
||||
recovered_text_only_items: Final[dict[int, dict[str, object]]] = {}
|
||||
|
||||
for chunk in raw_sse.splitlines():
|
||||
for chunk in split_sse_lines(raw_sse):
|
||||
parsed_chunk = parse_sse_json_chunk(chunk)
|
||||
if parsed_chunk is None:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any, Final
|
|||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines, iter_sse_lines
|
||||
from litellm.proxy.pass_through_endpoints.success_handler import (
|
||||
PassThroughEndpointLogging,
|
||||
)
|
||||
|
|
@ -131,9 +132,9 @@ class GoogleGenAIGenerateContentStreamingIterator(BaseGoogleGenAIGenerateContent
|
|||
self.model = model
|
||||
self.generate_content_provider_config = generate_content_provider_config
|
||||
self.litellm_metadata = litellm_metadata
|
||||
# Gemini streamGenerateContent uses SSE line framing; iter_lines keeps
|
||||
# Gemini streamGenerateContent uses SSE line framing; iter_sse_lines keeps
|
||||
# large inlineData payloads (e.g. image/jpeg) intact within one event.
|
||||
self.stream_iterator = response.iter_lines()
|
||||
self.stream_iterator = iter_sse_lines(response.iter_text())
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
|
@ -182,9 +183,9 @@ class AsyncGoogleGenAIGenerateContentStreamingIterator(BaseGoogleGenAIGenerateCo
|
|||
self.model = model
|
||||
self.generate_content_provider_config = generate_content_provider_config
|
||||
self.litellm_metadata = litellm_metadata
|
||||
# Gemini streamGenerateContent uses SSE line framing; aiter_lines keeps
|
||||
# Gemini streamGenerateContent uses SSE line framing; aiter_sse_lines keeps
|
||||
# large inlineData payloads (e.g. image/jpeg) intact within one event.
|
||||
self.stream_iterator = response.aiter_lines()
|
||||
self.stream_iterator = aiter_sse_lines(response.aiter_text())
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import asyncio
|
||||
import contextvars
|
||||
import importlib
|
||||
from collections.abc import Coroutine, Mapping
|
||||
from collections.abc import Collection, Coroutine, Mapping
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Final, Literal, cast, overload
|
||||
from typing import TYPE_CHECKING, Final, Literal, cast, get_type_hints, overload
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
|
|
@ -61,6 +61,7 @@ from litellm.types.utils import (
|
|||
ProviderRoutingHints,
|
||||
)
|
||||
from litellm.utils import (
|
||||
EXTRA_BODY_ROUTING_KEYS,
|
||||
ImageResponse,
|
||||
ProviderConfigManager,
|
||||
get_llm_provider,
|
||||
|
|
@ -466,7 +467,7 @@ def image_generation(
|
|||
litellm.LlmProviders.QWENCLOUD,
|
||||
litellm.LlmProviders.QWEN_AI_PLATFORM,
|
||||
litellm.LlmProviders.EDENAI,
|
||||
):
|
||||
) or (custom_llm_provider == litellm.LlmProviders.TENCENT and image_generation_config is not None):
|
||||
if image_generation_config is None:
|
||||
raise ValueError(f"image generation config is not supported for {custom_llm_provider}")
|
||||
|
||||
|
|
@ -807,6 +808,32 @@ def image_variation(
|
|||
return response
|
||||
|
||||
|
||||
_IMAGE_EDIT_OPENAI_PARAMS: Final = frozenset(get_type_hints(ImageEditOptionalRequestParams))
|
||||
_DROP_PARAM_KWARGS: Final = frozenset({"drop_params", "additional_drop_params"})
|
||||
_ADDITIONAL_DROP_PARAMS: Final = TypeAdapter[tuple[str, ...]](tuple[str, ...])
|
||||
|
||||
|
||||
def _listed_drop_params(additional_drop_params: object) -> tuple[str, ...]:
|
||||
try:
|
||||
return _ADDITIONAL_DROP_PARAMS.validate_python(additional_drop_params)
|
||||
except ValidationError:
|
||||
return ()
|
||||
|
||||
|
||||
def _tokenhub_passthrough_edit_params(
|
||||
non_default_params: Mapping[str, object],
|
||||
extra_body: Mapping[str, object] | None,
|
||||
mapped_params: Collection[str],
|
||||
additional_drop_params: Collection[str] | None,
|
||||
) -> Mapping[str, object]:
|
||||
dropped: Final = frozenset(additional_drop_params or ())
|
||||
unmapped_excluded: Final = _DROP_PARAM_KWARGS | _IMAGE_EDIT_OPENAI_PARAMS | frozenset(mapped_params) | dropped
|
||||
return {
|
||||
**{key: value for key, value in non_default_params.items() if key not in unmapped_excluded},
|
||||
**{key: value for key, value in (extra_body or {}).items() if key not in dropped | EXTRA_BODY_ROUTING_KEYS},
|
||||
}
|
||||
|
||||
|
||||
@client
|
||||
def image_edit(
|
||||
image: FileTypes | list[FileTypes] | None = None,
|
||||
|
|
@ -935,22 +962,24 @@ def image_edit(
|
|||
raise ValueError(f"image edit is not supported for {custom_llm_provider}")
|
||||
|
||||
local_vars.update(kwargs)
|
||||
provider_supported_params: Final[frozenset[str]] = frozenset(
|
||||
image_edit_provider_config.get_supported_openai_params(model)
|
||||
)
|
||||
# Get ImageEditOptionalRequestParams with only valid parameters
|
||||
image_edit_optional_params: Final[ImageEditOptionalRequestParams] = (
|
||||
_get_ImageEditRequestUtils().get_requested_image_edit_optional_param(
|
||||
local_vars,
|
||||
provider_supported_params=frozenset(
|
||||
image_edit_provider_config.get_supported_openai_params(model)
|
||||
).intersection(non_default_params),
|
||||
provider_supported_params=provider_supported_params.intersection(non_default_params),
|
||||
)
|
||||
)
|
||||
# Get optional parameters for the responses API
|
||||
additional_drop_params: Final = _listed_drop_params(kwargs.get("additional_drop_params"))
|
||||
image_edit_request_params: Final[dict] = _get_ImageEditRequestUtils().get_optional_params_image_edit(
|
||||
model=model,
|
||||
image_edit_provider_config=image_edit_provider_config,
|
||||
image_edit_optional_params=image_edit_optional_params,
|
||||
drop_params=kwargs.get("drop_params"),
|
||||
additional_drop_params=kwargs.get("additional_drop_params"),
|
||||
additional_drop_params=additional_drop_params,
|
||||
)
|
||||
|
||||
if image_edit_provider_config.use_multipart_form_data() and (
|
||||
|
|
@ -1039,7 +1068,19 @@ def image_edit(
|
|||
image=images,
|
||||
prompt=prompt,
|
||||
image_edit_provider_config=image_edit_provider_config,
|
||||
image_edit_optional_request_params=image_edit_request_params,
|
||||
image_edit_optional_request_params={
|
||||
**image_edit_request_params,
|
||||
**(
|
||||
_tokenhub_passthrough_edit_params(
|
||||
non_default_params,
|
||||
extra_body if isinstance(extra_body, dict) else None,
|
||||
provider_supported_params,
|
||||
additional_drop_params,
|
||||
)
|
||||
if custom_llm_provider == litellm.LlmProviders.TENCENT.value
|
||||
else {}
|
||||
),
|
||||
},
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
litellm_params=litellm_params,
|
||||
logging_obj=litellm_logging_obj,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import base64
|
||||
import os
|
||||
from collections.abc import Collection, Mapping
|
||||
from io import BufferedReader, BytesIO
|
||||
from typing import Any, Final, cast, get_type_hints
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, Protocol, cast, get_type_hints, runtime_checkable
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.token_counter import get_image_type
|
||||
|
|
@ -16,7 +19,7 @@ class ImageEditRequestUtils:
|
|||
image_edit_provider_config: BaseImageEditConfig,
|
||||
image_edit_optional_params: ImageEditOptionalRequestParams,
|
||||
drop_params: bool | None = None,
|
||||
additional_drop_params: list[str] | None = None,
|
||||
additional_drop_params: Collection[str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Get optional parameters for the image edit API.
|
||||
|
|
@ -145,3 +148,36 @@ class ImageEditRequestUtils:
|
|||
except Exception:
|
||||
# If anything goes wrong, default to PNG
|
||||
return FILE_MIME_TYPES[FileType.PNG]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _SeekableBinaryReader(Protocol):
|
||||
def tell(self) -> int: ...
|
||||
|
||||
def seek(self, offset: int) -> int: ...
|
||||
|
||||
def read(self) -> bytes: ...
|
||||
|
||||
|
||||
def _read_image_bytes(image: object) -> bytes:
|
||||
if isinstance(image, bytes):
|
||||
return image
|
||||
if isinstance(image, tuple):
|
||||
return _read_image_bytes(image[1])
|
||||
if isinstance(image, os.PathLike):
|
||||
return Path(image).read_bytes()
|
||||
if isinstance(image, _SeekableBinaryReader):
|
||||
position: Final = image.tell()
|
||||
image.seek(0)
|
||||
data: Final = image.read()
|
||||
image.seek(position)
|
||||
return data
|
||||
raise ValueError(f"Unsupported image type for image edit: {type(image).__name__}")
|
||||
|
||||
|
||||
def image_to_data_url(image: object) -> str:
|
||||
if isinstance(image, str):
|
||||
return image
|
||||
image_bytes: Final = _read_image_bytes(image)
|
||||
mime_type: Final = ImageEditRequestUtils.get_image_content_type(image_bytes)
|
||||
return f"data:{mime_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}"
|
||||
|
|
|
|||
|
|
@ -338,6 +338,8 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
|
|||
"LiteLLM_ToolTable",
|
||||
"LiteLLM_TelemetryReport",
|
||||
"LiteLLM_AccessGroupTable",
|
||||
"LiteLLM_AccountingDelivery",
|
||||
"LiteLLM_AccountingEvent",
|
||||
"LiteLLM_ClaudeCodePluginTable",
|
||||
"LiteLLM_MemoryTable",
|
||||
"LiteLLM_AdaptiveRouterState",
|
||||
|
|
@ -434,6 +436,7 @@ _POSTGRES_OPERATION_BY_CALL_TYPE: Final[Mapping[str, PostgresOperation]] = Mappi
|
|||
"reset_budget_windows": PostgresOperation("select", None),
|
||||
"write_budget_windows": PostgresOperation("update", None),
|
||||
"roll_window_spend_row": PostgresOperation("update", None),
|
||||
"open_window_spend_row": PostgresOperation("upsert", None),
|
||||
"seed_window_spend": PostgresOperation("select", None),
|
||||
"select_window_spend_rows": PostgresOperation("select", None),
|
||||
"commit_window_spend_updates": PostgresOperation("upsert", None),
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ WEBSEARCH_NATIVE_BLOCKS_METADATA_KEY: Final = "websearch_native_blocks"
|
|||
|
||||
_RESPONSE_CONTENT_FIELD: Final = "content"
|
||||
|
||||
_CLAUDE_CODE_WEB_SEARCH_PROMPT_PREFIX: Final = "Perform a web search for the query:"
|
||||
|
||||
_ResponseT: Final = TypeVar("_ResponseT")
|
||||
|
||||
|
||||
|
|
@ -363,7 +365,8 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
get_last_user_message,
|
||||
)
|
||||
|
||||
query: Final = get_last_user_message(cast(list[AllMessageValues], messages))
|
||||
last_user_message: Final = get_last_user_message(cast(list[AllMessageValues], messages)) or ""
|
||||
query: Final = last_user_message.removeprefix(_CLAUDE_CODE_WEB_SEARCH_PROMPT_PREFIX).strip()
|
||||
if not query:
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
|||
from litellm.litellm_core_utils.llm_response_utils.get_api_base import get_api_base
|
||||
from litellm.litellm_core_utils.thread_pool_executor import executor
|
||||
from litellm.llms.base_llm.interactions.transformation import BaseInteractionsAPIConfig
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines, iter_sse_lines
|
||||
from litellm.types.interactions import (
|
||||
InteractionsAPIStreamingResponse,
|
||||
)
|
||||
|
|
@ -145,7 +146,7 @@ class InteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator):
|
|||
litellm_metadata=litellm_metadata,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
self.stream_iterator = response.aiter_lines()
|
||||
self.stream_iterator = aiter_sse_lines(response.aiter_text())
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
|
@ -212,7 +213,7 @@ class SyncInteractionsAPIStreamingIterator(BaseInteractionsAPIStreamingIterator)
|
|||
litellm_metadata=litellm_metadata,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
self.stream_iterator = response.iter_lines()
|
||||
self.stream_iterator = iter_sse_lines(response.iter_text())
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
|
|
|||
|
|
@ -266,6 +266,7 @@ _FINISH_REASON_MAP: Final[dict[str, OpenAIChatCompletionFinishReason]] = {
|
|||
"tool_use": "tool_calls",
|
||||
"refusal": "content_filter",
|
||||
"compaction": "length",
|
||||
"model_context_window_exceeded": "length",
|
||||
# Cohere
|
||||
"COMPLETE": "stop",
|
||||
"ERROR_TOXIC": "content_filter",
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ OPTIONAL_KWARGS_KEYS: Final = (
|
|||
"rpm",
|
||||
"itpm",
|
||||
"otpm",
|
||||
"tpd",
|
||||
"rpd",
|
||||
"use_xai_oauth",
|
||||
"chatgpt_auth_file",
|
||||
"github_copilot_auth_type",
|
||||
|
|
|
|||
|
|
@ -2758,7 +2758,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
if entry is None or not is_str_keyed_dict(litellm_params):
|
||||
return
|
||||
metadata: Final[object] = litellm_params.get("metadata")
|
||||
bucket: Final = metadata if is_str_keyed_dict(metadata) else {}
|
||||
bucket: Final = {**metadata} if is_str_keyed_dict(metadata) else {}
|
||||
litellm_params["metadata"] = bucket
|
||||
with_guardrail_entry(bucket, entry)
|
||||
|
||||
|
|
@ -3481,7 +3481,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
print_verbose=print_verbose,
|
||||
level=LogfireLevel.INFO.value,
|
||||
level=LogfireLevel.INFO,
|
||||
)
|
||||
|
||||
if callback == "lunary" and lunaryLogger is not None:
|
||||
|
|
@ -4442,7 +4442,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
response_obj=result,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
level=LogfireLevel.ERROR.value,
|
||||
level=LogfireLevel.ERROR,
|
||||
print_verbose=print_verbose,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from collections.abc import Mapping, Sequence
|
|||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone, tzinfo
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal, TypedDict, cast
|
||||
from typing import Final, Literal, Protocol, TypedDict, cast
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from typing_extensions import ReadOnly
|
||||
|
|
@ -2085,6 +2085,41 @@ def calculate_image_response_web_search_cost(
|
|||
)
|
||||
|
||||
|
||||
class _ModelPricedImageCostCalculator(Protocol):
|
||||
def __call__(self, model: str, image_response: ImageResponse, model_info: ModelInfo | None = None) -> float: ...
|
||||
|
||||
|
||||
def _model_priced_image_cost_calculator(
|
||||
custom_llm_provider: str | None, model: str
|
||||
) -> _ModelPricedImageCostCalculator | None:
|
||||
match custom_llm_provider:
|
||||
case litellm.LlmProviders.RECRAFT.value:
|
||||
from litellm.llms.recraft.cost_calculator import cost_calculator as recraft_image_cost_calculator
|
||||
|
||||
return recraft_image_cost_calculator
|
||||
case litellm.LlmProviders.AIML.value:
|
||||
from litellm.llms.aiml.image_generation.cost_calculator import cost_calculator as aiml_image_cost_calculator
|
||||
|
||||
return aiml_image_cost_calculator
|
||||
case litellm.LlmProviders.COMETAPI.value:
|
||||
from litellm.llms.cometapi.image_generation.cost_calculator import (
|
||||
cost_calculator as cometapi_image_cost_calculator,
|
||||
)
|
||||
|
||||
return cometapi_image_cost_calculator
|
||||
case litellm.LlmProviders.RUNWAYML.value:
|
||||
from litellm.llms.runwayml.cost_calculator import cost_calculator as runwayml_image_cost_calculator
|
||||
|
||||
return runwayml_image_cost_calculator
|
||||
case litellm.LlmProviders.TENCENT.value:
|
||||
from litellm.llms.tencent.cost_calculator import image_cost_calculator as tencent_image_cost_calculator
|
||||
from litellm.llms.tencent.image_generation.transformation import is_tokenhub_image_model
|
||||
|
||||
return tencent_image_cost_calculator if is_tokenhub_image_model(model) else None
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
class CostCalculatorUtils:
|
||||
@staticmethod
|
||||
def call_type_has_image_response(call_type: str) -> bool:
|
||||
|
|
@ -2131,9 +2166,6 @@ class CostCalculatorUtils:
|
|||
from litellm.llms.gemini.image_generation.cost_calculator import (
|
||||
cost_calculator as gemini_image_cost_calculator,
|
||||
)
|
||||
from litellm.llms.recraft.cost_calculator import (
|
||||
cost_calculator as recraft_image_cost_calculator,
|
||||
)
|
||||
from litellm.llms.vertex_ai.image_generation.cost_calculator import (
|
||||
cost_calculator as vertex_ai_image_cost_calculator,
|
||||
)
|
||||
|
|
@ -2146,7 +2178,10 @@ class CostCalculatorUtils:
|
|||
)
|
||||
resolved_n: Final = n if n is not None else (len(completion_response.data) if completion_response.data else 0)
|
||||
pricing: Final = deployment_pricing(model_info)
|
||||
model_priced_calculator: Final = _model_priced_image_cost_calculator(custom_llm_provider, model)
|
||||
|
||||
if model_priced_calculator is not None:
|
||||
return model_priced_calculator(model=model, image_response=completion_response, model_info=pricing)
|
||||
if custom_llm_provider == litellm.LlmProviders.VERTEX_AI.value:
|
||||
if isinstance(completion_response, ImageResponse):
|
||||
return vertex_ai_image_cost_calculator(
|
||||
|
|
@ -2164,36 +2199,6 @@ class CostCalculatorUtils:
|
|||
optional_params=optional_params,
|
||||
)
|
||||
raise TypeError("completion_response must be of type ImageResponse for bedrock image cost calculation")
|
||||
elif custom_llm_provider == litellm.LlmProviders.RECRAFT.value:
|
||||
from litellm.llms.recraft.cost_calculator import (
|
||||
cost_calculator as recraft_image_cost_calculator,
|
||||
)
|
||||
|
||||
return recraft_image_cost_calculator(
|
||||
model=model,
|
||||
image_response=completion_response,
|
||||
model_info=pricing,
|
||||
)
|
||||
elif custom_llm_provider == litellm.LlmProviders.AIML.value:
|
||||
from litellm.llms.aiml.image_generation.cost_calculator import (
|
||||
cost_calculator as aiml_image_cost_calculator,
|
||||
)
|
||||
|
||||
return aiml_image_cost_calculator(
|
||||
model=model,
|
||||
image_response=completion_response,
|
||||
model_info=pricing,
|
||||
)
|
||||
elif custom_llm_provider == litellm.LlmProviders.COMETAPI.value:
|
||||
from litellm.llms.cometapi.image_generation.cost_calculator import (
|
||||
cost_calculator as cometapi_image_cost_calculator,
|
||||
)
|
||||
|
||||
return cometapi_image_cost_calculator(
|
||||
model=model,
|
||||
image_response=completion_response,
|
||||
model_info=pricing,
|
||||
)
|
||||
elif custom_llm_provider == litellm.LlmProviders.GEMINI.value:
|
||||
if call_type in (
|
||||
CallTypes.image_edit.value,
|
||||
|
|
@ -2237,16 +2242,6 @@ class CostCalculatorUtils:
|
|||
optional_params=optional_params,
|
||||
model_info=pricing,
|
||||
)
|
||||
elif custom_llm_provider == litellm.LlmProviders.RUNWAYML.value:
|
||||
from litellm.llms.runwayml.cost_calculator import (
|
||||
cost_calculator as runwayml_image_cost_calculator,
|
||||
)
|
||||
|
||||
return runwayml_image_cost_calculator(
|
||||
model=model,
|
||||
image_response=completion_response,
|
||||
model_info=pricing,
|
||||
)
|
||||
elif (
|
||||
custom_llm_provider == litellm.LlmProviders.OPENAI.value
|
||||
or custom_llm_provider == litellm.LlmProviders.AZURE.value
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ from pydantic import TypeAdapter
|
|||
|
||||
import litellm
|
||||
from litellm import verbose_logger
|
||||
from litellm.litellm_core_utils.batch_utils import InMemoryFile
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping
|
||||
from litellm.router_utils.batch_utils import InMemoryFile
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
ChatCompletionAssistantMessage,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ def safe_json_structure(
|
|||
def _transform(key: str | None, value: str) -> str:
|
||||
return value if value_transform is None else value_transform(key, value)
|
||||
|
||||
def _serialize(obj: object, seen: set[int], depth: int, key: str | None = None) -> Any:
|
||||
def _serialize(obj: object, seen: set[int], depth: int, key: str | None = None) -> object:
|
||||
# Check for maximum depth.
|
||||
if depth > max_depth:
|
||||
return "MaxDepthExceeded"
|
||||
|
|
@ -48,32 +48,32 @@ def safe_json_structure(
|
|||
if id(obj) in seen:
|
||||
return "CircularReference Detected"
|
||||
seen.add(id(obj))
|
||||
result: dict | list | tuple | set | str
|
||||
if isinstance(obj, dict):
|
||||
result = {}
|
||||
for k, v in obj.items():
|
||||
if isinstance(k, (str)):
|
||||
clean_k = k.replace("\x00", "") if "\x00" in k else k
|
||||
result[clean_k] = _serialize(v, seen, depth + 1, clean_k)
|
||||
mapping_result: Final = {
|
||||
(clean_k := k.replace("\x00", "") if "\x00" in k else k): _serialize(v, seen, depth + 1, clean_k)
|
||||
for k, v in obj.items()
|
||||
if isinstance(k, str)
|
||||
}
|
||||
seen.remove(id(obj))
|
||||
return result
|
||||
return mapping_result
|
||||
elif isinstance(obj, list):
|
||||
result = [_serialize(item, seen, depth + 1, key) for item in obj]
|
||||
list_result: Final = [_serialize(item, seen, depth + 1, key) for item in obj]
|
||||
seen.remove(id(obj))
|
||||
return result
|
||||
return list_result
|
||||
elif isinstance(obj, tuple):
|
||||
result = tuple(_serialize(item, seen, depth + 1, key) for item in obj)
|
||||
tuple_result: Final = tuple(_serialize(item, seen, depth + 1, key) for item in obj)
|
||||
seen.remove(id(obj))
|
||||
return result
|
||||
return tuple_result
|
||||
elif isinstance(obj, set):
|
||||
result = sorted([_serialize(item, seen, depth + 1, key) for item in obj])
|
||||
sortable_values: Final[list[Any]] = [_serialize(item, seen, depth + 1, key) for item in obj]
|
||||
set_result: Final = sorted(sortable_values)
|
||||
seen.remove(id(obj))
|
||||
return result
|
||||
return set_result
|
||||
elif isinstance(obj, BaseModel):
|
||||
dumped: Final = obj.model_dump()
|
||||
result = _serialize(dumped, seen, depth + 1, key)
|
||||
model_result: Final = _serialize(dumped, seen, depth + 1, key)
|
||||
seen.remove(id(obj))
|
||||
return result
|
||||
return model_result
|
||||
else:
|
||||
# Fall back to string conversion for non-serializable objects.
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1263,7 +1263,7 @@ class CustomStreamWrapper:
|
|||
)
|
||||
|
||||
if _is_delta_empty:
|
||||
model_response.choices[0].delta = Delta(content=None) # ensure empty delta chunk returned
|
||||
model_response.choices[0].delta = Delta(content=self._close_open_think_block())
|
||||
# get any function call arguments
|
||||
model_response.choices[0].finish_reason = map_finish_reason(
|
||||
finish_reason=self.received_finish_reason
|
||||
|
|
@ -1292,21 +1292,26 @@ class CustomStreamWrapper:
|
|||
if self.merge_reasoning_content_in_choices is not True:
|
||||
return
|
||||
delta: Final = model_response.choices[0].delta
|
||||
reasoning_content: Final = getattr(delta, "reasoning_content", None)
|
||||
reasoning_content: Final = getattr(delta, "reasoning_content", None) or ""
|
||||
content: Final = delta.content or ""
|
||||
if reasoning_content:
|
||||
self._emitted_disqualifying_content = True
|
||||
closes_thinking: Final = bool(content) and not self.sent_last_thinking_block
|
||||
opening: Final = "" if self.sent_first_thinking_block else "<think>"
|
||||
delta.content = opening + reasoning_content + ("</think>" + content if closes_thinking else "")
|
||||
self.sent_first_thinking_block = True
|
||||
self.sent_last_thinking_block = self.sent_last_thinking_block or closes_thinking
|
||||
elif self.sent_first_thinking_block and not self.sent_last_thinking_block and content:
|
||||
delta.content = "</think>" + content
|
||||
self.sent_last_thinking_block = True
|
||||
|
||||
ends_thinking: Final = bool(content or delta.tool_calls or delta.function_call)
|
||||
inside_think_block: Final = self.sent_first_thinking_block and not self.sent_last_thinking_block
|
||||
opening: Final = "<think>" if reasoning_content and not inside_think_block else ""
|
||||
closing: Final = "</think>" if ends_thinking and (reasoning_content or inside_think_block) else ""
|
||||
if hasattr(delta, "reasoning_content"):
|
||||
del delta.reasoning_content
|
||||
if not reasoning_content and not closing:
|
||||
return
|
||||
delta.content = opening + reasoning_content + closing + content
|
||||
self.sent_first_thinking_block = True
|
||||
self.sent_last_thinking_block = bool(closing)
|
||||
self._emitted_disqualifying_content = self._emitted_disqualifying_content or bool(reasoning_content)
|
||||
|
||||
def _close_open_think_block(self) -> str | None:
|
||||
if not self.sent_first_thinking_block or self.sent_last_thinking_block:
|
||||
return None
|
||||
self.sent_last_thinking_block = True
|
||||
return "</think>"
|
||||
|
||||
def _dispatch_provider_chunk(
|
||||
self,
|
||||
|
|
@ -1932,8 +1937,20 @@ class CustomStreamWrapper:
|
|||
model_response.choices[0].finish_reason == "stop" and self.tool_call
|
||||
): # don't overwrite for other - potential error finish reasons
|
||||
model_response.choices[0].finish_reason = "tool_calls"
|
||||
close_tag: Final = self._close_open_think_block()
|
||||
if close_tag is not None:
|
||||
model_response.choices[0].delta.content = close_tag
|
||||
return model_response
|
||||
|
||||
def _final_chunk_for_logging(self, processed_chunk: "ModelResponseStream") -> "ModelResponseStream | None":
|
||||
if self.received_finish_reason is not None or self.intermittent_finish_reason is not None:
|
||||
return processed_chunk
|
||||
if processed_chunk.choices[0].delta.content is None:
|
||||
return None
|
||||
return processed_chunk.model_copy(
|
||||
update={"choices": [processed_chunk.choices[0].model_copy(update={"finish_reason": None})]}
|
||||
)
|
||||
|
||||
def _record_usage_only_chunk(self, model_response: "ModelResponseStream") -> None:
|
||||
"""
|
||||
Keep provider usage-only chunks (e.g. OpenRouter's post-finish chunk, which carries a
|
||||
|
|
@ -2173,10 +2190,9 @@ class CustomStreamWrapper:
|
|||
else:
|
||||
self.sent_last_chunk = True
|
||||
processed_chunk: Final = self.finish_reason_handler()
|
||||
# The logged response is built from self.chunks; keep a finish_reason the provider sent on its
|
||||
# last content chunk (stripped there), but never add the synthetic "stop" used when it sent none.
|
||||
if self.received_finish_reason is not None or self.intermittent_finish_reason is not None:
|
||||
self.chunks.append(processed_chunk)
|
||||
final_chunk_for_logging: Final = self._final_chunk_for_logging(processed_chunk)
|
||||
if final_chunk_for_logging is not None:
|
||||
self.chunks.append(final_chunk_for_logging)
|
||||
if self.stream_options is None:
|
||||
self._set_final_chunk_usage(processed_chunk)
|
||||
## LOGGING
|
||||
|
|
@ -2406,10 +2422,9 @@ class CustomStreamWrapper:
|
|||
else:
|
||||
self.sent_last_chunk = True
|
||||
processed_chunk: Final = self.finish_reason_handler()
|
||||
# The logged response is built from self.chunks; keep a finish_reason the provider sent on its
|
||||
# last content chunk (stripped there), but never add the synthetic "stop" used when it sent none.
|
||||
if self.received_finish_reason is not None or self.intermittent_finish_reason is not None:
|
||||
self.chunks.append(processed_chunk)
|
||||
final_chunk_for_logging: Final = self._final_chunk_for_logging(processed_chunk)
|
||||
if final_chunk_for_logging is not None:
|
||||
self.chunks.append(final_chunk_for_logging)
|
||||
if self.stream_options is None:
|
||||
self._set_final_chunk_usage(processed_chunk)
|
||||
# see sync __next__'s sibling branch: deliberately do NOT restore
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
get_async_httpx_client,
|
||||
get_httpx_client,
|
||||
)
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines, iter_sse_lines
|
||||
from litellm.types.llms.anthropic import (
|
||||
ContentBlockDelta,
|
||||
ContentBlockStart,
|
||||
|
|
@ -233,7 +234,7 @@ async def make_call(
|
|||
raise AnthropicError(status_code=500, message=str(e))
|
||||
|
||||
completion_stream: Final = ModelResponseIterator(
|
||||
streaming_response=response.aiter_lines(),
|
||||
streaming_response=aiter_sse_lines(response.aiter_text()),
|
||||
sync_stream=False,
|
||||
json_mode=json_mode,
|
||||
speed=speed,
|
||||
|
|
@ -304,7 +305,7 @@ def make_sync_call(
|
|||
)
|
||||
|
||||
completion_stream: Final = ModelResponseIterator(
|
||||
streaming_response=response.iter_lines(),
|
||||
streaming_response=iter_sse_lines(response.iter_text()),
|
||||
sync_stream=True,
|
||||
json_mode=json_mode,
|
||||
speed=speed,
|
||||
|
|
|
|||
|
|
@ -268,6 +268,7 @@ async def caller_within_model_budget(
|
|||
user_id=user_id,
|
||||
user_model_max_budget=user_model_max_budget,
|
||||
model=model,
|
||||
team_id=user_api_key_auth.team_id,
|
||||
)
|
||||
except litellm.BudgetExceededError:
|
||||
return "denied"
|
||||
|
|
@ -309,6 +310,7 @@ async def caller_within_model_budget(
|
|||
end_user_id=end_user_id,
|
||||
end_user_model_max_budget=end_user_model_max_budget,
|
||||
model=model,
|
||||
team_id=user_api_key_auth.team_id,
|
||||
)
|
||||
except litellm.BudgetExceededError:
|
||||
return "denied"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from litellm.llms.anthropic.chat.handler import (
|
|||
)
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
from litellm.llms.anthropic.messages.native.utils import MESSAGE_DELTA_OPTIONAL_KEYS
|
||||
from litellm.llms.custom_httpx.sse_lines import split_sse_lines
|
||||
from litellm.types.utils import (
|
||||
Choices,
|
||||
Message,
|
||||
|
|
@ -269,7 +270,7 @@ def build_complete_streaming_response_legacy(
|
|||
|
||||
def extract_sse_data(event_str: str) -> dict | None:
|
||||
"""Parse the JSON object from the ``data:`` line of an Anthropic SSE event."""
|
||||
for line in event_str.splitlines():
|
||||
for line in split_sse_lines(event_str):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("data:"):
|
||||
payload = stripped[len("data:") :].strip()
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ from litellm.llms.anthropic.messages.native.transformation import (
|
|||
AnthropicMessagesConfig,
|
||||
)
|
||||
from litellm.llms.anthropic.messages.native.utils import AnthropicMessagesRequestUtils, prepare_native_messages
|
||||
from litellm.router_utils.baseline_request import (
|
||||
from litellm.router_strategy.auto_router.baseline_request import (
|
||||
BASELINE_PARAMETERS,
|
||||
capture_baseline_parameters,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from litellm.llms.azure_ai.agents.transformation import (
|
|||
AzureAIAgentsConfig,
|
||||
AzureAIAgentsError,
|
||||
)
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines
|
||||
from litellm.types.llms.openai import (
|
||||
ChatCompletionAnnotation,
|
||||
ChatCompletionAnnotationURLCitation,
|
||||
|
|
@ -632,7 +633,7 @@ class AzureAIAgentsHandler:
|
|||
|
||||
current_event = None
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
async for line in aiter_sse_lines(response.aiter_text()):
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith("event:"):
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ class BaseModelResponseIterator:
|
|||
released (and a backend like vLLM aborts generation) when the stream
|
||||
is abandoned before its natural end.
|
||||
|
||||
``streaming_response`` is usually a bare ``aiter_lines()`` generator
|
||||
``streaming_response`` is usually a bare ``aiter_sse_lines()`` generator
|
||||
that holds no reference to the response, so the handler that owns the
|
||||
response attaches it here after construction."""
|
||||
if self.http_response is not None:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from typing import Final, TypeVar, cast # noqa: TID251 # a rebuilt chat row ha
|
|||
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
|
||||
from litellm.llms.custom_httpx.sse_lines import split_sse_lines
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUsage
|
||||
from litellm.types.llms.openai import AllMessageValues, ResponseAPIUsage
|
||||
|
||||
|
|
@ -24,7 +25,7 @@ def _anthropic_stream_chunk_events(item: object) -> list[dict]:
|
|||
|
||||
events: Final[list[dict]] = []
|
||||
for block in chunk.split("\n\n"):
|
||||
for line in block.splitlines():
|
||||
for line in split_sse_lines(block):
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("data:"):
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from typing import Final
|
|||
import httpx
|
||||
from pydantic import Field, PrivateAttr
|
||||
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines
|
||||
from litellm.types.llms.base import LiteLLMPydanticObjectBase
|
||||
|
||||
SANDBOX_MAX_OUTPUT_BYTES: Final = 10 * 1024 * 1024
|
||||
|
|
@ -99,7 +100,7 @@ class BaseSandboxConfig:
|
|||
async def _read_capped_lines(self, response: httpx.Response) -> list[str]:
|
||||
lines: Final[list[str]] = []
|
||||
total = 0
|
||||
async for line in response.aiter_lines():
|
||||
async for line in aiter_sse_lines(response.aiter_text()):
|
||||
total += len(line.encode("utf-8"))
|
||||
if total > SANDBOX_MAX_OUTPUT_BYTES:
|
||||
raise ValueError(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import time
|
|||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
from pydantic import ConfigDict, TypeAdapter, with_config
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -34,6 +36,15 @@ else:
|
|||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _PegasusResponse(TypedDict):
|
||||
message: ReadOnly[NotRequired[str | None]]
|
||||
finishReason: ReadOnly[NotRequired[str | None]]
|
||||
|
||||
|
||||
_PEGASUS_RESPONSE: Final = TypeAdapter(_PegasusResponse)
|
||||
|
||||
|
||||
class AmazonTwelveLabsPegasusConfig(AmazonInvokeConfig, BaseConfig):
|
||||
"""
|
||||
Handles transforming OpenAI-style requests into Bedrock InvokeModel requests for
|
||||
|
|
@ -207,7 +218,7 @@ class AmazonTwelveLabsPegasusConfig(AmazonInvokeConfig, BaseConfig):
|
|||
ModelResponse with choices[0].message.content and finish_reason
|
||||
"""
|
||||
try:
|
||||
completion_response: Final = raw_response.json()
|
||||
completion_response: Final = _PEGASUS_RESPONSE.validate_python(raw_response.json())
|
||||
except Exception as e:
|
||||
raise BedrockError(
|
||||
message=f"Error parsing response: {raw_response.text}, error: {e}",
|
||||
|
|
@ -224,7 +235,7 @@ class AmazonTwelveLabsPegasusConfig(AmazonInvokeConfig, BaseConfig):
|
|||
message_content: Final = completion_response.get("message", "")
|
||||
|
||||
# Extract finish reason and map to LiteLLM format
|
||||
finish_reason_raw: Final = completion_response.get("finishReason", "stop")
|
||||
finish_reason_raw: Final = completion_response.get("finishReason") or "stop"
|
||||
finish_reason: Final = map_finish_reason(finish_reason_raw)
|
||||
|
||||
# Set the response content
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import copy
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, cast, get_args
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from pydantic import ConfigDict, TypeAdapter, ValidationError, with_config
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -48,6 +49,27 @@ from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
|||
|
||||
_GUARDRAIL_CONFIG_VALIDATOR: Final["TypeAdapter[GuardrailConfigBlock]"] = TypeAdapter(GuardrailConfigBlock)
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _CohereGeneration(TypedDict):
|
||||
text: ReadOnly[str | None]
|
||||
finish_reason: ReadOnly[str | None]
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _CohereInvokeResponse(TypedDict):
|
||||
text: NotRequired[ReadOnly[str | None]]
|
||||
generations: NotRequired[ReadOnly[Sequence[_CohereGeneration]]]
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _LlamaInvokeResponse(TypedDict):
|
||||
generation: ReadOnly[str | None]
|
||||
|
||||
|
||||
_COHERE_INVOKE_RESPONSE: Final = TypeAdapter(_CohereInvokeResponse)
|
||||
_LLAMA_INVOKE_RESPONSE: Final = TypeAdapter(_LlamaInvokeResponse)
|
||||
|
||||
_GUARDRAIL_CONFIG_EXPECTED_FORMAT: Final = (
|
||||
"{'guardrailIdentifier': str, 'guardrailVersion': str, 'trace': 'enabled'|'disabled'|'enabled_full'}"
|
||||
)
|
||||
|
|
@ -310,12 +332,13 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM):
|
|||
outputText: str | None = None
|
||||
try:
|
||||
if provider == "cohere":
|
||||
if "text" in completion_response:
|
||||
outputText = completion_response["text"]
|
||||
elif "generations" in completion_response:
|
||||
outputText = completion_response["generations"][0]["text"]
|
||||
cohere_response: Final = _COHERE_INVOKE_RESPONSE.validate_python(completion_response)
|
||||
if "text" in cohere_response:
|
||||
outputText = cohere_response["text"]
|
||||
elif "generations" in cohere_response:
|
||||
outputText = cohere_response["generations"][0]["text"]
|
||||
model_response.choices[0].finish_reason = map_finish_reason(
|
||||
completion_response["generations"][0]["finish_reason"]
|
||||
cohere_response["generations"][0]["finish_reason"] or "stop"
|
||||
)
|
||||
elif provider == "anthropic":
|
||||
return litellm.AmazonAnthropicClaudeConfig().transform_response(
|
||||
|
|
@ -361,7 +384,7 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM):
|
|||
elif provider == "ai21":
|
||||
outputText = completion_response.get("completions")[0].get("data").get("text")
|
||||
elif provider == "meta" or provider == "llama" or provider == "deepseek_r1":
|
||||
outputText = completion_response["generation"]
|
||||
outputText = _LLAMA_INVOKE_RESPONSE.validate_python(completion_response)["generation"]
|
||||
elif provider == "mistral":
|
||||
outputText = litellm.AmazonMistralConfig.get_outputText(completion_response, model_response)
|
||||
else: # amazon titan
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ from litellm.llms.base_llm.files.transformation import (
|
|||
BaseFilesConfig,
|
||||
LiteLLMLoggingObj,
|
||||
)
|
||||
from litellm.llms.custom_httpx.sse_lines import split_sse_lines
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
from litellm.types.llms.bedrock import AwsAuthParams, BedrockBatchRecordKind
|
||||
from litellm.types.llms.openai import (
|
||||
|
|
@ -400,7 +401,7 @@ def _batch_input_line(line_number: int, line: str) -> _BatchInputLine:
|
|||
|
||||
|
||||
def _non_blank_lines(file_content: str) -> Iterator[tuple[int, str]]:
|
||||
numbered_lines: Final = enumerate(file_content.splitlines(), start=1)
|
||||
numbered_lines: Final = enumerate(split_sse_lines(file_content), start=1)
|
||||
return ((line_number, line) for line_number, line in numbered_lines if line.strip())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from litellm.llms.bedrock.passthrough.guardrail_translation.structured_edits imp
|
|||
structured_edits,
|
||||
tool_edits,
|
||||
)
|
||||
from litellm.llms.custom_httpx.sse_lines import split_sse_lines
|
||||
from litellm.proxy.guardrails.auto_router_compression import is_compression_guardrail
|
||||
from litellm.types.utils import GenericGuardrailAPIInputs
|
||||
|
||||
|
|
@ -947,7 +948,7 @@ def _invoke_frames(items: Sequence[object]) -> tuple[InvokeStreamFrame, ...]:
|
|||
def _sse_events(sse: bytes) -> tuple[Mapping[str, object], ...]:
|
||||
return tuple(
|
||||
_JSON_OBJECT.validate_python(json.loads(line[len("data:") :]))
|
||||
for line in sse.decode().splitlines()
|
||||
for line in split_sse_lines(sse.decode())
|
||||
if line.startswith("data:")
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ from litellm.types.realtime import (
|
|||
RealtimeResponseTypedDict,
|
||||
)
|
||||
|
||||
_JSON_OBJECT_MESSAGE: Final = TypeAdapter(dict[str, JsonValue], config=ConfigDict(strict=True))
|
||||
_JSON_VALUE: Final = TypeAdapter(object)
|
||||
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
|
@ -856,7 +857,7 @@ class BedrockRealtimeConfig(BaseRealtimeConfig):
|
|||
List of Bedrock format messages (JSON strings)
|
||||
"""
|
||||
try:
|
||||
json_message: Final[dict[str, JsonValue]] = json.loads(message)
|
||||
json_message: Final = _JSON_OBJECT_MESSAGE.validate_python(json.loads(message))
|
||||
except json.JSONDecodeError:
|
||||
verbose_logger.warning("Invalid JSON message: %s", message[:200])
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ from litellm.llms.base_llm.search.transformation import (
|
|||
)
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from litellm.llms.custom_httpx.sse_lines import split_sse_lines
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
||||
# AgentCore web-search rejects queries longer than 200 characters
|
||||
|
|
@ -146,7 +147,9 @@ def _iter_sse_events(text: str) -> Iterator[Mapping[str, object]]:
|
|||
progress notifications before the JSON-RPC response.
|
||||
"""
|
||||
for chunk in _SSE_EVENT_SEPARATOR.split(text):
|
||||
payload = "\n".join(line[len("data:") :].lstrip() for line in chunk.splitlines() if line.startswith("data:"))
|
||||
payload = "\n".join(
|
||||
line[len("data:") :].lstrip() for line in split_sse_lines(chunk) if line.startswith("data:")
|
||||
)
|
||||
if not payload:
|
||||
continue
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo
|
|||
safe_convert_created_field,
|
||||
)
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
from litellm.llms.custom_httpx.sse_lines import split_sse_lines
|
||||
from litellm.llms.openai.common_utils import OpenAIError
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.responses.sse_output_recovery import (
|
||||
|
|
@ -199,7 +200,7 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
error_message = None
|
||||
streamed_output_items: Final[dict[int, dict[str, object]]] = {}
|
||||
text_only_output_items: Final[dict[int, dict[str, object]]] = {}
|
||||
for chunk in body_text.splitlines():
|
||||
for chunk in split_sse_lines(body_text):
|
||||
parsed_chunk = parse_sse_json_chunk(chunk)
|
||||
if parsed_chunk is None:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
AsyncHTTPHandler,
|
||||
get_async_httpx_client,
|
||||
)
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines, iter_sse_lines
|
||||
from litellm.types.utils import TextChoices
|
||||
from litellm.utils import CustomStreamWrapper, TextCompletionResponse
|
||||
|
||||
|
|
@ -109,7 +110,7 @@ async def make_call(
|
|||
if response.status_code != 200:
|
||||
raise TextCompletionCodestralError(status_code=response.status_code, message=response.text)
|
||||
|
||||
completion_stream: Final = response.aiter_lines()
|
||||
completion_stream: Final = aiter_sse_lines(response.aiter_text())
|
||||
# LOGGING
|
||||
logging_obj.post_call(
|
||||
input=messages,
|
||||
|
|
@ -339,13 +340,13 @@ class CodestralTextCompletion:
|
|||
response = litellm.module_level_client.post(
|
||||
completion_url,
|
||||
headers=headers,
|
||||
data=json.dumps(data),
|
||||
data=json.dumps({**data, "stream": True}),
|
||||
stream=stream,
|
||||
)
|
||||
_response: Final = CustomStreamWrapper(
|
||||
response.iter_lines(),
|
||||
iter_sse_lines(response.iter_text()),
|
||||
model,
|
||||
custom_llm_provider="codestral",
|
||||
custom_llm_provider="text-completion-codestral",
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
return _response
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
get_httpx_client,
|
||||
get_ssl_configuration,
|
||||
)
|
||||
from litellm.llms.custom_httpx.sse_lines import iter_sse_lines
|
||||
from litellm.types.llms.openai import FileTypes
|
||||
from litellm.types.utils import HttpHandlerRequestFields, ImageResponse, LlmProviders
|
||||
from litellm.utils import CustomStreamWrapper, ModelResponse, ProviderConfigManager
|
||||
|
|
@ -466,7 +467,7 @@ class BaseLLMAIOHTTPHandler:
|
|||
)
|
||||
else:
|
||||
completion_stream = provider_config.get_model_response_iterator(
|
||||
streaming_response=response.iter_lines(), sync_stream=True
|
||||
streaming_response=iter_sse_lines(response.iter_text()), sync_stream=True
|
||||
)
|
||||
|
||||
# LOGGING
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import ssl
|
|||
import sys
|
||||
import typing
|
||||
import urllib.request
|
||||
import weakref
|
||||
from collections.abc import Callable, Generator
|
||||
from typing import ClassVar, Final
|
||||
|
||||
|
|
@ -109,6 +110,24 @@ def map_aiohttp_exceptions() -> Generator[None, None, None]:
|
|||
raise mapped_exc(message) from exc
|
||||
|
||||
|
||||
def _release_transport_anchor(_transport: object) -> None:
|
||||
"""Release a transport anchored to a response stream. See ``_anchor_transport_to``."""
|
||||
|
||||
|
||||
def _anchor_transport_to(stream: "AiohttpResponseStream", transport: object) -> None:
|
||||
"""Keep the transport alive for as long as the stream can still read.
|
||||
|
||||
The transport finalizer closes the session it owns, and nothing on the path from a
|
||||
live response back to that session references the transport, so a client dropped
|
||||
mid-stream would close the session under the body. ``weakref.finalize`` holds the
|
||||
transport in its own registry rather than on the stream, which keeps the transport
|
||||
out of the response's reference cycle (an exception carrying the response, for one)
|
||||
so it is finalized by refcount once the anchor drops and can still schedule an async
|
||||
close, instead of being collected in the same cyclic pass as the session it closes.
|
||||
"""
|
||||
weakref.finalize(stream, _release_transport_anchor, transport)
|
||||
|
||||
|
||||
class AiohttpResponseStream(httpx.AsyncByteStream):
|
||||
CHUNK_SIZE = 1024 * 16
|
||||
|
||||
|
|
@ -463,10 +482,12 @@ class LiteLLMAiohttpTransport(AiohttpTransport):
|
|||
# Re-raise if it's a different RuntimeError
|
||||
raise
|
||||
|
||||
stream: Final = AiohttpResponseStream(response)
|
||||
_anchor_transport_to(stream, self)
|
||||
return httpx.Response(
|
||||
status_code=response.status,
|
||||
headers=response.headers,
|
||||
stream=AiohttpResponseStream(response),
|
||||
stream=stream,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
get_httpx_client,
|
||||
header_value,
|
||||
)
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines, iter_sse_lines
|
||||
from litellm.responses.streaming_iterator import (
|
||||
BaseResponsesAPIStreamingIterator,
|
||||
MockResponsesAPIStreamingIterator,
|
||||
|
|
@ -1086,7 +1087,7 @@ class BaseLLMHTTPHandler:
|
|||
completion_stream: object = MockResponseIterator(model_response=model_response, json_mode=json_mode)
|
||||
else:
|
||||
completion_stream = provider_config.get_model_response_iterator_for_request(
|
||||
streaming_response=response.iter_lines(),
|
||||
streaming_response=iter_sse_lines(response.iter_text()),
|
||||
sync_stream=True,
|
||||
json_mode=json_mode,
|
||||
optional_params=_OPTIONAL_PARAMS.validate_python(optional_params),
|
||||
|
|
@ -1229,7 +1230,7 @@ class BaseLLMHTTPHandler:
|
|||
completion_stream: object = MockResponseIterator(model_response=model_response, json_mode=json_mode)
|
||||
else:
|
||||
completion_stream = provider_config.get_model_response_iterator_for_request(
|
||||
streaming_response=response.aiter_lines(),
|
||||
streaming_response=aiter_sse_lines(response.aiter_text()),
|
||||
sync_stream=False,
|
||||
json_mode=json_mode,
|
||||
optional_params=_OPTIONAL_PARAMS.validate_python(optional_params),
|
||||
|
|
|
|||
74
litellm/llms/custom_httpx/sse_lines.py
Normal file
74
litellm/llms/custom_httpx/sse_lines.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""
|
||||
Split a decoded Server-Sent Events body into lines the way the SSE spec does.
|
||||
|
||||
httpx's `iter_lines()` / `aiter_lines()` follow `str.splitlines()`, which also breaks on U+0085, U+2028,
|
||||
U+2029, \\x0b, \\x0c and \\x1c-\\x1e. JSON leaves U+0085, U+2028 and U+2029 unescaped inside strings, so a
|
||||
`data:` line carrying one of them gets cut in half. SSE lines end only at CRLF, LF or CR
|
||||
(https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream)
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator, Sequence
|
||||
from typing import Final
|
||||
|
||||
_SSE_LINE_BREAK: Final = re.compile(r"\r\n|\r|\n")
|
||||
_SSE_LINE_WITH_BREAK: Final = re.compile(r"[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+")
|
||||
|
||||
|
||||
def _split_lines(text: str) -> Sequence[str]:
|
||||
if "\r" not in text:
|
||||
return text.split("\n")
|
||||
crlf_folded: Final = text.replace("\r\n", "\n")
|
||||
if "\r" not in crlf_folded:
|
||||
return crlf_folded.split("\n")
|
||||
return _SSE_LINE_BREAK.split(text)
|
||||
|
||||
|
||||
class _SSELineSplitter:
|
||||
def __init__(self) -> None:
|
||||
self._partial_line: io.StringIO = io.StringIO()
|
||||
self._after_cr: bool = False
|
||||
|
||||
def feed(self, text: str) -> tuple[str, ...]:
|
||||
if not text:
|
||||
return ()
|
||||
fresh: Final = text[1:] if self._after_cr and text.startswith("\n") else text
|
||||
self._after_cr = fresh.endswith("\r")
|
||||
*ended, tail = _split_lines(fresh)
|
||||
if not ended:
|
||||
_ = self._partial_line.write(tail)
|
||||
return ()
|
||||
first: Final = self._partial_line.getvalue() + ended[0]
|
||||
self._partial_line = io.StringIO()
|
||||
_ = self._partial_line.write(tail)
|
||||
return (first, *ended[1:])
|
||||
|
||||
def flush(self) -> tuple[str, ...]:
|
||||
rest: Final = self._partial_line.getvalue()
|
||||
self._partial_line = io.StringIO()
|
||||
return (rest,) if rest else ()
|
||||
|
||||
|
||||
def iter_sse_lines(text_chunks: Iterable[str]) -> Iterator[str]:
|
||||
splitter: Final = _SSELineSplitter()
|
||||
for text in text_chunks:
|
||||
yield from splitter.feed(text)
|
||||
yield from splitter.flush()
|
||||
|
||||
|
||||
def split_sse_lines(text: str) -> tuple[str, ...]:
|
||||
return tuple(iter_sse_lines((text,)))
|
||||
|
||||
|
||||
def split_sse_lines_keepends(text: str) -> tuple[str, ...]:
|
||||
return tuple(_SSE_LINE_WITH_BREAK.findall(text))
|
||||
|
||||
|
||||
async def aiter_sse_lines(text_chunks: AsyncIterable[str]) -> AsyncIterator[str]:
|
||||
splitter: Final = _SSELineSplitter()
|
||||
async for text in text_chunks:
|
||||
for line in splitter.feed(text):
|
||||
yield line
|
||||
for line in splitter.flush():
|
||||
yield line
|
||||
|
|
@ -14,6 +14,8 @@ Docs - https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api
|
|||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import ConfigDict, TypeAdapter, with_config
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.url_utils import ensure_api_route
|
||||
|
|
@ -28,6 +30,23 @@ from ..common_utils import DashScopeError
|
|||
DEFAULT_API_BASE: Final = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _DashScopeUsage(TypedDict, total=False):
|
||||
prompt_tokens: NotRequired[ReadOnly[int]]
|
||||
total_tokens: NotRequired[ReadOnly[int]]
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _DashScopeEmbeddingResponse(TypedDict, total=False):
|
||||
data: NotRequired[ReadOnly[list[object]]]
|
||||
model: NotRequired[ReadOnly[str | None]]
|
||||
usage: NotRequired[ReadOnly[_DashScopeUsage | None]]
|
||||
id: NotRequired[ReadOnly[object]]
|
||||
|
||||
|
||||
_DASHSCOPE_EMBEDDING_RESPONSE: Final = TypeAdapter(_DashScopeEmbeddingResponse)
|
||||
|
||||
|
||||
class DashScopeEmbeddingConfig(BaseEmbeddingConfig):
|
||||
"""
|
||||
Reference: https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api
|
||||
|
|
@ -152,11 +171,12 @@ class DashScopeEmbeddingConfig(BaseEmbeddingConfig):
|
|||
message=message,
|
||||
)
|
||||
|
||||
parsed_response: Final = _DASHSCOPE_EMBEDDING_RESPONSE.validate_python(response_json)
|
||||
model_response.object = "list"
|
||||
model_response.data = response_json.get("data", [])
|
||||
model_response.model = response_json.get("model", model)
|
||||
model_response.data = parsed_response.get("data", [])
|
||||
model_response.model = parsed_response.get("model", model)
|
||||
|
||||
usage: Final = response_json.get("usage") or {}
|
||||
usage: Final = parsed_response.get("usage") or {}
|
||||
prompt_tokens: Final = usage.get("prompt_tokens", 0)
|
||||
total_tokens: Final = usage.get("total_tokens", prompt_tokens)
|
||||
setattr(
|
||||
|
|
@ -169,8 +189,8 @@ class DashScopeEmbeddingConfig(BaseEmbeddingConfig):
|
|||
),
|
||||
)
|
||||
|
||||
if "id" in response_json:
|
||||
setattr(model_response, "id", response_json["id"])
|
||||
if "id" in parsed_response:
|
||||
setattr(model_response, "id", parsed_response["id"])
|
||||
|
||||
return model_response
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import json
|
||||
from typing import Final
|
||||
|
||||
from pydantic import ConfigDict, TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm import verbose_logger
|
||||
from litellm.types.llms.openai import (
|
||||
|
|
@ -23,6 +25,9 @@ def _usage_block(usage: Usage) -> ChatCompletionUsageBlock:
|
|||
)
|
||||
|
||||
|
||||
_JSON_CHUNK: Final = TypeAdapter(dict[str, object], config=ConfigDict(strict=True))
|
||||
|
||||
|
||||
class ModelResponseIterator:
|
||||
def __init__(self, streaming_response, sync_stream: bool):
|
||||
self.streaming_response = streaming_response
|
||||
|
|
@ -99,7 +104,7 @@ class ModelResponseIterator:
|
|||
self.response_iterator = self.streaming_response
|
||||
chunk: Final = self._next_sse_data()
|
||||
try:
|
||||
json_chunk: Final = json.loads(chunk)
|
||||
json_chunk: Final = _JSON_CHUNK.validate_python(json.loads(chunk))
|
||||
return self.chunk_parser(chunk=json_chunk)
|
||||
except ValueError as e:
|
||||
verbose_logger.debug(
|
||||
|
|
@ -126,7 +131,7 @@ class ModelResponseIterator:
|
|||
if chunk == "[DONE]":
|
||||
raise StopAsyncIteration
|
||||
try:
|
||||
json_chunk: Final = json.loads(chunk)
|
||||
json_chunk: Final = _JSON_CHUNK.validate_python(json.loads(chunk))
|
||||
return self.chunk_parser(chunk=json_chunk)
|
||||
except ValueError as e:
|
||||
verbose_logger.debug(
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ from typing import Final
|
|||
|
||||
from httpx._types import RequestFiles
|
||||
|
||||
from litellm.images.utils import image_to_data_url
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.images.main import ImageEditOptionalRequestParams
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import FileTypes
|
||||
|
||||
from .transformation import DEFAULT_BASE_URL, FalAIImageEditConfig, to_data_url
|
||||
from .transformation import DEFAULT_BASE_URL, FalAIImageEditConfig
|
||||
|
||||
FLUX_LORA_DEPTH_ENDPOINT: Final[str] = "fal-ai/flux-lora-depth"
|
||||
SUPPORTED_OPENAI_PARAMS: Final[tuple[str, ...]] = ("n", "size")
|
||||
|
|
@ -68,7 +69,7 @@ class FalAIFluxLoraDepthEditConfig(FalAIImageEditConfig):
|
|||
)
|
||||
request_body: Final[dict[str, object]] = { # mutable-ok: base class contract returns a dict
|
||||
"prompt": prompt,
|
||||
"image_url": to_data_url(next(iter(images))),
|
||||
"image_url": image_to_data_url(next(iter(images))),
|
||||
**provider_params,
|
||||
}
|
||||
return request_body, ()
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
import base64
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import httpx
|
||||
from httpx._types import RequestFiles
|
||||
from pydantic import ConfigDict, TypeAdapter
|
||||
|
||||
from litellm.images.utils import ImageEditRequestUtils
|
||||
from litellm.images.utils import image_to_data_url
|
||||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
from litellm.llms.fal_ai.image_generation.gpt_image_2_transformation import (
|
||||
map_gpt_image_quality,
|
||||
|
|
@ -39,39 +36,6 @@ PARAM_TRANSLATION: Final[Mapping[str, str]] = MappingProxyType(
|
|||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _SeekableBinaryReader(Protocol):
|
||||
def tell(self) -> int: ...
|
||||
|
||||
def seek(self, offset: int) -> int: ...
|
||||
|
||||
def read(self) -> bytes: ...
|
||||
|
||||
|
||||
def _read_image_bytes(image: object) -> bytes:
|
||||
if isinstance(image, bytes):
|
||||
return image
|
||||
if isinstance(image, tuple):
|
||||
return _read_image_bytes(image[1])
|
||||
if isinstance(image, os.PathLike):
|
||||
return Path(image).read_bytes()
|
||||
if isinstance(image, _SeekableBinaryReader):
|
||||
position: Final = image.tell()
|
||||
image.seek(0)
|
||||
data: Final = image.read()
|
||||
image.seek(position)
|
||||
return data
|
||||
raise ValueError(f"Unsupported image type for Fal AI image edit: {type(image).__name__}")
|
||||
|
||||
|
||||
def to_data_url(image: object) -> str:
|
||||
if isinstance(image, str):
|
||||
return image
|
||||
image_bytes: Final = _read_image_bytes(image)
|
||||
mime_type: Final = ImageEditRequestUtils.get_image_content_type(image_bytes)
|
||||
return f"data:{mime_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}"
|
||||
|
||||
|
||||
def _first(value: object) -> object:
|
||||
return value[0] if isinstance(value, list) and value else value
|
||||
|
||||
|
|
@ -146,14 +110,14 @@ class FalAIImageEditConfig(BaseImageEditConfig):
|
|||
raise ValueError("Fal AI image edit requires at least one input image")
|
||||
mask: Final = _first(image_edit_optional_request_params.get("mask"))
|
||||
mask_field: Final[Mapping[str, str]] = (
|
||||
MappingProxyType({"mask_url": to_data_url(mask)}) if mask is not None else MappingProxyType({})
|
||||
MappingProxyType({"mask_url": image_to_data_url(mask)}) if mask is not None else MappingProxyType({})
|
||||
)
|
||||
provider_params: Final[Mapping[str, object]] = MappingProxyType(
|
||||
{key: value for key, value in image_edit_optional_request_params.items() if key != "mask"}
|
||||
)
|
||||
request_body: Final[dict[str, object]] = { # mutable-ok: base class contract returns a dict
|
||||
"prompt": prompt,
|
||||
"image_urls": tuple(to_data_url(img) for img in images),
|
||||
"image_urls": tuple(image_to_data_url(img) for img in images),
|
||||
**mask_field,
|
||||
**provider_params,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from litellm.llms.base_llm.guardrail_translation.base_translation import (
|
|||
BaseTranslation,
|
||||
StreamTransformSink,
|
||||
)
|
||||
from litellm.llms.custom_httpx.sse_lines import split_sse_lines
|
||||
from litellm.types.utils import GenericGuardrailAPIInputs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -121,7 +122,7 @@ def _parse_json_dict_or_none(payload: str) -> Mapping[str, object] | None:
|
|||
def _sse_payload_texts(sse_text: str) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
text
|
||||
for line in sse_text.splitlines()
|
||||
for line in split_sse_lines(sse_text)
|
||||
if line.startswith("data:")
|
||||
for payload in (line[len("data:") :].strip(),)
|
||||
if payload and payload != "[DONE]"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ from types import MappingProxyType
|
|||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import ConfigDict, TypeAdapter, with_config
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.caching.caching import InMemoryCache
|
||||
|
|
@ -37,6 +39,17 @@ _token_cache: Final = InMemoryCache()
|
|||
_NO_LITELLM_PARAMS: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _GigaChatTokenResponse(TypedDict):
|
||||
tok: NotRequired[ReadOnly[str | None]]
|
||||
access_token: NotRequired[ReadOnly[str | None]]
|
||||
exp: NotRequired[ReadOnly[int | float | str | None]]
|
||||
expires_at: NotRequired[ReadOnly[int | float | str | None]]
|
||||
|
||||
|
||||
_TOKEN_RESPONSE: Final = TypeAdapter(_GigaChatTokenResponse)
|
||||
|
||||
|
||||
class GigaChatAuthError(BaseLLMException):
|
||||
"""GigaChat authentication error."""
|
||||
|
||||
|
|
@ -230,7 +243,7 @@ async def _request_token_async(
|
|||
|
||||
def _parse_token_response(response: httpx.Response) -> tuple[str, int]:
|
||||
"""Parse OAuth token response."""
|
||||
data: Final = response.json()
|
||||
data: Final = _TOKEN_RESPONSE.validate_python(response.json())
|
||||
|
||||
# GigaChat returns either 'tok'/'exp' or 'access_token'/'expires_at'
|
||||
access_token: Final = data.get("tok") or data.get("access_token")
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
|
|||
)
|
||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
|
||||
from litellm.llms.custom_httpx.sse_lines import iter_sse_lines
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import Choices, Message, ModelResponse, Usage
|
||||
|
|
@ -357,7 +358,7 @@ class HuggingFaceEmbeddingConfig(BaseConfig):
|
|||
api_key: str | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
streamed_response: Final = CustomStreamWrapper(
|
||||
completion_stream=response.iter_lines(),
|
||||
completion_stream=iter_sse_lines(response.iter_text()),
|
||||
model=model,
|
||||
custom_llm_provider="huggingface",
|
||||
logging_obj=logging_obj,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import httpx
|
|||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines, iter_sse_lines
|
||||
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
|
||||
|
||||
_DECODED_JSON: Final = TypeAdapter(object)
|
||||
|
|
@ -36,12 +37,12 @@ class LangGraphSSEStreamIterator:
|
|||
|
||||
def __iter__(self):
|
||||
"""Initialize sync iteration."""
|
||||
self.line_iterator = self.response.iter_lines()
|
||||
self.line_iterator = iter_sse_lines(self.response.iter_text())
|
||||
return self
|
||||
|
||||
def __aiter__(self):
|
||||
"""Initialize async iteration."""
|
||||
self.async_line_iterator = self.response.aiter_lines()
|
||||
self.async_line_iterator = aiter_sse_lines(self.response.aiter_text())
|
||||
return self
|
||||
|
||||
def _parse_sse_line(self, line: str) -> ModelResponseStream | None:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Final
|
|||
import litellm
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
from litellm.llms.custom_httpx.http_handler import get_httpx_client
|
||||
from litellm.llms.custom_httpx.sse_lines import iter_sse_lines
|
||||
from litellm.utils import EmbeddingResponse, ModelResponse, Usage
|
||||
|
||||
from ..common_utils import OobaboogaError
|
||||
|
|
@ -74,7 +75,7 @@ def completion(
|
|||
stream=optional_params["stream"] if "stream" in optional_params else False,
|
||||
)
|
||||
if "stream" in optional_params and optional_params["stream"] is True:
|
||||
return response.iter_lines()
|
||||
return iter_sse_lines(response.iter_text())
|
||||
else:
|
||||
return oobabooga_config.transform_response(
|
||||
model=model,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ This requires websockets, and is currently only supported on LiteLLM Proxy.
|
|||
"""
|
||||
|
||||
import ssl
|
||||
from typing import Any, Final, cast
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
|
||||
from litellm._logging import redact_string, verbose_logger
|
||||
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
|
||||
|
|
@ -23,6 +23,9 @@ from ....llms.custom_httpx.http_handler import get_shared_realtime_ssl_context
|
|||
from ..common_utils import is_openai_backed_api_base
|
||||
from ..openai import OpenAIChatCompletion
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class OpenAIRealtime(OpenAIChatCompletion):
|
||||
"""
|
||||
|
|
@ -131,7 +134,7 @@ class OpenAIRealtime(OpenAIChatCompletion):
|
|||
async def async_realtime(
|
||||
self,
|
||||
model: str,
|
||||
websocket: Any,
|
||||
websocket: "WebSocket",
|
||||
logging_obj: LiteLLMLogging,
|
||||
api_base: str | None = None,
|
||||
api_key: str | None = None,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from litellm import LlmProviders
|
|||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.base_llm.base_model_iterator import MockResponseIterator
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines, iter_sse_lines
|
||||
from litellm.llms.databricks.streaming_utils import ModelResponseIterator
|
||||
from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
|
||||
from litellm.llms.openai.openai import OpenAIConfig
|
||||
|
|
@ -65,7 +66,9 @@ async def make_call(
|
|||
model_response: Final = _fake_streamed_model_response(response.json())
|
||||
completion_stream = MockResponseIterator(model_response=model_response)
|
||||
else:
|
||||
completion_stream = ModelResponseIterator(streaming_response=response.aiter_lines(), sync_stream=False)
|
||||
completion_stream = ModelResponseIterator(
|
||||
streaming_response=aiter_sse_lines(response.aiter_text()), sync_stream=False
|
||||
)
|
||||
# LOGGING
|
||||
logging_obj.post_call(
|
||||
input=messages,
|
||||
|
|
@ -103,7 +106,9 @@ def make_sync_call(
|
|||
model_response: Final = _fake_streamed_model_response(response.json())
|
||||
completion_stream = MockResponseIterator(model_response=model_response)
|
||||
else:
|
||||
completion_stream = ModelResponseIterator(streaming_response=response.iter_lines(), sync_stream=True)
|
||||
completion_stream = ModelResponseIterator(
|
||||
streaming_response=iter_sse_lines(response.iter_text()), sync_stream=True
|
||||
)
|
||||
|
||||
# LOGGING
|
||||
logging_obj.post_call(
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ This module decodes them into float arrays for OpenAI-compatible responses.
|
|||
"""
|
||||
|
||||
import base64
|
||||
import builtins
|
||||
import struct
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Final, Literal
|
||||
|
||||
import httpx
|
||||
from pydantic import ConfigDict, TypeAdapter
|
||||
from pydantic import ConfigDict, TypeAdapter, with_config
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.url_utils import join_api_base
|
||||
|
|
@ -31,6 +33,17 @@ _JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_i
|
|||
_INT: Final = TypeAdapter(int)
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow", strict=True))
|
||||
class _PerplexityEmbeddingResponse(TypedDict, total=False):
|
||||
model: ReadOnly[str | None]
|
||||
object: ReadOnly[Literal["list"]]
|
||||
data: ReadOnly[Sequence[builtins.object] | Mapping[str, builtins.object] | Literal[""]]
|
||||
usage: ReadOnly[builtins.object]
|
||||
|
||||
|
||||
_EMBEDDING_RESPONSE_ADAPTER: Final = TypeAdapter(_PerplexityEmbeddingResponse)
|
||||
|
||||
|
||||
class PerplexityEmbeddingError(BaseLLMException):
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -153,14 +166,18 @@ class PerplexityEmbeddingConfig(BaseEmbeddingConfig):
|
|||
litellm_params: dict = {},
|
||||
) -> EmbeddingResponse:
|
||||
try:
|
||||
raw_response_json: Final = raw_response.json()
|
||||
raw_response_json: Final[object] = raw_response.json()
|
||||
except Exception:
|
||||
raise PerplexityEmbeddingError(message=raw_response.text, status_code=raw_response.status_code)
|
||||
|
||||
model_response.model = raw_response_json.get("model", model)
|
||||
model_response.object = raw_response_json.get("object", "list")
|
||||
response_data: Final[_PerplexityEmbeddingResponse] = _EMBEDDING_RESPONSE_ADAPTER.validate_python(
|
||||
raw_response_json
|
||||
)
|
||||
|
||||
raw_data: Final = raw_response_json.get("data", [])
|
||||
model_response.model = response_data.get("model", model)
|
||||
model_response.object = response_data.get("object", "list")
|
||||
|
||||
raw_data: Final = response_data.get("data", [])
|
||||
decoded_data: Final[list[dict[str, object]]] = []
|
||||
for item in raw_data:
|
||||
decoded_item = dict(_JSON_OBJECT.validate_python(item))
|
||||
|
|
@ -168,7 +185,7 @@ class PerplexityEmbeddingConfig(BaseEmbeddingConfig):
|
|||
decoded_data.append(decoded_item)
|
||||
model_response.data = decoded_data
|
||||
|
||||
usage_data: Final = _JSON_OBJECT.validate_python(raw_response_json.get("usage", {}))
|
||||
usage_data: Final = _JSON_OBJECT.validate_python(response_data.get("usage", {}))
|
||||
usage: Final = Usage(
|
||||
prompt_tokens=_INT.validate_python(
|
||||
usage_data.get("prompt_tokens", 0) or usage_data.get("total_tokens", 0) or 0
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
AsyncHTTPHandler,
|
||||
get_async_httpx_client,
|
||||
)
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines, iter_sse_lines
|
||||
from litellm.utils import CustomStreamWrapper, ModelResponse
|
||||
|
||||
from ..common_utils import PredibaseError
|
||||
|
|
@ -34,7 +35,7 @@ async def make_call(
|
|||
if response.status_code != 200:
|
||||
raise PredibaseError(status_code=response.status_code, message=response.text)
|
||||
|
||||
completion_stream: Final = response.aiter_lines()
|
||||
completion_stream: Final = aiter_sse_lines(response.aiter_text())
|
||||
# LOGGING
|
||||
logging_obj.post_call(
|
||||
input=messages,
|
||||
|
|
@ -163,7 +164,7 @@ class PredibaseChatCompletion:
|
|||
timeout=timeout,
|
||||
)
|
||||
_response: Final = CustomStreamWrapper(
|
||||
response.iter_lines(),
|
||||
iter_sse_lines(response.iter_text()),
|
||||
model,
|
||||
custom_llm_provider="predibase",
|
||||
logging_obj=logging_obj,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,49 @@
|
|||
from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token
|
||||
from litellm.types.utils import Usage
|
||||
from typing import Final
|
||||
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
||||
calculate_image_response_cost_from_usage,
|
||||
flat_image_cost,
|
||||
generic_cost_per_token,
|
||||
resolve_image_model_info,
|
||||
)
|
||||
from litellm.types.utils import ImageResponse, ModelInfo, Usage
|
||||
|
||||
|
||||
def cost_per_token(model: str, usage: Usage) -> tuple[float, float]:
|
||||
return generic_cost_per_token(model=model, usage=usage, custom_llm_provider="tencent")
|
||||
|
||||
|
||||
def _deployment_image_token_pricing(model_info: ModelInfo | None) -> ModelInfo | None:
|
||||
if model_info is None or model_info.get("output_cost_per_image_token") is not None:
|
||||
return model_info
|
||||
output_cost_per_token: Final = model_info.get("output_cost_per_token")
|
||||
if output_cost_per_token is None:
|
||||
return model_info
|
||||
priced: Final[ModelInfo] = {**model_info, "output_cost_per_image_token": output_cost_per_token}
|
||||
return priced
|
||||
|
||||
|
||||
def _deployment_prices_per_image_only(model_info: ModelInfo | None) -> bool:
|
||||
return (
|
||||
model_info is not None
|
||||
and model_info.get("output_cost_per_image") is not None
|
||||
and model_info.get("output_cost_per_image_token") is None
|
||||
and model_info.get("output_cost_per_token") is None
|
||||
)
|
||||
|
||||
|
||||
def image_cost_calculator(model: str, image_response: ImageResponse, model_info: ModelInfo | None = None) -> float:
|
||||
resolved_model_info: Final = resolve_image_model_info(
|
||||
model=model, custom_llm_provider="tencent", model_info=_deployment_image_token_pricing(model_info)
|
||||
)
|
||||
if _deployment_prices_per_image_only(model_info):
|
||||
return flat_image_cost(resolved_model_info, image_response)
|
||||
token_based_cost: Final = calculate_image_response_cost_from_usage(
|
||||
model=model,
|
||||
image_response=image_response,
|
||||
custom_llm_provider="tencent",
|
||||
model_info=resolved_model_info,
|
||||
)
|
||||
if token_based_cost is not None:
|
||||
return token_based_cost
|
||||
return flat_image_cost(resolved_model_info, image_response)
|
||||
|
|
|
|||
0
litellm/llms/tencent/image_edit/__init__.py
Normal file
0
litellm/llms/tencent/image_edit/__init__.py
Normal file
147
litellm/llms/tencent/image_edit/transformation.py
Normal file
147
litellm/llms/tencent/image_edit/transformation.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, TypeAlias
|
||||
|
||||
import httpx
|
||||
from httpx._types import RequestFiles
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import override
|
||||
|
||||
from litellm.images.utils import image_to_data_url
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
from litellm.llms.tencent.image_generation.transformation import (
|
||||
TOKENHUB_IMAGE_PARAMS,
|
||||
tokenhub_image_headers,
|
||||
tokenhub_image_params,
|
||||
tokenhub_image_response,
|
||||
tokenhub_image_url,
|
||||
)
|
||||
from litellm.types.images.main import ImageEditOptionalRequestParams
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import ImageResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
_IMAGES: Final = TypeAdapter(tuple[object, ...])
|
||||
_FORM_BOOLEANS: Final = MappingProxyType({"true": True, "false": False})
|
||||
_OPENAI_PARAMS: Final = frozenset(TOKENHUB_IMAGE_PARAMS)
|
||||
|
||||
_FormEntries: TypeAlias = tuple[tuple[tuple[str, ...], object], ...]
|
||||
|
||||
|
||||
def _form_scalar(value: object) -> object:
|
||||
match value:
|
||||
case str() if value.lower() in _FORM_BOOLEANS:
|
||||
return _FORM_BOOLEANS[value.lower()]
|
||||
case str() if value.removeprefix("-").isdigit():
|
||||
return int(value)
|
||||
case _:
|
||||
return value
|
||||
|
||||
|
||||
def _form_path(key: str) -> tuple[str, ...]:
|
||||
root, bracket, rest = key.partition("[")
|
||||
if not bracket or not rest.endswith("]"):
|
||||
return (key,)
|
||||
return (root, *rest.removesuffix("]").split("]["))
|
||||
|
||||
|
||||
def _nested_form_value(entries: _FormEntries) -> object:
|
||||
match entries:
|
||||
case (((), value),):
|
||||
return _form_scalar(value)
|
||||
case _:
|
||||
return _nested_form_fields(tuple(entry for entry in entries if entry[0]))
|
||||
|
||||
|
||||
def _nested_form_fields(entries: _FormEntries) -> Mapping[str, object]:
|
||||
roots: Final = tuple(dict.fromkeys(path[0] for path, _ in entries))
|
||||
return {
|
||||
root: _nested_form_value(tuple((path[1:], value) for path, value in entries if path[0] == root))
|
||||
for root in roots
|
||||
}
|
||||
|
||||
|
||||
def _tokenhub_form_params(params: Mapping[str, object]) -> Mapping[str, object]:
|
||||
tokenhub_entries: Final = tuple(
|
||||
(_form_path(key), value) for key, value in params.items() if key not in _OPENAI_PARAMS
|
||||
)
|
||||
return {
|
||||
**{key: value for key, value in params.items() if key in _OPENAI_PARAMS},
|
||||
**_nested_form_fields(tokenhub_entries),
|
||||
}
|
||||
|
||||
|
||||
class TencentImageEditConfig(BaseImageEditConfig):
|
||||
@override
|
||||
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: mirrors override contract
|
||||
return list(TOKENHUB_IMAGE_PARAMS)
|
||||
|
||||
@override
|
||||
def map_openai_params(
|
||||
self,
|
||||
image_edit_optional_params: ImageEditOptionalRequestParams,
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict[str, object]: # mutable-ok: mirrors override contract
|
||||
return dict(tokenhub_image_params(image_edit_optional_params, model, drop_params))
|
||||
|
||||
@override
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: Mapping[str, str],
|
||||
model: str,
|
||||
api_key: str | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict[str, str]: # mutable-ok: mirrors override contract
|
||||
return dict(tokenhub_image_headers(headers, api_key, model))
|
||||
|
||||
@override
|
||||
def get_complete_url(
|
||||
self,
|
||||
model: str,
|
||||
api_base: str | None,
|
||||
litellm_params: Mapping[str, object],
|
||||
) -> str:
|
||||
return tokenhub_image_url(api_base)
|
||||
|
||||
@override
|
||||
def use_multipart_form_data(self) -> bool:
|
||||
return False
|
||||
|
||||
@override
|
||||
def transform_image_edit_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str | None,
|
||||
image: object,
|
||||
image_edit_optional_request_params: Mapping[str, object],
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: Mapping[str, str],
|
||||
) -> tuple[dict[str, object], RequestFiles]: # mutable-ok: mirrors override contract
|
||||
images: Final = _IMAGES.validate_python(image) if isinstance(image, list) else (image,)
|
||||
return {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"images": tuple(image_to_data_url(img) for img in images if img is not None),
|
||||
**_tokenhub_form_params(image_edit_optional_request_params),
|
||||
}, ()
|
||||
|
||||
@override
|
||||
def transform_image_edit_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
) -> ImageResponse:
|
||||
image_response: Final = tokenhub_image_response(raw_response)
|
||||
if image_response is None:
|
||||
raise BaseLLMException(
|
||||
status_code=raw_response.status_code,
|
||||
message=raw_response.text,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
return image_response
|
||||
0
litellm/llms/tencent/image_generation/__init__.py
Normal file
0
litellm/llms/tencent/image_generation/__init__.py
Normal file
200
litellm/llms/tencent/image_generation/transformation.py
Normal file
200
litellm/llms/tencent/image_generation/transformation.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
from typing_extensions import override
|
||||
|
||||
from litellm.exceptions import AuthenticationError, UnsupportedParamsError
|
||||
from litellm.litellm_core_utils.url_utils import ensure_api_route, join_api_base
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.image_generation.transformation import (
|
||||
BaseImageGenerationConfig,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
OpenAIImageGenerationOptionalParams,
|
||||
)
|
||||
from litellm.types.utils import (
|
||||
ImageObject,
|
||||
ImageResponse,
|
||||
ImageUsage,
|
||||
ImageUsageInputTokensDetails,
|
||||
LlmProviders,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
TOKENHUB_API_BASE: Final = "https://tokenhub-intl.tencentcloudmaas.com/v1"
|
||||
TOKENHUB_IMAGE_PATH: Final = "/wand/si-image/generation"
|
||||
TOKENHUB_IMAGE_MODEL_PREFIX: Final = "seedream-"
|
||||
TOKENHUB_IMAGE_PARAMS: Final[tuple[OpenAIImageGenerationOptionalParams, ...]] = (
|
||||
"n",
|
||||
"size",
|
||||
"response_format",
|
||||
"output_format",
|
||||
"background",
|
||||
"user",
|
||||
)
|
||||
|
||||
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True))
|
||||
|
||||
|
||||
class _TokenHubImage(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
url: str | None = None
|
||||
b64_json: str | None = None
|
||||
size: str | None = None
|
||||
output_format: str | None = None
|
||||
|
||||
|
||||
class _TokenHubUsage(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
total_tokens: int
|
||||
|
||||
|
||||
class _TokenHubImageResponse(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, hide_input_in_errors=True)
|
||||
|
||||
created: int
|
||||
data: tuple[_TokenHubImage, ...]
|
||||
tokenhub_usage: _TokenHubUsage | None = None
|
||||
|
||||
|
||||
def is_tokenhub_image_model(model: str) -> bool:
|
||||
return model.removeprefix(f"{LlmProviders.TENCENT.value}/").startswith(TOKENHUB_IMAGE_MODEL_PREFIX)
|
||||
|
||||
|
||||
def tokenhub_image_url(api_base: str | None) -> str:
|
||||
base: Final = api_base or get_secret_str("TENCENT_API_BASE") or TOKENHUB_API_BASE
|
||||
return join_api_base(ensure_api_route(base, "v1"), TOKENHUB_IMAGE_PATH)
|
||||
|
||||
|
||||
def tokenhub_image_headers(headers: Mapping[str, str], api_key: str | None, model: str) -> Mapping[str, str]:
|
||||
resolved_api_key: Final = api_key or get_secret_str("TENCENT_API_KEY")
|
||||
if not resolved_api_key:
|
||||
raise AuthenticationError(
|
||||
message="TENCENT_API_KEY is not set. Set it in the environment or pass api_key.",
|
||||
llm_provider="tencent",
|
||||
model=model,
|
||||
)
|
||||
return {**headers, "Authorization": f"Bearer {resolved_api_key}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def tokenhub_image_params(params: Mapping[str, object], model: str, drop_params: bool) -> Mapping[str, object]:
|
||||
n: Final = params.get("n")
|
||||
if n is not None and n != 1 and not drop_params:
|
||||
raise UnsupportedParamsError(
|
||||
status_code=400,
|
||||
message=f"Tencent TokenHub returns one image per request, so n={n} is not supported for {model}. "
|
||||
+ "To send n=1 instead, set `litellm.drop_params = True`.",
|
||||
)
|
||||
return {
|
||||
key: value
|
||||
for key, value in params.items()
|
||||
if key != "n" and value is not None and not (key == "background" and value == "auto")
|
||||
}
|
||||
|
||||
|
||||
def tokenhub_image_response(raw_response: httpx.Response) -> ImageResponse | None:
|
||||
try:
|
||||
parsed: Final = _TokenHubImageResponse.model_validate_json(raw_response.content)
|
||||
except ValidationError:
|
||||
return None
|
||||
total_tokens: Final = parsed.tokenhub_usage.total_tokens if parsed.tokenhub_usage is not None else 0
|
||||
first_image: Final = parsed.data[0] if parsed.data else _TokenHubImage()
|
||||
return ImageResponse(
|
||||
created=parsed.created,
|
||||
data=[ImageObject(url=image.url, b64_json=image.b64_json) for image in parsed.data],
|
||||
size=first_image.size,
|
||||
output_format=first_image.output_format,
|
||||
usage=ImageUsage(
|
||||
input_tokens=0,
|
||||
input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=0),
|
||||
output_tokens=total_tokens,
|
||||
total_tokens=total_tokens,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TencentImageGenerationConfig(BaseImageGenerationConfig):
|
||||
@override
|
||||
def get_supported_openai_params(
|
||||
self, model: str
|
||||
) -> list[OpenAIImageGenerationOptionalParams]: # mutable-ok: mirrors override contract
|
||||
return list(TOKENHUB_IMAGE_PARAMS)
|
||||
|
||||
@override
|
||||
def map_openai_params(
|
||||
self,
|
||||
non_default_params: Mapping[str, object],
|
||||
optional_params: Mapping[str, object],
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict[str, object]: # mutable-ok: mirrors override contract
|
||||
return {**optional_params, **tokenhub_image_params(non_default_params, model, drop_params)}
|
||||
|
||||
@override
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: str | None,
|
||||
api_key: str | None,
|
||||
model: str,
|
||||
optional_params: Mapping[str, object],
|
||||
litellm_params: Mapping[str, object],
|
||||
stream: bool | None = None,
|
||||
) -> str:
|
||||
return tokenhub_image_url(api_base)
|
||||
|
||||
@override
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: Mapping[str, str],
|
||||
model: str,
|
||||
messages: Sequence[AllMessageValues],
|
||||
optional_params: Mapping[str, object],
|
||||
litellm_params: Mapping[str, object],
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict[str, str]: # mutable-ok: mirrors override contract
|
||||
return dict(tokenhub_image_headers(headers, api_key, model))
|
||||
|
||||
@override
|
||||
def transform_image_generation_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
optional_params: Mapping[str, object],
|
||||
litellm_params: Mapping[str, object],
|
||||
headers: Mapping[str, str],
|
||||
) -> dict[str, object]: # mutable-ok: mirrors override contract
|
||||
extra_body: Final = _JSON_OBJECT.validate_python(optional_params.get("extra_body") or {})
|
||||
params: Final = {key: value for key, value in optional_params.items() if key != "extra_body"}
|
||||
return {"model": model, "prompt": prompt, **params, **extra_body}
|
||||
|
||||
@override
|
||||
def transform_image_generation_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
model_response: ImageResponse,
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
request_data: Mapping[str, object],
|
||||
optional_params: Mapping[str, object],
|
||||
litellm_params: Mapping[str, object],
|
||||
encoding: object,
|
||||
api_key: str | None = None,
|
||||
json_mode: bool | None = None,
|
||||
) -> ImageResponse:
|
||||
image_response: Final = tokenhub_image_response(raw_response)
|
||||
if image_response is None:
|
||||
raise BaseLLMException(
|
||||
status_code=raw_response.status_code,
|
||||
message=raw_response.text,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
return image_response
|
||||
|
|
@ -22,6 +22,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|||
convert_content_list_to_str,
|
||||
)
|
||||
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines, iter_sse_lines
|
||||
from litellm.llms.vertex_ai.agent_engine.sse_iterator import (
|
||||
VertexAgentEngineResponseIterator,
|
||||
)
|
||||
|
|
@ -357,7 +358,7 @@ class VertexAgentEngineConfig(BaseConfig, VertexBase):
|
|||
) -> VertexAgentEngineResponseIterator:
|
||||
"""Return a streaming iterator for SSE responses."""
|
||||
return VertexAgentEngineResponseIterator(
|
||||
streaming_response=raw_response.iter_lines(),
|
||||
streaming_response=iter_sse_lines(raw_response.iter_text()),
|
||||
sync_stream=True,
|
||||
)
|
||||
|
||||
|
|
@ -465,7 +466,7 @@ class VertexAgentEngineConfig(BaseConfig, VertexBase):
|
|||
|
||||
# Create iterator for SSE stream (async)
|
||||
completion_stream: Final = VertexAgentEngineResponseIterator(
|
||||
streaming_response=response.aiter_lines(),
|
||||
streaming_response=aiter_sse_lines(response.aiter_text()),
|
||||
sync_stream=False,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
get_async_httpx_client,
|
||||
get_httpx_client,
|
||||
)
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines, iter_sse_lines
|
||||
from litellm.types.llms.anthropic import AnthropicThinkingParam
|
||||
from litellm.types.llms.base import CachedTokensDetails
|
||||
from litellm.types.llms.gemini import BidiGenerateContentServerMessage
|
||||
|
|
@ -2743,10 +2744,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
|||
chunk_indices = support.get("groundingChunkIndices", [])
|
||||
|
||||
if start_index is not None and end_index is not None and chunk_indices:
|
||||
# Use the first chunk's URL for the annotation
|
||||
first_chunk_idx = chunk_indices[0]
|
||||
if first_chunk_idx in chunk_to_uri_map:
|
||||
uri_info = chunk_to_uri_map[first_chunk_idx]
|
||||
for chunk_index in chunk_indices:
|
||||
if chunk_index not in chunk_to_uri_map:
|
||||
continue
|
||||
uri_info = chunk_to_uri_map[chunk_index]
|
||||
|
||||
url_citation: ChatCompletionAnnotationURLCitation = {
|
||||
"start_index": start_index,
|
||||
|
|
@ -3241,7 +3242,7 @@ async def make_call(
|
|||
raise
|
||||
|
||||
completion_stream: Final = ModelResponseIterator(
|
||||
streaming_response=response.aiter_lines(),
|
||||
streaming_response=aiter_sse_lines(response.aiter_text()),
|
||||
sync_stream=False,
|
||||
logging_obj=logging_obj,
|
||||
response_headers=response.headers,
|
||||
|
|
@ -3312,7 +3313,7 @@ def make_sync_call(
|
|||
raise
|
||||
|
||||
completion_stream: Final = ModelResponseIterator(
|
||||
streaming_response=response.iter_lines(),
|
||||
streaming_response=iter_sse_lines(response.iter_text()),
|
||||
sync_stream=True,
|
||||
logging_obj=logging_obj,
|
||||
response_headers=response.headers,
|
||||
|
|
@ -4190,7 +4191,7 @@ class ModelResponseIterator:
|
|||
log_failed_hop(e)
|
||||
return self._truncated_stream_end(token)
|
||||
self._start_hop(hop_response, token)
|
||||
self.streaming_response = hop_response.aiter_lines()
|
||||
self.streaming_response = aiter_sse_lines(hop_response.aiter_text())
|
||||
self.async_response_iterator = self.streaming_response
|
||||
return None
|
||||
|
||||
|
|
@ -4204,7 +4205,7 @@ class ModelResponseIterator:
|
|||
log_failed_hop(e)
|
||||
return self._truncated_stream_end(token)
|
||||
self._start_hop(hop_response, token)
|
||||
self.streaming_response = hop_response.iter_lines()
|
||||
self.streaming_response = iter_sse_lines(hop_response.iter_text())
|
||||
self.response_iterator = self.streaming_response
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ The actual message transformation reuses OpenAIGPTConfig since Gemma uses OpenAI
|
|||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
|
||||
import httpx
|
||||
from pydantic import ValidationError
|
||||
from pydantic import ConfigDict, TypeAdapter, ValidationError
|
||||
from typing_extensions import NotRequired, ReadOnly, TypeAliasType, TypedDict
|
||||
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
|
|
@ -32,6 +33,17 @@ if TYPE_CHECKING:
|
|||
from litellm.litellm_core_utils.tokenizer import Encoding as Tokenizer
|
||||
|
||||
|
||||
class _PredictionsEnvelope(TypedDict):
|
||||
predictions: NotRequired[ReadOnly[dict[str, object]]]
|
||||
|
||||
|
||||
_PredictionsEnvelopeType = TypeAliasType("_PredictionsEnvelopeType", _PredictionsEnvelope)
|
||||
_PREDICTIONS_ENVELOPE: Final[TypeAdapter[_PredictionsEnvelope]] = TypeAdapter(
|
||||
_PredictionsEnvelopeType,
|
||||
config=ConfigDict(strict=True, extra="allow"),
|
||||
)
|
||||
|
||||
|
||||
def parse_vertex_gemma_container_error(predictions: object) -> VertexGemmaContainerError | None:
|
||||
try:
|
||||
return VertexGemmaContainerError.model_validate(predictions)
|
||||
|
|
@ -142,7 +154,7 @@ class VertexGemmaConfig(OpenAIGPTConfig):
|
|||
|
||||
def _unwrap_predictions_response(
|
||||
self,
|
||||
response_json: dict[str, Any],
|
||||
response_json: _PredictionsEnvelope,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Unwrap the Vertex Gemma predictions format to OpenAI format.
|
||||
|
|
@ -354,7 +366,7 @@ class VertexGemmaConfig(OpenAIGPTConfig):
|
|||
message=f"Request failed: {response.text}",
|
||||
)
|
||||
|
||||
response_json: Final = response.json()
|
||||
response_json: Final = _PREDICTIONS_ENVELOPE.validate_python(response.json())
|
||||
|
||||
# Unwrap predictions to get OpenAI-compatible response
|
||||
openai_response: Final = self._unwrap_predictions_response(response_json)
|
||||
|
|
@ -449,7 +461,7 @@ class VertexGemmaConfig(OpenAIGPTConfig):
|
|||
message=f"Request failed: {response.text}",
|
||||
)
|
||||
|
||||
response_json: Final = response.json()
|
||||
response_json: Final = _PREDICTIONS_ENVELOPE.validate_python(response.json())
|
||||
|
||||
# Unwrap predictions to get OpenAI-compatible response
|
||||
openai_response: Final = self._unwrap_predictions_response(response_json)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from types import UnionType
|
|||
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, Union, get_args, get_origin
|
||||
|
||||
import httpx
|
||||
from pydantic import ConfigDict, TypeAdapter
|
||||
from pydantic import fields as pyd_fields
|
||||
|
||||
import litellm
|
||||
|
|
@ -37,6 +38,8 @@ if TYPE_CHECKING:
|
|||
else:
|
||||
LiteLLMLoggingObj = Any
|
||||
|
||||
_RESPONSE_BODY: Final = TypeAdapter(dict[str, object], config=ConfigDict(strict=True))
|
||||
|
||||
|
||||
class _EventModelClass(Protocol):
|
||||
@property
|
||||
|
|
@ -420,7 +423,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
|
||||
@staticmethod
|
||||
def _parsed_response_body(raw_response: httpx.Response) -> dict[str, object]:
|
||||
return raw_response.json()
|
||||
return _RESPONSE_BODY.validate_python(raw_response.json())
|
||||
|
||||
@staticmethod
|
||||
def _annotation_origin(annotation: object) -> object:
|
||||
|
|
|
|||
|
|
@ -21589,7 +21589,13 @@
|
|||
],
|
||||
"supports_vision": true,
|
||||
"supports_audio_input": true,
|
||||
"supports_video_input": true
|
||||
"supports_video_input": true,
|
||||
"input_cost_per_token_priority": 5.6252e-07,
|
||||
"output_cost_per_token_priority": 3.37498e-06,
|
||||
"cache_read_input_token_cost_priority": 5.628e-08,
|
||||
"cache_creation_input_token_cost_priority": 5.6252e-07,
|
||||
"input_cost_per_audio_token_priority": 1.12497e-06,
|
||||
"cache_read_input_audio_token_cost_priority": 1.1249e-07
|
||||
},
|
||||
"databricks/databricks-gemini-3-1-flash-image": {
|
||||
"cache_creation_input_token_cost": 6.2503e-07,
|
||||
|
|
@ -21676,7 +21682,14 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_video_input": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 4.50002e-06,
|
||||
"output_cost_per_token_priority": 2.699998e-05,
|
||||
"cache_read_input_token_cost_priority": 4.5003e-07,
|
||||
"cache_creation_input_token_cost_priority": 4.50002e-06,
|
||||
"input_cost_per_token_above_200k_tokens_priority": 8.99997e-06,
|
||||
"output_cost_per_token_above_200k_tokens_priority": 4.049997e-05,
|
||||
"cache_read_input_token_cost_above_200k_tokens_priority": 8.9999e-07
|
||||
},
|
||||
"databricks/databricks-gemini-3-flash": {
|
||||
"cache_creation_input_token_cost": 6.2503e-07,
|
||||
|
|
@ -21710,7 +21723,13 @@
|
|||
],
|
||||
"supports_vision": true,
|
||||
"supports_audio_input": true,
|
||||
"supports_video_input": true
|
||||
"supports_video_input": true,
|
||||
"input_cost_per_token_priority": 1.12497e-06,
|
||||
"output_cost_per_token_priority": 6.75003e-06,
|
||||
"cache_read_input_token_cost_priority": 1.1249e-07,
|
||||
"cache_creation_input_token_cost_priority": 1.12497e-06,
|
||||
"input_cost_per_audio_token_priority": 2.25001e-06,
|
||||
"cache_read_input_audio_token_cost_priority": 2.2498e-07
|
||||
},
|
||||
"databricks/databricks-gemini-3-pro": {
|
||||
"cache_creation_input_token_cost": 2.49998e-06,
|
||||
|
|
@ -21757,7 +21776,11 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 1.35002e-06,
|
||||
"output_cost_per_token_priority": 6.75003e-06,
|
||||
"cache_read_input_token_cost_priority": 1.3503e-07,
|
||||
"cache_creation_input_token_cost_priority": 1.35002e-06
|
||||
},
|
||||
"databricks/databricks-gemini-3-7-flash": {
|
||||
"cache_creation_input_token_cost": 7.4998e-07,
|
||||
|
|
@ -21785,10 +21808,14 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 1.35002e-06,
|
||||
"output_cost_per_token_priority": 6.75003e-06,
|
||||
"cache_read_input_token_cost_priority": 1.3503e-07,
|
||||
"cache_creation_input_token_cost_priority": 1.35002e-06
|
||||
},
|
||||
"databricks/databricks-gemini-3-6-flash": {
|
||||
"cache_creation_input_token_cost": 1.87502e-06,
|
||||
"cache_creation_input_token_cost": 7.4998e-07,
|
||||
"cache_read_input_token_cost": 7.497e-08,
|
||||
"input_cost_per_token": 7.4998e-07,
|
||||
"input_dbu_cost_per_token": 1.0714e-05,
|
||||
|
|
@ -21816,7 +21843,11 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 1.35002e-06,
|
||||
"output_cost_per_token_priority": 6.75003e-06,
|
||||
"cache_read_input_token_cost_priority": 1.3503e-07,
|
||||
"cache_creation_input_token_cost_priority": 1.35002e-06
|
||||
},
|
||||
"databricks/databricks-gemini-3-5-flash": {
|
||||
"cache_creation_input_token_cost": 1.87502e-06,
|
||||
|
|
@ -21846,7 +21877,11 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 3.37498e-06,
|
||||
"output_cost_per_token_priority": 2.025002e-05,
|
||||
"cache_read_input_token_cost_priority": 3.3747e-07,
|
||||
"cache_creation_input_token_cost_priority": 3.37498e-06
|
||||
},
|
||||
"databricks/databricks-gemini-3-5-flash-lite": {
|
||||
"cache_creation_input_token_cost": 3.7499e-07,
|
||||
|
|
@ -21878,7 +21913,11 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 6.7501e-07,
|
||||
"output_cost_per_token_priority": 5.62499e-06,
|
||||
"cache_read_input_token_cost_priority": 6.748e-08,
|
||||
"cache_creation_input_token_cost_priority": 6.7501e-07
|
||||
},
|
||||
"databricks/databricks-gemma-3-12b": {
|
||||
"cache_creation_input_token_cost": 1.5001e-07,
|
||||
|
|
@ -21921,7 +21960,11 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false
|
||||
"supports_vision": false,
|
||||
"input_cost_per_token_priority": 2.45e-06,
|
||||
"output_cost_per_token_priority": 7.7e-06,
|
||||
"cache_read_input_token_cost_priority": 4.55e-07,
|
||||
"cache_creation_input_token_cost_priority": 2.45e-06
|
||||
},
|
||||
"databricks/databricks-glm-5-3": {
|
||||
"cache_creation_input_token_cost": 1.4e-06,
|
||||
|
|
@ -21951,7 +21994,11 @@
|
|||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false,
|
||||
"thinking_always_on": true
|
||||
"thinking_always_on": true,
|
||||
"input_cost_per_token_priority": 2.45e-06,
|
||||
"output_cost_per_token_priority": 7.7e-06,
|
||||
"cache_read_input_token_cost_priority": 4.55e-07,
|
||||
"cache_creation_input_token_cost_priority": 2.45e-06
|
||||
},
|
||||
"databricks/databricks-glm-5-3-flash": {
|
||||
"cache_creation_input_token_cost": 1.5001e-07,
|
||||
|
|
@ -21980,7 +22027,11 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"thinking_always_on": true
|
||||
"thinking_always_on": true,
|
||||
"input_cost_per_token_priority": 2.625e-07,
|
||||
"output_cost_per_token_priority": 8.75e-07,
|
||||
"cache_read_input_token_cost_priority": 5.25e-08,
|
||||
"cache_creation_input_token_cost_priority": 2.625e-07
|
||||
},
|
||||
"databricks/databricks-gpt-5": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
|
|
@ -21999,7 +22050,11 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
"supports_tool_choice": true,
|
||||
"input_cost_per_token_priority": 2.49998e-06,
|
||||
"output_cost_per_token_priority": 1.999998e-05,
|
||||
"cache_read_input_token_cost_priority": 2.4997e-07,
|
||||
"cache_creation_input_token_cost_priority": 2.49998e-06
|
||||
},
|
||||
"databricks/databricks-gpt-5-1": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
|
|
@ -22018,7 +22073,11 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
"supports_tool_choice": true,
|
||||
"input_cost_per_token_priority": 2.49998e-06,
|
||||
"output_cost_per_token_priority": 1.999998e-05,
|
||||
"cache_read_input_token_cost_priority": 2.4997e-07,
|
||||
"cache_creation_input_token_cost_priority": 2.49998e-06
|
||||
},
|
||||
"databricks/databricks-gpt-5-2": {
|
||||
"cache_creation_input_token_cost": 1.75e-06,
|
||||
|
|
@ -22037,7 +22096,11 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
"supports_tool_choice": true,
|
||||
"input_cost_per_token_priority": 3.5e-06,
|
||||
"output_cost_per_token_priority": 2.8e-05,
|
||||
"cache_read_input_token_cost_priority": 3.5e-07,
|
||||
"cache_creation_input_token_cost_priority": 3.5e-06
|
||||
},
|
||||
"databricks/databricks-gpt-5-3-codex": {
|
||||
"cache_creation_input_token_cost": 1.75e-06,
|
||||
|
|
@ -22056,7 +22119,11 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
"supports_tool_choice": true,
|
||||
"input_cost_per_token_priority": 3.5e-06,
|
||||
"output_cost_per_token_priority": 2.8e-05,
|
||||
"cache_read_input_token_cost_priority": 3.5e-07,
|
||||
"cache_creation_input_token_cost_priority": 3.5e-06
|
||||
},
|
||||
"databricks/databricks-gpt-5-4": {
|
||||
"cache_creation_input_token_cost": 2.49998e-06,
|
||||
|
|
@ -22087,7 +22154,14 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 5.00003e-06,
|
||||
"output_cost_per_token_priority": 2.999997e-05,
|
||||
"cache_read_input_token_cost_priority": 5.0001e-07,
|
||||
"cache_creation_input_token_cost_priority": 5.00003e-06,
|
||||
"input_cost_per_token_above_272k_tokens_priority": 5.00003e-06,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 2.999997e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 5.0001e-07
|
||||
},
|
||||
"databricks/databricks-gpt-5-4-mini": {
|
||||
"cache_creation_input_token_cost": 7.4998e-07,
|
||||
|
|
@ -22115,7 +22189,11 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 1.50003e-06,
|
||||
"output_cost_per_token_priority": 8.99997e-06,
|
||||
"cache_read_input_token_cost_priority": 1.5001e-07,
|
||||
"cache_creation_input_token_cost_priority": 1.50003e-06
|
||||
},
|
||||
"databricks/databricks-gpt-5-4-nano": {
|
||||
"cache_creation_input_token_cost": 1.9999e-07,
|
||||
|
|
@ -22175,7 +22253,15 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 8.00002e-06,
|
||||
"output_cost_per_token_priority": 4.000003e-05,
|
||||
"cache_read_input_token_cost_priority": 8.0003e-07,
|
||||
"cache_creation_input_token_cost_priority": 9.99999e-06,
|
||||
"input_cost_per_token_above_272k_tokens_priority": 1.599997e-05,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 6.000001e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 1.59999e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": 1.999998e-05
|
||||
},
|
||||
"databricks/databricks-gpt-5-6-terra": {
|
||||
"cache_creation_input_token_cost": 2.49998e-06,
|
||||
|
|
@ -22207,7 +22293,15 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 4.00001e-06,
|
||||
"output_cost_per_token_priority": 2.399999e-05,
|
||||
"cache_read_input_token_cost_priority": 3.9998e-07,
|
||||
"cache_creation_input_token_cost_priority": 5.00003e-06,
|
||||
"input_cost_per_token_above_272k_tokens_priority": 8.00002e-06,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 3.600002e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 8.0003e-07,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": 9.99999e-06
|
||||
},
|
||||
"databricks/databricks-gpt-5-6-luna": {
|
||||
"cache_creation_input_token_cost": 2.4997e-07,
|
||||
|
|
@ -22239,7 +22333,15 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 3.9998e-07,
|
||||
"output_cost_per_token_priority": 2.40002e-06,
|
||||
"cache_read_input_token_cost_priority": 3.997e-08,
|
||||
"cache_creation_input_token_cost_priority": 5.0001e-07,
|
||||
"input_cost_per_token_above_272k_tokens_priority": 8.0003e-07,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 3.60003e-06,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 8.001e-08,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": 1.00002e-06
|
||||
},
|
||||
"databricks/databricks-gpt-5-5": {
|
||||
"cache_creation_input_token_cost": 5.00003e-06,
|
||||
|
|
@ -22270,7 +22372,14 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 1.249997e-05,
|
||||
"output_cost_per_token_priority": 7.50001e-05,
|
||||
"cache_read_input_token_cost_priority": 1.24999e-06,
|
||||
"cache_creation_input_token_cost_priority": 1.249997e-05,
|
||||
"input_cost_per_token_above_272k_tokens_priority": 1.249997e-05,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 7.50001e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 1.24999e-06
|
||||
},
|
||||
"databricks/databricks-gpt-5-5-pro": {
|
||||
"cache_creation_input_token_cost": 2.999997e-05,
|
||||
|
|
@ -22319,7 +22428,11 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
"supports_tool_choice": true,
|
||||
"input_cost_per_token_priority": 4.5003e-07,
|
||||
"output_cost_per_token_priority": 3.60003e-06,
|
||||
"cache_read_input_token_cost_priority": 4.501e-08,
|
||||
"cache_creation_input_token_cost_priority": 4.5003e-07
|
||||
},
|
||||
"databricks/databricks-gpt-5-nano": {
|
||||
"cache_creation_input_token_cost": 4.998e-08,
|
||||
|
|
@ -22469,7 +22582,11 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 5.25e-06,
|
||||
"output_cost_per_token_priority": 2.625e-05,
|
||||
"cache_read_input_token_cost_priority": 5.25e-07,
|
||||
"cache_creation_input_token_cost_priority": 5.25e-06
|
||||
},
|
||||
"databricks/system.ai.kimi-k3": {
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -22551,7 +22668,11 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false,
|
||||
"thinking_always_on": true
|
||||
"thinking_always_on": true,
|
||||
"input_cost_per_token_priority": 3.85e-07,
|
||||
"output_cost_per_token_priority": 3.85e-06,
|
||||
"cache_read_input_token_cost_priority": 3.85e-07,
|
||||
"cache_creation_input_token_cost_priority": 3.85e-07
|
||||
},
|
||||
"databricks/databricks-qwen3-next-80b-a3b-instruct": {
|
||||
"cache_creation_input_token_cost": 1.5001e-07,
|
||||
|
|
@ -28732,6 +28853,7 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.039,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_image_token_batches": 1.5e-05,
|
||||
"output_cost_per_reasoning_token": 2.5e-06,
|
||||
"output_cost_per_token": 2.5e-06,
|
||||
"output_cost_per_token_batches": 1.25e-06,
|
||||
|
|
@ -28789,6 +28911,7 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.134,
|
||||
"output_cost_per_image_token": 0.00012,
|
||||
"output_cost_per_image_token_batches": 6e-05,
|
||||
"output_cost_per_token": 1.2e-05,
|
||||
"output_cost_per_token_above_200k_tokens": 1.8e-05,
|
||||
"output_cost_per_token_above_200k_tokens_priority": 3.24e-05,
|
||||
|
|
@ -28842,12 +28965,13 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.134,
|
||||
"output_cost_per_image_token": 0.00012,
|
||||
"output_cost_per_image_token_batches": 6e-05,
|
||||
"output_cost_per_token": 1.2e-05,
|
||||
"output_cost_per_token_priority": 2.16e-05,
|
||||
"output_cost_per_token_above_200k_tokens": 1.8e-05,
|
||||
"output_cost_per_token_above_200k_tokens_priority": 3.24e-05,
|
||||
"output_cost_per_token_batches": 6e-06,
|
||||
"source": "https://ai.google.dev/gemini-api/docs/pricing",
|
||||
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/images/generations",
|
||||
|
|
@ -28893,6 +29017,7 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0672,
|
||||
"output_cost_per_image_token": 6e-05,
|
||||
"output_cost_per_image_token_batches": 3e-05,
|
||||
"output_cost_per_token": 3e-06,
|
||||
"output_cost_per_token_batches": 1.5e-06,
|
||||
"output_cost_per_token_flex": 1.5e-06,
|
||||
|
|
@ -28938,9 +29063,10 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0672,
|
||||
"output_cost_per_image_token": 6e-05,
|
||||
"output_cost_per_image_token_batches": 3e-05,
|
||||
"output_cost_per_token": 3e-06,
|
||||
"output_cost_per_token_batches": 1.5e-06,
|
||||
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
|
||||
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/images/generations",
|
||||
|
|
@ -28985,6 +29111,7 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_image_token_batches": 1.5e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"output_cost_per_token_batches": 7.5e-07,
|
||||
"output_cost_per_token_flex": 7.5e-07,
|
||||
|
|
@ -29551,10 +29678,12 @@
|
|||
},
|
||||
"search_free_requests": {"count": 10000, "period": "day", "shared_group": "gemini-2.5-pro"},
|
||||
"google_maps_grounding_cost_per_query": 0.025,
|
||||
"input_cost_per_token_above_200k_tokens_batches": 1.25e-06,
|
||||
"input_cost_per_token_above_200k_tokens_priority": 4.5e-06,
|
||||
"input_cost_per_token_batches": 6.25e-07,
|
||||
"input_cost_per_token_flex": 6.25e-07,
|
||||
"input_cost_per_token_priority": 2.25e-06,
|
||||
"output_cost_per_token_above_200k_tokens_batches": 7.5e-06,
|
||||
"output_cost_per_token_above_200k_tokens_priority": 2.7e-05,
|
||||
"output_cost_per_token_batches": 5e-06,
|
||||
"output_cost_per_token_flex": 5e-06,
|
||||
|
|
@ -29569,6 +29698,7 @@
|
|||
"google_maps_grounding_cost_per_query": 0.014,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"input_cost_per_token_above_200k_tokens": 4e-06,
|
||||
"input_cost_per_token_above_200k_tokens_batches": 2e-06,
|
||||
"input_cost_per_token_above_200k_tokens_priority": 7.2e-06,
|
||||
"input_cost_per_token_batches": 1e-06,
|
||||
"input_cost_per_token_flex": 1e-06,
|
||||
|
|
@ -29580,6 +29710,7 @@
|
|||
"output_cost_per_image": 0.00012,
|
||||
"output_cost_per_token": 1.2e-05,
|
||||
"output_cost_per_token_above_200k_tokens": 1.8e-05,
|
||||
"output_cost_per_token_above_200k_tokens_batches": 9e-06,
|
||||
"output_cost_per_token_above_200k_tokens_priority": 3.24e-05,
|
||||
"output_cost_per_token_batches": 6e-06,
|
||||
"output_cost_per_token_flex": 6e-06,
|
||||
|
|
@ -30108,6 +30239,7 @@
|
|||
"google_maps_grounding_cost_per_query": 0.014,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"input_cost_per_token_above_200k_tokens": 4e-06,
|
||||
"input_cost_per_token_above_200k_tokens_batches": 2e-06,
|
||||
"input_cost_per_token_above_200k_tokens_priority": 7.2e-06,
|
||||
"input_cost_per_token_batches": 1e-06,
|
||||
"input_cost_per_token_flex": 1e-06,
|
||||
|
|
@ -30119,6 +30251,7 @@
|
|||
"output_cost_per_image": 0.00012,
|
||||
"output_cost_per_token": 1.2e-05,
|
||||
"output_cost_per_token_above_200k_tokens": 1.8e-05,
|
||||
"output_cost_per_token_above_200k_tokens_batches": 9e-06,
|
||||
"output_cost_per_token_above_200k_tokens_priority": 3.24e-05,
|
||||
"output_cost_per_token_batches": 6e-06,
|
||||
"output_cost_per_token_flex": 6e-06,
|
||||
|
|
@ -51789,6 +51922,7 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.039,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_image_token_batches": 1.5e-05,
|
||||
"output_cost_per_reasoning_token": 2.5e-06,
|
||||
"output_cost_per_token": 2.5e-06,
|
||||
"output_cost_per_token_batches": 1.25e-06,
|
||||
|
|
@ -51847,6 +51981,7 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.134,
|
||||
"output_cost_per_image_token": 0.00012,
|
||||
"output_cost_per_image_token_batches": 6e-05,
|
||||
"output_cost_per_token": 1.2e-05,
|
||||
"output_cost_per_token_above_200k_tokens": 1.8e-05,
|
||||
"output_cost_per_token_above_200k_tokens_priority": 3.24e-05,
|
||||
|
|
@ -51881,13 +52016,14 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.134,
|
||||
"output_cost_per_image_token": 0.00012,
|
||||
"output_cost_per_image_token_batches": 6e-05,
|
||||
"output_cost_per_token": 1.2e-05,
|
||||
"output_cost_per_token_priority": 2.16e-05,
|
||||
"output_cost_per_token_above_200k_tokens": 1.8e-05,
|
||||
"output_cost_per_token_above_200k_tokens_priority": 3.24e-05,
|
||||
"output_cost_per_token_batches": 6e-06,
|
||||
"supports_reasoning": false,
|
||||
"source": "https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image",
|
||||
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/images/generations",
|
||||
|
|
@ -51913,6 +52049,7 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0672,
|
||||
"output_cost_per_image_token": 6e-05,
|
||||
"output_cost_per_image_token_batches": 3e-05,
|
||||
"output_cost_per_token": 3e-06,
|
||||
"output_cost_per_token_batches": 1.5e-06,
|
||||
"output_cost_per_token_flex": 1.5e-06,
|
||||
|
|
@ -51940,10 +52077,11 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0672,
|
||||
"output_cost_per_image_token": 6e-05,
|
||||
"output_cost_per_image_token_batches": 3e-05,
|
||||
"output_cost_per_token": 3e-06,
|
||||
"output_cost_per_token_batches": 1.5e-06,
|
||||
"supports_reasoning": false,
|
||||
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
|
||||
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/images/generations",
|
||||
|
|
@ -51968,6 +52106,7 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_image_token_batches": 1.5e-05,
|
||||
"output_cost_per_token": 1.5e-06,
|
||||
"output_cost_per_token_batches": 7.5e-07,
|
||||
"output_cost_per_token_flex": 7.5e-07,
|
||||
|
|
@ -61865,6 +62004,39 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_vision": false
|
||||
},
|
||||
"tencent/seedream-image-v5.0-flash": {
|
||||
"litellm_provider": "tencent",
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.018,
|
||||
"output_cost_per_image_token": 1.6e-06,
|
||||
"source": "https://cloud.tencent.com/document/product/1823/130055",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"tencent/seedream-image-v5.0-lite": {
|
||||
"litellm_provider": "tencent",
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.035,
|
||||
"output_cost_per_image_token": 1.6e-06,
|
||||
"source": "https://www.tencentcloud.com/document/product/1300/78937",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"tencent/seedream-image-v5.0-pro": {
|
||||
"litellm_provider": "tencent",
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.09,
|
||||
"output_cost_per_image_token": 1.6e-06,
|
||||
"source": "https://www.tencentcloud.com/document/product/1300/78937",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"cognition/swe-1.6": {
|
||||
"input_cost_per_token": 5e-07,
|
||||
"output_cost_per_token": 2.5e-06,
|
||||
|
|
@ -74246,6 +74418,7 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_image_token_batches": 1.5e-05,
|
||||
"output_cost_per_token": 7.5e-06,
|
||||
"output_cost_per_token_batches": 3.75e-06,
|
||||
"output_cost_per_token_priority": 1.35e-05,
|
||||
|
|
@ -74291,6 +74464,7 @@
|
|||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.0336,
|
||||
"output_cost_per_image_token": 3e-05,
|
||||
"output_cost_per_image_token_batches": 1.5e-05,
|
||||
"output_cost_per_token": 7.5e-06,
|
||||
"output_cost_per_token_batches": 3.75e-06,
|
||||
"output_cost_per_token_priority": 1.35e-05,
|
||||
|
|
@ -77774,7 +77948,11 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 5.25e-07,
|
||||
"output_cost_per_token_priority": 2.1e-06,
|
||||
"cache_read_input_token_cost_priority": 5.25e-08,
|
||||
"cache_creation_input_token_cost_priority": 5.25e-07
|
||||
},
|
||||
"databricks/databricks-deepseek-v4-1-flash-1017": {
|
||||
"cache_creation_input_token_cost": 3.0002e-07,
|
||||
|
|
@ -77897,7 +78075,11 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_video_input": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 4.50002e-06,
|
||||
"output_cost_per_token_priority": 2.250003e-05,
|
||||
"cache_read_input_token_cost_priority": 2.2498e-07,
|
||||
"cache_creation_input_token_cost_priority": 4.50002e-06
|
||||
},
|
||||
"databricks/databricks-gpt-5-4-pro": {
|
||||
"cache_creation_input_token_cost": 2.999997e-05,
|
||||
|
|
@ -77959,7 +78141,15 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 4.00001e-06,
|
||||
"output_cost_per_token_priority": 1.99997e-05,
|
||||
"cache_read_input_token_cost_priority": 1.9999e-07,
|
||||
"cache_creation_input_token_cost_priority": 5.00003e-06,
|
||||
"input_cost_per_token_above_272k_tokens_priority": 8.00002e-06,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 2.99999e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 3.9998e-07,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": 9.99999e-06
|
||||
},
|
||||
"databricks/databricks-gpt-6-1-terra": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
|
|
@ -77991,7 +78181,15 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 1.99997e-06,
|
||||
"output_cost_per_token_priority": 9.99999e-06,
|
||||
"cache_read_input_token_cost_priority": 1.0003e-07,
|
||||
"cache_creation_input_token_cost_priority": 2.49998e-06,
|
||||
"input_cost_per_token_above_272k_tokens_priority": 4.00001e-06,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 1.500002e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 1.9999e-07,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": 5.00003e-06
|
||||
},
|
||||
"databricks/databricks-gpt-6-astra": {
|
||||
"cache_creation_input_token_cost": 1.249997e-05,
|
||||
|
|
@ -78023,7 +78221,15 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 1.999998e-05,
|
||||
"output_cost_per_token_priority": 9.99999e-05,
|
||||
"cache_read_input_token_cost_priority": 1.99997e-06,
|
||||
"cache_creation_input_token_cost_priority": 2.500001e-05,
|
||||
"input_cost_per_token_above_272k_tokens_priority": 4.000003e-05,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 0.0001500002,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 4.00001e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": 5.000002e-05
|
||||
},
|
||||
"databricks/databricks-gpt-6-luna": {
|
||||
"cache_creation_input_token_cost": 1.2502e-07,
|
||||
|
|
@ -78055,7 +78261,15 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 1.9999e-07,
|
||||
"output_cost_per_token_priority": 1.0003e-06,
|
||||
"cache_read_input_token_cost_priority": 2.002e-08,
|
||||
"cache_creation_input_token_cost_priority": 2.4997e-07,
|
||||
"input_cost_per_token_above_272k_tokens_priority": 3.9998e-07,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 1.5001e-06,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 3.997e-08,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": 5.0001e-07
|
||||
},
|
||||
"databricks/databricks-gpt-6-sol": {
|
||||
"cache_creation_input_token_cost": 2.49998e-06,
|
||||
|
|
@ -78087,7 +78301,15 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"input_cost_per_token_priority": 4.00001e-06,
|
||||
"output_cost_per_token_priority": 1.99997e-05,
|
||||
"cache_read_input_token_cost_priority": 3.9998e-07,
|
||||
"cache_creation_input_token_cost_priority": 5.00003e-06,
|
||||
"input_cost_per_token_above_272k_tokens_priority": 8.00002e-06,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 2.99999e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 8.0003e-07,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": 9.99999e-06
|
||||
},
|
||||
"databricks/databricks-gpt-image-1": {
|
||||
"cache_creation_input_token_cost": 5.00003e-06,
|
||||
|
|
|
|||
|
|
@ -3793,8 +3793,8 @@
|
|||
"responses": true,
|
||||
"text_completion": true,
|
||||
"embeddings": false,
|
||||
"image_generations": false,
|
||||
"image_edits": false,
|
||||
"image_generations": true,
|
||||
"image_edits": true,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"rerank": false,
|
||||
|
|
|
|||
|
|
@ -3487,6 +3487,13 @@
|
|||
"title": "Entities",
|
||||
"type": "object"
|
||||
},
|
||||
"guardrails": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/GuardrailMetric"
|
||||
},
|
||||
"title": "Guardrails",
|
||||
"type": "object"
|
||||
},
|
||||
"mcp_servers": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/MetricWithMetadata"
|
||||
|
|
@ -3802,6 +3809,74 @@
|
|||
"title": "ExportType",
|
||||
"type": "string"
|
||||
},
|
||||
"GuardrailKeyMetric": {
|
||||
"properties": {
|
||||
"metadata": {
|
||||
"$ref": "#/components/schemas/KeyMetadata"
|
||||
},
|
||||
"metrics": {
|
||||
"$ref": "#/components/schemas/GuardrailSpendMetrics"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"metrics",
|
||||
"metadata"
|
||||
],
|
||||
"title": "GuardrailKeyMetric",
|
||||
"type": "object"
|
||||
},
|
||||
"GuardrailMetadata": {
|
||||
"properties": {
|
||||
"guardrail_name": {
|
||||
"title": "Guardrail Name",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"guardrail_name"
|
||||
],
|
||||
"title": "GuardrailMetadata",
|
||||
"type": "object"
|
||||
},
|
||||
"GuardrailMetric": {
|
||||
"properties": {
|
||||
"api_key_breakdown": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/GuardrailKeyMetric"
|
||||
},
|
||||
"title": "Api Key Breakdown",
|
||||
"type": "object"
|
||||
},
|
||||
"metadata": {
|
||||
"$ref": "#/components/schemas/GuardrailMetadata"
|
||||
},
|
||||
"metrics": {
|
||||
"$ref": "#/components/schemas/GuardrailSpendMetrics"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"metrics",
|
||||
"metadata"
|
||||
],
|
||||
"title": "GuardrailMetric",
|
||||
"type": "object"
|
||||
},
|
||||
"GuardrailSpendMetrics": {
|
||||
"properties": {
|
||||
"spend": {
|
||||
"default": 0.0,
|
||||
"title": "Spend",
|
||||
"type": "number"
|
||||
},
|
||||
"untracked_units": {
|
||||
"default": 0,
|
||||
"title": "Untracked Units",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"title": "GuardrailSpendMetrics",
|
||||
"type": "object"
|
||||
},
|
||||
"HTTPAuthSecurityScheme": {
|
||||
"description": "Defines a security scheme using HTTP authentication.",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from litellm._logging import verbose_proxy_logger
|
|||
from litellm.litellm_core_utils.core_helpers import as_str_mapping
|
||||
from litellm.litellm_core_utils.url_utils import SSRFError, validate_url
|
||||
from litellm.llms.a2a.common_utils import resolve_a2a_hop_auth_header
|
||||
from litellm.llms.custom_httpx.sse_lines import aiter_sse_lines
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.a2a.version_convert import (
|
||||
A2AVersion,
|
||||
|
|
@ -320,7 +321,7 @@ async def _a2a_sse_event_source(
|
|||
"error": {"code": -32603, "message": resp.reason_phrase},
|
||||
}
|
||||
return
|
||||
async for line in resp.aiter_lines():
|
||||
async for line in aiter_sse_lines(resp.aiter_text()):
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("data:"):
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ from typing import Final
|
|||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm.llms.custom_httpx.sse_lines import split_sse_lines_keepends
|
||||
|
||||
_MESSAGE_START_EVENT: Final = "message_start"
|
||||
_MESSAGE_START_MARKER: Final = b"message_start"
|
||||
_SSE_DATA_FIELD: Final = "data:"
|
||||
|
|
@ -52,7 +54,7 @@ def _restamped_data_line(line: str, requested_model: str) -> str | None:
|
|||
|
||||
|
||||
def _restamped_frame(frame: str, requested_model: str) -> str | None:
|
||||
lines: Final = frame.splitlines(keepends=True)
|
||||
lines: Final = split_sse_lines_keepends(frame)
|
||||
restamped: Final = tuple(_restamped_data_line(line, requested_model) for line in lines)
|
||||
if all(line is None for line in restamped):
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -370,7 +370,7 @@ class _KeyModelBudgetLimiter(Protocol):
|
|||
|
||||
class _UserModelBudgetLimiter(Protocol):
|
||||
async def is_user_within_model_budget(
|
||||
self, user_id: str, user_model_max_budget: Mapping[str, object], model: str
|
||||
self, user_id: str, user_model_max_budget: Mapping[str, object], model: str, team_id: str | None = None
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
|
|
@ -388,11 +388,15 @@ class _EveryModelBudgetLimiter(Protocol):
|
|||
async def is_key_within_model_budget(self, user_api_key_dict: UserAPIKeyAuth, model: str) -> bool: ...
|
||||
|
||||
async def is_user_within_model_budget(
|
||||
self, user_id: str, user_model_max_budget: Mapping[str, object], model: str
|
||||
self, user_id: str, user_model_max_budget: Mapping[str, object], model: str, team_id: str | None = None
|
||||
) -> bool: ...
|
||||
|
||||
async def is_end_user_within_model_budget(
|
||||
self, end_user_id: str, end_user_model_max_budget: Mapping[str, object], model: str
|
||||
self,
|
||||
end_user_id: str,
|
||||
end_user_model_max_budget: Mapping[str, object],
|
||||
model: str,
|
||||
team_id: str | None = None,
|
||||
) -> bool: ...
|
||||
|
||||
async def is_team_within_model_budget(
|
||||
|
|
@ -503,6 +507,7 @@ async def _check_user_model_budget(
|
|||
user_id=valid_token.user_id,
|
||||
user_model_max_budget=user_model_max_budget,
|
||||
model=model_name,
|
||||
team_id=valid_token.team_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -547,6 +552,7 @@ async def _is_within_every_model_budget(
|
|||
user_id=valid_token.user_id,
|
||||
user_model_max_budget=user_model_max_budget,
|
||||
model=model,
|
||||
team_id=valid_token.team_id,
|
||||
)
|
||||
if (
|
||||
valid_token.end_user_id is not None
|
||||
|
|
@ -557,6 +563,7 @@ async def _is_within_every_model_budget(
|
|||
end_user_id=valid_token.end_user_id,
|
||||
end_user_model_max_budget=end_user_model_max_budget,
|
||||
model=model,
|
||||
team_id=valid_token.team_id,
|
||||
)
|
||||
if valid_token.team_id is not None and isinstance(team_model_max_budget, Mapping) and team_model_max_budget:
|
||||
await model_max_budget_limiter.is_team_within_model_budget(
|
||||
|
|
@ -3154,6 +3161,7 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
|
|||
end_user_id=valid_token.end_user_id,
|
||||
end_user_model_max_budget=end_user_mmb,
|
||||
model=model_name,
|
||||
team_id=valid_token.team_id,
|
||||
)
|
||||
|
||||
# Check 6: Additional Common Checks across jwt + key auth
|
||||
|
|
@ -4733,6 +4741,7 @@ async def _run_post_custom_auth_checks(
|
|||
end_user_id=valid_token.end_user_id,
|
||||
end_user_model_max_budget=end_user_mmb,
|
||||
model=model_name,
|
||||
team_id=valid_token.team_id,
|
||||
)
|
||||
|
||||
# team / user / end_user / project context objects are fetched by
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ from collections.abc import Mapping
|
|||
from typing import Any, Final
|
||||
|
||||
import requests
|
||||
from pydantic import ConfigDict, TypeAdapter
|
||||
|
||||
from .exceptions import NotFoundError, UnauthorizedError
|
||||
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, object], config=ConfigDict(strict=True))
|
||||
|
||||
|
||||
class ModelsManagementClient:
|
||||
def __init__(self, base_url: str, api_key: str | None = None, timeout: int = 30):
|
||||
|
|
@ -109,7 +112,7 @@ class ModelsManagementClient:
|
|||
try:
|
||||
response: Final = session.send(request.prepare(), timeout=self._timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return _JSON_OBJECT.validate_python(response.json())
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 401:
|
||||
raise UnauthorizedError(e)
|
||||
|
|
@ -145,7 +148,7 @@ class ModelsManagementClient:
|
|||
try:
|
||||
response: Final = session.send(request.prepare(), timeout=self._timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return _JSON_OBJECT.validate_python(response.json())
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 401:
|
||||
raise UnauthorizedError(e)
|
||||
|
|
@ -287,7 +290,7 @@ class ModelsManagementClient:
|
|||
try:
|
||||
response: Final = session.send(request.prepare(), timeout=self._timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return _JSON_OBJECT.validate_python(response.json())
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 401:
|
||||
raise UnauthorizedError(e)
|
||||
|
|
|
|||
|
|
@ -139,8 +139,8 @@ from litellm.proxy.utils import ( # noqa: F401 # legacy module exports
|
|||
)
|
||||
from litellm.responses.streaming_iterator import partial_responses_incomplete_event_on_disconnect, spawn_logging_task
|
||||
from litellm.router import Router
|
||||
from litellm.router_strategy.auto_router.auto_router_model_naming import AUTO_ROUTER_MODEL_PREFIX
|
||||
from litellm.router_utils.add_retry_fallback_headers import get_hidden_params_dict
|
||||
from litellm.router_utils.auto_router_model_naming import AUTO_ROUTER_MODEL_PREFIX
|
||||
from litellm.router_utils.common_utils import resolve_model_group_alias, resolve_served_model
|
||||
from litellm.types.extraction import ExtractionResponse
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
|
|
@ -266,6 +266,8 @@ from litellm.proxy.anthropic_endpoints.streaming_model_restamp import (
|
|||
AnthropicStreamModelRestamper,
|
||||
)
|
||||
from litellm.proxy.litellm_pre_call_utils import (
|
||||
ROUTER_SCHEDULED_ROUTE_TYPES,
|
||||
SCHEDULER_PRIORITY_HEADER,
|
||||
LiteLLMProxyRequestSetup,
|
||||
add_litellm_data_to_request,
|
||||
get_metadata_variable_name,
|
||||
|
|
@ -2478,14 +2480,20 @@ class ProxyBaseLLMRequestProcessing:
|
|||
self.data["model"] = alias_target
|
||||
|
||||
aliased_model: Final = self.data.get("model")
|
||||
if llm_router is not None and route_type in ROUTER_SCHEDULED_ROUTE_TYPES and isinstance(aliased_model, str):
|
||||
self.data.update(
|
||||
llm_router.priority_header_update(
|
||||
model=aliased_model, kwargs=self.data, header=request.headers.get(SCHEDULER_PRIORITY_HEADER)
|
||||
)
|
||||
)
|
||||
|
||||
litellm_call_id: Final = resolve_litellm_call_id(request.headers.get("x-litellm-call-id"))
|
||||
self.data["litellm_call_id"] = litellm_call_id
|
||||
bind_telemetry_call_id(litellm_call_id)
|
||||
DDSpanTagger.tag_call_id(self.data.get("litellm_call_id"))
|
||||
DDSpanTagger.tag_call_id(litellm_call_id)
|
||||
DDSpanTagger.tag_request(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
requested_model=self.data.get("model"),
|
||||
requested_model=aliased_model,
|
||||
)
|
||||
|
||||
### AUTO STREAM USAGE TRACKING ###
|
||||
|
|
|
|||
269
litellm/proxy/db/accounting_ledger.py
Normal file
269
litellm/proxy/db/accounting_ledger.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import AwareDatetime, ConfigDict, JsonValue, TypeAdapter
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from litellm.accounting import CostEvent, DeliveryTarget, EventKind, InboundUsageEvent, OutboundUsageEvent
|
||||
from litellm.proxy.db.db_span import db_span
|
||||
from litellm.proxy.db.routing_prisma_wrapper import writer_wrapper
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma import Prisma
|
||||
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
||||
AccountingEvent: TypeAlias = InboundUsageEvent | OutboundUsageEvent | CostEvent
|
||||
_JSON: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_INSTANT: Final[TypeAdapter[datetime]] = TypeAdapter(AwareDatetime)
|
||||
_STRINGS: Final = TypeAdapter(tuple[dict[str, str], ...])
|
||||
_TIMEOUT: Final = timedelta(seconds=10)
|
||||
|
||||
|
||||
class AccountingConflictError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
_Transaction: TypeAlias = AbstractAsyncContextManager["Prisma"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _AcceptedEvent:
|
||||
event_id: str
|
||||
kind: EventKind
|
||||
source_event_id: str | None
|
||||
occurred_at: datetime
|
||||
payload: str
|
||||
payload_hash: str
|
||||
targets: tuple[str, ...]
|
||||
schema_version: int
|
||||
version: int
|
||||
|
||||
|
||||
def _json(value: object) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def _prepare(
|
||||
event: AccountingEvent,
|
||||
targets: tuple[DeliveryTarget, ...],
|
||||
envelope: str,
|
||||
schema_version: int,
|
||||
version: int,
|
||||
) -> _AcceptedEvent:
|
||||
if type(schema_version) is not int or type(version) is not int or min(schema_version, version) < 1:
|
||||
raise ValueError("Accounting versions must be positive integers")
|
||||
validated: Final = type(event).model_validate_json(event.model_dump_json())
|
||||
payload: Final = _JSON.validate_json(envelope)
|
||||
if payload.get("event") != validated.model_dump(mode="json"):
|
||||
raise ValueError("Accounting envelope must contain the matching public event")
|
||||
destinations: Final = tuple(sorted(frozenset(DeliveryTarget(target).value for target in targets)))
|
||||
serialized: Final = _json(payload)
|
||||
identity: Final = _json((schema_version, version, destinations, payload))
|
||||
kind: Final = (
|
||||
EventKind.COST
|
||||
if isinstance(validated, CostEvent)
|
||||
else EventKind.INBOUND_USAGE
|
||||
if isinstance(validated, InboundUsageEvent)
|
||||
else EventKind.OUTBOUND_USAGE
|
||||
)
|
||||
return _AcceptedEvent(
|
||||
event_id=validated.event_id,
|
||||
kind=kind,
|
||||
source_event_id=validated.usage_event_id if isinstance(validated, CostEvent) else None,
|
||||
occurred_at=validated.occurred_at if isinstance(validated, CostEvent) else validated.ended_at,
|
||||
payload=serialized,
|
||||
payload_hash=hashlib.sha256(identity.encode()).hexdigest(),
|
||||
targets=destinations,
|
||||
schema_version=schema_version,
|
||||
version=version,
|
||||
)
|
||||
|
||||
|
||||
_INSERT_EVENT: Final = """
|
||||
INSERT INTO "LiteLLM_AccountingEvent"
|
||||
(event_id, kind, source_event_id, schema_version, payload_hash, occurred_at, payload)
|
||||
VALUES ($1, $2::"AccountingEventKind", $3, $4::int, $5, $6::timestamp, $7::jsonb)
|
||||
ON CONFLICT (event_id) DO NOTHING RETURNING event_id
|
||||
"""
|
||||
_INSERT_DELIVERIES: Final = """
|
||||
INSERT INTO "LiteLLM_AccountingDelivery" (event_id, target, version)
|
||||
SELECT $1, value::"AccountingDeliveryTarget", $3::int FROM jsonb_array_elements_text($2::jsonb)
|
||||
"""
|
||||
_LOCK_SOURCE: Final = """
|
||||
SELECT event_id FROM "LiteLLM_AccountingEvent"
|
||||
WHERE event_id = $1 AND kind = 'outbound_usage' FOR UPDATE
|
||||
"""
|
||||
_EXISTING_COST: Final = """
|
||||
SELECT event_id FROM "LiteLLM_AccountingEvent" WHERE kind = 'cost' AND source_event_id = $1
|
||||
"""
|
||||
_LOCK_EVENT: Final = """
|
||||
SELECT payload_hash FROM "LiteLLM_AccountingEvent" WHERE event_id = $1 FOR UPDATE
|
||||
"""
|
||||
_COMPLETED_FAMILY: Final = """
|
||||
root.source_event_id IS NULL AND root.kind IN ('inbound_usage', 'outbound_usage')
|
||||
AND root.recorded_at < $1::timestamp
|
||||
AND (root.kind = 'inbound_usage' OR EXISTS (
|
||||
SELECT 1 FROM "LiteLLM_AccountingEvent" AS cost WHERE cost.source_event_id = root.event_id AND cost.kind = 'cost'
|
||||
))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "LiteLLM_AccountingEvent" AS member
|
||||
WHERE (member.event_id = root.event_id OR (member.kind = 'cost' AND member.source_event_id = root.event_id))
|
||||
AND (member.recorded_at >= $1::timestamp
|
||||
OR EXISTS (SELECT 1 FROM "LiteLLM_AccountingDelivery" AS delivery
|
||||
WHERE delivery.event_id = member.event_id AND delivery.state != 'applied'))
|
||||
)
|
||||
"""
|
||||
_RETENTION_CANDIDATES: Final = (
|
||||
'SELECT root.event_id FROM "LiteLLM_AccountingEvent" AS root WHERE '
|
||||
+ _COMPLETED_FAMILY
|
||||
+ " ORDER BY root.recorded_at, root.event_id LIMIT $2::int FOR UPDATE OF root SKIP LOCKED"
|
||||
)
|
||||
_ELIGIBLE_GROUP: Final = (
|
||||
'SELECT root.event_id FROM "LiteLLM_AccountingEvent" AS root WHERE root.event_id = $2 AND ' + _COMPLETED_FAMILY
|
||||
)
|
||||
_DELETE_DELIVERIES: Final = """
|
||||
DELETE FROM "LiteLLM_AccountingDelivery" WHERE event_id IN (
|
||||
SELECT event_id FROM "LiteLLM_AccountingEvent" WHERE event_id = $1 OR (kind = 'cost' AND source_event_id = $1)
|
||||
)
|
||||
"""
|
||||
_DELETE_EVENTS: Final = """
|
||||
DELETE FROM "LiteLLM_AccountingEvent" WHERE event_id = $1 OR (kind = 'cost' AND source_event_id = $1)
|
||||
"""
|
||||
|
||||
|
||||
_Conflict: TypeAlias = Literal["missing_source", "competing_cost", "identity_conflict"]
|
||||
|
||||
|
||||
async def _accept(db: Prisma, record: _AcceptedEvent) -> Literal["inserted", "duplicate"] | _Conflict:
|
||||
if record.source_event_id is not None:
|
||||
source: Final = _STRINGS.validate_python(await db.query_raw(_LOCK_SOURCE, record.source_event_id))
|
||||
if not source:
|
||||
return "missing_source"
|
||||
costs: Final = _STRINGS.validate_python(tuple(await db.query_raw(_EXISTING_COST, record.source_event_id)))
|
||||
if any(cost["event_id"] != record.event_id for cost in costs):
|
||||
return "competing_cost"
|
||||
inserted: Final = _STRINGS.validate_python(
|
||||
await db.query_raw(
|
||||
_INSERT_EVENT,
|
||||
record.event_id,
|
||||
record.kind.value,
|
||||
record.source_event_id,
|
||||
record.schema_version,
|
||||
record.payload_hash,
|
||||
record.occurred_at.astimezone(timezone.utc).isoformat(),
|
||||
record.payload,
|
||||
)
|
||||
)
|
||||
if not inserted:
|
||||
existing: Final = _STRINGS.validate_python(tuple(await db.query_raw(_LOCK_EVENT, record.event_id)))
|
||||
if not existing or existing[0]["payload_hash"] != record.payload_hash:
|
||||
return "identity_conflict"
|
||||
return "duplicate"
|
||||
await db.execute_raw(_INSERT_DELIVERIES, record.event_id, _json(record.targets), record.version)
|
||||
return "inserted"
|
||||
|
||||
|
||||
async def _delete_completed(db: Prisma, event_id: str, cutoff: str) -> int:
|
||||
if not _STRINGS.validate_python(await db.query_raw(_ELIGIBLE_GROUP, cutoff, event_id)):
|
||||
return 0
|
||||
await db.execute_raw(_DELETE_DELIVERIES, event_id)
|
||||
return await db.execute_raw(_DELETE_EVENTS, event_id)
|
||||
|
||||
|
||||
class AccountingLedger:
|
||||
def __init__(self, transaction: Callable[[], _Transaction]) -> None:
|
||||
self._transaction: Final = transaction
|
||||
|
||||
@classmethod
|
||||
def for_client(cls, client: PrismaClient) -> AccountingLedger:
|
||||
from prisma.client import TransactionManager
|
||||
|
||||
managers: Final = TypeAdapter(TransactionManager, config=ConfigDict(arbitrary_types_allowed=True))
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction() -> AsyncGenerator[Prisma]:
|
||||
primary: Final = writer_wrapper(client.db)
|
||||
manager: Final = managers.validate_python(primary.tx(timeout=_TIMEOUT))
|
||||
async with db_span("accounting_ledger", "LiteLLM_AccountingEvent"), manager as db:
|
||||
await db.execute_raw("SET LOCAL statement_timeout = 5000")
|
||||
await db.execute_raw("SET LOCAL lock_timeout = 1000")
|
||||
yield db
|
||||
|
||||
return cls(transaction)
|
||||
|
||||
async def insert_event(
|
||||
self,
|
||||
event: AccountingEvent,
|
||||
targets: tuple[DeliveryTarget, ...],
|
||||
*,
|
||||
envelope: str,
|
||||
schema_version: int = 1,
|
||||
version: int = 1,
|
||||
) -> Literal["inserted", "duplicate"]:
|
||||
"""Accept T5/T6's serialized envelope, containing its public event under `event`.
|
||||
|
||||
The immutable acceptance hash includes the target set and delivery version.
|
||||
Changing either for an accepted event conflicts. T7 must provide a separate
|
||||
add-target or new-version workflow that preserves receipts and prevents
|
||||
duplicate contributions; redelivery cannot silently create or reset work.
|
||||
|
||||
The Prisma adapter opens one interactive transaction per event, including
|
||||
two SET LOCAL statements and a source-row lock for costs. I/O is async,
|
||||
but awaiting this method inline adds commit latency per event, potentially
|
||||
several times per request. T5 owns bounded asynchronous capture and an
|
||||
explicit batching strategy; looping over this method does not batch commits.
|
||||
Success acknowledges commit, independently of HTTP response acknowledgement.
|
||||
"""
|
||||
record: Final = _prepare(event, targets, envelope, schema_version, version)
|
||||
async with self._transaction() as db:
|
||||
result: Final = await _accept(db, record)
|
||||
match result:
|
||||
case "inserted" | "duplicate":
|
||||
return result
|
||||
case "missing_source":
|
||||
raise AccountingConflictError("Cost source must be a durably accepted outbound usage event")
|
||||
case "competing_cost":
|
||||
raise AccountingConflictError("Usage event already has an authoritative cost")
|
||||
case "identity_conflict":
|
||||
raise AccountingConflictError("Event identity conflicts with its accepted content or delivery set")
|
||||
case _:
|
||||
assert_never(result)
|
||||
|
||||
async def purge_completed(
|
||||
self,
|
||||
*,
|
||||
redelivery_before: datetime | None = None,
|
||||
recovery_before: datetime | None = None,
|
||||
limit: int = 100,
|
||||
) -> int:
|
||||
"""Delete at most `limit` completed families once both retention owners permit it.
|
||||
|
||||
T8 must supply a coherent recovery watermark including outstanding holds.
|
||||
Omitting either watermark disables deletion. Each usage row and its cost
|
||||
remain together so a retained charge never loses its source or vice versa.
|
||||
Applied receipts are deleted with the family, ending its retained dedup
|
||||
protection. The caller must guarantee events recorded before
|
||||
redelivery_before cannot be redelivered through supported paths.
|
||||
T8's recovery_before must also protect checkpoints,
|
||||
outstanding holds and unfinished settlements; age alone is insufficient.
|
||||
"""
|
||||
if type(limit) is not int or not 1 <= limit <= 1000:
|
||||
raise ValueError("Accounting retention batch size must be between 1 and 1000")
|
||||
if redelivery_before is None or recovery_before is None:
|
||||
return 0
|
||||
cutoff: Final = min(
|
||||
_INSTANT.validate_python(redelivery_before).astimezone(timezone.utc),
|
||||
_INSTANT.validate_python(recovery_before).astimezone(timezone.utc),
|
||||
).isoformat()
|
||||
async with self._transaction() as db:
|
||||
roots: Final = _STRINGS.validate_python(tuple(await db.query_raw(_RETENTION_CANDIDATES, cutoff, limit)))
|
||||
deleted: Final = tuple([await _delete_completed(db, root["event_id"], cutoff) for root in roots])
|
||||
return sum(deleted)
|
||||
|
|
@ -64,6 +64,16 @@ _ROLL_WINDOW_SPEND_SQL: Final = (
|
|||
"AND window_start < ($4::timestamptz AT TIME ZONE 'UTC')"
|
||||
)
|
||||
|
||||
_OPEN_WINDOW_SPEND_SQL: Final = (
|
||||
'INSERT INTO "LiteLLM_BudgetWindowSpend" '
|
||||
"(entity_type, entity_id, window_duration, window_start, spend, created_at, updated_at) "
|
||||
"VALUES ($1, $2, $3, ($4::timestamptz AT TIME ZONE 'UTC'), 0, "
|
||||
"($4::timestamptz AT TIME ZONE 'UTC'), ($4::timestamptz AT TIME ZONE 'UTC')) "
|
||||
"ON CONFLICT (entity_type, entity_id, window_duration) DO UPDATE SET "
|
||||
"window_start = EXCLUDED.window_start, spend = 0, updated_at = EXCLUDED.updated_at "
|
||||
"WHERE \"LiteLLM_BudgetWindowSpend\".window_start <= ($5::timestamptz AT TIME ZONE 'UTC')"
|
||||
)
|
||||
|
||||
_SEED_FROM_SPEND_LOGS_KEY_SQL: Final = (
|
||||
"SELECT COALESCE(SUM(spend), 0.0) AS total, "
|
||||
"COALESCE(SUM(spend) FILTER (WHERE \"startTime\" < ($3::timestamptz AT TIME ZONE 'UTC')), 0.0) AS before_batch "
|
||||
|
|
@ -389,3 +399,29 @@ async def roll_window_spend_row(
|
|||
to_naive_utc(new_window_start),
|
||||
to_naive_utc(datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
|
||||
async def open_window_spend_row(
|
||||
prisma_client: "PrismaClient",
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
window_duration: str,
|
||||
window_start: datetime,
|
||||
replaces_window_start: datetime | None,
|
||||
) -> None:
|
||||
"""Start a window at `window_start` unless another writer already started one.
|
||||
|
||||
The row is created when it does not exist, and moved onto the new window only
|
||||
while it still holds `replaces_window_start`, the ended window the caller saw.
|
||||
A writer that lost the race leaves the winner's window in place, so reading
|
||||
the row back gives every writer the same window start.
|
||||
"""
|
||||
async with db_span("open_window_spend_row", "LiteLLM_BudgetWindowSpend"):
|
||||
await prisma_client.db.execute_raw(
|
||||
_OPEN_WINDOW_SPEND_SQL,
|
||||
entity_type,
|
||||
entity_id,
|
||||
window_duration,
|
||||
to_naive_utc(window_start),
|
||||
None if replaces_window_start is None else to_naive_utc(replaces_window_start),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -305,6 +305,11 @@ def iter_message_text(data: Mapping[str, object]) -> Iterator[str]:
|
|||
yield from _iter_text_parts_in_content(message.get("content"))
|
||||
|
||||
|
||||
def _is_tool_message(message: object) -> bool:
|
||||
fields: Final = _text_object(message)
|
||||
return fields is not None and str(fields.get("role") or "").lower() == "tool"
|
||||
|
||||
|
||||
def _call_item(item: object) -> tuple[str, object] | None:
|
||||
"""Return ``(field, value)`` for a Responses tool-call input item, else None."""
|
||||
fields: Final = _text_object(item)
|
||||
|
|
@ -316,11 +321,14 @@ def walk_user_text(
|
|||
data: dict[str, object],
|
||||
visit: Callable[[str], str],
|
||||
visit_tool: Callable[[JsonValue], JsonValue] | None = None,
|
||||
*,
|
||||
skip_tool_message: bool = False,
|
||||
) -> int:
|
||||
"""Rewrite every text fragment in place via ``visit``.
|
||||
|
||||
Mutates ``data["messages"]`` and ``data["input"]`` (including tool payloads), ``data["system"]`` and
|
||||
``data["instructions"]``. Returns the number
|
||||
``data["instructions"]``. ``skip_tool_message`` leaves ``role: tool`` messages and ``tool_result``
|
||||
blocks as sent. Returns the number
|
||||
of fragments visited so callers can short-circuit when nothing was
|
||||
inspected.
|
||||
"""
|
||||
|
|
@ -353,6 +361,8 @@ def walk_user_text(
|
|||
|
||||
def _rewrite_part(part: Mapping[str, object], native: bool) -> Mapping[str, object]:
|
||||
nonlocal visited
|
||||
if skip_tool_message and part.get("type") == "tool_result":
|
||||
return part
|
||||
text: Final = _part_text(part)
|
||||
rewritten: Final = {**part, **({"text": visit(text)} if text is not None else {})}
|
||||
if text is not None:
|
||||
|
|
@ -384,18 +394,17 @@ def walk_user_text(
|
|||
return content
|
||||
|
||||
messages: Final = data.get("messages")
|
||||
if isinstance(messages, list):
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
if "content" in message:
|
||||
message["content"] = _rewrite_content(message["content"])
|
||||
calls = message.get("tool_calls")
|
||||
if isinstance(calls, list):
|
||||
for call in calls:
|
||||
function = call.get("function") if isinstance(call, dict) else None
|
||||
if isinstance(function, dict) and "arguments" in function:
|
||||
function["arguments"] = _rewrite_arguments(function["arguments"])
|
||||
for message in _object_list(messages) or ():
|
||||
if (skip_tool_message and _is_tool_message(message)) or not isinstance(message, dict):
|
||||
continue
|
||||
if "content" in message:
|
||||
message["content"] = _rewrite_content(message["content"])
|
||||
calls = message.get("tool_calls")
|
||||
if isinstance(calls, list):
|
||||
for call in calls:
|
||||
function = call.get("function") if isinstance(call, dict) else None
|
||||
if isinstance(function, dict) and "arguments" in function:
|
||||
function["arguments"] = _rewrite_arguments(function["arguments"])
|
||||
|
||||
if "system" in data:
|
||||
data["system"] = _rewrite_content(data["system"]) # rebind-ok: preserve the native hook's in-place contract
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Final
|
|||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket
|
||||
from litellm.router_utils.auto_router_model_naming import AUTO_ROUTER_MODEL_PREFIX
|
||||
from litellm.router_strategy.auto_router.auto_router_model_naming import AUTO_ROUTER_MODEL_PREFIX
|
||||
from litellm.types.utils import GenericGuardrailAPIInputs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail):
|
|||
|
||||
from litellm.types.llms.openai import OpenAIModerationResponse
|
||||
|
||||
return OpenAIModerationResponse(**response.json())
|
||||
return OpenAIModerationResponse.model_validate(response.json())
|
||||
|
||||
def _check_moderation_result(self, moderation_response: "OpenAIModerationResponse") -> None:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from litellm.constants import (
|
|||
HEALTH_CHECK_TIMEOUT_SECONDS,
|
||||
)
|
||||
from litellm.litellm_core_utils.health_check_helpers import native_health_check_mode
|
||||
from litellm.router_utils.auto_router_model_naming import (
|
||||
from litellm.router_strategy.auto_router.auto_router_model_naming import (
|
||||
StrategyRouterDependency,
|
||||
classify_strategy_router_model,
|
||||
strategy_router_dependencies,
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ from litellm.proxy.spend_tracking.savings import (
|
|||
_effective_model_info, # pyright: ignore[reportPrivateUsage] # existing deployment-price owner
|
||||
_proxy_llm_router, # pyright: ignore[reportPrivateUsage] # existing optional proxy-router owner
|
||||
)
|
||||
from litellm.router_strategy.auto_router.baseline_request import baseline_request
|
||||
from litellm.router_strategy.complexity_router.context_compaction import compaction_applied
|
||||
from litellm.router_utils.baseline_request import baseline_request
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
from litellm.types.router import BaselineRouteStamp
|
||||
from litellm.types.utils import CallTypes, ModelInfo, Usage
|
||||
|
|
|
|||
176
litellm/proxy/hooks/model_budget_shared_windows.py
Normal file
176
litellm/proxy/hooks/model_budget_shared_windows.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Final, Protocol, TypeAlias
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.proxy.db.budget_window_spend_writer import open_window_spend_row
|
||||
from litellm.proxy.db.db_transaction_queue.window_spend_update_queue import (
|
||||
WindowSpendTransaction,
|
||||
build_window_spend_transaction,
|
||||
)
|
||||
from litellm.repositories.table_repositories import BudgetWindowSpendRepository
|
||||
from litellm.router_strategy.budget_limiter import budget_window_seconds
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
||||
MODEL_BUDGET_WINDOW_ENTITY_TYPE: Final = "model_max_budget"
|
||||
_READ_TTL_SECONDS: Final = 5
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SharedWindow:
|
||||
started_at: float
|
||||
spend: float
|
||||
|
||||
def is_open(self, budget_duration: str, now: float) -> bool:
|
||||
return now < self.started_at + budget_window_seconds(self.started_at, budget_duration)
|
||||
|
||||
|
||||
class LocalTTLCache(Protocol):
|
||||
def get_cache(self, key: str) -> object: ...
|
||||
|
||||
def set_cache(self, key: str, value: object, *, ttl: float) -> None: ...
|
||||
|
||||
def delete_cache(self, key: str) -> None: ...
|
||||
|
||||
|
||||
class SharedWindows(Protocol):
|
||||
async def read(self, spend_key: str, budget_duration: str) -> SharedWindow | None: ...
|
||||
|
||||
async def open(self, spend_key: str, budget_duration: str, now: float) -> float | None: ...
|
||||
|
||||
async def add(self, spend_key: str, budget_duration: str, started_at: float, spend: float) -> None: ...
|
||||
|
||||
|
||||
PrismaClientSource: TypeAlias = Callable[[], "PrismaClient | None"]
|
||||
WindowSpendSink: TypeAlias = Callable[[WindowSpendTransaction], Awaitable[None]]
|
||||
|
||||
|
||||
def _proxy_prisma_client() -> "PrismaClient | None":
|
||||
try:
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
except ImportError:
|
||||
return None
|
||||
return prisma_client
|
||||
|
||||
|
||||
async def _queue_on_proxy_spend_writer(transaction: WindowSpendTransaction) -> None:
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj
|
||||
|
||||
await proxy_logging_obj.db_spend_update_writer.window_spend_update_queue.add_update(transaction)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Reading:
|
||||
window: SharedWindow | None
|
||||
|
||||
|
||||
class DatabaseSharedWindows:
|
||||
def __init__(
|
||||
self,
|
||||
prisma_client_source: PrismaClientSource = _proxy_prisma_client,
|
||||
sink: WindowSpendSink = _queue_on_proxy_spend_writer,
|
||||
read_ttl_seconds: int = _READ_TTL_SECONDS,
|
||||
) -> None:
|
||||
self._prisma_client_source = prisma_client_source
|
||||
self._sink = sink
|
||||
self._read_ttl_seconds = read_ttl_seconds
|
||||
self._readings: LocalTTLCache = InMemoryCache(max_size_in_memory=10_000, default_ttl=read_ttl_seconds)
|
||||
|
||||
async def read(self, spend_key: str, budget_duration: str) -> SharedWindow | None:
|
||||
cached: Final = self._readings.get_cache(key=spend_key)
|
||||
if isinstance(cached, _Reading):
|
||||
return cached.window
|
||||
window: Final = await self._read_row(spend_key=spend_key, budget_duration=budget_duration)
|
||||
self._readings.set_cache(key=spend_key, value=_Reading(window=window), ttl=self._read_ttl_seconds)
|
||||
return window
|
||||
|
||||
async def open(self, spend_key: str, budget_duration: str, now: float) -> float | None:
|
||||
"""When the window every replica shares started, opening one at `now` if none is open.
|
||||
|
||||
Without a database every window is this replica's own, so it starts at `now`. None means the database did
|
||||
not answer, so this replica cannot know which window the others are in yet.
|
||||
"""
|
||||
prisma_client: Final = self._prisma_client_source()
|
||||
if prisma_client is None:
|
||||
return now
|
||||
try:
|
||||
seen: Final = await _fetch_row(
|
||||
prisma_client=prisma_client, spend_key=spend_key, budget_duration=budget_duration, use_writer=True
|
||||
)
|
||||
if seen is not None and seen.is_open(budget_duration=budget_duration, now=now):
|
||||
return seen.started_at
|
||||
await open_window_spend_row(
|
||||
prisma_client=prisma_client,
|
||||
entity_type=MODEL_BUDGET_WINDOW_ENTITY_TYPE,
|
||||
entity_id=spend_key,
|
||||
window_duration=budget_duration,
|
||||
window_start=datetime.fromtimestamp(now, tz=timezone.utc),
|
||||
replaces_window_start=None
|
||||
if seen is None
|
||||
else datetime.fromtimestamp(seen.started_at, tz=timezone.utc),
|
||||
)
|
||||
opened: Final = await _fetch_row(
|
||||
prisma_client=prisma_client, spend_key=spend_key, budget_duration=budget_duration, use_writer=True
|
||||
)
|
||||
except Exception: # noqa: BLE001 # an unreachable database leaves this replica on its own window
|
||||
verbose_proxy_logger.exception("Unable to open the shared model budget window")
|
||||
return None
|
||||
if opened is None or not opened.is_open(budget_duration=budget_duration, now=now):
|
||||
return None
|
||||
return opened.started_at
|
||||
|
||||
async def add(self, spend_key: str, budget_duration: str, started_at: float, spend: float) -> None:
|
||||
if self._prisma_client_source() is None:
|
||||
return
|
||||
try:
|
||||
await self._sink(
|
||||
build_window_spend_transaction(
|
||||
entity_type=MODEL_BUDGET_WINDOW_ENTITY_TYPE,
|
||||
entity_id=spend_key,
|
||||
window_duration=budget_duration,
|
||||
window_start=datetime.fromtimestamp(started_at, tz=timezone.utc),
|
||||
spend=spend,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001 # spend tracking must never fail the success callback
|
||||
verbose_proxy_logger.debug("Unable to queue shared model budget spend", exc_info=True)
|
||||
|
||||
async def _read_row(self, spend_key: str, budget_duration: str) -> SharedWindow | None:
|
||||
prisma_client: Final = self._prisma_client_source()
|
||||
if prisma_client is None:
|
||||
return None
|
||||
try:
|
||||
return await _fetch_row(
|
||||
prisma_client=prisma_client, spend_key=spend_key, budget_duration=budget_duration, use_writer=False
|
||||
)
|
||||
except Exception: # noqa: BLE001 # an unreadable row leaves enforcement on this replica's own counter
|
||||
verbose_proxy_logger.exception("Unable to read shared model budget spend")
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_row(
|
||||
prisma_client: "PrismaClient", spend_key: str, budget_duration: str, use_writer: bool
|
||||
) -> SharedWindow | None:
|
||||
row: Final = await BudgetWindowSpendRepository(prisma_client, use_writer=use_writer).table.find_unique(
|
||||
where={
|
||||
"entity_type_entity_id_window_duration": {
|
||||
"entity_type": MODEL_BUDGET_WINDOW_ENTITY_TYPE,
|
||||
"entity_id": spend_key,
|
||||
"window_duration": budget_duration,
|
||||
}
|
||||
}
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
window_start: Final = row.window_start
|
||||
started_at: Final = (
|
||||
window_start if window_start.tzinfo is not None else window_start.replace(tzinfo=timezone.utc)
|
||||
).timestamp()
|
||||
return SharedWindow(started_at=started_at, spend=float(row.spend or 0.0))
|
||||
|
||||
|
||||
PROXY_SHARED_WINDOWS: Final = DatabaseSharedWindows()
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -118,6 +118,9 @@ _SESSION_ID_VALUE_RE: Final = re.compile(r"^[a-zA-Z0-9_\-]{8,}$")
|
|||
|
||||
_SHA256_HEX_RE: Final = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
SCHEDULER_PRIORITY_HEADER: Final = "x-litellm-priority"
|
||||
ROUTER_SCHEDULED_ROUTE_TYPES: Final = frozenset({"acompletion", "atext_completion"})
|
||||
|
||||
# W3C Trace Context traceparent header: https://www.w3.org/TR/trace-context/
|
||||
# e.g. "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
|
||||
_TRACEPARENT_RE: Final = re.compile(r"^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$", re.IGNORECASE)
|
||||
|
|
@ -688,7 +691,7 @@ def _metadata_string_tags(metadata_objects: Iterable[Mapping[str, object]]) -> t
|
|||
|
||||
|
||||
def _strip_untrusted_request_header_controls(
|
||||
headers: Any,
|
||||
headers: object,
|
||||
*,
|
||||
allow_client_message_redaction_opt_out: bool = False,
|
||||
) -> None:
|
||||
|
|
@ -1692,7 +1695,7 @@ class LiteLLMProxyRequestSetup:
|
|||
|
||||
@staticmethod
|
||||
def add_internal_user_from_user_mapping(
|
||||
general_settings: dict | None,
|
||||
general_settings: Mapping[str, object] | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
headers: dict,
|
||||
) -> UserAPIKeyAuth:
|
||||
|
|
@ -1711,7 +1714,7 @@ class LiteLLMProxyRequestSetup:
|
|||
return user_api_key_dict
|
||||
|
||||
@staticmethod
|
||||
def get_user_from_headers(headers: dict, general_settings: dict | None = None) -> str | None:
|
||||
def get_user_from_headers(headers: dict, general_settings: Mapping[str, object] | None = None) -> str | None:
|
||||
"""
|
||||
Get the user from the specified header if `general_settings.user_header_name` is set.
|
||||
"""
|
||||
|
|
@ -1732,7 +1735,9 @@ class LiteLLMProxyRequestSetup:
|
|||
return user
|
||||
|
||||
@staticmethod
|
||||
def get_openai_org_id_from_headers(headers: dict, general_settings: dict | None = None) -> str | None:
|
||||
def get_openai_org_id_from_headers(
|
||||
headers: dict, general_settings: Mapping[str, object] | None = None
|
||||
) -> str | None:
|
||||
"""
|
||||
Get the OpenAI Org ID from the headers.
|
||||
"""
|
||||
|
|
@ -1823,7 +1828,7 @@ class LiteLLMProxyRequestSetup:
|
|||
headers: dict,
|
||||
request_data: Mapping[str, object],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
general_settings: dict[str, object] | None = None,
|
||||
general_settings: Mapping[str, object] | None = None,
|
||||
) -> LitellmDataForBackendLLMCall:
|
||||
"""
|
||||
- Adds user from headers
|
||||
|
|
@ -3285,7 +3290,7 @@ def _per_user_credential_names_for_groups(
|
|||
GITHUB_COPILOT_AUTH_TYPE_KEY,
|
||||
GITHUB_COPILOT_PER_USER_AUTH_TYPE,
|
||||
)
|
||||
from litellm.router_utils.auto_router_model_naming import classify_strategy_router_model
|
||||
from litellm.router_strategy.auto_router.auto_router_model_naming import classify_strategy_router_model
|
||||
|
||||
names: Final[list[str]] = [] # mutable-ok: accumulates one name per per-user deployment
|
||||
for group in model_groups:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue