mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
Merge upstream main into fix/bedrock-scheduled-batch-status
This commit is contained in:
commit
93a19ab604
149 changed files with 18970 additions and 504 deletions
|
|
@ -77,6 +77,7 @@ legacy_paths() {
|
|||
echo tests/unit/embeddings
|
||||
echo tests/unit/endpoints
|
||||
echo tests/unit/files
|
||||
echo tests/unit/harness
|
||||
echo tests/unit/images
|
||||
echo tests/unit/interactions
|
||||
echo tests/unit/messages
|
||||
|
|
@ -145,6 +146,7 @@ legacy_paths() {
|
|||
echo tests/unit/proxy/test_proxy_token_counter.py
|
||||
echo tests/unit/proxy/test_server_root_path.py ;;
|
||||
proxy-db-proxy-server-core)
|
||||
echo tests/unit/proxy/test__lazy_features.py
|
||||
echo tests/unit/proxy/test_aproxy_startup.py
|
||||
echo tests/unit/proxy/test_proxy_server.py ;;
|
||||
proxy-db-proxy-utils) echo tests/unit/proxy/test_proxy_utils.py ;;
|
||||
|
|
|
|||
8
.github/ci-coverage-allowlist.yml
vendored
8
.github/ci-coverage-allowlist.yml
vendored
|
|
@ -4,6 +4,14 @@ description: >-
|
|||
by a job nor listed here, so every entry below is a decision on the record.
|
||||
|
||||
test_paths:
|
||||
- reason: >-
|
||||
litellm.agent() end-to-end suite. It drives the real claude, codex and opencode CLIs and
|
||||
deepagents against a live LiteLLM AI Gateway, so it needs those binaries on PATH plus
|
||||
LITELLM_PROXY_API_BASE / LITELLM_PROXY_API_KEY, and skips without them. Run manually
|
||||
before changing litellm/harness; the mocked coverage runs in tests/unit/harness and
|
||||
tests/unit/llms/*/harness
|
||||
paths:
|
||||
- tests/harness_e2e
|
||||
- reason: >-
|
||||
The Rust/Python parity harness is run manually through its local CLI. Recorded replay,
|
||||
fixture generation, and harness checks are intentionally outside pull request CI
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -268,6 +268,31 @@ For MCP OAuth, an upstream may advertise dynamic client registration but refuse
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Agents</b> - Run Claude Code, Codex, OpenCode or Deep Agents on any model (Python SDK)</summary>
|
||||
|
||||
### Python SDK - Agents
|
||||
|
||||
```python
|
||||
import litellm
|
||||
from litellm import Harness, sandbox
|
||||
|
||||
result = litellm.agent(
|
||||
Harness.CLAUDE_CODE, # or Harness.CODEX, Harness.OPENCODE, Harness.DEEPAGENTS
|
||||
"Find why tests/test_router.py is flaky and fix it.",
|
||||
sandbox=sandbox.local("./repo"),
|
||||
model="litellm_proxy/claude-sonnet-4-5", # a model group on your AI Gateway
|
||||
)
|
||||
|
||||
print(result.text, result.cost, [f.path for f in result.files])
|
||||
```
|
||||
|
||||
Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call the agent makes goes through your AI Gateway, tagged `harness,claude_code`. Drop the `litellm_proxy/` prefix to call a provider directly. Install `starlette uvicorn` plus the agent's CLI (`claude`, `codex` or `opencode`), or `deepagents langchain-litellm` for Deep Agents.
|
||||
|
||||
[**Docs: Agent Harnesses**](https://docs.litellm.ai/docs/harness)
|
||||
|
||||
</details>
|
||||
|
||||
### Supported Providers ([Website Supported Models](https://models.litellm.ai/) | [Docs](https://docs.litellm.ai/docs/providers))
|
||||
|
||||
| Provider | `/chat/completions` | `/messages` | `/responses` | `/embeddings` | `/image/generations` | `/audio/transcriptions` | `/audio/speech` | `/moderations` | `/batches` | `/rerank` |
|
||||
|
|
|
|||
|
|
@ -87,3 +87,21 @@ def migration_lock(database_url: str) -> Generator[MigrationCoordinator, None, N
|
|||
f"Timed out waiting for another v2 migration resolver after {wait_seconds}s. "
|
||||
f"Check the running migration or increase {MIGRATION_LOCK_TIMEOUT_ENV_VAR}."
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def held_migration_lock(connection: "psycopg.Connection[tuple[object, ...]]") -> Generator[bool, None, None]:
|
||||
"""A session-level, non-blocking hold of the migration coordinator lock on an autocommit
|
||||
connection, for DDL that cannot run inside a transaction (`CREATE INDEX CONCURRENTLY`).
|
||||
Yields whether the lock was acquired; a v2 resolver or another migration job's index build
|
||||
holding it yields False. Released on exit."""
|
||||
from psycopg.rows import class_row
|
||||
|
||||
with connection.cursor(row_factory=class_row(_LockResult)) as cursor:
|
||||
row: Final = cursor.execute("SELECT pg_try_advisory_lock(%s) AS acquired", (MIGRATION_LOCK_KEY,)).fetchone()
|
||||
acquired: Final = row is not None and row.acquired
|
||||
try:
|
||||
yield acquired
|
||||
finally:
|
||||
if acquired:
|
||||
connection.execute("SELECT pg_advisory_unlock(%s)", (MIGRATION_LOCK_KEY,))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import hashlib
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -156,3 +157,48 @@ def baseline_current_schema(
|
|||
"review any feature-specific backfill requirements.",
|
||||
len(migrations),
|
||||
)
|
||||
|
||||
|
||||
_LINE_COMMENT_RE: Final = re.compile(r"--[^\n]*")
|
||||
_BLOCK_COMMENT_RE: Final = re.compile(r"/\*.*?\*/", re.DOTALL)
|
||||
_NO_OP_STATEMENT_RE: Final = re.compile(r"^\s*SELECT\s+1\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def is_inert_migration(script: str) -> bool:
|
||||
"""Whether a migration file changes nothing: only comments and `SELECT 1`, so
|
||||
applying it can neither repeat nor skip a database change."""
|
||||
stripped: Final = _LINE_COMMENT_RE.sub("", _BLOCK_COMMENT_RE.sub("", script))
|
||||
return all(not part.strip() or _NO_OP_STATEMENT_RE.match(part) for part in stripped.split(";"))
|
||||
|
||||
|
||||
def roll_back_failed_inert_migration(coordinator: MigrationCoordinator, schema: str, migration: Path) -> bool:
|
||||
"""Roll back the failed ledger row of a migration whose file in this build is inert,
|
||||
so `migrate deploy` applies the inert file on its next pass. The row records an
|
||||
earlier build's attempt at SQL this build no longer ships (an index now built by the
|
||||
migration job), so no database change can be repeated or skipped by replaying
|
||||
the empty file. The caller commits this checkpoint before the next Prisma command.
|
||||
"""
|
||||
from psycopg import sql
|
||||
|
||||
if not is_inert_migration(migration.read_text(encoding="utf-8")):
|
||||
return False
|
||||
coordinator.acquire_prisma_lock()
|
||||
records: Final = _migration_records(coordinator.connection, schema, migration)
|
||||
unfinished: Final = tuple(record for record in records if not record.finished)
|
||||
if len(unfinished) != 1:
|
||||
return False
|
||||
result: Final = coordinator.connection.execute(
|
||||
sql.SQL(
|
||||
"UPDATE {} SET rolled_back_at = current_timestamp "
|
||||
"WHERE id = %s AND finished_at IS NULL AND rolled_back_at IS NULL"
|
||||
).format(sql.Identifier(schema, "_prisma_migrations")),
|
||||
(unfinished[0].id,),
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise RuntimeError("Could not roll back the failed inert migration history row; rerun the database setup.")
|
||||
logger.info(
|
||||
"Rolled back the failed history row of %s: this build ships it as an inert migration, "
|
||||
"its index is built by the migration job",
|
||||
migration.parent.name,
|
||||
)
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -1,2 +1,6 @@
|
|||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogs_api_key_startTime_idx" ON "LiteLLM_SpendLogs"("api_key", "startTime");
|
||||
-- The (api_key, startTime) index on LiteLLM_SpendLogs is built after migrate deploy,
|
||||
-- through litellm_proxy_extras/request_log_indexes.py: concurrently on a plain table and
|
||||
-- per partition on a partitioned one. The migration job builds it; a serving proxy that
|
||||
-- ran the migrations itself builds it in the background once it serves. A migration
|
||||
-- cannot do either without blocking spend-log writes or failing on a partitioned table.
|
||||
SELECT 1;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
-- CreateIndex (CONCURRENTLY)
|
||||
--
|
||||
-- Disclaimer:
|
||||
-- - CREATE INDEX CONCURRENTLY cannot run inside a transaction. This migration must stay a
|
||||
-- single statement so Prisma Migrate on PostgreSQL can apply it outside a transaction.
|
||||
-- - Builds are slower and use more I/O than a blocking CREATE INDEX; if the build is
|
||||
-- interrupted, Postgres may leave an INVALID index that must be dropped and recreated.
|
||||
-- - Do not edit this file after it has been applied to any database: Prisma checksums
|
||||
-- migrations; add a new migration instead.
|
||||
-- - Requires PostgreSQL that supports CONCURRENTLY with IF NOT EXISTS (use a new migration
|
||||
-- without IF NOT EXISTS if you must support older versions).
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_SpendLogs_litellm_call_id_idx" ON "LiteLLM_SpendLogs"("litellm_call_id");
|
||||
-- The litellm_call_id index on LiteLLM_SpendLogs is built after migrate deploy, through
|
||||
-- litellm_proxy_extras/request_log_indexes.py: concurrently on a plain table and per
|
||||
-- partition on a partitioned one. The migration job builds it; a serving proxy that ran
|
||||
-- the migrations itself builds it in the background once it serves. Postgres refuses
|
||||
-- CREATE INDEX CONCURRENTLY on a partitioned parent, so this migration no longer runs it.
|
||||
SELECT 1;
|
||||
|
|
|
|||
448
litellm-proxy-extras/litellm_proxy_extras/request_log_indexes.py
Normal file
448
litellm-proxy-extras/litellm_proxy_extras/request_log_indexes.py
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
"""The request-log indexes built after `prisma migrate deploy` instead of by a migration:
|
||||
by the migration job, or by a serving proxy that ran the migrations itself (in the
|
||||
background, once it serves).
|
||||
|
||||
A migration cannot build them: a plain `CREATE INDEX` blocks spend-log inserts for the
|
||||
whole build, and `CREATE INDEX CONCURRENTLY` is refused on a partitioned parent
|
||||
(db_scripts/partition_spend_logs.sql). `REQUEST_LOG_INDEXES` is the one list to extend;
|
||||
names match what Prisma derives from the `@@index` declarations in schema.prisma, so an
|
||||
index a database already has is recognized and never rebuilt.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from litellm_proxy_extras._logging import logger
|
||||
from litellm_proxy_extras.migration_lock import held_migration_lock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RequestLogIndex:
|
||||
"""One index the migration job owns: the table, the exact Prisma index name and the
|
||||
column list as it would be written after `ON <table>`."""
|
||||
|
||||
table: str
|
||||
name: str
|
||||
definition: str
|
||||
|
||||
@property
|
||||
def columns(self) -> tuple[str, ...]:
|
||||
return tuple(re.findall(r'"([^"]+)"', self.definition))
|
||||
|
||||
def partition_index_name(self, partition: str) -> str:
|
||||
"""The child index name for one partition, built the way Postgres names the
|
||||
children of a partitioned index, and kept within the 63 byte identifier limit."""
|
||||
name: Final = f"{partition}_{self.name.removeprefix(f'{self.table}_')}"
|
||||
if len(name.encode()) <= _IDENTIFIER_MAX_BYTES:
|
||||
return name
|
||||
digest: Final = hashlib.sha256(name.encode()).hexdigest()[:_DIGEST_LENGTH]
|
||||
budget: Final = _IDENTIFIER_MAX_BYTES - _DIGEST_LENGTH - 1
|
||||
kept: Final = next(name[:length] for length in range(len(name), 0, -1) if len(name[:length].encode()) <= budget)
|
||||
return f"{kept}_{digest}"
|
||||
|
||||
|
||||
REQUEST_LOG_INDEXES: Final = (
|
||||
RequestLogIndex("LiteLLM_SpendLogs", "LiteLLM_SpendLogs_api_key_startTime_idx", '("api_key", "startTime")'),
|
||||
RequestLogIndex("LiteLLM_SpendLogs", "LiteLLM_SpendLogs_litellm_call_id_idx", '("litellm_call_id")'),
|
||||
)
|
||||
|
||||
_IDENTIFIER_MAX_BYTES: Final = 63
|
||||
_PARENT_LOCK_TIMEOUT: Final = "2s"
|
||||
_PARENT_LOCK_ATTEMPTS: Final = 30
|
||||
_LOCK_HANDOVER_SECONDS: Final = 2.0
|
||||
_DIGEST_LENGTH: Final = 8
|
||||
_CREATE_INDEX_STATEMENT: Final = re.compile(
|
||||
r'^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?"(?P<index>[^"]+)"\s+ON\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TABLE_KIND_SQL: Final = "SELECT c.relkind = 'p' AS partitioned FROM pg_class c WHERE c.oid = to_regclass(%s)"
|
||||
_CHILDREN_WITHOUT_THE_INDEX_SQL: Final = (
|
||||
"SELECT child.relname AS name, n.nspname AS schema, child.relkind = 'p' AS partitioned "
|
||||
"FROM pg_inherits i JOIN pg_class child ON child.oid = i.inhrelid "
|
||||
"JOIN pg_namespace n ON n.oid = child.relnamespace "
|
||||
"WHERE i.inhparent = to_regclass(%s) AND NOT EXISTS ("
|
||||
"SELECT 1 FROM pg_inherits attached JOIN pg_index x ON x.indexrelid = attached.inhrelid "
|
||||
"WHERE attached.inhparent = to_regclass(%s) AND x.indrelid = child.oid) "
|
||||
"ORDER BY child.relname"
|
||||
)
|
||||
_EQUIVALENT_INDEXES_SQL: Final = (
|
||||
"SELECT i.relname AS name, x.indisvalid AS valid "
|
||||
"FROM pg_index x JOIN pg_class i ON i.oid = x.indexrelid JOIN pg_am am ON am.oid = i.relam "
|
||||
"WHERE x.indrelid = to_regclass(%s) AND i.relname <> %s AND am.amname = 'btree' AND NOT x.indisunique "
|
||||
"AND x.indexprs IS NULL AND x.indpred IS NULL AND x.indnkeyatts = x.indnatts "
|
||||
"AND NOT EXISTS (SELECT 1 FROM unnest(x.indoption::int2[]) o WHERE o <> 0) "
|
||||
"AND NOT EXISTS (SELECT 1 FROM unnest(x.indclass::oid[]) c JOIN pg_opclass oc ON oc.oid = c WHERE NOT oc.opcdefault) "
|
||||
"AND NOT EXISTS (SELECT 1 FROM unnest(x.indcollation::oid[]) WITH ORDINALITY c(coll, ord) "
|
||||
"JOIN unnest(x.indkey::int2[]) WITH ORDINALITY k(attnum, ord) ON k.ord = c.ord "
|
||||
"JOIN pg_attribute a ON a.attrelid = x.indrelid AND a.attnum = k.attnum "
|
||||
"WHERE c.coll <> 0 AND c.coll <> a.attcollation) "
|
||||
"AND (SELECT array_agg(a.attname::text ORDER BY k.ord) FROM unnest(x.indkey::int2[]) WITH ORDINALITY k(attnum, ord) "
|
||||
"JOIN pg_attribute a ON a.attrelid = x.indrelid AND a.attnum = k.attnum) = %s::text[] "
|
||||
"AND NOT EXISTS (SELECT 1 FROM pg_inherits WHERE inhrelid = x.indexrelid) "
|
||||
"ORDER BY x.indisvalid DESC, i.relname"
|
||||
)
|
||||
_INDEX_STATE_SQL: Final = (
|
||||
'SELECT x.indisvalid AS valid, t.relname AS "table" '
|
||||
"FROM pg_index x JOIN pg_class t ON t.oid = x.indrelid WHERE x.indexrelid = to_regclass(%s)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Relation:
|
||||
name: str
|
||||
schema: str
|
||||
partitioned: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _IndexState:
|
||||
valid: bool
|
||||
table: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _EquivalentIndex:
|
||||
name: str
|
||||
valid: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TableKind:
|
||||
partitioned: bool
|
||||
|
||||
|
||||
def filter_request_log_index_diff(diff_sql: str, indexes: tuple[RequestLogIndex, ...] = REQUEST_LOG_INDEXES) -> str:
|
||||
"""The `prisma migrate diff` script without the statements that create a migration-job-owned
|
||||
index, which the schema declares and the migrations deliberately do not build."""
|
||||
names: Final = frozenset(index.name for index in indexes)
|
||||
statements: Final = diff_sql.split(";")
|
||||
kept: Final = tuple(statement for statement in statements if not _creates_one_of(statement, names))
|
||||
return ";".join(kept) if any(part.strip() for part in kept) else ""
|
||||
|
||||
|
||||
def _creates_one_of(statement: str, names: frozenset[str]) -> bool:
|
||||
match: Final = _CREATE_INDEX_STATEMENT.match(_without_comments(statement))
|
||||
return match is not None and match["index"] in names
|
||||
|
||||
|
||||
def _without_comments(statement: str) -> str:
|
||||
return "\n".join(line for line in statement.splitlines() if not line.lstrip().startswith("--"))
|
||||
|
||||
|
||||
def _connect(database_url: str) -> "psycopg.Connection[tuple[object, ...]]":
|
||||
import psycopg
|
||||
|
||||
return psycopg.connect(database_url, connect_timeout=10, autocommit=True)
|
||||
|
||||
|
||||
def ensure_request_log_indexes(
|
||||
database_url: str,
|
||||
schema: str,
|
||||
indexes: tuple[RequestLogIndex, ...] = REQUEST_LOG_INDEXES,
|
||||
connect: "Callable[[str], psycopg.Connection[tuple[object, ...]]]" = _connect,
|
||||
) -> bool:
|
||||
"""Build every listed index that is missing or invalid. Each build step runs under
|
||||
the migration coordinator lock, held per statement so a resolver booting on another
|
||||
replica gets in between partitions rather than waiting for the whole table. Any
|
||||
failure is logged and left for the next index build; the result says whether
|
||||
every index ended up valid. Never raises."""
|
||||
import psycopg
|
||||
|
||||
try:
|
||||
with connect(database_url) as connection:
|
||||
connection.execute("SET statement_timeout = 0")
|
||||
results: Final = tuple(_ensure_index(connection, schema, index) for index in indexes)
|
||||
except psycopg.Error as exc:
|
||||
logger.warning("Could not build the request-log indexes, leaving them for the next index build: %s", exc)
|
||||
return False
|
||||
if not all(results):
|
||||
logger.warning("Some request-log indexes are not in place yet, leaving them for the next index build")
|
||||
return False
|
||||
logger.info("Request-log indexes are all in place")
|
||||
return True
|
||||
|
||||
|
||||
def _under_migration_lock(connection: "psycopg.Connection[tuple[object, ...]]", step: Callable[[], bool]) -> bool:
|
||||
with held_migration_lock(connection) as held:
|
||||
if not held:
|
||||
logger.info(
|
||||
"Another process holds the migration lock, leaving the request-log indexes to the next index build"
|
||||
)
|
||||
return False
|
||||
return step()
|
||||
|
||||
|
||||
def _ensure_index(connection: "psycopg.Connection[tuple[object, ...]]", schema: str, index: RequestLogIndex) -> bool:
|
||||
from psycopg.rows import class_row
|
||||
|
||||
with connection.cursor(row_factory=class_row(_TableKind)) as cursor:
|
||||
table: Final = cursor.execute(_TABLE_KIND_SQL, (_regclass_name(connection, schema, index.table),)).fetchone()
|
||||
if table is None:
|
||||
logger.info("Table %s does not exist yet, skipping index %s", index.table, index.name)
|
||||
return True
|
||||
if table.partitioned:
|
||||
return build_index_on_partitioned_table(connection, schema, index)
|
||||
return _build_leaf_index(connection, schema, index.table, index.name, index)
|
||||
|
||||
|
||||
def _regclass_name(connection: "psycopg.Connection[tuple[object, ...]]", schema: str, name: str) -> str:
|
||||
from psycopg import sql
|
||||
|
||||
return sql.Identifier(schema, name).as_string(connection)
|
||||
|
||||
|
||||
def _create_index_statement(
|
||||
connection: "psycopg.Connection[tuple[object, ...]]", prefix: "sql.Composed", definition: str
|
||||
) -> bytes:
|
||||
return (prefix.as_string(connection) + definition).encode()
|
||||
|
||||
|
||||
def _index_state(connection: "psycopg.Connection[tuple[object, ...]]", schema: str, index: str) -> "_IndexState | None":
|
||||
from psycopg.rows import class_row
|
||||
|
||||
with connection.cursor(row_factory=class_row(_IndexState)) as cursor:
|
||||
return cursor.execute(_INDEX_STATE_SQL, (_regclass_name(connection, schema, index),)).fetchone()
|
||||
|
||||
|
||||
def _equivalent_indexes(
|
||||
connection: "psycopg.Connection[tuple[object, ...]]",
|
||||
schema: str,
|
||||
table: str,
|
||||
name: str,
|
||||
index: RequestLogIndex,
|
||||
) -> tuple[_EquivalentIndex, ...]:
|
||||
"""The indexes on `table` other than `name` with the same definition: default btree
|
||||
over the same columns in the same order, no expression, predicate, DESC or custom
|
||||
opclass or collation, and not attached under a partitioned index. Valid ones first."""
|
||||
from psycopg.rows import class_row
|
||||
|
||||
with connection.cursor(row_factory=class_row(_EquivalentIndex)) as cursor:
|
||||
return tuple(
|
||||
cursor.execute(
|
||||
_EQUIVALENT_INDEXES_SQL, (_regclass_name(connection, schema, table), name, list(index.columns))
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
|
||||
def _adopt_equivalent_index(
|
||||
connection: "psycopg.Connection[tuple[object, ...]]",
|
||||
schema: str,
|
||||
table: str,
|
||||
name: str,
|
||||
index: RequestLogIndex,
|
||||
) -> bool:
|
||||
"""Rename a valid index of the same definition under another name (an operator's
|
||||
hand-built copy, say) to the name this code expects, instead of building a second
|
||||
one. RENAME on an index is a catalog change that lets writes through."""
|
||||
from psycopg import sql
|
||||
|
||||
equivalent: Final = next(
|
||||
(found for found in _equivalent_indexes(connection, schema, table, name, index) if found.valid), None
|
||||
)
|
||||
if equivalent is None:
|
||||
return False
|
||||
logger.info(
|
||||
"Renaming the equivalent index %s on %s to %s instead of building a second one", equivalent.name, table, name
|
||||
)
|
||||
connection.execute(
|
||||
sql.SQL("ALTER INDEX {} RENAME TO {}").format(sql.Identifier(schema, equivalent.name), sql.Identifier(name))
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _report_second_copies(
|
||||
connection: "psycopg.Connection[tuple[object, ...]]",
|
||||
schema: str,
|
||||
table: str,
|
||||
name: str,
|
||||
index: RequestLogIndex,
|
||||
concurrently: bool,
|
||||
) -> None:
|
||||
"""Log every other index of the same definition with the statement that removes it.
|
||||
Dropping is the operator's call: a second copy costs writes and disk, never results."""
|
||||
from psycopg import sql
|
||||
|
||||
drop: Final = "DROP INDEX CONCURRENTLY" if concurrently else "DROP INDEX"
|
||||
for copy in _equivalent_indexes(connection, schema, table, name, index):
|
||||
logger.warning(
|
||||
"Index %s on %s is a second copy of %s and only costs writes and disk; remove it with: %s %s",
|
||||
copy.name,
|
||||
table,
|
||||
name,
|
||||
drop,
|
||||
sql.Identifier(schema, copy.name).as_string(connection),
|
||||
)
|
||||
|
||||
|
||||
def _children_without_the_index(
|
||||
connection: "psycopg.Connection[tuple[object, ...]]", schema: str, table: str, index: str
|
||||
) -> tuple[_Relation, ...]:
|
||||
from psycopg.rows import class_row
|
||||
|
||||
with connection.cursor(row_factory=class_row(_Relation)) as cursor:
|
||||
return tuple(
|
||||
cursor.execute(
|
||||
_CHILDREN_WITHOUT_THE_INDEX_SQL,
|
||||
(_regclass_name(connection, schema, table), _regclass_name(connection, schema, index)),
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
|
||||
def _build_leaf_index(
|
||||
connection: "psycopg.Connection[tuple[object, ...]]",
|
||||
schema: str,
|
||||
table: str,
|
||||
name: str,
|
||||
index: RequestLogIndex,
|
||||
) -> bool:
|
||||
"""Build one plain table's or partition's index with CONCURRENTLY so writes keep
|
||||
flowing. The catalog is read under the migration lock, so a replica that saw an
|
||||
invalid index before the lock finds the valid one another replica just built and
|
||||
leaves it. An invalid index left by an interrupted build is dropped and rebuilt; a
|
||||
valid index of the same definition under another name is renamed rather than
|
||||
duplicated; an index of that name on another table is a collision this code will
|
||||
not touch."""
|
||||
from psycopg import sql
|
||||
|
||||
def build() -> bool:
|
||||
existing: Final = _index_state(connection, schema, name)
|
||||
if existing is not None and existing.table != table:
|
||||
logger.warning(
|
||||
"Index %s already exists on %s rather than %s, leaving it alone", name, existing.table, table
|
||||
)
|
||||
return False
|
||||
if existing is not None and existing.valid:
|
||||
return True
|
||||
if existing is not None:
|
||||
logger.info("Dropping the invalid index %s left by an interrupted build on %s", name, table)
|
||||
connection.execute(sql.SQL("DROP INDEX CONCURRENTLY {}").format(sql.Identifier(schema, name)))
|
||||
elif _adopt_equivalent_index(connection, schema, table, name, index):
|
||||
return True
|
||||
logger.info("Building index %s on %s concurrently", name, table)
|
||||
prefix: Final = sql.SQL("CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} ").format(
|
||||
sql.Identifier(name), sql.Identifier(schema, table)
|
||||
)
|
||||
connection.execute(_create_index_statement(connection, prefix, index.definition))
|
||||
built: Final = _index_state(connection, schema, name)
|
||||
return built is not None and built.valid
|
||||
|
||||
current: Final = _index_state(connection, schema, name)
|
||||
if current is None or not current.valid or current.table != table:
|
||||
if not _under_migration_lock(connection, build):
|
||||
return False
|
||||
time.sleep(_LOCK_HANDOVER_SECONDS)
|
||||
_report_second_copies(connection, schema, table, name, index, concurrently=True)
|
||||
return True
|
||||
|
||||
|
||||
def build_index_on_partitioned_table(
|
||||
connection: "psycopg.Connection[tuple[object, ...]]",
|
||||
schema: str,
|
||||
index: RequestLogIndex,
|
||||
table: "str | None" = None,
|
||||
name: "str | None" = None,
|
||||
) -> bool:
|
||||
"""Build the index the way Postgres allows on a partitioned parent: a metadata-only
|
||||
parent index ON ONLY the parent, one CONCURRENTLY build per partition, and ATTACH
|
||||
PARTITION for each child. Partitions that are themselves partitioned get the same
|
||||
treatment one level down. Every step checks the catalog before acting, so an
|
||||
interrupted run resumes where it stopped and a second run finds nothing to do; a
|
||||
parent or child index of the same definition under another name is renamed and
|
||||
used rather than duplicated. The connection must be in autocommit mode. True when
|
||||
the parent index ends up valid."""
|
||||
|
||||
parent_table: Final = index.table if table is None else table
|
||||
parent_index: Final = index.name if name is None else name
|
||||
existing: Final = _index_state(connection, schema, parent_index)
|
||||
if existing is not None and existing.table != parent_table:
|
||||
logger.warning(
|
||||
"Index %s already exists on %s rather than %s, leaving it alone", parent_index, existing.table, parent_table
|
||||
)
|
||||
return False
|
||||
if existing is None and not _under_migration_lock(
|
||||
connection,
|
||||
lambda: (
|
||||
_adopt_equivalent_index(connection, schema, parent_table, parent_index, index)
|
||||
or _create_parent_index(connection, schema, parent_index, parent_table, index)
|
||||
),
|
||||
):
|
||||
return False
|
||||
children: Final = _children_without_the_index(connection, schema, parent_table, parent_index)
|
||||
if not all(_attach_child_index(connection, schema, parent_index, child, index) for child in children):
|
||||
return False
|
||||
final: Final = _index_state(connection, schema, parent_index)
|
||||
if final is None or not final.valid:
|
||||
return False
|
||||
_report_second_copies(connection, schema, parent_table, parent_index, index, concurrently=False)
|
||||
return True
|
||||
|
||||
|
||||
def _create_parent_index(
|
||||
connection: "psycopg.Connection[tuple[object, ...]]",
|
||||
schema: str,
|
||||
name: str,
|
||||
table: str,
|
||||
index: RequestLogIndex,
|
||||
) -> bool:
|
||||
"""Create the metadata-only parent index. Postgres takes a SHARE lock on the
|
||||
parent for that statement, so it waits for in-flight writes and queues new ones
|
||||
behind it; a short lock_timeout with retries keeps every such pause bounded."""
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
prefix: Final = sql.SQL("CREATE INDEX IF NOT EXISTS {} ON ONLY {} ").format(
|
||||
sql.Identifier(name), sql.Identifier(schema, table)
|
||||
)
|
||||
statement: Final = _create_index_statement(connection, prefix, index.definition)
|
||||
connection.execute(sql.SQL("SET lock_timeout = {}").format(sql.Literal(_PARENT_LOCK_TIMEOUT)))
|
||||
try:
|
||||
for _ in range(_PARENT_LOCK_ATTEMPTS):
|
||||
try:
|
||||
connection.execute(statement)
|
||||
return True
|
||||
except psycopg.errors.LockNotAvailable:
|
||||
logger.info("Waiting for in-flight writes to %s before creating the parent index %s", table, name)
|
||||
time.sleep(random.uniform(0.1, 0.5))
|
||||
finally:
|
||||
connection.execute("SET lock_timeout = 0")
|
||||
logger.warning("Could not get the parent lock on %s to create %s, leaving it for the next index build", table, name)
|
||||
return False
|
||||
|
||||
|
||||
def _attach_child_index(
|
||||
connection: "psycopg.Connection[tuple[object, ...]]",
|
||||
schema: str,
|
||||
parent_index: str,
|
||||
child: _Relation,
|
||||
index: RequestLogIndex,
|
||||
) -> bool:
|
||||
from psycopg import sql
|
||||
|
||||
child_index: Final = index.partition_index_name(child.name)
|
||||
built: Final = (
|
||||
build_index_on_partitioned_table(connection, child.schema, index, child.name, child_index)
|
||||
if child.partitioned
|
||||
else _build_leaf_index(connection, child.schema, child.name, child_index, index)
|
||||
)
|
||||
if not built:
|
||||
return False
|
||||
|
||||
def attach() -> bool:
|
||||
connection.execute(
|
||||
sql.SQL("ALTER INDEX {} ATTACH PARTITION {}").format(
|
||||
sql.Identifier(schema, parent_index), sql.Identifier(child.schema, child_index)
|
||||
)
|
||||
)
|
||||
logger.info("Attached index %s on partition %s to %s", child_index, child.name, parent_index)
|
||||
return True
|
||||
|
||||
return _under_migration_lock(connection, attach)
|
||||
|
|
@ -5,6 +5,7 @@ import re
|
|||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
|
|
@ -13,6 +14,7 @@ from typing import TYPE_CHECKING, Final, Optional
|
|||
|
||||
from litellm_proxy_extras import prisma_toolchain
|
||||
from litellm_proxy_extras._logging import logger
|
||||
from litellm_proxy_extras.migration_lock import held_migration_lock
|
||||
from litellm_proxy_extras.prisma_toolchain import (
|
||||
PRISMA_COMMAND_TIMEOUT_ENV_VAR,
|
||||
PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR,
|
||||
|
|
@ -24,6 +26,7 @@ from litellm_proxy_extras.replica_identity import (
|
|||
REPLICA_IDENTITY_FULL_ENV_VAR,
|
||||
apply_replica_identity_full,
|
||||
)
|
||||
from litellm_proxy_extras.request_log_indexes import ensure_request_log_indexes, filter_request_log_index_diff
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import psycopg
|
||||
|
|
@ -433,6 +436,21 @@ class ProxyExtrasDBManager:
|
|||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _filter_migration_job_owned_drift(diff_sql: str, partitioned: bool | None = None) -> str:
|
||||
"""The drift script without the indexes the migration job builds (the schema
|
||||
declares them, the migrations deliberately do not) and, when LiteLLM_SpendLogs
|
||||
is partitioned, without its primary-key rewrite and partitioning artifacts."""
|
||||
without_indexes: Final = filter_request_log_index_diff(diff_sql)
|
||||
is_partitioned: Final = ProxyExtrasDBManager.spend_logs_is_partitioned() if partitioned is None else partitioned
|
||||
if not is_partitioned:
|
||||
return without_indexes
|
||||
logger.info(
|
||||
"LiteLLM_SpendLogs is partitioned; removed its primary-key "
|
||||
"rewrite and partitioning artifacts from the drift script"
|
||||
)
|
||||
return filter_partitioned_spend_logs_diff(without_indexes)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_all_migrations(
|
||||
migrations_dir: str, schema_path: str, mark_all_applied: bool = True
|
||||
|
|
@ -513,21 +531,14 @@ class ProxyExtrasDBManager:
|
|||
return
|
||||
logger.info(f"Migration diff created at {diff_sql_path}")
|
||||
|
||||
if ProxyExtrasDBManager.spend_logs_is_partitioned():
|
||||
filtered_sql = filter_partitioned_spend_logs_diff(
|
||||
diff_sql_path.read_text()
|
||||
)
|
||||
diff_sql_path.write_text(filtered_sql)
|
||||
logger.info(
|
||||
"LiteLLM_SpendLogs is partitioned; removed its primary-key "
|
||||
"rewrite and partitioning artifacts from the drift script"
|
||||
)
|
||||
if not filtered_sql.strip():
|
||||
logger.info("Drift script is empty after filtering; nothing to apply")
|
||||
if not mark_all_applied:
|
||||
return
|
||||
ProxyExtrasDBManager._mark_migrations_applied(migrations_dir)
|
||||
filtered_sql: Final = ProxyExtrasDBManager._filter_migration_job_owned_drift(diff_sql_path.read_text())
|
||||
diff_sql_path.write_text(filtered_sql)
|
||||
if not filtered_sql.strip():
|
||||
logger.info("Drift script is empty after filtering; nothing to apply")
|
||||
if not mark_all_applied:
|
||||
return
|
||||
ProxyExtrasDBManager._mark_migrations_applied(migrations_dir)
|
||||
return
|
||||
|
||||
# 2. Run prisma db execute to apply the migration
|
||||
applied_ok = False
|
||||
|
|
@ -800,7 +811,7 @@ class ProxyExtrasDBManager:
|
|||
conn.execute(statement)
|
||||
except psycopg.Error as e:
|
||||
logger.warning(
|
||||
"Could not repair invalid index %s.%s, will retry on the next startup. "
|
||||
"Could not repair invalid index %s.%s, will retry on the next database setup run. "
|
||||
"If this keeps happening, run `%s` by hand as the index owner. Error: %s",
|
||||
index.schema,
|
||||
index.name,
|
||||
|
|
@ -811,16 +822,21 @@ class ProxyExtrasDBManager:
|
|||
logger.info("%s invalid index %s.%s", action, index.schema, index.name)
|
||||
|
||||
@staticmethod
|
||||
def repair_invalid_indexes(lock_timeout: str = "30s") -> bool:
|
||||
def repair_invalid_indexes(
|
||||
lock_timeout: str = "30s",
|
||||
repair: "Callable[[psycopg.Connection[tuple[str, str, str]], _InvalidIndex], None] | None" = None,
|
||||
) -> bool:
|
||||
"""Rebuild LiteLLM indexes an interrupted CREATE INDEX CONCURRENTLY left
|
||||
INVALID (a migration deadlock between replicas is the usual cause; the
|
||||
retried migration skips them because of IF NOT EXISTS). Never raises:
|
||||
returns True when no invalid index remains, False when the repair was
|
||||
skipped or failed and will be retried on the next startup. Looks in the
|
||||
skipped or failed and will be retried on the next database setup run. Looks in the
|
||||
schema DATABASE_URL names, the only URL Prisma migrates through, but
|
||||
connects over DIRECT_URL when set: the session settings, the advisory
|
||||
lock and REINDEX CONCURRENTLY all need one server session, which a
|
||||
transaction pooler does not give."""
|
||||
transaction pooler does not give. Each rebuild holds the migration
|
||||
coordinator lock on its own, like the migration job's index build, so a resolver
|
||||
booting on another replica waits for one index at most."""
|
||||
prisma_url: Final = os.getenv("DATABASE_URL")
|
||||
if not prisma_url:
|
||||
return False
|
||||
|
|
@ -856,20 +872,53 @@ class ProxyExtrasDBManager:
|
|||
if lock_row is None or not lock_row[0]:
|
||||
logger.info("Another replica is already rebuilding the invalid indexes, skipping")
|
||||
return False
|
||||
for index in ProxyExtrasDBManager._invalid_litellm_indexes(conn, schema):
|
||||
ProxyExtrasDBManager._repair_index(conn, index)
|
||||
repair_one: Final = repair or ProxyExtrasDBManager._repair_index
|
||||
repaired: Final = all(
|
||||
ProxyExtrasDBManager._repair_under_migration_lock(conn, schema, index, repair_one)
|
||||
for index in found
|
||||
)
|
||||
if not repaired:
|
||||
return False
|
||||
remaining: Final = ProxyExtrasDBManager._invalid_litellm_indexes(conn, schema)
|
||||
except psycopg.Error as e:
|
||||
logger.warning("Could not check for invalid indexes, will retry on the next startup. Error: %s", e)
|
||||
logger.warning(
|
||||
"Could not check for invalid indexes, will retry on the next database setup run. Error: %s", e
|
||||
)
|
||||
return False
|
||||
return not remaining
|
||||
|
||||
@staticmethod
|
||||
def _repair_under_migration_lock(
|
||||
conn: "psycopg.Connection[tuple[str, str, str]]",
|
||||
schema: str,
|
||||
index: _InvalidIndex,
|
||||
repair: "Callable[[psycopg.Connection[tuple[str, str, str]], _InvalidIndex], None]",
|
||||
) -> bool:
|
||||
"""Rebuild one index under the migration coordinator lock, skipping it when a
|
||||
migration job finished or dropped it in the meantime. False when another process
|
||||
holds the lock, so the check waits for the next database setup run."""
|
||||
with held_migration_lock(conn) as held:
|
||||
if not held:
|
||||
logger.info(
|
||||
"Another process is building indexes under the migration lock, leaving the "
|
||||
"invalid index check to the next database setup run"
|
||||
)
|
||||
return False
|
||||
still_invalid: Final = ProxyExtrasDBManager._invalid_litellm_indexes(conn, schema)
|
||||
if any(found.schema == index.schema and found.name == index.name for found in still_invalid):
|
||||
repair(conn, index)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _setup_database_v2(use_migrate: bool) -> bool:
|
||||
if not use_migrate:
|
||||
return ProxyExtrasDBManager._run_database_v2(False)
|
||||
from litellm_proxy_extras.migration_lock import migration_environment, migration_lock
|
||||
from litellm_proxy_extras.migration_recovery import baseline_current_schema, recover_completed_migration
|
||||
from litellm_proxy_extras.migration_recovery import (
|
||||
baseline_current_schema,
|
||||
recover_completed_migration,
|
||||
roll_back_failed_inert_migration,
|
||||
)
|
||||
|
||||
database_url: Final = os.environ.get("DATABASE_URL")
|
||||
if not database_url:
|
||||
|
|
@ -884,7 +933,9 @@ class ProxyExtrasDBManager:
|
|||
if not migration.is_file():
|
||||
return False
|
||||
with migration_lock(lock_url) as coordinator:
|
||||
return recover_completed_migration(coordinator, schema, migration)
|
||||
return recover_completed_migration(coordinator, schema, migration) or roll_back_failed_inert_migration(
|
||||
coordinator, schema, migration
|
||||
)
|
||||
|
||||
def baseline_existing(migrations_dir: str) -> None:
|
||||
with migration_lock(lock_url) as coordinator:
|
||||
|
|
@ -1177,13 +1228,16 @@ class ProxyExtrasDBManager:
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def setup_database(
|
||||
use_migrate: bool = False, use_v2_resolver: bool = False
|
||||
) -> bool:
|
||||
def setup_database(use_migrate: bool = False, use_v2_resolver: bool = False) -> bool:
|
||||
"""
|
||||
Set up the database using either prisma migrate or prisma db push
|
||||
Uses migrations from litellm-proxy-extras package
|
||||
|
||||
The request-log indexes in `REQUEST_LOG_INDEXES` are not built here: the
|
||||
migration job builds them through `run_migration_job`, and a serving proxy that
|
||||
ran the migrations itself starts them through `start_request_log_index_build`
|
||||
once it is ready to serve.
|
||||
|
||||
Args:
|
||||
use_migrate: Whether to use prisma migrate instead of db push
|
||||
use_v2_resolver: Opt into the v2 migration resolver (safer during
|
||||
|
|
@ -1200,10 +1254,48 @@ class ProxyExtrasDBManager:
|
|||
migrated = ProxyExtrasDBManager._run_migrations(
|
||||
use_migrate=use_migrate, use_v2_resolver=use_v2_resolver
|
||||
)
|
||||
if migrated:
|
||||
ProxyExtrasDBManager.repair_invalid_indexes()
|
||||
ProxyExtrasDBManager.apply_replica_identity_full_if_requested()
|
||||
return migrated
|
||||
if not migrated:
|
||||
return False
|
||||
ProxyExtrasDBManager.repair_invalid_indexes()
|
||||
ProxyExtrasDBManager.apply_replica_identity_full_if_requested()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def build_request_log_indexes(build: Callable[[str, str], bool] = ensure_request_log_indexes) -> bool:
|
||||
"""Build the indexes in `REQUEST_LOG_INDEXES` on the writer, in the schema the
|
||||
migrations target. Idempotent and never raises; False when an index is still
|
||||
missing or invalid, so the migration job reports it and gets rerun instead of
|
||||
leaving the table unindexed until the next deploy."""
|
||||
database_url: Final = os.environ.get("DATABASE_URL")
|
||||
if not database_url:
|
||||
return True
|
||||
direct_url: Final = ProxyExtrasDBManager._strip_prisma_query_params(
|
||||
os.environ.get("DIRECT_URL") or database_url
|
||||
)
|
||||
schema: Final = ProxyExtrasDBManager._prisma_schema_param(database_url) or "public"
|
||||
return build(direct_url, schema)
|
||||
|
||||
@staticmethod
|
||||
def run_migration_job(
|
||||
use_migrate: bool = False,
|
||||
use_v2_resolver: bool = False,
|
||||
setup: Callable[[bool, bool], bool] = setup_database,
|
||||
build: Callable[[], bool] = build_request_log_indexes,
|
||||
) -> bool:
|
||||
"""The migration job's whole run: `setup_database`, then the request-log indexes,
|
||||
built synchronously so the job exits only once they are in place. False when the
|
||||
migrations failed or an index could not be built, so the Job is rerun."""
|
||||
return setup(use_migrate, use_v2_resolver) and build()
|
||||
|
||||
@staticmethod
|
||||
def start_request_log_index_build(build: Callable[[], bool] = build_request_log_indexes) -> threading.Thread:
|
||||
"""A serving proxy that ran the migrations itself (schema updates not disabled)
|
||||
builds the request-log indexes on a daemon thread, so a long build never delays
|
||||
readiness. A build that could not finish is logged and picked up by the next boot
|
||||
or the migration job."""
|
||||
thread: Final = threading.Thread(target=build, name="litellm-request-log-indexes", daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
@staticmethod
|
||||
def _run_migrations(use_migrate: bool, use_v2_resolver: bool) -> bool:
|
||||
|
|
@ -1247,15 +1339,16 @@ class ProxyExtrasDBManager:
|
|||
logger.info("✅ Post-migration sanity check completed")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.info(f"prisma db error: {e.stderr}, e: {e.stdout}")
|
||||
if "P3009" in e.stderr:
|
||||
stderr: Final = str(e.stderr or "")
|
||||
logger.info(f"prisma db error: {stderr}, e: {e.stdout}")
|
||||
if "P3009" in stderr:
|
||||
# Extract the failed migration name from the error message
|
||||
migration_match = re.search(
|
||||
r"`(\d+_.*)` migration", e.stderr
|
||||
r"`(\d+_.*)` migration", stderr
|
||||
)
|
||||
if migration_match:
|
||||
failed_migration = migration_match.group(1)
|
||||
if ProxyExtrasDBManager._is_idempotent_error(e.stderr):
|
||||
if ProxyExtrasDBManager._is_idempotent_error(stderr):
|
||||
logger.info(
|
||||
f"Migration {failed_migration} failed due to idempotent error (e.g., column already exists), resolving as applied"
|
||||
)
|
||||
|
|
@ -1311,8 +1404,8 @@ class ProxyExtrasDBManager:
|
|||
f"✅ Migration {failed_migration} marked as rolled back... retrying"
|
||||
)
|
||||
elif (
|
||||
"P3005" in e.stderr
|
||||
and "database schema is not empty" in e.stderr
|
||||
"P3005" in stderr
|
||||
and "database schema is not empty" in stderr
|
||||
):
|
||||
logger.info(
|
||||
"Database schema is not empty, creating baseline migration. In read-only file system, please set an environment variable `LITELLM_MIGRATION_DIR` to a writable directory to enable migrations. Learn more - https://docs.litellm.ai/docs/proxy/prod#read-only-file-system"
|
||||
|
|
@ -1326,13 +1419,13 @@ class ProxyExtrasDBManager:
|
|||
)
|
||||
logger.info("✅ All migrations resolved.")
|
||||
return True
|
||||
elif "P3018" in e.stderr:
|
||||
elif "P3018" in stderr:
|
||||
# Check if this is a permission error or idempotent error
|
||||
if ProxyExtrasDBManager._is_permission_error(e.stderr):
|
||||
if ProxyExtrasDBManager._is_permission_error(stderr):
|
||||
# Permission errors should NOT be marked as applied
|
||||
# Extract migration name for logging
|
||||
migration_match = re.search(
|
||||
r"Migration name: (\d+_.*)", e.stderr
|
||||
r"Migration name: (\d+_.*)", stderr
|
||||
)
|
||||
migration_name = (
|
||||
migration_match.group(1)
|
||||
|
|
@ -1342,7 +1435,7 @@ class ProxyExtrasDBManager:
|
|||
|
||||
logger.error(
|
||||
f"❌ Migration {migration_name} failed due to insufficient permissions. "
|
||||
f"Please check database user privileges. Error: {e.stderr}"
|
||||
f"Please check database user privileges. Error: {stderr}"
|
||||
)
|
||||
|
||||
# Mark as rolled back and exit with error
|
||||
|
|
@ -1365,7 +1458,7 @@ class ProxyExtrasDBManager:
|
|||
f"was NOT applied. Please grant necessary database permissions and retry."
|
||||
) from e
|
||||
|
||||
elif ProxyExtrasDBManager._is_idempotent_error(e.stderr):
|
||||
elif ProxyExtrasDBManager._is_idempotent_error(stderr):
|
||||
# Idempotent errors mean the migration has effectively been applied
|
||||
logger.info(
|
||||
"Migration failed due to idempotent error (e.g., column already exists), "
|
||||
|
|
@ -1373,7 +1466,7 @@ class ProxyExtrasDBManager:
|
|||
)
|
||||
# Extract the migration name from the error message
|
||||
migration_match = re.search(
|
||||
r"Migration name: (\d+_.*)", e.stderr
|
||||
r"Migration name: (\d+_.*)", stderr
|
||||
)
|
||||
if migration_match:
|
||||
migration_name = migration_match.group(1)
|
||||
|
|
@ -1422,7 +1515,7 @@ class ProxyExtrasDBManager:
|
|||
logger.warning(
|
||||
f"P3018 error encountered but could not classify "
|
||||
f"as permission or idempotent error. "
|
||||
f"Error: {e.stderr}"
|
||||
f"Error: {stderr}"
|
||||
)
|
||||
raise
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -2282,6 +2282,24 @@ if TYPE_CHECKING:
|
|||
# Track if async client cleanup has been registered (for lazy loading)
|
||||
_async_client_cleanup_registered = False
|
||||
|
||||
# litellm.agent() entrypoints, resolved lazily from litellm.harness by __getattr__.
|
||||
_AGENT_EXPORTS: Final = frozenset(
|
||||
{
|
||||
"agent",
|
||||
"aagent",
|
||||
"agent_session",
|
||||
"aagent_session",
|
||||
"agent_resume",
|
||||
"aagent_resume",
|
||||
"agent_capabilities",
|
||||
"Harness",
|
||||
"ClaudeCodeOptions",
|
||||
"CodexOptions",
|
||||
"OpenCodeOptions",
|
||||
"DeepAgentsOptions",
|
||||
}
|
||||
)
|
||||
|
||||
# Eager loading for backwards compatibility with VCR and other HTTP recording tools
|
||||
# When LITELLM_DISABLE_LAZY_LOADING is set, lazy-loaded attributes are loaded at import time
|
||||
# For now, this only affects encoding (tiktoken) as it was the only reported issue
|
||||
|
|
@ -2315,6 +2333,13 @@ def __getattr__(name: str) -> Any:
|
|||
handler_func: Final = registry[name]
|
||||
return handler_func(name)
|
||||
|
||||
# litellm.agent() and friends: imported on first access (not needed for completion calls)
|
||||
if name == "harness" or name in _AGENT_EXPORTS:
|
||||
import importlib
|
||||
|
||||
harness_module = importlib.import_module("litellm.harness")
|
||||
return harness_module if name == "harness" else getattr(harness_module, name)
|
||||
|
||||
# Lazy load encoding from main.py to avoid heavy tiktoken import
|
||||
if name == "encoding":
|
||||
from ._lazy_imports import get_litellm_globals
|
||||
|
|
|
|||
|
|
@ -2199,3 +2199,26 @@ EMPTY_MAPPING: Final = MappingProxyType({})
|
|||
|
||||
# API endpoint for breached password k-anonymity search
|
||||
HIBP_RANGE_API_BASE: Final = "https://api.pwnedpasswords.com/range"
|
||||
|
||||
# litellm.harness defaults
|
||||
HARNESS_ENDPOINT_HOST: Final = "127.0.0.1"
|
||||
HARNESS_ENDPOINT_STARTUP_TIMEOUT_SECONDS: Final = 10.0
|
||||
HARNESS_ENDPOINT_REQUEST_TIMEOUT_SECONDS: Final = 600.0
|
||||
HARNESS_SESSION_TOKEN_BYTES: Final = 32
|
||||
HARNESS_MAX_DIFF_BYTES: Final = 256 * 1024
|
||||
HARNESS_STDERR_TAIL_LINES: Final = 40
|
||||
HARNESS_STREAM_READ_CHUNK_BYTES: Final = 64 * 1024
|
||||
HARNESS_EVENT_QUEUE_MAX_SIZE: Final = 1024
|
||||
HARNESS_PROCESS_KILL_GRACE_SECONDS: Final = 5.0
|
||||
HARNESS_SNAPSHOT_SKIP_DIRS: Final = frozenset(
|
||||
{
|
||||
".git",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
}
|
||||
)
|
||||
|
|
|
|||
98
litellm/harness/__init__.py
Normal file
98
litellm/harness/__init__.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Agent harnesses: run Claude Code, Codex, OpenCode or Deep Agents on any LiteLLM model.
|
||||
|
||||
The entrypoints live on the top-level package:
|
||||
|
||||
import litellm
|
||||
from litellm import Harness, sandbox
|
||||
|
||||
result = litellm.agent(
|
||||
Harness.CLAUDE_CODE,
|
||||
"fix the failing test",
|
||||
sandbox=sandbox.local("."),
|
||||
model="litellm_proxy/claude-sonnet-4-5", # a model group on your AI Gateway
|
||||
)
|
||||
|
||||
This module holds the types you get back: events, Result, State, errors.
|
||||
"""
|
||||
|
||||
from litellm.harness.errors import (
|
||||
CapabilityUnsupported,
|
||||
HarnessError,
|
||||
HarnessInstallFailed,
|
||||
OptionsMismatch,
|
||||
OutputInvalid,
|
||||
SandboxError,
|
||||
SessionClosed,
|
||||
StateIncompatible,
|
||||
)
|
||||
from litellm.harness.options import (
|
||||
ClaudeCodeOptions,
|
||||
CodexOptions,
|
||||
DeepAgentsOptions,
|
||||
OpenCodeOptions,
|
||||
)
|
||||
from litellm.harness.runtime import (
|
||||
AsyncEventStream,
|
||||
AsyncSession,
|
||||
aagent,
|
||||
aagent_resume,
|
||||
aagent_session,
|
||||
agent_capabilities,
|
||||
)
|
||||
from litellm.harness.sync import EventStream, Session, agent, agent_resume, agent_session
|
||||
from litellm.harness.types import (
|
||||
Approval,
|
||||
Capabilities,
|
||||
Compaction,
|
||||
Done,
|
||||
Event,
|
||||
FileChange,
|
||||
Harness,
|
||||
Reasoning,
|
||||
Result,
|
||||
State,
|
||||
Text,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
Usage,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"Approval",
|
||||
"AsyncEventStream",
|
||||
"AsyncSession",
|
||||
"Capabilities",
|
||||
"CapabilityUnsupported",
|
||||
"ClaudeCodeOptions",
|
||||
"CodexOptions",
|
||||
"Compaction",
|
||||
"DeepAgentsOptions",
|
||||
"Done",
|
||||
"Event",
|
||||
"EventStream",
|
||||
"FileChange",
|
||||
"Harness",
|
||||
"HarnessError",
|
||||
"HarnessInstallFailed",
|
||||
"OpenCodeOptions",
|
||||
"OptionsMismatch",
|
||||
"OutputInvalid",
|
||||
"Reasoning",
|
||||
"Result",
|
||||
"SandboxError",
|
||||
"Session",
|
||||
"SessionClosed",
|
||||
"State",
|
||||
"StateIncompatible",
|
||||
"Text",
|
||||
"ToolCall",
|
||||
"ToolResult",
|
||||
"Usage",
|
||||
"aagent",
|
||||
"aagent_resume",
|
||||
"aagent_session",
|
||||
"agent",
|
||||
"agent_capabilities",
|
||||
"agent_resume",
|
||||
"agent_session",
|
||||
)
|
||||
62
litellm/harness/context.py
Normal file
62
litellm/harness/context.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Per-session state shared by the runtime, handlers and harness configs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.harness.options import HarnessOptions
|
||||
from litellm.harness.sandbox.base import Sandbox
|
||||
from litellm.harness.types import Approval, Harness, PermissionMode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.harness.endpoint import ModelEndpoint
|
||||
|
||||
ApprovalHandler: TypeAlias = Callable[
|
||||
[Approval], bool | Awaitable[bool] # mutable-ok: Callable parameter list in a type alias, not a runtime collection
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayTarget:
|
||||
"""Resolved LiteLLM AI Gateway for `litellm_proxy/` models. Internal, not exported."""
|
||||
|
||||
api_base: str
|
||||
api_key: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionContext:
|
||||
"""Everything a handler and config need for a session. Owned by the runtime."""
|
||||
|
||||
harness: Harness
|
||||
sandbox: Sandbox
|
||||
session_id: str
|
||||
# Model name as sent to the runtime (litellm_proxy/ prefix already stripped).
|
||||
model: str | None = None
|
||||
gateway: GatewayTarget | None = None
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
endpoint: ModelEndpoint | None = None
|
||||
instructions: str | None = None
|
||||
tools: Sequence[Callable[..., Any]] = ()
|
||||
skills: Sequence[str] = ()
|
||||
disable_tools: Sequence[str] = ()
|
||||
permissions: PermissionMode = "full"
|
||||
on_approval: ApprovalHandler | None = None
|
||||
output: type[BaseModel] | None = None
|
||||
max_turns: int | None = None
|
||||
timeout: float | None = None
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
options: HarnessOptions | None = None
|
||||
# Set by the handler after each turn.
|
||||
final_text: str = ""
|
||||
output_json: str | None = None
|
||||
# Usage for in-process harnesses that call LiteLLM directly (no model endpoint).
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cost: float = 0.0
|
||||
calls: int = 0
|
||||
689
litellm/harness/endpoint.py
Normal file
689
litellm/harness/endpoint.py
Normal file
|
|
@ -0,0 +1,689 @@
|
|||
"""Per-session local model endpoint every CLI harness talks to.
|
||||
|
||||
The runtime inside the sandbox points its Anthropic / OpenAI base URL at this endpoint and
|
||||
authenticates with a random per-session token. The endpoint either reverse-proxies to a LiteLLM
|
||||
AI Gateway (gateway mode) or calls the LiteLLM SDK directly (SDK mode), and counts usage + cost.
|
||||
|
||||
starlette and uvicorn are optional: they are imported only when an endpoint starts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType, ModuleType
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
|
||||
import litellm
|
||||
from litellm.constants import (
|
||||
DEFAULT_POLLING_INTERVAL,
|
||||
HARNESS_ENDPOINT_HOST,
|
||||
HARNESS_ENDPOINT_REQUEST_TIMEOUT_SECONDS,
|
||||
HARNESS_ENDPOINT_STARTUP_TIMEOUT_SECONDS,
|
||||
HARNESS_PROCESS_KILL_GRACE_SECONDS,
|
||||
HARNESS_SESSION_TOKEN_BYTES,
|
||||
)
|
||||
from litellm.harness.context import GatewayTarget
|
||||
from litellm.harness.errors import HarnessError, HarnessInstallFailed
|
||||
from litellm.harness.types import Harness, Usage
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from uvicorn import Server
|
||||
|
||||
verbose_logger: Final = logging.getLogger("LiteLLM")
|
||||
|
||||
MISSING_DEPS_MESSAGE = "litellm.harness needs starlette and uvicorn: pip install starlette uvicorn"
|
||||
|
||||
ROUTE_MESSAGES = "messages"
|
||||
ROUTE_CHAT = "chat/completions"
|
||||
ROUTE_RESPONSES = "responses"
|
||||
POST_ROUTES = (ROUTE_MESSAGES, ROUTE_CHAT, ROUTE_RESPONSES)
|
||||
ROUTE_PREFIXES: Final = ("", "/v1")
|
||||
|
||||
# What an SDK call or its stream can raise: LiteLLM maps provider failures onto openai's
|
||||
# exception hierarchy; transport errors, bad request kwargs and unserializable chunks remain.
|
||||
SDK_ERRORS: Final = (openai.OpenAIError, httpx.HTTPError, HarnessError, ValueError, TypeError)
|
||||
|
||||
HOP_BY_HOP_HEADERS = frozenset(
|
||||
{
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"host",
|
||||
"content-length",
|
||||
}
|
||||
)
|
||||
DROPPED_REQUEST_HEADERS = HOP_BY_HOP_HEADERS | frozenset(
|
||||
(
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"accept-encoding",
|
||||
)
|
||||
)
|
||||
DROPPED_RESPONSE_HEADERS = HOP_BY_HOP_HEADERS | frozenset(("content-encoding",))
|
||||
COST_HEADER = "x-litellm-response-cost"
|
||||
SSE_MEDIA_TYPE = "text/event-stream"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ServerDeps:
|
||||
uvicorn: ModuleType
|
||||
applications: ModuleType
|
||||
routing: ModuleType
|
||||
responses: ModuleType
|
||||
|
||||
|
||||
def _load_server_deps() -> _ServerDeps:
|
||||
"""Import starlette + uvicorn on demand; they are not litellm dependencies."""
|
||||
try:
|
||||
import uvicorn
|
||||
from starlette import applications, responses, routing
|
||||
except ImportError as e:
|
||||
raise HarnessInstallFailed(MISSING_DEPS_MESSAGE) from e
|
||||
return _ServerDeps(
|
||||
uvicorn=uvicorn,
|
||||
applications=applications,
|
||||
routing=routing,
|
||||
responses=responses,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsageTracker:
|
||||
"""Running token + cost totals for one session."""
|
||||
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cost: float = 0.0
|
||||
calls: int = 0
|
||||
|
||||
def add(self, input_tokens: int = 0, output_tokens: int = 0, cost: float = 0.0) -> None:
|
||||
self.input_tokens += input_tokens
|
||||
self.output_tokens += output_tokens
|
||||
self.cost += cost
|
||||
self.calls += 1
|
||||
|
||||
def snapshot(self) -> Usage:
|
||||
return Usage(
|
||||
input_tokens=self.input_tokens,
|
||||
output_tokens=self.output_tokens,
|
||||
calls=self.calls,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Usage parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _as_int(value: object) -> int:
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
return 0
|
||||
|
||||
|
||||
def usage_from_mapping(usage: object) -> tuple[int, int]:
|
||||
"""(input, output) from a usage dict using OpenAI or Anthropic/Responses field names."""
|
||||
if not isinstance(usage, Mapping):
|
||||
return 0, 0
|
||||
input_tokens = usage.get("input_tokens", usage.get("prompt_tokens"))
|
||||
output_tokens = usage.get("output_tokens", usage.get("completion_tokens"))
|
||||
return _as_int(input_tokens), _as_int(output_tokens)
|
||||
|
||||
|
||||
def usage_from_body(body: object) -> tuple[int, int]:
|
||||
"""Usage from a non-streaming JSON response body."""
|
||||
if not isinstance(body, Mapping):
|
||||
return 0, 0
|
||||
if isinstance(body.get("usage"), Mapping):
|
||||
return usage_from_mapping(body["usage"])
|
||||
response = body.get("response")
|
||||
if isinstance(response, Mapping):
|
||||
return usage_from_mapping(response.get("usage"))
|
||||
return 0, 0
|
||||
|
||||
|
||||
class SSEUsageParser:
|
||||
"""Collects token usage from an SSE byte stream as it passes through."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.input_tokens = 0
|
||||
self.output_tokens = 0
|
||||
self._buffer = b""
|
||||
|
||||
def feed(self, chunk: bytes) -> None:
|
||||
self._buffer += chunk
|
||||
*lines, self._buffer = self._buffer.split(b"\n")
|
||||
for line in lines:
|
||||
self._feed_line(line)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._buffer:
|
||||
self._feed_line(self._buffer)
|
||||
self._buffer = b""
|
||||
|
||||
def _feed_line(self, line: bytes) -> None:
|
||||
text = line.strip()
|
||||
if not text.startswith(b"data:"):
|
||||
return
|
||||
payload = text[len(b"data:") :].strip()
|
||||
if not payload or payload == b"[DONE]":
|
||||
return
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except ValueError:
|
||||
return
|
||||
if isinstance(event, Mapping):
|
||||
self.absorb(event)
|
||||
|
||||
def absorb(self, event: Mapping[str, Any]) -> None:
|
||||
event_type = event.get("type")
|
||||
if event_type == "message_start":
|
||||
self._absorb_message_start(event)
|
||||
elif event_type == "message_delta":
|
||||
self._absorb_message_delta(event)
|
||||
elif event_type == "response.completed":
|
||||
self._absorb_response_completed(event)
|
||||
elif isinstance(event.get("usage"), Mapping):
|
||||
self._set(*usage_from_mapping(event["usage"]))
|
||||
|
||||
def _absorb_message_start(self, event: Mapping[str, Any]) -> None:
|
||||
message = event.get("message")
|
||||
if isinstance(message, Mapping):
|
||||
self._set(*usage_from_mapping(message.get("usage")))
|
||||
|
||||
def _absorb_message_delta(self, event: Mapping[str, Any]) -> None:
|
||||
# message_delta output_tokens is cumulative for the whole message.
|
||||
self._set(*usage_from_mapping(event.get("usage")))
|
||||
|
||||
def _absorb_response_completed(self, event: Mapping[str, Any]) -> None:
|
||||
response = event.get("response")
|
||||
if isinstance(response, Mapping):
|
||||
self._set(*usage_from_mapping(response.get("usage")))
|
||||
|
||||
def _set(self, input_tokens: int, output_tokens: int) -> None:
|
||||
if input_tokens:
|
||||
self.input_tokens = input_tokens
|
||||
if output_tokens:
|
||||
self.output_tokens = output_tokens
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cost + helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_cost(model: str | None, input_tokens: int, output_tokens: int) -> float:
|
||||
"""Cost from LiteLLM's price map. Never raises; unknown models cost 0.0."""
|
||||
if not model or not (input_tokens or output_tokens):
|
||||
return 0.0
|
||||
try:
|
||||
prompt_cost, completion_cost = litellm.cost_per_token(
|
||||
model=model, prompt_tokens=input_tokens, completion_tokens=output_tokens
|
||||
)
|
||||
return float(prompt_cost) + float(completion_cost)
|
||||
except Exception: # accounting must never break a call; the price-map lookup raises bare Exception
|
||||
verbose_logger.debug("harness endpoint: cost lookup failed for %s", model, exc_info=True)
|
||||
return 0.0
|
||||
|
||||
|
||||
def header_cost(headers: Mapping[str, str]) -> float | None:
|
||||
raw = headers.get(COST_HEADER)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def hidden_cost(response: object) -> float | None:
|
||||
hidden = getattr(response, "_hidden_params", None)
|
||||
if not isinstance(hidden, Mapping):
|
||||
return None
|
||||
try:
|
||||
cost = hidden.get("response_cost")
|
||||
return None if cost is None else float(cost)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_token(headers: Mapping[str, str]) -> str | None:
|
||||
auth = headers.get("authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
return auth[len("bearer ") :].strip()
|
||||
return headers.get("x-api-key")
|
||||
|
||||
|
||||
def gateway_headers(
|
||||
incoming: Mapping[str, str],
|
||||
gateway: GatewayTarget,
|
||||
harness: Harness,
|
||||
metadata: Mapping[str, Any] | None,
|
||||
) -> Mapping[str, str]:
|
||||
"""Incoming headers minus hop-by-hop/auth/x-litellm-*, plus gateway auth, tags, metadata."""
|
||||
kept = (
|
||||
(name, value)
|
||||
for name, value in incoming.items()
|
||||
if name.lower() not in DROPPED_REQUEST_HEADERS and not name.lower().startswith("x-litellm-")
|
||||
)
|
||||
metadata_json = json.dumps(dict(metadata), default=str) if metadata else None # mutable-ok: for json.dumps
|
||||
metadata_header = (("x-litellm-spend-logs-metadata", metadata_json),) if metadata_json is not None else ()
|
||||
added = (
|
||||
("authorization", f"Bearer {gateway.api_key}"),
|
||||
("x-litellm-tags", f"harness,{harness.value}"),
|
||||
*metadata_header,
|
||||
)
|
||||
return MappingProxyType(dict(itertools.chain(kept, added)))
|
||||
|
||||
|
||||
def response_headers(upstream: Mapping[str, str]) -> Mapping[str, str]:
|
||||
return MappingProxyType(
|
||||
{name: value for name, value in upstream.items() if name.lower() not in DROPPED_RESPONSE_HEADERS}
|
||||
)
|
||||
|
||||
|
||||
def sanitize(message: str, secret_values: tuple[str | None, ...]) -> str:
|
||||
for value in secret_values:
|
||||
if value:
|
||||
message = message.replace(value, "***")
|
||||
return message
|
||||
|
||||
|
||||
def error_status(exc: BaseException) -> int:
|
||||
status = getattr(exc, "status_code", None)
|
||||
if isinstance(status, int) and 400 <= status <= 599:
|
||||
return status
|
||||
return 500
|
||||
|
||||
|
||||
def error_body(exc: BaseException, message: str) -> dict[str, Any]: # mutable-ok: JSONResponse body
|
||||
return {"error": {"type": type(exc).__name__, "message": message}} # mutable-ok: JSONResponse body
|
||||
|
||||
|
||||
def to_jsonable(obj: object) -> object:
|
||||
if hasattr(obj, "model_dump"):
|
||||
return obj.model_dump(mode="json", exclude_none=True)
|
||||
if isinstance(obj, Mapping):
|
||||
return dict(obj) # mutable-ok: plain-dict copy so json.dumps can serialize any Mapping
|
||||
return obj
|
||||
|
||||
|
||||
def encode_anthropic_chunk(chunk: object) -> bytes:
|
||||
if isinstance(chunk, bytes):
|
||||
return chunk
|
||||
if isinstance(chunk, str):
|
||||
return chunk.encode()
|
||||
data = to_jsonable(chunk)
|
||||
event_type = data.get("type", "message") if isinstance(data, Mapping) else "message"
|
||||
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n".encode()
|
||||
|
||||
|
||||
def encode_chat_chunk(chunk: object) -> bytes:
|
||||
if hasattr(chunk, "model_dump_json"):
|
||||
return f"data: {chunk.model_dump_json()}\n\n".encode()
|
||||
return f"data: {json.dumps(to_jsonable(chunk))}\n\n".encode()
|
||||
|
||||
|
||||
def encode_responses_chunk(chunk: object) -> bytes:
|
||||
data = to_jsonable(chunk)
|
||||
event_type = data.get("type", "message") if isinstance(data, Mapping) else "message"
|
||||
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n".encode()
|
||||
|
||||
|
||||
STREAM_ENCODERS = MappingProxyType(
|
||||
{
|
||||
ROUTE_MESSAGES: encode_anthropic_chunk,
|
||||
ROUTE_CHAT: encode_chat_chunk,
|
||||
ROUTE_RESPONSES: encode_responses_chunk,
|
||||
}
|
||||
)
|
||||
STREAM_TRAILERS = MappingProxyType({ROUTE_CHAT: b"data: [DONE]\n\n"})
|
||||
|
||||
|
||||
def route_of(path: str) -> str:
|
||||
stripped = path.strip("/")
|
||||
stripped = stripped.removeprefix("v1/")
|
||||
return stripped
|
||||
|
||||
|
||||
def _noop() -> None:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ModelEndpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModelEndpoint:
|
||||
"""Local HTTP endpoint for one harness session. Use as an async context manager."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
harness: Harness,
|
||||
model: str | None,
|
||||
gateway: GatewayTarget | None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self.harness = harness
|
||||
self.model = model
|
||||
self.gateway = gateway
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base
|
||||
self.metadata: Mapping[str, Any] = MappingProxyType(dict(metadata or ()))
|
||||
self.token = secrets.token_urlsafe(HARNESS_SESSION_TOKEN_BYTES)
|
||||
self.usage = UsageTracker()
|
||||
self.port = 0
|
||||
# Injected client (tests); production uses LiteLLM's shared cached client.
|
||||
self._injected_client = client
|
||||
self._deps: _ServerDeps | None = None
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._server: Any = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"http://{HARNESS_ENDPOINT_HOST}:{self.port}"
|
||||
|
||||
# -- lifecycle ----------------------------------------------------------
|
||||
|
||||
async def __aenter__(self) -> ModelEndpoint:
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.stop()
|
||||
|
||||
async def start(self) -> None:
|
||||
self._deps = _load_server_deps()
|
||||
if self.gateway is not None:
|
||||
self._client = self._gateway_client()
|
||||
self._server = self._build_server(self._deps)
|
||||
self._task = asyncio.create_task(self._server.serve())
|
||||
try:
|
||||
await asyncio.wait_for(self._wait_started(), HARNESS_ENDPOINT_STARTUP_TIMEOUT_SECONDS)
|
||||
except BaseException:
|
||||
await self.stop()
|
||||
raise
|
||||
self.port = self._server.servers[0].sockets[0].getsockname()[1]
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._server is not None:
|
||||
self._server.should_exit = True
|
||||
if self._task is not None:
|
||||
with contextlib.suppress(BaseException):
|
||||
await self._task
|
||||
self._task = None
|
||||
# Never close the client: the shared cached one may still serve other requests,
|
||||
# and an injected one belongs to its caller.
|
||||
self._client = None
|
||||
|
||||
def _gateway_client(self) -> httpx.AsyncClient:
|
||||
"""LiteLLM's shared cached async client, unless one was injected."""
|
||||
if self._injected_client is not None:
|
||||
return self._injected_client
|
||||
handler = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.AgentHarness,
|
||||
params={ # mutable-ok: get_async_httpx_client takes a dict params argument
|
||||
"timeout": HARNESS_ENDPOINT_REQUEST_TIMEOUT_SECONDS
|
||||
},
|
||||
)
|
||||
return handler.client
|
||||
|
||||
async def _wait_started(self) -> None:
|
||||
while not self._server.started:
|
||||
if self._task is not None and self._task.done():
|
||||
raise HarnessError("harness model endpoint failed to start")
|
||||
await asyncio.sleep(DEFAULT_POLLING_INTERVAL)
|
||||
|
||||
def _build_server(self, deps: _ServerDeps) -> Server:
|
||||
config = deps.uvicorn.Config(
|
||||
self._build_app(deps),
|
||||
host=HARNESS_ENDPOINT_HOST,
|
||||
port=0,
|
||||
log_config=None,
|
||||
log_level="warning",
|
||||
access_log=False,
|
||||
lifespan="off",
|
||||
timeout_graceful_shutdown=HARNESS_PROCESS_KILL_GRACE_SECONDS,
|
||||
)
|
||||
server = deps.uvicorn.Server(config)
|
||||
# Never touch the host process's signal handlers.
|
||||
if hasattr(server, "capture_signals"):
|
||||
server.capture_signals = contextlib.nullcontext
|
||||
if hasattr(server, "install_signal_handlers"):
|
||||
server.install_signal_handlers = _noop
|
||||
return server
|
||||
|
||||
def _build_app(self, deps: _ServerDeps) -> Starlette:
|
||||
Route = deps.routing.Route
|
||||
post_routes = tuple(
|
||||
Route(
|
||||
f"{prefix}/{route}",
|
||||
self._handle,
|
||||
methods=["POST"], # mutable-ok: Starlette Route takes a methods list
|
||||
)
|
||||
for prefix, route in itertools.product(ROUTE_PREFIXES, POST_ROUTES)
|
||||
)
|
||||
get_routes = tuple(
|
||||
Route(f"{prefix}/models", self._models, methods=["GET"]) # mutable-ok: Starlette Route takes a methods list
|
||||
for prefix in ROUTE_PREFIXES
|
||||
)
|
||||
return deps.applications.Starlette(
|
||||
routes=[*post_routes, *get_routes] # mutable-ok: Starlette takes a routes list
|
||||
)
|
||||
|
||||
# -- request handling ---------------------------------------------------
|
||||
|
||||
@property
|
||||
def _responses(self) -> ModuleType:
|
||||
if self._deps is None:
|
||||
raise HarnessError("harness model endpoint is not started")
|
||||
return self._deps.responses
|
||||
|
||||
def _authorized(self, request: Request) -> bool:
|
||||
token = extract_token(request.headers)
|
||||
return token is not None and secrets.compare_digest(token.encode(), self.token.encode())
|
||||
|
||||
def _json(self, body: object, status_code: int = 200) -> Response:
|
||||
return self._responses.JSONResponse(body, status_code=status_code)
|
||||
|
||||
def _unauthorized(self) -> Response:
|
||||
return self._json(
|
||||
{"error": {"type": "authentication_error", "message": "invalid token"}}, # mutable-ok: JSONResponse body
|
||||
401,
|
||||
)
|
||||
|
||||
def _error(self, exc: BaseException, status_code: int | None = None) -> Response:
|
||||
message = sanitize(str(exc), self._secrets())
|
||||
return self._json(error_body(exc, message), status_code or error_status(exc))
|
||||
|
||||
def _secrets(self) -> tuple[str | None, ...]:
|
||||
gateway_key = self.gateway.api_key if self.gateway else None
|
||||
return (gateway_key, self.api_key, self.token)
|
||||
|
||||
async def _models(self, request: Request) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
entry = {"id": self.model, "object": "model", "created": 0, "owned_by": "litellm"} # mutable-ok: JSON body
|
||||
data = (entry,) if self.model else ()
|
||||
return self._json({"object": "list", "data": data}) # mutable-ok: JSON response body for Starlette JSONResponse
|
||||
|
||||
async def _handle(self, request: Request) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
body = json.loads(await request.body())
|
||||
except ValueError as e:
|
||||
return self._error(e, 400)
|
||||
if not isinstance(body, dict):
|
||||
return self._error(ValueError("request body must be a JSON object"), 400)
|
||||
route = route_of(request.url.path)
|
||||
if self.gateway is not None:
|
||||
return await self._forward(request, route, body)
|
||||
return await self._call_sdk(route, body)
|
||||
|
||||
def _cost_model(self, body: Mapping[str, Any]) -> str | None:
|
||||
model = self.model or body.get("model")
|
||||
return model if isinstance(model, str) else None
|
||||
|
||||
def _record(
|
||||
self,
|
||||
model: str | None,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
cost: float | None,
|
||||
) -> None:
|
||||
if cost is None:
|
||||
cost = compute_cost(model, input_tokens, output_tokens)
|
||||
self.usage.add(input_tokens, output_tokens, cost)
|
||||
|
||||
# -- gateway mode -------------------------------------------------------
|
||||
|
||||
async def _forward(self, request: Request, route: str, body: Mapping[str, Any]) -> Response:
|
||||
if self._client is None or self.gateway is None:
|
||||
raise HarnessError("gateway client is not started")
|
||||
if self.model:
|
||||
body = {**body, "model": self.model} # mutable-ok: JSON request body re-sent upstream via httpx json=
|
||||
upstream_request = self._client.build_request(
|
||||
"POST",
|
||||
f"{self.gateway.api_base}/v1/{route}",
|
||||
json=body,
|
||||
headers=gateway_headers(request.headers, self.gateway, self.harness, self.metadata),
|
||||
)
|
||||
try:
|
||||
upstream = await self._client.send(upstream_request, stream=True)
|
||||
except httpx.HTTPError as e:
|
||||
return self._error(e, 502)
|
||||
return self._responses.StreamingResponse(
|
||||
self._relay(upstream, self._cost_model(body)),
|
||||
status_code=upstream.status_code,
|
||||
headers=response_headers(upstream.headers),
|
||||
)
|
||||
|
||||
async def _relay(self, upstream: httpx.Response, model: str | None) -> AsyncIterator[bytes]:
|
||||
is_sse = SSE_MEDIA_TYPE in upstream.headers.get("content-type", "")
|
||||
parser = SSEUsageParser()
|
||||
collected = bytearray()
|
||||
try:
|
||||
async for chunk in upstream.aiter_bytes():
|
||||
if is_sse:
|
||||
parser.feed(chunk)
|
||||
else:
|
||||
collected.extend(chunk)
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
if upstream.status_code < 400:
|
||||
self._record_relayed(upstream, model, parser, is_sse, bytes(collected))
|
||||
|
||||
def _record_relayed(
|
||||
self,
|
||||
upstream: httpx.Response,
|
||||
model: str | None,
|
||||
parser: SSEUsageParser,
|
||||
is_sse: bool,
|
||||
collected: bytes,
|
||||
) -> None:
|
||||
if is_sse:
|
||||
parser.close()
|
||||
tokens = (parser.input_tokens, parser.output_tokens)
|
||||
else:
|
||||
try:
|
||||
tokens = usage_from_body(json.loads(collected))
|
||||
except ValueError:
|
||||
tokens = (0, 0)
|
||||
self._record(model, tokens[0], tokens[1], header_cost(upstream.headers))
|
||||
|
||||
# -- SDK mode -----------------------------------------------------------
|
||||
|
||||
def _sdk_kwargs(
|
||||
self, body: Mapping[str, Any]
|
||||
) -> dict[str, Any]: # mutable-ok: SDK call kwargs, mutated by _invoke_sdk then splatted
|
||||
kwargs: dict[str, Any] = {**body} # mutable-ok: SDK call kwargs built from the JSON body, then overridden
|
||||
if self.model:
|
||||
kwargs["model"] = self.model
|
||||
if self.api_key:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if self.api_base:
|
||||
kwargs["api_base"] = self.api_base
|
||||
return kwargs
|
||||
|
||||
async def _invoke_sdk(
|
||||
self,
|
||||
route: str,
|
||||
kwargs: dict[str, Any], # mutable-ok: injects stream_options into the SDK kwargs
|
||||
) -> object:
|
||||
if route == ROUTE_MESSAGES:
|
||||
return await litellm.anthropic.messages.acreate(**kwargs)
|
||||
if route == ROUTE_CHAT:
|
||||
if kwargs.get("stream"):
|
||||
stream_options = kwargs.get("stream_options") or {} # mutable-ok: empty default for a JSON field
|
||||
kwargs["stream_options"] = { # mutable-ok: JSON field sent to litellm.acompletion
|
||||
"include_usage": True,
|
||||
**stream_options,
|
||||
}
|
||||
return await litellm.acompletion(**kwargs)
|
||||
return await litellm.aresponses(**kwargs)
|
||||
|
||||
async def _call_sdk(self, route: str, body: Mapping[str, Any]) -> Response:
|
||||
kwargs = self._sdk_kwargs(body)
|
||||
model = self._cost_model(kwargs)
|
||||
try:
|
||||
response = await self._invoke_sdk(route, kwargs)
|
||||
except SDK_ERRORS as e:
|
||||
verbose_logger.debug("harness endpoint: SDK call failed: %s", type(e).__name__)
|
||||
return self._error(e)
|
||||
if kwargs.get("stream") and isinstance(response, AsyncIterable):
|
||||
return self._responses.StreamingResponse(
|
||||
self._sdk_stream(route, response, model), media_type=SSE_MEDIA_TYPE
|
||||
)
|
||||
data = to_jsonable(response)
|
||||
input_tokens, output_tokens = usage_from_body(data)
|
||||
self._record(model, input_tokens, output_tokens, hidden_cost(response))
|
||||
return self._json(data)
|
||||
|
||||
async def _sdk_stream(self, route: str, iterator: AsyncIterable[object], model: str | None) -> AsyncIterator[bytes]:
|
||||
encode = STREAM_ENCODERS[route]
|
||||
parser = SSEUsageParser()
|
||||
try:
|
||||
async for chunk in iterator:
|
||||
encoded = encode(chunk)
|
||||
parser.feed(encoded)
|
||||
yield encoded
|
||||
trailer = STREAM_TRAILERS.get(route)
|
||||
if trailer:
|
||||
yield trailer
|
||||
except SDK_ERRORS as e:
|
||||
message = sanitize(str(e), self._secrets())
|
||||
yield f"event: error\ndata: {json.dumps(error_body(e, message))}\n\n".encode()
|
||||
finally:
|
||||
parser.close()
|
||||
self._record(model, parser.input_tokens, parser.output_tokens, None)
|
||||
45
litellm/harness/errors.py
Normal file
45
litellm/harness/errors.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Exceptions raised by litellm.harness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.harness.types import Result
|
||||
|
||||
|
||||
class HarnessError(Exception):
|
||||
"""Base class for every litellm.harness error."""
|
||||
|
||||
|
||||
class CapabilityUnsupported(HarnessError):
|
||||
"""The harness cannot do what was asked. Raised before the runtime starts."""
|
||||
|
||||
|
||||
class OptionsMismatch(HarnessError):
|
||||
"""Options for a different harness, or a native option LiteLLM manages itself."""
|
||||
|
||||
|
||||
class HarnessInstallFailed(HarnessError):
|
||||
"""The runtime is missing from the sandbox or failed to start."""
|
||||
|
||||
|
||||
class SandboxError(HarnessError):
|
||||
"""The sandbox failed to start, run a command, or reach the host."""
|
||||
|
||||
|
||||
class SessionClosed(HarnessError):
|
||||
"""A turn was started on a session that is closed or detached."""
|
||||
|
||||
|
||||
class StateIncompatible(HarnessError):
|
||||
"""resume() was given a State from another harness or an unreadable version."""
|
||||
|
||||
|
||||
class OutputInvalid(HarnessError):
|
||||
"""The final answer did not validate against output=."""
|
||||
|
||||
def __init__(self, message: str, raw: str, result: Result | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.raw = raw
|
||||
self.result = result
|
||||
35
litellm/harness/handlers/__init__.py
Normal file
35
litellm/harness/handlers/__init__.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Handlers run a harness config: CLI runtimes as subprocesses, Deep Agents in-process."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from litellm.harness.errors import HarnessError
|
||||
from litellm.harness.handlers.base import BaseHarnessHandler
|
||||
from litellm.harness.types import Harness, require_harness
|
||||
from litellm.llms.base_llm.harness.transformation import (
|
||||
BaseCLIHarnessConfig,
|
||||
BaseHarnessConfig,
|
||||
)
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
|
||||
def get_harness_config(harness: Harness) -> BaseHarnessConfig:
|
||||
config = ProviderConfigManager.get_provider_harness_config(require_harness(harness))
|
||||
if config is None:
|
||||
raise HarnessError(f"No harness config registered for Harness.{harness.name}")
|
||||
return config
|
||||
|
||||
|
||||
def get_harness_handler(config: BaseHarnessConfig) -> BaseHarnessHandler:
|
||||
"""The handler that knows how to run this kind of config."""
|
||||
if isinstance(config, BaseCLIHarnessConfig):
|
||||
from litellm.harness.handlers.cli_handler import CLIHarnessHandler
|
||||
|
||||
return CLIHarnessHandler(config)
|
||||
if config.harness is Harness.DEEPAGENTS:
|
||||
from litellm.harness.handlers.deepagents_handler import DeepAgentsHandler
|
||||
|
||||
return DeepAgentsHandler(config)
|
||||
raise HarnessError(f"No handler for Harness.{config.harness.name}")
|
||||
|
||||
|
||||
__all__ = ("BaseHarnessHandler", "get_harness_config", "get_harness_handler")
|
||||
42
litellm/harness/handlers/base.py
Normal file
42
litellm/harness/handlers/base.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""The handler interface the runtime drives. A handler owns I/O for one session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from litellm.harness.context import SessionContext
|
||||
from litellm.harness.errors import CapabilityUnsupported
|
||||
from litellm.harness.types import Event
|
||||
from litellm.llms.base_llm.harness.transformation import BaseHarnessConfig
|
||||
|
||||
|
||||
class BaseHarnessHandler(ABC):
|
||||
"""Runs one harness session. The config decides what to run; the handler runs it."""
|
||||
|
||||
def __init__(self, config: BaseHarnessConfig) -> None:
|
||||
self.config = config
|
||||
|
||||
@abstractmethod
|
||||
async def start(self, ctx: SessionContext) -> None:
|
||||
"""Prepare the runtime (config files, skills, agent build). Called again after an interrupt."""
|
||||
|
||||
@abstractmethod
|
||||
def turn(self, ctx: SessionContext, prompt: str) -> AsyncIterator[Event]:
|
||||
"""Run one turn and yield events (never Done). Sets ctx.final_text / ctx.output_json."""
|
||||
|
||||
@abstractmethod
|
||||
async def stop(self, ctx: SessionContext) -> None:
|
||||
"""Stop anything this handler started. Safe to call twice."""
|
||||
|
||||
@abstractmethod
|
||||
def native_session_id(self) -> str | None:
|
||||
"""The runtime's own session id, for State / resume."""
|
||||
|
||||
@abstractmethod
|
||||
async def resume(self, ctx: SessionContext, native_session_id: str) -> None:
|
||||
"""Continue the runtime's own session on the next turn."""
|
||||
|
||||
async def history(self, ctx: SessionContext) -> list[dict[str, Any]]: # mutable-ok: public history() API shape
|
||||
raise CapabilityUnsupported(f"Harness.{self.config.harness.name} does not expose history")
|
||||
161
litellm/harness/handlers/cli_handler.py
Normal file
161
litellm/harness/handlers/cli_handler.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""
|
||||
Generic handler for CLI harnesses (Claude Code, Codex, OpenCode).
|
||||
|
||||
The config (`litellm/llms/<harness>/harness/transformation.py`) says what to run and how to
|
||||
read it; this handler does every sandbox and process operation: binary check, private dir,
|
||||
config files, persisted dirs, skills, spawning the turn, streaming stdout lines into the
|
||||
config's parser, collecting stderr, and killing the process on early exit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import Any, Final
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import HARNESS_STDERR_TAIL_LINES, HARNESS_STREAM_READ_CHUNK_BYTES
|
||||
from litellm.harness.context import SessionContext
|
||||
from litellm.harness.errors import HarnessInstallFailed, SandboxError
|
||||
from litellm.harness.handlers.base import BaseHarnessHandler
|
||||
from litellm.harness.sandbox.base import Process, Sandbox
|
||||
from litellm.harness.types import Event
|
||||
from litellm.llms.base_llm.harness.transformation import BaseCLIHarnessConfig, HarnessSessionSetup
|
||||
from litellm.llms.base_llm.harness.utils import decode_json_line, read_skill_files
|
||||
|
||||
# Link <private_dir>/<dir> to a LiteLLM-owned cache dir so a later session can resume.
|
||||
PERSIST_DIR_SCRIPT: Final = (
|
||||
'd="${HOME:-/tmp}/.cache/litellm-harness/$2"; mkdir -p "$d" && mkdir -p "$(dirname "$1")" && ln -sfn "$d" "$1"'
|
||||
)
|
||||
|
||||
|
||||
async def iter_stream_lines(stream: asyncio.StreamReader) -> AsyncIterator[bytes]:
|
||||
"""Newline-delimited lines without StreamReader's 64KiB readline limit."""
|
||||
buffer = b""
|
||||
while True:
|
||||
chunk = await stream.read(HARNESS_STREAM_READ_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk
|
||||
*lines, buffer = buffer.split(b"\n")
|
||||
for line in lines:
|
||||
yield line
|
||||
if buffer:
|
||||
yield buffer
|
||||
|
||||
|
||||
async def drain_stderr(stream: asyncio.StreamReader, tail: deque[str]) -> None: # mutable-ok: stderr ring
|
||||
async for line in iter_stream_lines(stream):
|
||||
tail.append(line.decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
async def send_stdin(proc: Process, data: str) -> None:
|
||||
if proc.stdin is None:
|
||||
raise SandboxError("harness process has no stdin")
|
||||
proc.stdin.write(data.encode("utf-8"))
|
||||
await proc.stdin.drain()
|
||||
proc.stdin.close()
|
||||
|
||||
|
||||
async def private_dir_for(sandbox: Sandbox) -> str:
|
||||
tempdir = getattr(sandbox, "tempdir", None)
|
||||
if tempdir is None:
|
||||
raise SandboxError(f"{type(sandbox).__name__} has no tempdir(); CLI harnesses need a private config dir")
|
||||
path: str = await tempdir()
|
||||
return path
|
||||
|
||||
|
||||
def sandbox_path(private_dir: str, path: str) -> str:
|
||||
return path if path.startswith("/") else f"{private_dir}/{path}"
|
||||
|
||||
|
||||
async def persist_dir(sandbox: Sandbox, link_path: str, cache_subpath: str) -> None:
|
||||
script_args: Final = ("-c", PERSIST_DIR_SCRIPT, "sh", link_path, cache_subpath)
|
||||
cmd: Final = ["sh", *script_args] # mutable-ok: Sandbox.run takes list[str]
|
||||
run = await sandbox.run(cmd)
|
||||
if run.exit_code != 0:
|
||||
verbose_logger.debug(
|
||||
"harness: could not persist %s, resume across sessions disabled: %s", cache_subpath, run.stderr.strip()
|
||||
)
|
||||
|
||||
|
||||
async def copy_skills(sandbox: Sandbox, skills: Sequence[str], skills_root: str) -> None:
|
||||
for skill in skills:
|
||||
name = os.path.basename(os.path.realpath(os.fspath(skill)))
|
||||
for rel, data in await asyncio.to_thread(read_skill_files, skill):
|
||||
await sandbox.write(f"{skills_root}/{name}/{rel.replace(os.sep, '/')}", data)
|
||||
|
||||
|
||||
class CLIHarnessHandler(BaseHarnessHandler):
|
||||
config: BaseCLIHarnessConfig
|
||||
|
||||
def __init__(self, config: BaseCLIHarnessConfig) -> None:
|
||||
super().__init__(config)
|
||||
self._private_dir: str | None = None
|
||||
self._setup: HarnessSessionSetup | None = None
|
||||
self._native_id: str | None = None
|
||||
self._proc: Process | None = None
|
||||
|
||||
async def start(self, ctx: SessionContext) -> None:
|
||||
self.config.validate_environment(ctx)
|
||||
binary = self.config.get_binary()
|
||||
if not await ctx.sandbox.which(binary):
|
||||
raise HarnessInstallFailed(
|
||||
f"`{binary}` was not found on PATH in the sandbox. Install it with: {self.config.get_install_hint()}"
|
||||
)
|
||||
private_dir = await private_dir_for(ctx.sandbox)
|
||||
setup = self.config.transform_session_setup(ctx, private_dir)
|
||||
for link, cache_subpath in setup.persisted_dirs:
|
||||
await persist_dir(ctx.sandbox, sandbox_path(private_dir, link), cache_subpath)
|
||||
for rel_path, data in setup.files.items():
|
||||
await ctx.sandbox.write(sandbox_path(private_dir, rel_path), data)
|
||||
if ctx.skills and setup.skills_dir:
|
||||
await copy_skills(ctx.sandbox, tuple(ctx.skills), sandbox_path(private_dir, setup.skills_dir))
|
||||
self._private_dir = private_dir
|
||||
self._setup = setup
|
||||
|
||||
async def turn(self, ctx: SessionContext, prompt: str) -> AsyncIterator[Event]:
|
||||
if self._setup is None or self._private_dir is None:
|
||||
raise RuntimeError("CLIHarnessHandler.turn() called before start()")
|
||||
request = self.config.transform_turn_request(ctx, self._setup, self._private_dir, prompt, self._native_id)
|
||||
argv: Final = list(request.argv) # mutable-ok: Sandbox.exec takes list[str]
|
||||
proc = await ctx.sandbox.exec(argv, env=request.env, cwd=request.cwd)
|
||||
self._proc = proc
|
||||
tail: Final[deque[str]] = deque(maxlen=HARNESS_STDERR_TAIL_LINES) # mutable-ok: bounded stderr ring buffer
|
||||
stderr_task = asyncio.ensure_future(drain_stderr(proc.stderr, tail))
|
||||
state: Any = self.config.create_stream_state()
|
||||
exit_code: int | None = None
|
||||
try:
|
||||
await send_stdin(proc, request.stdin)
|
||||
async for raw in iter_stream_lines(proc.stdout):
|
||||
line = decode_json_line(raw)
|
||||
if line is None:
|
||||
continue
|
||||
for event in self.config.transform_stream_line(line, state):
|
||||
yield event
|
||||
self._native_id = self.config.get_native_session_id(state) or self._native_id
|
||||
exit_code = await proc.wait()
|
||||
await stderr_task
|
||||
finally:
|
||||
self._proc = None
|
||||
if exit_code is None:
|
||||
# Consumer stopped early, timed out or errored: don't leave the runtime running.
|
||||
await proc.kill()
|
||||
if not stderr_task.done():
|
||||
stderr_task.cancel()
|
||||
response = self.config.transform_turn_response(ctx, state, exit_code, tuple(tail))
|
||||
ctx.final_text = response.final_text
|
||||
ctx.output_json = response.output_json
|
||||
|
||||
async def stop(self, ctx: SessionContext) -> None:
|
||||
proc, self._proc = self._proc, None
|
||||
if proc is not None:
|
||||
await proc.kill()
|
||||
|
||||
def native_session_id(self) -> str | None:
|
||||
return self._native_id
|
||||
|
||||
async def resume(self, ctx: SessionContext, native_session_id: str) -> None:
|
||||
self._native_id = native_session_id
|
||||
261
litellm/harness/handlers/deepagents_handler.py
Normal file
261
litellm/harness/handlers/deepagents_handler.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"""
|
||||
In-process handler for Deep Agents.
|
||||
|
||||
Deep Agents is a Python library, so there is no process or model endpoint: the handler
|
||||
builds the agent with a LiteLLM chat model, streams the LangGraph run, turns interrupts into
|
||||
Approval events and counts usage. Translation lives in
|
||||
`litellm/llms/deepagents/harness/transformation.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType, ModuleType
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from litellm.harness.context import SessionContext
|
||||
from litellm.harness.errors import HarnessError, HarnessInstallFailed
|
||||
from litellm.harness.handlers.base import BaseHarnessHandler
|
||||
from litellm.harness.handlers.cli_handler import copy_skills
|
||||
from litellm.harness.options import DeepAgentsOptions
|
||||
from litellm.harness.types import Approval, Event
|
||||
from litellm.llms.deepagents.harness.transformation import (
|
||||
EXECUTE_TOOLS,
|
||||
INSTALL_HINT,
|
||||
SKILLS_DIR,
|
||||
WRITE_TOOLS,
|
||||
TurnState,
|
||||
approval_requests,
|
||||
blocked_tools,
|
||||
chat_model_kwargs,
|
||||
decision,
|
||||
final_ai_text,
|
||||
interrupt_config,
|
||||
interrupts_in,
|
||||
normalized_tool_name,
|
||||
recursion_limit,
|
||||
stream_events,
|
||||
structured_json,
|
||||
update_events,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.graph.state import CompiledStateGraph
|
||||
from langgraph.types import Command
|
||||
|
||||
from litellm.llms.base_llm.harness.transformation import BaseHarnessConfig
|
||||
|
||||
_MODEL_NODE = "model"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeepAgentsDeps:
|
||||
"""The optional-dependency entrypoints this handler uses."""
|
||||
|
||||
create_deep_agent: Any
|
||||
chat_litellm: Any
|
||||
checkpointer_cls: Any
|
||||
command_cls: Any
|
||||
subagent_defaults: Mapping[str, Any]
|
||||
convert_to_openai_messages: Any
|
||||
backend: ModuleType
|
||||
|
||||
|
||||
def load_deps() -> DeepAgentsDeps:
|
||||
"""Import deepagents + langchain-litellm, or raise HarnessInstallFailed."""
|
||||
try:
|
||||
deepagents = importlib.import_module("deepagents")
|
||||
subagents = importlib.import_module("deepagents.middleware.subagents")
|
||||
chat = importlib.import_module("langchain_litellm")
|
||||
memory = importlib.import_module("langgraph.checkpoint.memory")
|
||||
lg_types = importlib.import_module("langgraph.types")
|
||||
messages = importlib.import_module("langchain_core.messages")
|
||||
backend = importlib.import_module("litellm.llms.deepagents.harness.sandbox_backend")
|
||||
except ImportError as e:
|
||||
raise HarnessInstallFailed(f"{INSTALL_HINT} ({e})") from e
|
||||
return DeepAgentsDeps(
|
||||
create_deep_agent=deepagents.create_deep_agent,
|
||||
chat_litellm=chat.ChatLiteLLM,
|
||||
checkpointer_cls=memory.InMemorySaver,
|
||||
command_cls=lg_types.Command,
|
||||
subagent_defaults=subagents.GENERAL_PURPOSE_SUBAGENT,
|
||||
convert_to_openai_messages=messages.convert_to_openai_messages,
|
||||
backend=backend,
|
||||
)
|
||||
|
||||
|
||||
_SHARED_CHECKPOINTER: dict[str, Any] = {} # mutable-ok: process-wide lazy singleton slot for the in-memory checkpointer
|
||||
|
||||
|
||||
def shared_checkpointer(deps: DeepAgentsDeps) -> BaseCheckpointSaver:
|
||||
"""One in-memory checkpointer per process, so resume() works across sessions in-process."""
|
||||
saver = _SHARED_CHECKPOINTER.get("saver")
|
||||
if saver is None:
|
||||
saver = deps.checkpointer_cls()
|
||||
_SHARED_CHECKPOINTER["saver"] = saver
|
||||
return saver
|
||||
|
||||
|
||||
def build_chat_model(ctx: SessionContext, deps: DeepAgentsDeps) -> BaseChatModel:
|
||||
"""The LangChain chat model for this session. Tests monkeypatch this."""
|
||||
return deps.chat_litellm(**chat_model_kwargs(ctx))
|
||||
|
||||
|
||||
class DeepAgentsHandler(BaseHarnessHandler):
|
||||
def __init__(self, config: BaseHarnessConfig) -> None:
|
||||
super().__init__(config)
|
||||
self._deps: DeepAgentsDeps | None = None
|
||||
self._agent: Any = None
|
||||
self._thread_id: str | None = None
|
||||
self._skip_tools: frozenset[str] = frozenset()
|
||||
|
||||
async def start(self, ctx: SessionContext) -> None:
|
||||
self.config.validate_environment(ctx)
|
||||
deps = load_deps()
|
||||
self._deps = deps
|
||||
blocked = blocked_tools(ctx.permissions, ctx.disable_tools)
|
||||
backend = deps.backend.SandboxBackend(
|
||||
ctx.sandbox,
|
||||
loop=asyncio.get_running_loop(),
|
||||
writable=WRITE_TOOLS.isdisjoint(blocked),
|
||||
allow_execute=EXECUTE_TOOLS.isdisjoint(blocked),
|
||||
)
|
||||
self._agent = deps.create_deep_agent(
|
||||
model=build_chat_model(ctx, deps),
|
||||
tools=list(ctx.tools), # mutable-ok: deepagents create_deep_agent(tools=) takes a list
|
||||
system_prompt=ctx.instructions,
|
||||
middleware=self._middleware(deps, blocked),
|
||||
subagents=self._subagents(ctx, deps, blocked),
|
||||
skills=await self._install_skills(ctx),
|
||||
backend=backend,
|
||||
interrupt_on=interrupt_config(ctx.permissions, blocked),
|
||||
response_format=ctx.output,
|
||||
checkpointer=shared_checkpointer(deps),
|
||||
)
|
||||
self._skip_tools = frozenset({ctx.output.__name__}) if ctx.output is not None else frozenset()
|
||||
if self._thread_id is None:
|
||||
self._thread_id = ctx.session_id
|
||||
|
||||
async def stop(self, ctx: SessionContext) -> None:
|
||||
self._agent = None
|
||||
|
||||
def native_session_id(self) -> str | None:
|
||||
return self._thread_id
|
||||
|
||||
async def resume(self, ctx: SessionContext, native_session_id: str) -> None:
|
||||
self._thread_id = native_session_id
|
||||
|
||||
async def history(
|
||||
self, ctx: SessionContext
|
||||
) -> list[dict[str, Any]]: # mutable-ok: BaseHarnessHandler.history API returns OpenAI message dicts
|
||||
agent, deps = self._require_agent()
|
||||
snapshot = await agent.aget_state(self._run_config(ctx, None))
|
||||
messages = (snapshot.values or MappingProxyType({})).get("messages") or ()
|
||||
converted: list[dict[str, Any]] = deps.convert_to_openai_messages( # mutable-ok: LangChain returns a list
|
||||
messages
|
||||
)
|
||||
return converted
|
||||
|
||||
async def turn(self, ctx: SessionContext, prompt: str) -> AsyncIterator[Event]:
|
||||
agent, deps = self._require_agent()
|
||||
run_config = self._run_config(ctx, deps.backend.UsageCallback(ctx, ctx.model))
|
||||
user_message = {"role": "user", "content": prompt} # mutable-ok: LangGraph input message dict
|
||||
payload: dict[str, object] | Command = {"messages": [user_message]} # mutable-ok: LangGraph input state
|
||||
while True:
|
||||
state = TurnState()
|
||||
async for event in self._stream_pass(agent, payload, run_config, state):
|
||||
yield event
|
||||
if not state.interrupts:
|
||||
break
|
||||
resume: dict[str, Any] = {} # mutable-ok: Command(resume=) payload, filled per answered approval
|
||||
for interrupt in state.interrupts:
|
||||
decisions: list[dict[str, Any]] = [] # mutable-ok: HITL decisions collected across awaited approvals
|
||||
for request in approval_requests(getattr(interrupt, "value", None)):
|
||||
approval = Approval(
|
||||
tool=normalized_tool_name(str(request.get("name") or "")),
|
||||
input=dict(request.get("args") or ()), # mutable-ok: Approval.input is a public dict field
|
||||
)
|
||||
yield approval
|
||||
decisions.append(decision(*await approval.wait()))
|
||||
resume[interrupt.id] = {"decisions": decisions} # mutable-ok: LangGraph HITL resume payload
|
||||
payload = deps.command_cls(resume=resume)
|
||||
await self._finish_turn(ctx, agent, run_config)
|
||||
|
||||
async def _stream_pass(
|
||||
self,
|
||||
agent: CompiledStateGraph,
|
||||
payload: dict[str, object] | Command, # mutable-ok: LangGraph astream input type
|
||||
run_config: RunnableConfig,
|
||||
state: TurnState,
|
||||
) -> AsyncIterator[Event]:
|
||||
async for part in agent.astream(
|
||||
payload,
|
||||
run_config,
|
||||
stream_mode=["messages", "updates"], # mutable-ok: LangGraph stream_mode takes a list
|
||||
):
|
||||
# A list stream_mode yields (mode, chunk) tuples; LangGraph's overloads don't say so.
|
||||
if not isinstance(part, tuple) or len(part) != 2:
|
||||
continue
|
||||
mode, chunk = part
|
||||
if mode == "messages":
|
||||
message, meta = chunk
|
||||
if isinstance(meta, Mapping) and meta.get("langgraph_node") == _MODEL_NODE:
|
||||
for event in stream_events(message):
|
||||
yield event
|
||||
elif mode == "updates":
|
||||
state.interrupts = (*state.interrupts, *interrupts_in(chunk))
|
||||
for event in update_events(chunk, self._skip_tools):
|
||||
yield event
|
||||
|
||||
async def _finish_turn(self, ctx: SessionContext, agent: CompiledStateGraph, run_config: RunnableConfig) -> None:
|
||||
snapshot = await agent.aget_state(run_config)
|
||||
values = snapshot.values or MappingProxyType({})
|
||||
ctx.final_text = final_ai_text(values.get("messages") or ())
|
||||
if ctx.output is not None:
|
||||
ctx.output_json = structured_json(values.get("structured_response"))
|
||||
|
||||
def _require_agent(self) -> tuple[Any, DeepAgentsDeps]:
|
||||
if self._agent is None or self._deps is None:
|
||||
raise HarnessError("Deep Agents session is not started")
|
||||
return self._agent, self._deps
|
||||
|
||||
def _run_config(self, ctx: SessionContext, usage_callback: BaseCallbackHandler | None) -> RunnableConfig:
|
||||
run_config: RunnableConfig = {
|
||||
"configurable": {"thread_id": self._thread_id or ctx.session_id},
|
||||
"recursion_limit": recursion_limit(ctx),
|
||||
}
|
||||
if usage_callback is not None:
|
||||
run_config["callbacks"] = [usage_callback] # mutable-ok: LangChain RunnableConfig.callbacks is a list
|
||||
return run_config
|
||||
|
||||
@staticmethod
|
||||
def _middleware(deps: DeepAgentsDeps, blocked: frozenset[str]) -> list[Any]: # mutable-ok: deepagents API
|
||||
filters = (deps.backend.ToolFilterMiddleware(blocked),) if blocked else ()
|
||||
return list(filters) # mutable-ok: deepagents create_deep_agent(middleware=) takes a list
|
||||
|
||||
def _subagents(
|
||||
self, ctx: SessionContext, deps: DeepAgentsDeps, blocked: frozenset[str]
|
||||
) -> list[Any]: # mutable-ok: deepagents create_deep_agent(subagents=) takes a list
|
||||
"""User subagents, plus a general-purpose one that honours disable_tools when set."""
|
||||
options = ctx.options if isinstance(ctx.options, DeepAgentsOptions) else None
|
||||
user_subagents = tuple(options.subagents) if options is not None else ()
|
||||
has_general = any(
|
||||
isinstance(s, Mapping) and s.get("name") == deps.subagent_defaults["name"] for s in user_subagents
|
||||
)
|
||||
spec = {**deps.subagent_defaults, "middleware": self._middleware(deps, blocked)} # mutable-ok: SubAgent dict
|
||||
general = (spec,) if blocked and not has_general else ()
|
||||
return [*general, *user_subagents] # mutable-ok: deepagents create_deep_agent(subagents=) takes a list
|
||||
|
||||
@staticmethod
|
||||
async def _install_skills(ctx: SessionContext) -> list[str] | None: # mutable-ok: deepagents skills= takes a list
|
||||
if not ctx.skills:
|
||||
return None
|
||||
await copy_skills(ctx.sandbox, ctx.skills, f"{ctx.sandbox.workdir}/{SKILLS_DIR}")
|
||||
return [f"/{SKILLS_DIR}/"] # mutable-ok: deepagents create_deep_agent(skills=) takes a list
|
||||
37
litellm/harness/options.py
Normal file
37
litellm/harness/options.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Typed per-harness options. Settings that only make sense for one runtime live here."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaudeCodeOptions:
|
||||
config: Mapping[str, Any] = field(default_factory=dict)
|
||||
env: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CodexOptions:
|
||||
reasoning_effort: Literal["low", "medium", "high", "xhigh"] | None = None
|
||||
web_search: bool = False
|
||||
config: Mapping[str, Any] = field(default_factory=dict)
|
||||
env: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenCodeOptions:
|
||||
agent: str = "build"
|
||||
config: Mapping[str, Any] = field(default_factory=dict)
|
||||
env: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeepAgentsOptions:
|
||||
subagents: Sequence[Any] = ()
|
||||
recursion_limit: int | None = None
|
||||
|
||||
|
||||
HarnessOptions = ClaudeCodeOptions | CodexOptions | OpenCodeOptions | DeepAgentsOptions
|
||||
1070
litellm/harness/runtime.py
Normal file
1070
litellm/harness/runtime.py
Normal file
File diff suppressed because it is too large
Load diff
25
litellm/harness/sandbox/__init__.py
Normal file
25
litellm/harness/sandbox/__init__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Sandboxes for litellm.harness: where the runtime runs and which files it can touch."""
|
||||
|
||||
from litellm.harness.sandbox.base import CompletedRun, Process, Sandbox
|
||||
from litellm.harness.sandbox.docker import DockerSandbox, docker
|
||||
from litellm.harness.sandbox.local import LocalSandbox, local
|
||||
from litellm.harness.sandbox.snapshot import (
|
||||
build_file_changes,
|
||||
capture_text_contents,
|
||||
diff_snapshots,
|
||||
snapshot_local,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"CompletedRun",
|
||||
"DockerSandbox",
|
||||
"LocalSandbox",
|
||||
"Process",
|
||||
"Sandbox",
|
||||
"build_file_changes",
|
||||
"capture_text_contents",
|
||||
"diff_snapshots",
|
||||
"docker",
|
||||
"local",
|
||||
"snapshot_local",
|
||||
)
|
||||
62
litellm/harness/sandbox/base.py
Normal file
62
litellm/harness/sandbox/base.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""The Sandbox protocol: where a harness runtime runs and which files it can touch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletedRun:
|
||||
stdout: str
|
||||
stderr: str
|
||||
exit_code: int
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Process(Protocol):
|
||||
stdin: asyncio.StreamWriter | None
|
||||
stdout: asyncio.StreamReader
|
||||
stderr: asyncio.StreamReader
|
||||
|
||||
async def wait(self) -> int: ...
|
||||
|
||||
async def kill(self) -> None: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Sandbox(Protocol):
|
||||
workdir: str
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
) -> Process: ...
|
||||
|
||||
async def run(
|
||||
self,
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> CompletedRun: ...
|
||||
|
||||
async def read(self, path: str) -> bytes: ...
|
||||
|
||||
async def write(self, path: str, data: bytes) -> None: ...
|
||||
|
||||
def host_url(self, port: int) -> str: ...
|
||||
|
||||
async def which(self, binary: str) -> str | None: ...
|
||||
|
||||
async def snapshot(self) -> Mapping[str, str]: ...
|
||||
|
||||
async def tempdir(self) -> str: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
298
litellm/harness/sandbox/docker.py
Normal file
298
litellm/harness/sandbox/docker.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"""DockerSandbox: run the harness runtime inside a container via the docker CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import posixpath
|
||||
import shutil
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from litellm.constants import HARNESS_SNAPSHOT_SKIP_DIRS
|
||||
from litellm.harness.errors import SandboxError
|
||||
from litellm.harness.sandbox.base import CompletedRun
|
||||
from litellm.harness.sandbox.local import SubprocessHandle, collect_output
|
||||
from litellm.harness.sandbox.snapshot import HARNESS_SNAPSHOT_MAX_FILE_BYTES
|
||||
|
||||
DOCKER_HOST_ALIAS: Final = "host.docker.internal"
|
||||
_SHA256_HEX_LEN: Final = 64
|
||||
_WRITE_SCRIPT: Final = 'mkdir -p "$(dirname "$1")" && cat > "$1"'
|
||||
_WHICH_SCRIPT: Final = 'command -v "$1"'
|
||||
|
||||
|
||||
def _snapshot_script() -> str:
|
||||
prune = " -o ".join(f"-name '{name}'" for name in sorted(HARNESS_SNAPSHOT_SKIP_DIRS))
|
||||
return (
|
||||
'cd "$1" && find . -type d \\( '
|
||||
+ prune
|
||||
+ " \\) -prune -o -type f -size -"
|
||||
+ f"{HARNESS_SNAPSHOT_MAX_FILE_BYTES + 1}c"
|
||||
+ " -exec sha256sum {} +"
|
||||
)
|
||||
|
||||
|
||||
def parse_sha256sum(output: str) -> Mapping[str, str]:
|
||||
"""Parse `sha256sum` lines ("<hex> ./rel/path") into {rel/path: hex}."""
|
||||
return MappingProxyType(
|
||||
{
|
||||
line[_SHA256_HEX_LEN + 2 :].removeprefix("./"): line[:_SHA256_HEX_LEN]
|
||||
for line in output.splitlines()
|
||||
if len(line) > _SHA256_HEX_LEN + 2
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DockerSandbox:
|
||||
"""Sandbox backed by a long-lived `sleep infinity` container."""
|
||||
|
||||
# Harness configs read this to skip a runtime's own nested OS sandbox.
|
||||
is_container = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image: str,
|
||||
mounts: Mapping[str | os.PathLike[str], str] | None = None,
|
||||
workdir: str = "/workspace",
|
||||
env: Mapping[str, str] | None = None,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
if not image:
|
||||
raise SandboxError("docker sandbox needs an image")
|
||||
if not posixpath.isabs(workdir):
|
||||
raise SandboxError(f"docker workdir must be absolute: {workdir}")
|
||||
self.image = image
|
||||
self.workdir: str = posixpath.normpath(workdir)
|
||||
self.mounts: Mapping[str, str] = MappingProxyType(
|
||||
{os.path.abspath(os.fspath(host)): container for host, container in (mounts.items() if mounts else ())}
|
||||
)
|
||||
self.env: Mapping[str, str] = MappingProxyType(dict(env or ()))
|
||||
self.name = name
|
||||
self.container_id: str | None = None
|
||||
self._start_lock = asyncio.Lock()
|
||||
self._processes: set[SubprocessHandle] = set() # mutable-ok: live-process registry (add/discard)
|
||||
self._closed = False
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"DockerSandbox({self.image!r}, workdir={self.workdir!r})"
|
||||
|
||||
# -- docker CLI plumbing (tests monkeypatch these two) ---------------------
|
||||
|
||||
def _docker_binary(self) -> str:
|
||||
binary = shutil.which("docker")
|
||||
if binary is None:
|
||||
raise SandboxError(
|
||||
"docker sandbox requires the `docker` CLI on PATH; install Docker or use sandbox.local(path)"
|
||||
)
|
||||
return binary
|
||||
|
||||
async def _spawn(self, args: Sequence[str]) -> SubprocessHandle:
|
||||
"""Start `docker <args>` with stdin/stdout/stderr pipes."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
self._docker_binary(),
|
||||
*args,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except (FileNotFoundError, PermissionError) as exc:
|
||||
raise SandboxError(f"could not run docker: {exc}") from exc
|
||||
return SubprocessHandle(proc)
|
||||
|
||||
async def _docker(
|
||||
self,
|
||||
args: Sequence[str],
|
||||
*,
|
||||
input: bytes | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> tuple[int, bytes, bytes]:
|
||||
"""Run `docker <args>` to completion; returns (exit_code, stdout, stderr)."""
|
||||
handle = await self._spawn(args)
|
||||
try:
|
||||
return await asyncio.wait_for(_communicate(handle, input), timeout)
|
||||
except asyncio.TimeoutError:
|
||||
await handle.kill()
|
||||
raise SandboxError(f"docker {args[0]} timed out after {timeout}s")
|
||||
|
||||
# -- command construction --------------------------------------------------
|
||||
|
||||
def run_args(
|
||||
self,
|
||||
) -> list[str]: # mutable-ok: argv is returned as a list, the shape callers and tests compare against
|
||||
name_args = ("--name", self.name) if self.name else ()
|
||||
mount_args = tuple(
|
||||
arg for host, container in self.mounts.items() for arg in ("-v", f"{host}:{container}")
|
||||
) # comprehension-ok: flattens (flag, value) pairs into argv
|
||||
env_args = tuple(
|
||||
arg for key, value in self.env.items() for arg in ("-e", f"{key}={value}")
|
||||
) # comprehension-ok: flattens (flag, value) pairs into argv
|
||||
return [ # mutable-ok: argv is returned as a list, the shape callers and tests compare against
|
||||
"run",
|
||||
"-d",
|
||||
"--rm",
|
||||
f"--add-host={DOCKER_HOST_ALIAS}:host-gateway",
|
||||
*name_args,
|
||||
*mount_args,
|
||||
*env_args,
|
||||
"-w",
|
||||
self.workdir,
|
||||
self.image,
|
||||
"sleep",
|
||||
"infinity",
|
||||
]
|
||||
|
||||
def exec_args(
|
||||
self,
|
||||
container_id: str,
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
) -> list[str]: # mutable-ok: argv is returned as a list, the shape callers and tests compare against
|
||||
env_args = tuple(
|
||||
arg for key, value in (env.items() if env else ()) for arg in ("-e", f"{key}={value}")
|
||||
) # comprehension-ok: flattens (flag, value) pairs into argv
|
||||
return [ # mutable-ok: argv is returned as a list, the shape callers and tests compare against
|
||||
"exec",
|
||||
"-i",
|
||||
"-w",
|
||||
self.container_path(cwd or self.workdir),
|
||||
*env_args,
|
||||
container_id,
|
||||
*cmd,
|
||||
]
|
||||
|
||||
def container_path(self, path: str) -> str:
|
||||
"""Absolute container path; relative paths resolve against workdir."""
|
||||
joined = path if posixpath.isabs(path) else posixpath.join(self.workdir, path)
|
||||
return posixpath.normpath(joined)
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------------
|
||||
|
||||
async def start(self) -> str:
|
||||
"""Start the container if needed and return its id."""
|
||||
if self._closed:
|
||||
raise SandboxError("sandbox is closed")
|
||||
async with self._start_lock:
|
||||
if self.container_id is not None:
|
||||
return self.container_id
|
||||
code, out, err = await self._docker(self.run_args())
|
||||
if code != 0:
|
||||
raise SandboxError(f"docker run {self.image} failed ({code}): {err.decode(errors='replace').strip()}")
|
||||
container_id = out.decode().strip()
|
||||
if not container_id:
|
||||
raise SandboxError("docker run returned no container id")
|
||||
self.container_id = container_id
|
||||
return container_id
|
||||
|
||||
async def _exec_capture(self, cmd: Sequence[str], *, input: bytes | None = None) -> tuple[int, bytes, bytes]:
|
||||
container_id = await self.start()
|
||||
return await self._docker(self.exec_args(container_id, cmd), input=input)
|
||||
|
||||
# -- Sandbox protocol --------------------------------------------------------
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
) -> SubprocessHandle:
|
||||
if not cmd:
|
||||
raise SandboxError("exec() needs a non-empty command")
|
||||
container_id = await self.start()
|
||||
handle = await self._spawn(self.exec_args(container_id, cmd, env=env, cwd=cwd))
|
||||
self._processes.add(handle)
|
||||
return handle
|
||||
|
||||
async def run(
|
||||
self,
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> CompletedRun:
|
||||
handle = await self.exec(cmd, env=env, cwd=cwd)
|
||||
try:
|
||||
return await collect_output(handle, cmd, timeout)
|
||||
finally:
|
||||
self._processes.discard(handle)
|
||||
|
||||
async def read(self, path: str) -> bytes:
|
||||
target = self.container_path(path)
|
||||
code, out, err = await self._exec_capture(("cat", target))
|
||||
if code != 0:
|
||||
raise SandboxError(f"could not read {target}: {err.decode(errors='replace').strip()}")
|
||||
return out
|
||||
|
||||
async def write(self, path: str, data: bytes) -> None:
|
||||
target = self.container_path(path)
|
||||
code, _, err = await self._exec_capture(("sh", "-c", _WRITE_SCRIPT, "sh", target), input=data)
|
||||
if code != 0:
|
||||
raise SandboxError(f"could not write {target}: {err.decode(errors='replace').strip()}")
|
||||
|
||||
def host_url(self, port: int) -> str:
|
||||
return f"http://{DOCKER_HOST_ALIAS}:{port}"
|
||||
|
||||
async def which(self, binary: str) -> str | None:
|
||||
code, out, _ = await self._exec_capture(("sh", "-lc", _WHICH_SCRIPT, "sh", binary))
|
||||
found = out.decode(errors="replace").strip()
|
||||
return found if code == 0 and found else None
|
||||
|
||||
async def tempdir(self) -> str:
|
||||
"""A fresh `mktemp -d` directory inside the container."""
|
||||
code, out, err = await self._exec_capture(("mktemp", "-d"))
|
||||
path = out.decode(errors="replace").strip()
|
||||
if code != 0 or not path:
|
||||
raise SandboxError(f"mktemp -d failed: {err.decode(errors='replace').strip()}")
|
||||
return path
|
||||
|
||||
async def snapshot(self) -> Mapping[str, str]:
|
||||
code, out, err = await self._exec_capture(("sh", "-c", _snapshot_script(), "sh", self.workdir))
|
||||
if code != 0:
|
||||
raise SandboxError(f"snapshot failed: {err.decode(errors='replace').strip()}")
|
||||
return parse_sha256sum(out.decode("utf-8", errors="replace"))
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
live = tuple(h for h in self._processes if h.returncode is None)
|
||||
await asyncio.gather(*(h.kill() for h in live), return_exceptions=True)
|
||||
self._processes.clear()
|
||||
if self.container_id is not None:
|
||||
container_id, self.container_id = self.container_id, None
|
||||
await self._docker(
|
||||
["rm", "-f", container_id] # mutable-ok: argv list, the shape _spawn records and tests assert on
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> DockerSandbox:
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.close()
|
||||
|
||||
|
||||
async def _communicate(handle: SubprocessHandle, data: bytes | None) -> tuple[int, bytes, bytes]:
|
||||
if handle.stdin is not None:
|
||||
if data:
|
||||
handle.stdin.write(data)
|
||||
await handle.stdin.drain()
|
||||
handle.stdin.close()
|
||||
stdout, stderr = await asyncio.gather(handle.stdout.read(), handle.stderr.read())
|
||||
return await handle.wait(), stdout, stderr
|
||||
|
||||
|
||||
def docker(
|
||||
image: str,
|
||||
mounts: Mapping[str | os.PathLike[str], str] | None = None,
|
||||
workdir: str = "/workspace",
|
||||
env: Mapping[str, str] | None = None,
|
||||
name: str | None = None,
|
||||
) -> DockerSandbox:
|
||||
"""Sandbox in a new container of `image`, started lazily on first use."""
|
||||
return DockerSandbox(image, mounts=mounts, workdir=workdir, env=env, name=name)
|
||||
277
litellm/harness/sandbox/local.py
Normal file
277
litellm/harness/sandbox/local.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
"""LocalSandbox: run the harness runtime as a subprocess on this machine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import tempfile
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from litellm.constants import HARNESS_PROCESS_KILL_GRACE_SECONDS
|
||||
from litellm.harness.errors import SandboxError
|
||||
from litellm.harness.sandbox.base import CompletedRun
|
||||
from litellm.harness.sandbox.snapshot import snapshot_local
|
||||
|
||||
_SECRET_PREFIXES: Final = (
|
||||
"ANTHROPIC_",
|
||||
"OPENAI_",
|
||||
"LITELLM_",
|
||||
"AZURE_",
|
||||
"AWS_",
|
||||
"GEMINI_",
|
||||
"CODEX_",
|
||||
"CURSOR_",
|
||||
"VERTEX",
|
||||
# A parent Claude Code session's socket/session vars make a child `claude` attach to
|
||||
# the parent's login instead of the harness token.
|
||||
"CLAUDE_CODE_",
|
||||
"CLAUDE_PID",
|
||||
"CLAUDECODE",
|
||||
)
|
||||
_SECRET_NAMES: Final = frozenset({"GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS"})
|
||||
_SECRET_SUBSTRINGS: Final = ("API_KEY", "TOKEN", "SECRET")
|
||||
_TEMPDIR_PREFIX: Final = "litellm-harness-"
|
||||
|
||||
|
||||
def is_secret_env_name(name: str) -> bool:
|
||||
"""True if an env var name looks like a provider credential."""
|
||||
upper = name.upper()
|
||||
if upper in _SECRET_NAMES or upper.startswith(_SECRET_PREFIXES):
|
||||
return True
|
||||
return any(part in upper for part in _SECRET_SUBSTRINGS)
|
||||
|
||||
|
||||
def filtered_environ(
|
||||
base: Mapping[str, str] | None = None,
|
||||
extra: Mapping[str, str] | None = None,
|
||||
) -> Mapping[str, str]:
|
||||
"""base (default os.environ) without provider secrets, then extra on top."""
|
||||
source = os.environ if base is None else base
|
||||
kept = ((k, v) for k, v in source.items() if not is_secret_env_name(k))
|
||||
overlay = extra.items() if extra else ()
|
||||
return MappingProxyType(dict(itertools.chain(kept, overlay)))
|
||||
|
||||
|
||||
def _signal_process(proc: asyncio.subprocess.Process, sig: int) -> None:
|
||||
try:
|
||||
os.killpg(proc.pid, sig)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
try:
|
||||
proc.send_signal(sig)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
class SubprocessHandle:
|
||||
"""Process-protocol wrapper around an asyncio subprocess."""
|
||||
|
||||
def __init__(self, proc: asyncio.subprocess.Process) -> None:
|
||||
if proc.stdout is None or proc.stderr is None:
|
||||
raise SandboxError("subprocess was started without stdout/stderr pipes")
|
||||
self._proc = proc
|
||||
self.stdin: asyncio.StreamWriter | None = proc.stdin
|
||||
self.stdout: asyncio.StreamReader = proc.stdout
|
||||
self.stderr: asyncio.StreamReader = proc.stderr
|
||||
|
||||
@property
|
||||
def pid(self) -> int:
|
||||
return self._proc.pid
|
||||
|
||||
@property
|
||||
def returncode(self) -> int | None:
|
||||
return self._proc.returncode
|
||||
|
||||
async def wait(self) -> int:
|
||||
return await self._proc.wait()
|
||||
|
||||
async def kill(self) -> None:
|
||||
"""SIGTERM, wait HARNESS_PROCESS_KILL_GRACE_SECONDS, then SIGKILL."""
|
||||
if self._proc.returncode is not None:
|
||||
return
|
||||
_signal_process(self._proc, signal.SIGTERM)
|
||||
try:
|
||||
await asyncio.wait_for(self._proc.wait(), timeout=HARNESS_PROCESS_KILL_GRACE_SECONDS)
|
||||
return
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
_signal_process(self._proc, signal.SIGKILL)
|
||||
await self._proc.wait()
|
||||
|
||||
|
||||
async def _read_all(handle: SubprocessHandle) -> tuple[bytes, bytes, int]:
|
||||
if handle.stdin is not None:
|
||||
handle.stdin.close()
|
||||
stdout, stderr = await asyncio.gather(handle.stdout.read(), handle.stderr.read())
|
||||
exit_code = await handle.wait()
|
||||
return stdout, stderr, exit_code
|
||||
|
||||
|
||||
async def collect_output(handle: SubprocessHandle, cmd: Sequence[str], timeout: float | None) -> CompletedRun:
|
||||
"""Close stdin, read stdout/stderr to EOF; kill and raise SandboxError on timeout."""
|
||||
try:
|
||||
stdout, stderr, code = await asyncio.wait_for(_read_all(handle), timeout)
|
||||
except asyncio.TimeoutError:
|
||||
await handle.kill()
|
||||
raise SandboxError(f"command timed out after {timeout}s: {cmd[0]}")
|
||||
return CompletedRun(
|
||||
stdout=stdout.decode("utf-8", errors="replace"),
|
||||
stderr=stderr.decode("utf-8", errors="replace"),
|
||||
exit_code=code,
|
||||
)
|
||||
|
||||
|
||||
def _is_within(path: str, root: str) -> bool:
|
||||
return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
|
||||
|
||||
|
||||
class LocalSandbox:
|
||||
"""Sandbox backed by the local filesystem and asyncio subprocesses."""
|
||||
|
||||
def __init__(self, path: str | os.PathLike[str]) -> None:
|
||||
resolved = os.path.realpath(os.path.abspath(os.fspath(path)))
|
||||
if not os.path.isdir(resolved):
|
||||
raise SandboxError(f"sandbox path does not exist or is not a directory: {resolved}")
|
||||
self.workdir: str = resolved
|
||||
self._processes: set[SubprocessHandle] = set() # mutable-ok: live-process registry (add/discard)
|
||||
self._tempdirs: list[str] = [] # mutable-ok: tempdirs created on demand by tempdir(), removed on close()
|
||||
self._closed = False
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"LocalSandbox({self.workdir!r})"
|
||||
|
||||
def _check_open(self) -> None:
|
||||
if self._closed:
|
||||
raise SandboxError("sandbox is closed")
|
||||
|
||||
def _allowed_roots(self) -> tuple[str, ...]:
|
||||
return (self.workdir, *self._tempdirs)
|
||||
|
||||
def resolve_path(self, path: str) -> str:
|
||||
"""Absolute real path for path; SandboxError if it escapes the sandbox."""
|
||||
joined = path if os.path.isabs(path) else os.path.join(self.workdir, path)
|
||||
real = os.path.realpath(joined)
|
||||
if not any(_is_within(real, root) for root in self._allowed_roots()):
|
||||
raise SandboxError(f"path escapes the sandbox: {path}")
|
||||
return real
|
||||
|
||||
def _resolve_cwd(self, cwd: str | None) -> str:
|
||||
if cwd is None:
|
||||
return self.workdir
|
||||
resolved = self.resolve_path(cwd)
|
||||
if not os.path.isdir(resolved):
|
||||
raise SandboxError(f"cwd is not a directory: {cwd}")
|
||||
return resolved
|
||||
|
||||
def child_env(self, env: Mapping[str, str] | None = None) -> Mapping[str, str]:
|
||||
return filtered_environ(extra=env)
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
) -> SubprocessHandle:
|
||||
self._check_open()
|
||||
if not cmd:
|
||||
raise SandboxError("exec() needs a non-empty command")
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self._resolve_cwd(cwd),
|
||||
env=self.child_env(env),
|
||||
start_new_session=True,
|
||||
)
|
||||
except (FileNotFoundError, PermissionError) as exc:
|
||||
raise SandboxError(f"could not start {cmd[0]}: {exc}") from exc
|
||||
handle = SubprocessHandle(proc)
|
||||
self._processes.add(handle)
|
||||
return handle
|
||||
|
||||
async def run(
|
||||
self,
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
cwd: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> CompletedRun:
|
||||
handle = await self.exec(cmd, env=env, cwd=cwd)
|
||||
try:
|
||||
return await collect_output(handle, cmd, timeout)
|
||||
finally:
|
||||
self._processes.discard(handle)
|
||||
|
||||
async def read(self, path: str) -> bytes:
|
||||
self._check_open()
|
||||
resolved = self.resolve_path(path)
|
||||
try:
|
||||
return await asyncio.to_thread(_read_bytes, resolved)
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"could not read {path}: {exc}") from exc
|
||||
|
||||
async def write(self, path: str, data: bytes) -> None:
|
||||
self._check_open()
|
||||
resolved = self.resolve_path(path)
|
||||
try:
|
||||
await asyncio.to_thread(_write_bytes, resolved, data)
|
||||
except OSError as exc:
|
||||
raise SandboxError(f"could not write {path}: {exc}") from exc
|
||||
|
||||
def host_url(self, port: int) -> str:
|
||||
return f"http://127.0.0.1:{port}"
|
||||
|
||||
async def which(self, binary: str) -> str | None:
|
||||
return shutil.which(binary, path=self.child_env().get("PATH"))
|
||||
|
||||
async def tempdir(self) -> str:
|
||||
"""A private temp dir (e.g. for CODEX_HOME), removed on close()."""
|
||||
self._check_open()
|
||||
path = os.path.realpath(tempfile.mkdtemp(prefix=_TEMPDIR_PREFIX))
|
||||
self._tempdirs.append(path)
|
||||
return path
|
||||
|
||||
async def snapshot(self) -> Mapping[str, str]:
|
||||
self._check_open()
|
||||
return await snapshot_local(self.workdir)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
live = tuple(h for h in self._processes if h.returncode is None)
|
||||
await asyncio.gather(*(h.kill() for h in live), return_exceptions=True)
|
||||
self._processes.clear()
|
||||
for path in self._tempdirs:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
self._tempdirs.clear()
|
||||
|
||||
async def __aenter__(self) -> LocalSandbox:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.close()
|
||||
|
||||
|
||||
def _read_bytes(path: str) -> bytes:
|
||||
with open(path, "rb") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def _write_bytes(path: str, data: bytes) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(data)
|
||||
|
||||
|
||||
def local(path: str | os.PathLike[str]) -> LocalSandbox:
|
||||
"""Sandbox rooted at an existing local directory."""
|
||||
return LocalSandbox(path)
|
||||
183
litellm/harness/sandbox/snapshot.py
Normal file
183
litellm/harness/sandbox/snapshot.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""Workspace snapshots and FileChange construction.
|
||||
|
||||
A snapshot maps a workspace-relative POSIX path to the sha256 of its contents.
|
||||
Diffing two snapshots tells us which files a turn created, modified or deleted;
|
||||
`build_file_changes` turns that into `FileChange` events with unified diffs for
|
||||
small text files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import difflib
|
||||
import functools
|
||||
import hashlib
|
||||
import os
|
||||
from collections.abc import Iterator, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from litellm.constants import HARNESS_MAX_DIFF_BYTES, HARNESS_SNAPSHOT_SKIP_DIRS
|
||||
from litellm.harness.errors import HarnessError
|
||||
from litellm.harness.types import FileChange, FileChangeKind
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.harness.sandbox.base import Sandbox
|
||||
|
||||
# Files larger than this are left out of snapshots entirely.
|
||||
HARNESS_SNAPSHOT_MAX_FILE_BYTES: Final = 50 * 1024 * 1024
|
||||
# Upper bound on bytes read by capture_text_contents() for one turn.
|
||||
HARNESS_SNAPSHOT_MAX_TOTAL_BYTES: Final = 16 * 1024 * 1024
|
||||
_HASH_CHUNK_BYTES: Final = 1024 * 1024
|
||||
_NO_NEWLINE_MARKER: Final = "\\ No newline at end of file\n"
|
||||
|
||||
|
||||
def _hash_file(path: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(functools.partial(fh.read, _HASH_CHUNK_BYTES), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _hash_entry(root: str, dirpath: str, filename: str) -> tuple[str, str] | None:
|
||||
full = os.path.join(dirpath, filename)
|
||||
try:
|
||||
info = os.lstat(full)
|
||||
except OSError:
|
||||
return None
|
||||
if not os.path.isfile(full) or os.path.islink(full):
|
||||
return None
|
||||
if info.st_size > HARNESS_SNAPSHOT_MAX_FILE_BYTES:
|
||||
return None
|
||||
try:
|
||||
digest = _hash_file(full)
|
||||
except OSError:
|
||||
return None
|
||||
rel = os.path.relpath(full, root).replace(os.sep, "/")
|
||||
return rel, digest
|
||||
|
||||
|
||||
def _walk_entries(root: str) -> Iterator[tuple[str, str]]:
|
||||
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
dirnames[:] = [ # mutable-ok: os.walk prunes only via in-place mutation of its dirnames list
|
||||
d for d in dirnames if d not in HARNESS_SNAPSHOT_SKIP_DIRS
|
||||
]
|
||||
for filename in filenames:
|
||||
entry = _hash_entry(root, dirpath, filename)
|
||||
if entry is not None:
|
||||
yield entry
|
||||
|
||||
|
||||
def snapshot_local_sync(root: str) -> Mapping[str, str]:
|
||||
"""Hash every regular file under root. Symlinks are never followed."""
|
||||
return MappingProxyType(dict(_walk_entries(root)))
|
||||
|
||||
|
||||
async def snapshot_local(root: str) -> Mapping[str, str]:
|
||||
"""Async wrapper around snapshot_local_sync (runs in a worker thread)."""
|
||||
return await asyncio.to_thread(snapshot_local_sync, root)
|
||||
|
||||
|
||||
def _change_kind(path: str, before: Mapping[str, str], after: Mapping[str, str]) -> FileChangeKind | None:
|
||||
if path not in before:
|
||||
return "created"
|
||||
if path not in after:
|
||||
return "deleted"
|
||||
if before[path] != after[path]:
|
||||
return "modified"
|
||||
return None
|
||||
|
||||
|
||||
def diff_snapshots(
|
||||
before: Mapping[str, str], after: Mapping[str, str]
|
||||
) -> list[tuple[str, FileChangeKind]]: # mutable-ok: public sandbox helper; callers compare against a list
|
||||
"""Return (path, kind) for every changed file, sorted by path."""
|
||||
kinds = ((path, _change_kind(path, before, after)) for path in sorted(frozenset(before) | frozenset(after)))
|
||||
return [ # mutable-ok: public sandbox helper returns a list
|
||||
(path, kind) for path, kind in kinds if kind is not None
|
||||
]
|
||||
|
||||
|
||||
def _as_text(data: bytes) -> str | None:
|
||||
if len(data) > HARNESS_MAX_DIFF_BYTES or b"\0" in data:
|
||||
return None
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def unified_diff(path: str, old: str | None, new: str | None) -> str:
|
||||
"""Unified diff between two versions of path; None means the file is absent."""
|
||||
from_file = "/dev/null" if old is None else f"a/{path}"
|
||||
to_file = "/dev/null" if new is None else f"b/{path}"
|
||||
lines = difflib.unified_diff(
|
||||
(old or "").splitlines(keepends=True),
|
||||
(new or "").splitlines(keepends=True),
|
||||
fromfile=from_file,
|
||||
tofile=to_file,
|
||||
)
|
||||
return "".join(line if line.endswith("\n") else line + "\n" + _NO_NEWLINE_MARKER for line in lines)
|
||||
|
||||
|
||||
async def _read_or_none(sandbox: Sandbox, path: str) -> bytes | None:
|
||||
try:
|
||||
return await sandbox.read(path)
|
||||
except (HarnessError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
async def capture_text_contents(sandbox: Sandbox, paths_hashes: Mapping[str, str]) -> Mapping[str, bytes]:
|
||||
"""Read small text files before a turn so "modified"/"deleted" diffs can be built.
|
||||
|
||||
Each kept file is <= HARNESS_MAX_DIFF_BYTES; every byte read (kept or not) counts
|
||||
toward HARNESS_SNAPSHOT_MAX_TOTAL_BYTES, after which capture stops.
|
||||
"""
|
||||
captured: dict[str, bytes] = {} # mutable-ok: async accumulator (awaits per read), frozen on return
|
||||
total = 0
|
||||
for path in sorted(paths_hashes):
|
||||
if total >= HARNESS_SNAPSHOT_MAX_TOTAL_BYTES:
|
||||
break
|
||||
data = await _read_or_none(sandbox, path)
|
||||
if data is None:
|
||||
continue
|
||||
total += len(data)
|
||||
if _as_text(data) is not None:
|
||||
captured[path] = data
|
||||
return MappingProxyType(captured)
|
||||
|
||||
|
||||
async def _change_for(
|
||||
sandbox: Sandbox,
|
||||
path: str,
|
||||
kind: FileChangeKind,
|
||||
before_contents: Mapping[str, bytes],
|
||||
) -> FileChange:
|
||||
old_bytes = before_contents.get(path)
|
||||
old = _as_text(old_bytes) if old_bytes is not None else None
|
||||
if kind == "deleted":
|
||||
diff = unified_diff(path, old, None) if old is not None else None
|
||||
return FileChange(path=path, kind=kind, diff=diff)
|
||||
new_bytes = await _read_or_none(sandbox, path)
|
||||
new = _as_text(new_bytes) if new_bytes is not None else None
|
||||
if new is None or (kind == "modified" and old is None):
|
||||
return FileChange(path=path, kind=kind, diff=None)
|
||||
return FileChange(
|
||||
path=path,
|
||||
kind=kind,
|
||||
diff=unified_diff(path, old if kind == "modified" else None, new),
|
||||
)
|
||||
|
||||
|
||||
async def build_file_changes(
|
||||
sandbox: Sandbox,
|
||||
before: Mapping[str, str],
|
||||
after: Mapping[str, str],
|
||||
before_contents: Mapping[str, bytes] | None = None,
|
||||
) -> list[FileChange]: # mutable-ok: feeds the public Result.files list
|
||||
"""FileChange per changed path. diff is None when it cannot be built as text."""
|
||||
contents: Mapping[str, bytes] = before_contents or MappingProxyType({})
|
||||
return [ # mutable-ok: feeds the public Result.files list
|
||||
await _change_for(sandbox, path, kind, contents) for path, kind in diff_snapshots(before, after)
|
||||
]
|
||||
443
litellm/harness/sync.py
Normal file
443
litellm/harness/sync.py
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
"""Sync API for litellm.harness: one daemon event-loop thread runs every async call."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping, Sequence
|
||||
from concurrent.futures import Future
|
||||
from typing import (
|
||||
Any,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.harness.context import ApprovalHandler
|
||||
from litellm.harness.options import HarnessOptions
|
||||
from litellm.harness.runtime import (
|
||||
AsyncEventStream,
|
||||
AsyncSession,
|
||||
aagent_resume,
|
||||
aagent_session,
|
||||
arun_agent,
|
||||
astream_agent,
|
||||
)
|
||||
from litellm.harness.sandbox.base import Sandbox
|
||||
from litellm.harness.types import (
|
||||
Done,
|
||||
Event,
|
||||
Harness,
|
||||
PermissionMode,
|
||||
Result,
|
||||
State,
|
||||
Usage,
|
||||
)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
IN_LOOP_MESSAGE = (
|
||||
"litellm.{name}() cannot be called from a running event loop; use `await litellm.a{name}(...)` instead"
|
||||
)
|
||||
|
||||
|
||||
class _LoopThread:
|
||||
"""A single background event loop shared by every sync call in the process."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def loop(self) -> asyncio.AbstractEventLoop:
|
||||
with self._lock:
|
||||
if self._loop is None or self._thread is None or not self._thread.is_alive():
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop.run_forever,
|
||||
name="litellm-harness-loop",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
return self._loop
|
||||
|
||||
def in_loop_thread(self) -> bool:
|
||||
return self._thread is not None and threading.current_thread() is self._thread
|
||||
|
||||
def submit(self, coro: Coroutine[Any, Any, T]) -> Future[T]:
|
||||
return asyncio.run_coroutine_threadsafe(coro, self.loop())
|
||||
|
||||
|
||||
_LOOP = _LoopThread()
|
||||
|
||||
|
||||
def _ensure_sync_context(name: str) -> None:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
raise RuntimeError(IN_LOOP_MESSAGE.format(name=name))
|
||||
|
||||
|
||||
def run_sync(coro: Coroutine[Any, Any, T], name: str) -> T:
|
||||
"""Run coro on the harness loop thread and block for its result."""
|
||||
try:
|
||||
_ensure_sync_context(name)
|
||||
except RuntimeError:
|
||||
coro.close()
|
||||
raise
|
||||
future = _LOOP.submit(coro)
|
||||
try:
|
||||
return future.result()
|
||||
except KeyboardInterrupt:
|
||||
future.cancel()
|
||||
raise
|
||||
|
||||
|
||||
async def _anext(iterator: AsyncIterator[Event]) -> Event | None:
|
||||
try:
|
||||
return await iterator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
return None
|
||||
|
||||
|
||||
async def _aclose_stream(stream: AsyncEventStream) -> None:
|
||||
await stream.aclose()
|
||||
|
||||
|
||||
class EventStream:
|
||||
"""Sync iterator of events for one turn. `.result` is set once Done is seen."""
|
||||
|
||||
def __init__(self, stream: AsyncEventStream, name: str = "stream") -> None:
|
||||
self._stream = stream
|
||||
self._name = name
|
||||
self._result: Result | None = None
|
||||
self._finished = False
|
||||
|
||||
def __iter__(self) -> EventStream:
|
||||
return self
|
||||
|
||||
def __next__(self) -> Event:
|
||||
if self._finished:
|
||||
raise StopIteration
|
||||
event = run_sync(_anext(self._stream), self._name)
|
||||
if event is None:
|
||||
self._finished = True
|
||||
raise StopIteration
|
||||
if isinstance(event, Done):
|
||||
self._result = event.result
|
||||
return event
|
||||
|
||||
def __enter__(self) -> EventStream:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.close()
|
||||
|
||||
@property
|
||||
def result(self) -> Result | None:
|
||||
return self._result
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Stop the turn. Iteration still ends with Done(stop_reason='cancelled')."""
|
||||
_LOOP.loop().call_soon_threadsafe(self._stream.cancel)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Abandon the stream and release the session behind it."""
|
||||
if self._finished:
|
||||
return
|
||||
self._finished = True
|
||||
run_sync(_aclose_stream(self._stream), self._name)
|
||||
|
||||
|
||||
class Session:
|
||||
"""Sync multi-turn session. Use as a context manager."""
|
||||
|
||||
def __init__(self, inner: AsyncSession) -> None:
|
||||
self._inner = inner
|
||||
|
||||
@property
|
||||
def aio(self) -> AsyncSession:
|
||||
"""The underlying AsyncSession (runs on the harness loop thread)."""
|
||||
return self._inner
|
||||
|
||||
def start(self) -> Session:
|
||||
run_sync(self._inner.start(), "session")
|
||||
return self
|
||||
|
||||
def __enter__(self) -> Session:
|
||||
return self.start()
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.close()
|
||||
|
||||
def run(self, prompt: str) -> Result:
|
||||
return run_sync(self._inner.arun(prompt), "run")
|
||||
|
||||
def stream(self, prompt: str) -> EventStream:
|
||||
return EventStream(self._inner.astream(prompt))
|
||||
|
||||
def close(self) -> None:
|
||||
run_sync(self._inner.aclose(), "close")
|
||||
|
||||
def detach(self) -> State:
|
||||
return run_sync(self._inner.adetach(), "detach")
|
||||
|
||||
def stop(self) -> State:
|
||||
return run_sync(self._inner.astop(), "stop")
|
||||
|
||||
def history(
|
||||
self,
|
||||
) -> list[dict[str, Any]]: # mutable-ok: public API returns OpenAI-format message dicts from the handler
|
||||
return run_sync(self._inner.history(), "history")
|
||||
|
||||
@property
|
||||
def cost(self) -> float:
|
||||
return self._inner.cost
|
||||
|
||||
@property
|
||||
def usage(self) -> Usage:
|
||||
return self._inner.usage
|
||||
|
||||
@property
|
||||
def results(self) -> list[Result]: # mutable-ok: public property; returns a detached copy of the session's results
|
||||
return list(self._inner.results) # mutable-ok: detached copy so callers cannot mutate the session's accumulator
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
return self._inner.session_id
|
||||
|
||||
|
||||
def _run(
|
||||
harness: Harness,
|
||||
prompt: str,
|
||||
*,
|
||||
sandbox: Sandbox,
|
||||
model: str | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
instructions: str | None = None,
|
||||
tools: Sequence[Callable[..., Any]] = (),
|
||||
skills: Sequence[str | os.PathLike[str]] = (),
|
||||
disable_tools: Sequence[str] = (),
|
||||
permissions: PermissionMode = "full",
|
||||
on_approval: ApprovalHandler | None = None,
|
||||
output: type[BaseModel] | None = None,
|
||||
max_turns: int | None = None,
|
||||
timeout: float | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
options: HarnessOptions | None = None,
|
||||
install: bool = False,
|
||||
) -> Result:
|
||||
"""Run one prompt to completion (blocking) and return the Result."""
|
||||
return run_sync(
|
||||
arun_agent(
|
||||
harness,
|
||||
prompt,
|
||||
sandbox=sandbox,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
instructions=instructions,
|
||||
tools=tools,
|
||||
skills=skills,
|
||||
disable_tools=disable_tools,
|
||||
permissions=permissions,
|
||||
on_approval=on_approval,
|
||||
output=output,
|
||||
max_turns=max_turns,
|
||||
timeout=timeout,
|
||||
metadata=metadata,
|
||||
options=options,
|
||||
install=install,
|
||||
),
|
||||
"agent",
|
||||
)
|
||||
|
||||
|
||||
def _stream(
|
||||
harness: Harness,
|
||||
prompt: str,
|
||||
*,
|
||||
sandbox: Sandbox,
|
||||
model: str | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
instructions: str | None = None,
|
||||
tools: Sequence[Callable[..., Any]] = (),
|
||||
skills: Sequence[str | os.PathLike[str]] = (),
|
||||
disable_tools: Sequence[str] = (),
|
||||
permissions: PermissionMode = "full",
|
||||
on_approval: ApprovalHandler | None = None,
|
||||
output: type[BaseModel] | None = None,
|
||||
max_turns: int | None = None,
|
||||
timeout: float | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
options: HarnessOptions | None = None,
|
||||
install: bool = False,
|
||||
) -> EventStream:
|
||||
"""Stream events for one prompt (sync iterator). Validation errors raise here."""
|
||||
_ensure_sync_context("agent")
|
||||
inner = astream_agent(
|
||||
harness,
|
||||
prompt,
|
||||
sandbox=sandbox,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
instructions=instructions,
|
||||
tools=tools,
|
||||
skills=skills,
|
||||
disable_tools=disable_tools,
|
||||
permissions=permissions,
|
||||
on_approval=on_approval,
|
||||
output=output,
|
||||
max_turns=max_turns,
|
||||
timeout=timeout,
|
||||
metadata=metadata,
|
||||
options=options,
|
||||
install=install,
|
||||
)
|
||||
return EventStream(inner)
|
||||
|
||||
|
||||
def agent_session(
|
||||
harness: Harness,
|
||||
*,
|
||||
sandbox: Sandbox,
|
||||
model: str | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
instructions: str | None = None,
|
||||
tools: Sequence[Callable[..., Any]] = (),
|
||||
skills: Sequence[str | os.PathLike[str]] = (),
|
||||
disable_tools: Sequence[str] = (),
|
||||
permissions: PermissionMode = "full",
|
||||
on_approval: ApprovalHandler | None = None,
|
||||
output: type[BaseModel] | None = None,
|
||||
max_turns: int | None = None,
|
||||
timeout: float | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
options: HarnessOptions | None = None,
|
||||
install: bool = False,
|
||||
) -> Session:
|
||||
"""A multi-turn agent session: `with litellm.agent_session(...) as s: s.run(...)`."""
|
||||
_ensure_sync_context("agent_session")
|
||||
return Session(
|
||||
aagent_session(
|
||||
harness,
|
||||
sandbox=sandbox,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
instructions=instructions,
|
||||
tools=tools,
|
||||
skills=skills,
|
||||
disable_tools=disable_tools,
|
||||
permissions=permissions,
|
||||
on_approval=on_approval,
|
||||
output=output,
|
||||
max_turns=max_turns,
|
||||
timeout=timeout,
|
||||
metadata=metadata,
|
||||
options=options,
|
||||
install=install,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def agent_resume(
|
||||
state: State | bytes,
|
||||
*,
|
||||
sandbox: Sandbox,
|
||||
model: str | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
instructions: str | None = None,
|
||||
tools: Sequence[Callable[..., Any]] = (),
|
||||
skills: Sequence[str | os.PathLike[str]] = (),
|
||||
disable_tools: Sequence[str] = (),
|
||||
permissions: PermissionMode = "full",
|
||||
on_approval: ApprovalHandler | None = None,
|
||||
output: type[BaseModel] | None = None,
|
||||
max_turns: int | None = None,
|
||||
timeout: float | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
options: HarnessOptions | None = None,
|
||||
install: bool = False,
|
||||
) -> Session:
|
||||
"""Continue a detached or stopped agent session from its State."""
|
||||
_ensure_sync_context("agent_resume")
|
||||
return Session(
|
||||
aagent_resume(
|
||||
state,
|
||||
sandbox=sandbox,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
instructions=instructions,
|
||||
tools=tools,
|
||||
skills=skills,
|
||||
disable_tools=disable_tools,
|
||||
permissions=permissions,
|
||||
on_approval=on_approval,
|
||||
output=output,
|
||||
max_turns=max_turns,
|
||||
timeout=timeout,
|
||||
metadata=metadata,
|
||||
options=options,
|
||||
install=install,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def agent(
|
||||
harness: Harness,
|
||||
prompt: str,
|
||||
*,
|
||||
sandbox: Sandbox,
|
||||
stream: bool = False,
|
||||
model: str | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
instructions: str | None = None,
|
||||
tools: Sequence[Callable[..., Any]] = (),
|
||||
skills: Sequence[str | os.PathLike[str]] = (),
|
||||
disable_tools: Sequence[str] = (),
|
||||
permissions: PermissionMode = "full",
|
||||
on_approval: ApprovalHandler | None = None,
|
||||
output: type[BaseModel] | None = None,
|
||||
max_turns: int | None = None,
|
||||
timeout: float | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
options: HarnessOptions | None = None,
|
||||
install: bool = False,
|
||||
) -> Result | EventStream:
|
||||
"""Run an agent harness (Claude Code, Codex, OpenCode, Deep Agents) on one prompt.
|
||||
|
||||
Returns a Result. With stream=True it returns an iterator of events instead.
|
||||
Prefix the model with `litellm_proxy/` to route every model call through your
|
||||
LiteLLM AI Gateway.
|
||||
"""
|
||||
kwargs: dict[str, Any] = { # mutable-ok: forwarded as **kwargs to _run/_stream
|
||||
"sandbox": sandbox,
|
||||
"model": model,
|
||||
"api_key": api_key,
|
||||
"api_base": api_base,
|
||||
"instructions": instructions,
|
||||
"tools": tools,
|
||||
"skills": skills,
|
||||
"disable_tools": disable_tools,
|
||||
"permissions": permissions,
|
||||
"on_approval": on_approval,
|
||||
"output": output,
|
||||
"max_turns": max_turns,
|
||||
"timeout": timeout,
|
||||
"metadata": metadata,
|
||||
"options": options,
|
||||
"install": install,
|
||||
}
|
||||
if stream:
|
||||
return _stream(harness, prompt, **kwargs)
|
||||
return _run(harness, prompt, **kwargs)
|
||||
214
litellm/harness/types.py
Normal file
214
litellm/harness/types.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""Public types for litellm.harness: the Harness enum, events, results and state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.harness.errors import StateIncompatible
|
||||
|
||||
StopReason = Literal["done", "max_turns", "timeout", "cancelled", "runtime_error"]
|
||||
PermissionMode = Literal["read-only", "ask", "edit", "full"]
|
||||
FileChangeKind = Literal["created", "modified", "deleted"]
|
||||
|
||||
STATE_VERSION = 1
|
||||
|
||||
|
||||
class Harness(Enum):
|
||||
"""Supported agent runtimes. A plain Enum on purpose: strings are rejected."""
|
||||
|
||||
CLAUDE_CODE = "claude_code"
|
||||
CODEX = "codex"
|
||||
OPENCODE = "opencode"
|
||||
DEEPAGENTS = "deepagents"
|
||||
|
||||
|
||||
def require_harness(harness: object) -> Harness:
|
||||
"""Return harness if it is a Harness member, else raise TypeError with a hint."""
|
||||
if isinstance(harness, Harness):
|
||||
return harness
|
||||
hint = ""
|
||||
if isinstance(harness, str):
|
||||
normalized = harness.strip().lower().replace("-", "_")
|
||||
for member in Harness:
|
||||
if normalized in (member.value, member.name.lower()):
|
||||
hint = f" Did you mean Harness.{member.name}?"
|
||||
raise TypeError(
|
||||
f"harness must be a litellm.harness.Harness member, got {type(harness).__name__} {harness!r}.{hint}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Usage:
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
calls: int = 0
|
||||
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
return self.input_tokens + self.output_tokens
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Text:
|
||||
delta: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reasoning:
|
||||
delta: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCall:
|
||||
id: str
|
||||
name: str
|
||||
native_name: str
|
||||
input: Mapping[str, Any]
|
||||
builtin: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolResult:
|
||||
id: str
|
||||
output: str
|
||||
is_error: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileChange:
|
||||
path: str
|
||||
kind: FileChangeKind
|
||||
diff: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Compaction:
|
||||
tokens_before: int | None = None
|
||||
tokens_after: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Approval:
|
||||
"""A request to run a tool. The turn waits until allow() or deny() is called."""
|
||||
|
||||
tool: str
|
||||
input: Mapping[str, Any]
|
||||
_decision: asyncio.Future[tuple[bool, str]] = field(
|
||||
default_factory=lambda: asyncio.get_event_loop().create_future(),
|
||||
compare=False,
|
||||
repr=False,
|
||||
)
|
||||
|
||||
def allow(self) -> None:
|
||||
self._resolve(True, "")
|
||||
|
||||
def deny(self, reason: str = "") -> None:
|
||||
self._resolve(False, reason)
|
||||
|
||||
@property
|
||||
def answered(self) -> bool:
|
||||
return self._decision.done()
|
||||
|
||||
async def wait(self) -> tuple[bool, str]:
|
||||
return await self._decision
|
||||
|
||||
def _resolve(self, allowed: bool, reason: str) -> None:
|
||||
if self._decision.done():
|
||||
return
|
||||
loop = self._decision.get_loop()
|
||||
loop.call_soon_threadsafe(self._set_result, allowed, reason)
|
||||
|
||||
def _set_result(self, allowed: bool, reason: str) -> None:
|
||||
if not self._decision.done():
|
||||
self._decision.set_result((allowed, reason))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
text: str
|
||||
output: BaseModel | None
|
||||
files: list[FileChange] # mutable-ok: public Result field; users index/iterate it as a list
|
||||
events: list[Event] # mutable-ok: public Result field; users index/iterate it as a list
|
||||
usage: Usage
|
||||
cost: float
|
||||
stop_reason: StopReason
|
||||
session_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Done:
|
||||
result: Result
|
||||
|
||||
@property
|
||||
def usage(self) -> Usage:
|
||||
return self.result.usage
|
||||
|
||||
@property
|
||||
def cost(self) -> float:
|
||||
return self.result.cost
|
||||
|
||||
@property
|
||||
def stop_reason(self) -> StopReason:
|
||||
return self.result.stop_reason
|
||||
|
||||
|
||||
Event = Text | Reasoning | ToolCall | ToolResult | FileChange | Compaction | Approval | Done
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Capabilities:
|
||||
structured_output: bool
|
||||
tool_approval: bool
|
||||
tool_filtering: bool
|
||||
history: bool
|
||||
custom_tools: bool
|
||||
skills: bool
|
||||
resume: bool
|
||||
permission_modes: frozenset[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class State:
|
||||
"""Resume state for a detached or stopped session. Contains no credentials."""
|
||||
|
||||
harness: Harness
|
||||
native_session_id: str | None
|
||||
workdir: str
|
||||
model: str | None = None
|
||||
version: int = STATE_VERSION
|
||||
|
||||
def dumps(self) -> bytes:
|
||||
return json.dumps(
|
||||
{ # mutable-ok: JSON payload serialized immediately by json.dumps
|
||||
"harness": self.harness.value,
|
||||
"native_session_id": self.native_session_id,
|
||||
"workdir": self.workdir,
|
||||
"model": self.model,
|
||||
"version": self.version,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
@classmethod
|
||||
def loads(cls, data: bytes) -> State:
|
||||
try:
|
||||
raw = json.loads(data.decode("utf-8"))
|
||||
harness = Harness(raw["harness"])
|
||||
version = int(raw["version"])
|
||||
except (ValueError, KeyError, TypeError, UnicodeDecodeError) as e:
|
||||
raise StateIncompatible(f"Unreadable harness state: {e}") from e
|
||||
if version != STATE_VERSION:
|
||||
raise StateIncompatible(f"State version {version} is not supported (expected {STATE_VERSION})")
|
||||
return cls(
|
||||
harness=harness,
|
||||
native_session_id=raw.get("native_session_id"),
|
||||
workdir=raw["workdir"],
|
||||
model=raw.get("model"),
|
||||
version=version,
|
||||
)
|
||||
|
|
@ -3826,7 +3826,7 @@ def _build_bedrock_tool_result_content_blocks(
|
|||
if tool_result_content_blocks:
|
||||
return tool_result_content_blocks, True
|
||||
|
||||
message_content: Final = message["content"]
|
||||
message_content: Final = message.get("content")
|
||||
if isinstance(message_content, str):
|
||||
return [BedrockToolResultContentBlock(text=message_content)], False
|
||||
if isinstance(message_content, list):
|
||||
|
|
@ -4095,23 +4095,20 @@ def get_user_message_block_or_continue_message(
|
|||
) -> ChatCompletionUserMessage:
|
||||
"""
|
||||
Returns the user content block
|
||||
if content block is an empty string, then return the default continue message
|
||||
if content block is missing or an empty string, then return the default continue message
|
||||
|
||||
Relevant Issue: https://github.com/BerriAI/litellm/issues/7169
|
||||
"""
|
||||
content_block: Final = message.get("content", None)
|
||||
|
||||
# Handle None case
|
||||
if content_block is None or (user_continue_message is None and litellm.modify_params is False):
|
||||
if user_continue_message is None and litellm.modify_params is False:
|
||||
return skip_empty_text_blocks(message=message)
|
||||
|
||||
# Handle string case
|
||||
if content_block is None or (isinstance(content_block, str) and not content_block.strip()):
|
||||
return ChatCompletionUserMessage(**(user_continue_message or DEFAULT_USER_CONTINUE_MESSAGE))
|
||||
|
||||
if isinstance(content_block, str):
|
||||
# check if content is empty
|
||||
if content_block.strip():
|
||||
return message
|
||||
else:
|
||||
return ChatCompletionUserMessage(**(user_continue_message or DEFAULT_USER_CONTINUE_MESSAGE))
|
||||
return message
|
||||
|
||||
# Handle list case
|
||||
if isinstance(content_block, list):
|
||||
|
|
@ -4374,9 +4371,10 @@ class BedrockConverseMessagesProcessor:
|
|||
message=messages[msg_i],
|
||||
user_continue_message=user_continue_message,
|
||||
)
|
||||
if isinstance(message_block["content"], list):
|
||||
message_content = message_block.get("content")
|
||||
if isinstance(message_content, list):
|
||||
_parts: list[BedrockContentBlock] = []
|
||||
for element in message_block["content"]:
|
||||
for element in message_content:
|
||||
if isinstance(element, dict):
|
||||
if element["type"] == "text":
|
||||
_part = BedrockContentBlock(text=element["text"])
|
||||
|
|
@ -4418,8 +4416,8 @@ class BedrockConverseMessagesProcessor:
|
|||
if _cache_point_block is not None:
|
||||
_parts.append(_cache_point_block)
|
||||
user_content.extend(_parts)
|
||||
elif message_block["content"] and isinstance(message_block["content"], str):
|
||||
_part = BedrockContentBlock(text=messages[msg_i]["content"])
|
||||
elif message_content and isinstance(message_content, str):
|
||||
_part = BedrockContentBlock(text=message_content)
|
||||
_cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block(
|
||||
message_block, block_type="content_block", model=model
|
||||
)
|
||||
|
|
@ -4746,9 +4744,10 @@ def _bedrock_converse_messages_pt(
|
|||
message=messages[msg_i],
|
||||
user_continue_message=user_continue_message,
|
||||
)
|
||||
if isinstance(message_block["content"], list):
|
||||
message_content = message_block.get("content")
|
||||
if isinstance(message_content, list):
|
||||
_parts: list[BedrockContentBlock] = []
|
||||
for element in message_block["content"]:
|
||||
for element in message_content:
|
||||
if isinstance(element, dict):
|
||||
if element["type"] == "text":
|
||||
_part = BedrockContentBlock(text=element["text"])
|
||||
|
|
@ -4791,8 +4790,8 @@ def _bedrock_converse_messages_pt(
|
|||
if _cache_point_block is not None:
|
||||
_parts.append(_cache_point_block)
|
||||
user_content.extend(_parts)
|
||||
elif message_block["content"] and isinstance(message_block["content"], str):
|
||||
_part = BedrockContentBlock(text=messages[msg_i]["content"])
|
||||
elif message_content and isinstance(message_content, str):
|
||||
_part = BedrockContentBlock(text=message_content)
|
||||
_cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block(
|
||||
message_block, block_type="content_block", model=model
|
||||
)
|
||||
|
|
|
|||
0
litellm/llms/base_llm/harness/__init__.py
Normal file
0
litellm/llms/base_llm/harness/__init__.py
Normal file
151
litellm/llms/base_llm/harness/transformation.py
Normal file
151
litellm/llms/base_llm/harness/transformation.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""
|
||||
Base agent-harness transformation configuration.
|
||||
|
||||
A harness is a complete agent runtime (Claude Code, Codex, OpenCode, Deep Agents).
|
||||
Like the LLM provider configs in `litellm/llms/base_llm/chat/transformation.py`, a
|
||||
harness config only translates: LiteLLM's session parameters in, the runtime's native
|
||||
command / config / event stream out. It never does I/O. A handler in
|
||||
`litellm/harness/handlers/` owns the sandbox, the process and the per-session model
|
||||
endpoint, and calls these transforms.
|
||||
|
||||
Adding a CLI harness is one subclass of `BaseCLIHarnessConfig` in
|
||||
`litellm/llms/<harness>/harness/transformation.py`, plus one line in
|
||||
`ProviderConfigManager.get_provider_harness_config`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar
|
||||
|
||||
from litellm.harness.errors import HarnessError, OptionsMismatch
|
||||
from litellm.harness.types import Capabilities, Event, Harness
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.harness.context import SessionContext
|
||||
|
||||
# A config's typed options (ClaudeCodeOptions, CodexOptions, ...) and its per-turn parser state.
|
||||
OptionsT = TypeVar("OptionsT")
|
||||
StreamStateT = TypeVar("StreamStateT")
|
||||
|
||||
|
||||
def event_list(*events: Event) -> Sequence[Event]:
|
||||
"""A transform_stream_line result. One place builds it so every parser returns the same shape."""
|
||||
return list(events) # mutable-ok: stream-line results are list-shaped; callers and tests compare with list literals
|
||||
|
||||
|
||||
class HarnessTurnError(HarnessError):
|
||||
"""The runtime reported a failed turn. The runtime maps this to stop_reason='runtime_error'."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HarnessSessionSetup:
|
||||
"""What the handler must prepare in the sandbox before the first turn.
|
||||
|
||||
Paths are relative to `private_dir` (a per-session temp dir inside the sandbox)
|
||||
unless they are absolute.
|
||||
"""
|
||||
|
||||
files: Mapping[str, bytes] = field(default_factory=dict)
|
||||
# (dir inside private_dir, cache subpath under ~/.cache/litellm-harness) linked so a
|
||||
# later session can resume the runtime's own conversation.
|
||||
persisted_dirs: Sequence[tuple[str, str]] = ()
|
||||
# Where skill folders are copied, relative to private_dir, or absolute.
|
||||
skills_dir: str | None = None
|
||||
# Env passed on every turn. Values may contain `{private_dir}`.
|
||||
env: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HarnessTurnRequest:
|
||||
"""One turn of a CLI runtime: the process to run and what to send on stdin."""
|
||||
|
||||
argv: Sequence[str]
|
||||
env: Mapping[str, str]
|
||||
stdin: str
|
||||
cwd: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HarnessTurnResponse:
|
||||
"""What the runtime produced for one turn, after the process exited."""
|
||||
|
||||
final_text: str
|
||||
output_json: str | None = None
|
||||
|
||||
|
||||
class BaseHarnessConfig(ABC, Generic[OptionsT]):
|
||||
"""Declares what a harness is and validates a session before anything starts."""
|
||||
|
||||
harness: ClassVar[Harness]
|
||||
options_type: type[OptionsT]
|
||||
capabilities: ClassVar[Capabilities]
|
||||
# CLI runtimes call a per-session model endpoint; in-process ones call LiteLLM directly.
|
||||
uses_model_endpoint: ClassVar[bool] = True
|
||||
|
||||
def get_options(self, ctx: SessionContext) -> OptionsT:
|
||||
"""ctx.options, or this harness's default options."""
|
||||
options = ctx.options
|
||||
if options is None:
|
||||
return self.options_type()
|
||||
if not isinstance(options, self.options_type):
|
||||
raise OptionsMismatch(
|
||||
f"{type(options).__name__} cannot be used with Harness.{self.harness.name}; "
|
||||
f"use {self.options_type.__name__}"
|
||||
)
|
||||
return options
|
||||
|
||||
def validate_environment(self, ctx: SessionContext) -> None:
|
||||
"""Static checks on the session. Raise OptionsMismatch / ValueError early."""
|
||||
self.get_options(ctx)
|
||||
|
||||
|
||||
class BaseCLIHarnessConfig(BaseHarnessConfig[OptionsT], Generic[OptionsT, StreamStateT]):
|
||||
"""A runtime driven as a subprocess that prints one JSON event per line."""
|
||||
|
||||
@abstractmethod
|
||||
def get_binary(self) -> str:
|
||||
"""Executable that must be on the sandbox's PATH."""
|
||||
|
||||
@abstractmethod
|
||||
def get_install_hint(self) -> str:
|
||||
"""How to install the binary; shown in HarnessInstallFailed."""
|
||||
|
||||
@abstractmethod
|
||||
def transform_session_setup(self, ctx: SessionContext, private_dir: str) -> HarnessSessionSetup:
|
||||
"""Config files, env and persisted dirs for the session."""
|
||||
|
||||
@abstractmethod
|
||||
def transform_turn_request(
|
||||
self,
|
||||
ctx: SessionContext,
|
||||
setup: HarnessSessionSetup,
|
||||
private_dir: str,
|
||||
prompt: str,
|
||||
native_session_id: str | None,
|
||||
) -> HarnessTurnRequest:
|
||||
"""argv / env / stdin for one turn. native_session_id is set after the first turn."""
|
||||
|
||||
@abstractmethod
|
||||
def create_stream_state(self) -> StreamStateT:
|
||||
"""Fresh per-turn parser state."""
|
||||
|
||||
@abstractmethod
|
||||
def transform_stream_line(self, line: Mapping[str, Any], state: StreamStateT) -> Sequence[Event]:
|
||||
"""One decoded JSON line from stdout to zero or more events. Pure."""
|
||||
|
||||
@abstractmethod
|
||||
def get_native_session_id(self, state: StreamStateT) -> str | None:
|
||||
"""The runtime's own session / thread id, once the stream has reported it."""
|
||||
|
||||
@abstractmethod
|
||||
def transform_turn_response(
|
||||
self,
|
||||
ctx: SessionContext,
|
||||
state: StreamStateT,
|
||||
exit_code: int,
|
||||
stderr_tail: Sequence[str],
|
||||
) -> HarnessTurnResponse:
|
||||
"""Final text and structured output, or raise HarnessTurnError."""
|
||||
116
litellm/llms/base_llm/harness/utils.py
Normal file
116
litellm/llms/base_llm/harness/utils.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Pure helpers shared by harness configs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final, TypeAlias
|
||||
|
||||
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
|
||||
|
||||
# A decoded JSON document: what json.loads / model_json_schema() produce.
|
||||
JSONValue: TypeAlias = "dict[str, JSONValue] | list[JSONValue] | str | int | float | bool | None"
|
||||
|
||||
SKILL_MANIFEST: Final = "SKILL.md"
|
||||
_JSON_DECODER: Final = json.JSONDecoder()
|
||||
|
||||
|
||||
def normalize_tool_name(native_name: str, mapping: Mapping[str, str]) -> str:
|
||||
"""Normalized tool name (read, write, edit, bash, ...) or the native name if unmapped."""
|
||||
return mapping.get(native_name, native_name)
|
||||
|
||||
|
||||
def native_tool_names(normalized: Sequence[str], mapping: Mapping[str, Sequence[str]]) -> Sequence[str]:
|
||||
"""Native names for normalized tool names, de-duplicated, order kept."""
|
||||
expanded: Final = itertools.chain.from_iterable(mapping.get(name, (name,)) for name in normalized)
|
||||
return list(dict.fromkeys(expanded)) # mutable-ok: public helper whose callers/tests compare against list literals
|
||||
|
||||
|
||||
def last_json_object(text: str) -> str | None:
|
||||
"""The last top-level `{...}` in text that parses as a JSON object, re-serialized."""
|
||||
last: str | None = None
|
||||
index = text.find("{")
|
||||
while index != -1:
|
||||
try:
|
||||
obj, end = _JSON_DECODER.raw_decode(text, index)
|
||||
except json.JSONDecodeError:
|
||||
index = text.find("{", index + 1)
|
||||
continue
|
||||
if isinstance(obj, dict):
|
||||
last = json.dumps(obj)
|
||||
index = text.find("{", end)
|
||||
return last
|
||||
|
||||
|
||||
def structured_output_instruction(schema: Mapping[str, Any]) -> str:
|
||||
return (
|
||||
"When you have finished, your final message must be a single JSON object that "
|
||||
"matches this JSON schema, with no other text before or after it:\n"
|
||||
f"{json.dumps(schema)}"
|
||||
)
|
||||
|
||||
|
||||
def strict_json_schema(schema: JSONValue, depth: int = 0) -> JSONValue:
|
||||
"""Make a JSON schema acceptable to OpenAI strict structured outputs.
|
||||
|
||||
Every object gets `additionalProperties: false` and all of its properties required,
|
||||
recursively. Keywords strict mode rejects next to $ref are dropped. Nesting deeper than
|
||||
DEFAULT_MAX_RECURSE_DEPTH raises instead of recursing further.
|
||||
"""
|
||||
if depth > DEFAULT_MAX_RECURSE_DEPTH:
|
||||
raise ValueError(f"output schema is nested deeper than {DEFAULT_MAX_RECURSE_DEPTH} levels")
|
||||
if isinstance(schema, list):
|
||||
return [strict_json_schema(entry, depth + 1) for entry in schema] # mutable-ok: JSON document output
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
entries: Final = ((key, strict_json_schema(value, depth + 1)) for key, value in schema.items())
|
||||
result = dict(entries) # mutable-ok: JSON document; "default" is popped below
|
||||
if "$ref" in result:
|
||||
return {"$ref": result["$ref"]} # mutable-ok: JSONValue output is a plain JSON document
|
||||
result.pop("default", None)
|
||||
properties = result.get("properties")
|
||||
if result.get("type") == "object" or isinstance(properties, dict):
|
||||
props = properties if isinstance(properties, dict) else {} # mutable-ok: JSONValue object member
|
||||
required: Final[list[JSONValue]] = list(props) # mutable-ok: JSON array in the output schema
|
||||
strict: Final[Mapping[str, JSONValue]] = MappingProxyType(
|
||||
{"properties": props, "required": required, "additionalProperties": False}
|
||||
)
|
||||
result = {**result, **strict} # mutable-ok: JSON document output
|
||||
return result
|
||||
|
||||
|
||||
def decode_json_line(line: bytes | str) -> Mapping[str, Any] | None:
|
||||
"""One JSONL line as a dict, or None for blank / non-JSON / non-object lines."""
|
||||
text = line.strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return obj if isinstance(obj, dict) else None
|
||||
|
||||
|
||||
def stderr_tail_text(stderr_tail: Sequence[str]) -> str:
|
||||
return "\n".join(line for line in stderr_tail if line.strip())
|
||||
|
||||
|
||||
def _read_bytes(path: str) -> bytes:
|
||||
with open(path, "rb") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def _walk_files(root: str) -> Iterator[str]:
|
||||
for dirpath, _dirnames, filenames in os.walk(root):
|
||||
yield from (os.path.join(dirpath, filename) for filename in sorted(filenames))
|
||||
|
||||
|
||||
def read_skill_files(skill_dir: str) -> tuple[tuple[str, bytes], ...]:
|
||||
"""(relative path, bytes) for every file under a local skill folder."""
|
||||
root: Final = os.path.realpath(os.fspath(skill_dir))
|
||||
if not os.path.isfile(os.path.join(root, SKILL_MANIFEST)):
|
||||
raise ValueError(f"skill folder {skill_dir!r} has no {SKILL_MANIFEST}")
|
||||
return tuple((os.path.relpath(path, root), _read_bytes(path)) for path in _walk_files(root))
|
||||
|
|
@ -1448,7 +1448,7 @@ class AmazonConverseConfig(BaseConfig):
|
|||
)
|
||||
|
||||
def _converted_text_blocks(self, message: ChatCompletionSystemMessage) -> tuple[ChatCompletionTextObject, ...]:
|
||||
content: Final = message["content"]
|
||||
content: Final = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return (self._converted_text_block(content, message.get("cache_control")),) if content else ()
|
||||
parts: Final[Sequence[object]] = content or ()
|
||||
|
|
@ -1483,13 +1483,14 @@ class AmazonConverseConfig(BaseConfig):
|
|||
for message in hoisted:
|
||||
if message["role"] != "system":
|
||||
continue
|
||||
if isinstance(message["content"], str) and message["content"]:
|
||||
system_content_blocks.append(SystemContentBlock(text=message["content"]))
|
||||
content = message.get("content")
|
||||
if isinstance(content, str) and content:
|
||||
system_content_blocks.append(SystemContentBlock(text=content))
|
||||
cache_block = self.get_cache_point_block(message, block_type="system", model=model)
|
||||
if cache_block:
|
||||
system_content_blocks.append(cache_block)
|
||||
elif isinstance(message["content"], list):
|
||||
for m in message["content"]:
|
||||
elif isinstance(content, list):
|
||||
for m in content:
|
||||
if m.get("type") == "text" and m.get("text"):
|
||||
system_content_blocks.append(SystemContentBlock(text=m["text"]))
|
||||
cache_block = self.get_cache_point_block(m, block_type="system", model=model)
|
||||
|
|
|
|||
0
litellm/llms/claude_code/__init__.py
Normal file
0
litellm/llms/claude_code/__init__.py
Normal file
0
litellm/llms/claude_code/harness/__init__.py
Normal file
0
litellm/llms/claude_code/harness/__init__.py
Normal file
389
litellm/llms/claude_code/harness/transformation.py
Normal file
389
litellm/llms/claude_code/harness/transformation.py
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
"""
|
||||
Claude Code harness config: `claude -p --output-format stream-json`, once per turn.
|
||||
|
||||
Every model call goes to the per-session endpoint with the per-session token. The CLI gets
|
||||
a private CLAUDE_CONFIG_DIR and only the `user` setting source (that private dir), so
|
||||
neither the user's login, keychain, nor a repo's `.claude/settings.json` can swap the base
|
||||
URL or credentials. Verified against Claude Code 2.1.285.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from litellm.harness.errors import HarnessError, OptionsMismatch
|
||||
from litellm.harness.options import ClaudeCodeOptions
|
||||
from litellm.harness.types import (
|
||||
Capabilities,
|
||||
Compaction,
|
||||
Event,
|
||||
Harness,
|
||||
Reasoning,
|
||||
Text,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from litellm.llms.base_llm.harness.transformation import (
|
||||
BaseCLIHarnessConfig,
|
||||
HarnessSessionSetup,
|
||||
HarnessTurnError,
|
||||
HarnessTurnRequest,
|
||||
HarnessTurnResponse,
|
||||
event_list,
|
||||
)
|
||||
from litellm.llms.base_llm.harness.utils import (
|
||||
last_json_object,
|
||||
native_tool_names,
|
||||
normalize_tool_name,
|
||||
stderr_tail_text,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.harness.context import SessionContext
|
||||
|
||||
CLAUDE_BINARY: Final = "claude"
|
||||
SYNTHETIC_MODEL: Final = "<synthetic>"
|
||||
|
||||
BASE_COMMAND: Final = ("-p", "--output-format", "stream-json", "--verbose", "--input-format", "text")
|
||||
|
||||
PERMISSION_MODES: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"read-only": "plan",
|
||||
"ask": "default",
|
||||
"edit": "acceptEdits",
|
||||
"full": "bypassPermissions",
|
||||
}
|
||||
)
|
||||
|
||||
NATIVE_TO_NORMALIZED: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"Read": "read",
|
||||
"Write": "write",
|
||||
"Edit": "edit",
|
||||
"MultiEdit": "edit",
|
||||
"Bash": "bash",
|
||||
"Glob": "glob",
|
||||
"Grep": "grep",
|
||||
"WebSearch": "web_search",
|
||||
"LS": "ls",
|
||||
}
|
||||
)
|
||||
|
||||
NORMALIZED_TO_NATIVE: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType(
|
||||
{
|
||||
"read": ("Read",),
|
||||
"write": ("Write",),
|
||||
"edit": ("Edit", "MultiEdit"),
|
||||
"bash": ("Bash",),
|
||||
"glob": ("Glob",),
|
||||
"grep": ("Grep",),
|
||||
"web_search": ("WebSearch",),
|
||||
"ls": ("LS",),
|
||||
}
|
||||
)
|
||||
|
||||
# Env the config owns; ClaudeCodeOptions.env may not override these.
|
||||
MANAGED_ENV_KEYS: Final = frozenset(
|
||||
{
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
}
|
||||
)
|
||||
|
||||
# Claude Code settings.json keys LiteLLM manages (or that could reroute model calls or credentials).
|
||||
MANAGED_CONFIG_KEYS: Final = frozenset(
|
||||
{"env", "apiKeyHelper", "model", "permissions", "awsAuthRefresh", "awsCredentialExport", "forceLoginMethod"}
|
||||
)
|
||||
|
||||
STATIC_ENV: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"DISABLE_TELEMETRY": "1",
|
||||
"DISABLE_ERROR_REPORTING": "1",
|
||||
"DISABLE_AUTOUPDATER": "1",
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
||||
}
|
||||
)
|
||||
|
||||
STRUCTURED_OUTPUT_INSTRUCTION: Final = (
|
||||
"When you have finished the task, end your final reply with only a single JSON "
|
||||
"object (no code fences, no prose after it) that matches this JSON schema:\n{schema}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClaudeCodeStreamState:
|
||||
"""What the parser has learned from one turn's stream-json output."""
|
||||
|
||||
session_id: str | None = None
|
||||
text_parts: list[str] = field(default_factory=list) # mutable-ok: parser appends text deltas
|
||||
result_seen: bool = False
|
||||
result_text: str | None = None
|
||||
is_error: bool = False
|
||||
errors: Sequence[str] = ()
|
||||
structured_output: Any | None = None
|
||||
|
||||
@property
|
||||
def final_text(self) -> str:
|
||||
if self.result_text is not None:
|
||||
return self.result_text
|
||||
return "".join(self.text_parts)
|
||||
|
||||
|
||||
def stringify_tool_output(content: object) -> str:
|
||||
"""tool_result content is a string or a list of content blocks."""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "\n".join(_stringify_block(block) for block in content)
|
||||
return json.dumps(content, ensure_ascii=False)
|
||||
|
||||
|
||||
def _stringify_block(block: object) -> str:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
return str(block.get("text", ""))
|
||||
if isinstance(block, str):
|
||||
return block
|
||||
return json.dumps(block, ensure_ascii=False)
|
||||
|
||||
|
||||
def _message_blocks(event: Mapping[str, Any]) -> Sequence[Any]:
|
||||
message: Final = event.get("message")
|
||||
content: Final = message.get("content") if isinstance(message, Mapping) else None
|
||||
if isinstance(content, str):
|
||||
return ({"type": "text", "text": content},) # mutable-ok: JSON content block, like the stream's
|
||||
return content if isinstance(content, list) else ()
|
||||
|
||||
|
||||
def _assistant_block_events(block: Mapping[str, Any], state: ClaudeCodeStreamState) -> tuple[Event, ...]:
|
||||
kind = block.get("type")
|
||||
if kind == "text" and block.get("text"):
|
||||
state.text_parts.append(block["text"])
|
||||
return (Text(delta=block["text"]),)
|
||||
if kind == "thinking" and block.get("thinking"):
|
||||
return (Reasoning(delta=block["thinking"]),)
|
||||
if kind == "tool_use":
|
||||
native = str(block.get("name", ""))
|
||||
return (
|
||||
ToolCall(
|
||||
id=str(block.get("id", "")),
|
||||
name=normalize_tool_name(native, NATIVE_TO_NORMALIZED),
|
||||
native_name=native,
|
||||
input=block.get("input")
|
||||
or {}, # mutable-ok: ToolCall.input is a dict field; empty default for a missing input
|
||||
builtin=not native.startswith("mcp__"),
|
||||
),
|
||||
)
|
||||
return ()
|
||||
|
||||
|
||||
def _assistant_events(event: Mapping[str, Any], state: ClaudeCodeStreamState) -> Sequence[Event]:
|
||||
if event.get("parent_tool_use_id"):
|
||||
return event_list() # subagent traffic
|
||||
message: Final = event.get("message")
|
||||
if isinstance(message, Mapping) and message.get("model") == SYNTHETIC_MODEL:
|
||||
return event_list() # CLI-generated error text; surfaced via the result event
|
||||
blocks: Final = (block for block in _message_blocks(event) if isinstance(block, dict))
|
||||
return event_list(*itertools.chain.from_iterable(_assistant_block_events(block, state) for block in blocks))
|
||||
|
||||
|
||||
def _is_tool_result(block: object) -> bool:
|
||||
return isinstance(block, dict) and block.get("type") == "tool_result"
|
||||
|
||||
|
||||
def _user_events(event: Mapping[str, Any]) -> Sequence[Event]:
|
||||
if event.get("parent_tool_use_id"):
|
||||
return event_list()
|
||||
return event_list(
|
||||
*(
|
||||
ToolResult(
|
||||
id=str(block.get("tool_use_id", "")),
|
||||
output=stringify_tool_output(block.get("content")),
|
||||
is_error=bool(block.get("is_error", False)),
|
||||
)
|
||||
for block in _message_blocks(event)
|
||||
if _is_tool_result(block)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _system_events(event: Mapping[str, Any], state: ClaudeCodeStreamState) -> Sequence[Event]:
|
||||
subtype = event.get("subtype")
|
||||
if subtype == "init" and event.get("session_id"):
|
||||
state.session_id = str(event["session_id"])
|
||||
return event_list()
|
||||
if subtype == "compact_boundary":
|
||||
meta: Final = event.get("compact_metadata")
|
||||
pre_tokens: Final = meta.get("pre_tokens") if isinstance(meta, Mapping) else None
|
||||
return event_list(Compaction(tokens_before=pre_tokens, tokens_after=None))
|
||||
return event_list()
|
||||
|
||||
|
||||
def _record_result(event: Mapping[str, Any], state: ClaudeCodeStreamState) -> Sequence[Event]:
|
||||
state.result_seen = True
|
||||
state.is_error = bool(event.get("is_error", False))
|
||||
result = event.get("result")
|
||||
state.result_text = result if isinstance(result, str) else None
|
||||
state.errors = [str(e) for e in event.get("errors") or ()] # mutable-ok: mirrors the JSON errors array
|
||||
state.structured_output = event.get("structured_output")
|
||||
if event.get("session_id"):
|
||||
state.session_id = str(event["session_id"])
|
||||
return event_list()
|
||||
|
||||
|
||||
def turn_error_message(state: ClaudeCodeStreamState, exit_code: int, stderr_tail: Sequence[str]) -> str | None:
|
||||
"""None if the turn succeeded, else the message for HarnessTurnError."""
|
||||
if exit_code == 0 and state.result_seen and not state.is_error:
|
||||
return None
|
||||
reason = state.result_text or "; ".join(state.errors)
|
||||
if not reason:
|
||||
reason = "no result event" if not state.result_seen else "unknown error"
|
||||
message = f"claude exited with code {exit_code}: {reason}"
|
||||
tail = stderr_tail_text(stderr_tail)
|
||||
return f"{message}\nstderr:\n{tail}" if tail else message
|
||||
|
||||
|
||||
def build_system_prompt(instructions: str | None, output_schema: Mapping[str, Any] | None) -> str | None:
|
||||
schema_part: Final = (
|
||||
STRUCTURED_OUTPUT_INSTRUCTION.format(schema=json.dumps(output_schema)) if output_schema is not None else None
|
||||
)
|
||||
parts: Final = tuple(part for part in (instructions, schema_part) if part)
|
||||
return "\n\n".join(parts) if parts else None
|
||||
|
||||
|
||||
class ClaudeCodeHarnessConfig(BaseCLIHarnessConfig):
|
||||
harness = Harness.CLAUDE_CODE
|
||||
options_type = ClaudeCodeOptions
|
||||
capabilities = Capabilities(
|
||||
structured_output=True,
|
||||
tool_approval=False,
|
||||
tool_filtering=True,
|
||||
history=False,
|
||||
custom_tools=False,
|
||||
skills=True,
|
||||
resume=True,
|
||||
permission_modes=frozenset({"read-only", "edit", "full"}),
|
||||
)
|
||||
|
||||
def get_binary(self) -> str:
|
||||
return CLAUDE_BINARY
|
||||
|
||||
def get_install_hint(self) -> str:
|
||||
return "npm install -g @anthropic-ai/claude-code"
|
||||
|
||||
def validate_environment(self, ctx: SessionContext) -> None:
|
||||
options: ClaudeCodeOptions = self.get_options(ctx)
|
||||
clashing = sorted(MANAGED_ENV_KEYS.intersection(options.env))
|
||||
if clashing:
|
||||
raise OptionsMismatch(f"ClaudeCodeOptions.env may not set {', '.join(clashing)}; LiteLLM manages it")
|
||||
managed = sorted(MANAGED_CONFIG_KEYS.intersection(options.config))
|
||||
if managed:
|
||||
raise OptionsMismatch(
|
||||
f"ClaudeCodeOptions.config may not set {', '.join(managed)}; "
|
||||
"use the matching agent() argument (model=, permissions=) instead"
|
||||
)
|
||||
|
||||
def transform_session_setup(self, ctx: SessionContext, private_dir: str) -> HarnessSessionSetup:
|
||||
if ctx.endpoint is None or not ctx.endpoint.token:
|
||||
raise HarnessError("Claude Code needs the session model endpoint")
|
||||
options: ClaudeCodeOptions = self.get_options(ctx)
|
||||
model = ctx.model
|
||||
# Background calls (titles, summaries) use the same model group, like OpenCode.
|
||||
model_env: Final = (
|
||||
MappingProxyType({"ANTHROPIC_MODEL": model, "ANTHROPIC_SMALL_FAST_MODEL": model})
|
||||
if model
|
||||
else MappingProxyType({})
|
||||
)
|
||||
env: Final = MappingProxyType(
|
||||
{
|
||||
**options.env,
|
||||
**STATIC_ENV,
|
||||
"ANTHROPIC_BASE_URL": ctx.sandbox.host_url(ctx.endpoint.port),
|
||||
"ANTHROPIC_AUTH_TOKEN": ctx.endpoint.token,
|
||||
"ANTHROPIC_API_KEY": "",
|
||||
"CLAUDE_CONFIG_DIR": private_dir,
|
||||
**model_env,
|
||||
}
|
||||
)
|
||||
return HarnessSessionSetup(
|
||||
persisted_dirs=[("projects", "claude_code/projects")], # mutable-ok: tests compare to a list
|
||||
skills_dir="skills",
|
||||
env=env,
|
||||
)
|
||||
|
||||
def transform_turn_request(
|
||||
self,
|
||||
ctx: SessionContext,
|
||||
setup: HarnessSessionSetup,
|
||||
private_dir: str,
|
||||
prompt: str,
|
||||
native_session_id: str | None,
|
||||
) -> HarnessTurnRequest:
|
||||
options: ClaudeCodeOptions = self.get_options(ctx)
|
||||
schema = ctx.output.model_json_schema() if ctx.output is not None else None
|
||||
system_prompt: Final = build_system_prompt(ctx.instructions, schema)
|
||||
disallowed: Final = native_tool_names(ctx.disable_tools, NORMALIZED_TO_NATIVE)
|
||||
config: Final = dict(options.config) # mutable-ok: json.dumps needs a plain dict
|
||||
settings: Final = json.dumps(config) if config else None
|
||||
argv: Final = (
|
||||
CLAUDE_BINARY,
|
||||
*BASE_COMMAND,
|
||||
"--permission-mode",
|
||||
PERMISSION_MODES[ctx.permissions],
|
||||
*(("--model", ctx.model) if ctx.model else ()),
|
||||
# Only read settings from the private CLAUDE_CONFIG_DIR, never the repo's .claude/.
|
||||
"--setting-sources",
|
||||
"user",
|
||||
*(("--settings", settings) if settings else ()),
|
||||
*(("--append-system-prompt", system_prompt) if system_prompt else ()),
|
||||
*(("--max-turns", str(ctx.max_turns)) if ctx.max_turns is not None else ()),
|
||||
*(("--disallowedTools", ",".join(disallowed)) if disallowed else ()),
|
||||
*(("--resume", native_session_id) if native_session_id else ()),
|
||||
)
|
||||
return HarnessTurnRequest(argv=argv, env=setup.env, stdin=prompt)
|
||||
|
||||
def create_stream_state(self) -> ClaudeCodeStreamState:
|
||||
return ClaudeCodeStreamState()
|
||||
|
||||
def transform_stream_line(self, line: Mapping[str, Any], state: ClaudeCodeStreamState) -> Sequence[Event]:
|
||||
kind = line.get("type")
|
||||
if kind == "assistant":
|
||||
return _assistant_events(line, state)
|
||||
if kind == "user":
|
||||
return _user_events(line)
|
||||
if kind == "system":
|
||||
return _system_events(line, state)
|
||||
if kind == "result":
|
||||
return _record_result(line, state)
|
||||
return event_list()
|
||||
|
||||
def get_native_session_id(self, state: ClaudeCodeStreamState) -> str | None:
|
||||
return state.session_id
|
||||
|
||||
def transform_turn_response(
|
||||
self,
|
||||
ctx: SessionContext,
|
||||
state: ClaudeCodeStreamState,
|
||||
exit_code: int,
|
||||
stderr_tail: Sequence[str],
|
||||
) -> HarnessTurnResponse:
|
||||
error = turn_error_message(state, exit_code, stderr_tail)
|
||||
if error is not None:
|
||||
raise HarnessTurnError(error)
|
||||
output_json: str | None = None
|
||||
if ctx.output is not None:
|
||||
if isinstance(state.structured_output, dict):
|
||||
output_json = json.dumps(state.structured_output)
|
||||
else:
|
||||
output_json = last_json_object(state.final_text)
|
||||
return HarnessTurnResponse(final_text=state.final_text, output_json=output_json)
|
||||
0
litellm/llms/codex/__init__.py
Normal file
0
litellm/llms/codex/__init__.py
Normal file
0
litellm/llms/codex/harness/__init__.py
Normal file
0
litellm/llms/codex/harness/__init__.py
Normal file
349
litellm/llms/codex/harness/transformation.py
Normal file
349
litellm/llms/codex/harness/transformation.py
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
"""
|
||||
Codex harness config: `codex exec --json` (JSONL events), once per turn.
|
||||
|
||||
Every model call goes to one custom provider (`litellm`, wire_api=responses) pointing at the
|
||||
per-session endpoint. The bearer token only travels in the LITELLM_HARNESS_TOKEN env var,
|
||||
never in argv. CODEX_HOME is the private session dir so the user's own Codex config and
|
||||
auth are never read. Verified against codex-cli 0.135.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
|
||||
from litellm.harness.errors import HarnessError, OptionsMismatch
|
||||
from litellm.harness.options import CodexOptions
|
||||
from litellm.harness.types import (
|
||||
Capabilities,
|
||||
Event,
|
||||
Harness,
|
||||
Reasoning,
|
||||
Text,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from litellm.llms.base_llm.harness.transformation import (
|
||||
BaseCLIHarnessConfig,
|
||||
HarnessSessionSetup,
|
||||
HarnessTurnError,
|
||||
HarnessTurnRequest,
|
||||
HarnessTurnResponse,
|
||||
event_list,
|
||||
)
|
||||
from litellm.llms.base_llm.harness.utils import stderr_tail_text, strict_json_schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.harness.context import SessionContext
|
||||
|
||||
CODEX_BINARY: Final = "codex"
|
||||
CODEX_PROVIDER_ID: Final = "litellm"
|
||||
CODEX_TOKEN_ENV: Final = "LITELLM_HARNESS_TOKEN"
|
||||
CODEX_SCHEMA_FILENAME: Final = "output_schema.json"
|
||||
# Top-level config keys LiteLLM sets itself; users may not override them via options.config.
|
||||
MANAGED_CONFIG_KEYS: Final = frozenset(
|
||||
{
|
||||
"model",
|
||||
"model_provider",
|
||||
"model_providers",
|
||||
"approval_policy",
|
||||
"sandbox_mode",
|
||||
"mcp_servers",
|
||||
"developer_instructions",
|
||||
"web_search",
|
||||
}
|
||||
)
|
||||
_BARE_TOML_KEY: Final = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
_TOOL_ITEM_TYPES: Final = frozenset({"command_execution", "file_change", "web_search", "mcp_tool_call"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodexStreamState:
|
||||
"""What the parser has learned from one turn's JSONL events."""
|
||||
|
||||
thread_id: str | None = None
|
||||
final_text: str = ""
|
||||
error: str | None = None
|
||||
failed: bool = False
|
||||
started: set[str] = field(default_factory=set) # mutable-ok: parser records announced tool items
|
||||
|
||||
|
||||
def _tool_input(item: Mapping[str, Any]) -> tuple[str, str, Mapping[str, Any], bool]:
|
||||
"""(normalized name, native name, input, builtin) for a tool-like item."""
|
||||
item_type = item.get("type")
|
||||
if item_type == "command_execution":
|
||||
return "bash", "command_execution", MappingProxyType({"command": item.get("command", "")}), True
|
||||
if item_type == "file_change":
|
||||
changes: Final = list(item.get("changes") or ()) # mutable-ok: JSON array, as codex reports it
|
||||
return "edit", "apply_patch", MappingProxyType({"changes": changes}), True
|
||||
if item_type == "web_search":
|
||||
return "web_search", "web_search", MappingProxyType({"query": item.get("query", "")}), True
|
||||
server = str(item.get("server") or "")
|
||||
tool = str(item.get("tool") or "")
|
||||
arguments = item.get("arguments")
|
||||
tool_args = arguments if isinstance(arguments, dict) else MappingProxyType({"arguments": arguments})
|
||||
name = f"{server}.{tool}" if server else tool
|
||||
return name, tool, tool_args, False
|
||||
|
||||
|
||||
def _tool_output(item: Mapping[str, Any]) -> tuple[str, bool]:
|
||||
"""(output text, is_error) for a completed tool-like item."""
|
||||
item_type = item.get("type")
|
||||
status = item.get("status")
|
||||
if item_type == "command_execution":
|
||||
exit_code = item.get("exit_code")
|
||||
is_error = status == "failed" or (exit_code is not None and exit_code != 0)
|
||||
return str(item.get("aggregated_output") or ""), is_error
|
||||
if item_type == "file_change":
|
||||
lines = (f"{c.get('kind', '')} {c.get('path', '')}".strip() for c in item.get("changes") or ())
|
||||
return "\n".join(lines), status == "failed"
|
||||
if item_type == "web_search":
|
||||
return "", status == "failed"
|
||||
error = item.get("error")
|
||||
if error:
|
||||
message = error.get("message") if isinstance(error, dict) else error
|
||||
return str(message), True
|
||||
result = item.get("result")
|
||||
if result is None:
|
||||
return "", status == "failed"
|
||||
if isinstance(result, str):
|
||||
return result, status == "failed"
|
||||
return json.dumps(result), status == "failed"
|
||||
|
||||
|
||||
def _tool_item_events(
|
||||
item_id: str, item: Mapping[str, Any], completed: bool, state: CodexStreamState
|
||||
) -> Iterator[Event]:
|
||||
if item_id not in state.started:
|
||||
state.started.add(item_id)
|
||||
name, native_name, tool_input, builtin = _tool_input(item)
|
||||
yield ToolCall(id=item_id, name=name, native_name=native_name, input=tool_input, builtin=builtin)
|
||||
if completed:
|
||||
output, is_error = _tool_output(item)
|
||||
yield ToolResult(id=item_id, output=output, is_error=is_error)
|
||||
|
||||
|
||||
def _item_events(event_type: str, item: Mapping[str, Any], state: CodexStreamState) -> Sequence[Event]:
|
||||
item_type = item.get("type")
|
||||
item_id = str(item.get("id") or "")
|
||||
completed = event_type == "item.completed"
|
||||
if item_type == "agent_message":
|
||||
if not completed:
|
||||
return event_list()
|
||||
text = str(item.get("text") or "")
|
||||
state.final_text = text
|
||||
return event_list(Text(delta=text)) if text else event_list()
|
||||
if item_type == "reasoning":
|
||||
text = str(item.get("text") or "")
|
||||
return event_list(Reasoning(delta=text)) if completed and text else event_list()
|
||||
if item_type not in _TOOL_ITEM_TYPES:
|
||||
return event_list()
|
||||
return event_list(*_tool_item_events(item_id, item, completed, state))
|
||||
|
||||
|
||||
def toml_value(value: object, depth: int = 0) -> str:
|
||||
"""Encode a Python value as a TOML value for `codex -c key=value`."""
|
||||
if depth > DEFAULT_MAX_RECURSE_DEPTH:
|
||||
raise OptionsMismatch(f"CodexOptions.config is nested deeper than {DEFAULT_MAX_RECURSE_DEPTH} levels")
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return repr(value)
|
||||
if isinstance(value, str):
|
||||
return json.dumps(value)
|
||||
if isinstance(value, Mapping):
|
||||
pairs = ", ".join(f"{toml_key(k)} = {toml_value(v, depth + 1)}" for k, v in value.items())
|
||||
return "{" + pairs + "}"
|
||||
if isinstance(value, (list, tuple)):
|
||||
return "[" + ", ".join(toml_value(v, depth + 1) for v in value) + "]"
|
||||
raise OptionsMismatch(f"CodexOptions.config value of type {type(value).__name__} cannot be passed to codex")
|
||||
|
||||
|
||||
def toml_key(key: object) -> str:
|
||||
text = str(key)
|
||||
return text if _BARE_TOML_KEY.match(text) else json.dumps(text)
|
||||
|
||||
|
||||
def _config_override(key: object, value: object) -> str:
|
||||
dotted = str(key)
|
||||
if not dotted or "=" in dotted:
|
||||
raise OptionsMismatch(f"Invalid CodexOptions.config key: {dotted!r}")
|
||||
if dotted.split(".", 1)[0] in MANAGED_CONFIG_KEYS:
|
||||
raise OptionsMismatch(
|
||||
f"CodexOptions.config[{dotted!r}] is managed by LiteLLM; use the matching agent() argument instead"
|
||||
)
|
||||
return f"{dotted}={toml_value(value)}"
|
||||
|
||||
|
||||
def config_overrides(config: Mapping[str, Any]) -> Sequence[str]:
|
||||
"""`-c` override strings for CodexOptions.config, rejecting managed keys."""
|
||||
overrides: Final = (_config_override(key, value) for key, value in config.items())
|
||||
return list(overrides) # mutable-ok: public helper; tests compare to a list
|
||||
|
||||
|
||||
def _flag_pairs(flag: str, values: Sequence[str]) -> tuple[str, ...]:
|
||||
return tuple(itertools.chain.from_iterable((flag, value) for value in values))
|
||||
|
||||
|
||||
class CodexHarnessConfig(BaseCLIHarnessConfig):
|
||||
harness = Harness.CODEX
|
||||
options_type = CodexOptions
|
||||
capabilities = Capabilities(
|
||||
structured_output=True,
|
||||
tool_approval=False,
|
||||
tool_filtering=False,
|
||||
history=False,
|
||||
custom_tools=False,
|
||||
skills=True,
|
||||
resume=True,
|
||||
permission_modes=frozenset({"read-only", "full"}),
|
||||
)
|
||||
|
||||
def get_binary(self) -> str:
|
||||
return CODEX_BINARY
|
||||
|
||||
def get_install_hint(self) -> str:
|
||||
return "npm install -g @openai/codex (or brew install codex)"
|
||||
|
||||
def validate_environment(self, ctx: SessionContext) -> None:
|
||||
options: CodexOptions = self.get_options(ctx)
|
||||
config_overrides(options.config)
|
||||
|
||||
def transform_session_setup(self, ctx: SessionContext, private_dir: str) -> HarnessSessionSetup:
|
||||
if ctx.endpoint is None:
|
||||
raise HarnessError("Codex needs the session model endpoint")
|
||||
options: CodexOptions = self.get_options(ctx)
|
||||
files: Final = (
|
||||
MappingProxyType(
|
||||
{CODEX_SCHEMA_FILENAME: json.dumps(strict_json_schema(ctx.output.model_json_schema())).encode("utf-8")}
|
||||
)
|
||||
if ctx.output is not None
|
||||
else MappingProxyType({})
|
||||
)
|
||||
return HarnessSessionSetup(
|
||||
files=files,
|
||||
persisted_dirs=[("sessions", "codex/sessions")], # mutable-ok: tests compare to a list
|
||||
skills_dir="skills",
|
||||
env=MappingProxyType({**options.env, CODEX_TOKEN_ENV: ctx.endpoint.token, "CODEX_HOME": private_dir}),
|
||||
)
|
||||
|
||||
def transform_turn_request(
|
||||
self,
|
||||
ctx: SessionContext,
|
||||
setup: HarnessSessionSetup,
|
||||
private_dir: str,
|
||||
prompt: str,
|
||||
native_session_id: str | None,
|
||||
) -> HarnessTurnRequest:
|
||||
if ctx.endpoint is None:
|
||||
raise HarnessError("Codex needs the session model endpoint")
|
||||
options: CodexOptions = self.get_options(ctx)
|
||||
head: Final = (
|
||||
(CODEX_BINARY, "exec", "resume", native_session_id) if native_session_id else (CODEX_BINARY, "exec")
|
||||
)
|
||||
argv: Final = (
|
||||
*head,
|
||||
"--json",
|
||||
"--skip-git-repo-check",
|
||||
*(("-m", ctx.model) if ctx.model else ()),
|
||||
*_flag_pairs("-c", self._provider_overrides(ctx)),
|
||||
*self._permission_args(ctx, native_session_id),
|
||||
*_flag_pairs("-c", self._feature_overrides(ctx, options)),
|
||||
*_flag_pairs("-c", config_overrides(options.config)),
|
||||
*(
|
||||
("--output-schema", f"{private_dir}/{CODEX_SCHEMA_FILENAME}")
|
||||
if CODEX_SCHEMA_FILENAME in setup.files
|
||||
else ()
|
||||
),
|
||||
*(() if native_session_id else ("-C", ctx.sandbox.workdir)),
|
||||
"-",
|
||||
)
|
||||
return HarnessTurnRequest(argv=argv, env=setup.env, stdin=prompt, cwd=ctx.sandbox.workdir)
|
||||
|
||||
def _provider_overrides(self, ctx: SessionContext) -> tuple[str, ...]:
|
||||
assert ctx.endpoint is not None
|
||||
base_url = ctx.sandbox.host_url(ctx.endpoint.port).rstrip("/") + "/v1"
|
||||
prefix = f"model_providers.{CODEX_PROVIDER_ID}"
|
||||
return (
|
||||
f"model_provider={CODEX_PROVIDER_ID}",
|
||||
f"{prefix}.name={CODEX_PROVIDER_ID}",
|
||||
f"{prefix}.base_url={toml_value(base_url)}",
|
||||
f"{prefix}.env_key={CODEX_TOKEN_ENV}",
|
||||
f"{prefix}.wire_api=responses",
|
||||
"approval_policy=never",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _permission_args(ctx: SessionContext, native_session_id: str | None) -> tuple[str, ...]:
|
||||
if ctx.permissions == "read-only":
|
||||
mode = "read-only"
|
||||
elif getattr(ctx.sandbox, "is_container", False):
|
||||
# The container is already the boundary; nested sandboxing fails in containers.
|
||||
return ("--dangerously-bypass-approvals-and-sandbox",)
|
||||
else:
|
||||
mode = "workspace-write"
|
||||
# `codex exec resume` has no --sandbox flag; the config key works for both.
|
||||
if native_session_id:
|
||||
return ("-c", f"sandbox_mode={toml_value(mode)}")
|
||||
return ("--sandbox", mode)
|
||||
|
||||
@staticmethod
|
||||
def _feature_overrides(ctx: SessionContext, options: CodexOptions) -> tuple[str, ...]:
|
||||
reasoning: Final = (
|
||||
(
|
||||
f"model_reasoning_effort={options.reasoning_effort}",
|
||||
"model_reasoning_summary=auto",
|
||||
"model_supports_reasoning_summaries=true",
|
||||
)
|
||||
if options.reasoning_effort
|
||||
else ()
|
||||
)
|
||||
instructions: Final = (f"developer_instructions={toml_value(ctx.instructions)}",) if ctx.instructions else ()
|
||||
return (f"web_search={'live' if options.web_search else 'disabled'}", *reasoning, *instructions)
|
||||
|
||||
def create_stream_state(self) -> CodexStreamState:
|
||||
return CodexStreamState()
|
||||
|
||||
def transform_stream_line(self, line: Mapping[str, Any], state: CodexStreamState) -> Sequence[Event]:
|
||||
"""turn.completed usage is ignored on purpose: the session endpoint accounts it."""
|
||||
event_type = line.get("type")
|
||||
if event_type == "thread.started":
|
||||
if line.get("thread_id"):
|
||||
state.thread_id = str(line["thread_id"])
|
||||
return event_list()
|
||||
if event_type in ("item.started", "item.updated", "item.completed"):
|
||||
item = line.get("item")
|
||||
return _item_events(str(event_type), item, state) if isinstance(item, dict) else event_list()
|
||||
if event_type == "error":
|
||||
state.error = str(line.get("message") or "codex reported an error")
|
||||
return event_list()
|
||||
if event_type == "turn.failed":
|
||||
error = line.get("error")
|
||||
message = error.get("message") if isinstance(error, dict) else error
|
||||
state.error = str(message or state.error or "codex turn failed")
|
||||
state.failed = True
|
||||
return event_list()
|
||||
|
||||
def get_native_session_id(self, state: CodexStreamState) -> str | None:
|
||||
return state.thread_id
|
||||
|
||||
def transform_turn_response(
|
||||
self,
|
||||
ctx: SessionContext,
|
||||
state: CodexStreamState,
|
||||
exit_code: int,
|
||||
stderr_tail: Sequence[str],
|
||||
) -> HarnessTurnResponse:
|
||||
if state.failed:
|
||||
raise HarnessTurnError(f"codex turn failed: {state.error}")
|
||||
if exit_code != 0:
|
||||
detail = stderr_tail_text(stderr_tail) or state.error or "no output"
|
||||
raise HarnessTurnError(f"codex exited with code {exit_code}: {detail}")
|
||||
output_json = state.final_text if ctx.output is not None else None
|
||||
return HarnessTurnResponse(final_text=state.final_text, output_json=output_json)
|
||||
0
litellm/llms/deepagents/__init__.py
Normal file
0
litellm/llms/deepagents/__init__.py
Normal file
0
litellm/llms/deepagents/harness/__init__.py
Normal file
0
litellm/llms/deepagents/harness/__init__.py
Normal file
559
litellm/llms/deepagents/harness/sandbox_backend.py
Normal file
559
litellm/llms/deepagents/harness/sandbox_backend.py
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
"""Deep Agents pieces that subclass optional-dependency bases.
|
||||
|
||||
Only imported by `litellm.harness.handlers.deepagents_handler.load_deps()`, so `deepagents`,
|
||||
`langchain` and `langchain-core` are never imported unless Harness.DEEPAGENTS is used.
|
||||
|
||||
`SandboxBackend` implements deepagents' `SandboxBackendProtocol` on top of a litellm
|
||||
`Sandbox`. The agent sees virtual paths rooted at the sandbox workdir (`/src/a.py` is
|
||||
`<workdir>/src/a.py`); file bytes move through `Sandbox.read/write`, and ls/glob/grep/
|
||||
delete/execute run plain POSIX commands through `Sandbox.run`, so the same code serves the
|
||||
local and docker sandboxes (no python3 needed inside the sandbox) and inherits the sandbox's
|
||||
env scrubbing and path confinement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import itertools
|
||||
import posixpath
|
||||
import re
|
||||
import shlex
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable, Coroutine, Iterator, Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final, TypeVar
|
||||
|
||||
from deepagents.backends.protocol import (
|
||||
DeleteResult,
|
||||
EditResult,
|
||||
ExecuteResponse,
|
||||
FileData,
|
||||
FileDownloadResponse,
|
||||
FileInfo,
|
||||
FileUploadResponse,
|
||||
GlobResult,
|
||||
GrepMatch,
|
||||
GrepResult,
|
||||
LsResult,
|
||||
ReadResult,
|
||||
SandboxBackendProtocol,
|
||||
WriteResult,
|
||||
)
|
||||
from deepagents.backends.utils import (
|
||||
InvalidGlobPatternError,
|
||||
compile_grep_include_glob,
|
||||
perform_string_replacement,
|
||||
slice_read_response,
|
||||
)
|
||||
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse, ToolCallRequest
|
||||
from langchain.agents.middleware.types import ModelCallResult
|
||||
from langchain_core.callbacks import AsyncCallbackHandler
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.outputs import LLMResult
|
||||
from langchain_core.tools import BaseTool
|
||||
from langgraph.types import Command
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import HARNESS_SNAPSHOT_SKIP_DIRS
|
||||
from litellm.harness.context import SessionContext
|
||||
from litellm.harness.errors import SandboxError
|
||||
from litellm.harness.sandbox.base import CompletedRun, Sandbox
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# Module alias so ruff recognises `.exception()` as logging the swallowed error (BLE001).
|
||||
_logger = verbose_logger
|
||||
# Models cost_per_token could not price: logged once, then skipped (always 0.0).
|
||||
_UNPRICED_MODELS: set[str] = set() # mutable-ok: process-wide log-once memo, grown as unpriced models are seen
|
||||
|
||||
DEEPAGENTS_EXECUTE_TIMEOUT_SECONDS: Final = 120.0
|
||||
DEEPAGENTS_FS_TIMEOUT_SECONDS: Final = 60.0
|
||||
DEEPAGENTS_MAX_OUTPUT_BYTES: Final = 100_000
|
||||
_EXIT_NOT_FOUND: Final = 3
|
||||
_EXIT_NOT_DIR: Final = 4
|
||||
_EXIT_TIMEOUT: Final = 124
|
||||
_READ_ONLY_ERROR: Final = "Error: this session is read-only; files cannot be changed"
|
||||
_NO_EXECUTE_ERROR: Final = "Error: shell execution is disabled for this session"
|
||||
# $1 = directory. Prints "d/<name>" or "f/<name>" per entry ("/" never appears in a name).
|
||||
_LS_SCRIPT: Final = (
|
||||
'[ -e "$1" ] || exit 3; [ -d "$1" ] || exit 4; cd "$1" || exit 5; '
|
||||
'for f in * .[!.]* ..?*; do if [ -e "$f" ] || [ -L "$f" ]; then '
|
||||
'if [ -d "$f" ]; then printf "d/%s\\n" "$f"; else printf "f/%s\\n" "$f"; fi; fi; done'
|
||||
)
|
||||
_DELETE_SCRIPT: Final = '[ -e "$1" ] || [ -L "$1" ] || exit 3; rm -rf -- "$1"'
|
||||
# $1 = path. Prints the symlink-resolved absolute path of its nearest existing ancestor (the
|
||||
# path itself when it exists). New files and new directories (`a/b/new.py`) resolve through
|
||||
# whatever part already exists, so a symlinked ancestor is still caught.
|
||||
_REALPATH_SCRIPT: Final = (
|
||||
'p="$1"; while [ ! -e "$p" ] && [ ! -L "$p" ]; do q=$(dirname -- "$p"); '
|
||||
'[ "$q" = "$p" ] && exit 3; p="$q"; done; realpath -- "$p"'
|
||||
)
|
||||
_GREP_LINE: Final = re.compile(r"^(.+?):(\d+):(.*)$")
|
||||
_FILTER_MIDDLEWARE_NAME: Final = "LiteLLMHarnessToolFilter"
|
||||
|
||||
|
||||
def _decode(data: bytes) -> str:
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _ls_entries(base: str, stdout: str) -> Iterator[FileInfo]:
|
||||
for line in stdout.splitlines():
|
||||
kind, _, name = line.partition("/")
|
||||
if name:
|
||||
is_dir = kind == "d"
|
||||
yield FileInfo(path=f"{base}/{name}" + ("/" if is_dir else ""), is_dir=is_dir)
|
||||
|
||||
|
||||
class SandboxBackend(SandboxBackendProtocol): # pyright: ignore[reportUntypedBaseClass] # deepagents/langchain are optional and not installed for type checking
|
||||
"""deepagents backend whose files and shell live in a litellm Sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sandbox: Sandbox,
|
||||
*,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
writable: bool = True,
|
||||
allow_execute: bool = True,
|
||||
) -> None:
|
||||
self._sandbox = sandbox
|
||||
self._loop = loop
|
||||
self._root = posixpath.normpath(sandbox.workdir)
|
||||
self._real_root: str | None = None
|
||||
self._writable = writable
|
||||
self._allow_execute = allow_execute
|
||||
self._id = f"litellm-harness-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self._id
|
||||
|
||||
# -- paths --------------------------------------------------------------
|
||||
|
||||
def to_real(self, path: str) -> str:
|
||||
"""Sandbox path for a virtual path (or an absolute path already under workdir)."""
|
||||
normalized = posixpath.normpath("/" + path.lstrip("/"))
|
||||
if ".." in normalized.split("/"):
|
||||
raise ValueError(f"path traversal not allowed: {path}")
|
||||
if normalized == self._root or normalized.startswith(self._root + "/"):
|
||||
return normalized
|
||||
if normalized == "/":
|
||||
return self._root
|
||||
return self._root + normalized
|
||||
|
||||
async def to_confined(self, path: str) -> str:
|
||||
"""to_real, then resolve symlinks inside the sandbox and refuse anything outside workdir.
|
||||
|
||||
A repo can contain `link -> ~/.aws/credentials`; without this, read/grep/glob would
|
||||
follow it and read host secrets even in read-only mode.
|
||||
"""
|
||||
real = self.to_real(path)
|
||||
done = await self._run(("sh", "-c", _REALPATH_SCRIPT, "sh", real))
|
||||
resolved = done.stdout.strip()
|
||||
if done.exit_code != 0 or not resolved:
|
||||
raise ValueError(f"path not found: {path}")
|
||||
root = await self._resolved_root()
|
||||
if resolved != root and not resolved.startswith(root + "/"):
|
||||
raise ValueError(f"path resolves outside the workspace: {path}")
|
||||
return real
|
||||
|
||||
async def _resolved_root(self) -> str:
|
||||
if self._real_root is None:
|
||||
done = await self._run(("realpath", "--", self._root))
|
||||
self._real_root = done.stdout.strip() if done.exit_code == 0 and done.stdout.strip() else self._root
|
||||
return self._real_root
|
||||
|
||||
def to_virtual(self, real: str) -> str:
|
||||
if real == self._root:
|
||||
return "/"
|
||||
if real.startswith(self._root + "/"):
|
||||
return real[len(self._root) :]
|
||||
return real
|
||||
|
||||
# -- sync bridge (deepagents only calls these outside the event loop) ---
|
||||
|
||||
def _sync(self, coro: Coroutine[Any, Any, T]) -> T:
|
||||
try:
|
||||
running = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
running = None
|
||||
if running is self._loop:
|
||||
coro.close()
|
||||
raise RuntimeError("SandboxBackend sync methods cannot run on the event loop thread")
|
||||
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
|
||||
|
||||
async def _run(self, cmd: Sequence[str], timeout: float | None = DEEPAGENTS_FS_TIMEOUT_SECONDS) -> CompletedRun:
|
||||
return await self._sandbox.run(cmd, timeout=timeout)
|
||||
|
||||
# -- ls -----------------------------------------------------------------
|
||||
|
||||
async def als(self, path: str) -> LsResult:
|
||||
try:
|
||||
real = await self.to_confined(path)
|
||||
done = await self._run(("sh", "-c", _LS_SCRIPT, "sh", real))
|
||||
except (ValueError, SandboxError) as e:
|
||||
return LsResult(error=f"Path '{path}': {e}")
|
||||
if done.exit_code == _EXIT_NOT_FOUND:
|
||||
return LsResult(error=f"Path '{path}': path_not_found")
|
||||
if done.exit_code == _EXIT_NOT_DIR:
|
||||
return LsResult(error=f"Path '{path}': not_a_directory")
|
||||
if done.exit_code != 0:
|
||||
return LsResult(error=f"Path '{path}': {done.stderr.strip() or 'ls failed'}")
|
||||
base = self.to_virtual(real).rstrip("/")
|
||||
entries = sorted(_ls_entries(base, done.stdout), key=lambda e: e["path"])
|
||||
return LsResult(entries=entries)
|
||||
|
||||
def ls(self, path: str) -> LsResult:
|
||||
return self._sync(self.als(path))
|
||||
|
||||
# -- read / write / edit ------------------------------------------------
|
||||
|
||||
async def _read_bytes(self, path: str) -> bytes:
|
||||
return await self._sandbox.read(await self.to_confined(path))
|
||||
|
||||
async def aread(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult:
|
||||
try:
|
||||
data = await self._read_bytes(file_path)
|
||||
except ValueError as e:
|
||||
return ReadResult(error=f"Error reading file '{file_path}': {e}")
|
||||
except SandboxError:
|
||||
return ReadResult(error=f"File '{file_path}' not found")
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
encoded = base64.standard_b64encode(data).decode("ascii")
|
||||
return ReadResult(file_data=FileData(content=encoded, encoding="base64"))
|
||||
return slice_read_response(FileData(content=text, encoding="utf-8"), offset, limit)
|
||||
|
||||
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult:
|
||||
return self._sync(self.aread(file_path, offset, limit))
|
||||
|
||||
async def awrite(self, file_path: str, content: str) -> WriteResult:
|
||||
if not self._writable:
|
||||
return WriteResult(error=_READ_ONLY_ERROR)
|
||||
try:
|
||||
await self._sandbox.write(await self.to_confined(file_path), content.encode("utf-8"))
|
||||
except (ValueError, SandboxError) as e:
|
||||
return WriteResult(error=f"Error writing file '{file_path}': {e}")
|
||||
return WriteResult(path=file_path)
|
||||
|
||||
def write(self, file_path: str, content: str) -> WriteResult:
|
||||
return self._sync(self.awrite(file_path, content))
|
||||
|
||||
async def aedit(
|
||||
self,
|
||||
file_path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False,
|
||||
) -> EditResult:
|
||||
if not self._writable:
|
||||
return EditResult(error=_READ_ONLY_ERROR)
|
||||
try:
|
||||
content = _decode(await self._read_bytes(file_path))
|
||||
except ValueError as e:
|
||||
return EditResult(error=f"Error editing file '{file_path}': {e}")
|
||||
except SandboxError:
|
||||
return EditResult(error=f"Error: File '{file_path}' not found")
|
||||
old = old_string.replace("\r\n", "\n")
|
||||
new = new_string.replace("\r\n", "\n")
|
||||
replaced = perform_string_replacement(content.replace("\r\n", "\n"), old, new, replace_all)
|
||||
if isinstance(replaced, str):
|
||||
return EditResult(error=replaced)
|
||||
new_content, occurrences = replaced
|
||||
try:
|
||||
await self._sandbox.write(await self.to_confined(file_path), new_content.encode("utf-8"))
|
||||
except SandboxError as e:
|
||||
return EditResult(error=f"Error editing file '{file_path}': {e}")
|
||||
return EditResult(path=file_path, occurrences=int(occurrences))
|
||||
|
||||
def edit(
|
||||
self,
|
||||
file_path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False,
|
||||
) -> EditResult:
|
||||
return self._sync(self.aedit(file_path, old_string, new_string, replace_all))
|
||||
|
||||
async def adelete(self, file_path: str) -> DeleteResult:
|
||||
if not self._writable:
|
||||
return DeleteResult(error=_READ_ONLY_ERROR)
|
||||
try:
|
||||
real = await self.to_confined(file_path)
|
||||
if real == self._root:
|
||||
return DeleteResult(error="Error: refusing to delete the workspace root")
|
||||
done = await self._run(("sh", "-c", _DELETE_SCRIPT, "sh", real))
|
||||
except (ValueError, SandboxError) as e:
|
||||
return DeleteResult(error=f"Error deleting '{file_path}': {e}")
|
||||
if done.exit_code == _EXIT_NOT_FOUND:
|
||||
return DeleteResult(error=f"Error: '{file_path}' not found")
|
||||
if done.exit_code != 0:
|
||||
return DeleteResult(error=f"Error deleting '{file_path}': {done.stderr.strip()}")
|
||||
return DeleteResult(path=file_path)
|
||||
|
||||
def delete(self, file_path: str) -> DeleteResult:
|
||||
return self._sync(self.adelete(file_path))
|
||||
|
||||
# -- glob / grep --------------------------------------------------------
|
||||
|
||||
def _find_cmd(self, root: str) -> tuple[str, ...]:
|
||||
prune = tuple(
|
||||
itertools.chain.from_iterable(
|
||||
("-o", "-name", name) if index else ("-name", name)
|
||||
for index, name in enumerate(sorted(HARNESS_SNAPSHOT_SKIP_DIRS))
|
||||
)
|
||||
)
|
||||
# -P: never follow symlinks, so a repo link to ~/.aws cannot pull host files in.
|
||||
return ("find", "-P", root, "(", *prune, ")", "-prune", "-o", "-type", "f", "-print")
|
||||
|
||||
def _grep_cmd(self, pattern: str, root: str) -> tuple[str, ...]:
|
||||
# grep only the regular files `find -P -type f` lists: symlinks are never followed,
|
||||
# whatever grep implementation (GNU -R vs BSD -r) the sandbox has.
|
||||
find_cmd = " ".join(shlex.quote(part) for part in self._find_cmd(root))
|
||||
return ("sh", "-c", f'{find_cmd} | tr "\\n" "\\0" | xargs -0 grep -nHFI -e "$1" --', "sh", pattern)
|
||||
|
||||
async def aglob(self, pattern: str, path: str | None = None) -> GlobResult:
|
||||
try:
|
||||
matcher = compile_grep_include_glob(pattern)
|
||||
root = await self.to_confined(path or "/")
|
||||
done = await self._run(self._find_cmd(root))
|
||||
except (InvalidGlobPatternError, ValueError, SandboxError) as e:
|
||||
return GlobResult(error=str(e), matches=None)
|
||||
if done.exit_code != 0 and not done.stdout:
|
||||
return GlobResult(matches=[]) # mutable-ok: deepagents GlobResult.matches is typed list[FileInfo]
|
||||
matches = sorted(
|
||||
(
|
||||
FileInfo(path=self.to_virtual(real), is_dir=False)
|
||||
for real in done.stdout.splitlines()
|
||||
if matcher(posixpath.relpath(real, root))
|
||||
),
|
||||
key=lambda m: m["path"],
|
||||
)
|
||||
return GlobResult(matches=matches, truncated=done.exit_code != 0)
|
||||
|
||||
def _grep_matches(self, stdout: str, root: str, include: Callable[[str], bool] | None) -> Iterator[GrepMatch]:
|
||||
for line in stdout.splitlines():
|
||||
parsed = _GREP_LINE.match(line)
|
||||
if parsed is None:
|
||||
continue
|
||||
real = parsed.group(1)
|
||||
if include is None or include(posixpath.relpath(real, root)):
|
||||
yield GrepMatch(path=self.to_virtual(real), line=int(parsed.group(2)), text=parsed.group(3))
|
||||
|
||||
def glob(self, pattern: str, path: str | None = None) -> GlobResult:
|
||||
return self._sync(self.aglob(pattern, path))
|
||||
|
||||
async def agrep(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
*,
|
||||
max_count: int | None = None,
|
||||
) -> GrepResult:
|
||||
try:
|
||||
include = compile_grep_include_glob(glob) if glob else None
|
||||
root = await self.to_confined(path or "/")
|
||||
done = await self._run(self._grep_cmd(pattern, root))
|
||||
except (InvalidGlobPatternError, ValueError, SandboxError) as e:
|
||||
return GrepResult(error=f"Path '{path or '/'}': {e}")
|
||||
if done.exit_code not in (0, 1) and not done.stdout:
|
||||
return GrepResult(error=f"Path '{path or '/'}': {done.stderr.strip() or 'grep failed'}")
|
||||
matches = list( # mutable-ok: GrepResult.matches is list[GrepMatch]
|
||||
self._grep_matches(done.stdout, root, include)
|
||||
)
|
||||
if max_count is not None and len(matches) > max_count:
|
||||
return GrepResult(matches=matches[:max_count], truncated=True)
|
||||
return GrepResult(matches=matches)
|
||||
|
||||
def grep(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
*,
|
||||
max_count: int | None = None,
|
||||
) -> GrepResult:
|
||||
return self._sync(self.agrep(pattern, path, glob, max_count=max_count))
|
||||
|
||||
# -- upload / download --------------------------------------------------
|
||||
|
||||
async def _upload_one(self, path: str, data: bytes) -> FileUploadResponse:
|
||||
if not self._writable:
|
||||
return FileUploadResponse(path=path, error="permission_denied")
|
||||
try:
|
||||
await self._sandbox.write(await self.to_confined(path), data)
|
||||
except ValueError:
|
||||
return FileUploadResponse(path=path, error="invalid_path")
|
||||
except SandboxError as e:
|
||||
return FileUploadResponse(path=path, error=str(e))
|
||||
return FileUploadResponse(path=path)
|
||||
|
||||
async def aupload_files(
|
||||
self,
|
||||
files: list[tuple[str, bytes]], # mutable-ok: signature fixed by deepagents BackendProtocol
|
||||
) -> list[FileUploadResponse]: # mutable-ok: return type fixed by deepagents BackendProtocol
|
||||
return [ # mutable-ok: BackendProtocol returns a list
|
||||
await self._upload_one(path, data) for path, data in files
|
||||
]
|
||||
|
||||
def upload_files(
|
||||
self,
|
||||
files: list[tuple[str, bytes]], # mutable-ok: signature fixed by deepagents BackendProtocol
|
||||
) -> list[FileUploadResponse]: # mutable-ok: return type fixed by deepagents BackendProtocol
|
||||
return self._sync(self.aupload_files(files))
|
||||
|
||||
async def _download_one(self, path: str) -> FileDownloadResponse:
|
||||
try:
|
||||
return FileDownloadResponse(path=path, content=await self._read_bytes(path))
|
||||
except ValueError:
|
||||
return FileDownloadResponse(path=path, error="invalid_path")
|
||||
except SandboxError:
|
||||
return FileDownloadResponse(path=path, error="file_not_found")
|
||||
|
||||
async def adownload_files(
|
||||
self,
|
||||
paths: list[str], # mutable-ok: signature fixed by deepagents BackendProtocol
|
||||
) -> list[FileDownloadResponse]: # mutable-ok: return type fixed by deepagents BackendProtocol
|
||||
return [await self._download_one(path) for path in paths] # mutable-ok: BackendProtocol returns a list
|
||||
|
||||
def download_files(
|
||||
self,
|
||||
paths: list[str], # mutable-ok: signature fixed by deepagents BackendProtocol
|
||||
) -> list[FileDownloadResponse]: # mutable-ok: return type fixed by deepagents BackendProtocol
|
||||
return self._sync(self.adownload_files(paths))
|
||||
|
||||
# -- execute ------------------------------------------------------------
|
||||
|
||||
async def aexecute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
||||
if not self._allow_execute:
|
||||
return ExecuteResponse(output=_NO_EXECUTE_ERROR, exit_code=1)
|
||||
limit = float(timeout) if timeout else DEEPAGENTS_EXECUTE_TIMEOUT_SECONDS
|
||||
try:
|
||||
done = await self._run(("sh", "-c", command), timeout=limit)
|
||||
except SandboxError as e:
|
||||
return ExecuteResponse(output=f"Error: {e}", exit_code=_EXIT_TIMEOUT)
|
||||
output = done.stdout
|
||||
if done.stderr:
|
||||
output = f"{output}\n{done.stderr}" if output else done.stderr
|
||||
truncated = len(output.encode("utf-8")) > DEEPAGENTS_MAX_OUTPUT_BYTES
|
||||
if truncated:
|
||||
output = output.encode("utf-8")[:DEEPAGENTS_MAX_OUTPUT_BYTES].decode("utf-8", errors="ignore")
|
||||
return ExecuteResponse(output=output, exit_code=done.exit_code, truncated=truncated)
|
||||
|
||||
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
||||
return self._sync(self.aexecute(command, timeout=timeout))
|
||||
|
||||
|
||||
def _tool_name(tool: BaseTool | Mapping[str, object]) -> str | None:
|
||||
name = tool.get("name") if isinstance(tool, Mapping) else tool.name
|
||||
return name if isinstance(name, str) else None
|
||||
|
||||
|
||||
def _blocked_message(request: ToolCallRequest, blocked: frozenset[str]) -> ToolMessage | None:
|
||||
name = request.tool_call["name"]
|
||||
if name not in blocked:
|
||||
return None
|
||||
return ToolMessage(
|
||||
content=f"Error: {name} is disabled for this session.",
|
||||
tool_call_id=request.tool_call["id"] or "",
|
||||
name=name,
|
||||
status="error",
|
||||
)
|
||||
|
||||
|
||||
class ToolFilterMiddleware(AgentMiddleware): # pyright: ignore[reportUntypedBaseClass] # deepagents/langchain are optional and not installed for type checking
|
||||
"""Hide tools from the model and refuse calls to them (disable_tools / permissions)."""
|
||||
|
||||
def __init__(self, blocked: frozenset[str]) -> None:
|
||||
super().__init__()
|
||||
self._blocked = blocked
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return _FILTER_MIDDLEWARE_NAME
|
||||
|
||||
def _filtered(self, request: ModelRequest) -> ModelRequest:
|
||||
return request.override(
|
||||
tools=[ # mutable-ok: ModelRequest.tools is a list
|
||||
t for t in request.tools if _tool_name(t) not in self._blocked
|
||||
]
|
||||
)
|
||||
|
||||
def wrap_model_call(
|
||||
self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
|
||||
) -> ModelCallResult:
|
||||
return handler(self._filtered(request))
|
||||
|
||||
async def awrap_model_call(
|
||||
self, request: ModelRequest, handler: Callable[[ModelRequest], Awaitable[ModelResponse]]
|
||||
) -> ModelCallResult:
|
||||
return await handler(self._filtered(request))
|
||||
|
||||
def wrap_tool_call(
|
||||
self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command]
|
||||
) -> ToolMessage | Command:
|
||||
return _blocked_message(request, self._blocked) or handler(request)
|
||||
|
||||
async def awrap_tool_call(
|
||||
self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]]
|
||||
) -> ToolMessage | Command:
|
||||
return _blocked_message(request, self._blocked) or await handler(request)
|
||||
|
||||
|
||||
def _number(value: object) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def message_cost(message: object, cost_model: str | None, input_tokens: int, output_tokens: int) -> float:
|
||||
"""Cost of one model call: response_cost reported by litellm, else cost_per_token, else 0."""
|
||||
metadata = getattr(message, "response_metadata", None) or MappingProxyType({})
|
||||
reported = _number(metadata.get("response_cost"))
|
||||
if reported is not None:
|
||||
return reported
|
||||
if not cost_model or cost_model in _UNPRICED_MODELS:
|
||||
return 0.0
|
||||
try:
|
||||
prompt_cost, completion_cost = litellm.cost_per_token(
|
||||
model=cost_model,
|
||||
prompt_tokens=input_tokens,
|
||||
completion_tokens=output_tokens,
|
||||
)
|
||||
return float(prompt_cost) + float(completion_cost)
|
||||
except Exception: # litellm raises plain Exception for unmapped models
|
||||
_UNPRICED_MODELS.add(cost_model)
|
||||
_logger.exception("harness deepagents: no cost for %s; counting its calls as 0", cost_model)
|
||||
return 0.0
|
||||
|
||||
|
||||
def record_llm_usage(ctx: SessionContext, cost_model: str | None, response: LLMResult) -> None:
|
||||
"""Add one model call's tokens and cost to the session counters. Never raises."""
|
||||
try:
|
||||
ctx.calls += 1
|
||||
for generations in response.generations:
|
||||
for generation in generations:
|
||||
message = getattr(generation, "message", None)
|
||||
usage = getattr(message, "usage_metadata", None) or MappingProxyType({})
|
||||
input_tokens = int(usage.get("input_tokens") or 0)
|
||||
output_tokens = int(usage.get("output_tokens") or 0)
|
||||
ctx.input_tokens += input_tokens
|
||||
ctx.output_tokens += output_tokens
|
||||
ctx.cost += message_cost(message, cost_model, input_tokens, output_tokens)
|
||||
except Exception:
|
||||
# Usage accounting must never fail a turn; log with traceback and move on.
|
||||
_logger.exception("harness deepagents: usage accounting failed")
|
||||
|
||||
|
||||
class UsageCallback(AsyncCallbackHandler): # pyright: ignore[reportUntypedBaseClass] # deepagents/langchain are optional and not installed for type checking
|
||||
"""Counts every model call in the graph, subagents and summarization included."""
|
||||
|
||||
def __init__(self, ctx: SessionContext, cost_model: str | None) -> None:
|
||||
self._ctx = ctx
|
||||
self._cost_model = cost_model
|
||||
|
||||
async def on_llm_end(self, response: LLMResult, **kwargs: object) -> None:
|
||||
record_llm_usage(self._ctx, self._cost_model, response)
|
||||
313
litellm/llms/deepagents/harness/transformation.py
Normal file
313
litellm/llms/deepagents/harness/transformation.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""
|
||||
Deep Agents harness config: LangChain `deepagents` running in your Python process.
|
||||
|
||||
Pure translation only: model kwargs (gateway mode uses `litellm_proxy/<group>` with the same
|
||||
attribution headers the CLI endpoint adds), permission and tool filtering, and LangGraph
|
||||
stream chunks to events. `litellm/harness/handlers/deepagents_handler.py` builds the agent,
|
||||
streams it, answers approvals and counts usage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from litellm.harness.options import DeepAgentsOptions
|
||||
from litellm.harness.types import (
|
||||
Capabilities,
|
||||
Event,
|
||||
Harness,
|
||||
Reasoning,
|
||||
Text,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from litellm.llms.base_llm.harness.transformation import BaseHarnessConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.harness.context import SessionContext
|
||||
|
||||
INSTALL_HINT: Final = "Deep Agents is not installed. Run: pip install deepagents langchain-litellm"
|
||||
SKILLS_DIR: Final = ".deepagents/skills"
|
||||
# Graph supersteps per agent turn (model node, tools node, middleware hooks) for recursion_limit.
|
||||
DEEPAGENTS_STEPS_PER_TURN: Final = 6
|
||||
DEEPAGENTS_BASE_RECURSION_LIMIT: Final = 25
|
||||
DEEPAGENTS_DEFAULT_RECURSION_LIMIT: Final = 1000
|
||||
_MODEL_NODE: Final = "model"
|
||||
# Only these graph nodes produce new messages; middleware hooks may re-emit history.
|
||||
_EVENT_NODES: Final = frozenset({"model", "tools"})
|
||||
|
||||
NORMALIZED_TO_NATIVE: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"read": "read_file",
|
||||
"write": "write_file",
|
||||
"edit": "edit_file",
|
||||
"bash": "execute",
|
||||
}
|
||||
)
|
||||
NATIVE_TO_NORMALIZED: Final[Mapping[str, str]] = MappingProxyType({v: k for k, v in NORMALIZED_TO_NATIVE.items()})
|
||||
BUILTIN_TOOLS: Final = frozenset(
|
||||
{
|
||||
"ls",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"delete",
|
||||
"glob",
|
||||
"grep",
|
||||
"execute",
|
||||
"write_todos",
|
||||
"task",
|
||||
}
|
||||
)
|
||||
WRITE_TOOLS: Final = frozenset({"write_file", "edit_file", "delete"})
|
||||
EXECUTE_TOOLS: Final = frozenset({"execute"})
|
||||
APPROVAL_TOOLS: Final = WRITE_TOOLS | EXECUTE_TOOLS
|
||||
_APPROVAL_DECISIONS: Final = ("approve", "reject")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure helpers (unit tested directly; kept module-level so they port cleanly)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def gateway_headers(
|
||||
ctx: SessionContext,
|
||||
) -> dict[str, str]: # mutable-ok: ChatLiteLLM.extra_headers is a pydantic dict field
|
||||
"""Same attribution headers the session endpoint adds for CLI harnesses."""
|
||||
metadata = ctx.metadata
|
||||
metadata_json = json.dumps(dict(metadata), default=str) if metadata else None # mutable-ok: for json.dumps
|
||||
metadata_header = (("x-litellm-spend-logs-metadata", metadata_json),) if metadata_json is not None else ()
|
||||
return dict( # mutable-ok: ChatLiteLLM.extra_headers is a pydantic dict field
|
||||
(("x-litellm-tags", f"harness,{ctx.harness.value}"), *metadata_header)
|
||||
)
|
||||
|
||||
|
||||
def chat_model_kwargs(
|
||||
ctx: SessionContext,
|
||||
) -> dict[str, Any]: # mutable-ok: ChatLiteLLM constructor kwargs, splatted as **kwargs
|
||||
"""ChatLiteLLM constructor kwargs for gateway or SDK mode."""
|
||||
if not ctx.model:
|
||||
raise ValueError("Harness.DEEPAGENTS needs model=")
|
||||
if ctx.gateway is not None:
|
||||
return { # mutable-ok: ChatLiteLLM constructor kwargs, splatted as **kwargs
|
||||
"model": f"litellm_proxy/{ctx.model}",
|
||||
"api_base": ctx.gateway.api_base,
|
||||
"api_key": ctx.gateway.api_key,
|
||||
"extra_headers": gateway_headers(ctx),
|
||||
}
|
||||
return {"model": ctx.model, "api_key": ctx.api_key, "api_base": ctx.api_base} # mutable-ok: ChatLiteLLM kwargs
|
||||
|
||||
|
||||
def native_tool_name(name: str) -> str:
|
||||
return NORMALIZED_TO_NATIVE.get(name, name)
|
||||
|
||||
|
||||
def normalized_tool_name(native: str) -> str:
|
||||
return NATIVE_TO_NORMALIZED.get(native, native)
|
||||
|
||||
|
||||
def blocked_tools(permissions: str, disable_tools: Sequence[str]) -> frozenset[str]:
|
||||
"""Native tool names the model must not see or call."""
|
||||
disabled = frozenset(native_tool_name(name) for name in disable_tools)
|
||||
if permissions == "read-only":
|
||||
return disabled | WRITE_TOOLS | EXECUTE_TOOLS
|
||||
if permissions == "edit":
|
||||
return disabled | EXECUTE_TOOLS
|
||||
return disabled
|
||||
|
||||
|
||||
def interrupt_config(
|
||||
permissions: str, blocked: frozenset[str]
|
||||
) -> dict[str, Any] | None: # mutable-ok: deepagents create_deep_agent(interrupt_on=) takes a dict
|
||||
"""interrupt_on for permissions='ask': approve/reject every mutating built-in."""
|
||||
if permissions != "ask":
|
||||
return None
|
||||
return { # mutable-ok: deepagents interrupt_on config (dict of InterruptOnConfig with list allowed_decisions)
|
||||
name: {"allowed_decisions": list(_APPROVAL_DECISIONS)} # mutable-ok: deepagents InterruptOnConfig shape
|
||||
for name in sorted(APPROVAL_TOOLS - blocked)
|
||||
}
|
||||
|
||||
|
||||
def recursion_limit(ctx: SessionContext) -> int:
|
||||
options = ctx.options if isinstance(ctx.options, DeepAgentsOptions) else None
|
||||
if options is not None and options.recursion_limit is not None:
|
||||
return options.recursion_limit
|
||||
if ctx.max_turns is not None:
|
||||
return DEEPAGENTS_BASE_RECURSION_LIMIT + ctx.max_turns * DEEPAGENTS_STEPS_PER_TURN
|
||||
return DEEPAGENTS_DEFAULT_RECURSION_LIMIT
|
||||
|
||||
|
||||
def content_text(content: object) -> str:
|
||||
"""Plain text of a LangChain message content (str or content blocks)."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if not isinstance(content, list):
|
||||
return ""
|
||||
return "".join(
|
||||
block if isinstance(block, str) else block.get("text", "") for block in content if _is_text_block(block)
|
||||
)
|
||||
|
||||
|
||||
def _is_text_block(block: object) -> bool:
|
||||
return isinstance(block, str) or (isinstance(block, dict) and block.get("type") == "text")
|
||||
|
||||
|
||||
def reasoning_text(message: object) -> str:
|
||||
"""Reasoning deltas from additional_kwargs or reasoning/thinking content blocks."""
|
||||
extra = getattr(message, "additional_kwargs", None) or MappingProxyType({})
|
||||
reasoning = extra.get("reasoning_content")
|
||||
if isinstance(reasoning, str) and reasoning:
|
||||
return reasoning
|
||||
content = getattr(message, "content", None)
|
||||
if not isinstance(content, list):
|
||||
return ""
|
||||
return "".join(
|
||||
str(block.get("reasoning") or block.get("thinking") or "")
|
||||
for block in content
|
||||
if isinstance(block, dict) and block.get("type") in ("reasoning", "thinking")
|
||||
)
|
||||
|
||||
|
||||
def stream_events(
|
||||
message: object,
|
||||
) -> list[Event]: # mutable-ok: returns a list; existing callers/tests compare it to list literals
|
||||
"""Text / Reasoning deltas for one streamed message chunk."""
|
||||
if getattr(message, "type", None) not in ("AIMessageChunk", "ai"):
|
||||
return [] # mutable-ok: returns a list; existing callers/tests compare it to list literals
|
||||
reasoning = reasoning_text(message)
|
||||
text = content_text(getattr(message, "content", ""))
|
||||
reasoning_events: tuple[Event, ...] = (Reasoning(delta=reasoning),) if reasoning else ()
|
||||
text_events: tuple[Event, ...] = (Text(delta=text),) if text else ()
|
||||
return [ # mutable-ok: returns a list; existing callers/tests compare it to list literals
|
||||
*reasoning_events,
|
||||
*text_events,
|
||||
]
|
||||
|
||||
|
||||
def tool_call_event(call: Mapping[str, Any]) -> ToolCall:
|
||||
native = str(call.get("name") or "")
|
||||
args = call.get("args")
|
||||
return ToolCall(
|
||||
id=str(call.get("id") or ""),
|
||||
name=normalized_tool_name(native),
|
||||
native_name=native,
|
||||
input=dict(args) if isinstance(args, Mapping) else {"args": args}, # mutable-ok: ToolCall.input is a dict
|
||||
builtin=native in BUILTIN_TOOLS,
|
||||
)
|
||||
|
||||
|
||||
def _node_messages(update: Mapping[Any, Any]) -> Iterator[object]:
|
||||
for node, delta in update.items():
|
||||
if node not in _EVENT_NODES or not isinstance(delta, Mapping):
|
||||
continue
|
||||
messages = delta.get("messages")
|
||||
if isinstance(messages, list):
|
||||
yield from messages
|
||||
|
||||
|
||||
def update_events(
|
||||
update: object, skip_tools: frozenset[str]
|
||||
) -> list[Event]: # mutable-ok: returns a list; existing callers/tests compare it to list literals
|
||||
"""ToolCall / ToolResult events from one `updates` stream chunk (node -> state delta)."""
|
||||
if not isinstance(update, Mapping):
|
||||
return [] # mutable-ok: returns a list; existing callers/tests compare it to list literals
|
||||
return list( # mutable-ok: returns a list; existing callers/tests compare it to list literals
|
||||
itertools.chain.from_iterable(_message_events(message, skip_tools) for message in _node_messages(update))
|
||||
)
|
||||
|
||||
|
||||
def _message_events(message: object, skip_tools: frozenset[str]) -> tuple[Event, ...]:
|
||||
kind = getattr(message, "type", None)
|
||||
if kind == "ai":
|
||||
calls = getattr(message, "tool_calls", None) or ()
|
||||
return tuple(tool_call_event(call) for call in calls if call.get("name") not in skip_tools)
|
||||
if kind == "tool" and getattr(message, "name", None) not in skip_tools:
|
||||
return (
|
||||
ToolResult(
|
||||
id=str(getattr(message, "tool_call_id", "") or ""),
|
||||
output=content_text(getattr(message, "content", "")),
|
||||
is_error=getattr(message, "status", None) == "error",
|
||||
),
|
||||
)
|
||||
return ()
|
||||
|
||||
|
||||
def interrupts_in(
|
||||
update: object,
|
||||
) -> list[Any]: # mutable-ok: returns a list; existing callers/tests compare it to list literals
|
||||
if not isinstance(update, Mapping):
|
||||
return [] # mutable-ok: returns a list; existing callers/tests compare it to list literals
|
||||
found = update.get("__interrupt__")
|
||||
items = tuple(found) if isinstance(found, (list, tuple)) else ()
|
||||
return list(items) # mutable-ok: list return; callers/tests compare to lists
|
||||
|
||||
|
||||
def final_ai_text(messages: Sequence[Any]) -> str:
|
||||
for message in reversed(messages):
|
||||
if getattr(message, "type", None) == "ai":
|
||||
text = content_text(getattr(message, "content", ""))
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def structured_json(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
dump = getattr(value, "model_dump_json", None)
|
||||
if callable(dump):
|
||||
return str(dump())
|
||||
return json.dumps(value, default=str)
|
||||
|
||||
|
||||
def approval_requests(
|
||||
interrupt_value: object,
|
||||
) -> list[Mapping[str, Any]]: # mutable-ok: returns a list; existing callers/tests compare it to list literals
|
||||
"""action_requests of a HumanInTheLoopMiddleware interrupt payload."""
|
||||
if not isinstance(interrupt_value, Mapping):
|
||||
return [] # mutable-ok: returns a list; existing callers/tests compare it to list literals
|
||||
requests = interrupt_value.get("action_requests")
|
||||
kept = tuple(r for r in requests if isinstance(r, Mapping)) if isinstance(requests, list) else ()
|
||||
return list(kept) # mutable-ok: list return; callers/tests compare to lists
|
||||
|
||||
|
||||
def decision(allowed: bool, reason: str) -> dict[str, Any]: # mutable-ok: LangGraph resume payload (HITL decision dict)
|
||||
if allowed:
|
||||
return {"type": "approve"} # mutable-ok: LangGraph resume payload (HITL decision dict)
|
||||
return { # mutable-ok: LangGraph HITL decision
|
||||
"type": "reject",
|
||||
"message": reason or "The user denied this tool call.",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnState:
|
||||
"""Mutable state across the stream passes of one turn."""
|
||||
|
||||
interrupts: tuple[Any, ...] = ()
|
||||
|
||||
|
||||
class DeepAgentsHarnessConfig(BaseHarnessConfig):
|
||||
harness = Harness.DEEPAGENTS
|
||||
options_type = DeepAgentsOptions
|
||||
uses_model_endpoint = False
|
||||
capabilities = Capabilities(
|
||||
structured_output=True,
|
||||
tool_approval=True,
|
||||
tool_filtering=True,
|
||||
history=True,
|
||||
custom_tools=True,
|
||||
skills=True,
|
||||
resume=True,
|
||||
permission_modes=frozenset({"read-only", "ask", "edit", "full"}),
|
||||
)
|
||||
|
||||
def validate_environment(self, ctx: SessionContext) -> None:
|
||||
self.get_options(ctx)
|
||||
if not ctx.model:
|
||||
raise ValueError("Harness.DEEPAGENTS needs model=")
|
||||
0
litellm/llms/opencode/__init__.py
Normal file
0
litellm/llms/opencode/__init__.py
Normal file
0
litellm/llms/opencode/harness/__init__.py
Normal file
0
litellm/llms/opencode/harness/__init__.py
Normal file
427
litellm/llms/opencode/harness/transformation.py
Normal file
427
litellm/llms/opencode/harness/transformation.py
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
"""
|
||||
OpenCode harness config: `opencode run --format json`, once per turn.
|
||||
|
||||
Every model call goes to one custom provider (`litellm`, `@ai-sdk/openai-compatible`,
|
||||
bundled in the binary) whose baseURL is the per-session endpoint. The config travels in
|
||||
OPENCODE_CONFIG_CONTENT, which opencode applies after global and project config, so a
|
||||
repo's own opencode.json cannot redirect model calls. The token is never in argv or env:
|
||||
the config references it with `{file:<private_dir>/token}`. XDG dirs point at a persisted
|
||||
LiteLLM-owned root so the user's opencode config and auth are never read, and the session
|
||||
DB outlives a session for resume. Verified against opencode 1.14.41.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from litellm.harness.errors import CapabilityUnsupported, HarnessError, OptionsMismatch
|
||||
from litellm.harness.options import OpenCodeOptions
|
||||
from litellm.harness.types import (
|
||||
Capabilities,
|
||||
Event,
|
||||
Harness,
|
||||
PermissionMode,
|
||||
Reasoning,
|
||||
Text,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from litellm.llms.base_llm.harness.transformation import (
|
||||
BaseCLIHarnessConfig,
|
||||
HarnessSessionSetup,
|
||||
HarnessTurnError,
|
||||
HarnessTurnRequest,
|
||||
HarnessTurnResponse,
|
||||
event_list,
|
||||
)
|
||||
from litellm.llms.base_llm.harness.utils import (
|
||||
last_json_object,
|
||||
native_tool_names,
|
||||
normalize_tool_name,
|
||||
stderr_tail_text,
|
||||
structured_output_instruction,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.harness.context import SessionContext
|
||||
|
||||
OPENCODE_BINARY: Final = "opencode"
|
||||
OPENCODE_PROVIDER_ID: Final = "litellm"
|
||||
OPENCODE_PROVIDER_NPM: Final = "@ai-sdk/openai-compatible"
|
||||
# A fixed title skips opencode's extra title-generation model call on the first turn.
|
||||
OPENCODE_SESSION_TITLE: Final = "litellm-harness"
|
||||
TOKEN_FILENAME: Final = "token"
|
||||
INSTRUCTIONS_FILENAME: Final = "instructions.md"
|
||||
XDG_DIRNAME: Final = "xdg"
|
||||
XDG_SUBDIRS: Final = ("config", "data", "state", "cache")
|
||||
|
||||
# Env that keeps opencode off the network (except the endpoint) and away from ~/.claude.
|
||||
OPENCODE_ISOLATION_ENV: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"OPENCODE_DISABLE_AUTOUPDATE": "1",
|
||||
"OPENCODE_DISABLE_MODELS_FETCH": "1",
|
||||
"OPENCODE_DISABLE_LSP_DOWNLOAD": "1",
|
||||
"OPENCODE_DISABLE_SHARE": "1",
|
||||
"OPENCODE_DISABLE_DEFAULT_PLUGINS": "1",
|
||||
"OPENCODE_DISABLE_CLAUDE_CODE": "1",
|
||||
"OPENCODE_DISABLE_EXTERNAL_SKILLS": "1",
|
||||
# Blank (falsy to opencode) so an inherited value can't add config, auth or rules.
|
||||
"OPENCODE_CONFIG": "",
|
||||
"OPENCODE_CONFIG_DIR": "",
|
||||
"OPENCODE_PERMISSION": "",
|
||||
"OPENCODE_AUTH_CONTENT": "",
|
||||
}
|
||||
)
|
||||
|
||||
MANAGED_CONFIG_KEYS: Final = frozenset(
|
||||
{
|
||||
"provider",
|
||||
"model",
|
||||
"small_model",
|
||||
"permission",
|
||||
"tools",
|
||||
"enabled_providers",
|
||||
"disabled_providers",
|
||||
# plugins run arbitrary code as the host user; runs always use --pure
|
||||
"plugin",
|
||||
}
|
||||
)
|
||||
AGENT_MANAGED_KEYS: Final = frozenset({"permission", "tools", "model"})
|
||||
|
||||
# Later keys win in opencode, so disable_tools denies go last. `opencode run` auto-rejects
|
||||
# anything left at "ask", so no mode leaves a tool on ask.
|
||||
PERMISSION_RULES: Final[Mapping[str, Mapping[str, str]]] = MappingProxyType(
|
||||
{
|
||||
"read-only": MappingProxyType({"edit": "deny", "bash": "deny", "webfetch": "deny"}),
|
||||
"edit": MappingProxyType({"edit": "allow", "bash": "deny", "webfetch": "allow"}),
|
||||
"full": MappingProxyType({"*": "allow"}),
|
||||
}
|
||||
)
|
||||
|
||||
# opencode gates write, edit and apply_patch with the single `edit` permission.
|
||||
NORMALIZED_TO_NATIVE: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType(
|
||||
{
|
||||
"read": ("read",),
|
||||
"write": ("edit",),
|
||||
"edit": ("edit",),
|
||||
"bash": ("bash",),
|
||||
"glob": ("glob",),
|
||||
"grep": ("grep",),
|
||||
"ls": ("list",),
|
||||
"web_search": ("webfetch", "websearch"),
|
||||
}
|
||||
)
|
||||
|
||||
NATIVE_TO_NORMALIZED: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"read": "read",
|
||||
"write": "write",
|
||||
"edit": "edit",
|
||||
"multiedit": "edit",
|
||||
"patch": "edit",
|
||||
"apply_patch": "edit",
|
||||
"bash": "bash",
|
||||
"glob": "glob",
|
||||
"grep": "grep",
|
||||
"list": "ls",
|
||||
"webfetch": "web_search",
|
||||
"websearch": "web_search",
|
||||
}
|
||||
)
|
||||
|
||||
OPENCODE_BUILTIN_TOOLS: Final = frozenset(
|
||||
{
|
||||
*NATIVE_TO_NORMALIZED,
|
||||
"task",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"skill",
|
||||
"invalid",
|
||||
"question",
|
||||
"lsp",
|
||||
"codesearch",
|
||||
"plan_enter",
|
||||
"plan_exit",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenCodeStreamState:
|
||||
"""What the parser has learned from one `opencode run`."""
|
||||
|
||||
session_id: str | None = None
|
||||
final_text: str = ""
|
||||
error: str | None = None
|
||||
step_texts: Sequence[str] = ()
|
||||
|
||||
|
||||
def _as_dict(value: object) -> Mapping[str, Any]:
|
||||
return value if isinstance(value, dict) else MappingProxyType({})
|
||||
|
||||
|
||||
def _tool_events(part: Mapping[str, Any]) -> Sequence[Event]:
|
||||
native = str(part.get("tool") or "")
|
||||
call_id = str(part.get("callID") or part.get("id") or "")
|
||||
state = _as_dict(part.get("state"))
|
||||
tool_input = state.get("input")
|
||||
call = ToolCall(
|
||||
id=call_id,
|
||||
name=normalize_tool_name(native, NATIVE_TO_NORMALIZED),
|
||||
native_name=native,
|
||||
input=tool_input if isinstance(tool_input, dict) else MappingProxyType({"input": tool_input}),
|
||||
builtin=native in OPENCODE_BUILTIN_TOOLS,
|
||||
)
|
||||
if state.get("status") == "error":
|
||||
message = str(state.get("error") or state.get("output") or "tool failed")
|
||||
return event_list(call, ToolResult(id=call_id, output=message, is_error=True))
|
||||
output = state.get("output")
|
||||
text = output if isinstance(output, str) else json.dumps(output)
|
||||
# The `invalid` pseudo-tool is how opencode reports a call to an unavailable tool.
|
||||
return event_list(call, ToolResult(id=call_id, output=text, is_error=native == "invalid"))
|
||||
|
||||
|
||||
def _error_message(error: object) -> str:
|
||||
if not isinstance(error, dict):
|
||||
return str(error or "opencode reported an error")
|
||||
data = error.get("data")
|
||||
if isinstance(data, dict) and data.get("message"):
|
||||
return str(data["message"])
|
||||
return str(error.get("name") or "opencode reported an error")
|
||||
|
||||
|
||||
def validate_user_config(config: Mapping[str, Any]) -> None:
|
||||
"""Reject OpenCodeOptions.config keys LiteLLM manages (or that bypass permissions)."""
|
||||
for key in config:
|
||||
if key in MANAGED_CONFIG_KEYS:
|
||||
raise OptionsMismatch(
|
||||
f"OpenCodeOptions.config[{key!r}] is managed by LiteLLM; use the matching "
|
||||
"agent() argument (model=, permissions=, disable_tools=) instead"
|
||||
)
|
||||
for section in ("agent", "mode"):
|
||||
entries = config.get(section)
|
||||
if entries is None:
|
||||
continue
|
||||
if not isinstance(entries, Mapping):
|
||||
raise OptionsMismatch(f"OpenCodeOptions.config[{section!r}] must be a mapping")
|
||||
for name, agent in entries.items():
|
||||
managed = AGENT_MANAGED_KEYS & frozenset(agent or ())
|
||||
if managed:
|
||||
raise OptionsMismatch(
|
||||
f"OpenCodeOptions.config[{section!r}][{name!r}] sets {sorted(managed)}, "
|
||||
"which LiteLLM manages; use permissions=/disable_tools=/model= instead"
|
||||
)
|
||||
|
||||
|
||||
def permission_rules(permissions: PermissionMode, disable_tools: Sequence[str]) -> Mapping[str, str]:
|
||||
"""opencode `permission` config for a mode plus denies for disable_tools."""
|
||||
if permissions not in PERMISSION_RULES:
|
||||
raise CapabilityUnsupported(
|
||||
f"Harness.OPENCODE does not support permissions={permissions!r} (supported: {sorted(PERMISSION_RULES)})"
|
||||
)
|
||||
denied: Final = native_tool_names(disable_tools, NORMALIZED_TO_NATIVE)
|
||||
# Denies go last (later keys win in opencode), so drop them from the mode rules first.
|
||||
kept: Final = ((key, value) for key, value in PERMISSION_RULES[permissions].items() if key not in denied)
|
||||
rules: Final = itertools.chain(kept, ((native, "deny") for native in denied))
|
||||
return dict(rules) # mutable-ok: opencode config JSON
|
||||
|
||||
|
||||
def build_opencode_config(
|
||||
*,
|
||||
model: str,
|
||||
base_url: str,
|
||||
token_path: str,
|
||||
permissions: PermissionMode,
|
||||
disable_tools: Sequence[str] = (),
|
||||
user_config: Mapping[str, Any] | None = None,
|
||||
instructions_path: str | None = None,
|
||||
skills_path: str | None = None,
|
||||
) -> Mapping[str, Any]:
|
||||
"""The full opencode config: user config underneath, LiteLLM-managed keys on top."""
|
||||
user: Final = user_config or MappingProxyType({})
|
||||
validate_user_config(user)
|
||||
qualified = f"{OPENCODE_PROVIDER_ID}/{model}"
|
||||
extra_instructions: Final = (instructions_path,) if instructions_path else ()
|
||||
instructions: Final = [*(user.get("instructions") or ()), *extra_instructions] # mutable-ok: opencode config JSON
|
||||
user_skills = _as_dict(user.get("skills"))
|
||||
extra_skills: Final = (skills_path,) if skills_path else ()
|
||||
skill_paths: Final = [*(user_skills.get("paths") or ()), *extra_skills] # mutable-ok: opencode config JSON
|
||||
options: Final = {"baseURL": base_url, "apiKey": "{file:" + token_path + "}"} # mutable-ok: opencode config JSON
|
||||
models: Final[dict[str, Any]] = {model: {}} # mutable-ok: opencode config JSON
|
||||
provider: Final = { # mutable-ok: opencode config JSON
|
||||
"npm": OPENCODE_PROVIDER_NPM,
|
||||
"name": "LiteLLM",
|
||||
"options": options,
|
||||
"models": models,
|
||||
}
|
||||
managed: Final = { # mutable-ok: opencode config JSON
|
||||
"provider": {OPENCODE_PROVIDER_ID: provider}, # mutable-ok: opencode config JSON
|
||||
"enabled_providers": [OPENCODE_PROVIDER_ID], # mutable-ok: opencode config JSON
|
||||
"model": qualified,
|
||||
"small_model": qualified,
|
||||
"permission": permission_rules(permissions, disable_tools),
|
||||
"autoupdate": False,
|
||||
"share": "disabled",
|
||||
}
|
||||
skills: Final = {**user_skills, "paths": skill_paths} # mutable-ok: opencode config JSON
|
||||
optional: Final = (("instructions", instructions), ("skills", skills if skill_paths else None))
|
||||
present: Final = ((key, value) for key, value in optional if value)
|
||||
return {**user, **managed, **dict(present)} # mutable-ok: opencode config JSON
|
||||
|
||||
|
||||
def build_instructions(ctx: SessionContext) -> str | None:
|
||||
schema_part: Final = (
|
||||
structured_output_instruction(ctx.output.model_json_schema()) if ctx.output is not None else None
|
||||
)
|
||||
sections: Final = tuple(section for section in (ctx.instructions, schema_part) if section)
|
||||
return "\n\n".join(sections) if sections else None
|
||||
|
||||
|
||||
def turn_prompt(ctx: SessionContext, prompt: str) -> str:
|
||||
"""Repeat the schema instruction in the user turn; system instructions alone are too weak."""
|
||||
if ctx.output is None:
|
||||
return prompt
|
||||
return f"{prompt}\n\n{structured_output_instruction(ctx.output.model_json_schema())}"
|
||||
|
||||
|
||||
class OpenCodeHarnessConfig(BaseCLIHarnessConfig):
|
||||
harness = Harness.OPENCODE
|
||||
options_type = OpenCodeOptions
|
||||
capabilities = Capabilities(
|
||||
structured_output=True,
|
||||
tool_approval=False,
|
||||
tool_filtering=True,
|
||||
history=False,
|
||||
custom_tools=False,
|
||||
skills=True,
|
||||
resume=True,
|
||||
permission_modes=frozenset({"read-only", "edit", "full"}),
|
||||
)
|
||||
|
||||
def get_binary(self) -> str:
|
||||
return OPENCODE_BINARY
|
||||
|
||||
def get_install_hint(self) -> str:
|
||||
return "npm install -g opencode-ai (or brew install sst/tap/opencode)"
|
||||
|
||||
def validate_environment(self, ctx: SessionContext) -> None:
|
||||
options: OpenCodeOptions = self.get_options(ctx)
|
||||
validate_user_config(options.config)
|
||||
|
||||
def transform_session_setup(self, ctx: SessionContext, private_dir: str) -> HarnessSessionSetup:
|
||||
if ctx.endpoint is None:
|
||||
raise HarnessError("OpenCode needs the session model endpoint")
|
||||
model = ctx.model or ctx.endpoint.model
|
||||
if not model:
|
||||
raise ValueError("Harness.OPENCODE needs model= (a gateway model group or litellm model)")
|
||||
options: OpenCodeOptions = self.get_options(ctx)
|
||||
instructions = build_instructions(ctx)
|
||||
token: Final = ctx.endpoint.token.encode("utf-8")
|
||||
files: Final = (
|
||||
MappingProxyType({TOKEN_FILENAME: token, INSTRUCTIONS_FILENAME: instructions.encode("utf-8")})
|
||||
if instructions is not None
|
||||
else MappingProxyType({TOKEN_FILENAME: token})
|
||||
)
|
||||
config = build_opencode_config(
|
||||
model=model,
|
||||
base_url=ctx.sandbox.host_url(ctx.endpoint.port).rstrip("/") + "/v1",
|
||||
token_path=f"{private_dir}/{TOKEN_FILENAME}",
|
||||
permissions=ctx.permissions,
|
||||
disable_tools=ctx.disable_tools,
|
||||
user_config=options.config,
|
||||
instructions_path=f"{private_dir}/{INSTRUCTIONS_FILENAME}" if instructions is not None else None,
|
||||
skills_path=f"{private_dir}/skills" if ctx.skills else None,
|
||||
)
|
||||
xdg: Final = MappingProxyType(
|
||||
{f"XDG_{sub.upper()}_HOME": f"{private_dir}/{XDG_DIRNAME}/{sub}" for sub in XDG_SUBDIRS}
|
||||
)
|
||||
return HarnessSessionSetup(
|
||||
files=files,
|
||||
persisted_dirs=((XDG_DIRNAME, "opencode"),),
|
||||
skills_dir="skills",
|
||||
env=MappingProxyType(
|
||||
{**OPENCODE_ISOLATION_ENV, **options.env, **xdg, "OPENCODE_CONFIG_CONTENT": json.dumps(config)}
|
||||
),
|
||||
)
|
||||
|
||||
def transform_turn_request(
|
||||
self,
|
||||
ctx: SessionContext,
|
||||
setup: HarnessSessionSetup,
|
||||
private_dir: str,
|
||||
prompt: str,
|
||||
native_session_id: str | None,
|
||||
) -> HarnessTurnRequest:
|
||||
options: OpenCodeOptions = self.get_options(ctx)
|
||||
model = ctx.model or (ctx.endpoint.model if ctx.endpoint else None)
|
||||
# --pure: never load plugins. A repo's .opencode/plugin/*.js would otherwise run as the
|
||||
# host user at startup, before any tool permission applies.
|
||||
argv: Final = (
|
||||
OPENCODE_BINARY,
|
||||
"run",
|
||||
"--pure",
|
||||
"--format",
|
||||
"json",
|
||||
"--thinking",
|
||||
"-m",
|
||||
f"{OPENCODE_PROVIDER_ID}/{model}",
|
||||
*(("--agent", options.agent) if options.agent else ()),
|
||||
*(("--session", native_session_id) if native_session_id else ("--title", OPENCODE_SESSION_TITLE)),
|
||||
)
|
||||
# The prompt goes on stdin; opencode appends non-TTY stdin to the message.
|
||||
return HarnessTurnRequest(argv=argv, env=setup.env, stdin=turn_prompt(ctx, prompt), cwd=ctx.sandbox.workdir)
|
||||
|
||||
def create_stream_state(self) -> OpenCodeStreamState:
|
||||
return OpenCodeStreamState()
|
||||
|
||||
def transform_stream_line(self, line: Mapping[str, Any], state: OpenCodeStreamState) -> Sequence[Event]:
|
||||
"""step_finish token counts are ignored on purpose: the session endpoint accounts usage."""
|
||||
session_id = line.get("sessionID")
|
||||
if session_id and state.session_id is None:
|
||||
state.session_id = str(session_id)
|
||||
event_type = line.get("type")
|
||||
part = _as_dict(line.get("part"))
|
||||
if event_type == "step_start":
|
||||
state.step_texts = ()
|
||||
return event_list()
|
||||
if event_type == "text":
|
||||
text = str(part.get("text") or "")
|
||||
if not text:
|
||||
return event_list()
|
||||
state.step_texts = (*state.step_texts, text)
|
||||
state.final_text = "\n\n".join(state.step_texts)
|
||||
return event_list(Text(delta=text))
|
||||
if event_type == "reasoning":
|
||||
text = str(part.get("text") or "")
|
||||
return event_list(Reasoning(delta=text)) if text else event_list()
|
||||
if event_type == "tool_use":
|
||||
return _tool_events(part)
|
||||
if event_type == "error":
|
||||
message = _error_message(line.get("error"))
|
||||
state.error = f"{state.error}\n{message}" if state.error else message
|
||||
return event_list()
|
||||
|
||||
def get_native_session_id(self, state: OpenCodeStreamState) -> str | None:
|
||||
return state.session_id
|
||||
|
||||
def transform_turn_response(
|
||||
self,
|
||||
ctx: SessionContext,
|
||||
state: OpenCodeStreamState,
|
||||
exit_code: int,
|
||||
stderr_tail: Sequence[str],
|
||||
) -> HarnessTurnResponse:
|
||||
# opencode exits 0 after an `error` event, so check state first.
|
||||
if state.error:
|
||||
raise HarnessTurnError(f"opencode turn failed: {state.error}")
|
||||
if exit_code != 0:
|
||||
raise HarnessTurnError(
|
||||
f"opencode exited with code {exit_code}: {stderr_tail_text(stderr_tail) or 'no output'}"
|
||||
)
|
||||
output_json = last_json_object(state.final_text) if ctx.output is not None else None
|
||||
return HarnessTurnResponse(final_text=state.final_text, output_json=output_json)
|
||||
|
|
@ -1947,10 +1947,41 @@ class MCPRequestHandler:
|
|||
scope, the tool union and billing attribution are all just different reads of this one
|
||||
answer — computing it separately per consumer is how they drift (a throttle map scoped by
|
||||
roster instead of by grant charged unrelated teams' buckets)."""
|
||||
return [
|
||||
grants: Final = [
|
||||
(source, set(await MCPRequestHandler.get_allowed_mcp_servers(source, keyless_source=True)))
|
||||
for source in await MCPRequestHandler.admitted_subject_sources(auth, allowed_team_ids=allowed_team_ids)
|
||||
]
|
||||
scope: Final = await MCPRequestHandler._toolset_scope(auth)
|
||||
if scope is None:
|
||||
return grants
|
||||
return [(source, granted & frozenset(scope)) for source, granted in grants]
|
||||
|
||||
@staticmethod
|
||||
async def _toolset_scope(auth: UserAPIKeyAuth) -> dict[str, list[str]] | None:
|
||||
"""The ``server_id -> tools`` a namespaced toolset route pinned this subject to via
|
||||
``mcp_toolset_id``, or None on the aggregate scope. Every source's servers and tools are
|
||||
intersected with it, so the route narrows a team grant exactly as it narrows the user's own."""
|
||||
if auth.mcp_toolset_id is None:
|
||||
return None
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
return await global_mcp_server_manager.resolve_toolset_tool_permissions(
|
||||
toolset_ids=[auth.mcp_toolset_id], requires_fresh_policy=auth.requires_fresh_policy
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _narrow_tools_to_toolset(
|
||||
tools: list[str] | None,
|
||||
server_id: str,
|
||||
auth: UserAPIKeyAuth,
|
||||
) -> list[str] | None:
|
||||
scope: Final = await MCPRequestHandler._toolset_scope(auth)
|
||||
if scope is None:
|
||||
return tools
|
||||
scoped: Final = frozenset(scope.get(server_id, ()))
|
||||
return sorted(scoped if tools is None else scoped & frozenset(tools))
|
||||
|
||||
@staticmethod
|
||||
async def resolve_admitted_subject_servers(
|
||||
|
|
@ -2048,9 +2079,9 @@ class MCPRequestHandler:
|
|||
continue
|
||||
tools = await MCPRequestHandler.get_allowed_tools_for_server(server_id, source, keyless_source=True)
|
||||
if tools is None:
|
||||
return None
|
||||
return await MCPRequestHandler._narrow_tools_to_toolset(None, server_id, auth)
|
||||
allowed.update(tools)
|
||||
return sorted(allowed)
|
||||
return await MCPRequestHandler._narrow_tools_to_toolset(sorted(allowed), server_id, auth)
|
||||
|
||||
@staticmethod
|
||||
def _get_key_object_permission(
|
||||
|
|
@ -2067,6 +2098,16 @@ class MCPRequestHandler:
|
|||
|
||||
return user_api_key_auth.object_permission
|
||||
|
||||
@staticmethod
|
||||
async def team_object_permission(user_api_key_auth: UserAPIKeyAuth) -> LiteLLM_ObjectPermissionTable | None:
|
||||
return await MCPRequestHandler._get_team_object_permission(user_api_key_auth)
|
||||
|
||||
@staticmethod
|
||||
async def key_object_permission_hydrated(
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
) -> LiteLLM_ObjectPermissionTable | None:
|
||||
return await MCPRequestHandler._key_object_permission_hydrated(user_api_key_auth)
|
||||
|
||||
@staticmethod
|
||||
async def _get_team_object_permission(
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
|
|
|
|||
|
|
@ -915,11 +915,9 @@ if MCP_AVAILABLE:
|
|||
) -> UserAPIKeyAuth:
|
||||
"""The one credential this tools request acts as.
|
||||
|
||||
A toolset name narrows the caller's own credential to that toolset; otherwise a dashboard
|
||||
session is swapped for its admitted subject. The two are mutually exclusive by construction,
|
||||
which is why they share an owner: the admitted subject resolves per grant source and a team
|
||||
source deliberately carries none of the caller's ``object_permission``, so a toolset
|
||||
narrowing layered on top would evaporate on every team-granted server."""
|
||||
A toolset name pins the acting principal to that toolset through ``_apply_toolset_scope``,
|
||||
which itself swaps a dashboard session for its admitted subject; otherwise the swap happens
|
||||
here so both shapes resolve as the same identity."""
|
||||
if not toolset_name:
|
||||
return await acting_user_auth(user_api_key_dict)
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,13 @@ from litellm.proxy._experimental.mcp_server.oauth_utils import (
|
|||
get_route_relative_request_path,
|
||||
well_known_root_suffix,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.ui_session_utils import is_ui_session_credential
|
||||
from litellm.proxy._experimental.mcp_server.ui_session_utils import (
|
||||
ActingUser,
|
||||
GrantedToolsetIds,
|
||||
acting_user_auth,
|
||||
granted_toolset_ids,
|
||||
is_ui_session_credential,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.utils import (
|
||||
LITELLM_MCP_SERVER_DESCRIPTION,
|
||||
LITELLM_MCP_SERVER_NAME,
|
||||
|
|
@ -1515,16 +1521,21 @@ if MCP_AVAILABLE:
|
|||
async def _apply_toolset_scope(
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
toolset_id: str,
|
||||
acting_user: ActingUser = acting_user_auth,
|
||||
granted: GrantedToolsetIds = granted_toolset_ids,
|
||||
) -> UserAPIKeyAuth:
|
||||
"""
|
||||
Restrict a key's MCP permissions to a single toolset.
|
||||
Pin a principal's MCP permissions to a single toolset for /toolset/{name}/mcp.
|
||||
|
||||
When a request arrives via /toolset/{name}/mcp we override the key's
|
||||
object_permission so that only the toolset's tools are visible.
|
||||
A virtual key (and an admin session) has its object_permission rewritten to
|
||||
the toolset's servers and tools. A keyless subject resolves per grant source,
|
||||
so a non-admin dashboard session first becomes its admitted user and the
|
||||
toolset rides along as ``mcp_toolset_id``, which every source's grant is
|
||||
intersected with; a team-granted toolset is served without the user's own
|
||||
row capping it.
|
||||
|
||||
Raises HTTPException(403) if the key has an explicit toolset grant list
|
||||
that does not include toolset_id (i.e. mcp_toolsets is set but empty,
|
||||
or set to a list that omits this toolset). Admin keys always pass.
|
||||
Raises HTTPException(403) unless the principal holds toolset_id through one
|
||||
of its grant sources. Admins always pass.
|
||||
"""
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
||||
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
|
||||
|
|
@ -1540,26 +1551,31 @@ if MCP_AVAILABLE:
|
|||
detail="API key is scoped to no MCP servers; toolset access is denied.",
|
||||
)
|
||||
|
||||
# Access control: non-admin keys must have this toolset in their grant list.
|
||||
# Use _user_has_admin_view so that PROXY_ADMIN_VIEW_ONLY is also treated as admin.
|
||||
is_admin: Final = _user_has_admin_view(user_api_key_auth)
|
||||
if not is_admin:
|
||||
op: Final = user_api_key_auth.object_permission
|
||||
granted: Final = getattr(op, "mcp_toolsets", None) if op else None
|
||||
# granted=None → key has no explicit toolset grants → deny (same semantics as
|
||||
# fetch_mcp_toolsets which returns [] for non-admin keys with no grants configured).
|
||||
# granted=[] or list without toolset_id → also deny.
|
||||
if granted is None or toolset_id not in granted:
|
||||
acting: Final = await acting_user(user_api_key_auth)
|
||||
is_admin: Final = _user_has_admin_view(acting)
|
||||
if not is_admin and toolset_id not in await granted(acting):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"API key does not have access to toolset '{toolset_id}'.",
|
||||
)
|
||||
if _is_mcp_admitted_user_subject(acting):
|
||||
resource_server_id: Final = acting.mcp_session_resource_server_id
|
||||
if resource_server_id is not None and resource_server_id not in (
|
||||
await operations.global_mcp_server_manager.resolve_toolset_tool_permissions(
|
||||
toolset_ids=[toolset_id], requires_fresh_policy=acting.requires_fresh_policy
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"API key does not have access to toolset '{toolset_id}'.",
|
||||
)
|
||||
return acting.model_copy(update={"mcp_toolset_id": toolset_id})
|
||||
|
||||
tool_permissions = await operations.global_mcp_server_manager.resolve_toolset_tool_permissions(
|
||||
toolset_ids=[toolset_id]
|
||||
)
|
||||
server_ids: Final = list(tool_permissions.keys())
|
||||
existing_op: Final = user_api_key_auth.object_permission
|
||||
existing_op: Final = acting.object_permission
|
||||
if existing_op is not None:
|
||||
updated_op = existing_op.model_copy(
|
||||
update={
|
||||
|
|
@ -1576,7 +1592,12 @@ if MCP_AVAILABLE:
|
|||
mcp_servers=server_ids,
|
||||
mcp_tool_permissions=tool_permissions,
|
||||
)
|
||||
return user_api_key_auth.model_copy(update={"object_permission": updated_op, "mcp_toolset_id": toolset_id})
|
||||
return acting.model_copy(update={"object_permission": updated_op, "mcp_toolset_id": toolset_id})
|
||||
|
||||
async def _toolset_server_ids(toolset_id: str) -> set[str]:
|
||||
return set(
|
||||
await operations.global_mcp_server_manager.resolve_toolset_tool_permissions(toolset_ids=[toolset_id])
|
||||
)
|
||||
|
||||
async def _raise_preemptive_401_for_unauthenticated_servers(
|
||||
scope: Scope,
|
||||
|
|
@ -2007,8 +2028,7 @@ if MCP_AVAILABLE:
|
|||
toolset_allowed_server_ids: set[str] | None = None
|
||||
if active_toolset_id and user_api_key_auth is not None:
|
||||
user_api_key_auth = await _apply_toolset_scope(user_api_key_auth, active_toolset_id)
|
||||
op: Final = user_api_key_auth.object_permission
|
||||
toolset_allowed_server_ids = set(op.mcp_servers or []) if op else set()
|
||||
toolset_allowed_server_ids = await _toolset_server_ids(active_toolset_id)
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
|
||||
# Must run after toolset scoping so the challenge set is derived
|
||||
|
|
@ -2355,8 +2375,7 @@ if MCP_AVAILABLE:
|
|||
toolset_allowed_server_ids: set[str] | None = None
|
||||
if active_toolset_id and user_api_key_auth is not None:
|
||||
user_api_key_auth = await _apply_toolset_scope(user_api_key_auth, active_toolset_id)
|
||||
op: Final = user_api_key_auth.object_permission
|
||||
toolset_allowed_server_ids = set(op.mcp_servers or []) if op else set()
|
||||
toolset_allowed_server_ids = await _toolset_server_ids(active_toolset_id)
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
|
||||
# Must run after toolset scoping so the challenge set is derived
|
||||
|
|
|
|||
|
|
@ -2,14 +2,23 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Final
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from itertools import chain
|
||||
from typing import Final, TypeAlias
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth
|
||||
|
||||
EffectiveAuthContexts: TypeAlias = Callable[[UserAPIKeyAuth], Awaitable[Sequence[UserAPIKeyAuth]]]
|
||||
TeamObjectPermission: TypeAlias = Callable[[UserAPIKeyAuth], Awaitable[LiteLLM_ObjectPermissionTable | None]]
|
||||
OwnObjectPermission: TypeAlias = Callable[[UserAPIKeyAuth], Awaitable[LiteLLM_ObjectPermissionTable | None]]
|
||||
AdmittedContext: TypeAlias = Callable[[UserAPIKeyAuth], Awaitable[UserAPIKeyAuth | None]]
|
||||
ActingUser: TypeAlias = Callable[[UserAPIKeyAuth], Awaitable[UserAPIKeyAuth]]
|
||||
GrantedToolsetIds: TypeAlias = Callable[[UserAPIKeyAuth], Awaitable[frozenset[str]]]
|
||||
|
||||
|
||||
def clone_user_api_key_auth_with_team(
|
||||
|
|
@ -109,10 +118,10 @@ async def acting_user_auth(user_api_key_auth: UserAPIKeyAuth) -> UserAPIKeyAuth:
|
|||
both surfaces. An admin session keeps its operator view and any caller-passed credential is
|
||||
returned unchanged, never widened.
|
||||
|
||||
Do not combine this with a narrowing that rewrites a single credential's ``object_permission``
|
||||
(toolset scope): the admitted subject resolves per grant source and a team source deliberately
|
||||
carries none of the caller's own grants, so the narrowing would silently evaporate on every
|
||||
team-granted server. A request carrying such a scope keeps the caller's own credential."""
|
||||
A toolset narrowing is never applied to the admitted subject by rewriting its ``object_permission``:
|
||||
it resolves per grant source and a team source deliberately carries none of the caller's own grants,
|
||||
so the rewrite would evaporate on every team-granted server. The route pins ``mcp_toolset_id``
|
||||
instead, which every source's grant is intersected with."""
|
||||
|
||||
if not is_ui_session_credential(user_api_key_auth):
|
||||
return user_api_key_auth
|
||||
|
|
@ -153,3 +162,135 @@ async def can_access_mcp_server(
|
|||
if server_id in await allowed_servers(context):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _restricts_mcp(permission: LiteLLM_ObjectPermissionTable | None) -> bool:
|
||||
return permission is not None and bool(
|
||||
permission.mcp_servers
|
||||
or permission.mcp_toolsets
|
||||
or permission.mcp_tool_permissions
|
||||
or permission.mcp_access_groups
|
||||
)
|
||||
|
||||
|
||||
def is_keyless_mcp_subject(user_api_key_auth: UserAPIKeyAuth) -> bool:
|
||||
"""A principal with no virtual key to declare MCP access on: the dashboard's own session token or a
|
||||
gateway-admitted user. Its grants are resolved per source, never through a key row."""
|
||||
|
||||
return is_ui_session_credential(user_api_key_auth) or user_api_key_auth.mcp_admitted_user_subject is True
|
||||
|
||||
|
||||
async def toolset_grant_contexts(
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
admitted_context: AdmittedContext = admitted_user_context,
|
||||
admitted_sources: EffectiveAuthContexts | None = None,
|
||||
) -> Sequence[UserAPIKeyAuth]:
|
||||
"""The grant sources a toolset is looked up through. A virtual key is its own single source. A keyless
|
||||
subject fans out exactly as the aggregate /mcp resolution does: its own user row plus every team whose
|
||||
live roster still lists it, so a membership that only survives in the user's cached team list grants
|
||||
nothing."""
|
||||
|
||||
if not is_keyless_mcp_subject(user_api_key_auth):
|
||||
return (user_api_key_auth,)
|
||||
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
||||
MCPRequestHandler,
|
||||
)
|
||||
|
||||
load_sources: Final = admitted_sources or MCPRequestHandler.admitted_subject_sources
|
||||
acting: Final = await admitted_context(user_api_key_auth)
|
||||
return tuple(await load_sources(acting if acting is not None else user_api_key_auth))
|
||||
|
||||
|
||||
async def _own_toolset_ids(
|
||||
context: UserAPIKeyAuth,
|
||||
load_own_permission: OwnObjectPermission,
|
||||
) -> Sequence[str] | None:
|
||||
"""The source's own toolsets, or None when it declares no MCP grant of its own. A source that names an
|
||||
``object_permission_id`` is a known restriction even when the row is unhydrated, unreadable or gone,
|
||||
so it is loaded rather than read as unrestricted, and grants nothing when it cannot be read."""
|
||||
if context.object_permission is None and not context.object_permission_id:
|
||||
return None
|
||||
try:
|
||||
own: Final = await load_own_permission(context)
|
||||
except Exception as exc: # noqa: BLE001 # a named but unreadable own grant must deny, not widen to the team
|
||||
verbose_logger.warning(
|
||||
"MCP toolset grants: object permission %s unreadable, granting nothing through it: %s",
|
||||
context.object_permission_id,
|
||||
exc,
|
||||
)
|
||||
return ()
|
||||
if own is None:
|
||||
return ()
|
||||
if not _restricts_mcp(own):
|
||||
return None
|
||||
return own.mcp_toolsets or ()
|
||||
|
||||
|
||||
async def _inherited_toolset_ids(
|
||||
context: UserAPIKeyAuth,
|
||||
load_team_permission: TeamObjectPermission,
|
||||
) -> Sequence[str]:
|
||||
try:
|
||||
team: Final = await load_team_permission(context)
|
||||
except Exception as exc: # noqa: BLE001 # an unreadable team grants nothing through this source and must not fail the caller's other sources
|
||||
verbose_logger.warning(
|
||||
"MCP toolset grants: team %s unreadable, inheriting nothing from it: %s",
|
||||
context.team_id,
|
||||
exc,
|
||||
)
|
||||
return ()
|
||||
return () if team is None else (team.mcp_toolsets or ())
|
||||
|
||||
|
||||
async def _context_toolset_ids(
|
||||
context: UserAPIKeyAuth,
|
||||
inherits_team: bool,
|
||||
load_team_permission: TeamObjectPermission,
|
||||
load_own_permission: OwnObjectPermission,
|
||||
) -> Sequence[str]:
|
||||
own: Final = await _own_toolset_ids(context, load_own_permission)
|
||||
if own is not None:
|
||||
return own
|
||||
if not inherits_team or not context.team_id:
|
||||
return ()
|
||||
return await _inherited_toolset_ids(context, load_team_permission)
|
||||
|
||||
|
||||
async def granted_toolset_ids(
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
effective_contexts: EffectiveAuthContexts = toolset_grant_contexts,
|
||||
team_object_permission: TeamObjectPermission | None = None,
|
||||
require_key_access: bool | None = None,
|
||||
own_object_permission: OwnObjectPermission | None = None,
|
||||
) -> frozenset[str]:
|
||||
"""Toolset ids the principal holds, resolved per grant source with the key/team rule the aggregate
|
||||
/mcp listing applies: a source that declares any MCP grant of its own is scoped to its own toolsets and
|
||||
never reads its team, one that declares none inherits its team's, except a virtual key under
|
||||
``require_key_mcp_access_defined``, which inherits nothing. A keyless subject's team sources always
|
||||
inherit. A team that cannot be read contributes nothing while every other source still counts, and an
|
||||
own grant that is named but cannot be read grants nothing. No grant anywhere yields the empty set."""
|
||||
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
||||
MCPRequestHandler,
|
||||
)
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
require: Final = (
|
||||
require_key_access
|
||||
if require_key_access is not None
|
||||
else bool(
|
||||
general_settings.get( # pyright: ignore[reportUnknownArgumentType] # general_settings is an untyped dict; truthiness must match the /mcp path's read of this flag
|
||||
"require_key_mcp_access_defined", False
|
||||
)
|
||||
)
|
||||
)
|
||||
inherits_team: Final = is_keyless_mcp_subject(user_api_key_auth) or not require
|
||||
load_team_permission: Final = team_object_permission or MCPRequestHandler.team_object_permission
|
||||
load_own_permission: Final = own_object_permission or MCPRequestHandler.key_object_permission_hydrated
|
||||
contexts: Final = await effective_contexts(user_api_key_auth)
|
||||
per_context: Final = await asyncio.gather(
|
||||
*(
|
||||
_context_toolset_ids(context, inherits_team, load_team_permission, load_own_permission)
|
||||
for context in contexts
|
||||
)
|
||||
)
|
||||
return frozenset(chain.from_iterable(per_context))
|
||||
|
|
|
|||
|
|
@ -3,19 +3,24 @@ Lazy registration for optional feature routers. Each LAZY_FEATURES entry
|
|||
imports its module only on the first request matching its path prefix,
|
||||
saving ~700 MB at idle for deployments that don't use these features.
|
||||
First hit pays the import cost (1-3 s for heavy modules); /openapi.json
|
||||
omits each feature's routes until the feature is warmed.
|
||||
omits each feature's routes until the feature is warmed. Setting
|
||||
LITELLM_DISABLE_LAZY_ROUTES registers every feature at worker startup
|
||||
instead, so the route table is complete before the first request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
import os
|
||||
from collections.abc import AsyncGenerator, Callable, Mapping, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from starlette.routing import BaseRoute, Match
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
from starlette.types import ASGIApp, Lifespan, Receive, Scope, Send
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy.route_priority import hot_routes_first
|
||||
|
|
@ -428,57 +433,127 @@ def _in_registry_order(
|
|||
async def _force_load(app: "FastAPI", feat: LazyFeature, features: tuple[LazyFeature, ...] = LAZY_FEATURES) -> bool:
|
||||
"""Import + register a lazy feature exactly once per (app, module).
|
||||
Shared by the middleware and the /lazy/warm endpoint."""
|
||||
async with _lazy_lock(app, feat.module_path):
|
||||
if feat.module_path in _lazy_loaded(app):
|
||||
return False
|
||||
# Import on a thread (heavy modules take 1-3 s). register_fn
|
||||
# mutates app.router.routes, so it stays on the loop thread.
|
||||
imported: Final = asyncio.get_running_loop().run_in_executor(None, importlib.import_module, feat.module_path)
|
||||
await asyncio.wait((imported,))
|
||||
return _install(app, feat, imported.result, features)
|
||||
|
||||
|
||||
def _install(
|
||||
app: "FastAPI", feat: LazyFeature, import_module: Callable[[], object], features: tuple[LazyFeature, ...]
|
||||
) -> bool:
|
||||
try:
|
||||
_register_feature(app, feat, import_module(), features)
|
||||
return True
|
||||
except Exception as exc:
|
||||
_mark_failed(app, feat, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _lazy_loaded(app: "FastAPI") -> set[str]:
|
||||
if not hasattr(app.state, "lazy_loaded"):
|
||||
app.state.lazy_loaded = set()
|
||||
app.state.lazy_locks = {}
|
||||
lock: Final = app.state.lazy_locks.setdefault(feat.module_path, asyncio.Lock())
|
||||
async with lock:
|
||||
if feat.module_path in app.state.lazy_loaded:
|
||||
return False
|
||||
try:
|
||||
# Import on a thread (heavy modules take 1-3 s). register_fn
|
||||
# mutates app.router.routes, so it stays on the loop thread.
|
||||
loop: Final = asyncio.get_running_loop()
|
||||
module: Final = await loop.run_in_executor(None, importlib.import_module, feat.module_path)
|
||||
before: Final = len(app.router.routes)
|
||||
feat.register_fn(app, module)
|
||||
previous: Final[Mapping[str, tuple[BaseRoute, ...]]] = (
|
||||
app.state.lazy_routes if hasattr(app.state, "lazy_routes") else MappingProxyType({})
|
||||
)
|
||||
lazy_routes: Final[Mapping[str, tuple[BaseRoute, ...]]] = MappingProxyType(
|
||||
{**previous, feat.module_path: tuple(app.router.routes[before:])}
|
||||
)
|
||||
app.state.lazy_routes = lazy_routes # rebind-ok: the app owns the record of which routes each feature added
|
||||
app.router.routes[:] = hot_routes_first( # rebind-ok: the app owns its route table
|
||||
_in_registry_order(app.router.routes, lazy_routes, features, _lazy_slots(app))
|
||||
)
|
||||
app.state.lazy_loaded.add(feat.module_path)
|
||||
app.openapi_schema = None
|
||||
verbose_proxy_logger.info(
|
||||
"Lazy-loaded optional feature %r (module: %s)",
|
||||
feat.name,
|
||||
feat.module_path,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
# Mark loaded anyway so we don't retry on every request.
|
||||
app.state.lazy_loaded.add(feat.module_path)
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to lazy-load optional feature %r (module: %s): %s. "
|
||||
"This feature's endpoints will return 404 until restart.",
|
||||
feat.name,
|
||||
feat.module_path,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
app.state.lazy_loaded = set[str]()
|
||||
app.state.lazy_locks = dict[str, asyncio.Lock]()
|
||||
loaded: Final[set[str]] = app.state.lazy_loaded
|
||||
return loaded
|
||||
|
||||
|
||||
def attach_lazy_features(app: "FastAPI") -> None:
|
||||
app.include_router(_make_warmup_router(app))
|
||||
app.add_middleware(LazyFeatureMiddleware, fastapi_app=app)
|
||||
def _lazy_lock(app: "FastAPI", module_path: str) -> asyncio.Lock:
|
||||
if not hasattr(app.state, "lazy_locks"):
|
||||
app.state.lazy_locks = dict[str, asyncio.Lock]()
|
||||
locks: Final[dict[str, asyncio.Lock]] = app.state.lazy_locks
|
||||
return locks.setdefault(module_path, asyncio.Lock())
|
||||
|
||||
|
||||
def _make_warmup_router(app: "FastAPI") -> "APIRouter":
|
||||
def _register_feature(app: "FastAPI", feat: LazyFeature, module: object, features: tuple[LazyFeature, ...]) -> None:
|
||||
before: Final = len(app.router.routes)
|
||||
feat.register_fn(app, module)
|
||||
previous: Final[Mapping[str, tuple[BaseRoute, ...]]] = (
|
||||
app.state.lazy_routes if hasattr(app.state, "lazy_routes") else MappingProxyType({})
|
||||
)
|
||||
lazy_routes: Final[Mapping[str, tuple[BaseRoute, ...]]] = MappingProxyType(
|
||||
{**previous, feat.module_path: tuple(app.router.routes[before:])}
|
||||
)
|
||||
app.state.lazy_routes = lazy_routes # rebind-ok: the app owns the record of which routes each feature added
|
||||
app.router.routes[:] = hot_routes_first( # rebind-ok: the app owns its route table
|
||||
_in_registry_order(app.router.routes, lazy_routes, features, _lazy_slots(app))
|
||||
)
|
||||
_lazy_loaded(app).add(feat.module_path)
|
||||
app.openapi_schema = None
|
||||
verbose_proxy_logger.info(
|
||||
"Lazy-loaded optional feature %r (module: %s)",
|
||||
feat.name,
|
||||
feat.module_path,
|
||||
)
|
||||
|
||||
|
||||
def _mark_failed(app: "FastAPI", feat: LazyFeature, exc: Exception) -> None:
|
||||
# Mark loaded anyway so we don't retry on every request.
|
||||
_lazy_loaded(app).add(feat.module_path)
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to lazy-load optional feature %r (module: %s): %s. "
|
||||
"This feature's endpoints will return 404 until restart.",
|
||||
feat.name,
|
||||
feat.module_path,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def lazy_routes_disabled() -> bool:
|
||||
return os.getenv("LITELLM_DISABLE_LAZY_ROUTES", "").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def register_all_features(app: "FastAPI", features: tuple[LazyFeature, ...] = LAZY_FEATURES) -> None:
|
||||
"""Register every feature router now, in registry order, so app.routes is
|
||||
complete before the app serves its first request."""
|
||||
for feat in features:
|
||||
_install(app, feat, partial(importlib.import_module, feat.module_path), features)
|
||||
|
||||
|
||||
def attach_lazy_features(app: "FastAPI", features: tuple[LazyFeature, ...] = LAZY_FEATURES) -> None:
|
||||
if lazy_routes_disabled():
|
||||
app.router.lifespan_context = _register_all_on_startup(app.router.lifespan_context, features)
|
||||
return
|
||||
app.include_router(_make_warmup_router(app, features))
|
||||
app.add_middleware(LazyFeatureMiddleware, fastapi_app=app, features=features)
|
||||
|
||||
|
||||
def _register_all_on_startup(inner: "Lifespan[FastAPI]", features: tuple[LazyFeature, ...]) -> "Lifespan[FastAPI]":
|
||||
"""Registering at startup, once every route the app defines exists, lands the features
|
||||
where lazy mode splices them: after every eager route (so /mcp/proxy, defined after
|
||||
attach_lazy_features(), still beats the /mcp mount) and before LITELLM_WORKER_STARTUP_HOOKS
|
||||
or an outer lifespan can filter the table. The inner lifespan then adds routes of its own
|
||||
(config pass-through endpoints), so the table is put back in lazy mode's order once it is up."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: "FastAPI") -> AsyncGenerator[None]:
|
||||
register_all_features(app, features)
|
||||
async with inner(app):
|
||||
_restore_registry_order(app, features)
|
||||
yield
|
||||
|
||||
return lifespan
|
||||
|
||||
|
||||
def _restore_registry_order(app: "FastAPI", features: tuple[LazyFeature, ...]) -> None:
|
||||
present: Final = frozenset(id(route) for route in app.router.routes)
|
||||
registered: Final[Mapping[str, tuple[BaseRoute, ...]]] = (
|
||||
app.state.lazy_routes if hasattr(app.state, "lazy_routes") else MappingProxyType({})
|
||||
)
|
||||
still_routed: Final = MappingProxyType(
|
||||
{module_path: tuple(r for r in routes if id(r) in present) for module_path, routes in registered.items()}
|
||||
)
|
||||
app.router.routes[:] = hot_routes_first( # rebind-ok: the app owns its route table
|
||||
_in_registry_order(app.router.routes, still_routed, features, _lazy_slots(app))
|
||||
)
|
||||
app.openapi_schema = None
|
||||
|
||||
|
||||
def _make_warmup_router(app: "FastAPI", features: tuple[LazyFeature, ...] = LAZY_FEATURES) -> "APIRouter":
|
||||
"""POST /lazy/warm/{name}: load a feature and return its partial openapi
|
||||
so the Swagger plugin can merge in-place without a full /openapi.json refetch.
|
||||
Requires auth — anyone who can hit the proxy can already trigger the same
|
||||
|
|
@ -497,13 +572,13 @@ def _make_warmup_router(app: "FastAPI") -> "APIRouter":
|
|||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def warm(name: str):
|
||||
feat: Final = next((f for f in LAZY_FEATURES if f.name == name), None)
|
||||
feat: Final = next((f for f in features if f.name == name), None)
|
||||
if feat is None:
|
||||
raise HTTPException(404, f"unknown lazy feature: {name}")
|
||||
if feat.persistent_swagger_stub:
|
||||
return {"stub_path": None, "paths": {}, "components": {"schemas": {}}}
|
||||
|
||||
await _force_load(app, feat)
|
||||
await _force_load(app, feat, features)
|
||||
|
||||
feat_routes: Final = [r for r in app.routes if feat.matches(getattr(r, "path", ""))]
|
||||
full: Final = get_openapi(title=app.title, version=app.version, routes=feat_routes)
|
||||
|
|
@ -524,7 +599,7 @@ def _make_warmup_router(app: "FastAPI") -> "APIRouter":
|
|||
|
||||
def loaded_lazy_modules(app: "FastAPI") -> frozenset[str]:
|
||||
"""The set of lazy feature modules whose routers are actually registered
|
||||
on this app (tracked by _force_load), empty before the middleware ever ran.
|
||||
on this app (tracked by _install), empty until a feature loads or eager startup runs.
|
||||
sys.modules is the wrong signal: boot code imports several feature modules
|
||||
(mcp_management, cloudzero, vantage, config_overrides) without mounting
|
||||
their routers, and their stubs must still be injected."""
|
||||
|
|
@ -583,6 +658,6 @@ def lazy_tag_to_prefix() -> dict[str, str]:
|
|||
because /openapi.json already has full route info."""
|
||||
from litellm.proxy._lazy_openapi_snapshot import load_snapshot
|
||||
|
||||
if load_snapshot():
|
||||
if lazy_routes_disabled() or load_snapshot():
|
||||
return {}
|
||||
return {feat.name: feat.path_prefixes[0] for feat in LAZY_FEATURES if not feat.persistent_swagger_stub}
|
||||
|
|
|
|||
|
|
@ -984,6 +984,30 @@ class PrismaManager:
|
|||
os.chdir(original_dir)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def build_request_log_indexes() -> bool:
|
||||
"""Build the request-log indexes the migrations leave out and wait for them, for the
|
||||
migration job (`--skip_server_startup`) after `setup_database` succeeds. False when
|
||||
an index could not be built, so the job exits non-zero and is rerun."""
|
||||
try:
|
||||
from litellm_proxy_extras.utils import ProxyExtrasDBManager
|
||||
except ImportError as e:
|
||||
verbose_proxy_logger.error("\x1b[1;31mLiteLLM: Failed to import proxy extras. Got %s\x1b[0m", e)
|
||||
return False
|
||||
return ProxyExtrasDBManager.build_request_log_indexes()
|
||||
|
||||
@staticmethod
|
||||
def start_request_log_index_build() -> None:
|
||||
"""Build the request-log indexes on a daemon thread, for a serving proxy that ran the
|
||||
migrations itself (`DISABLE_SCHEMA_UPDATE` unset), so a long build never delays
|
||||
readiness. A build that could not finish is logged and retried on the next boot."""
|
||||
try:
|
||||
from litellm_proxy_extras.utils import ProxyExtrasDBManager
|
||||
except ImportError as e:
|
||||
verbose_proxy_logger.error("\x1b[1;31mLiteLLM: Failed to import proxy extras. Got %s\x1b[0m", e)
|
||||
return
|
||||
ProxyExtrasDBManager.start_request_log_index_build()
|
||||
|
||||
|
||||
def should_update_prisma_schema(
|
||||
disable_updates: bool | str | None = None,
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ if MCP_AVAILABLE:
|
|||
from litellm.proxy._experimental.mcp_server.ui_session_utils import (
|
||||
admitted_user_context,
|
||||
build_effective_auth_contexts,
|
||||
granted_toolset_ids,
|
||||
is_ui_session_credential,
|
||||
)
|
||||
from litellm.proxy._types import (
|
||||
|
|
@ -3332,18 +3333,15 @@ if MCP_AVAILABLE:
|
|||
):
|
||||
"""Return toolsets the calling key is allowed to access."""
|
||||
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
|
||||
is_admin: Final = _user_has_admin_view(user_api_key_dict)
|
||||
op: Final = user_api_key_dict.object_permission
|
||||
# mcp_toolsets=None or [] both mean "not restricted by toolsets".
|
||||
# For admins: either value → no restriction → return all.
|
||||
# For non-admins: either value → no toolsets explicitly granted → return nothing.
|
||||
# (An admin whose DB row has mcp_toolsets=[] should still see all toolsets.)
|
||||
raw_toolsets: Final = getattr(op, "mcp_toolsets", None) if op else None
|
||||
if not raw_toolsets:
|
||||
if is_admin:
|
||||
if _user_has_admin_view(user_api_key_dict):
|
||||
op: Final = user_api_key_dict.object_permission
|
||||
if op is None or not op.mcp_toolsets:
|
||||
return await list_mcp_toolsets(prisma_client)
|
||||
return await list_mcp_toolsets(prisma_client, toolset_ids=op.mcp_toolsets)
|
||||
granted: Final = await granted_toolset_ids(user_api_key_dict)
|
||||
if not granted:
|
||||
return []
|
||||
return await list_mcp_toolsets(prisma_client, toolset_ids=raw_toolsets)
|
||||
return await list_mcp_toolsets(prisma_client, toolset_ids=sorted(granted))
|
||||
|
||||
@router.get(
|
||||
"/toolset/{toolset_id}",
|
||||
|
|
@ -3355,15 +3353,13 @@ if MCP_AVAILABLE:
|
|||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
|
||||
# Non-admin keys may only fetch toolsets they've been explicitly granted.
|
||||
if not _user_has_admin_view(user_api_key_dict):
|
||||
op: Final = user_api_key_dict.object_permission
|
||||
granted: Final = getattr(op, "mcp_toolsets", None) if op else None
|
||||
if granted is None or toolset_id not in granted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"error": "API key does not have access to this toolset."},
|
||||
)
|
||||
if not _user_has_admin_view(user_api_key_dict) and toolset_id not in await granted_toolset_ids(
|
||||
user_api_key_dict
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"error": "API key does not have access to this toolset."},
|
||||
)
|
||||
toolset: Final = await get_mcp_toolset(prisma_client, toolset_id)
|
||||
if toolset is None:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -3466,6 +3466,12 @@ async def team_member_add(
|
|||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
)
|
||||
|
||||
await delete_cache_team_object(
|
||||
team_id=data.team_id,
|
||||
team_alias=complete_team_data.team_alias,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
await evict_and_broadcast(
|
||||
cache_keys=tuple(sorted(user.user_id for user in updated_users)),
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
|
|
|
|||
|
|
@ -1410,10 +1410,15 @@ def run_server(
|
|||
"LiteLLM versions contend for the same DB.\033[0m"
|
||||
)
|
||||
try:
|
||||
setup_ok: Final = PrismaManager.setup_database(
|
||||
migrated: Final = PrismaManager.setup_database(
|
||||
use_migrate=not use_prisma_db_push,
|
||||
use_v2_resolver=use_v2_resolver,
|
||||
)
|
||||
setup_ok: Final = migrated and (
|
||||
not skip_server_startup or PrismaManager.build_request_log_indexes()
|
||||
)
|
||||
if migrated and not skip_server_startup:
|
||||
PrismaManager.start_request_log_index_build()
|
||||
except RuntimeError as e:
|
||||
# Raised on unrecoverable migration errors: the v2
|
||||
# resolver's non-idempotent failures and permission
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ if TYPE_CHECKING:
|
|||
from mcp.types import CallToolResult
|
||||
from mcp.types import Tool as MCPTool
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.ui_session_utils import GrantedToolsetIds
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
else:
|
||||
|
|
@ -223,7 +224,9 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
mcp_servers=all_server_ids,
|
||||
mcp_tool_permissions=tool_permissions,
|
||||
)
|
||||
return user_api_key_auth.model_copy(update={"object_permission": updated_op})
|
||||
return user_api_key_auth.model_copy(
|
||||
update={"object_permission": updated_op, "mcp_explicit_grants_only": True}
|
||||
)
|
||||
except Exception as _e:
|
||||
verbose_logger.debug("Could not apply toolset permissions: %s", _e)
|
||||
return user_api_key_auth
|
||||
|
|
@ -237,6 +240,7 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
|
||||
request_tags: list[str] | None = None,
|
||||
raw_headers: dict[str, str] | None = None,
|
||||
granted_toolsets: "GrantedToolsetIds | None" = None,
|
||||
) -> tuple[list[MCPTool], list[str]]:
|
||||
"""
|
||||
Get available tools from the MCP server manager.
|
||||
|
|
@ -279,23 +283,19 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
if prisma_client is not None:
|
||||
toolset = await global_mcp_server_manager.get_toolset_by_name_cached(prisma_client, name)
|
||||
if toolset is not None:
|
||||
# Access control: only allow if the key explicitly grants this toolset.
|
||||
if user_api_key_auth is not None:
|
||||
from litellm.proxy._experimental.mcp_server.ui_session_utils import (
|
||||
granted_toolset_ids,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_user_has_admin_view,
|
||||
)
|
||||
|
||||
is_admin = _user_has_admin_view(user_api_key_auth)
|
||||
if not is_admin:
|
||||
op = user_api_key_auth.object_permission
|
||||
granted = getattr(op, "mcp_toolsets", None) if op else None
|
||||
# None means no grants configured → deny (consistent with
|
||||
# fetch_mcp_toolsets which returns [] for unconfigured keys)
|
||||
if granted is None or toolset.toolset_id not in granted:
|
||||
verbose_logger.debug(
|
||||
"Key does not have access to toolset '%s', skipping.", name
|
||||
)
|
||||
continue
|
||||
if not _user_has_admin_view(user_api_key_auth) and toolset.toolset_id not in (
|
||||
await (granted_toolsets or granted_toolset_ids)(user_api_key_auth)
|
||||
):
|
||||
verbose_logger.debug("Key does not have access to toolset '%s', skipping.", name)
|
||||
continue
|
||||
resolved_toolset_ids.append(toolset.toolset_id)
|
||||
# Don't add to resolved_mcp_servers — toolset scope
|
||||
# restricts via object_permission, not server name filter.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
"""litellm.sandbox: code-interpreter providers (see main.py) plus harness sandboxes.
|
||||
|
||||
`sandbox.local(path)` and `sandbox.docker(image, ...)` re-export litellm.harness.sandbox.
|
||||
They resolve lazily so `import litellm` does not pull in litellm.harness.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Final
|
||||
|
||||
_HARNESS_SANDBOX_MODULE: Final = "litellm.harness.sandbox"
|
||||
_HARNESS_EXPORTS: Final = frozenset(
|
||||
{
|
||||
"local",
|
||||
"docker",
|
||||
"LocalSandbox",
|
||||
"DockerSandbox",
|
||||
"Sandbox",
|
||||
"Process",
|
||||
"CompletedRun",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
if name in _HARNESS_EXPORTS:
|
||||
return getattr(importlib.import_module(_HARNESS_SANDBOX_MODULE), name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
|
@ -36,6 +36,7 @@ class httpxSpecialProvider(str, Enum):
|
|||
ModelCostMap = "model_cost_map"
|
||||
PasswordBreachCheck = "password_breach_check"
|
||||
ASGI = "asgi"
|
||||
AgentHarness = "agent_harness"
|
||||
|
||||
|
||||
VerifyTypes = str | bool | ssl.SSLContext
|
||||
|
|
|
|||
|
|
@ -308,6 +308,7 @@ if TYPE_CHECKING:
|
|||
CachingHandlerResponse,
|
||||
LLMCachingHandler,
|
||||
)
|
||||
from litellm.harness.types import Harness
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
# Type stubs for lazy-loaded functions and classes
|
||||
|
|
@ -385,6 +386,7 @@ if TYPE_CHECKING:
|
|||
from litellm.llms.base_llm.google_genai.transformation import (
|
||||
BaseGoogleGenAIGenerateContentConfig,
|
||||
)
|
||||
from litellm.llms.base_llm.harness.transformation import BaseHarnessConfig
|
||||
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
|
||||
from litellm.llms.base_llm.image_generation.transformation import (
|
||||
BaseImageGenerationConfig,
|
||||
|
|
@ -9883,6 +9885,37 @@ class ProviderConfigManager:
|
|||
return OpenSandboxSandboxConfig()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_provider_harness_config(harness: Harness) -> BaseHarnessConfig | None:
|
||||
"""
|
||||
Get the agent-harness configuration (Claude Code, Codex, OpenCode, Deep Agents).
|
||||
"""
|
||||
from litellm.harness.types import Harness as _Harness
|
||||
|
||||
if harness == _Harness.CLAUDE_CODE:
|
||||
from litellm.llms.claude_code.harness.transformation import (
|
||||
ClaudeCodeHarnessConfig,
|
||||
)
|
||||
|
||||
return ClaudeCodeHarnessConfig()
|
||||
if harness == _Harness.CODEX:
|
||||
from litellm.llms.codex.harness.transformation import CodexHarnessConfig
|
||||
|
||||
return CodexHarnessConfig()
|
||||
if harness == _Harness.OPENCODE:
|
||||
from litellm.llms.opencode.harness.transformation import (
|
||||
OpenCodeHarnessConfig,
|
||||
)
|
||||
|
||||
return OpenCodeHarnessConfig()
|
||||
if harness == _Harness.DEEPAGENTS:
|
||||
from litellm.llms.deepagents.harness.transformation import (
|
||||
DeepAgentsHarnessConfig,
|
||||
)
|
||||
|
||||
return DeepAgentsHarnessConfig()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_provider_text_to_speech_config(
|
||||
model: str,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
|
||||
Runs `prisma migrate deploy` against the LiteLLM writer database using the
|
||||
recovery logic in `litellm_proxy_extras.ProxyExtrasDBManager.setup_database`
|
||||
(P3005 baseline + P3009/P3018 idempotent-error handling, retries, etc.).
|
||||
(P3005 baseline + P3009/P3018 idempotent-error handling, retries, etc.), then
|
||||
builds the request-log indexes the migrations leave out
|
||||
(`litellm_proxy_extras.request_log_indexes`), waiting for them. The job exits
|
||||
non-zero when an index could not be built so that it is rerun. A serving proxy
|
||||
that runs the migrations itself builds the same indexes in the background once
|
||||
it serves.
|
||||
|
||||
Env vars:
|
||||
DATABASE_URL required unless it can be assembled at
|
||||
|
|
@ -23,10 +28,11 @@ Env vars:
|
|||
import os
|
||||
import sys
|
||||
|
||||
from litellm.proxy.db.db_url_settings import DatabaseURLSettings
|
||||
from litellm_proxy_extras._logging import logger
|
||||
from litellm_proxy_extras.utils import ProxyExtrasDBManager, str_to_bool
|
||||
|
||||
from litellm.proxy.db.db_url_settings import DatabaseURLSettings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Assemble DATABASE_URL from the discrete DATABASE_* env vars, matching
|
||||
|
|
@ -52,7 +58,7 @@ def main() -> int:
|
|||
not use_db_push,
|
||||
use_v2,
|
||||
)
|
||||
ok = ProxyExtrasDBManager.setup_database(
|
||||
ok = ProxyExtrasDBManager.run_migration_job(
|
||||
use_migrate=not use_db_push,
|
||||
use_v2_resolver=use_v2,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,15 +7,16 @@ anything whose cost scales with existing table size turns into downtime. A singl
|
|||
plus a doubled heap that plain autovacuum will not give back.
|
||||
|
||||
What is banned is the row-rewriting DML behind that, not everything whose cost
|
||||
scales that way. A non-concurrent `CREATE INDEX`, an `ALTER COLUMN ... TYPE` that is
|
||||
not binary coercible, a volatile `DEFAULT` on a new column, a `CREATE TABLE ... AS
|
||||
SELECT` or `SELECT ... INTO` filling a new table from an existing one, the rename
|
||||
that pairs with one of those to swap a table out, and a `REFRESH MATERIALIZED VIEW`
|
||||
all read the whole table and all pass. That is deliberate: a rule wide enough to
|
||||
reach them fires on most ordinary migrations, and a marker everyone adds by reflex
|
||||
stops carrying information. The outage this was written for was a backfill.
|
||||
scales that way. A non-concurrent `CREATE INDEX` passes except on a request-log
|
||||
table, where it blocks writes until the build finishes. An `ALTER COLUMN ... TYPE`
|
||||
that is not binary coercible, a volatile `DEFAULT` on a new column, a `CREATE TABLE
|
||||
... AS SELECT` or `SELECT ... INTO` filling a new table from an existing one, the
|
||||
rename that pairs with one of those to swap a table out, and a `REFRESH MATERIALIZED
|
||||
VIEW` all read the whole table and all pass. That is deliberate: a rule wide enough
|
||||
to reach them fires on most ordinary migrations, and a marker everyone adds by
|
||||
reflex stops carrying information. The outage this was written for was a backfill.
|
||||
|
||||
The one schema change banned outright is `ADD COLUMN ... DEFAULT` on a table in
|
||||
One column change banned outright is `ADD COLUMN ... DEFAULT` on a table in
|
||||
`REQUEST_LOG_TABLES`, the tables that hold a row per request. Postgres 11 stores such
|
||||
a default as metadata and touches no rows, but Postgres 10, which is supported,
|
||||
rewrites the whole heap and rebuilds every index under an `ACCESS EXCLUSIVE` lock,
|
||||
|
|
@ -23,6 +24,13 @@ which on a spend-log-sized table is the same outage as a backfill. Every other t
|
|||
is small enough that the rewrite is not worth a rule, and a column added to a log
|
||||
table without a default is still free on every version.
|
||||
|
||||
An index on a request-log table cannot ship as a migration at all. A plain `CREATE
|
||||
INDEX` blocks writes to the table until the build finishes, and `CREATE INDEX
|
||||
CONCURRENTLY` is refused by Postgres on a partitioned parent, which LiteLLM_SpendLogs
|
||||
is wherever the operator ran db_scripts/partition_spend_logs.sql. The migration job builds
|
||||
those indexes after `migrate deploy`, concurrently and per partition, from the list in
|
||||
litellm_proxy_extras/request_log_indexes.py, so that list is where a new one goes.
|
||||
|
||||
Flagged, per statement, by its leading keyword:
|
||||
|
||||
UPDATE rewrites every matching row, and `WHERE` does not bound the scan
|
||||
|
|
@ -44,6 +52,8 @@ Flagged, per statement, by its leading keyword:
|
|||
actions adds a column with a `DEFAULT`. An `ALTER COLUMN ... SET
|
||||
DEFAULT` written after the column exists changes metadata alone, so it
|
||||
passes, as does an `ADD CONSTRAINT`
|
||||
CREATE only a `CREATE [UNIQUE] INDEX` on a request-log table, concurrent or
|
||||
not; the migration job builds those
|
||||
|
||||
Referential actions (`ON DELETE CASCADE`, `ON UPDATE CASCADE`) are schema, never a
|
||||
statement's leading keyword, so they pass.
|
||||
|
|
@ -85,7 +95,9 @@ below line up with the statements they exempt.
|
|||
Add a column and let the application populate it, or run the rewrite as an opt-in
|
||||
batched job outside boot. When a rewrite is genuinely bounded and must ship inside
|
||||
the migration, put `-- data-migration-ok: <reason>` on the statement or on the line
|
||||
above it, naming what bounds it. The reason is required. A marker sharing a line
|
||||
above it, naming what bounds it. The reason is required. A marker never exempts a
|
||||
`CREATE INDEX` on a request-log table, since no bound makes that statement safe:
|
||||
the migration job is the only place such an index is built. A marker sharing a line
|
||||
with the statement it follows exempts that statement alone, so the next statement
|
||||
down is still checked rather than picking the marker up as its own. A marker on an
|
||||
`EXECUTE` or on the assignment feeding one covers the single-quoted SQL that
|
||||
|
|
@ -108,6 +120,7 @@ import sys
|
|||
from collections.abc import Iterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
MIGRATIONS_DIR = REPO_ROOT / "litellm-proxy-extras" / "litellm_proxy_extras" / "migrations"
|
||||
|
|
@ -115,6 +128,9 @@ MIGRATIONS_DIR = REPO_ROOT / "litellm-proxy-extras" / "litellm_proxy_extras" / "
|
|||
GRANDFATHERED = frozenset(
|
||||
{
|
||||
"20250425182129_add_session_id",
|
||||
"20250510142544_add_session_id_index_spend_logs",
|
||||
"20260228100000_add_spend_logs_composite_index",
|
||||
"20250326162113_baseline",
|
||||
"20260817000000_shadow_eval_multi_key",
|
||||
"20260818000000_add_spend_log_timestamps",
|
||||
"20260818224500_add_shadow_eval_stopped_by",
|
||||
|
|
@ -139,9 +155,7 @@ WORD_OR_ASSIGN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*|:=|(?<![<>!:=])=(?![=>])")
|
|||
PRECEDING_WORD = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)[^A-Za-z0-9_]*$")
|
||||
QUALIFIER_GAP = re.compile(r"[\s.]*")
|
||||
EXPLAIN_OPTIONS = re.compile(r"\bEXPLAIN\b(?:\s+(?:ANALYZE|ANALYSE|VERBOSE)\b)+", re.IGNORECASE)
|
||||
DEFINES_A_ROUTINE = re.compile(
|
||||
r"\bCREATE\b(?:\s+OR\s+REPLACE)?\s+(?:FUNCTION|PROCEDURE)\b", re.IGNORECASE
|
||||
)
|
||||
DEFINES_A_ROUTINE = re.compile(r"\bCREATE\b(?:\s+OR\s+REPLACE)?\s+(?:FUNCTION|PROCEDURE)\b", re.IGNORECASE)
|
||||
QUALIFIED_NAME = r"(?:\"[^\"]*\"|[A-Za-z_][A-Za-z0-9_$]*)"
|
||||
ROUTINE_NAME = re.compile(rf"\s*(?:{QUALIFIED_NAME}\s*\.\s*)?({QUALIFIED_NAME})")
|
||||
TABLE_NAME = ROUTINE_NAME
|
||||
|
|
@ -204,6 +218,12 @@ statement with the bound spelled out:
|
|||
-- data-migration-ok: <what bounds this>
|
||||
UPDATE ...
|
||||
|
||||
An index on a request-log table is not a migration, and no marker exempts one. Declare it with `@@index` in
|
||||
schema.prisma and add it to REQUEST_LOG_INDEXES in
|
||||
litellm_proxy_extras/request_log_indexes.py under the name Prisma derives for it; the
|
||||
migration job builds it after `migrate deploy`, concurrently on a plain table and per
|
||||
partition on a partitioned one, which no single migration statement can do.
|
||||
|
||||
On Postgres 10 an `ADD COLUMN ... DEFAULT` on a request-log table rewrites the table
|
||||
too. Add the column nullable with no default, then set the default in a separate
|
||||
`ALTER COLUMN ... SET DEFAULT`, which never touches existing rows.
|
||||
|
|
@ -215,10 +235,11 @@ class Violation:
|
|||
migration: str
|
||||
line: int
|
||||
keyword: str
|
||||
consequence: str = "rewrites existing rows at boot"
|
||||
|
||||
def render(self) -> str:
|
||||
location = f"{MIGRATIONS_DIR.relative_to(REPO_ROOT)}/{self.migration}/migration.sql"
|
||||
return f"{location}:{self.line}: {self.keyword} rewrites existing rows at boot"
|
||||
return f"{location}:{self.line}: {self.keyword} {self.consequence}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -578,6 +599,35 @@ def rewrites_a_log_table(clause: str, region: str, base: int) -> str | None:
|
|||
return f"ADD COLUMN ... DEFAULT on {named.group(1)}"
|
||||
|
||||
|
||||
def builds_a_log_index(clause: str, region: str, base: int) -> str | None:
|
||||
"""The keyword to report when a `CREATE INDEX` targets a request-log table, concurrent or
|
||||
not: a plain build blocks writes for its whole duration, and a concurrent one fails with
|
||||
P3018 on a partitioned parent, so the migration job builds those instead."""
|
||||
created: Final[re.Match[str] | None] = re.match(
|
||||
r"\s*CREATE\s+(?:UNIQUE\s+)?INDEX\b(?:\s+CONCURRENTLY\b)?", clause, re.IGNORECASE
|
||||
)
|
||||
if created is None:
|
||||
return None
|
||||
on: Final[re.Match[str] | None] = re.search(r"\bON\b(?:\s+ONLY\b)?", clause[created.end() :], re.IGNORECASE)
|
||||
if on is None:
|
||||
return None
|
||||
named: Final[re.Match[str] | None] = TABLE_NAME.match(
|
||||
region, skip_comments(region, base + created.end() + on.end())
|
||||
)
|
||||
if named is None or named.group(1).strip('"') not in REQUEST_LOG_TABLES:
|
||||
return None
|
||||
return f"CREATE INDEX on {named.group(1)}"
|
||||
|
||||
|
||||
def consequence_of(found: str) -> str:
|
||||
if found.startswith("CREATE INDEX"):
|
||||
return (
|
||||
"blocks writes until the build finishes, or fails on a partitioned table; "
|
||||
"add it to REQUEST_LOG_INDEXES in litellm_proxy_extras/request_log_indexes.py instead"
|
||||
)
|
||||
return "rewrites existing rows at boot"
|
||||
|
||||
|
||||
def skip_comments(sql: str, start: int) -> int:
|
||||
index = start
|
||||
while index < len(sql):
|
||||
|
|
@ -746,8 +796,7 @@ def read_markers(sql: str) -> Markers:
|
|||
return Markers(
|
||||
sql,
|
||||
tuple(
|
||||
Marker(match.start(), match.end(), alone_on_its_line(sql, match.start()))
|
||||
for match in MARKER.finditer(sql)
|
||||
Marker(match.start(), match.end(), alone_on_its_line(sql, match.start())) for match in MARKER.finditer(sql)
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -760,9 +809,7 @@ def scan(sql: str, migration: str, markers: Markers) -> Iterator[Violation]:
|
|||
yield from scan_region(sql, sql, migration, markers, 0)
|
||||
|
||||
|
||||
def scan_region(
|
||||
document: str, region: str, migration: str, markers: Markers, offset: int
|
||||
) -> Iterator[Violation]:
|
||||
def scan_region(document: str, region: str, migration: str, markers: Markers, offset: int) -> Iterator[Violation]:
|
||||
"""Violations in one region of `document`, whose text begins at `offset`. Positions are
|
||||
always counted against the whole document, so a statement nested in a dollar-quoted body
|
||||
reports its real file line and lines up with the markers read from that file. A single-quoted
|
||||
|
|
@ -790,13 +837,18 @@ def scan_region(
|
|||
offset + start,
|
||||
)
|
||||
|
||||
keyword = offending_keyword(clause)
|
||||
if exempt:
|
||||
continue
|
||||
found = keyword or rewrites_a_log_table(clause, region, base)
|
||||
index = builds_a_log_index(clause, region, base)
|
||||
found = (
|
||||
index if exempt else offending_keyword(clause) or rewrites_a_log_table(clause, region, base) or index
|
||||
)
|
||||
if found is None:
|
||||
continue
|
||||
yield Violation(migration, line_of(document, offset + keyword_start(clause, base)), found)
|
||||
yield Violation(
|
||||
migration,
|
||||
line_of(document, offset + keyword_start(clause, base)),
|
||||
found,
|
||||
consequence_of(found),
|
||||
)
|
||||
|
||||
for body in bodies:
|
||||
if not runs_when_applied(masked, region, bodies, runnable, identifiers, body):
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ EXCLUDED_FOLDERS = {
|
|||
"pass_through",
|
||||
"openai_like", # This is a generic handler, not a specific provider
|
||||
"aiohttp_openai", # Internal implementation detail for async HTTP
|
||||
# Agent-harness configs for litellm.agent(), not LLM providers; documented under docs/harness
|
||||
"claude_code",
|
||||
"codex",
|
||||
"opencode",
|
||||
"deepagents",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ IGNORE_FUNCTIONS = [
|
|||
"_iter_fallback_targets", # max depth set (2 * ROUTER_MAX_FALLBACKS); fails closed by raising ValueError at the cap.
|
||||
"_mergeable_branch", # max depth set (_MAX_SCHEMA_FLATTEN_DEPTH=32) plus a seen_refs cycle guard; passes the schema through untouched at the cap.
|
||||
"json_string_leaves", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); fails closed by raising at the cap so nothing goes unscanned.
|
||||
"strict_json_schema", # harness: max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by raising ValueError at the cap.
|
||||
"toml_value", # harness/codex: max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by raising OptionsMismatch at the cap.
|
||||
"with_json_string_leaves", # transitively bounded: only runs on a tree json_string_leaves already walked under the cap.
|
||||
"json_unrewritable_labels", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); returns the None sentinel at the cap so the caller blocks.
|
||||
"_flatten_form_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
|
||||
|
|
|
|||
0
tests/harness_e2e/__init__.py
Normal file
0
tests/harness_e2e/__init__.py
Normal file
74
tests/harness_e2e/conftest.py
Normal file
74
tests/harness_e2e/conftest.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Fixtures for the litellm.agent() end-to-end tests.
|
||||
|
||||
These run the real harness runtimes (claude, codex, opencode, deepagents) against a real
|
||||
LiteLLM AI Gateway, routed with the `litellm_proxy/` model prefix. They skip unless
|
||||
LITELLM_PROXY_API_BASE and LITELLM_PROXY_API_KEY are set. Model groups can be overridden
|
||||
per harness with HARNESS_E2E_MODEL_<HARNESS>.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm import Harness
|
||||
|
||||
GATEWAY_BASE = os.environ.get("LITELLM_PROXY_API_BASE", "").strip()
|
||||
GATEWAY_KEY = os.environ.get("LITELLM_PROXY_API_KEY", "").strip()
|
||||
|
||||
DEFAULT_MODEL_GROUPS = {
|
||||
Harness.CLAUDE_CODE: "claude-haiku-4-5-20251001",
|
||||
Harness.CODEX: "bedrock_mantle/openai.gpt-5.4",
|
||||
Harness.OPENCODE: "claude-haiku-4-5-20251001",
|
||||
Harness.DEEPAGENTS: "claude-haiku-4-5-20251001",
|
||||
}
|
||||
|
||||
BINARIES = {
|
||||
Harness.CLAUDE_CODE: "claude",
|
||||
Harness.CODEX: "codex",
|
||||
Harness.OPENCODE: "opencode",
|
||||
}
|
||||
|
||||
requires_gateway = pytest.mark.skipif(
|
||||
not (GATEWAY_BASE and GATEWAY_KEY),
|
||||
reason="LITELLM_PROXY_API_BASE / LITELLM_PROXY_API_KEY not set",
|
||||
)
|
||||
|
||||
|
||||
def model_for(harness: Harness) -> str:
|
||||
"""`litellm_proxy/<group>`: every model call goes through the gateway."""
|
||||
override = os.environ.get(f"HARNESS_E2E_MODEL_{harness.name}", "").strip()
|
||||
return f"litellm_proxy/{override or DEFAULT_MODEL_GROUPS[harness]}"
|
||||
|
||||
|
||||
def harness_available(harness: Harness) -> bool:
|
||||
if harness is Harness.DEEPAGENTS:
|
||||
return all(
|
||||
importlib.util.find_spec(m) is not None
|
||||
for m in ("deepagents", "langchain_litellm")
|
||||
)
|
||||
return shutil.which(BINARIES[harness]) is not None
|
||||
|
||||
|
||||
def harness_params() -> list:
|
||||
return [
|
||||
pytest.param(
|
||||
h,
|
||||
id=h.value,
|
||||
marks=pytest.mark.skipif(
|
||||
not harness_available(h), reason=f"{h.value} runtime not installed"
|
||||
),
|
||||
)
|
||||
for h in Harness
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path: Path) -> Iterator[Path]:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "README.md").write_text("# demo\n")
|
||||
yield repo
|
||||
150
tests/harness_e2e/test_harness_e2e.py
Normal file
150
tests/harness_e2e/test_harness_e2e.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""End-to-end: every harness, real runtime, real LiteLLM AI Gateway via litellm_proxy/."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
import litellm
|
||||
from litellm import Harness, sandbox
|
||||
from litellm.harness import (
|
||||
CapabilityUnsupported,
|
||||
Done,
|
||||
FileChange,
|
||||
State,
|
||||
Text,
|
||||
ToolCall,
|
||||
)
|
||||
|
||||
from .conftest import harness_params, model_for, requires_gateway
|
||||
|
||||
pytestmark = [requires_gateway]
|
||||
|
||||
TURN_TIMEOUT = 300
|
||||
|
||||
|
||||
class Answer(BaseModel):
|
||||
city: str
|
||||
country: str
|
||||
|
||||
|
||||
@pytest.mark.parametrize("harness", harness_params())
|
||||
def test_agent_creates_file_and_reports_cost(harness: Harness, workspace: Path) -> None:
|
||||
result = litellm.agent(
|
||||
harness,
|
||||
"Create a file named hello.txt whose entire content is the single word: hi",
|
||||
sandbox=sandbox.local(workspace),
|
||||
model=model_for(harness),
|
||||
timeout=TURN_TIMEOUT,
|
||||
)
|
||||
|
||||
assert result.stop_reason == "done", result.text
|
||||
assert (workspace / "hello.txt").read_text().strip().lower() == "hi"
|
||||
assert [f.path for f in result.files if f.kind == "created"] == ["hello.txt"]
|
||||
assert result.usage.calls >= 1
|
||||
assert result.usage.input_tokens > 0
|
||||
assert result.cost >= 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("harness", harness_params())
|
||||
def test_agent_stream_event_order(harness: Harness, workspace: Path) -> None:
|
||||
(workspace / "secret.txt").write_text("The secret word is ZEBRA.\n")
|
||||
events = list(
|
||||
litellm.agent(
|
||||
harness,
|
||||
"Read secret.txt and reply with just the secret word in it.",
|
||||
sandbox=sandbox.local(workspace),
|
||||
model=model_for(harness),
|
||||
timeout=TURN_TIMEOUT,
|
||||
stream=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(events[-1], Done)
|
||||
assert sum(isinstance(e, Done) for e in events) == 1
|
||||
assert any(isinstance(e, Text) for e in events)
|
||||
assert any(isinstance(e, ToolCall) for e in events)
|
||||
assert "zebra" in events[-1].result.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("harness", harness_params())
|
||||
def test_agent_structured_output(harness: Harness, workspace: Path) -> None:
|
||||
result = litellm.agent(
|
||||
harness,
|
||||
"What is the capital of France? Do not use any tools.",
|
||||
sandbox=sandbox.local(workspace),
|
||||
model=model_for(harness),
|
||||
output=Answer,
|
||||
permissions="read-only",
|
||||
timeout=TURN_TIMEOUT,
|
||||
)
|
||||
|
||||
assert isinstance(result.output, Answer)
|
||||
assert result.output.city.lower() == "paris"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("harness", harness_params())
|
||||
def test_agent_session_remembers_previous_turn(
|
||||
harness: Harness, workspace: Path
|
||||
) -> None:
|
||||
with litellm.agent_session(
|
||||
harness,
|
||||
sandbox=sandbox.local(workspace),
|
||||
model=model_for(harness),
|
||||
timeout=TURN_TIMEOUT,
|
||||
) as s:
|
||||
s.run("Remember this code word: PELICAN. Reply with just OK.")
|
||||
second = s.run(
|
||||
"What code word did I ask you to remember? Reply with just the word."
|
||||
)
|
||||
assert "pelican" in second.text.lower()
|
||||
assert s.cost >= second.cost
|
||||
|
||||
|
||||
@pytest.mark.parametrize("harness", harness_params())
|
||||
def test_agent_detach_and_resume(harness: Harness, workspace: Path) -> None:
|
||||
box = sandbox.local(workspace)
|
||||
s = litellm.agent_session(
|
||||
harness, sandbox=box, model=model_for(harness), timeout=TURN_TIMEOUT
|
||||
)
|
||||
s.run("Remember this number: 4817. Reply with just OK.")
|
||||
raw = s.detach().dumps()
|
||||
|
||||
with litellm.agent_resume(
|
||||
State.loads(raw), sandbox=box, model=model_for(harness)
|
||||
) as resumed:
|
||||
r = resumed.run(
|
||||
"What number did I ask you to remember? Reply with just the number."
|
||||
)
|
||||
assert "4817" in r.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("harness", harness_params())
|
||||
def test_agent_read_only_blocks_writes(harness: Harness, workspace: Path) -> None:
|
||||
result = litellm.agent(
|
||||
harness,
|
||||
"Create a file named blocked.txt containing x. If you cannot, just say you cannot.",
|
||||
sandbox=sandbox.local(workspace),
|
||||
model=model_for(harness),
|
||||
permissions="read-only",
|
||||
timeout=TURN_TIMEOUT,
|
||||
)
|
||||
|
||||
assert not (workspace / "blocked.txt").exists()
|
||||
assert not [f for f in result.files if isinstance(f, FileChange)]
|
||||
|
||||
|
||||
def test_string_harness_rejected(workspace: Path) -> None:
|
||||
with pytest.raises(TypeError, match=r"Harness\.CODEX"):
|
||||
litellm.agent("codex", "hi", sandbox=sandbox.local(workspace)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_capability_checked_before_start(workspace: Path) -> None:
|
||||
with pytest.raises(CapabilityUnsupported):
|
||||
litellm.agent(
|
||||
Harness.CODEX,
|
||||
"hi",
|
||||
sandbox=sandbox.local(workspace),
|
||||
model=model_for(Harness.CODEX),
|
||||
disable_tools=["bash"],
|
||||
)
|
||||
|
|
@ -51,12 +51,12 @@ def delete_toolset(gateway: Gateway, identity: str) -> None:
|
|||
assert response.status_code in (200, 202, 204), response.text
|
||||
|
||||
|
||||
def create_toolset(scenario: Scenario, tools: tuple[tuple[str, str], ...]) -> str:
|
||||
def create_toolset(scenario: Scenario, tools: tuple[tuple[str, str], ...], toolset_name: str | None = None) -> str:
|
||||
response: Final = scenario.gateway.request(
|
||||
"POST",
|
||||
"/v1/mcp/toolset",
|
||||
{
|
||||
"toolset_name": f"integration-{uuid.uuid4().hex[:10]}",
|
||||
"toolset_name": toolset_name or f"integration-{uuid.uuid4().hex[:10]}",
|
||||
"tools": [{"server_id": server_id, "tool_name": tool} for server_id, tool in tools],
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import httpx
|
|||
import psutil
|
||||
from integration._support.client import GATEWAY_LIMITS, Gateway
|
||||
|
||||
DB_PUSH: Final = ("--use_prisma_db_push",)
|
||||
MIGRATE_DEPLOY: Final = ()
|
||||
LEGACY_MIGRATE_DEPLOY: Final = ("--use_legacy_migration_resolver",)
|
||||
|
||||
|
||||
def proxy_database_environment() -> Mapping[str, str]:
|
||||
writer: Final = os.environ.get("INTEGRATION_PROXY_DATABASE_URL", "")
|
||||
|
|
@ -73,9 +77,16 @@ def owned_proxy(
|
|||
config: Path | None = None,
|
||||
remove_environment: tuple[str, ...] = (),
|
||||
workers: int = 1,
|
||||
database_setup: tuple[str, ...] = DB_PUSH,
|
||||
) -> Iterator[Gateway]:
|
||||
with owned_proxy_process(
|
||||
gateway, directory, overrides, config=config, remove_environment=remove_environment, workers=workers
|
||||
gateway,
|
||||
directory,
|
||||
overrides,
|
||||
config=config,
|
||||
remove_environment=remove_environment,
|
||||
workers=workers,
|
||||
database_setup=database_setup,
|
||||
) as owned:
|
||||
yield owned.gateway
|
||||
|
||||
|
|
@ -133,7 +144,7 @@ def _lost_port_race(launch: _Launch) -> bool:
|
|||
|
||||
def _wait_until_ready(launch: _Launch) -> None:
|
||||
with httpx.Client(base_url=f"http://127.0.0.1:{launch.port}", timeout=15, trust_env=False) as client:
|
||||
deadline: Final = time.monotonic() + 70
|
||||
deadline: Final = time.monotonic() + float(os.environ.get("INTEGRATION_PROXY_READY_SECONDS", "70"))
|
||||
while launch.process.poll() is None:
|
||||
try:
|
||||
if client.get("/health/readiness", timeout=2).status_code == 200:
|
||||
|
|
@ -171,6 +182,7 @@ def owned_proxy_process(
|
|||
config: Path | None = None,
|
||||
remove_environment: tuple[str, ...] = (),
|
||||
workers: int = 1,
|
||||
database_setup: tuple[str, ...] = DB_PUSH,
|
||||
) -> Iterator[OwnedProxy]:
|
||||
root: Final = Path(os.environ.get("INTEGRATION_PROXY_ROOT") or Path(__file__).resolve().parents[3])
|
||||
environment: Final = {
|
||||
|
|
@ -196,7 +208,7 @@ def owned_proxy_process(
|
|||
"127.0.0.1",
|
||||
"--num_workers",
|
||||
str(workers),
|
||||
"--use_prisma_db_push",
|
||||
*database_setup,
|
||||
"--enforce_prisma_migration_check",
|
||||
)
|
||||
launch: Final = _launch_until_bound(command, root, environment, output, _PORT_ATTEMPTS)
|
||||
|
|
|
|||
605
tests/integration/configuration/test_lazy_routes_flag.py
Normal file
605
tests/integration/configuration/test_lazy_routes_flag.py
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
"""Route table contract for the LITELLM_DISABLE_LAZY_ROUTES startup flag.
|
||||
|
||||
By default optional feature routers (``LAZY_FEATURES``) are registered on the first
|
||||
request to their path prefix, so an operator inspecting the route table right after
|
||||
boot cannot see or gate them. With the flag set every feature is registered at worker
|
||||
startup, so ``GET /routes`` lists them before any feature request is served and the
|
||||
first feature request changes nothing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Iterator, Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import anthropic
|
||||
import httpx
|
||||
import openai
|
||||
import psutil
|
||||
import pytest
|
||||
import yaml
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
from litellm.proxy._lazy_features import LAZY_FEATURES, LazyFeature
|
||||
from tests.integration._support.client import Gateway, eventually, object_value, string_value
|
||||
from tests.integration._support.mcp import McpPeer, call_tool, echo_tool, scripted_peer, tool_calls, tool_names
|
||||
from tests.integration._support.process import OwnedProxy, owned_proxy_process
|
||||
from tests.integration._support.wire import Reply, Request, Wire, wire_server
|
||||
|
||||
TICKET_FEATURES: Final = ("mcp_management", "mcp_byok_oauth")
|
||||
FLAG: Final = "LITELLM_DISABLE_LAZY_ROUTES"
|
||||
WARMUP_ROUTE: Final = "/lazy/warm/{name}"
|
||||
MCP_WARM_PATH: Final = "/mcp/enabled"
|
||||
MARKER: Final = re.compile(rb"lazyroutes-[0-9a-f]{32}")
|
||||
FAILED_FEATURE: Final = re.compile(r"Failed to lazy-load optional feature '([a-z_]+)'")
|
||||
JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
||||
HOOK_MODULE: Final = "lazy_routes_route_filter_hook"
|
||||
HOOK_SOURCE: Final = """from litellm.proxy.proxy_server import app
|
||||
|
||||
|
||||
def drop_mcp_routes() -> None:
|
||||
app.router.routes[:] = [
|
||||
route for route in app.router.routes if not getattr(route, "path", "").startswith(("/mcp", "/v1/mcp"))
|
||||
]
|
||||
"""
|
||||
|
||||
|
||||
def _paths(candidate: Gateway) -> tuple[str, ...]:
|
||||
routes: Final = candidate.get("/routes")["routes"]
|
||||
assert isinstance(routes, list), routes
|
||||
return tuple(string_value(object_value(route)["path"]) for route in routes)
|
||||
|
||||
|
||||
def _routed_features(candidate: Gateway) -> Mapping[str, tuple[str, ...]]:
|
||||
paths: Final = _paths(candidate)
|
||||
return {feature.name: tuple(path for path in paths if feature.matches(path)) for feature in LAZY_FEATURES}
|
||||
|
||||
|
||||
def _mcp_paths(candidate: Gateway) -> tuple[str, ...]:
|
||||
return tuple(path for path in _paths(candidate) if path.startswith(("/mcp", "/v1/mcp")))
|
||||
|
||||
|
||||
def _route_filter_hook(directory: Path) -> Mapping[str, str]:
|
||||
(directory / f"{HOOK_MODULE}.py").write_text(HOOK_SOURCE)
|
||||
search_path: Final = (str(directory), os.environ.get("PYTHONPATH", ""))
|
||||
return {
|
||||
"PYTHONPATH": os.pathsep.join(entry for entry in search_path if entry),
|
||||
"LITELLM_WORKER_STARTUP_HOOKS": f"{HOOK_MODULE}:drop_mcp_routes",
|
||||
}
|
||||
|
||||
|
||||
def test_lazy_routes_are_absent_from_the_route_table_until_first_request(gateway: Gateway, tmp_path: Path) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, {}, remove_environment=(FLAG,)) as owned:
|
||||
at_boot: Final = _routed_features(owned.gateway)
|
||||
assert {name: at_boot[name] for name in TICKET_FEATURES} == {name: () for name in TICKET_FEATURES}, at_boot
|
||||
listing: Final = owned.gateway.request("GET", "/v1/mcp/server")
|
||||
assert listing.status_code == 200, listing.text
|
||||
after_first_request: Final = _routed_features(owned.gateway)
|
||||
assert after_first_request["mcp_management"] != (), "first request did not register the router"
|
||||
assert after_first_request["mcp_byok_oauth"] == (), "only the requested feature is mounted"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workers", (1, 4))
|
||||
def test_disable_lazy_routes_flag_registers_every_feature_at_startup(
|
||||
gateway: Gateway, tmp_path: Path, workers: int
|
||||
) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}, workers=workers) as owned:
|
||||
at_boot: Final = tuple(_routed_features(owned.gateway) for _ in range(2 * workers))
|
||||
unregistered: Final = sorted(name for name, paths in at_boot[0].items() if not paths)
|
||||
assert unregistered == [], f"features still missing from /routes at startup: {unregistered}"
|
||||
assert all(table == at_boot[0] for table in at_boot), "workers disagree on the route table"
|
||||
listing: Final = owned.gateway.request("GET", "/v1/mcp/server")
|
||||
assert listing.status_code == 200, listing.text
|
||||
assert _routed_features(owned.gateway) == at_boot[0], "first feature request changed the route table"
|
||||
|
||||
|
||||
def test_startup_hook_cannot_remove_lazy_routes_that_register_after_it_ran(gateway: Gateway, tmp_path: Path) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, _route_filter_hook(tmp_path), remove_environment=(FLAG,)) as owned:
|
||||
assert _mcp_paths(owned.gateway) == (), "hook should have removed the routes registered before it ran"
|
||||
listing: Final = owned.gateway.request("GET", "/v1/mcp/server")
|
||||
assert listing.status_code == 200, listing.text
|
||||
assert _mcp_paths(owned.gateway) != (), "first request should have registered the routes the hook never saw"
|
||||
|
||||
|
||||
def test_disable_lazy_routes_flag_lets_a_startup_hook_remove_optional_routes_for_good(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
overrides: Final = {**_route_filter_hook(tmp_path), FLAG: "true"}
|
||||
with owned_proxy_process(gateway, tmp_path, overrides) as owned:
|
||||
assert _mcp_paths(owned.gateway) == (), "hook should have seen and removed every MCP route"
|
||||
listing: Final = owned.gateway.request("GET", "/v1/mcp/server")
|
||||
assert listing.status_code == 404, listing.text
|
||||
mounted: Final = owned.gateway.request("POST", "/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
||||
assert mounted.status_code == 404, mounted.text
|
||||
guardrails: Final = owned.gateway.request("GET", "/guardrails/list")
|
||||
assert guardrails.status_code == 200, guardrails.text
|
||||
assert _mcp_paths(owned.gateway) == (), "a feature request re-registered routes the hook removed"
|
||||
|
||||
|
||||
def _openapi_paths(candidate: Gateway) -> Mapping[str, tuple[str, ...]]:
|
||||
paths: Final = object_value(candidate.get("/openapi.json")["paths"])
|
||||
return {path: tuple(sorted(object_value(operations))) for path, operations in paths.items()}
|
||||
|
||||
|
||||
def _published(feature: LazyFeature, paths: Mapping[str, tuple[str, ...]]) -> bool:
|
||||
return any(feature.matches(path) for path in paths)
|
||||
|
||||
|
||||
def _warm_every_feature(candidate: Gateway) -> None:
|
||||
warmed: Final = tuple(
|
||||
(feature.name, candidate.request("POST", f"/lazy/warm/{feature.name}")) for feature in LAZY_FEATURES
|
||||
)
|
||||
cold: Final = [
|
||||
(name, response.status_code, response.text) for name, response in warmed if response.status_code != 200
|
||||
]
|
||||
assert cold == [], cold
|
||||
enabled: Final = candidate.request("GET", MCP_WARM_PATH)
|
||||
assert enabled.status_code == 200, enabled.text
|
||||
unregistered: Final = sorted(name for name, paths in _routed_features(candidate).items() if not paths)
|
||||
assert unregistered == [], f"features still missing after warming every one of them: {unregistered}"
|
||||
|
||||
|
||||
def _shadowed_dependency(directory: Path) -> Mapping[str, str]:
|
||||
package: Final = directory / "shadow" / "RestrictedPython"
|
||||
package.mkdir(parents=True)
|
||||
(package / "__init__.py").write_text('raise ImportError("shadowed by the lazy routes audit")\n')
|
||||
search_path: Final = (str(package.parent), os.environ.get("PYTHONPATH", ""))
|
||||
return {"PYTHONPATH": os.pathsep.join(entry for entry in search_path if entry)}
|
||||
|
||||
|
||||
def _failed_features(owned: OwnedProxy) -> frozenset[str]:
|
||||
return frozenset(FAILED_FEATURE.findall(owned.log.read_text()))
|
||||
|
||||
|
||||
def _marker() -> str:
|
||||
return "lazyroutes-" + uuid.uuid4().hex
|
||||
|
||||
|
||||
def _chat_reply(identity: str, stream: bool) -> Reply:
|
||||
if not stream:
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"id": identity,
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [
|
||||
{"index": 0, "message": {"role": "assistant", "content": "lazy ok"}, "finish_reason": "stop"}
|
||||
],
|
||||
"usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
chunk: Final[dict[str, JsonValue]] = {
|
||||
"id": identity,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
}
|
||||
deltas: Final[tuple[dict[str, JsonValue], ...]] = (
|
||||
{**chunk, "choices": [{"index": 0, "delta": {"role": "assistant", "content": "lazy"}}]},
|
||||
{**chunk, "choices": [{"index": 0, "delta": {"content": " ok"}, "finish_reason": "stop"}]},
|
||||
{**chunk, "choices": [], "usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}},
|
||||
)
|
||||
return Reply(
|
||||
content_type="text/event-stream",
|
||||
chunks=(*(b"data: " + json.dumps(delta).encode() + b"\n\n" for delta in deltas), b"data: [DONE]\n\n"),
|
||||
)
|
||||
|
||||
|
||||
def _responses_reply(identity: str, stream: bool) -> Reply:
|
||||
response: Final[dict[str, JsonValue]] = {
|
||||
"id": identity,
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"status": "completed",
|
||||
"model": "gpt-4o-mini",
|
||||
"output": [
|
||||
{
|
||||
"id": "msg_" + identity,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "lazy ok", "annotations": []}],
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 7, "output_tokens": 2, "total_tokens": 9},
|
||||
}
|
||||
if not stream:
|
||||
return Reply(body=json.dumps(response).encode())
|
||||
events: Final[tuple[dict[str, JsonValue], ...]] = (
|
||||
{"type": "response.created", "sequence_number": 0, "response": {**response, "status": "in_progress"}},
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"sequence_number": 1,
|
||||
"item_id": "msg_" + identity,
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": "lazy ok",
|
||||
},
|
||||
{"type": "response.completed", "sequence_number": 2, "response": response},
|
||||
)
|
||||
return Reply(
|
||||
content_type="text/event-stream",
|
||||
chunks=tuple(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode() for event in events),
|
||||
)
|
||||
|
||||
|
||||
def _upstream(request: Request) -> Reply:
|
||||
found: Final = MARKER.search(request.body)
|
||||
if found is None:
|
||||
return Reply(status=404, body=b'{"error":"no marker"}')
|
||||
marker: Final = found.group(0).decode()
|
||||
stream: Final = object_value(JSON.validate_json(request.body)).get("stream") is True
|
||||
if request.target.endswith("/responses"):
|
||||
return _responses_reply(f"resp_{marker}", stream)
|
||||
return _chat_reply(f"chatcmpl-{marker}", stream)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def provider() -> Iterator[Wire]:
|
||||
with wire_server(_upstream) as wire:
|
||||
yield wire
|
||||
|
||||
|
||||
async def _stream_chat(base_url: str, key: str, model: str, marker: str) -> tuple[frozenset[str], str]:
|
||||
client: Final = openai.AsyncOpenAI(base_url=base_url + "/v1", api_key=key, max_retries=0)
|
||||
stream: Final = await client.chat.completions.create(
|
||||
model=model, messages=[{"role": "user", "content": marker}], stream=True
|
||||
)
|
||||
chunks: Final = [chunk async for chunk in stream]
|
||||
text: Final = "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices)
|
||||
return frozenset(chunk.id for chunk in chunks), text
|
||||
|
||||
|
||||
async def _stream_message(base_url: str, key: str, model: str, marker: str) -> str:
|
||||
client: Final = anthropic.AsyncAnthropic(base_url=base_url, api_key=key, max_retries=0)
|
||||
async with client.messages.stream(
|
||||
model=model, max_tokens=16, messages=[{"role": "user", "content": marker}]
|
||||
) as stream:
|
||||
return "".join([text async for text in stream.text_stream])
|
||||
|
||||
|
||||
def _status(candidate: Gateway, path: str) -> int:
|
||||
return candidate.request("GET", path).status_code
|
||||
|
||||
|
||||
def _workers(owned: OwnedProxy) -> tuple[psutil.Process, ...]:
|
||||
return tuple(child for child in psutil.Process(owned.process.pid).children() if _is_worker(child))
|
||||
|
||||
|
||||
def _is_worker(child: psutil.Process) -> bool:
|
||||
try:
|
||||
return "spawn_main" in " ".join(child.cmdline()) and child.status() != psutil.STATUS_ZOMBIE
|
||||
except psutil.Error:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spelling", ("1", "Yes", "ON"))
|
||||
def test_every_truthy_spelling_of_the_flag_registers_the_ticket_features_at_startup(
|
||||
gateway: Gateway, tmp_path: Path, spelling: str
|
||||
) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, {FLAG: spelling}) as owned:
|
||||
at_boot: Final = _routed_features(owned.gateway)
|
||||
assert all(at_boot[name] for name in TICKET_FEATURES), {name: at_boot[name] for name in TICKET_FEATURES}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"spelling", ("", "0", "off", "maybe", "x" * 5000), ids=("empty", "zero", "off", "unknown-word", "five-kilobytes")
|
||||
)
|
||||
def test_a_falsey_or_unknown_flag_value_keeps_the_default_lazy_registration(
|
||||
gateway: Gateway, tmp_path: Path, spelling: str
|
||||
) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, {FLAG: spelling}) as owned:
|
||||
at_boot: Final = _routed_features(owned.gateway)
|
||||
assert {name: at_boot[name] for name in TICKET_FEATURES} == {name: () for name in TICKET_FEATURES}, at_boot
|
||||
listing: Final = owned.gateway.request("GET", "/v1/mcp/server")
|
||||
assert listing.status_code == 200, listing.text
|
||||
assert _routed_features(owned.gateway)["mcp_management"] != (), "first request did not register the router"
|
||||
|
||||
|
||||
def test_disable_lazy_routes_flag_publishes_the_live_route_table_in_openapi_before_any_feature_request(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as owned:
|
||||
at_boot: Final = _openapi_paths(owned.gateway)
|
||||
unpublished: Final = sorted(
|
||||
feature.name
|
||||
for feature in LAZY_FEATURES
|
||||
if feature.name in TICKET_FEATURES and not _published(feature, at_boot)
|
||||
)
|
||||
assert unpublished == [], f"ticket features missing from /openapi.json at startup: {unpublished}"
|
||||
assert "get" in at_boot["/v1/mcp/server"], at_boot["/v1/mcp/server"]
|
||||
assert WARMUP_ROUTE not in at_boot
|
||||
stranger: Final = owned.gateway.request("GET", "/v1/mcp/server", key="sk-not-a-real-key")
|
||||
assert stranger.status_code == 401, stranger.text
|
||||
listing: Final = owned.gateway.request("GET", "/v1/mcp/server")
|
||||
assert listing.status_code == 200, listing.text
|
||||
assert _openapi_paths(owned.gateway) == at_boot, "first feature request changed /openapi.json"
|
||||
|
||||
|
||||
def test_disable_lazy_routes_flag_removes_the_warmup_route(gateway: Gateway, tmp_path: Path) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as owned:
|
||||
assert WARMUP_ROUTE not in _paths(owned.gateway)
|
||||
warmed: Final = owned.gateway.request("POST", "/lazy/warm/mcp_management")
|
||||
assert warmed.status_code == 404, warmed.text
|
||||
listing: Final = owned.gateway.request("GET", "/v1/mcp/server")
|
||||
assert listing.status_code == 200, listing.text
|
||||
|
||||
|
||||
def test_the_warmup_route_registers_a_feature_on_demand_by_default(gateway: Gateway, tmp_path: Path) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, {}, remove_environment=(FLAG,)) as owned:
|
||||
assert WARMUP_ROUTE in _paths(owned.gateway)
|
||||
warmed: Final = owned.gateway.request("POST", "/lazy/warm/mcp_management")
|
||||
assert warmed.status_code == 200, warmed.text
|
||||
assert "/v1/mcp/server" in object_value(object_value(JSON.validate_json(warmed.content))["paths"])
|
||||
assert _routed_features(owned.gateway)["mcp_management"] != (), "warmup did not register the router"
|
||||
|
||||
|
||||
def test_disable_lazy_routes_flag_keeps_the_fixed_mcp_proxy_route_ahead_of_the_mcp_mount(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, {}, remove_environment=(FLAG,)) as lazy:
|
||||
control: Final = lazy.gateway.request("POST", "/mcp/proxy", {})
|
||||
assert control.status_code == 400, control.text
|
||||
warmed: Final = _paths(lazy.gateway)
|
||||
assert warmed.count("/mcp") == 2, "expected the fixed /mcp route and the /mcp mount"
|
||||
with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as eager:
|
||||
at_boot: Final = _paths(eager.gateway)
|
||||
assert at_boot.count("/mcp") == 2, "expected the fixed /mcp route and the /mcp mount at startup"
|
||||
mount: Final = max(index for index, path in enumerate(at_boot) if path == "/mcp")
|
||||
assert at_boot.index("/mcp/proxy") < mount, "the /mcp mount shadows /mcp/proxy"
|
||||
proxied: Final = eager.gateway.request("POST", "/mcp/proxy", {})
|
||||
assert (proxied.status_code, proxied.text) == (control.status_code, control.text)
|
||||
|
||||
|
||||
def test_disable_lazy_routes_flag_matches_the_fully_warmed_lazy_route_table_and_openapi(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, {}, remove_environment=(FLAG,)) as lazy:
|
||||
_warm_every_feature(lazy.gateway)
|
||||
warmed_paths: Final = tuple(path for path in _paths(lazy.gateway) if path != WARMUP_ROUTE)
|
||||
warmed_openapi: Final = _openapi_paths(lazy.gateway)
|
||||
with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as eager:
|
||||
assert _paths(eager.gateway) == warmed_paths
|
||||
assert _openapi_paths(eager.gateway) == warmed_openapi
|
||||
|
||||
|
||||
def test_disable_lazy_routes_flag_keeps_registering_after_an_optional_dependency_fails_to_import(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, {**_shadowed_dependency(tmp_path), FLAG: "true"}) as owned:
|
||||
at_boot: Final = _routed_features(owned.gateway)
|
||||
unregistered: Final = frozenset(name for name, paths in at_boot.items() if not paths)
|
||||
failed: Final = _failed_features(owned)
|
||||
assert "guardrails" in failed, owned.log.read_text()
|
||||
assert unregistered == failed, (sorted(unregistered), sorted(failed))
|
||||
guardrails: Final = owned.gateway.request("GET", "/guardrails/list")
|
||||
assert guardrails.status_code == 404, guardrails.text
|
||||
listing: Final = owned.gateway.request("GET", "/v1/mcp/server")
|
||||
assert listing.status_code == 200, listing.text
|
||||
stores: Final = owned.gateway.request("GET", "/vector_store/list")
|
||||
assert stores.status_code == 200, stores.text
|
||||
assert _routed_features(owned.gateway) == at_boot, "feature requests changed the route table"
|
||||
|
||||
|
||||
def test_a_broken_optional_dependency_only_404s_its_own_feature_by_default(gateway: Gateway, tmp_path: Path) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, _shadowed_dependency(tmp_path), remove_environment=(FLAG,)) as owned:
|
||||
guardrails: Final = owned.gateway.request("GET", "/guardrails/list")
|
||||
assert guardrails.status_code == 404, guardrails.text
|
||||
assert "guardrails" in _failed_features(owned), owned.log.read_text()
|
||||
listing: Final = owned.gateway.request("GET", "/v1/mcp/server")
|
||||
assert listing.status_code == 200, listing.text
|
||||
assert _routed_features(owned.gateway)["mcp_management"] != ()
|
||||
|
||||
|
||||
def test_disable_lazy_routes_flag_leaves_the_completion_endpoints_serving_every_client(
|
||||
gateway: Gateway, tmp_path: Path, provider: Wire
|
||||
) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as owned, owned.gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(api_base=provider.url + "/v1")
|
||||
base_url: Final = str(owned.gateway.client.base_url)
|
||||
key: Final = owned.gateway.key
|
||||
markers: Final = tuple(_marker() for _ in range(6))
|
||||
|
||||
completion: Final = openai.OpenAI(
|
||||
base_url=base_url + "/v1", api_key=key, max_retries=0
|
||||
).chat.completions.create(model=model, messages=[{"role": "user", "content": markers[0]}])
|
||||
assert (completion.id, completion.choices[0].message.content) == (f"chatcmpl-{markers[0]}", "lazy ok")
|
||||
|
||||
assert asyncio.run(_stream_chat(base_url, key, model, markers[1])) == (
|
||||
frozenset({f"chatcmpl-{markers[1]}"}),
|
||||
"lazy ok",
|
||||
)
|
||||
|
||||
message: Final = anthropic.Anthropic(base_url=base_url, api_key=key, max_retries=0).messages.create(
|
||||
model=model, max_tokens=16, messages=[{"role": "user", "content": markers[2]}]
|
||||
)
|
||||
assert [block.text for block in message.content if block.type == "text"] == ["lazy ok"]
|
||||
|
||||
assert asyncio.run(_stream_message(base_url, key, model, markers[3])) == "lazy ok"
|
||||
|
||||
responded: Final = owned.gateway.request("POST", "/v1/responses", {"model": model, "input": markers[4]})
|
||||
assert responded.status_code == 200, responded.text
|
||||
response: Final = object_value(JSON.validate_json(responded.content))
|
||||
assert (response["status"], response["object"]) == ("completed", "response"), responded.text
|
||||
assert "lazy ok" in responded.text, responded.text
|
||||
|
||||
streamed: Final = owned.gateway.request(
|
||||
"POST", "/v1/responses", {"model": model, "input": markers[5], "stream": True}
|
||||
)
|
||||
assert streamed.status_code == 200, streamed.text
|
||||
assert "response.completed" in streamed.text and "lazy ok" in streamed.text, streamed.text
|
||||
|
||||
reached: Final = tuple(request.target for request in provider.drain() if MARKER.search(request.body))
|
||||
assert len(reached) == 6, reached
|
||||
|
||||
|
||||
def test_disable_lazy_routes_flag_route_table_survives_a_boot_burst_and_a_killed_worker(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
probes: Final = ("/routes", "/v1/mcp/server", "/openapi.json", "/guardrails/list", "/vector_store/list") * 8
|
||||
with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}, workers=2) as owned:
|
||||
at_boot: Final = _routed_features(owned.gateway)
|
||||
unregistered: Final = sorted(name for name, paths in at_boot.items() if not paths)
|
||||
assert unregistered == [], f"features still missing from /routes at startup: {unregistered}"
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
statuses: Final = tuple(pool.map(partial(_status, owned.gateway), probes))
|
||||
assert statuses == (200,) * len(probes), statuses
|
||||
assert _routed_features(owned.gateway) == at_boot, "the boot burst changed the route table"
|
||||
|
||||
victim: Final = eventually(lambda: _workers(owned), lambda workers: len(workers) == 2)[0]
|
||||
victim.kill()
|
||||
with httpx.Client(base_url=owned.gateway.client.base_url, timeout=15, trust_env=False) as fresh:
|
||||
survivor: Final = Gateway(fresh, owned.gateway.key, owned.gateway.upstream_url)
|
||||
during: Final = tuple(survivor.request("GET", "/v1/mcp/server").status_code for _ in range(10))
|
||||
assert during == (200,) * 10, during
|
||||
respawned: Final = eventually(
|
||||
lambda: frozenset(worker.pid for worker in _workers(owned)),
|
||||
lambda pids: len(pids) == 2 and victim.pid not in pids,
|
||||
seconds=30,
|
||||
)
|
||||
assert f"Child process [{victim.pid}] died" in owned.log.read_text(), respawned
|
||||
tables: Final = tuple(_routed_features(owned.gateway) for _ in range(4))
|
||||
assert all(table == at_boot for table in tables), "the respawned worker disagrees on the route table"
|
||||
|
||||
|
||||
def test_disable_lazy_routes_flag_yields_the_same_route_table_after_a_restart(gateway: Gateway, tmp_path: Path) -> None:
|
||||
with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as first:
|
||||
table: Final = _paths(first.gateway)
|
||||
with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}) as second:
|
||||
assert _paths(second.gateway) == table
|
||||
assert all(_routed_features(second.gateway).values()), "a feature is missing after restart"
|
||||
|
||||
|
||||
SELF_HOSTED_LANGFUSE: Final = "/self-hosted-langfuse"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ConfiguredFeatures:
|
||||
alias: str
|
||||
config: Path
|
||||
policy: Wire
|
||||
langfuse: Wire
|
||||
peer: McpPeer
|
||||
|
||||
|
||||
def _allow(request: Request) -> Reply:
|
||||
return Reply(body=json.dumps({"action": "NONE"}).encode())
|
||||
|
||||
|
||||
def _langfuse_health(request: Request) -> Reply:
|
||||
return Reply(body=json.dumps({"status": "OK"}).encode())
|
||||
|
||||
|
||||
def _config_declaring(directory: Path, alias: str, policy: Wire, langfuse: Wire, peer: McpPeer) -> Path:
|
||||
base: Final = object_value(
|
||||
JSON.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
|
||||
)
|
||||
config: Final = {
|
||||
**base,
|
||||
"guardrails": [
|
||||
{
|
||||
"guardrail_name": alias,
|
||||
"litellm_params": {
|
||||
"guardrail": "generic_guardrail_api",
|
||||
"mode": "pre_call",
|
||||
"default_on": True,
|
||||
"api_base": policy.url,
|
||||
"api_key": "synthetic-guardrail-key",
|
||||
},
|
||||
}
|
||||
],
|
||||
"mcp_servers": {alias: peer.registration()},
|
||||
"general_settings": {
|
||||
**object_value(base["general_settings"]),
|
||||
"pass_through_endpoints": [
|
||||
{
|
||||
"path": "/langfuse",
|
||||
"target": langfuse.url + SELF_HOSTED_LANGFUSE,
|
||||
"include_subpath": True,
|
||||
"auth": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
path: Final = directory / "configured-features.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
return path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _configured_features(directory: Path) -> Iterator[_ConfiguredFeatures]:
|
||||
alias: Final = "lazyroutes" + uuid.uuid4().hex[:8]
|
||||
with (
|
||||
wire_server(_allow) as policy,
|
||||
wire_server(_langfuse_health) as langfuse,
|
||||
scripted_peer(echo_tool("add")) as peer,
|
||||
):
|
||||
config: Final = _config_declaring(directory, alias, policy, langfuse, peer)
|
||||
yield _ConfiguredFeatures(alias, config, policy, langfuse, peer)
|
||||
|
||||
|
||||
def _config_server_id(candidate: Gateway, alias: str) -> str:
|
||||
servers: Final = JSON.validate_json(candidate.request("GET", "/v1/mcp/server").content)
|
||||
assert isinstance(servers, list), servers
|
||||
return next(
|
||||
string_value(object_value(server)["server_id"])
|
||||
for server in servers
|
||||
if object_value(server)["server_name"] == alias
|
||||
)
|
||||
|
||||
|
||||
def _assert_config_declared_features_serve(owned: OwnedProxy, features: _ConfiguredFeatures, provider: Wire) -> None:
|
||||
marker: Final = _marker()
|
||||
with owned.gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(api_base=provider.url + "/v1")
|
||||
completion: Final = owned.gateway.request(
|
||||
"POST", "/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": marker}]}
|
||||
)
|
||||
assert completion.status_code == 200, completion.text
|
||||
screened: Final = [request for request in features.policy.drain() if marker.encode() in request.body]
|
||||
assert len(screened) == 1, "the config-declared guardrail did not screen the completion"
|
||||
assert len([request for request in provider.drain() if marker.encode() in request.body]) == 1
|
||||
|
||||
identity: Final = _config_server_id(owned.gateway, features.alias)
|
||||
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
||||
tool: Final = tool_names(owned.gateway, key, identity)["add"]
|
||||
features.peer.drain()
|
||||
called: Final = call_tool(owned.gateway, key, identity, tool, {"marker": marker})
|
||||
assert called.status_code == 200, called.text
|
||||
reached_peer: Final = [
|
||||
object_value(object_value(call["body"])["params"]) for call in tool_calls(features.peer.drain())
|
||||
]
|
||||
assert [(params["name"], params["arguments"]) for params in reached_peer] == [("add", {"marker": marker})]
|
||||
|
||||
forwarded: Final = owned.gateway.request("GET", "/langfuse/api/public/health")
|
||||
assert forwarded.status_code == 200, forwarded.text
|
||||
reached_langfuse: Final = tuple(request.target for request in features.langfuse.drain())
|
||||
assert reached_langfuse == (SELF_HOSTED_LANGFUSE + "/api/public/health",), (
|
||||
f"the config pass-through for /langfuse lost to the built-in Langfuse route: {reached_langfuse}"
|
||||
)
|
||||
|
||||
|
||||
def test_disable_lazy_routes_flag_serves_config_declared_features_like_the_warmed_lazy_proxy(
|
||||
gateway: Gateway, tmp_path: Path, provider: Wire
|
||||
) -> None:
|
||||
with _configured_features(tmp_path) as features:
|
||||
with owned_proxy_process(gateway, tmp_path, {}, config=features.config, remove_environment=(FLAG,)) as lazy:
|
||||
_assert_config_declared_features_serve(lazy, features, provider)
|
||||
_warm_every_feature(lazy.gateway)
|
||||
warmed_paths: Final = tuple(path for path in _paths(lazy.gateway) if path != WARMUP_ROUTE)
|
||||
warmed_openapi: Final = _openapi_paths(lazy.gateway)
|
||||
with owned_proxy_process(gateway, tmp_path, {FLAG: "true"}, config=features.config) as eager:
|
||||
_assert_config_declared_features_serve(eager, features, provider)
|
||||
assert _paths(eager.gateway) == warmed_paths
|
||||
assert _openapi_paths(eager.gateway) == warmed_openapi
|
||||
364
tests/integration/database/test_request_log_indexes_at_boot.py
Normal file
364
tests/integration/database/test_request_log_indexes_at_boot.py
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from itertools import product
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from integration._support.client import Gateway, eventually
|
||||
from integration._support.database import scratch_database
|
||||
from integration._support.process import LEGACY_MIGRATE_DEPLOY, MIGRATE_DEPLOY, owned_proxy_process
|
||||
from psycopg import sql
|
||||
from psycopg.rows import class_row
|
||||
|
||||
REPO_ROOT: Final = Path(__file__).resolve().parents[3]
|
||||
PRISMA_DIR: Final = REPO_ROOT / "litellm-proxy-extras" / "litellm_proxy_extras"
|
||||
PARTITION_SCRIPT: Final = REPO_ROOT / "db_scripts" / "partition_spend_logs.sql"
|
||||
SHIPPED_MIGRATIONS: Final = tuple(sorted(path.name for path in (PRISMA_DIR / "migrations").iterdir() if path.is_dir()))
|
||||
API_KEY_INDEX_MIGRATION: Final = "20260823000000_add_spend_logs_api_key_starttime_index"
|
||||
CALL_ID_INDEX_MIGRATION: Final = "20260831120001_spend_logs_litellm_call_id_index"
|
||||
ORIGINAL_MIGRATION_SQL: Final = MappingProxyType(
|
||||
{
|
||||
API_KEY_INDEX_MIGRATION: (
|
||||
"-- CreateIndex\n"
|
||||
'CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogs_api_key_startTime_idx" '
|
||||
'ON "LiteLLM_SpendLogs"("api_key", "startTime");\n'
|
||||
),
|
||||
CALL_ID_INDEX_MIGRATION: (
|
||||
"-- CreateIndex\n"
|
||||
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_SpendLogs_litellm_call_id_idx" '
|
||||
'ON "LiteLLM_SpendLogs"("litellm_call_id");\n'
|
||||
),
|
||||
}
|
||||
)
|
||||
MIGRATION_JOB_SECONDS: Final = 300
|
||||
INDEXES_IN_PLACE: Final = "Request-log indexes are all in place"
|
||||
INDEX_BUILD_LINES: Final = ("Building index", "Attached index")
|
||||
BUILD_SECONDS: Final = 60
|
||||
SPEND_LOGS_INDEXES: Final = ("LiteLLM_SpendLogs_api_key_startTime_idx", "LiteLLM_SpendLogs_litellm_call_id_idx")
|
||||
POPULATED_PARTITIONS: Final = MappingProxyType(
|
||||
{
|
||||
"LiteLLM_SpendLogs_p2026_08": ("2026-08-01", "2026-09-01"),
|
||||
"LiteLLM_SpendLogs_p2026_09": ("2026-09-01", "2026-10-01"),
|
||||
}
|
||||
)
|
||||
DEFAULT_PARTITION: Final = "LiteLLM_SpendLogs_pdefault"
|
||||
ROWS_PER_PARTITION: Final = 500
|
||||
PARTITIONED_PARENT_ERROR: Final = 'cannot create index on partitioned table "LiteLLM_SpendLogs" concurrently'
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Resolver:
|
||||
"""One migration resolver as the serving proxy selects it (CLI flags) and as the
|
||||
migration job selects it (environment)."""
|
||||
|
||||
proxy_flags: tuple[str, ...]
|
||||
job_environment: Mapping[str, str]
|
||||
|
||||
|
||||
V2: Final = Resolver(MIGRATE_DEPLOY, MappingProxyType({"USE_V2_MIGRATION_RESOLVER": "true"}))
|
||||
LEGACY: Final = Resolver(LEGACY_MIGRATE_DEPLOY, MappingProxyType({"USE_V2_MIGRATION_RESOLVER": "false"}))
|
||||
RESOLVERS: Final = pytest.mark.parametrize("resolver", (V2, LEGACY), ids=("v2", "legacy"))
|
||||
|
||||
|
||||
def release_layout(directory: Path, migrations: tuple[str, ...]) -> Path:
|
||||
"""The Prisma layout of the release that shipped `migrations`: the two index migrations
|
||||
carry the SQL they shipped with, not the inert files of this build."""
|
||||
(directory / "migrations").mkdir(parents=True)
|
||||
shutil.copy(PRISMA_DIR / "schema.prisma", directory / "schema.prisma")
|
||||
shutil.copy(PRISMA_DIR / "migrations" / "migration_lock.toml", directory / "migrations" / "migration_lock.toml")
|
||||
for name in migrations:
|
||||
shutil.copytree(PRISMA_DIR / "migrations" / name, directory / "migrations" / name)
|
||||
for name, original in ORIGINAL_MIGRATION_SQL.items():
|
||||
if name in migrations:
|
||||
(directory / "migrations" / name / "migration.sql").write_text(original)
|
||||
return directory / "schema.prisma"
|
||||
|
||||
|
||||
def migrate_deploy(database_url: str, schema: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-I", "-m", "prisma", "migrate", "deploy", "--schema", str(schema)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
env={**os.environ, "DATABASE_URL": database_url},
|
||||
)
|
||||
|
||||
|
||||
def migration_job(database_url: str, resolver: Resolver) -> subprocess.CompletedProcess[str]:
|
||||
"""The migrations image entrypoint, as the helm migration Job runs it."""
|
||||
return subprocess.run(
|
||||
[sys.executable, "-I", str(REPO_ROOT / "migrations" / "run.py")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=MIGRATION_JOB_SECONDS,
|
||||
cwd=REPO_ROOT,
|
||||
env={**os.environ, "DATABASE_URL": database_url, **resolver.job_environment},
|
||||
)
|
||||
|
||||
|
||||
def migration_cli(database_url: str, gateway: Gateway, resolver: Resolver) -> subprocess.CompletedProcess[str]:
|
||||
"""The proxy CLI as a migration job: `--skip_server_startup` migrates, builds the
|
||||
indexes and exits by their result."""
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-P",
|
||||
"-m",
|
||||
"integration._support.proxy",
|
||||
"--config",
|
||||
"tests/integration/proxy_config.yaml",
|
||||
*resolver.proxy_flags,
|
||||
"--skip_server_startup",
|
||||
"--enforce_prisma_migration_check",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=MIGRATION_JOB_SECONDS,
|
||||
cwd=REPO_ROOT,
|
||||
env={**os.environ, "DATABASE_URL": database_url, "LITELLM_MASTER_KEY": gateway.key},
|
||||
)
|
||||
|
||||
|
||||
def deploy_schema_before_the_index_migrations(database_url: str, directory: Path) -> None:
|
||||
older: Final = tuple(name for name in SHIPPED_MIGRATIONS if name < API_KEY_INDEX_MIGRATION)
|
||||
deployed: Final = migrate_deploy(database_url, release_layout(directory / "older-release", older))
|
||||
assert deployed.returncode == 0, deployed.stdout + deployed.stderr
|
||||
|
||||
|
||||
def deploy_the_original_index_migrations(database_url: str, directory: Path) -> subprocess.CompletedProcess[str]:
|
||||
"""Boot the v1.103.0 layout once: on a partitioned table its CONCURRENTLY call_id index
|
||||
fails with P3018 and leaves the ledger row unfinished, on a plain table both apply."""
|
||||
return migrate_deploy(database_url, release_layout(directory / "v1.103.0", SHIPPED_MIGRATIONS))
|
||||
|
||||
|
||||
def fail_the_call_id_index_migration_like_the_shipped_release(database_url: str, directory: Path) -> None:
|
||||
deployed: Final = deploy_the_original_index_migrations(database_url, directory)
|
||||
assert deployed.returncode != 0, deployed.stdout
|
||||
assert "P3018" in deployed.stderr and PARTITIONED_PARENT_ERROR in deployed.stderr, deployed.stderr
|
||||
assert ledger(database_url)[CALL_ID_INDEX_MIGRATION] is False
|
||||
|
||||
|
||||
def partition_spend_logs(database_url: str) -> None:
|
||||
with psycopg.connect(database_url, autocommit=True) as connection:
|
||||
connection.execute(PARTITION_SCRIPT.read_bytes())
|
||||
for partition, (start, stop) in POPULATED_PARTITIONS.items():
|
||||
add_partition(connection, partition, start, stop)
|
||||
connection.execute(
|
||||
'INSERT INTO "LiteLLM_SpendLogs" ("request_id", "call_type", "api_key", "startTime", "endTime") '
|
||||
"SELECT %s || n, 'acompletion', 'sk-' || (n %% 7), %s::timestamp + (n * interval '1 minute'), "
|
||||
"%s::timestamp + (n * interval '1 minute') + interval '1 second' FROM generate_series(1, %s) AS n",
|
||||
(partition, start, start, ROWS_PER_PARTITION),
|
||||
)
|
||||
connection.execute(
|
||||
'INSERT INTO "LiteLLM_SpendLogs" ("request_id", "call_type", "startTime", "endTime") '
|
||||
"SELECT 'default-' || n, 'acompletion', '2026-07-01'::timestamp + (n * interval '1 minute'), "
|
||||
"'2026-07-01'::timestamp + (n * interval '1 minute') FROM generate_series(1, %s) AS n",
|
||||
(ROWS_PER_PARTITION,),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LedgerRow:
|
||||
name: str
|
||||
finished: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _IndexRow:
|
||||
index: str
|
||||
valid: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _OidRow:
|
||||
index: str
|
||||
oid: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _AttachedRow:
|
||||
partition: str
|
||||
parent_index: str
|
||||
valid: bool
|
||||
|
||||
|
||||
def ledger(database_url: str) -> Mapping[str, bool]:
|
||||
"""Every migration in the ledger that was not rolled back, mapped to whether it finished."""
|
||||
with psycopg.connect(database_url) as connection, connection.cursor(row_factory=class_row(_LedgerRow)) as cursor:
|
||||
rows: Final = cursor.execute(
|
||||
'SELECT migration_name AS name, finished_at IS NOT NULL AS finished FROM "_prisma_migrations" '
|
||||
"WHERE rolled_back_at IS NULL ORDER BY migration_name"
|
||||
).fetchall()
|
||||
return MappingProxyType({row.name: row.finished for row in rows})
|
||||
|
||||
|
||||
def parent_index_validity(database_url: str) -> Mapping[str, bool]:
|
||||
with psycopg.connect(database_url) as connection, connection.cursor(row_factory=class_row(_IndexRow)) as cursor:
|
||||
rows: Final = cursor.execute(
|
||||
"SELECT c.relname AS index, x.indisvalid AS valid FROM pg_index x JOIN pg_class c ON c.oid = x.indexrelid "
|
||||
"WHERE x.indrelid = '\"LiteLLM_SpendLogs\"'::regclass AND c.relname = ANY(%s)",
|
||||
(list(SPEND_LOGS_INDEXES),),
|
||||
).fetchall()
|
||||
return MappingProxyType({row.index: row.valid for row in rows})
|
||||
|
||||
|
||||
def index_oids(database_url: str) -> Mapping[str, int]:
|
||||
"""index name -> oid for the SpendLogs indexes on the parent or any partition; a rebuild changes the oid."""
|
||||
with psycopg.connect(database_url) as connection, connection.cursor(row_factory=class_row(_OidRow)) as cursor:
|
||||
rows: Final = cursor.execute(
|
||||
"SELECT c.relname AS index, c.oid::int AS oid FROM pg_index x JOIN pg_class c ON c.oid = x.indexrelid "
|
||||
"WHERE x.indrelid = '\"LiteLLM_SpendLogs\"'::regclass OR x.indrelid IN "
|
||||
"(SELECT inhrelid FROM pg_inherits WHERE inhparent = '\"LiteLLM_SpendLogs\"'::regclass)"
|
||||
).fetchall()
|
||||
return MappingProxyType({row.index: row.oid for row in rows})
|
||||
|
||||
|
||||
def attached_partition_indexes(database_url: str) -> frozenset[tuple[str, str, bool]]:
|
||||
"""(partition, parent index, child is valid) for every child index attached under a SpendLogs parent index."""
|
||||
with psycopg.connect(database_url) as connection, connection.cursor(row_factory=class_row(_AttachedRow)) as cursor:
|
||||
rows: Final = cursor.execute(
|
||||
"SELECT part.relname AS partition, parent_index.relname AS parent_index, child.indisvalid AS valid "
|
||||
"FROM pg_inherits attached "
|
||||
"JOIN pg_class parent_index ON parent_index.oid = attached.inhparent "
|
||||
"JOIN pg_index child ON child.indexrelid = attached.inhrelid "
|
||||
"JOIN pg_class part ON part.oid = child.indrelid "
|
||||
"WHERE parent_index.relname = ANY(%s)",
|
||||
(list(SPEND_LOGS_INDEXES),),
|
||||
).fetchall()
|
||||
return frozenset((row.partition, row.parent_index, row.valid) for row in rows)
|
||||
|
||||
|
||||
def expected_attachments(partitions: tuple[str, ...]) -> frozenset[tuple[str, str, bool]]:
|
||||
return frozenset((partition, index, True) for partition, index in product(partitions, SPEND_LOGS_INDEXES))
|
||||
|
||||
|
||||
def add_partition(connection: psycopg.Connection[tuple[object, ...]], partition: str, start: str, stop: str) -> None:
|
||||
connection.execute(
|
||||
sql.SQL('CREATE TABLE {} PARTITION OF "LiteLLM_SpendLogs" FOR VALUES FROM ({}) TO ({})').format(
|
||||
sql.Identifier(partition), sql.Literal(start), sql.Literal(stop)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def assert_ready(booted_gateway: Gateway) -> None:
|
||||
readiness: Final = booted_gateway.request("GET", "/health/readiness")
|
||||
assert readiness.status_code == 200, readiness.text
|
||||
assert readiness.json()["db"] == "connected", readiness.text
|
||||
|
||||
|
||||
def assert_both_indexes_cover_every_partition(database_url: str) -> None:
|
||||
"""Every populated partition's index is attached and valid, both parents are valid, and
|
||||
a partition created afterwards inherits both indexes."""
|
||||
populated: Final = (*POPULATED_PARTITIONS, DEFAULT_PARTITION)
|
||||
assert ledger(database_url) == {name: True for name in SHIPPED_MIGRATIONS}
|
||||
assert attached_partition_indexes(database_url) == expected_attachments(populated)
|
||||
assert parent_index_validity(database_url) == {index: True for index in SPEND_LOGS_INDEXES}
|
||||
with psycopg.connect(database_url, autocommit=True) as connection:
|
||||
add_partition(connection, "LiteLLM_SpendLogs_p2026_10", "2026-10-01", "2026-11-01")
|
||||
assert attached_partition_indexes(database_url) == expected_attachments((*populated, "LiteLLM_SpendLogs_p2026_10"))
|
||||
|
||||
|
||||
def assert_the_serving_proxy_boots_and_finds_the_indexes_in_place(
|
||||
gateway: Gateway, directory: Path, database_url: str, resolver: Resolver
|
||||
) -> None:
|
||||
"""The serving proxy applies the inert files, reports ready, and its background build
|
||||
finds every index already there, so it builds nothing and the catalog is untouched."""
|
||||
oids: Final = index_oids(database_url)
|
||||
with owned_proxy_process(
|
||||
gateway, directory, {"DATABASE_URL": database_url}, database_setup=resolver.proxy_flags
|
||||
) as booted:
|
||||
assert_ready(booted.gateway)
|
||||
log: Final = eventually(
|
||||
lambda: booted.log.read_text(errors="replace"), lambda text: INDEXES_IN_PLACE in text, seconds=BUILD_SECONDS
|
||||
)
|
||||
assert ledger(database_url) == {name: True for name in SHIPPED_MIGRATIONS}
|
||||
assert not any(line in log for line in INDEX_BUILD_LINES), log[-4000:]
|
||||
assert index_oids(database_url) == oids
|
||||
|
||||
|
||||
@RESOLVERS
|
||||
def test_the_migration_job_gives_a_partitioned_table_at_the_pre_index_schema_both_indexes_per_partition(
|
||||
gateway: Gateway, tmp_path: Path, resolver: Resolver
|
||||
) -> None:
|
||||
with scratch_database() as database_url:
|
||||
deploy_schema_before_the_index_migrations(database_url, tmp_path)
|
||||
partition_spend_logs(database_url)
|
||||
job: Final = migration_job(database_url, resolver)
|
||||
assert job.returncode == 0, job.stdout + job.stderr
|
||||
assert INDEXES_IN_PLACE in job.stderr + job.stdout, job.stdout + job.stderr
|
||||
assert_both_indexes_cover_every_partition(database_url)
|
||||
assert_the_serving_proxy_boots_and_finds_the_indexes_in_place(gateway, tmp_path, database_url, resolver)
|
||||
|
||||
|
||||
@RESOLVERS
|
||||
def test_the_migration_job_heals_a_partitioned_table_left_with_the_failed_call_id_ledger_row(
|
||||
gateway: Gateway, tmp_path: Path, resolver: Resolver
|
||||
) -> None:
|
||||
with scratch_database() as database_url:
|
||||
deploy_schema_before_the_index_migrations(database_url, tmp_path)
|
||||
partition_spend_logs(database_url)
|
||||
fail_the_call_id_index_migration_like_the_shipped_release(database_url, tmp_path)
|
||||
job: Final = migration_job(database_url, resolver)
|
||||
assert job.returncode == 0, job.stdout + job.stderr
|
||||
assert_both_indexes_cover_every_partition(database_url)
|
||||
assert_the_serving_proxy_boots_and_finds_the_indexes_in_place(gateway, tmp_path, database_url, resolver)
|
||||
|
||||
|
||||
@RESOLVERS
|
||||
def test_the_migration_job_leaves_a_plain_table_that_applied_the_original_index_migrations_alone(
|
||||
gateway: Gateway, tmp_path: Path, resolver: Resolver
|
||||
) -> None:
|
||||
with scratch_database() as database_url:
|
||||
deploy_schema_before_the_index_migrations(database_url, tmp_path)
|
||||
deployed: Final = deploy_the_original_index_migrations(database_url, tmp_path)
|
||||
assert deployed.returncode == 0, deployed.stdout + deployed.stderr
|
||||
before: Final = index_oids(database_url)
|
||||
assert set(SPEND_LOGS_INDEXES) <= set(before), before
|
||||
job: Final = migration_job(database_url, resolver)
|
||||
assert job.returncode == 0, job.stdout + job.stderr
|
||||
assert INDEXES_IN_PLACE in job.stderr + job.stdout, job.stdout + job.stderr
|
||||
assert "Building index" not in job.stderr + job.stdout, job.stdout + job.stderr
|
||||
assert ledger(database_url) == {name: True for name in SHIPPED_MIGRATIONS}
|
||||
assert index_oids(database_url) == before
|
||||
assert_the_serving_proxy_boots_and_finds_the_indexes_in_place(gateway, tmp_path, database_url, resolver)
|
||||
|
||||
|
||||
@RESOLVERS
|
||||
def test_a_serving_proxy_that_runs_the_migrations_itself_builds_both_indexes_after_it_is_ready(
|
||||
gateway: Gateway, tmp_path: Path, resolver: Resolver
|
||||
) -> None:
|
||||
"""A deployment that runs migrate deploy from the serving proxy and never runs the
|
||||
migration job answers readiness with the inert files applied, then its background build
|
||||
puts both indexes on every partition."""
|
||||
with scratch_database() as database_url:
|
||||
deploy_schema_before_the_index_migrations(database_url, tmp_path)
|
||||
partition_spend_logs(database_url)
|
||||
assert "LiteLLM_SpendLogs_litellm_call_id_idx" not in parent_index_validity(database_url)
|
||||
with owned_proxy_process(
|
||||
gateway, tmp_path, {"DATABASE_URL": database_url}, database_setup=resolver.proxy_flags
|
||||
) as booted:
|
||||
assert_ready(booted.gateway)
|
||||
assert ledger(database_url) == {name: True for name in SHIPPED_MIGRATIONS}
|
||||
log: Final = eventually(
|
||||
lambda: booted.log.read_text(errors="replace"),
|
||||
lambda text: INDEXES_IN_PLACE in text,
|
||||
seconds=BUILD_SECONDS,
|
||||
)
|
||||
assert "Building index" in log and "Attached index" in log, log[-4000:]
|
||||
assert_both_indexes_cover_every_partition(database_url)
|
||||
|
||||
|
||||
def test_the_cli_run_as_a_migration_job_builds_both_indexes_before_it_exits(gateway: Gateway, tmp_path: Path) -> None:
|
||||
with scratch_database() as database_url:
|
||||
deploy_schema_before_the_index_migrations(database_url, tmp_path)
|
||||
partition_spend_logs(database_url)
|
||||
job: Final = migration_cli(database_url, gateway, V2)
|
||||
assert job.returncode == 0, job.stdout + job.stderr
|
||||
assert_both_indexes_cover_every_partition(database_url)
|
||||
|
|
@ -9,6 +9,7 @@ import httpx
|
|||
import pytest
|
||||
from integration._support.client import Gateway, Scenario
|
||||
from integration._support.mcp import McpPeer, mcp_peer, register_mcp, tool_calls
|
||||
from integration._support.mcp_grants import create_toolset
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
|
||||
Surface = Literal["chat", "responses", "messages", "messages_bridge"]
|
||||
|
|
@ -194,9 +195,7 @@ class Rig:
|
|||
)
|
||||
|
||||
def upstream_tools(self) -> tuple[tuple[str, ...], ...]:
|
||||
return tuple(
|
||||
_tool_names(json.loads(request.body)) for request in self.wire.drain() if request.method == "POST"
|
||||
)
|
||||
return tuple(_tool_names(json.loads(request.body)) for request in self.wire.drain() if request.method == "POST")
|
||||
|
||||
def final_text(self, body: Mapping[str, object]) -> str:
|
||||
if self.surface == "chat":
|
||||
|
|
@ -314,6 +313,25 @@ def test_allowed_tools_narrows_the_tool_list_handed_to_the_model(gateway: Gatewa
|
|||
assert [call["body"]["params"]["name"] for call in _peer_add_calls(rig.peer)] == ["add"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("surface", ("chat", "responses", "messages"))
|
||||
def test_toolset_gateway_url_serves_a_team_granted_toolset_to_a_key_without_its_own_grant(
|
||||
gateway: Gateway, surface: Surface
|
||||
) -> None:
|
||||
with _rig(gateway, surface) as rig:
|
||||
register_mcp(rig.scenario, rig.peer, "open" + uuid.uuid4().hex[:8], allow_all_keys=True)
|
||||
toolset_name: Final = "ts" + uuid.uuid4().hex[:8]
|
||||
toolset_id: Final = create_toolset(rig.scenario, ((rig.server_id, "add"),), toolset_name=toolset_name)
|
||||
sibling_id: Final = create_toolset(rig.scenario, ((rig.server_id, "multiply"),))
|
||||
team_id: Final = rig.scenario.team(object_permission={"mcp_toolsets": [toolset_id, sibling_id]})
|
||||
key: Final = rig.scenario.key(team_id=team_id)
|
||||
response: Final = rig.send(key, [{**AUTO, "server_url": f"litellm_proxy/mcp/{toolset_name}"}])
|
||||
assert response.status_code == 200, response.text
|
||||
requests: Final = rig.upstream_tools()
|
||||
assert requests, "model was never called"
|
||||
assert all(names == (rig.tool,) for names in requests), requests
|
||||
assert [call["body"]["params"]["name"] for call in _peer_add_calls(rig.peer)] == ["add"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("surface", ("chat", "responses", "messages"))
|
||||
def test_server_scoped_gateway_url_exposes_only_that_servers_tools(gateway: Gateway, surface: Surface) -> None:
|
||||
with _rig(gateway, surface) as rig, mcp_peer() as other_peer:
|
||||
|
|
@ -347,3 +365,41 @@ def test_streaming_chat_executes_the_tool_once_and_streams_the_follow_up(gateway
|
|||
assert text == ANSWER, response.text
|
||||
assert [call["body"]["params"]["name"] for call in _peer_add_calls(rig.peer)] == ["add"]
|
||||
assert len(rig.upstream_tools()) == 2
|
||||
|
||||
|
||||
def test_streaming_chat_through_a_toolset_gateway_url_serves_a_team_key_without_its_own_grant(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with _rig(gateway, "chat") as rig:
|
||||
register_mcp(rig.scenario, rig.peer, "open" + uuid.uuid4().hex[:8], allow_all_keys=True)
|
||||
toolset_name: Final = "ts" + uuid.uuid4().hex[:8]
|
||||
toolset_id: Final = create_toolset(rig.scenario, ((rig.server_id, "add"),), toolset_name=toolset_name)
|
||||
key: Final = rig.scenario.key(team_id=rig.scenario.team(object_permission={"mcp_toolsets": [toolset_id]}))
|
||||
response: Final = rig.send(key, [{**AUTO, "server_url": f"litellm_proxy/mcp/{toolset_name}"}], stream=True)
|
||||
assert response.status_code == 200, response.text
|
||||
chunks: Final = tuple(
|
||||
json.loads(line.removeprefix("data: "))
|
||||
for line in response.text.splitlines()
|
||||
if line.startswith("data: ") and line != "data: [DONE]"
|
||||
)
|
||||
text: Final = "".join(
|
||||
str(chunk["choices"][0]["delta"].get("content") or "") for chunk in chunks if chunk.get("choices")
|
||||
)
|
||||
assert text == ANSWER, response.text
|
||||
assert [call["body"]["params"]["name"] for call in _peer_add_calls(rig.peer)] == ["add"]
|
||||
requests: Final = rig.upstream_tools()
|
||||
assert len(requests) == 2 and all(names == (rig.tool,) for names in requests), requests
|
||||
|
||||
|
||||
@pytest.mark.parametrize("surface", ("chat", "responses", "messages"))
|
||||
def test_toolset_gateway_url_gives_a_key_of_an_ungranted_team_no_tools_and_never_reaches_the_peer(
|
||||
gateway: Gateway, surface: Surface
|
||||
) -> None:
|
||||
with _rig(gateway, surface) as rig:
|
||||
toolset_name: Final = "ts" + uuid.uuid4().hex[:8]
|
||||
create_toolset(rig.scenario, ((rig.server_id, "add"),), toolset_name=toolset_name)
|
||||
key: Final = rig.scenario.key(team_id=rig.scenario.team())
|
||||
response: Final = rig.send(key, [{**AUTO, "server_url": f"litellm_proxy/mcp/{toolset_name}"}])
|
||||
assert _peer_add_calls(rig.peer) == (), "denied caller reached the peer"
|
||||
assert all(rig.tool not in names for names in rig.upstream_tools()), rig.upstream_tools()
|
||||
assert response.status_code in (200, 400, 401, 403), response.text
|
||||
|
|
|
|||
|
|
@ -1,25 +1,31 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from integration._support.client import Gateway, eventually
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.mcp import (
|
||||
ENTRY_POINTS,
|
||||
INITIALIZE,
|
||||
EntryPoint,
|
||||
McpCaller,
|
||||
McpPeer,
|
||||
Outcome,
|
||||
_outcome_from_rpc,
|
||||
call_tool,
|
||||
mcp_peer,
|
||||
register_mcp,
|
||||
tool_calls,
|
||||
)
|
||||
from integration._support.mcp_grants import create_toolset
|
||||
from integration._support.oauth_server import AuthorizationServer, oauth_server
|
||||
|
||||
ADD: Final = {"a": 2, "b": 3}
|
||||
|
|
@ -383,3 +389,117 @@ def test_dcr_bridge_relays_client_registration_and_advertises_gateway_endpoints(
|
|||
assert issuer.json()["authorization_endpoint"] == f"{_base(gateway)}/{alias}/authorize"
|
||||
assert issuer.json()["token_endpoint"] == f"{_base(gateway)}/{alias}/token"
|
||||
assert "S256" in issuer.json()["code_challenge_methods_supported"]
|
||||
|
||||
|
||||
def _ui_session_cookie(gateway: Gateway, user_id: str) -> dict[str, str]:
|
||||
claims: Final = {"user_id": user_id, "login_method": "username_password", "exp": int(time.time()) + 600}
|
||||
return {"token": jwt.encode(claims, gateway.key, algorithm="HS256")}
|
||||
|
||||
|
||||
def _gateway_session_bearer(gateway: Gateway, user_id: str, resource: str | None = None) -> str:
|
||||
registered: Final = gateway.client.post(
|
||||
"/register", json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"}
|
||||
)
|
||||
assert registered.status_code in (200, 201), registered.text
|
||||
client_id: Final = registered.json()["client_id"]
|
||||
pkce: Final = _Pkce(secrets.token_urlsafe(48))
|
||||
cookies: Final = _ui_session_cookie(gateway, user_id)
|
||||
started: Final = gateway.client.get(
|
||||
"/authorize",
|
||||
params={
|
||||
"client_id": client_id,
|
||||
"redirect_uri": CLIENT_REDIRECT,
|
||||
"response_type": "code",
|
||||
"state": "lit6029",
|
||||
"code_challenge": pkce.challenge,
|
||||
"code_challenge_method": "S256",
|
||||
**({} if resource is None else {"resource": resource}),
|
||||
},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert started.status_code == 303, started.text
|
||||
handle: Final = parse_qs(urlsplit(started.headers["location"]).query)["connect_flow"][0]
|
||||
completed: Final = gateway.client.post(
|
||||
"/authorize/complete", data={"flow": handle}, cookies={**cookies, **dict(started.cookies)}
|
||||
)
|
||||
assert completed.status_code == 303, completed.text
|
||||
callback: Final = parse_qs(urlsplit(completed.headers["location"]).query)
|
||||
assert "code" in callback, completed.headers["location"]
|
||||
issued: Final = gateway.client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": callback["code"][0],
|
||||
"redirect_uri": CLIENT_REDIRECT,
|
||||
"client_id": client_id,
|
||||
"code_verifier": pkce.verifier,
|
||||
},
|
||||
)
|
||||
assert issued.status_code == 200, issued.text
|
||||
return _issued_token(issued.json())
|
||||
|
||||
|
||||
def _toolset_rpc(gateway: Gateway, bearer: str, name: str, method: str, params: dict[str, object]) -> Outcome:
|
||||
def post(rpc_method: str, rpc_params: dict[str, object]) -> httpx.Response:
|
||||
return gateway.client.post(
|
||||
f"/toolset/{name}/mcp",
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": rpc_method, "params": rpc_params},
|
||||
headers={"Authorization": f"Bearer {bearer}", "Accept": "application/json, text/event-stream"},
|
||||
)
|
||||
|
||||
initialized: Final = _outcome_from_rpc(post("initialize", dict(INITIALIZE)))
|
||||
if not initialized.ok:
|
||||
return initialized
|
||||
return _outcome_from_rpc(post(method, params))
|
||||
|
||||
|
||||
def test_gateway_session_bearer_of_a_team_member_is_served_the_team_toolset_on_its_route(gateway: Gateway) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029sess" + uuid.uuid4().hex[:6]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
granted_name: Final = "lit6029g" + uuid.uuid4().hex[:8]
|
||||
withheld_name: Final = "lit6029w" + uuid.uuid4().hex[:8]
|
||||
granted_id: Final = create_toolset(scenario, ((server_id, "add"),), toolset_name=granted_name)
|
||||
create_toolset(scenario, ((server_id, "multiply"),), toolset_name=withheld_name)
|
||||
member: Final = scenario.member(scenario.team(object_permission={"mcp_toolsets": [granted_id]}))
|
||||
bearer: Final = _gateway_session_bearer(gateway, member)
|
||||
assert bearer.startswith("llm_session_"), bearer[:16]
|
||||
listed: Final = _toolset_rpc(gateway, bearer, granted_name, "tools/list", {})
|
||||
assert listed.tools == (f"{alias}-add",), listed.raw
|
||||
peer.drain()
|
||||
called: Final = _toolset_rpc(
|
||||
gateway, bearer, granted_name, "tools/call", {"name": f"{alias}-add", "arguments": {"a": 4, "b": 5}}
|
||||
)
|
||||
assert called.ok and called.text == "9", called.raw
|
||||
assert len(tool_calls(peer.drain())) == 1
|
||||
denied: Final = _toolset_rpc(gateway, bearer, withheld_name, "tools/list", {})
|
||||
assert denied.status == 403, denied.raw
|
||||
assert tool_calls(peer.drain()) == ()
|
||||
|
||||
|
||||
def test_resource_scoped_session_bearer_opens_a_team_toolset_inside_its_server_and_none_outside(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
inside: Final = "lit6029in" + uuid.uuid4().hex[:6]
|
||||
outside: Final = "lit6029out" + uuid.uuid4().hex[:6]
|
||||
inside_server: Final = register_mcp(scenario, peer, inside)
|
||||
outside_server: Final = register_mcp(scenario, peer, outside)
|
||||
inside_name: Final = "lit6029i" + uuid.uuid4().hex[:8]
|
||||
outside_name: Final = "lit6029o" + uuid.uuid4().hex[:8]
|
||||
inside_id: Final = create_toolset(scenario, ((inside_server, "add"),), toolset_name=inside_name)
|
||||
outside_id: Final = create_toolset(scenario, ((outside_server, "add"),), toolset_name=outside_name)
|
||||
member: Final = scenario.member(scenario.team(object_permission={"mcp_toolsets": [inside_id, outside_id]}))
|
||||
bearer: Final = _gateway_session_bearer(gateway, member, resource=f"{_base(gateway)}/{inside}/mcp")
|
||||
assert bearer.startswith("llm_session_"), bearer[:16]
|
||||
listed: Final = _toolset_rpc(gateway, bearer, inside_name, "tools/list", {})
|
||||
assert listed.tools == (f"{inside}-add",), listed.raw
|
||||
peer.drain()
|
||||
called: Final = _toolset_rpc(
|
||||
gateway, bearer, inside_name, "tools/call", {"name": f"{inside}-add", "arguments": {"a": 4, "b": 5}}
|
||||
)
|
||||
assert called.ok and called.text == "9", called.raw
|
||||
assert len(tool_calls(peer.drain())) == 1
|
||||
refused: Final = _toolset_rpc(gateway, bearer, outside_name, "tools/list", {})
|
||||
assert refused.status == 403, refused.raw
|
||||
assert tool_calls(peer.drain()) == ()
|
||||
|
|
|
|||
509
tests/integration/mcp/test_mcp_toolsets.py
Normal file
509
tests/integration/mcp/test_mcp_toolsets.py
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import Gateway, Scenario, object_value
|
||||
from integration._support.mcp import (
|
||||
INITIALIZE,
|
||||
Outcome,
|
||||
_outcome_from_rest,
|
||||
_outcome_from_rpc,
|
||||
mcp_peer,
|
||||
register_mcp,
|
||||
tool_calls,
|
||||
)
|
||||
from integration._support.mcp_grants import create_toolset
|
||||
from integration._support.process import owned_proxy
|
||||
|
||||
from litellm.models.user import LiteLLM_UserTable
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.auth_checks import LITELLM_SESSION_TOKEN_PREFIX, ExperimentalUIJWTToken
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_bearer_token
|
||||
|
||||
ADD: Final = {"a": 4, "b": 5}
|
||||
|
||||
|
||||
def _dashboard_ui_session_token(user_id: str) -> str:
|
||||
user: Final = LiteLLM_UserTable(user_id=user_id, user_role="internal_user", models=[])
|
||||
return ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token(user)
|
||||
|
||||
|
||||
def _toolset(scenario: Scenario, server_id: str, tool: str) -> tuple[str, str]:
|
||||
name: Final = "lit6029_" + uuid.uuid4().hex[:10]
|
||||
return create_toolset(scenario, ((server_id, tool),), toolset_name=name), name
|
||||
|
||||
|
||||
def _toolset_rpc(
|
||||
gateway: Gateway, headers: dict[str, str], name: str, method: str, params: dict[str, object]
|
||||
) -> Outcome:
|
||||
def post(rpc_method: str, rpc_params: dict[str, object]) -> httpx.Response:
|
||||
return gateway.client.post(
|
||||
f"/toolset/{name}/mcp",
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": rpc_method, "params": rpc_params},
|
||||
headers={**headers, "Accept": "application/json, text/event-stream"},
|
||||
)
|
||||
|
||||
initialized: Final = _outcome_from_rpc(post("initialize", dict(INITIALIZE)))
|
||||
if not initialized.ok:
|
||||
return initialized
|
||||
return _outcome_from_rpc(post(method, params))
|
||||
|
||||
|
||||
def _listed_toolset_ids(gateway: Gateway, headers: dict[str, str]) -> tuple[str, ...]:
|
||||
response: Final = gateway.client.get("/v1/mcp/toolset", headers=headers)
|
||||
assert response.status_code == 200, response.text
|
||||
return tuple(toolset["toolset_id"] for toolset in response.json())
|
||||
|
||||
|
||||
def _assert_team_grants_only(gateway: Gateway, team_id: str, key: str, toolset_id: str) -> None:
|
||||
team: Final = object_value(gateway.get("/team/info", {"team_id": team_id})["team_info"])
|
||||
assert object_value(team["object_permission"])["mcp_toolsets"] == [toolset_id], team
|
||||
key_info: Final = object_value(gateway.get("/key/info", {"key": key})["info"])
|
||||
assert key_info.get("object_permission") is None, f"key must carry no grant of its own: {key_info}"
|
||||
|
||||
|
||||
def test_team_granted_toolset_is_listed_and_served_to_a_team_key(gateway: Gateway) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029_" + uuid.uuid4().hex[:8]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
granted_id, granted_name = _toolset(scenario, server_id, "add")
|
||||
withheld_id, withheld_name = _toolset(scenario, server_id, "multiply")
|
||||
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
|
||||
key: Final = scenario.key(team_id=team_id)
|
||||
_assert_team_grants_only(gateway, team_id, key, granted_id)
|
||||
headers: Final = {"Authorization": f"Bearer {key}"}
|
||||
|
||||
assert _listed_toolset_ids(gateway, headers) == (granted_id,)
|
||||
detail: Final = gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=headers)
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert detail.json()["toolset_name"] == granted_name, detail.text
|
||||
withheld_detail: Final = gateway.client.get(f"/v1/mcp/toolset/{withheld_id}", headers=headers)
|
||||
assert withheld_detail.status_code == 403, withheld_detail.text
|
||||
|
||||
listed: Final = _toolset_rpc(gateway, headers, granted_name, "tools/list", {})
|
||||
assert listed.ok, listed.raw
|
||||
assert listed.tools == (f"{alias}-add",), listed.raw
|
||||
peer.drain()
|
||||
called: Final = _toolset_rpc(
|
||||
gateway, headers, granted_name, "tools/call", {"name": f"{alias}-add", "arguments": ADD}
|
||||
)
|
||||
assert called.ok and called.text == "9", called.raw
|
||||
assert len(tool_calls(peer.drain())) == 1
|
||||
denied: Final = _toolset_rpc(gateway, headers, withheld_name, "tools/list", {})
|
||||
assert denied.status == 403, denied.raw
|
||||
|
||||
|
||||
def test_dashboard_session_of_a_team_member_lists_the_team_granted_toolset(
|
||||
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029_" + uuid.uuid4().hex[:8]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
granted_id, granted_name = _toolset(scenario, server_id, "add")
|
||||
_toolset(scenario, server_id, "multiply")
|
||||
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
|
||||
user_id: Final = scenario.user(user_role="internal_user", teams=[team_id])
|
||||
user: Final = object_value(gateway.get("/user/info", {"user_id": user_id})["user_info"])
|
||||
assert user["teams"] == [team_id], user
|
||||
headers: Final = {"Authorization": f"Bearer {_dashboard_ui_session_token(user_id)}"}
|
||||
|
||||
assert _listed_toolset_ids(gateway, headers) == (granted_id,)
|
||||
detail: Final = gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=headers)
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert detail.json()["toolset_name"] == granted_name, detail.text
|
||||
|
||||
|
||||
def test_direct_grants_no_grants_and_admin_listing_are_unchanged_by_team_resolution(gateway: Gateway) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029_" + uuid.uuid4().hex[:8]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
granted_id, granted_name = _toolset(scenario, server_id, "add")
|
||||
withheld_id, withheld_name = _toolset(scenario, server_id, "multiply")
|
||||
direct: Final = {"Authorization": f"Bearer {scenario.key(object_permission={'mcp_toolsets': [granted_id]})}"}
|
||||
ungranted_team: Final = scenario.team()
|
||||
no_grant: Final = {"Authorization": f"Bearer {scenario.key(team_id=ungranted_team)}"}
|
||||
admin: Final = {"Authorization": f"Bearer {gateway.key}"}
|
||||
|
||||
assert _listed_toolset_ids(gateway, direct) == (granted_id,)
|
||||
assert _toolset_rpc(gateway, direct, granted_name, "tools/list", {}).tools == (f"{alias}-add",)
|
||||
assert _toolset_rpc(gateway, direct, withheld_name, "tools/list", {}).status == 403
|
||||
assert gateway.client.get(f"/v1/mcp/toolset/{withheld_id}", headers=direct).status_code == 403
|
||||
|
||||
assert _listed_toolset_ids(gateway, no_grant) == ()
|
||||
assert gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=no_grant).status_code == 403
|
||||
assert _toolset_rpc(gateway, no_grant, granted_name, "tools/list", {}).status == 403
|
||||
|
||||
assert {granted_id, withheld_id} <= set(_listed_toolset_ids(gateway, admin))
|
||||
assert gateway.client.get(f"/v1/mcp/toolset/{withheld_id}", headers=admin).status_code == 200
|
||||
assert _toolset_rpc(gateway, admin, withheld_name, "tools/list", {}).tools == (f"{alias}-multiply",)
|
||||
|
||||
|
||||
def test_a_key_with_its_own_toolset_grant_does_not_inherit_the_team_toolset(gateway: Gateway) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029_" + uuid.uuid4().hex[:8]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
own_id, own_name = _toolset(scenario, server_id, "add")
|
||||
team_only_id, team_only_name = _toolset(scenario, server_id, "multiply")
|
||||
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [own_id, team_only_id]})
|
||||
key: Final = scenario.key(team_id=team_id, object_permission={"mcp_toolsets": [own_id]})
|
||||
headers: Final = {"Authorization": f"Bearer {key}"}
|
||||
|
||||
assert _listed_toolset_ids(gateway, headers) == (own_id,)
|
||||
assert gateway.client.get(f"/v1/mcp/toolset/{team_only_id}", headers=headers).status_code == 403
|
||||
assert _toolset_rpc(gateway, headers, team_only_name, "tools/list", {}).status == 403
|
||||
assert _toolset_rpc(gateway, headers, own_name, "tools/list", {}).tools == (f"{alias}-add",)
|
||||
|
||||
|
||||
def _team_member_with_own_grant(scenario: Scenario, team_id: str, own_server_id: str) -> str:
|
||||
user_id: Final = scenario.user(user_role="internal_user", object_permission={"mcp_servers": [own_server_id]})
|
||||
scenario.gateway.post("/team/member_add", {"team_id": team_id, "member": {"role": "user", "user_id": user_id}})
|
||||
return user_id
|
||||
|
||||
|
||||
def test_dashboard_session_serves_the_team_toolset_despite_a_disjoint_grant_on_the_user_row(
|
||||
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The member's own row grants a different server outright. That grant must not cap the team's toolset
|
||||
to nothing, and the team's sibling toolset must not leak onto the granted toolset's route."""
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029_" + uuid.uuid4().hex[:8]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
own_server_id: Final = register_mcp(scenario, peer, "lit6029_own_" + uuid.uuid4().hex[:8])
|
||||
granted_id, granted_name = _toolset(scenario, server_id, "add")
|
||||
sibling_id, sibling_name = _toolset(scenario, server_id, "multiply")
|
||||
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id, sibling_id]})
|
||||
user_id: Final = _team_member_with_own_grant(scenario, team_id, own_server_id)
|
||||
headers: Final = {"Authorization": f"Bearer {_dashboard_ui_session_token(user_id)}"}
|
||||
|
||||
assert set(_listed_toolset_ids(gateway, headers)) == {granted_id, sibling_id}
|
||||
listed: Final = _toolset_rpc(gateway, headers, granted_name, "tools/list", {})
|
||||
assert listed.ok, listed.raw
|
||||
assert listed.tools == (f"{alias}-add",), listed.raw
|
||||
assert _toolset_rpc(gateway, headers, sibling_name, "tools/list", {}).tools == (f"{alias}-multiply",)
|
||||
peer.drain()
|
||||
called: Final = _toolset_rpc(
|
||||
gateway, headers, granted_name, "tools/call", {"name": f"{alias}-add", "arguments": ADD}
|
||||
)
|
||||
assert called.ok and called.text == "9", called.raw
|
||||
assert len(tool_calls(peer.drain())) == 1
|
||||
stranger: Final = {
|
||||
"Authorization": f"Bearer {_dashboard_ui_session_token(scenario.user(user_role='internal_user'))}"
|
||||
}
|
||||
assert _toolset_rpc(gateway, stranger, granted_name, "tools/list", {}).status == 403
|
||||
|
||||
|
||||
def test_a_member_removed_from_the_team_loses_its_toolset_on_the_dashboard_session(
|
||||
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029_" + uuid.uuid4().hex[:8]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
granted_id, granted_name = _toolset(scenario, server_id, "add")
|
||||
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
|
||||
user_id: Final = scenario.member(team_id)
|
||||
headers: Final = {"Authorization": f"Bearer {_dashboard_ui_session_token(user_id)}"}
|
||||
assert _listed_toolset_ids(gateway, headers) == (granted_id,)
|
||||
assert _toolset_rpc(gateway, headers, granted_name, "tools/list", {}).tools == (f"{alias}-add",)
|
||||
|
||||
gateway.post("/team/member_delete", {"team_id": team_id, "user_id": user_id})
|
||||
|
||||
assert _listed_toolset_ids(gateway, headers) == ()
|
||||
assert gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=headers).status_code == 403
|
||||
assert _toolset_rpc(gateway, headers, granted_name, "tools/list", {}).status == 403
|
||||
|
||||
|
||||
def _bearer(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _expired_dashboard_token(user_id: str) -> str:
|
||||
expired: Final = (datetime.now(timezone.utc) - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
||||
stale: Final = UserAPIKeyAuth(
|
||||
token="ui-token",
|
||||
key_name="ui-token",
|
||||
key_alias="ui-token",
|
||||
expires=expired + "+00:00",
|
||||
user_id=user_id,
|
||||
team_id="litellm-dashboard",
|
||||
models=[],
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
)
|
||||
return encrypt_bearer_token(stale.model_dump_json(exclude_none=True), prefix=LITELLM_SESSION_TOKEN_PREFIX)
|
||||
|
||||
|
||||
def _rest_list(gateway: Gateway, headers: dict[str, str], params: object) -> httpx.Response:
|
||||
return gateway.client.get("/mcp-rest/tools/list", headers=headers, params=params)
|
||||
|
||||
|
||||
def _rest_call(gateway: Gateway, headers: dict[str, str], name: str, server_id: str) -> Outcome:
|
||||
return _outcome_from_rest(
|
||||
gateway.client.post(
|
||||
"/mcp-rest/tools/call",
|
||||
headers=headers,
|
||||
json={"name": name, "arguments": dict(ADD), "server_id": server_id},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _route_tools(gateway: Gateway, headers: dict[str, str], name: str) -> Outcome:
|
||||
return _toolset_rpc(gateway, headers, name, "tools/list", {})
|
||||
|
||||
|
||||
def _route_call(gateway: Gateway, headers: dict[str, str], name: str, tool: str) -> Outcome:
|
||||
return _toolset_rpc(gateway, headers, name, "tools/call", {"name": tool, "arguments": dict(ADD)})
|
||||
|
||||
|
||||
def _strict_config(directory: Path) -> Path:
|
||||
base: Final = yaml.safe_load((Path(__file__).resolve().parents[1] / "proxy_config.yaml").read_text())
|
||||
strict: Final = {
|
||||
**base,
|
||||
"general_settings": {**base.get("general_settings", {}), "require_key_mcp_access_defined": True},
|
||||
}
|
||||
path: Final = directory / "require_key_mcp_access.yaml"
|
||||
path.write_text(yaml.safe_dump(strict))
|
||||
return path
|
||||
|
||||
|
||||
def test_mcp_rest_toolset_name_narrows_the_list_and_serves_the_call_for_a_team_key_and_a_dashboard_member(
|
||||
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029rest" + uuid.uuid4().hex[:6]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
granted_id, granted_name = _toolset(scenario, server_id, "add")
|
||||
_, withheld_name = _toolset(scenario, server_id, "multiply")
|
||||
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
|
||||
key: Final = scenario.key(team_id=team_id)
|
||||
member: Final = scenario.member(team_id)
|
||||
for headers in (_bearer(key), _bearer(_dashboard_ui_session_token(member))):
|
||||
listed: Final = _rest_list(gateway, headers, {"toolset_name": granted_name})
|
||||
assert listed.status_code == 200, listed.text
|
||||
listed_names: Final = tuple(tool["name"] for tool in listed.json()["tools"])
|
||||
assert len(listed_names) == 1 and listed_names[0].endswith("add"), listed.text
|
||||
denied: Final = _rest_list(gateway, headers, {"toolset_name": withheld_name})
|
||||
assert denied.status_code == 200 and denied.json()["tools"] == [], denied.text
|
||||
assert "does not have access to toolset" in denied.json()["message"], denied.text
|
||||
peer.drain()
|
||||
called: Final = _rest_call(gateway, headers, listed_names[0], server_id)
|
||||
assert called.ok and called.text == "9", called.raw
|
||||
assert len(tool_calls(peer.drain())) == 1
|
||||
|
||||
|
||||
def test_a_key_restricted_to_its_own_servers_does_not_inherit_the_team_toolset(gateway: Gateway) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029own" + uuid.uuid4().hex[:6]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
other_id: Final = register_mcp(scenario, peer, "lit6029other" + uuid.uuid4().hex[:6])
|
||||
granted_id, granted_name = _toolset(scenario, server_id, "add")
|
||||
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id], "mcp_servers": [other_id]})
|
||||
key: Final = scenario.key(team_id=team_id, object_permission={"mcp_servers": [other_id]})
|
||||
headers: Final = _bearer(key)
|
||||
assert _listed_toolset_ids(gateway, headers) == ()
|
||||
assert gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=headers).status_code == 403
|
||||
assert _route_tools(gateway, headers, granted_name).status == 403
|
||||
assert tool_calls(peer.drain()) == ()
|
||||
|
||||
|
||||
def test_a_team_with_an_empty_or_absent_toolset_grant_gives_its_keys_nothing(gateway: Gateway) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
server_id: Final = register_mcp(scenario, peer, "lit6029empty" + uuid.uuid4().hex[:6])
|
||||
granted_id, granted_name = _toolset(scenario, server_id, "add")
|
||||
teams: Final = (
|
||||
scenario.team(object_permission={"mcp_toolsets": []}),
|
||||
scenario.team(object_permission={"mcp_toolsets": None}),
|
||||
scenario.team(),
|
||||
)
|
||||
for team_id in teams:
|
||||
headers: Final = _bearer(scenario.key(team_id=team_id))
|
||||
assert _listed_toolset_ids(gateway, headers) == (), team_id
|
||||
assert gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=headers).status_code == 403
|
||||
assert _route_tools(gateway, headers, granted_name).status == 403, team_id
|
||||
assert tool_calls(peer.drain()) == ()
|
||||
|
||||
|
||||
def test_an_unknown_or_malformed_toolset_name_is_refused_without_peer_traffic_and_the_route_keeps_serving(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029bad" + uuid.uuid4().hex[:6]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
granted_id, granted_name = _toolset(scenario, server_id, "add")
|
||||
_, withheld_name = _toolset(scenario, server_id, "multiply")
|
||||
headers: Final = _bearer(scenario.key(team_id=scenario.team(object_permission={"mcp_toolsets": [granted_id]})))
|
||||
unknown: Final = "missing" + uuid.uuid4().hex[:8]
|
||||
assert _route_tools(gateway, headers, unknown).status == 404
|
||||
assert _rest_list(gateway, headers, {"toolset_name": unknown}).status_code == 404
|
||||
malformed: Final = (
|
||||
[("toolset_name", granted_name), ("toolset_name", withheld_name)],
|
||||
{"toolset_name": "x" * 5000},
|
||||
{"toolset_name": ""},
|
||||
{"toolset_name": granted_name + "\x00"},
|
||||
)
|
||||
statuses: Final = tuple(_rest_list(gateway, headers, params).status_code for params in malformed)
|
||||
assert all(status < 500 for status in statuses), statuses
|
||||
assert tool_calls(peer.drain()) == ()
|
||||
assert gateway.client.get("/health/liveliness").status_code == 200
|
||||
served: Final = _route_tools(gateway, headers, granted_name)
|
||||
assert served.tools == (f"{alias}-add",), served.raw
|
||||
|
||||
|
||||
def test_garbage_expired_and_tampered_credentials_are_refused_on_every_toolset_surface(
|
||||
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
server_id: Final = register_mcp(scenario, peer, "lit6029cred" + uuid.uuid4().hex[:6])
|
||||
granted_id, granted_name = _toolset(scenario, server_id, "add")
|
||||
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
|
||||
member: Final = scenario.member(team_id)
|
||||
forged: Final = (
|
||||
"sk-" + secrets.token_urlsafe(24),
|
||||
_expired_dashboard_token(member),
|
||||
"llm_session_" + secrets.token_urlsafe(32),
|
||||
)
|
||||
for bearer in forged:
|
||||
headers: Final = _bearer(bearer)
|
||||
listed: Final = gateway.client.get("/v1/mcp/toolset", headers=headers)
|
||||
assert listed.status_code == 401, (bearer[:12], listed.text)
|
||||
detail: Final = gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=headers)
|
||||
assert detail.status_code == 401, (bearer[:12], detail.text)
|
||||
routed: Final = _route_tools(gateway, headers, granted_name)
|
||||
assert routed.status == 401, (bearer[:12], routed.raw)
|
||||
rest: Final = _rest_list(gateway, headers, {"toolset_name": granted_name})
|
||||
assert rest.status_code == 401, (bearer[:12], rest.text)
|
||||
assert peer.drain() == ()
|
||||
|
||||
|
||||
def test_a_dashboard_member_of_a_deleted_team_loses_the_toolset_while_a_direct_grant_survives(
|
||||
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029gone" + uuid.uuid4().hex[:6]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
team_id_, team_name = _toolset(scenario, server_id, "add")
|
||||
own_id, own_name = _toolset(scenario, server_id, "multiply")
|
||||
doomed: Final = scenario.gateway.post(
|
||||
"/team/new",
|
||||
{"team_alias": f"integration-{uuid.uuid4().hex}", "object_permission": {"mcp_toolsets": [team_id_]}},
|
||||
)
|
||||
doomed_team: Final = str(doomed["team_id"])
|
||||
member: Final = scenario.user(user_role="internal_user", teams=[doomed_team])
|
||||
granted: Final = scenario.user(
|
||||
user_role="internal_user", teams=[doomed_team], object_permission={"mcp_toolsets": [own_id]}
|
||||
)
|
||||
member_headers: Final = _bearer(_dashboard_ui_session_token(member))
|
||||
granted_headers: Final = _bearer(_dashboard_ui_session_token(granted))
|
||||
assert _listed_toolset_ids(gateway, member_headers) == (team_id_,)
|
||||
assert set(_listed_toolset_ids(gateway, granted_headers)) == {team_id_, own_id}
|
||||
scenario.delete_team(doomed_team)
|
||||
assert _listed_toolset_ids(gateway, member_headers) == ()
|
||||
assert gateway.client.get(f"/v1/mcp/toolset/{team_id_}", headers=member_headers).status_code == 403
|
||||
assert _route_tools(gateway, member_headers, team_name).status == 403
|
||||
assert _listed_toolset_ids(gateway, granted_headers) == (own_id,)
|
||||
assert _route_tools(gateway, granted_headers, team_name).status == 403
|
||||
assert _route_tools(gateway, granted_headers, own_name).tools == (f"{alias}-multiply",)
|
||||
peer.drain()
|
||||
kept: Final = _route_call(gateway, granted_headers, own_name, f"{alias}-multiply")
|
||||
assert kept.ok and kept.text == "20", kept.raw
|
||||
assert [call["body"]["params"]["name"] for call in tool_calls(peer.drain())] == ["multiply"]
|
||||
|
||||
|
||||
def test_require_key_mcp_access_defined_stops_key_inheritance_but_not_the_dashboard_member(
|
||||
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
|
||||
with (
|
||||
owned_proxy(gateway, tmp_path, {}, config=_strict_config(tmp_path), workers=2) as strict,
|
||||
mcp_peer() as peer,
|
||||
strict.scenario() as scenario,
|
||||
):
|
||||
alias: Final = "lit6029strict" + uuid.uuid4().hex[:6]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
granted_id, granted_name = _toolset(scenario, server_id, "add")
|
||||
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
|
||||
inheriting: Final = _bearer(scenario.key(team_id=team_id))
|
||||
own: Final = _bearer(scenario.key(team_id=team_id, object_permission={"mcp_toolsets": [granted_id]}))
|
||||
member: Final = _bearer(_dashboard_ui_session_token(scenario.member(team_id)))
|
||||
assert _listed_toolset_ids(strict, inheriting) == ()
|
||||
assert _route_tools(strict, inheriting, granted_name).status == 403
|
||||
assert _listed_toolset_ids(strict, own) == (granted_id,)
|
||||
assert _route_tools(strict, own, granted_name).tools == (f"{alias}-add",)
|
||||
assert _listed_toolset_ids(strict, member) == (granted_id,)
|
||||
assert _route_tools(strict, member, granted_name).tools == (f"{alias}-add",)
|
||||
peer.drain()
|
||||
called: Final = _route_call(strict, member, granted_name, f"{alias}-add")
|
||||
assert called.ok and called.text == "9", called.raw
|
||||
assert len(tool_calls(peer.drain())) == 1
|
||||
|
||||
|
||||
def test_a_key_with_only_a_vector_store_grant_still_inherits_the_team_toolset(gateway: Gateway) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029vs" + uuid.uuid4().hex[:6]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
granted_id, granted_name = _toolset(scenario, server_id, "add")
|
||||
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
|
||||
headers: Final = _bearer(
|
||||
scenario.key(team_id=team_id, object_permission={"vector_stores": ["vs-" + uuid.uuid4().hex[:8]]})
|
||||
)
|
||||
assert _listed_toolset_ids(gateway, headers) == (granted_id,)
|
||||
assert _route_tools(gateway, headers, granted_name).tools == (f"{alias}-add",)
|
||||
peer.drain()
|
||||
called: Final = _route_call(gateway, headers, granted_name, f"{alias}-add")
|
||||
assert called.ok and called.text == "9", called.raw
|
||||
assert len(tool_calls(peer.drain())) == 1
|
||||
|
||||
|
||||
def test_a_member_added_after_the_team_was_cached_sees_the_toolset_on_every_following_request(
|
||||
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
server_id: Final = register_mcp(scenario, peer, "lit6029cache" + uuid.uuid4().hex[:6])
|
||||
granted_id, _ = _toolset(scenario, server_id, "add")
|
||||
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
|
||||
user_id: Final = scenario.user(user_role="internal_user")
|
||||
headers: Final = _bearer(_dashboard_ui_session_token(user_id))
|
||||
warm: Final = _bearer(scenario.key(team_id=team_id))
|
||||
assert tuple(_listed_toolset_ids(gateway, warm) for _ in range(4)) == ((granted_id,),) * 4
|
||||
assert tuple(_listed_toolset_ids(gateway, headers) for _ in range(4)) == ((),) * 4
|
||||
added: Final = gateway.request(
|
||||
"POST", "/team/member_add", {"team_id": team_id, "member": {"user_id": user_id, "role": "user"}}
|
||||
)
|
||||
assert added.status_code == 200, added.text
|
||||
listings: Final = tuple(_listed_toolset_ids(gateway, headers) for _ in range(8))
|
||||
assert listings == ((granted_id,),) * 8, listings
|
||||
|
||||
|
||||
def test_a_member_of_two_teams_sees_the_union_and_each_route_stays_narrowed_to_its_own_toolset(
|
||||
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "lit6029two" + uuid.uuid4().hex[:6]
|
||||
server_id: Final = register_mcp(scenario, peer, alias)
|
||||
first_id, first_name = _toolset(scenario, server_id, "add")
|
||||
second_id, second_name = _toolset(scenario, server_id, "multiply")
|
||||
teams: Final = (
|
||||
scenario.team(object_permission={"mcp_toolsets": [first_id]}),
|
||||
scenario.team(object_permission={"mcp_toolsets": [second_id]}),
|
||||
)
|
||||
headers: Final = _bearer(
|
||||
_dashboard_ui_session_token(scenario.user(user_role="internal_user", teams=list(teams)))
|
||||
)
|
||||
assert set(_listed_toolset_ids(gateway, headers)) == {first_id, second_id}
|
||||
assert _route_tools(gateway, headers, first_name).tools == (f"{alias}-add",)
|
||||
assert _route_tools(gateway, headers, second_name).tools == (f"{alias}-multiply",)
|
||||
peer.drain()
|
||||
crossed: Final = _route_call(gateway, headers, first_name, f"{alias}-multiply")
|
||||
assert not crossed.ok, crossed.raw
|
||||
assert tool_calls(peer.drain()) == ()
|
||||
|
|
@ -0,0 +1,966 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from queue import SimpleQueue
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
import anthropic
|
||||
import httpx
|
||||
import openai
|
||||
import psutil
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import (
|
||||
Gateway,
|
||||
Scenario,
|
||||
eventually,
|
||||
gateway_from_environment,
|
||||
object_value,
|
||||
string_value,
|
||||
)
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.process import owned_proxy_process
|
||||
from integration._support.upstream import _aws_event_frame
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_if_encrypted_with
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
_MODEL_ID: Final = "anthropic.claude-3-haiku-20240307-v1:0"
|
||||
_CONVERSE_MODEL: Final = f"bedrock/converse/{_MODEL_ID}"
|
||||
_INVOKE_MODEL: Final = f"bedrock/invoke/{_MODEL_ID}"
|
||||
_CONVERSE_TARGET: Final = f"/model/{_MODEL_ID}/converse"
|
||||
_STREAM_TARGET: Final = f"/model/{_MODEL_ID}/converse-stream"
|
||||
_INVOKE_TARGET: Final = f"/model/{_MODEL_ID}/invoke"
|
||||
_ANSWER: Final = "bedrock missing content control"
|
||||
_RESPONSE: Final = json.dumps(
|
||||
{
|
||||
"output": {"message": {"role": "assistant", "content": [{"text": _ANSWER}]}},
|
||||
"stopReason": "end_turn",
|
||||
"usage": {"inputTokens": 11, "outputTokens": 4, "totalTokens": 15},
|
||||
"metrics": {"latencyMs": 1},
|
||||
}
|
||||
).encode()
|
||||
_EVENT_STREAM: Final = "application/vnd.amazon.eventstream"
|
||||
_STREAM_EVENTS: Final[tuple[tuple[str, dict[str, JsonValue]], ...]] = (
|
||||
("messageStart", {"role": "assistant"}),
|
||||
("contentBlockDelta", {"delta": {"text": _ANSWER}, "contentBlockIndex": 0}),
|
||||
("messageStop", {"stopReason": "end_turn"}),
|
||||
("metadata", {"usage": {"inputTokens": 11, "outputTokens": 4, "totalTokens": 15}}),
|
||||
)
|
||||
_STREAM_BYTES: Final = b"".join(_aws_event_frame(kind, payload, "sc", "u") for kind, payload in _STREAM_EVENTS)
|
||||
_DEFAULT_CONTINUE: Final = "Please continue."
|
||||
_DEPLOYMENT_CONTINUE: Final = "Deployment says continue."
|
||||
_DEPLOYMENT_CONTINUE_MESSAGE: Final[dict[str, JsonValue]] = {"role": "user", "content": _DEPLOYMENT_CONTINUE}
|
||||
_NO_NON_SYSTEM_MESSAGE: Final = "bedrock requires at least one non-system message"
|
||||
_JSON: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]")
|
||||
_CALL_INDEX: Final = re.compile(r"call-[0-9a-f]{32}-(\d+)")
|
||||
_QUESTION: Final = "What is the capital of France?"
|
||||
_ANSWERED: Final = "Paris."
|
||||
_FOLLOW_UP: Final = "And the capital of Spain?"
|
||||
_QUESTION_TURN: Final[dict[str, JsonValue]] = {"role": "user", "content": _QUESTION}
|
||||
_ANSWERED_TURN: Final[dict[str, JsonValue]] = {"role": "assistant", "content": _ANSWERED}
|
||||
_FOLLOW_UP_TURN: Final[dict[str, JsonValue]] = {"role": "user", "content": _FOLLOW_UP}
|
||||
_NO_CONTENT_USER: Final[dict[str, JsonValue]] = {"role": "user"}
|
||||
_NULL_CONTENT_USER: Final[dict[str, JsonValue]] = {"role": "user", "content": None}
|
||||
_EMPTY_CONTENT_USER: Final[dict[str, JsonValue]] = {"role": "user", "content": ""}
|
||||
_NO_CONTENT_SYSTEM: Final[dict[str, JsonValue]] = {"role": "system"}
|
||||
_NULL_CONTENT_SYSTEM: Final[dict[str, JsonValue]] = {"role": "system", "content": None}
|
||||
_NO_CONTENT_ASSISTANT: Final[dict[str, JsonValue]] = {"role": "assistant"}
|
||||
_TOOL_CALL_TURN: Final[dict[str, JsonValue]] = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": '{"city": "Boston"}'}}
|
||||
],
|
||||
}
|
||||
_NO_CONTENT_TOOL: Final[dict[str, JsonValue]] = {"role": "tool", "tool_call_id": "call_1"}
|
||||
_TOOLS: Final[tuple[dict[str, JsonValue], ...]] = (
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
},
|
||||
},
|
||||
)
|
||||
_CONVERSE_QUESTION: Final[dict[str, JsonValue]] = {"role": "user", "content": [{"text": _QUESTION}]}
|
||||
_CONVERSE_ANSWERED: Final[dict[str, JsonValue]] = {"role": "assistant", "content": [{"text": _ANSWERED}]}
|
||||
_CONVERSE_FOLLOW_UP: Final[dict[str, JsonValue]] = {"role": "user", "content": [{"text": _FOLLOW_UP}]}
|
||||
_CONVERSE_TOOL_USE: Final[dict[str, JsonValue]] = {
|
||||
"role": "assistant",
|
||||
"content": [{"toolUse": {"toolUseId": "call_1", "name": "get_weather", "input": {"city": "Boston"}}}],
|
||||
}
|
||||
_CONVERSE_EMPTY_TOOL_RESULT: Final[dict[str, JsonValue]] = {
|
||||
"role": "user",
|
||||
"content": [{"toolResult": {"toolUseId": "call_1", "content": []}}],
|
||||
}
|
||||
_NEUTRALIZED_TOOL_CALL: Final[dict[str, JsonValue]] = {
|
||||
"role": "assistant",
|
||||
"content": [{"text": '[tool call call_1: get_weather({"city": "Boston"})]'}],
|
||||
}
|
||||
_NEUTRALIZED_TOOL_RESULT: Final[dict[str, JsonValue]] = {
|
||||
"role": "user",
|
||||
"content": [{"text": "[tool result for call_1: <non-text tool result omitted>]"}],
|
||||
}
|
||||
_EXTRA: Final[dict[str, JsonValue]] = {"num_retries": 0, "cache": {"no-cache": True}}
|
||||
_SIGNING_KEY: Final = os.environ.get("LITELLM_SALT_KEY", "sk-integration-salt")
|
||||
_AWS: Final[dict[str, JsonValue]] = {
|
||||
"aws_access_key_id": "AKIASCRIPTEDPROVIDER",
|
||||
"aws_secret_access_key": "scripted-secret",
|
||||
"aws_region_name": "us-east-1",
|
||||
}
|
||||
_PLAIN: Final = "bedrock-missing-content-plain"
|
||||
_CONTINUE: Final = "bedrock-missing-content-continue"
|
||||
|
||||
Endpoint = Literal["chat", "responses"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Call:
|
||||
endpoint: Endpoint
|
||||
stream: bool
|
||||
user: str
|
||||
index: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Served:
|
||||
call: _Call
|
||||
status: int
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ModifyParamsCell:
|
||||
row: str
|
||||
model: str
|
||||
messages: tuple[dict[str, JsonValue], ...]
|
||||
expected: tuple[dict[str, JsonValue], ...]
|
||||
extra: Mapping[str, JsonValue] = MappingProxyType({})
|
||||
|
||||
|
||||
def _continue_turn(text: str) -> dict[str, JsonValue]:
|
||||
return {"role": "user", "content": [{"text": text}]}
|
||||
|
||||
|
||||
_MODIFY_PARAMS_CELLS: Final = (
|
||||
_ModifyParamsCell("r11", _PLAIN, (_NO_CONTENT_USER,), (_continue_turn(_DEFAULT_CONTINUE),)),
|
||||
_ModifyParamsCell(
|
||||
"r12",
|
||||
_PLAIN,
|
||||
(_QUESTION_TURN, _ANSWERED_TURN, _NULL_CONTENT_USER),
|
||||
(_CONVERSE_QUESTION, _CONVERSE_ANSWERED, _continue_turn(_DEFAULT_CONTINUE)),
|
||||
),
|
||||
_ModifyParamsCell(
|
||||
"r13",
|
||||
_PLAIN,
|
||||
(_QUESTION_TURN, _TOOL_CALL_TURN, _NO_CONTENT_TOOL),
|
||||
(_CONVERSE_QUESTION, _CONVERSE_TOOL_USE, _CONVERSE_EMPTY_TOOL_RESULT),
|
||||
MappingProxyType({"tools": list(_TOOLS)}),
|
||||
),
|
||||
_ModifyParamsCell("r14", _PLAIN, (_NO_CONTENT_SYSTEM, _QUESTION_TURN), (_CONVERSE_QUESTION,)),
|
||||
_ModifyParamsCell("r15", _CONTINUE, (_NO_CONTENT_USER,), (_continue_turn(_DEPLOYMENT_CONTINUE),)),
|
||||
)
|
||||
|
||||
|
||||
def _converse_peer(request: Request) -> Reply:
|
||||
if unquote(request.target) == _STREAM_TARGET:
|
||||
return Reply(body=_STREAM_BYTES, content_type=_EVENT_STREAM)
|
||||
return Reply(body=_RESPONSE)
|
||||
|
||||
|
||||
def _scripted_error(status: int, message: str) -> Reply:
|
||||
return Reply(status=status, body=json.dumps({"message": message}).encode())
|
||||
|
||||
|
||||
def _converse_deployment(scenario: Scenario, wire: Wire, **extra: JsonValue) -> str:
|
||||
return scenario.model(model=_CONVERSE_MODEL, api_base=wire.url, **_AWS, **extra)
|
||||
|
||||
|
||||
def _auth(gateway: Gateway) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {gateway.key}"}
|
||||
|
||||
|
||||
def _proxy_url(gateway: Gateway) -> str:
|
||||
return str(gateway.client.base_url).rstrip("/")
|
||||
|
||||
|
||||
def _chat(
|
||||
model: str,
|
||||
messages: Sequence[Mapping[str, JsonValue]],
|
||||
*,
|
||||
stream: bool = False,
|
||||
cached: bool = False,
|
||||
**extra: JsonValue,
|
||||
) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"model": model,
|
||||
"messages": [dict(message) for message in messages],
|
||||
"max_tokens": 16,
|
||||
"stream": stream,
|
||||
"num_retries": 0,
|
||||
**({} if cached else {"cache": {"no-cache": True}}),
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _post_chat(gateway: Gateway, body: Mapping[str, JsonValue]) -> httpx.Response:
|
||||
return gateway.request("POST", "/v1/chat/completions", body)
|
||||
|
||||
|
||||
def _chat_answer(response: httpx.Response) -> str:
|
||||
assert response.status_code == 200, response.text
|
||||
body: Final = response.json()
|
||||
assert body["choices"][0]["message"]["content"] == _ANSWER, response.text
|
||||
return body["id"]
|
||||
|
||||
|
||||
def _only_received(wire: Wire) -> tuple[str, dict[str, JsonValue]]:
|
||||
(request,) = wire.drain()
|
||||
return unquote(request.target), json.loads(request.body)
|
||||
|
||||
|
||||
def _sse_payloads(lines: Iterable[str]) -> tuple[dict[str, JsonValue], ...]:
|
||||
return tuple(json.loads(line[6:]) for line in lines if line.startswith("data: ") and line != "data: [DONE]")
|
||||
|
||||
|
||||
def _stream_lines(gateway: Gateway, path: str, body: Mapping[str, JsonValue]) -> tuple[str, ...]:
|
||||
with gateway.client.stream("POST", path, json=body, headers=_auth(gateway)) as response:
|
||||
lines: Final = tuple(line for line in response.iter_lines() if line)
|
||||
status_code: Final = response.status_code
|
||||
assert status_code == 200, "\n".join(lines)
|
||||
return lines
|
||||
|
||||
|
||||
def _chat_stream_text(chunks: Iterable[dict[str, JsonValue]]) -> str:
|
||||
return "".join(chunk["choices"][0]["delta"].get("content") or "" for chunk in chunks if chunk["choices"])
|
||||
|
||||
|
||||
def _spend_row(request_id: str) -> dict[str, JsonValue]:
|
||||
(row,) = eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT request_id, status, call_type, end_user FROM "LiteLLM_SpendLogs" WHERE request_id=%s',
|
||||
(request_id,),
|
||||
),
|
||||
lambda found: len(found) >= 1,
|
||||
seconds=70,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def _success_rows(prefix: str, expected: int) -> tuple[dict[str, JsonValue], ...]:
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT request_id, call_type, end_user FROM "LiteLLM_SpendLogs" WHERE end_user LIKE %s AND status=%s',
|
||||
(f"{prefix}%", "success"),
|
||||
),
|
||||
lambda found: len(found) >= expected,
|
||||
seconds=70,
|
||||
)
|
||||
assert len(rows) == expected, rows
|
||||
assert len({row["request_id"] for row in rows}) == expected, rows
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
def _converse_cell(gateway: Gateway, wire: Wire, body: Mapping[str, JsonValue]) -> tuple[str, dict[str, JsonValue]]:
|
||||
identity: Final = _chat_answer(_post_chat(gateway, body))
|
||||
target, received = _only_received(wire)
|
||||
assert target == _CONVERSE_TARGET, target
|
||||
assert _spend_row(identity)["status"] == "success"
|
||||
return identity, received
|
||||
|
||||
|
||||
def _owned_config(wire: Wire, directory: Path, *, modify_params: bool) -> Path:
|
||||
base: Final = _JSON.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
|
||||
deployment: Final[dict[str, JsonValue]] = {
|
||||
"model": _CONVERSE_MODEL,
|
||||
"api_base": wire.url,
|
||||
"api_key": "integration-provider-key",
|
||||
**_AWS,
|
||||
}
|
||||
config: Final[dict[str, JsonValue]] = {
|
||||
**base,
|
||||
"model_list": [
|
||||
{"model_name": _PLAIN, "litellm_params": deployment},
|
||||
{
|
||||
"model_name": _CONTINUE,
|
||||
"litellm_params": {**deployment, "user_continue_message": _DEPLOYMENT_CONTINUE_MESSAGE},
|
||||
},
|
||||
],
|
||||
"litellm_settings": {**_JSON.validate_python(base["litellm_settings"]), "modify_params": modify_params},
|
||||
"router_settings": {**_JSON.validate_python(base["router_settings"]), "num_retries": 0},
|
||||
}
|
||||
path: Final = directory / f"bedrock-missing-content-{'modify-params' if modify_params else 'plain'}.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def modify_params_proxy(tmp_path_factory: pytest.TempPathFactory) -> Iterator[tuple[Gateway, Wire]]:
|
||||
directory: Final = tmp_path_factory.mktemp("bedrock-modify-params")
|
||||
with gateway_from_environment() as gateway, wire_server(_converse_peer) as wire:
|
||||
config: Final = _owned_config(wire, directory, modify_params=True)
|
||||
with owned_proxy_process(gateway, directory, {}, config=config, workers=2) as owned:
|
||||
yield owned.gateway, wire
|
||||
|
||||
|
||||
def test_r01_openai_sync_lone_user_without_content_sends_no_converse_block(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
with openai.OpenAI(base_url=f"{_proxy_url(gateway)}/v1", api_key=gateway.key, max_retries=0) as client:
|
||||
completion: Final = client.chat.completions.create(
|
||||
model=model, messages=[_NO_CONTENT_USER], max_tokens=16, extra_body=_EXTRA
|
||||
)
|
||||
assert completion.choices[0].message.content == _ANSWER, completion
|
||||
target, received = _only_received(wire)
|
||||
assert target == _CONVERSE_TARGET, target
|
||||
assert received["messages"] == [] and "system" not in received, received
|
||||
assert _spend_row(completion.id)["status"] == "success"
|
||||
|
||||
|
||||
async def test_r02_openai_async_user_with_null_content_after_an_assistant_turn_keeps_the_earlier_turns(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
async with openai.AsyncOpenAI(
|
||||
base_url=f"{_proxy_url(gateway)}/v1", api_key=gateway.key, max_retries=0
|
||||
) as client:
|
||||
completion: Final = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[_QUESTION_TURN, _ANSWERED_TURN, _NULL_CONTENT_USER],
|
||||
max_tokens=16,
|
||||
extra_body=_EXTRA,
|
||||
)
|
||||
assert completion.choices[0].message.content == _ANSWER, completion
|
||||
target, received = _only_received(wire)
|
||||
assert target == _CONVERSE_TARGET, target
|
||||
assert received["messages"] == [_CONVERSE_QUESTION, _CONVERSE_ANSWERED], received
|
||||
assert _spend_row(completion.id)["status"] == "success"
|
||||
|
||||
|
||||
def test_r03_httpx_raw_sse_user_without_content_after_an_assistant_turn_streams_to_done(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
lines: Final = _stream_lines(
|
||||
gateway,
|
||||
"/v1/chat/completions",
|
||||
_chat(model, (_QUESTION_TURN, _ANSWERED_TURN, _NO_CONTENT_USER), stream=True),
|
||||
)
|
||||
assert lines[-1] == "data: [DONE]", lines
|
||||
chunks: Final = _sse_payloads(lines)
|
||||
assert _chat_stream_text(chunks) == _ANSWER, lines
|
||||
(identity,) = {chunk["id"] for chunk in chunks}
|
||||
target, received = _only_received(wire)
|
||||
assert target == _STREAM_TARGET, target
|
||||
assert received["messages"] == [_CONVERSE_QUESTION, _CONVERSE_ANSWERED], received
|
||||
assert _spend_row(identity)["status"] == "success"
|
||||
|
||||
|
||||
async def test_r04_openai_async_stream_tool_turn_without_content_sends_an_empty_tool_result(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
async with openai.AsyncOpenAI(
|
||||
base_url=f"{_proxy_url(gateway)}/v1", api_key=gateway.key, max_retries=0
|
||||
) as client:
|
||||
stream: Final = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[_QUESTION_TURN, _TOOL_CALL_TURN, _NO_CONTENT_TOOL],
|
||||
tools=list(_TOOLS),
|
||||
max_tokens=16,
|
||||
stream=True,
|
||||
extra_body=_EXTRA,
|
||||
)
|
||||
chunks: Final = [chunk async for chunk in stream]
|
||||
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) == _ANSWER, chunks
|
||||
(identity,) = {chunk.id for chunk in chunks}
|
||||
target, received = _only_received(wire)
|
||||
assert target == _STREAM_TARGET, target
|
||||
assert received["messages"] == [_CONVERSE_QUESTION, _CONVERSE_TOOL_USE, _CONVERSE_EMPTY_TOOL_RESULT], received
|
||||
assert received["toolConfig"]["tools"][0]["toolSpec"]["name"] == "get_weather", received
|
||||
assert _spend_row(identity)["status"] == "success"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system_turn", (_NO_CONTENT_SYSTEM, _NULL_CONTENT_SYSTEM), ids=("r05", "r06"))
|
||||
def test_r05_r06_leading_system_without_content_is_dropped(gateway: Gateway, system_turn: dict[str, JsonValue]) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
_, received = _converse_cell(gateway, wire, _chat(model, (system_turn, _QUESTION_TURN)))
|
||||
assert "system" not in received, received
|
||||
assert received["messages"] == [_CONVERSE_QUESTION], received
|
||||
|
||||
|
||||
def test_r07_mid_conversation_system_without_content_is_dropped(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
_, received = _converse_cell(
|
||||
gateway, wire, _chat(model, (_QUESTION_TURN, _ANSWERED_TURN, _NO_CONTENT_SYSTEM, _FOLLOW_UP_TURN))
|
||||
)
|
||||
assert "system" not in received, received
|
||||
assert received["messages"] == [_CONVERSE_QUESTION, _CONVERSE_ANSWERED, _CONVERSE_FOLLOW_UP], received
|
||||
|
||||
|
||||
def test_r08_assistant_without_content_between_two_user_turns_merges_them(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
_, received = _converse_cell(
|
||||
gateway, wire, _chat(model, (_QUESTION_TURN, _NO_CONTENT_ASSISTANT, _FOLLOW_UP_TURN))
|
||||
)
|
||||
assert received["messages"] == [{"role": "user", "content": [{"text": _QUESTION}, {"text": _FOLLOW_UP}]}], (
|
||||
received
|
||||
)
|
||||
|
||||
|
||||
def test_r09_lone_user_with_empty_string_content_sends_no_converse_block(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
_, received = _converse_cell(gateway, wire, _chat(model, (_EMPTY_CONTENT_USER,)))
|
||||
assert received["messages"] == [], received
|
||||
|
||||
|
||||
def test_r10_empty_null_and_missing_content_produce_byte_identical_converse_bodies(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
identities: Final = tuple(
|
||||
_chat_answer(_post_chat(gateway, _chat(model, (turn,))))
|
||||
for turn in (_EMPTY_CONTENT_USER, _NULL_CONTENT_USER, _NO_CONTENT_USER)
|
||||
)
|
||||
assert len(set(identities)) == 3, identities
|
||||
received: Final = wire.drain()
|
||||
assert [unquote(request.target) for request in received] == [_CONVERSE_TARGET] * 3, received
|
||||
assert len({request.body for request in received}) == 1, received
|
||||
assert json.loads(received[0].body)["messages"] == [], received
|
||||
for identity in identities:
|
||||
assert _spend_row(identity)["status"] == "success"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cell", _MODIFY_PARAMS_CELLS, ids=lambda cell: cell.row)
|
||||
def test_r11_to_r15_modify_params_fills_the_missing_user_content(
|
||||
cell: _ModifyParamsCell, modify_params_proxy: tuple[Gateway, Wire]
|
||||
) -> None:
|
||||
gateway, wire = modify_params_proxy
|
||||
_, received = _converse_cell(gateway, wire, _chat(cell.model, cell.messages, **cell.extra))
|
||||
assert received["messages"] == list(cell.expected), received
|
||||
assert "system" not in received, received
|
||||
|
||||
|
||||
def test_r16_deployment_user_continue_message_fills_the_missing_content_without_modify_params(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire, user_continue_message=_DEPLOYMENT_CONTINUE_MESSAGE)
|
||||
_, received = _converse_cell(gateway, wire, _chat(model, (_NO_CONTENT_USER,)))
|
||||
assert received["messages"] == [_continue_turn(_DEPLOYMENT_CONTINUE)], received
|
||||
|
||||
|
||||
def test_r17_anthropic_sync_lone_user_without_content_is_rejected_before_any_peer_call(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
with anthropic.Anthropic(base_url=_proxy_url(gateway), api_key=gateway.key, max_retries=0) as client:
|
||||
with pytest.raises(anthropic.BadRequestError, match=_NO_NON_SYSTEM_MESSAGE):
|
||||
client.messages.create(model=model, max_tokens=16, messages=[_NO_CONTENT_USER], extra_body=_EXTRA)
|
||||
assert wire.drain() == ()
|
||||
|
||||
|
||||
async def test_r18_anthropic_async_stream_user_without_content_after_an_assistant_turn_keeps_the_earlier_turns(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
async with anthropic.AsyncAnthropic(base_url=_proxy_url(gateway), api_key=gateway.key, max_retries=0) as client:
|
||||
async with client.messages.stream(
|
||||
model=model,
|
||||
max_tokens=16,
|
||||
messages=[_QUESTION_TURN, _ANSWERED_TURN, _NO_CONTENT_USER],
|
||||
extra_body=_EXTRA,
|
||||
) as stream:
|
||||
events: Final = [event async for event in stream]
|
||||
final: Final = await stream.get_final_message()
|
||||
(started,) = tuple(event for event in events if event.type == "message_start")
|
||||
assert final.content[0].text == _ANSWER, final
|
||||
assert final.id == started.message.id, (final.id, started.message.id)
|
||||
target, received = _only_received(wire)
|
||||
assert target == _STREAM_TARGET, target
|
||||
assert received["messages"] == [_CONVERSE_QUESTION, _CONVERSE_ANSWERED], received
|
||||
assert _spend_row(final.id)["call_type"] == "anthropic_messages"
|
||||
|
||||
|
||||
def test_r19_anthropic_native_invoke_forwards_the_turn_verbatim_and_relays_the_scripted_400(gateway: Gateway) -> None:
|
||||
with (
|
||||
wire_server(lambda _: _scripted_error(400, "scripted invoke validation")) as wire,
|
||||
gateway.scenario() as scenario,
|
||||
):
|
||||
model: Final = scenario.model(
|
||||
model=_INVOKE_MODEL, api_key=None, aws_bedrock_runtime_endpoint=wire.url, api_base=wire.url, **_AWS
|
||||
)
|
||||
with anthropic.Anthropic(base_url=_proxy_url(gateway), api_key=gateway.key, max_retries=0) as client:
|
||||
with pytest.raises(anthropic.BadRequestError, match="scripted invoke validation"):
|
||||
client.messages.create(model=model, max_tokens=16, messages=[_NO_CONTENT_USER], extra_body=_EXTRA)
|
||||
target, received = _only_received(wire)
|
||||
assert target == _INVOKE_TARGET, target
|
||||
assert received["messages"] == [_NO_CONTENT_USER], received
|
||||
|
||||
|
||||
def test_r20_responses_lone_input_item_without_content_is_rejected_before_any_peer_call(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
response: Final = gateway.request(
|
||||
"POST", "/v1/responses", {"model": model, "input": [_NO_CONTENT_USER], **_EXTRA}
|
||||
)
|
||||
assert response.status_code == 400, response.text
|
||||
assert _NO_NON_SYSTEM_MESSAGE in response.text, response.text
|
||||
assert wire.drain() == ()
|
||||
|
||||
|
||||
def test_r21_responses_stream_input_item_without_content_after_an_assistant_item_keeps_the_earlier_turns(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
marker: Final = f"call-{uuid.uuid4().hex}"
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
lines: Final = _stream_lines(
|
||||
gateway,
|
||||
"/v1/responses",
|
||||
{
|
||||
"model": model,
|
||||
"input": [_QUESTION_TURN, _ANSWERED_TURN, _NO_CONTENT_USER],
|
||||
"stream": True,
|
||||
"user": marker,
|
||||
**_EXTRA,
|
||||
},
|
||||
)
|
||||
events: Final = _sse_payloads(lines)
|
||||
(completed,) = tuple(event for event in events if event["type"] == "response.completed")
|
||||
assert completed["response"]["output"][0]["content"][0]["text"] == _ANSWER, lines
|
||||
target, received = _only_received(wire)
|
||||
assert target == _STREAM_TARGET, target
|
||||
assert received["messages"] == [_CONVERSE_QUESTION, _CONVERSE_ANSWERED], received
|
||||
(row,) = _success_rows(marker, 1)
|
||||
assert row["call_type"] == "aresponses" and row["end_user"] == marker, row
|
||||
assert row["request_id"] == _inner_response_id(str(completed["response"]["id"])), (row, completed)
|
||||
|
||||
|
||||
def test_r22_passthrough_converse_forwards_a_message_without_content_verbatim(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
deployment: Final = scenario.model(
|
||||
model=f"bedrock/{_MODEL_ID}", api_base=wire.url, aws_bedrock_runtime_endpoint=wire.url, **_AWS
|
||||
)
|
||||
response: Final = gateway.request(
|
||||
"POST", f"/bedrock/model/{deployment}/converse", {"messages": [_NO_CONTENT_USER]}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.content == _RESPONSE, response.text
|
||||
(request,) = wire.drain()
|
||||
assert unquote(request.target) == _CONVERSE_TARGET, request.target
|
||||
assert json.loads(request.body)["messages"] == [_NO_CONTENT_USER], request.body
|
||||
|
||||
|
||||
def test_r23_tool_turn_without_content_and_without_tools_is_neutralized_to_text(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
_, received = _converse_cell(gateway, wire, _chat(model, (_QUESTION_TURN, _TOOL_CALL_TURN, _NO_CONTENT_TOOL)))
|
||||
assert received["messages"] == [_CONVERSE_QUESTION, _NEUTRALIZED_TOOL_CALL, _NEUTRALIZED_TOOL_RESULT], received
|
||||
assert "toolConfig" not in received, received
|
||||
|
||||
|
||||
def test_s01_lone_user_with_integer_content_errors_before_any_peer_call(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
response: Final = _post_chat(gateway, _chat(model, ({"role": "user", "content": 42},)))
|
||||
assert response.status_code >= 400, response.text
|
||||
assert "error" in response.json(), response.text
|
||||
assert wire.drain() == ()
|
||||
|
||||
|
||||
def test_s02_lone_user_with_list_content_sends_the_text_block(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
_, received = _converse_cell(
|
||||
gateway, wire, _chat(model, ({"role": "user", "content": [{"type": "text", "text": _QUESTION}]},))
|
||||
)
|
||||
assert received["messages"] == [_CONVERSE_QUESTION], received
|
||||
|
||||
|
||||
def test_s03_lone_user_with_a_five_kilobyte_string_reaches_the_peer_whole(gateway: Gateway) -> None:
|
||||
text: Final = "k" * 5120
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
_, received = _converse_cell(gateway, wire, _chat(model, ({"role": "user", "content": text},)))
|
||||
assert received["messages"] == [{"role": "user", "content": [{"text": text}]}], received
|
||||
|
||||
|
||||
def test_s04_duplicate_content_keys_in_the_raw_body_let_the_last_value_win(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
raw: Final = (
|
||||
f'{{"model": "{model}", "messages": [{{"role": "user", "content": "first", "content": "second"}}],'
|
||||
' "max_tokens": 16, "num_retries": 0, "cache": {"no-cache": true}}'
|
||||
)
|
||||
response: Final = gateway.client.post(
|
||||
"/v1/chat/completions",
|
||||
content=raw.encode(),
|
||||
headers={**_auth(gateway), "content-type": "application/json"},
|
||||
)
|
||||
identity: Final = _chat_answer(response)
|
||||
target, received = _only_received(wire)
|
||||
assert target == _CONVERSE_TARGET, target
|
||||
assert received["messages"] == [{"role": "user", "content": [{"text": "second"}]}], received
|
||||
assert _spend_row(identity)["status"] == "success"
|
||||
|
||||
|
||||
def test_s05_unauthenticated_content_less_request_never_reaches_the_peer(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
response: Final = gateway.request(
|
||||
"POST", "/v1/chat/completions", _chat(model, (_NO_CONTENT_USER,)), key="sk-integration-bogus"
|
||||
)
|
||||
assert response.status_code == 401, response.text
|
||||
assert wire.drain() == (), response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("peer_status", "message", "expected"),
|
||||
(
|
||||
(400, "ValidationException: scripted validation", 400),
|
||||
(429, "ThrottlingException: scripted throttle", 429),
|
||||
(500, "scripted outage", 503),
|
||||
),
|
||||
ids=("s06", "s07", "s08"),
|
||||
)
|
||||
def test_s06_to_s08_peer_errors_on_a_content_less_turn_reach_the_caller_after_one_attempt(
|
||||
gateway: Gateway, peer_status: int, message: str, expected: int
|
||||
) -> None:
|
||||
with wire_server(lambda _: _scripted_error(peer_status, message)) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
response: Final = _post_chat(gateway, _chat(model, (_NO_CONTENT_USER,)))
|
||||
assert response.status_code == expected, response.text
|
||||
assert message in response.text, response.text
|
||||
assert len(wire.drain()) == 1, response.text
|
||||
|
||||
|
||||
def test_s09_unknown_model_with_a_content_less_turn_never_reaches_the_peer(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire:
|
||||
response: Final = _post_chat(gateway, _chat(f"integration-missing-{uuid.uuid4().hex}", (_NO_CONTENT_USER,)))
|
||||
assert response.status_code in (400, 404), response.text
|
||||
assert wire.drain() == (), response.text
|
||||
|
||||
|
||||
def test_s10_a_deployment_continue_message_without_content_adds_no_converse_block(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire, user_continue_message={"role": "user"})
|
||||
_, received = _converse_cell(gateway, wire, _chat(model, (_NO_CONTENT_USER,)))
|
||||
assert received["messages"] == [], received
|
||||
|
||||
|
||||
def test_s11_a_deployment_continue_message_given_as_a_string_errors_in_the_body_and_leaves_the_proxy_serving(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
broken: Final = _converse_deployment(scenario, wire, user_continue_message=_DEPLOYMENT_CONTINUE)
|
||||
healthy: Final = _converse_deployment(scenario, wire)
|
||||
response: Final = _post_chat(gateway, _chat(broken, (_NO_CONTENT_USER,)))
|
||||
assert response.status_code >= 400, response.text
|
||||
assert "error" in response.json(), response.text
|
||||
assert wire.drain() == ()
|
||||
_, received = _converse_cell(gateway, wire, _chat(healthy, (_QUESTION_TURN,)))
|
||||
assert received["messages"] == [_CONVERSE_QUESTION], received
|
||||
|
||||
|
||||
def test_e01_the_same_content_less_request_twice_with_no_cache_hits_the_peer_twice(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
first: Final = _chat_answer(_post_chat(gateway, _chat(model, (_NO_CONTENT_USER,))))
|
||||
second: Final = _chat_answer(_post_chat(gateway, _chat(model, (_NO_CONTENT_USER,))))
|
||||
assert first != second
|
||||
assert len(wire.drain()) == 2
|
||||
assert _spend_row(first)["status"] == "success"
|
||||
assert _spend_row(second)["status"] == "success"
|
||||
|
||||
|
||||
def test_e02_the_same_content_less_request_twice_is_served_from_the_response_cache(gateway: Gateway) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
body: Final = _chat(model, (_NO_CONTENT_USER,), cached=True)
|
||||
first: Final = _chat_answer(_post_chat(gateway, body))
|
||||
second: Final = _chat_answer(_post_chat(gateway, body))
|
||||
assert first == second
|
||||
assert len(wire.drain()) == 1
|
||||
assert _spend_row(first)["status"] == "success"
|
||||
cache_hits: Final = eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT request_id FROM "LiteLLM_SpendLogs" WHERE request_id LIKE %s', (f"{first}_cache_hit%",)
|
||||
),
|
||||
lambda rows: len(rows) >= 1,
|
||||
seconds=70,
|
||||
)
|
||||
assert len(cache_hits) == 1, cache_hits
|
||||
|
||||
|
||||
def _model_id(gateway: Gateway, name: str) -> str:
|
||||
entries: Final = gateway.get("/model/info")["data"]
|
||||
assert isinstance(entries, list), entries
|
||||
(identity,) = (
|
||||
string_value(object_value(object_value(entry)["model_info"])["id"])
|
||||
for entry in entries
|
||||
if object_value(entry)["model_name"] == name
|
||||
)
|
||||
return identity
|
||||
|
||||
|
||||
def _content_less_bodies(gateway: Gateway, wire: Wire, model: str, count: int) -> tuple[JsonValue, ...]:
|
||||
identities: Final = tuple(
|
||||
_chat_answer(_post_chat(gateway, _chat(model, (_NO_CONTENT_USER,)))) for _ in range(count)
|
||||
)
|
||||
received: Final = wire.drain()
|
||||
assert len(received) == len(identities), (identities, received)
|
||||
bodies: Final = tuple(_JSON.validate_python(json.loads(request.body))["messages"] for request in received)
|
||||
assert all(body in ([], [_continue_turn(_DEPLOYMENT_CONTINUE)]) for body in bodies), bodies
|
||||
return bodies
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_e03_updating_the_deployment_continue_message_under_traffic_never_breaks_a_content_less_turn(
|
||||
gateway: Gateway,
|
||||
) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
assert _content_less_bodies(gateway, wire, model, 4) == ([],) * 4
|
||||
gateway.post(
|
||||
"/model/update",
|
||||
{
|
||||
"model_info": {"id": _model_id(gateway, model)},
|
||||
"litellm_params": {"user_continue_message": _DEPLOYMENT_CONTINUE_MESSAGE},
|
||||
},
|
||||
)
|
||||
settled: Final = eventually(
|
||||
lambda: _content_less_bodies(gateway, wire, model, 8),
|
||||
lambda bodies: all(body == [_continue_turn(_DEPLOYMENT_CONTINUE)] for body in bodies),
|
||||
seconds=90,
|
||||
)
|
||||
assert len(settled) == 8, settled
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("continue_message", "expected"),
|
||||
((None, []), ({}, [_continue_turn(_DEFAULT_CONTINUE)])),
|
||||
ids=("e04", "e05"),
|
||||
)
|
||||
def test_e04_e05_a_null_continue_message_means_absent_and_an_empty_one_means_the_default(
|
||||
gateway: Gateway, continue_message: JsonValue, expected: list[JsonValue]
|
||||
) -> None:
|
||||
with wire_server(_converse_peer) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire, user_continue_message=continue_message)
|
||||
_, received = _converse_cell(gateway, wire, _chat(model, (_NO_CONTENT_USER,)))
|
||||
assert received["messages"] == expected, received
|
||||
|
||||
|
||||
def _path(endpoint: Endpoint) -> str:
|
||||
match endpoint:
|
||||
case "chat":
|
||||
return "/v1/chat/completions"
|
||||
case "responses":
|
||||
return "/v1/responses"
|
||||
|
||||
|
||||
def _calls(marker: str, endpoint: Endpoint, stream: bool, indexes: range) -> tuple[_Call, ...]:
|
||||
return tuple(_Call(endpoint, stream, f"{marker}-{index}", index) for index in indexes)
|
||||
|
||||
|
||||
def _burst_body(model: str, call: _Call) -> dict[str, JsonValue]:
|
||||
turns: Final = ({"role": "user", "content": f"{_QUESTION} {call.user}"}, _ANSWERED_TURN, _NO_CONTENT_USER)
|
||||
if call.endpoint == "chat":
|
||||
return {**_chat(model, turns, stream=call.stream), "user": call.user}
|
||||
return {"model": model, "input": [dict(turn) for turn in turns], "stream": call.stream, "user": call.user, **_EXTRA}
|
||||
|
||||
|
||||
def _call_index(request: Request) -> int:
|
||||
found: Final = _CALL_INDEX.search(request.body.decode())
|
||||
assert found is not None, request.body
|
||||
return int(found.group(1))
|
||||
|
||||
|
||||
async def _send(client: httpx.AsyncClient, key: str, model: str, call: _Call) -> _Served:
|
||||
async with client.stream(
|
||||
"POST", _path(call.endpoint), json=_burst_body(model, call), headers={"Authorization": f"Bearer {key}"}
|
||||
) as response:
|
||||
raw: Final = await response.aread()
|
||||
return _Served(call=call, status=response.status_code, text=raw.decode())
|
||||
|
||||
|
||||
async def _burst(
|
||||
base_url: str, key: str, model: str, calls: tuple[_Call, ...], *, tolerate_transport_errors: bool = False
|
||||
) -> tuple[_Served, ...]:
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=60, trust_env=False) as client:
|
||||
results: Final = await asyncio.gather(
|
||||
*(_send(client, key, model, call) for call in calls), return_exceptions=tolerate_transport_errors
|
||||
)
|
||||
for result in results:
|
||||
assert not isinstance(result, BaseException) or isinstance(result, httpx.TransportError), repr(result)
|
||||
return tuple(result for result in results if isinstance(result, _Served))
|
||||
|
||||
|
||||
def _inner_response_id(identity: str) -> str:
|
||||
managed: Final = decrypt_if_encrypted_with(identity.removeprefix("resp_"), _SIGNING_KEY)
|
||||
assert managed is not None, identity
|
||||
issued: Final = managed.split(";", 1)[0].rsplit("response_id:", 1)[1]
|
||||
decoded: Final = base64.b64decode(issued.removeprefix("resp_")).decode()
|
||||
return decoded.rsplit("response_id:", 1)[1]
|
||||
|
||||
|
||||
def _served_id(served: _Served) -> str:
|
||||
if served.call.stream:
|
||||
(identity,) = {chunk["id"] for chunk in _sse_payloads(served.text.splitlines())}
|
||||
return identity
|
||||
return json.loads(served.text)["id"]
|
||||
|
||||
|
||||
def _spend_row_id(served: _Served) -> str:
|
||||
identity: Final = _served_id(served)
|
||||
return identity if served.call.endpoint == "chat" else _inner_response_id(identity)
|
||||
|
||||
|
||||
def _answered(served: Iterable[_Served]) -> frozenset[str]:
|
||||
ids: Final = tuple(_spend_row_id(item) for item in served)
|
||||
assert len(set(ids)) == len(ids), ids
|
||||
return frozenset(ids)
|
||||
|
||||
|
||||
def _open_peer_connections(pid: int, peer_url: str) -> int:
|
||||
port: Final = urlsplit(peer_url).port
|
||||
return sum(
|
||||
1
|
||||
for connection in psutil.Process(pid).net_connections(kind="tcp")
|
||||
if connection.status == psutil.CONN_ESTABLISHED and connection.raddr and connection.raddr.port == port
|
||||
)
|
||||
|
||||
|
||||
async def test_c01_a_mixed_burst_of_content_less_calls_survives_a_peer_outage_window(gateway: Gateway) -> None:
|
||||
marker: Final = f"call-{uuid.uuid4().hex}"
|
||||
calls: Final = (
|
||||
*_calls(marker, "chat", False, range(0, 10)),
|
||||
*_calls(marker, "chat", True, range(10, 20)),
|
||||
*_calls(marker, "responses", False, range(20, 30)),
|
||||
)
|
||||
|
||||
def respond(request: Request) -> Reply:
|
||||
if _call_index(request) % 3 == 1:
|
||||
return _scripted_error(500, "scripted outage")
|
||||
return _converse_peer(request)
|
||||
|
||||
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
||||
model: Final = _converse_deployment(scenario, wire)
|
||||
served: Final = await _burst(_proxy_url(gateway), gateway.key, model, calls)
|
||||
assert len(served) == 30
|
||||
failed: Final = tuple(item for item in served if item.call.index % 3 == 1)
|
||||
answered: Final = tuple(item for item in served if item.call.index % 3 != 1)
|
||||
assert len(failed) == 10 and len(answered) == 20
|
||||
for item in failed:
|
||||
assert item.status == 503 and "scripted outage" in item.text, (item.call, item.status, item.text)
|
||||
for item in answered:
|
||||
assert item.status == 200 and _ANSWER in item.text, (item.call, item.status, item.text)
|
||||
identities: Final = _answered(answered)
|
||||
assert len(identities) == 20
|
||||
assert {row["request_id"] for row in _success_rows(marker, 20)} == identities
|
||||
assert len(wire.drain()) == 30
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
async def test_c02_worker_sigkill_mid_burst_leaves_the_sibling_serving_content_less_turns(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
marker: Final = f"call-{uuid.uuid4().hex}"
|
||||
again: Final = f"call-{uuid.uuid4().hex}"
|
||||
calls: Final = _calls(marker, "chat", False, range(20))
|
||||
release: Final = threading.Event()
|
||||
held_indexes: Final[SimpleQueue[int]] = SimpleQueue()
|
||||
|
||||
def held(request: Request) -> Reply:
|
||||
held_indexes.put(_call_index(request))
|
||||
assert release.wait(timeout=60), "The burst was never released"
|
||||
return _converse_peer(request)
|
||||
|
||||
with wire_server(held) as wire:
|
||||
config: Final = _owned_config(wire, tmp_path, modify_params=False)
|
||||
with owned_proxy_process(gateway, tmp_path, {}, config=config, workers=2) as owned:
|
||||
candidate: Final = owned.gateway
|
||||
workers: Final = eventually(
|
||||
lambda: tuple(int(pid) for pid in _STARTED_WORKER.findall(owned.log.read_text())),
|
||||
lambda pids: len(pids) == 2,
|
||||
seconds=30,
|
||||
)
|
||||
burst: Final = asyncio.create_task(
|
||||
_burst(_proxy_url(candidate), candidate.key, _PLAIN, calls, tolerate_transport_errors=True)
|
||||
)
|
||||
await asyncio.to_thread(eventually, held_indexes.qsize, lambda size: size == 20, 60)
|
||||
held_by: Final = MappingProxyType({pid: _open_peer_connections(pid, wire.url) for pid in workers})
|
||||
assert sum(held_by.values()) == 20, held_by
|
||||
victim_pid, survivor_pid = sorted(workers, key=held_by.__getitem__)
|
||||
psutil.Process(victim_pid).send_signal(signal.SIGKILL)
|
||||
release.set()
|
||||
served: Final = await burst
|
||||
assert held_by[survivor_pid] >= 10, held_by
|
||||
assert len(served) == held_by[survivor_pid], (held_by, len(served))
|
||||
for item in served:
|
||||
assert item.status == 200 and _ANSWER in item.text, (item.call, item.status, item.text)
|
||||
eventually(
|
||||
lambda: len(_STARTED_WORKER.findall(owned.log.read_text())), lambda count: count == 3, seconds=60
|
||||
)
|
||||
follow_up: Final = await _burst(
|
||||
_proxy_url(candidate), candidate.key, _PLAIN, _calls(again, "chat", False, range(6))
|
||||
)
|
||||
assert len(follow_up) == 6
|
||||
for item in follow_up:
|
||||
assert item.status == 200 and _ANSWER in item.text, (item.call, item.status, item.text)
|
||||
assert len(wire.drain()) == 26
|
||||
assert {row["request_id"] for row in _success_rows(marker, len(served))} == _answered(served)
|
||||
assert {row["request_id"] for row in _success_rows(again, 6)} == _answered(follow_up)
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
async def test_c03_proxy_terminated_mid_burst_lands_every_answered_content_less_call_at_most_once(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
marker: Final = f"call-{uuid.uuid4().hex}"
|
||||
calls: Final = _calls(marker, "chat", False, range(12))
|
||||
release: Final = threading.Event()
|
||||
held_indexes: Final[SimpleQueue[int]] = SimpleQueue()
|
||||
|
||||
def held(request: Request) -> Reply:
|
||||
held_indexes.put(_call_index(request))
|
||||
assert release.wait(timeout=60), "The burst was never released"
|
||||
return _converse_peer(request)
|
||||
|
||||
with wire_server(held) as wire:
|
||||
config: Final = _owned_config(wire, tmp_path, modify_params=False)
|
||||
with owned_proxy_process(gateway, tmp_path, {}, config=config, workers=1) as owned:
|
||||
candidate: Final = owned.gateway
|
||||
burst: Final = asyncio.create_task(
|
||||
_burst(_proxy_url(candidate), candidate.key, _PLAIN, calls, tolerate_transport_errors=True)
|
||||
)
|
||||
await asyncio.to_thread(eventually, held_indexes.qsize, lambda size: size == 12, 60)
|
||||
owned.process.terminate()
|
||||
release.set()
|
||||
served: Final = await burst
|
||||
eventually(owned.process.poll, lambda code: code is not None, seconds=60)
|
||||
answered: Final = _answered(item for item in served if item.status == 200)
|
||||
assert len(served) <= 12
|
||||
landed: Final = tuple(
|
||||
row["request_id"]
|
||||
for row in read_rows(
|
||||
'SELECT request_id FROM "LiteLLM_SpendLogs" WHERE end_user LIKE %s AND status=%s',
|
||||
(f"{marker}%", "success"),
|
||||
)
|
||||
)
|
||||
assert len(landed) == len(set(landed)), landed
|
||||
stray: Final = set(landed) - answered
|
||||
assert len(stray) <= 12 - len(answered), (landed, answered)
|
||||
assert len(wire.drain()) == 12
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import litellm_proxy_extras
|
||||
import psycopg
|
||||
import pytest
|
||||
from litellm_proxy_extras.request_log_indexes import REQUEST_LOG_INDEXES
|
||||
from psycopg.types.json import Jsonb
|
||||
from pydantic import JsonValue
|
||||
|
||||
|
|
@ -29,11 +28,8 @@ _SPEND_LOGS_DDL: Final = """
|
|||
)
|
||||
"""
|
||||
|
||||
_API_KEY_START_TIME_INDEX_MIGRATION: Final = (
|
||||
Path(litellm_proxy_extras.__file__).parent
|
||||
/ "migrations"
|
||||
/ "20260823000000_add_spend_logs_api_key_starttime_index"
|
||||
/ "migration.sql"
|
||||
_API_KEY_START_TIME_INDEX: Final = next(
|
||||
index for index in REQUEST_LOG_INDEXES if index.name == "LiteLLM_SpendLogs_api_key_startTime_idx"
|
||||
)
|
||||
|
||||
_STATS_SQL: Final = """
|
||||
|
|
@ -56,7 +52,12 @@ class _Settle:
|
|||
|
||||
def _create_spend_logs_table(database_url: str) -> None:
|
||||
write_rows(_SPEND_LOGS_DDL, (), database_url=database_url)
|
||||
write_rows(_API_KEY_START_TIME_INDEX_MIGRATION.read_text(), (), database_url=database_url)
|
||||
write_rows(
|
||||
f'CREATE INDEX "{_API_KEY_START_TIME_INDEX.name}" ON "{_API_KEY_START_TIME_INDEX.table}" ' # pyright: ignore[reportArgumentType] # DDL from the migration job index list
|
||||
f"{_API_KEY_START_TIME_INDEX.definition}",
|
||||
(),
|
||||
database_url=database_url,
|
||||
)
|
||||
|
||||
|
||||
def _spend_log_stats(database_url: str) -> dict[str, int]:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import tempfile
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from litellm_proxy_extras.request_log_indexes import filter_request_log_index_diff
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
|
|
@ -16,7 +17,9 @@ def test_schema_migration_in_sync():
|
|||
|
||||
Applies every committed migration to an empty database, then diffs the result
|
||||
against schema.prisma. A non-empty diff means the schema was changed without a
|
||||
matching migration being generated.
|
||||
matching migration being generated. The request-log indexes the migration job
|
||||
builds are declared in the schema and deliberately absent from the migrations,
|
||||
so those statements are filtered out before the diff is judged.
|
||||
"""
|
||||
db_url = os.environ["DATABASE_URL"]
|
||||
source_migrations_dir = Path(
|
||||
|
|
@ -60,11 +63,14 @@ def test_schema_migration_in_sync():
|
|||
)
|
||||
|
||||
if diff.returncode == 2:
|
||||
pytest.fail(
|
||||
"Schema changes detected that no migration captures. Run "
|
||||
"`python litellm/ci_cd/run_migration.py <migration_name>`.\n\n"
|
||||
+ diff.stdout
|
||||
)
|
||||
assert diff.returncode == 0, f"prisma migrate diff errored: {diff.stderr}"
|
||||
drift = filter_request_log_index_diff(diff.stdout)
|
||||
if drift.strip():
|
||||
pytest.fail(
|
||||
"Schema changes detected that no migration captures. Run "
|
||||
"`python litellm/ci_cd/run_migration.py <migration_name>`.\n\n"
|
||||
+ drift
|
||||
)
|
||||
else:
|
||||
assert diff.returncode == 0, f"prisma migrate diff errored: {diff.stderr}"
|
||||
finally:
|
||||
shutil.rmtree(temp_base, ignore_errors=True)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from litellm_proxy_extras.utils import INDEX_REPAIR_ADVISORY_LOCK_KEY, ProxyExtrasDBManager
|
||||
from litellm_proxy_extras.migration_lock import MIGRATION_LOCK_KEY
|
||||
from litellm_proxy_extras.utils import INDEX_REPAIR_ADVISORY_LOCK_KEY, ProxyExtrasDBManager, _InvalidIndex
|
||||
|
||||
psycopg = pytest.importorskip("psycopg")
|
||||
|
||||
|
|
@ -20,6 +22,7 @@ requires_db: Final = pytest.mark.skipif(
|
|||
HEALTH_TABLE: Final = "LiteLLM_HealthCheckTable"
|
||||
HEALTH_INDEX: Final = "LiteLLM_HealthCheckTable_model_id_model_name_checked_at_idx"
|
||||
HEALTH_INDEX_COLUMNS: Final = '"model_id", "model_name", "checked_at" DESC'
|
||||
SECOND_HEALTH_INDEX: Final = "LiteLLM_HealthCheckTable_model_name_idx"
|
||||
LOOKALIKE_TABLE: Final = "LiteLLMLookalikeTable"
|
||||
LOOKALIKE_INDEX: Final = "LiteLLMLookalikeTable_id_idx"
|
||||
PARTITIONED_TABLE: Final = "LiteLLM_PartitionedTable"
|
||||
|
|
@ -167,7 +170,74 @@ def test_repair_yields_to_the_replica_holding_the_repair_lock(scratch_schema: st
|
|||
|
||||
|
||||
@requires_db
|
||||
def test_repair_gives_up_on_a_blocked_rebuild_and_finishes_it_on_the_next_startup(scratch_schema: str) -> None:
|
||||
def test_repair_yields_to_the_migration_job_building_indexes_under_the_migration_lock(scratch_schema: str) -> None:
|
||||
"""A migration job's index build holds the migration lock while its CREATE INDEX CONCURRENTLY
|
||||
is cataloged as invalid; the repair must not rebuild that in-flight index."""
|
||||
_leave_invalid_index(scratch_schema, HEALTH_TABLE, HEALTH_INDEX, HEALTH_INDEX_COLUMNS)
|
||||
|
||||
with psycopg.connect(_base_url(), autocommit=True) as index_builder:
|
||||
index_builder.execute("SELECT pg_advisory_lock(%s)", (MIGRATION_LOCK_KEY,))
|
||||
assert ProxyExtrasDBManager.repair_invalid_indexes() is False
|
||||
assert _index_validity(scratch_schema) == {HEALTH_INDEX: False}
|
||||
|
||||
assert ProxyExtrasDBManager.repair_invalid_indexes() is True
|
||||
assert _index_validity(scratch_schema) == {HEALTH_INDEX: True}
|
||||
|
||||
|
||||
def _hold_migration_lock_once_free(release: threading.Event) -> None:
|
||||
with psycopg.connect(_base_url(), autocommit=True) as resolver:
|
||||
resolver.execute("SELECT pg_advisory_lock(%s)", (MIGRATION_LOCK_KEY,))
|
||||
release.wait(timeout=60)
|
||||
|
||||
|
||||
def _wait_until_a_session_queues_for_the_migration_lock() -> None:
|
||||
with psycopg.connect(_base_url(), autocommit=True) as conn:
|
||||
for _ in range(200):
|
||||
queued: Final = conn.execute(
|
||||
"SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted "
|
||||
"AND classid = %s AND objid = %s",
|
||||
(MIGRATION_LOCK_KEY >> 32, MIGRATION_LOCK_KEY & 0xFFFFFFFF),
|
||||
).fetchone()
|
||||
if queued is not None and queued[0]:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
pytest.fail("no session queued for the migration lock")
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_repair_releases_the_migration_lock_between_indexes_so_a_booting_resolver_gets_in(
|
||||
scratch_schema: str,
|
||||
) -> None:
|
||||
"""A v2 resolver on another replica waits for the migration lock; with two invalid
|
||||
indexes to rebuild it must get the lock after the first REINDEX, not after both."""
|
||||
_leave_invalid_index(scratch_schema, HEALTH_TABLE, HEALTH_INDEX, HEALTH_INDEX_COLUMNS)
|
||||
_leave_invalid_index(scratch_schema, HEALTH_TABLE, SECOND_HEALTH_INDEX, '"model_name"')
|
||||
release: Final = threading.Event()
|
||||
resolver: Final = threading.Thread(target=_hold_migration_lock_once_free, args=(release,))
|
||||
|
||||
def repair_then_let_a_resolver_queue_for_the_lock(
|
||||
conn: "psycopg.Connection[tuple[str, str, str]]", index: _InvalidIndex
|
||||
) -> None:
|
||||
ProxyExtrasDBManager._repair_index(conn, index)
|
||||
if not resolver.is_alive():
|
||||
resolver.start()
|
||||
_wait_until_a_session_queues_for_the_migration_lock()
|
||||
|
||||
try:
|
||||
assert (
|
||||
ProxyExtrasDBManager.repair_invalid_indexes(repair=repair_then_let_a_resolver_queue_for_the_lock) is False
|
||||
)
|
||||
assert sorted(_index_validity(scratch_schema).values()) == [False, True]
|
||||
finally:
|
||||
release.set()
|
||||
resolver.join()
|
||||
|
||||
assert ProxyExtrasDBManager.repair_invalid_indexes() is True
|
||||
assert _index_validity(scratch_schema) == {HEALTH_INDEX: True, SECOND_HEALTH_INDEX: True}
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_repair_gives_up_on_a_blocked_rebuild_and_finishes_it_on_the_next_boot(scratch_schema: str) -> None:
|
||||
_leave_invalid_index(scratch_schema, HEALTH_TABLE, HEALTH_INDEX, HEALTH_INDEX_COLUMNS)
|
||||
|
||||
with psycopg.connect(_base_url()) as pin:
|
||||
|
|
|
|||
763
tests/proxy_migration_tests/test_request_log_indexes.py
Normal file
763
tests/proxy_migration_tests/test_request_log_indexes.py
Normal file
|
|
@ -0,0 +1,763 @@
|
|||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from litellm_proxy_extras.migration_lock import MIGRATION_LOCK_KEY, migration_lock
|
||||
from litellm_proxy_extras.migration_recovery import roll_back_failed_inert_migration
|
||||
from litellm_proxy_extras.request_log_indexes import (
|
||||
REQUEST_LOG_INDEXES,
|
||||
RequestLogIndex,
|
||||
build_index_on_partitioned_table,
|
||||
ensure_request_log_indexes,
|
||||
)
|
||||
from litellm_proxy_extras.utils import ProxyExtrasDBManager
|
||||
from psycopg import sql
|
||||
from psycopg.abc import Params, QueryNoTemplate
|
||||
from psycopg.rows import class_row
|
||||
|
||||
pytestmark = pytest.mark.timeout(900)
|
||||
|
||||
requires_db: Final = pytest.mark.skipif(
|
||||
"DATABASE_URL" not in os.environ,
|
||||
reason="requires a postgres database (DATABASE_URL)",
|
||||
)
|
||||
|
||||
REPO: Final = Path(__file__).resolve().parents[2]
|
||||
PACKAGE: Final = REPO / "litellm-proxy-extras" / "litellm_proxy_extras"
|
||||
PARTITION_SCRIPT: Final = REPO / "db_scripts" / "partition_spend_logs.sql"
|
||||
API_KEY_INDEX_MIGRATION: Final = "20260823000000_add_spend_logs_api_key_starttime_index"
|
||||
CALL_ID_INDEX_MIGRATION: Final = "20260831120001_spend_logs_litellm_call_id_index"
|
||||
API_KEY_INDEX: Final = "LiteLLM_SpendLogs_api_key_startTime_idx"
|
||||
CALL_ID_INDEX: Final = "LiteLLM_SpendLogs_litellm_call_id_idx"
|
||||
PARTITIONED_PARENT_ERROR: Final = 'cannot create index on partitioned table "LiteLLM_SpendLogs" concurrently'
|
||||
ORIGINAL_MIGRATION_SQL: Final = MappingProxyType(
|
||||
{
|
||||
API_KEY_INDEX_MIGRATION: (
|
||||
"-- CreateIndex\n"
|
||||
'CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogs_api_key_startTime_idx" '
|
||||
'ON "LiteLLM_SpendLogs"("api_key", "startTime");\n'
|
||||
),
|
||||
CALL_ID_INDEX_MIGRATION: (
|
||||
"-- CreateIndex\n"
|
||||
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_SpendLogs_litellm_call_id_idx" '
|
||||
'ON "LiteLLM_SpendLogs"("litellm_call_id");\n'
|
||||
),
|
||||
}
|
||||
)
|
||||
CALL_ID_INDEX_DEFINITION: Final = next(index for index in REQUEST_LOG_INDEXES if index.name == CALL_ID_INDEX)
|
||||
RELEASES: Final = pytest.mark.parametrize(
|
||||
"release", (API_KEY_INDEX_MIGRATION, CALL_ID_INDEX_MIGRATION), ids=("v1.102.1", "v1.103.0")
|
||||
)
|
||||
PARTITIONS: Final = MappingProxyType(
|
||||
{
|
||||
"LiteLLM_SpendLogs_p2026_08": ("2026-08-01", "2026-09-01"),
|
||||
"LiteLLM_SpendLogs_p2026_09": ("2026-09-01", "2026-10-01"),
|
||||
}
|
||||
)
|
||||
DEFAULT_PARTITION: Final = "LiteLLM_SpendLogs_pdefault"
|
||||
ROWS_PER_PARTITION: Final = 200
|
||||
RESOLVERS: Final = pytest.mark.parametrize("use_v2_resolver", (True, False), ids=("v2", "v1"))
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
return os.environ["DATABASE_URL"].split("?")[0]
|
||||
|
||||
|
||||
def _migrate_deploy(database_url: str, schema: Path) -> "subprocess.CompletedProcess[str]":
|
||||
return subprocess.run(
|
||||
[sys.executable, "-I", "-m", "prisma", "migrate", "deploy", "--schema", str(schema)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "DATABASE_URL": database_url},
|
||||
)
|
||||
|
||||
|
||||
def _release_layout(prisma_dir: Path, before: str) -> Path:
|
||||
"""The shipped migrations older than `before`, with the two index migrations written
|
||||
the way the releases that shipped them did: the Prisma layout of a proxy on that release."""
|
||||
(prisma_dir / "migrations").mkdir(parents=True)
|
||||
shutil.copy(PACKAGE / "schema.prisma", prisma_dir / "schema.prisma")
|
||||
for migration in sorted((PACKAGE / "migrations").iterdir()):
|
||||
if migration.is_dir() and migration.name < before:
|
||||
shutil.copytree(migration, prisma_dir / "migrations" / migration.name)
|
||||
for name, original in ORIGINAL_MIGRATION_SQL.items():
|
||||
if (prisma_dir / "migrations" / name).is_dir():
|
||||
(prisma_dir / "migrations" / name / "migration.sql").write_text(original)
|
||||
return prisma_dir / "schema.prisma"
|
||||
|
||||
|
||||
def _deploy_release(database_url: str, prisma_dir: Path, before: str) -> None:
|
||||
deployed: Final = _migrate_deploy(database_url, _release_layout(prisma_dir, before))
|
||||
assert deployed.returncode == 0, deployed.stderr
|
||||
|
||||
|
||||
def _insert_spend_log(
|
||||
conn: "psycopg.Connection[tuple[object, ...]]", request_id: str, day: str, table: str = "LiteLLM_SpendLogs"
|
||||
) -> None:
|
||||
conn.execute(
|
||||
sql.SQL(
|
||||
'INSERT INTO {} ("request_id", "call_type", "startTime", "endTime", "api_key") VALUES (%s, %s, %s, %s, %s)'
|
||||
).format(sql.Identifier(table)),
|
||||
(request_id, "acompletion", day, day, f"key-{request_id[-1]}"),
|
||||
)
|
||||
|
||||
|
||||
def _partition_spend_logs(database_url: str) -> None:
|
||||
with psycopg.connect(database_url, autocommit=True) as conn:
|
||||
conn.execute(PARTITION_SCRIPT.read_bytes())
|
||||
for partition, (start, stop) in PARTITIONS.items():
|
||||
conn.execute(
|
||||
sql.SQL('CREATE TABLE {} PARTITION OF "LiteLLM_SpendLogs" FOR VALUES FROM ({}) TO ({})').format(
|
||||
sql.Identifier(partition), sql.Literal(start), sql.Literal(stop)
|
||||
)
|
||||
)
|
||||
for row in range(ROWS_PER_PARTITION):
|
||||
_insert_spend_log(conn, f"{partition}-{row}", start)
|
||||
for row in range(ROWS_PER_PARTITION):
|
||||
_insert_spend_log(conn, f"default-{row}", "2020-01-01")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def release() -> str:
|
||||
"""The first migration a database has not applied yet; the v1.103.0 shape unless a test parametrizes it."""
|
||||
return CALL_ID_INDEX_MIGRATION
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scratch_database(release: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[str]:
|
||||
"""A deployment stopped before `release`, with DATABASE_URL pointed at it so
|
||||
ProxyExtrasDBManager upgrades it like a booting proxy."""
|
||||
admin_url: Final = _base_url()
|
||||
name: Final = f"spend_logs_index_{uuid.uuid4().hex[:8]}"
|
||||
with psycopg.connect(admin_url, autocommit=True) as conn:
|
||||
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
|
||||
database_url: Final = f"{admin_url.rsplit('/', 1)[0]}/{name}"
|
||||
try:
|
||||
_deploy_release(database_url, tmp_path / "prisma", release)
|
||||
monkeypatch.delenv("DIRECT_URL", raising=False)
|
||||
monkeypatch.setenv("DATABASE_URL", database_url)
|
||||
yield database_url
|
||||
finally:
|
||||
with psycopg.connect(admin_url, autocommit=True) as conn:
|
||||
conn.execute(sql.SQL("DROP DATABASE {} WITH (FORCE)").format(sql.Identifier(name)))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def partitioned_database(scratch_database: str) -> str:
|
||||
_partition_spend_logs(scratch_database)
|
||||
return scratch_database
|
||||
|
||||
|
||||
def _fail_the_call_id_migration_like_the_shipped_release(database_url: str, tmp_path: Path) -> None:
|
||||
"""Boot the original v1.103.0 layout once: its CONCURRENTLY statement fails on the
|
||||
partitioned parent and leaves the call_id ledger row unfinished."""
|
||||
failed: Final = _migrate_deploy(database_url, _release_layout(tmp_path / "v1.103.0", "99999999999999"))
|
||||
assert failed.returncode != 0 and PARTITIONED_PARENT_ERROR in failed.stderr, failed.stderr
|
||||
assert _ledger(database_url)[CALL_ID_INDEX_MIGRATION] == (False, False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _IndexRow:
|
||||
name: str
|
||||
valid: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _AttachedRow:
|
||||
table: str
|
||||
index: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LedgerRow:
|
||||
name: str
|
||||
finished: bool
|
||||
rolled_back: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _OidRow:
|
||||
name: str
|
||||
oid: int
|
||||
|
||||
|
||||
def _index_validity(database_url: str, suffix: str) -> Mapping[str, bool]:
|
||||
"""index name -> indisvalid for every index ending in `suffix` on the SpendLogs parent or one of its partitions."""
|
||||
with psycopg.connect(database_url) as conn, conn.cursor(row_factory=class_row(_IndexRow)) as cursor:
|
||||
rows: Final = cursor.execute(
|
||||
"SELECT c.relname AS name, i.indisvalid AS valid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid "
|
||||
"WHERE c.relname LIKE %s AND (i.indrelid = to_regclass('\"LiteLLM_SpendLogs\"') OR i.indrelid IN "
|
||||
"(SELECT inhrelid FROM pg_inherits WHERE inhparent = to_regclass('\"LiteLLM_SpendLogs\"'))) "
|
||||
"ORDER BY c.relname",
|
||||
(f"%{suffix}",),
|
||||
).fetchall()
|
||||
return MappingProxyType({row.name: row.valid for row in rows})
|
||||
|
||||
|
||||
def _attached_children(database_url: str, parent_index: str) -> frozenset[tuple[str, str]]:
|
||||
"""(partition, child index) pairs attached under the parent index."""
|
||||
with psycopg.connect(database_url) as conn, conn.cursor(row_factory=class_row(_AttachedRow)) as cursor:
|
||||
rows: Final = cursor.execute(
|
||||
'SELECT t.relname AS "table", c.relname AS index FROM pg_inherits i '
|
||||
"JOIN pg_class c ON c.oid = i.inhrelid JOIN pg_index x ON x.indexrelid = c.oid "
|
||||
"JOIN pg_class t ON t.oid = x.indrelid "
|
||||
"WHERE i.inhparent = to_regclass(%s)",
|
||||
(f'"{parent_index}"',),
|
||||
).fetchall()
|
||||
return frozenset((row.table, row.index) for row in rows)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TableRow:
|
||||
name: str
|
||||
|
||||
|
||||
def _indexed_table(database_url: str, index: str) -> "str | None":
|
||||
with psycopg.connect(database_url) as conn, conn.cursor(row_factory=class_row(_TableRow)) as cursor:
|
||||
row: Final = cursor.execute(
|
||||
"SELECT t.relname AS name FROM pg_index x JOIN pg_class t ON t.oid = x.indrelid "
|
||||
"WHERE x.indexrelid = to_regclass(%s)",
|
||||
(f'"{index}"',),
|
||||
).fetchone()
|
||||
return None if row is None else row.name
|
||||
|
||||
|
||||
def _ledger(database_url: str) -> Mapping[str, tuple[bool, bool]]:
|
||||
"""migration name -> (finished, rolled back) for the newest ledger row of each migration."""
|
||||
with psycopg.connect(database_url) as conn, conn.cursor(row_factory=class_row(_LedgerRow)) as cursor:
|
||||
rows: Final = cursor.execute(
|
||||
"SELECT DISTINCT ON (migration_name) migration_name AS name, finished_at IS NOT NULL AS finished, "
|
||||
"rolled_back_at IS NOT NULL AS rolled_back FROM _prisma_migrations ORDER BY migration_name, started_at DESC"
|
||||
).fetchall()
|
||||
return MappingProxyType({row.name: (row.finished, row.rolled_back) for row in rows})
|
||||
|
||||
|
||||
def _index_oids(database_url: str) -> Mapping[str, int]:
|
||||
"""index name -> oid for every index on the SpendLogs parent or one of its partitions; a rebuild changes the oid."""
|
||||
with psycopg.connect(database_url) as conn, conn.cursor(row_factory=class_row(_OidRow)) as cursor:
|
||||
rows: Final = cursor.execute(
|
||||
"SELECT c.relname AS name, c.oid::int AS oid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid "
|
||||
"WHERE i.indrelid = to_regclass('\"LiteLLM_SpendLogs\"') OR i.indrelid IN "
|
||||
"(SELECT inhrelid FROM pg_inherits WHERE inhparent = to_regclass('\"LiteLLM_SpendLogs\"'))"
|
||||
).fetchall()
|
||||
return MappingProxyType({row.name: row.oid for row in rows})
|
||||
|
||||
|
||||
def _migration_job(use_v2_resolver: bool) -> bool:
|
||||
return ProxyExtrasDBManager.run_migration_job(use_migrate=True, use_v2_resolver=use_v2_resolver)
|
||||
|
||||
|
||||
def _assert_no_pending_migrations(database_url: str) -> None:
|
||||
status: Final = _migrate_deploy(database_url, PACKAGE / "schema.prisma")
|
||||
assert status.returncode == 0 and "No pending migrations" in status.stdout, status.stdout + status.stderr
|
||||
|
||||
|
||||
def _assert_every_ledger_row_is_finished(database_url: str) -> Mapping[str, tuple[bool, bool]]:
|
||||
ledger: Final = _ledger(database_url)
|
||||
assert ledger[API_KEY_INDEX_MIGRATION] == (True, False) and ledger[CALL_ID_INDEX_MIGRATION] == (True, False)
|
||||
assert all(finished and not rolled_back for finished, rolled_back in ledger.values()), ledger
|
||||
return ledger
|
||||
|
||||
|
||||
def _expected_children(partitions: tuple[str, ...], suffix: str) -> frozenset[tuple[str, str]]:
|
||||
return frozenset((partition, f"{partition}_{suffix}") for partition in partitions)
|
||||
|
||||
|
||||
def _assert_index_covers_every_partition(database_url: str, parent_index: str, suffix: str) -> None:
|
||||
partitions: Final = (*PARTITIONS, DEFAULT_PARTITION)
|
||||
assert _index_validity(database_url, suffix) == {parent_index: True} | {f"{p}_{suffix}": True for p in partitions}
|
||||
assert _attached_children(database_url, parent_index) == _expected_children(partitions, suffix)
|
||||
|
||||
|
||||
@requires_db
|
||||
@RESOLVERS
|
||||
@RELEASES
|
||||
def test_a_partitioned_spend_logs_upgrade_builds_both_indexes_per_partition_and_a_rerun_is_idempotent(
|
||||
partitioned_database: str, use_v2_resolver: bool
|
||||
) -> None:
|
||||
assert _migration_job(use_v2_resolver) is True
|
||||
|
||||
_assert_index_covers_every_partition(partitioned_database, API_KEY_INDEX, "api_key_startTime_idx")
|
||||
_assert_index_covers_every_partition(partitioned_database, CALL_ID_INDEX, "litellm_call_id_idx")
|
||||
ledger: Final = _assert_every_ledger_row_is_finished(partitioned_database)
|
||||
_assert_no_pending_migrations(partitioned_database)
|
||||
oids: Final = _index_oids(partitioned_database)
|
||||
|
||||
with psycopg.connect(partitioned_database, autocommit=True) as conn:
|
||||
conn.execute(
|
||||
'CREATE TABLE "LiteLLM_SpendLogs_p2026_10" PARTITION OF "LiteLLM_SpendLogs" '
|
||||
"FOR VALUES FROM ('2026-10-01') TO ('2026-11-01')"
|
||||
)
|
||||
inherited: Final = frozenset(
|
||||
("LiteLLM_SpendLogs_p2026_10", f"LiteLLM_SpendLogs_p2026_10_{suffix}")
|
||||
for suffix in ("api_key_startTime_idx", "litellm_call_id_idx")
|
||||
)
|
||||
attached: Final = _attached_children(partitioned_database, API_KEY_INDEX) | _attached_children(
|
||||
partitioned_database, CALL_ID_INDEX
|
||||
)
|
||||
assert inherited <= attached, attached
|
||||
|
||||
assert _migration_job(use_v2_resolver) is True
|
||||
assert _ledger(partitioned_database) == ledger
|
||||
assert {name: oid for name, oid in _index_oids(partitioned_database).items() if name in oids} == oids
|
||||
|
||||
|
||||
@requires_db
|
||||
@RESOLVERS
|
||||
@RELEASES
|
||||
def test_a_plain_spend_logs_upgrade_builds_both_indexes_and_a_second_job_run_rebuilds_nothing(
|
||||
scratch_database: str, use_v2_resolver: bool
|
||||
) -> None:
|
||||
with psycopg.connect(scratch_database, autocommit=True) as conn:
|
||||
for row in range(ROWS_PER_PARTITION):
|
||||
_insert_spend_log(conn, f"flat-{row}", "2026-09-01")
|
||||
|
||||
assert _migration_job(use_v2_resolver) is True
|
||||
|
||||
assert _index_validity(scratch_database, "api_key_startTime_idx") == {API_KEY_INDEX: True}
|
||||
assert _index_validity(scratch_database, "litellm_call_id_idx") == {CALL_ID_INDEX: True}
|
||||
_assert_every_ledger_row_is_finished(scratch_database)
|
||||
_assert_no_pending_migrations(scratch_database)
|
||||
oids: Final = _index_oids(scratch_database)
|
||||
|
||||
assert _migration_job(use_v2_resolver) is True
|
||||
assert _index_oids(scratch_database) == oids
|
||||
|
||||
|
||||
@requires_db
|
||||
@RESOLVERS
|
||||
def test_a_database_that_applied_the_original_migration_files_sees_no_pending_migrations_and_no_rebuild(
|
||||
scratch_database: str, use_v2_resolver: bool, tmp_path: Path
|
||||
) -> None:
|
||||
"""A plain table upgraded on v1.103.0 applied both original files. The inert files in
|
||||
this build must neither re-run nor fail those rows, and the migration job must keep the
|
||||
indexes the migrations built."""
|
||||
deployed: Final = _migrate_deploy(scratch_database, _release_layout(tmp_path / "v1.103.0", "99999999999999"))
|
||||
assert deployed.returncode == 0, deployed.stderr
|
||||
before: Final = _ledger(scratch_database)
|
||||
assert before[API_KEY_INDEX_MIGRATION] == (True, False) and before[CALL_ID_INDEX_MIGRATION] == (True, False)
|
||||
oids: Final = _index_oids(scratch_database)
|
||||
assert {API_KEY_INDEX, CALL_ID_INDEX} <= set(oids)
|
||||
|
||||
_assert_no_pending_migrations(scratch_database)
|
||||
assert _migration_job(use_v2_resolver) is True
|
||||
|
||||
assert _ledger(scratch_database) == before
|
||||
assert _index_oids(scratch_database) == oids
|
||||
|
||||
|
||||
@requires_db
|
||||
@RESOLVERS
|
||||
def test_a_failed_call_id_ledger_row_from_a_v1_103_boot_is_rolled_back_and_the_inert_file_applied(
|
||||
partitioned_database: str, use_v2_resolver: bool, tmp_path: Path
|
||||
) -> None:
|
||||
_fail_the_call_id_migration_like_the_shipped_release(partitioned_database, tmp_path)
|
||||
|
||||
assert _migration_job(use_v2_resolver) is True
|
||||
|
||||
_assert_every_ledger_row_is_finished(partitioned_database)
|
||||
_assert_index_covers_every_partition(partitioned_database, API_KEY_INDEX, "api_key_startTime_idx")
|
||||
_assert_index_covers_every_partition(partitioned_database, CALL_ID_INDEX, "litellm_call_id_idx")
|
||||
_assert_no_pending_migrations(partitioned_database)
|
||||
with psycopg.connect(partitioned_database) as conn:
|
||||
rows: Final = conn.execute(
|
||||
"SELECT finished_at IS NOT NULL, rolled_back_at IS NOT NULL FROM _prisma_migrations "
|
||||
"WHERE migration_name = %s ORDER BY started_at",
|
||||
(CALL_ID_INDEX_MIGRATION,),
|
||||
).fetchall()
|
||||
assert rows == [(False, True), (True, False)], rows
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_a_failed_row_whose_migration_still_runs_sql_in_this_build_is_left_for_the_operator(
|
||||
partitioned_database: str, tmp_path: Path
|
||||
) -> None:
|
||||
_fail_the_call_id_migration_like_the_shipped_release(partitioned_database, tmp_path)
|
||||
still_building: Final = tmp_path / "edited" / CALL_ID_INDEX_MIGRATION / "migration.sql"
|
||||
still_building.parent.mkdir(parents=True)
|
||||
still_building.write_text(ORIGINAL_MIGRATION_SQL[CALL_ID_INDEX_MIGRATION])
|
||||
|
||||
with migration_lock(partitioned_database) as coordinator:
|
||||
assert roll_back_failed_inert_migration(coordinator, "public", still_building) is False
|
||||
|
||||
assert _ledger(partitioned_database)[CALL_ID_INDEX_MIGRATION] == (False, False)
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_a_migration_without_a_failed_row_is_not_touched(partitioned_database: str) -> None:
|
||||
inert: Final = PACKAGE / "migrations" / CALL_ID_INDEX_MIGRATION / "migration.sql"
|
||||
before: Final = _ledger(partitioned_database)
|
||||
|
||||
with migration_lock(partitioned_database) as coordinator:
|
||||
assert roll_back_failed_inert_migration(coordinator, "public", inert) is False
|
||||
|
||||
assert _ledger(partitioned_database) == before
|
||||
|
||||
|
||||
def _pin_a_snapshot_on(database_url: str, table: str) -> "psycopg.Connection[tuple[object, ...]]":
|
||||
pin: Final = psycopg.connect(database_url)
|
||||
pin.isolation_level = psycopg.IsolationLevel.REPEATABLE_READ
|
||||
pin.execute(sql.SQL("SELECT count(*) FROM {}").format(sql.Identifier(table)))
|
||||
return pin
|
||||
|
||||
|
||||
def _leave_an_invalid_index(database_url: str, name: str, table: str, column: str) -> None:
|
||||
with _pin_a_snapshot_on(database_url, table):
|
||||
with psycopg.connect(database_url, autocommit=True) as builder:
|
||||
builder.execute("SET statement_timeout = '1s'")
|
||||
with pytest.raises(psycopg.errors.QueryCanceled):
|
||||
builder.execute(
|
||||
sql.SQL("CREATE INDEX CONCURRENTLY {} ON {} ({})").format(
|
||||
sql.Identifier(name), sql.Identifier(table), sql.Identifier(column)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_an_invalid_index_of_the_managed_name_on_a_plain_table_is_rebuilt(scratch_database: str) -> None:
|
||||
_leave_an_invalid_index(scratch_database, CALL_ID_INDEX, "LiteLLM_SpendLogs", "litellm_call_id")
|
||||
assert _index_validity(scratch_database, "litellm_call_id_idx") == {CALL_ID_INDEX: False}
|
||||
|
||||
assert ensure_request_log_indexes(scratch_database, "public") is True
|
||||
|
||||
assert _index_validity(scratch_database, "litellm_call_id_idx") == {CALL_ID_INDEX: True}
|
||||
|
||||
|
||||
def _rebuild_as_another_replica(database_url: str, name: str, table: str, column: str) -> int:
|
||||
"""Drop and rebuild the index from a second connection, as a replica that won the
|
||||
race would, and return the oid of the index it built."""
|
||||
with psycopg.connect(database_url, autocommit=True) as other_replica:
|
||||
other_replica.execute(sql.SQL("DROP INDEX {}").format(sql.Identifier(name)))
|
||||
other_replica.execute(
|
||||
sql.SQL("CREATE INDEX {} ON {} ({})").format(
|
||||
sql.Identifier(name), sql.Identifier(table), sql.Identifier(column)
|
||||
)
|
||||
)
|
||||
return _index_oids(database_url)[name]
|
||||
|
||||
|
||||
def _connecting_with_another_replica_acting_first(
|
||||
statement: str, other_replica: Callable[[QueryNoTemplate], None]
|
||||
) -> Callable[[str], "psycopg.Connection[tuple[object, ...]]"]:
|
||||
"""A connect function whose cursors let `other_replica` act, once, right before the
|
||||
first statement containing `statement` runs: the interleaving two replicas booting
|
||||
together can produce, made deterministic."""
|
||||
raced: Final = threading.Event()
|
||||
|
||||
class _RacedCursor(psycopg.Cursor[tuple[object, ...]]):
|
||||
def execute( # pyright: ignore[reportIncompatibleMethodOverride] # the builder never runs a Template query
|
||||
self,
|
||||
query: QueryNoTemplate,
|
||||
params: "Params | None" = None,
|
||||
*,
|
||||
prepare: "bool | None" = None,
|
||||
binary: "bool | None" = None,
|
||||
) -> "_RacedCursor":
|
||||
text: Final = query.as_string(self.connection) if isinstance(query, sql.Composable) else query
|
||||
if isinstance(text, str) and statement in text and not raced.is_set():
|
||||
raced.set()
|
||||
other_replica(query)
|
||||
return super().execute(query, params, prepare=prepare, binary=binary)
|
||||
|
||||
def connect(database_url: str) -> "psycopg.Connection[tuple[object, ...]]":
|
||||
return psycopg.connect(database_url, autocommit=True, cursor_factory=_RacedCursor)
|
||||
|
||||
return connect
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_an_index_another_replica_made_valid_before_the_lock_was_taken_is_kept(scratch_database: str) -> None:
|
||||
"""Two replicas boot against the same invalid index. The one that takes the lock
|
||||
second must read the catalog again under it, or it drops the valid index the first
|
||||
one just finished and starts the whole build over."""
|
||||
_leave_an_invalid_index(scratch_database, CALL_ID_INDEX, "LiteLLM_SpendLogs", "litellm_call_id")
|
||||
theirs: Final[queue.SimpleQueue[int]] = queue.SimpleQueue()
|
||||
connect: Final = _connecting_with_another_replica_acting_first(
|
||||
"pg_try_advisory_lock",
|
||||
lambda _: theirs.put(
|
||||
_rebuild_as_another_replica(scratch_database, CALL_ID_INDEX, "LiteLLM_SpendLogs", "litellm_call_id")
|
||||
),
|
||||
)
|
||||
|
||||
assert ensure_request_log_indexes(scratch_database, "public", (CALL_ID_INDEX_DEFINITION,), connect) is True
|
||||
|
||||
assert _index_oids(scratch_database)[CALL_ID_INDEX] == theirs.get_nowait()
|
||||
assert _index_validity(scratch_database, "litellm_call_id_idx") == {CALL_ID_INDEX: True}
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_a_child_index_another_replica_attached_first_is_not_attached_twice(partitioned_database: str) -> None:
|
||||
"""A replica that reaches the attach step after another one attached the same child
|
||||
relies on ATTACH PARTITION being a no-op for an index already under that parent
|
||||
(PostgreSQL 14 ALTER INDEX, ATExecAttachPartitionIdx, checked 2026-10-01); this test
|
||||
is where that would surface if a future version or a code change made it an error."""
|
||||
|
||||
def attach_as_another_replica(statement: QueryNoTemplate) -> None:
|
||||
with psycopg.connect(partitioned_database, autocommit=True) as other_replica:
|
||||
other_replica.execute(statement)
|
||||
|
||||
connect: Final = _connecting_with_another_replica_acting_first("ATTACH PARTITION", attach_as_another_replica)
|
||||
|
||||
assert ensure_request_log_indexes(partitioned_database, "public", (CALL_ID_INDEX_DEFINITION,), connect) is True
|
||||
|
||||
_assert_index_covers_every_partition(partitioned_database, CALL_ID_INDEX, "litellm_call_id_idx")
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_an_invalid_child_index_left_by_an_interrupted_build_is_rebuilt_and_attached(
|
||||
partitioned_database: str,
|
||||
) -> None:
|
||||
partition: Final = "LiteLLM_SpendLogs_p2026_08"
|
||||
child: Final = f"{partition}_litellm_call_id_idx"
|
||||
_leave_an_invalid_index(partitioned_database, child, partition, "litellm_call_id")
|
||||
assert _index_validity(partitioned_database, "litellm_call_id_idx") == {child: False}
|
||||
|
||||
assert ensure_request_log_indexes(partitioned_database, "public") is True
|
||||
|
||||
_assert_index_covers_every_partition(partitioned_database, CALL_ID_INDEX, "litellm_call_id_idx")
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_an_index_of_that_name_on_another_table_is_left_alone_and_reported(partitioned_database: str) -> None:
|
||||
with psycopg.connect(partitioned_database, autocommit=True) as conn:
|
||||
conn.execute(f'CREATE INDEX "{CALL_ID_INDEX}" ON "LiteLLM_ErrorLogs" ("request_id")')
|
||||
|
||||
assert ensure_request_log_indexes(partitioned_database, "public") is False
|
||||
|
||||
assert _index_validity(partitioned_database, "litellm_call_id_idx") == {}
|
||||
assert _indexed_table(partitioned_database, CALL_ID_INDEX) == "LiteLLM_ErrorLogs"
|
||||
_assert_index_covers_every_partition(partitioned_database, API_KEY_INDEX, "api_key_startTime_idx")
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_an_invalid_index_of_a_child_name_on_another_table_is_not_dropped(partitioned_database: str) -> None:
|
||||
child: Final = "LiteLLM_SpendLogs_p2026_08_litellm_call_id_idx"
|
||||
_leave_an_invalid_index(partitioned_database, child, "LiteLLM_ErrorLogs", "request_id")
|
||||
|
||||
assert ensure_request_log_indexes(partitioned_database, "public") is False
|
||||
|
||||
assert _indexed_table(partitioned_database, child) == "LiteLLM_ErrorLogs"
|
||||
assert _attached_children(partitioned_database, CALL_ID_INDEX) == frozenset()
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_a_process_holding_the_migration_lock_makes_the_build_wait_for_the_next_job_run(scratch_database: str) -> None:
|
||||
with psycopg.connect(scratch_database, autocommit=True) as other_replica:
|
||||
other_replica.execute("SELECT pg_advisory_lock(%s)", (MIGRATION_LOCK_KEY,))
|
||||
assert ensure_request_log_indexes(scratch_database, "public") is False
|
||||
assert _index_validity(scratch_database, "litellm_call_id_idx") == {}
|
||||
|
||||
assert ensure_request_log_indexes(scratch_database, "public") is True
|
||||
assert _index_validity(scratch_database, "litellm_call_id_idx") == {CALL_ID_INDEX: True}
|
||||
|
||||
|
||||
@requires_db
|
||||
@RESOLVERS
|
||||
def test_a_migration_job_that_could_not_build_the_indexes_reports_failure_and_succeeds_when_rerun(
|
||||
scratch_database: str, use_v2_resolver: bool
|
||||
) -> None:
|
||||
"""The migration job waits for the build and exits by run_migration_job's result; a job
|
||||
that exits 0 with the indexes missing would leave the table unindexed until the next
|
||||
deploy or until a serving proxy's background build gets to them."""
|
||||
with psycopg.connect(scratch_database, autocommit=True) as other_replica:
|
||||
other_replica.execute("SELECT pg_advisory_lock(%s)", (MIGRATION_LOCK_KEY,))
|
||||
assert _migration_job(use_v2_resolver) is False
|
||||
_assert_every_ledger_row_is_finished(scratch_database)
|
||||
assert _index_validity(scratch_database, "litellm_call_id_idx") == {}
|
||||
|
||||
assert _migration_job(use_v2_resolver) is True
|
||||
assert _index_validity(scratch_database, "litellm_call_id_idx") == {CALL_ID_INDEX: True}
|
||||
assert _index_validity(scratch_database, "api_key_startTime_idx") == {API_KEY_INDEX: True}
|
||||
|
||||
|
||||
@requires_db
|
||||
@RESOLVERS
|
||||
def test_the_serving_proxy_setup_applies_the_inert_migrations_and_builds_no_index(
|
||||
partitioned_database: str, use_v2_resolver: bool
|
||||
) -> None:
|
||||
"""setup_database alone applies the inert files and builds nothing, so a serving proxy's
|
||||
readiness is never held up by an index build; the build it starts afterwards, or the
|
||||
migration job, is what puts the indexes in place."""
|
||||
api_key_index_before: Final = _index_validity(partitioned_database, "api_key_startTime_idx")
|
||||
assert ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=use_v2_resolver) is True
|
||||
|
||||
_assert_every_ledger_row_is_finished(partitioned_database)
|
||||
_assert_no_pending_migrations(partitioned_database)
|
||||
assert _index_validity(partitioned_database, "litellm_call_id_idx") == {}
|
||||
assert _index_validity(partitioned_database, "api_key_startTime_idx") == api_key_index_before
|
||||
|
||||
assert _migration_job(use_v2_resolver) is True
|
||||
_assert_index_covers_every_partition(partitioned_database, API_KEY_INDEX, "api_key_startTime_idx")
|
||||
_assert_index_covers_every_partition(partitioned_database, CALL_ID_INDEX, "litellm_call_id_idx")
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_a_role_that_may_not_create_indexes_is_logged_and_left_for_the_next_job_run(
|
||||
scratch_database: str, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with psycopg.connect(scratch_database, autocommit=True) as conn:
|
||||
conn.execute("REVOKE CREATE ON SCHEMA public FROM PUBLIC")
|
||||
conn.execute("CREATE ROLE spend_logs_reader LOGIN PASSWORD 'reader'")
|
||||
conn.execute("GRANT USAGE ON SCHEMA public TO spend_logs_reader")
|
||||
conn.execute('GRANT SELECT ON "LiteLLM_SpendLogs" TO spend_logs_reader')
|
||||
reader_url: Final = scratch_database.replace("postgres:postgres@", "spend_logs_reader:reader@", 1)
|
||||
try:
|
||||
with caplog.at_level("WARNING", logger="litellm_proxy_extras"):
|
||||
assert ensure_request_log_indexes(reader_url, "public") is False
|
||||
finally:
|
||||
with psycopg.connect(scratch_database, autocommit=True) as conn:
|
||||
conn.execute("DROP OWNED BY spend_logs_reader")
|
||||
conn.execute("DROP ROLE spend_logs_reader")
|
||||
assert "leaving them for the next index build" in caplog.text
|
||||
assert _index_validity(scratch_database, "litellm_call_id_idx") == {}
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_inserts_keep_flowing_while_the_partition_indexes_build(partitioned_database: str) -> None:
|
||||
"""With a write open on one partition, the parent index goes on ONLY the parent and
|
||||
the CONCURRENTLY child build waits for that write without blocking new INSERTs. A
|
||||
plain CREATE INDEX on the parent would wait for the same write while holding SHARE
|
||||
on the parent, queueing every new INSERT behind it."""
|
||||
outcome: Final[list[bool]] = [] # mutable-ok: the builder thread hands its result back through it
|
||||
with psycopg.connect(partitioned_database) as writer:
|
||||
_insert_spend_log(writer, "LiteLLM_SpendLogs_p2026_08-open", "2026-08-15", table="LiteLLM_SpendLogs_p2026_08")
|
||||
builder_thread: Final = threading.Thread(
|
||||
target=lambda: outcome.append(_build_in_its_own_session(partitioned_database, CALL_ID_INDEX_DEFINITION))
|
||||
)
|
||||
builder_thread.start()
|
||||
try:
|
||||
_wait_until_the_build_is_waiting(partitioned_database)
|
||||
with psycopg.connect(partitioned_database, autocommit=True) as late_writer:
|
||||
late_writer.execute("SET lock_timeout = '1s'")
|
||||
_insert_spend_log(late_writer, "LiteLLM_SpendLogs_p2026_08-late", "2026-08-16")
|
||||
finally:
|
||||
writer.commit()
|
||||
builder_thread.join()
|
||||
assert outcome == [True]
|
||||
_assert_index_covers_every_partition(partitioned_database, CALL_ID_INDEX, "litellm_call_id_idx")
|
||||
|
||||
|
||||
def _build_in_its_own_session(database_url: str, index: RequestLogIndex) -> bool:
|
||||
with psycopg.connect(database_url, autocommit=True) as builder:
|
||||
return build_index_on_partitioned_table(builder, "public", index)
|
||||
|
||||
|
||||
def _wait_until_the_build_is_waiting(database_url: str) -> None:
|
||||
deadline: Final = time.monotonic() + 30
|
||||
with psycopg.connect(database_url, autocommit=True) as conn:
|
||||
while time.monotonic() < deadline:
|
||||
waiting = conn.execute(
|
||||
"SELECT 1 FROM pg_stat_activity WHERE query LIKE 'CREATE INDEX%' AND wait_event_type IS NOT NULL"
|
||||
).fetchone()
|
||||
if waiting is not None:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
pytest.fail("the partition index build never started waiting on the open write")
|
||||
|
||||
|
||||
def _create_index(database_url: str, name: str, table: str, columns: str) -> int:
|
||||
"""Create a plain index by hand, the way an operator's workaround would, and return its oid."""
|
||||
with psycopg.connect(database_url, autocommit=True) as conn:
|
||||
conn.execute(
|
||||
sql.SQL("CREATE INDEX {} ON {} {}").format(sql.Identifier(name), sql.Identifier(table), sql.SQL(columns))
|
||||
)
|
||||
return _index_oids(database_url)[name]
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_a_valid_index_of_the_same_definition_under_another_name_is_renamed_instead_of_rebuilt(
|
||||
scratch_database: str,
|
||||
) -> None:
|
||||
hand_built: Final = _create_index(scratch_database, "call_id_by_hand", "LiteLLM_SpendLogs", '("litellm_call_id")')
|
||||
|
||||
assert ensure_request_log_indexes(scratch_database, "public") is True
|
||||
|
||||
oids: Final = _index_oids(scratch_database)
|
||||
assert "call_id_by_hand" not in oids and oids[CALL_ID_INDEX] == hand_built
|
||||
assert _index_validity(scratch_database, "litellm_call_id_idx") == {CALL_ID_INDEX: True}
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_a_hand_built_child_index_under_another_name_is_renamed_and_attached(partitioned_database: str) -> None:
|
||||
partition: Final = "LiteLLM_SpendLogs_p2026_08"
|
||||
hand_built: Final = _create_index(
|
||||
partitioned_database, "p2026_08_call_id_by_hand", partition, '("litellm_call_id")'
|
||||
)
|
||||
|
||||
assert ensure_request_log_indexes(partitioned_database, "public") is True
|
||||
|
||||
_assert_index_covers_every_partition(partitioned_database, CALL_ID_INDEX, "litellm_call_id_idx")
|
||||
oids: Final = _index_oids(partitioned_database)
|
||||
assert "p2026_08_call_id_by_hand" not in oids and oids[f"{partition}_litellm_call_id_idx"] == hand_built
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_an_index_with_another_definition_is_not_taken_for_the_managed_one(scratch_database: str) -> None:
|
||||
with psycopg.connect(scratch_database, autocommit=True) as conn:
|
||||
conn.execute(sql.SQL("DROP INDEX {}").format(sql.Identifier(API_KEY_INDEX)))
|
||||
others: Final = {
|
||||
"time_then_key": _create_index(
|
||||
scratch_database, "time_then_key", "LiteLLM_SpendLogs", '("startTime", "api_key")'
|
||||
),
|
||||
"call_id_desc": _create_index(
|
||||
scratch_database, "call_id_desc", "LiteLLM_SpendLogs", '("litellm_call_id" DESC)'
|
||||
),
|
||||
"call_id_then_key": _create_index(
|
||||
scratch_database, "call_id_then_key", "LiteLLM_SpendLogs", '("litellm_call_id", "api_key")'
|
||||
),
|
||||
"call_id_pattern": _create_index(
|
||||
scratch_database, "call_id_pattern", "LiteLLM_SpendLogs", '("litellm_call_id" text_pattern_ops)'
|
||||
),
|
||||
}
|
||||
|
||||
assert ensure_request_log_indexes(scratch_database, "public") is True
|
||||
|
||||
oids: Final = _index_oids(scratch_database)
|
||||
assert {name: oids[name] for name in others} == others
|
||||
assert _index_validity(scratch_database, "litellm_call_id_idx") == {CALL_ID_INDEX: True}
|
||||
assert _index_validity(scratch_database, "api_key_startTime_idx") == {API_KEY_INDEX: True}
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_a_valid_partitioned_parent_index_under_another_name_is_renamed_with_its_children_kept(
|
||||
partitioned_database: str, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
hand_built: Final = _create_index(
|
||||
partitioned_database, "call_id_parent_by_hand", "LiteLLM_SpendLogs", '("litellm_call_id")'
|
||||
)
|
||||
children_before: Final = _attached_children(partitioned_database, "call_id_parent_by_hand")
|
||||
|
||||
with caplog.at_level("INFO", logger="litellm_proxy_extras"):
|
||||
assert ensure_request_log_indexes(partitioned_database, "public") is True
|
||||
|
||||
assert "Building index" not in caplog.text
|
||||
oids: Final = _index_oids(partitioned_database)
|
||||
assert "call_id_parent_by_hand" not in oids and oids[CALL_ID_INDEX] == hand_built
|
||||
assert _attached_children(partitioned_database, CALL_ID_INDEX) == children_before
|
||||
assert _index_validity(partitioned_database, "litellm_call_id_idx")[CALL_ID_INDEX] is True
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_a_second_copy_of_a_managed_index_is_reported_with_its_drop_statement_and_left_in_place(
|
||||
scratch_database: str, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
assert ensure_request_log_indexes(scratch_database, "public") is True
|
||||
copy: Final = _create_index(scratch_database, "call_id_copy", "LiteLLM_SpendLogs", '("litellm_call_id")')
|
||||
|
||||
with caplog.at_level("WARNING", logger="litellm_proxy_extras"):
|
||||
assert ensure_request_log_indexes(scratch_database, "public") is True
|
||||
|
||||
assert 'remove it with: DROP INDEX CONCURRENTLY "public"."call_id_copy"' in caplog.text
|
||||
assert _index_oids(scratch_database)["call_id_copy"] == copy
|
||||
0
tests/unit/harness/__init__.py
Normal file
0
tests/unit/harness/__init__.py
Normal file
246
tests/unit/harness/core_fakes.py
Normal file
246
tests/unit/harness/core_fakes.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"""Fake handler/config, sandbox and endpoint shared by the core runtime tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.harness import runtime
|
||||
from litellm.harness.context import SessionContext
|
||||
from litellm.harness.handlers.base import BaseHarnessHandler
|
||||
from litellm.llms.base_llm.harness.transformation import BaseHarnessConfig
|
||||
from litellm.harness.options import ClaudeCodeOptions
|
||||
from litellm.harness.sandbox.base import CompletedRun
|
||||
from litellm.harness.sandbox.snapshot import snapshot_local
|
||||
from litellm.harness.types import (
|
||||
Approval,
|
||||
Capabilities,
|
||||
Event,
|
||||
Harness,
|
||||
Text,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
|
||||
ALL_MODES = frozenset({"read-only", "ask", "edit", "full"})
|
||||
FULL_CAPS = Capabilities(
|
||||
structured_output=True,
|
||||
tool_approval=True,
|
||||
tool_filtering=True,
|
||||
history=True,
|
||||
custom_tools=True,
|
||||
skills=True,
|
||||
resume=True,
|
||||
permission_modes=ALL_MODES,
|
||||
)
|
||||
NARROW_CAPS = Capabilities(
|
||||
structured_output=False,
|
||||
tool_approval=False,
|
||||
tool_filtering=False,
|
||||
history=False,
|
||||
custom_tools=False,
|
||||
skills=False,
|
||||
resume=False,
|
||||
permission_modes=frozenset({"read-only", "full"}),
|
||||
)
|
||||
|
||||
|
||||
class FakeConfig(BaseHarnessConfig):
|
||||
"""Declares the fake harness; per-test subclasses override capabilities."""
|
||||
|
||||
harness: ClassVar[Harness] = Harness.CLAUDE_CODE
|
||||
options_type: ClassVar[type] = ClaudeCodeOptions
|
||||
capabilities: ClassVar[Capabilities] = FULL_CAPS
|
||||
uses_model_endpoint: ClassVar[bool] = True
|
||||
|
||||
|
||||
Script = Callable[["FakeAdapter", SessionContext, str], AsyncIterator[Event]]
|
||||
|
||||
|
||||
class FakeSandbox:
|
||||
"""A LocalSandbox-like object over a temp dir; no subprocesses."""
|
||||
|
||||
def __init__(self, workdir: str) -> None:
|
||||
self.workdir = workdir
|
||||
self.closed = False
|
||||
|
||||
def _path(self, path: str) -> str:
|
||||
return path if os.path.isabs(path) else os.path.join(self.workdir, path)
|
||||
|
||||
async def exec(self, cmd: list[str], *, env: Any = None, cwd: Any = None) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
async def run(
|
||||
self, cmd: list[str], *, env: Any = None, cwd: Any = None, timeout: Any = None
|
||||
) -> CompletedRun:
|
||||
return CompletedRun(stdout="", stderr="", exit_code=0)
|
||||
|
||||
async def read(self, path: str) -> bytes:
|
||||
with open(self._path(path), "rb") as fh:
|
||||
return fh.read()
|
||||
|
||||
async def write(self, path: str, data: bytes) -> None:
|
||||
full = self._path(path)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "wb") as fh:
|
||||
fh.write(data)
|
||||
|
||||
def host_url(self, port: int) -> str:
|
||||
return f"http://127.0.0.1:{port}"
|
||||
|
||||
async def which(self, binary: str) -> str | None:
|
||||
return None
|
||||
|
||||
async def snapshot(self) -> dict[str, str]:
|
||||
return await snapshot_local(self.workdir)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeUsage:
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cost: float = 0.0
|
||||
calls: int = 0
|
||||
|
||||
def add(self, input_tokens: int, output_tokens: int, cost: float) -> None:
|
||||
self.input_tokens += input_tokens
|
||||
self.output_tokens += output_tokens
|
||||
self.cost += cost
|
||||
self.calls += 1
|
||||
|
||||
|
||||
class FakeEndpoint:
|
||||
"""Stands in for ModelEndpoint; records every instance."""
|
||||
|
||||
instances: ClassVar[list[FakeEndpoint]] = []
|
||||
|
||||
def __init__(self, harness: Harness, model: Any, gateway: Any, **kwargs: Any):
|
||||
self.harness = harness
|
||||
self.model = model
|
||||
self.gateway = gateway
|
||||
self.kwargs = kwargs
|
||||
self.usage = FakeUsage()
|
||||
self.url = "http://127.0.0.1:1"
|
||||
self.token = "tok"
|
||||
self.entered = False
|
||||
self.exited = False
|
||||
FakeEndpoint.instances.append(self)
|
||||
|
||||
async def __aenter__(self) -> FakeEndpoint:
|
||||
self.entered = True
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
self.exited = True
|
||||
|
||||
|
||||
async def script_hello(
|
||||
adapter: FakeAdapter, ctx: SessionContext, prompt: str
|
||||
) -> AsyncIterator[Event]:
|
||||
yield Text("hello ")
|
||||
yield ToolCall(id="t1", name="bash", native_name="Bash", input={"cmd": "ls"})
|
||||
yield ToolResult(id="t1", output="a.txt")
|
||||
yield Text("world")
|
||||
if ctx.endpoint is not None:
|
||||
ctx.endpoint.usage.add(10, 5, 0.25)
|
||||
else:
|
||||
ctx.input_tokens += 10
|
||||
ctx.output_tokens += 5
|
||||
ctx.cost += 0.25
|
||||
ctx.calls += 1
|
||||
|
||||
|
||||
class FakeAdapter(BaseHarnessHandler):
|
||||
"""Configurable adapter; subclass per test and set `script` / `caps`."""
|
||||
|
||||
harness: ClassVar[Harness] = Harness.CLAUDE_CODE
|
||||
options_type: ClassVar[type] = ClaudeCodeOptions
|
||||
capabilities: ClassVar[Capabilities] = FULL_CAPS
|
||||
uses_endpoint: ClassVar[bool] = True
|
||||
script: ClassVar[Script] = script_hello
|
||||
instances: ClassVar[list[FakeAdapter]] = []
|
||||
|
||||
def __init__(self, config: BaseHarnessConfig | None = None) -> None:
|
||||
self.config = config if config is not None else FakeConfig()
|
||||
self.calls: list[str] = []
|
||||
self.prompts: list[str] = []
|
||||
self.resumed_with: str | None = None
|
||||
self.approvals: list[tuple[bool, str]] = []
|
||||
type(self).instances.append(self)
|
||||
|
||||
async def start(self, ctx: SessionContext) -> None:
|
||||
self.calls.append("start")
|
||||
|
||||
async def turn(self, ctx: SessionContext, prompt: str) -> AsyncIterator[Event]:
|
||||
self.calls.append("turn")
|
||||
self.prompts.append(prompt)
|
||||
async for event in type(self).script(self, ctx, prompt):
|
||||
yield event
|
||||
|
||||
async def stop(self, ctx: SessionContext) -> None:
|
||||
self.calls.append("stop")
|
||||
|
||||
def native_session_id(self) -> str | None:
|
||||
return "native-123"
|
||||
|
||||
async def resume(self, ctx: SessionContext, native_session_id: str) -> None:
|
||||
self.calls.append("resume")
|
||||
self.resumed_with = native_session_id
|
||||
|
||||
async def history(self, ctx: SessionContext) -> list[dict[str, Any]]:
|
||||
return [{"role": "user", "content": p} for p in self.prompts]
|
||||
|
||||
|
||||
async def script_approval(
|
||||
adapter: FakeAdapter, ctx: SessionContext, prompt: str
|
||||
) -> AsyncIterator[Event]:
|
||||
approval = Approval(tool="bash", input={"cmd": "rm"})
|
||||
yield approval
|
||||
decision = await approval.wait()
|
||||
adapter.approvals.append(decision)
|
||||
yield Text("allowed" if decision[0] else "denied")
|
||||
|
||||
|
||||
def install_adapter(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
script: Script = script_hello,
|
||||
caps: Capabilities = FULL_CAPS,
|
||||
uses_endpoint: bool = True,
|
||||
) -> type[FakeAdapter]:
|
||||
"""Register a FakeAdapter subclass for every harness and fake the endpoint."""
|
||||
adapter_cls = type(
|
||||
"TestAdapter",
|
||||
(FakeAdapter,),
|
||||
{
|
||||
"script": staticmethod(script),
|
||||
"capabilities": caps,
|
||||
"uses_endpoint": uses_endpoint,
|
||||
"instances": [],
|
||||
},
|
||||
)
|
||||
config_cls = type(
|
||||
"TestConfig",
|
||||
(FakeConfig,),
|
||||
{"capabilities": caps, "uses_model_endpoint": uses_endpoint},
|
||||
)
|
||||
monkeypatch.setattr(runtime, "get_harness_config", lambda harness: config_cls())
|
||||
monkeypatch.setattr(
|
||||
runtime, "get_harness_handler", lambda config: adapter_cls(config)
|
||||
)
|
||||
monkeypatch.setattr(runtime, "ModelEndpoint", FakeEndpoint)
|
||||
monkeypatch.delenv("LITELLM_PROXY_API_BASE", raising=False)
|
||||
monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False)
|
||||
FakeEndpoint.instances = []
|
||||
return adapter_cls
|
||||
|
||||
|
||||
async def wait_forever() -> None:
|
||||
await asyncio.Event().wait()
|
||||
0
tests/unit/harness/handlers/__init__.py
Normal file
0
tests/unit/harness/handlers/__init__.py
Normal file
378
tests/unit/harness/handlers/test_deepagents_handler.py
Normal file
378
tests/unit/harness/handlers/test_deepagents_handler.py
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import asyncio
|
||||
import builtins
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.harness.context import GatewayTarget, SessionContext
|
||||
from litellm.harness.errors import HarnessError, HarnessInstallFailed
|
||||
from litellm.harness.handlers import deepagents_handler as dh
|
||||
from litellm.harness.sandbox.local import LocalSandbox
|
||||
from litellm.harness.types import Approval, Harness, Text, ToolCall, ToolResult
|
||||
from litellm.llms.deepagents.harness.transformation import DeepAgentsHarnessConfig
|
||||
|
||||
pytest.importorskip("deepagents")
|
||||
pytest.importorskip("langchain_litellm")
|
||||
|
||||
from langchain_core.language_models.fake_chat_models import ( # noqa: E402
|
||||
FakeMessagesListChatModel,
|
||||
)
|
||||
from langchain_core.messages import AIMessage # noqa: E402
|
||||
|
||||
from litellm.llms.deepagents.harness.sandbox_backend import ( # noqa: E402
|
||||
SandboxBackend,
|
||||
message_cost,
|
||||
)
|
||||
|
||||
USAGE = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
|
||||
|
||||
|
||||
class FakeToolModel(FakeMessagesListChatModel):
|
||||
"""Canned responses; records the tool names bound on each call."""
|
||||
|
||||
bound: list = []
|
||||
|
||||
def bind_tools(self, tools: Any, **kwargs: Any) -> "FakeToolModel":
|
||||
names = [getattr(t, "name", None) or t.get("name") for t in tools]
|
||||
self.bound.append(sorted(n for n in names if n))
|
||||
return self
|
||||
|
||||
|
||||
def tool_call(name: str, args: dict, call_id: str) -> AIMessage:
|
||||
return AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": name, "args": args, "id": call_id}],
|
||||
usage_metadata=USAGE,
|
||||
)
|
||||
|
||||
|
||||
def final(text: str) -> AIMessage:
|
||||
return AIMessage(content=text, usage_metadata=USAGE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_model(monkeypatch: pytest.MonkeyPatch):
|
||||
def install(responses: list) -> FakeToolModel:
|
||||
model = FakeToolModel(responses=responses, bound=[])
|
||||
monkeypatch.setattr(dh, "build_chat_model", lambda ctx, deps: model)
|
||||
return model
|
||||
|
||||
return install
|
||||
|
||||
|
||||
def make_ctx(tmp_path: Path, **kwargs: Any) -> SessionContext:
|
||||
base: dict[str, Any] = {
|
||||
"harness": Harness.DEEPAGENTS,
|
||||
"sandbox": LocalSandbox(tmp_path),
|
||||
"session_id": f"s-{os.urandom(4).hex()}",
|
||||
"model": "gpt-4o-mini",
|
||||
}
|
||||
return SessionContext(**{**base, **kwargs})
|
||||
|
||||
|
||||
def make_handler() -> dh.DeepAgentsHandler:
|
||||
return dh.DeepAgentsHandler(DeepAgentsHarnessConfig())
|
||||
|
||||
|
||||
async def started(ctx: SessionContext) -> dh.DeepAgentsHandler:
|
||||
handler = make_handler()
|
||||
await handler.start(ctx)
|
||||
return handler
|
||||
|
||||
|
||||
async def run_turn(
|
||||
handler: dh.DeepAgentsHandler,
|
||||
ctx: SessionContext,
|
||||
prompt: str,
|
||||
approve: bool = True,
|
||||
) -> list:
|
||||
events = []
|
||||
async for event in handler.turn(ctx, prompt):
|
||||
events.append(event)
|
||||
if isinstance(event, Approval):
|
||||
event.allow() if approve else event.deny("no")
|
||||
return events
|
||||
|
||||
|
||||
async def test_write_then_read_events_and_file(tmp_path: Path, fake_model) -> None:
|
||||
fake_model(
|
||||
[
|
||||
tool_call("write_file", {"file_path": "/hello.txt", "content": "hi"}, "c1"),
|
||||
tool_call("read_file", {"file_path": "/hello.txt"}, "c2"),
|
||||
final("done"),
|
||||
]
|
||||
)
|
||||
ctx = make_ctx(tmp_path)
|
||||
handler = await started(ctx)
|
||||
events = await run_turn(handler, ctx, "write hello.txt with hi")
|
||||
|
||||
calls = [e for e in events if isinstance(e, ToolCall)]
|
||||
results = [e for e in events if isinstance(e, ToolResult)]
|
||||
assert [(c.name, c.native_name, c.builtin) for c in calls] == [
|
||||
("write", "write_file", True),
|
||||
("read", "read_file", True),
|
||||
]
|
||||
assert [r.id for r in results] == ["c1", "c2"]
|
||||
assert "hi" in results[1].output
|
||||
assert not any(r.is_error for r in results)
|
||||
assert "done" in "".join(e.delta for e in events if isinstance(e, Text))
|
||||
assert (tmp_path / "hello.txt").read_text() == "hi"
|
||||
assert ctx.final_text == "done"
|
||||
assert (ctx.input_tokens, ctx.output_tokens, ctx.calls) == (30, 15, 3)
|
||||
assert ctx.cost > 0
|
||||
history = await handler.history(ctx)
|
||||
assert history[0] == {"role": "user", "content": "write hello.txt with hi"}
|
||||
assert history[-1]["content"] == "done"
|
||||
|
||||
|
||||
async def test_read_only_hides_write_tools(tmp_path: Path, fake_model) -> None:
|
||||
model = fake_model(
|
||||
[
|
||||
tool_call("write_file", {"file_path": "/x.txt", "content": "no"}, "c1"),
|
||||
final("ok"),
|
||||
]
|
||||
)
|
||||
ctx = make_ctx(tmp_path, permissions="read-only")
|
||||
handler = await started(ctx)
|
||||
events = await run_turn(handler, ctx, "try to write")
|
||||
|
||||
first = model.bound[0]
|
||||
assert "read_file" in first and "ls" in first
|
||||
assert not {"write_file", "edit_file", "execute", "delete"} & set(first)
|
||||
result = next(e for e in events if isinstance(e, ToolResult))
|
||||
assert result.is_error
|
||||
assert not (tmp_path / "x.txt").exists()
|
||||
|
||||
|
||||
async def test_disable_tools_uses_normalized_names(tmp_path: Path, fake_model) -> None:
|
||||
model = fake_model([final("ok")])
|
||||
ctx = make_ctx(tmp_path, disable_tools=["bash", "grep"])
|
||||
handler = await started(ctx)
|
||||
await run_turn(handler, ctx, "hi")
|
||||
assert "execute" not in model.bound[0] and "grep" not in model.bound[0]
|
||||
assert "write_file" in model.bound[0]
|
||||
|
||||
|
||||
class Answer(BaseModel):
|
||||
city: str
|
||||
|
||||
|
||||
async def test_structured_output(tmp_path: Path, fake_model) -> None:
|
||||
fake_model([tool_call("Answer", {"city": "Paris"}, "c1")])
|
||||
ctx = make_ctx(tmp_path, output=Answer)
|
||||
handler = await started(ctx)
|
||||
events = await run_turn(handler, ctx, "capital of France?")
|
||||
assert Answer.model_validate_json(ctx.output_json or "") == Answer(city="Paris")
|
||||
assert not any(isinstance(e, ToolCall) for e in events)
|
||||
|
||||
|
||||
async def test_custom_tool(tmp_path: Path, fake_model) -> None:
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
fake_model([tool_call("add", {"a": 2, "b": 3}, "c1"), final("5")])
|
||||
ctx = make_ctx(tmp_path, tools=[add])
|
||||
handler = await started(ctx)
|
||||
events = await run_turn(handler, ctx, "2+3")
|
||||
call = next(e for e in events if isinstance(e, ToolCall))
|
||||
assert (call.name, call.builtin) == ("add", False)
|
||||
assert next(e for e in events if isinstance(e, ToolResult)).output == "5"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("approve", [True, False])
|
||||
async def test_ask_permissions_emit_approval(
|
||||
tmp_path: Path, fake_model, approve: bool
|
||||
) -> None:
|
||||
fake_model(
|
||||
[
|
||||
tool_call("write_file", {"file_path": "/a.txt", "content": "x"}, "c1"),
|
||||
final("end"),
|
||||
]
|
||||
)
|
||||
ctx = make_ctx(tmp_path, permissions="ask")
|
||||
handler = await started(ctx)
|
||||
events = await run_turn(handler, ctx, "write a", approve=approve)
|
||||
approval = next(e for e in events if isinstance(e, Approval))
|
||||
assert approval.tool == "write"
|
||||
assert approval.input["file_path"] == "/a.txt"
|
||||
assert (tmp_path / "a.txt").exists() is approve
|
||||
assert ctx.final_text == "end"
|
||||
|
||||
|
||||
async def test_edit_and_execute_through_sandbox(tmp_path: Path, fake_model) -> None:
|
||||
(tmp_path / "f.txt").write_text("one two\n")
|
||||
fake_model(
|
||||
[
|
||||
tool_call(
|
||||
"edit_file",
|
||||
{"file_path": "/f.txt", "old_string": "two", "new_string": "three"},
|
||||
"c1",
|
||||
),
|
||||
tool_call("execute", {"command": "cat f.txt"}, "c2"),
|
||||
final("ok"),
|
||||
]
|
||||
)
|
||||
ctx = make_ctx(tmp_path)
|
||||
handler = await started(ctx)
|
||||
events = await run_turn(handler, ctx, "edit")
|
||||
calls = [e.name for e in events if isinstance(e, ToolCall)]
|
||||
assert calls == ["edit", "bash"]
|
||||
results = [e for e in events if isinstance(e, ToolResult)]
|
||||
assert "one three" in results[1].output
|
||||
assert (tmp_path / "f.txt").read_text() == "one three\n"
|
||||
|
||||
|
||||
async def test_resume_keeps_thread(tmp_path: Path, fake_model) -> None:
|
||||
fake_model([final("first"), final("second")])
|
||||
ctx = make_ctx(tmp_path)
|
||||
handler = await started(ctx)
|
||||
await run_turn(handler, ctx, "one")
|
||||
native = handler.native_session_id()
|
||||
assert native == ctx.session_id
|
||||
|
||||
other = make_handler()
|
||||
ctx2 = make_ctx(tmp_path)
|
||||
await other.start(ctx2)
|
||||
await other.resume(ctx2, native or "")
|
||||
await run_turn(other, ctx2, "two")
|
||||
history = await other.history(ctx2)
|
||||
assert [m["content"] for m in history if m["role"] == "user"] == ["one", "two"]
|
||||
|
||||
|
||||
async def test_skills_copied_and_loaded(tmp_path: Path, fake_model) -> None:
|
||||
skill = tmp_path / "src-skills" / "greeter"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: greeter\ndescription: Says hi\n---\nSay hi.\n"
|
||||
)
|
||||
work = tmp_path / "work"
|
||||
work.mkdir()
|
||||
fake_model([final("ok")])
|
||||
ctx = make_ctx(work, skills=[str(skill)])
|
||||
handler = await started(ctx)
|
||||
await run_turn(handler, ctx, "hi")
|
||||
assert (work / ".deepagents" / "skills" / "greeter" / "SKILL.md").exists()
|
||||
|
||||
|
||||
async def test_turn_and_history_before_start_and_after_stop(
|
||||
tmp_path: Path, fake_model
|
||||
) -> None:
|
||||
fake_model([final("ok")])
|
||||
ctx = make_ctx(tmp_path)
|
||||
handler = make_handler()
|
||||
with pytest.raises(HarnessError, match="not started"):
|
||||
await run_turn(handler, ctx, "hi")
|
||||
await handler.start(ctx)
|
||||
await handler.stop(ctx)
|
||||
with pytest.raises(HarnessError, match="not started"):
|
||||
await handler.history(ctx)
|
||||
|
||||
|
||||
async def test_start_validates_model(tmp_path: Path, fake_model) -> None:
|
||||
fake_model([final("ok")])
|
||||
with pytest.raises(ValueError, match="needs model="):
|
||||
await started(make_ctx(tmp_path, model=None))
|
||||
|
||||
|
||||
def test_build_chat_model_uses_chat_model_kwargs(tmp_path: Path) -> None:
|
||||
deps = dh.load_deps()
|
||||
gw = GatewayTarget(api_base="https://gw.example.com", api_key="sk-virtual")
|
||||
model = dh.build_chat_model(make_ctx(tmp_path, gateway=gw), deps)
|
||||
assert isinstance(model, deps.chat_litellm)
|
||||
assert model.model == "litellm_proxy/gpt-4o-mini"
|
||||
|
||||
|
||||
def test_shared_checkpointer_is_process_wide() -> None:
|
||||
deps = dh.load_deps()
|
||||
assert dh.shared_checkpointer(deps) is dh.shared_checkpointer(deps)
|
||||
|
||||
|
||||
async def test_sandbox_backend_fs_ops(tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "a.py").write_text("print('hello')\n")
|
||||
(tmp_path / "b.txt").write_text("hello world\n")
|
||||
backend = SandboxBackend(LocalSandbox(tmp_path), loop=asyncio.get_running_loop())
|
||||
|
||||
ls = await backend.als("/")
|
||||
assert {e["path"] for e in ls.entries or []} == {"/b.txt", "/src/"}
|
||||
assert (await backend.als("/missing")).error
|
||||
globbed = await backend.aglob("*.py")
|
||||
assert [m["path"] for m in globbed.matches or []] == ["/src/a.py"]
|
||||
grep = await backend.agrep("hello", glob="*.txt")
|
||||
assert [(m["path"], m["line"]) for m in grep.matches or []] == [("/b.txt", 1)]
|
||||
read = await backend.aread("/b.txt")
|
||||
assert read.file_data and read.file_data["content"] == "hello world\n"
|
||||
assert (await backend.aread("/nope.txt")).error
|
||||
assert (await backend.aread("/../etc/passwd")).error
|
||||
edit = await backend.aedit("/b.txt", "hello", "bye")
|
||||
assert edit.occurrences == 1
|
||||
assert (await backend.aedit("/b.txt", "zzz", "q")).error
|
||||
assert (await backend.adelete("/src")).path == "/src"
|
||||
assert not (tmp_path / "src").exists()
|
||||
assert (
|
||||
backend.to_real(str(tmp_path / "b.txt"))
|
||||
== str(LocalSandbox(tmp_path).workdir) + "/b.txt"
|
||||
)
|
||||
sync_ls = await asyncio.to_thread(backend.ls, "/")
|
||||
assert [e["path"] for e in sync_ls.entries or []] == ["/b.txt"]
|
||||
|
||||
read_only = SandboxBackend(
|
||||
LocalSandbox(tmp_path),
|
||||
loop=asyncio.get_running_loop(),
|
||||
writable=False,
|
||||
allow_execute=False,
|
||||
)
|
||||
assert (await read_only.awrite("/c.txt", "x")).error
|
||||
assert (await read_only.aexecute("ls")).exit_code == 1
|
||||
assert not (tmp_path / "c.txt").exists()
|
||||
|
||||
|
||||
def test_message_cost_prefers_reported_and_never_raises() -> None:
|
||||
reported = AIMessage(content="", response_metadata={"response_cost": 0.5})
|
||||
assert message_cost(reported, "gpt-4o-mini", 1, 1) == 0.5
|
||||
assert message_cost(AIMessage(content=""), "not-a-real-model-xyz", 10, 10) == 0.0
|
||||
assert message_cost(AIMessage(content=""), "gpt-4o-mini", 1000, 1000) > 0
|
||||
|
||||
|
||||
def test_missing_deps_raise_install_hint(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
if name.startswith("deepagents"):
|
||||
raise ImportError("No module named 'deepagents'")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
for mod in [m for m in sys.modules if m.startswith("deepagents")]:
|
||||
monkeypatch.delitem(sys.modules, mod)
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
with pytest.raises(
|
||||
HarnessInstallFailed, match="pip install deepagents langchain-litellm"
|
||||
):
|
||||
dh.load_deps()
|
||||
|
||||
|
||||
LIVE_BASE = os.environ.get("LITELLM_PROXY_API_BASE", "")
|
||||
LIVE_KEY = os.environ.get("LITELLM_PROXY_API_KEY", "")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not (LIVE_BASE and LIVE_KEY), reason="LITELLM_PROXY_API_BASE / KEY not set"
|
||||
)
|
||||
async def test_live_gateway_write_file(tmp_path: Path) -> None:
|
||||
model = os.environ.get("HARNESS_DEEPAGENTS_LIVE_MODEL", "claude-haiku-4-5-20251001")
|
||||
ctx = make_ctx(
|
||||
tmp_path,
|
||||
model=model,
|
||||
gateway=GatewayTarget(api_base=LIVE_BASE, api_key=LIVE_KEY),
|
||||
max_turns=6,
|
||||
)
|
||||
handler = await started(ctx)
|
||||
events = await run_turn(handler, ctx, "write hello.txt with hi")
|
||||
assert any(isinstance(e, ToolCall) and e.name == "write" for e in events)
|
||||
assert (tmp_path / "hello.txt").read_text().strip() == "hi"
|
||||
assert ctx.calls >= 1 and ctx.input_tokens > 0
|
||||
0
tests/unit/harness/sandbox/__init__.py
Normal file
0
tests/unit/harness/sandbox/__init__.py
Normal file
241
tests/unit/harness/sandbox/test_docker.py
Normal file
241
tests/unit/harness/sandbox/test_docker.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm import sandbox
|
||||
from litellm.harness.errors import SandboxError
|
||||
from litellm.harness.sandbox import DockerSandbox, Sandbox
|
||||
from litellm.harness.sandbox.docker import parse_sha256sum
|
||||
|
||||
DOCKER_IMAGE = "alpine:3.20"
|
||||
CID = "cid123"
|
||||
|
||||
|
||||
class FakeStdin:
|
||||
def __init__(self) -> None:
|
||||
self.data = b""
|
||||
self.closed = False
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self.data += data
|
||||
|
||||
async def drain(self) -> None:
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FakeHandle:
|
||||
def __init__(self, stdout: bytes = b"", stderr: bytes = b"", code: int = 0):
|
||||
self.stdin = FakeStdin()
|
||||
self.stdout = asyncio.StreamReader()
|
||||
self.stdout.feed_data(stdout)
|
||||
self.stdout.feed_eof()
|
||||
self.stderr = asyncio.StreamReader()
|
||||
self.stderr.feed_data(stderr)
|
||||
self.stderr.feed_eof()
|
||||
self.returncode: Optional[int] = code
|
||||
self._code = code
|
||||
|
||||
async def wait(self) -> int:
|
||||
return self._code
|
||||
|
||||
async def kill(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Stands in for DockerSandbox._spawn; scripted responses by docker subcommand."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[list[str]] = []
|
||||
self.handles: list[FakeHandle] = []
|
||||
self.responses: dict[str, FakeHandle] = {}
|
||||
|
||||
async def __call__(self, args: list[str]) -> FakeHandle:
|
||||
self.calls.append(args)
|
||||
handle = self.responses.pop(args[0], None)
|
||||
if handle is None:
|
||||
handle = FakeHandle(stdout=f"{CID}\n".encode() if args[0] == "run" else b"")
|
||||
self.handles.append(handle)
|
||||
return handle
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake(monkeypatch):
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(DockerSandbox, "_spawn", lambda self, args: rec(args))
|
||||
return rec
|
||||
|
||||
|
||||
def test_run_args():
|
||||
box = sandbox.docker(
|
||||
"img:1",
|
||||
mounts={"/host/src": "/workspace"},
|
||||
env={"A": "1"},
|
||||
name="h1",
|
||||
)
|
||||
assert isinstance(box, Sandbox)
|
||||
assert box.run_args() == [
|
||||
"run",
|
||||
"-d",
|
||||
"--rm",
|
||||
"--add-host=host.docker.internal:host-gateway",
|
||||
"--name",
|
||||
"h1",
|
||||
"-v",
|
||||
"/host/src:/workspace",
|
||||
"-e",
|
||||
"A=1",
|
||||
"-w",
|
||||
"/workspace",
|
||||
"img:1",
|
||||
"sleep",
|
||||
"infinity",
|
||||
]
|
||||
assert box.host_url(8080) == "http://host.docker.internal:8080"
|
||||
|
||||
|
||||
def test_relative_workdir_rejected():
|
||||
with pytest.raises(SandboxError):
|
||||
sandbox.docker("img", workdir="rel")
|
||||
|
||||
|
||||
async def test_lazy_start_and_exec_args(fake):
|
||||
box = sandbox.docker("img")
|
||||
assert fake.calls == []
|
||||
await box.exec(["echo", "hi"], env={"K": "V"}, cwd="sub")
|
||||
await box.exec(["true"])
|
||||
assert fake.calls[0][0] == "run"
|
||||
assert [c for c in fake.calls if c[0] == "run"] == [fake.calls[0]]
|
||||
assert fake.calls[1] == [
|
||||
"exec",
|
||||
"-i",
|
||||
"-w",
|
||||
"/workspace/sub",
|
||||
"-e",
|
||||
"K=V",
|
||||
CID,
|
||||
"echo",
|
||||
"hi",
|
||||
]
|
||||
assert fake.calls[2] == ["exec", "-i", "-w", "/workspace", CID, "true"]
|
||||
|
||||
|
||||
async def test_run_start_failure(fake):
|
||||
fake.responses["run"] = FakeHandle(stderr=b"no such image", code=125)
|
||||
box = sandbox.docker("img")
|
||||
with pytest.raises(SandboxError, match="no such image"):
|
||||
await box.run(["echo"])
|
||||
|
||||
|
||||
async def test_read_write_which_tempdir(fake):
|
||||
box = sandbox.docker("img")
|
||||
await box.start()
|
||||
|
||||
fake.responses["exec"] = FakeHandle(stdout=b"content")
|
||||
assert await box.read("a.txt") == b"content"
|
||||
assert fake.calls[-1][-2:] == ["cat", "/workspace/a.txt"]
|
||||
|
||||
await box.write("d/b.txt", b"payload")
|
||||
assert fake.calls[-1][-5:-1] == ["sh", "-c", fake.calls[-1][-3], "sh"]
|
||||
assert fake.calls[-1][-1] == "/workspace/d/b.txt"
|
||||
assert fake.handles[-1].stdin.data == b"payload"
|
||||
assert fake.handles[-1].stdin.closed
|
||||
|
||||
fake.responses["exec"] = FakeHandle(stdout=b"/usr/bin/codex\n")
|
||||
assert await box.which("codex") == "/usr/bin/codex"
|
||||
assert fake.calls[-1][-5:] == ["sh", "-lc", 'command -v "$1"', "sh", "codex"]
|
||||
|
||||
fake.responses["exec"] = FakeHandle(code=1)
|
||||
assert await box.which("nope") is None
|
||||
|
||||
fake.responses["exec"] = FakeHandle(stdout=b"/tmp/tmp.abc\n")
|
||||
assert await box.tempdir() == "/tmp/tmp.abc"
|
||||
|
||||
fake.responses["exec"] = FakeHandle(stderr=b"No such file", code=1)
|
||||
with pytest.raises(SandboxError, match="No such file"):
|
||||
await box.read("missing")
|
||||
|
||||
|
||||
async def test_snapshot_parses_output(fake):
|
||||
box = sandbox.docker("img")
|
||||
digest = "a" * 64
|
||||
fake.responses["exec"] = FakeHandle(
|
||||
stdout=f"{digest} ./x.txt\n{digest} ./dir/with space.txt\n".encode()
|
||||
)
|
||||
snap = await box.snapshot()
|
||||
assert snap == {"x.txt": digest, "dir/with space.txt": digest}
|
||||
script = fake.calls[-1][-3]
|
||||
assert "-name '.git'" in script and "-prune" in script
|
||||
assert fake.calls[-1][-1] == "/workspace"
|
||||
|
||||
|
||||
async def test_close_removes_container(fake):
|
||||
box = sandbox.docker("img")
|
||||
await box.start()
|
||||
await box.close()
|
||||
assert fake.calls[-1] == ["rm", "-f", CID]
|
||||
with pytest.raises(SandboxError):
|
||||
await box.start()
|
||||
|
||||
|
||||
async def test_close_without_start_is_noop(fake):
|
||||
await sandbox.docker("img").close()
|
||||
assert fake.calls == []
|
||||
|
||||
|
||||
async def test_missing_docker_binary(monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name, *a, **k: None)
|
||||
with pytest.raises(SandboxError, match="docker"):
|
||||
await sandbox.docker("img").start()
|
||||
|
||||
|
||||
def test_parse_sha256sum_ignores_junk():
|
||||
assert parse_sha256sum("garbage\n\n") == {}
|
||||
|
||||
|
||||
def _docker_usable() -> bool:
|
||||
if shutil.which("docker") is None:
|
||||
return False
|
||||
try:
|
||||
return (
|
||||
subprocess.run(
|
||||
["docker", "info"], capture_output=True, timeout=20
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _docker_usable(), reason="docker daemon not available")
|
||||
async def test_real_docker_roundtrip():
|
||||
box = sandbox.docker(DOCKER_IMAGE, workdir="/workspace")
|
||||
try:
|
||||
result = await box.run(["echo", "hello"], timeout=120)
|
||||
assert result.stdout.strip() == "hello"
|
||||
assert result.exit_code == 0
|
||||
|
||||
await box.write("seed.txt", b"seed")
|
||||
await box.write("sub/out.txt", b"from host")
|
||||
assert await box.read("sub/out.txt") == b"from host"
|
||||
await box.write("node_modules/skip.js", b"x")
|
||||
|
||||
assert await box.which("sh") is not None
|
||||
assert await box.which("definitely-not-a-binary-xyz") is None
|
||||
tmp = await box.tempdir()
|
||||
assert tmp.startswith("/")
|
||||
|
||||
snap = await box.snapshot()
|
||||
assert snap == {
|
||||
"seed.txt": hashlib.sha256(b"seed").hexdigest(),
|
||||
"sub/out.txt": hashlib.sha256(b"from host").hexdigest(),
|
||||
}
|
||||
finally:
|
||||
await box.close()
|
||||
180
tests/unit/harness/sandbox/test_local.py
Normal file
180
tests/unit/harness/sandbox/test_local.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm import sandbox
|
||||
from litellm.harness.errors import SandboxError
|
||||
from litellm.harness.sandbox import LocalSandbox, Process, Sandbox
|
||||
from litellm.harness.sandbox.local import filtered_environ, is_secret_env_name
|
||||
|
||||
PY = sys.executable
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sbx(tmp_path):
|
||||
box = sandbox.local(tmp_path)
|
||||
yield box
|
||||
await box.close()
|
||||
|
||||
|
||||
def test_local_requires_existing_dir(tmp_path):
|
||||
with pytest.raises(SandboxError):
|
||||
sandbox.local(tmp_path / "missing")
|
||||
|
||||
|
||||
def test_local_resolves_absolute_and_satisfies_protocol(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "ws").mkdir()
|
||||
box = sandbox.local("ws")
|
||||
assert isinstance(box, LocalSandbox)
|
||||
assert isinstance(box, Sandbox)
|
||||
assert box.workdir == os.path.realpath(tmp_path / "ws")
|
||||
assert box.host_url(4321) == "http://127.0.0.1:4321"
|
||||
|
||||
|
||||
async def test_run_collects_output(sbx):
|
||||
result = await sbx.run(
|
||||
[
|
||||
PY,
|
||||
"-c",
|
||||
"import os,sys;print(os.getcwd());print('err',file=sys.stderr);sys.exit(3)",
|
||||
]
|
||||
)
|
||||
assert result.stdout.strip() == sbx.workdir
|
||||
assert result.stderr.strip() == "err"
|
||||
assert result.exit_code == 3
|
||||
|
||||
|
||||
async def test_run_cwd_inside_workdir(sbx):
|
||||
os.mkdir(os.path.join(sbx.workdir, "sub"))
|
||||
result = await sbx.run([PY, "-c", "import os;print(os.getcwd())"], cwd="sub")
|
||||
assert result.stdout.strip() == os.path.join(sbx.workdir, "sub")
|
||||
with pytest.raises(SandboxError):
|
||||
await sbx.run([PY, "-c", "pass"], cwd="/")
|
||||
|
||||
|
||||
async def test_exec_streams_stdin(sbx):
|
||||
proc = await sbx.exec([PY, "-c", "import sys;print(sys.stdin.read().upper())"])
|
||||
assert isinstance(proc, Process)
|
||||
assert proc.stdin is not None
|
||||
proc.stdin.write(b"hello")
|
||||
await proc.stdin.drain()
|
||||
proc.stdin.close()
|
||||
assert (await proc.stdout.read()).strip() == b"HELLO"
|
||||
assert await proc.wait() == 0
|
||||
|
||||
|
||||
async def test_run_timeout_kills(sbx):
|
||||
with pytest.raises(SandboxError, match="timed out"):
|
||||
await sbx.run([PY, "-c", "import time;time.sleep(30)"], timeout=0.5)
|
||||
|
||||
|
||||
async def test_missing_binary_raises(sbx):
|
||||
with pytest.raises(SandboxError):
|
||||
await sbx.run(["definitely-not-a-binary-xyz"])
|
||||
|
||||
|
||||
async def test_close_kills_live_processes(tmp_path):
|
||||
box = sandbox.local(tmp_path)
|
||||
proc = await box.exec([PY, "-c", "import time;time.sleep(30)"])
|
||||
await box.close()
|
||||
assert proc.returncode is not None
|
||||
with pytest.raises(SandboxError):
|
||||
await box.run([PY, "-c", "pass"])
|
||||
|
||||
|
||||
async def test_read_write_roundtrip(sbx):
|
||||
await sbx.write("a/b/c.txt", b"data")
|
||||
assert await sbx.read("a/b/c.txt") == b"data"
|
||||
abs_path = os.path.join(sbx.workdir, "a", "b", "c.txt")
|
||||
assert await sbx.read(abs_path) == b"data"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["../escape.txt", "a/../../escape.txt", "/etc/passwd"])
|
||||
async def test_path_escape_rejected(sbx, bad):
|
||||
with pytest.raises(SandboxError, match="escapes"):
|
||||
await sbx.read(bad)
|
||||
with pytest.raises(SandboxError, match="escapes"):
|
||||
await sbx.write(bad, b"x")
|
||||
|
||||
|
||||
async def test_symlink_escape_rejected(sbx, tmp_path_factory):
|
||||
outside = tmp_path_factory.mktemp("outside")
|
||||
os.symlink(outside, os.path.join(sbx.workdir, "link"))
|
||||
with pytest.raises(SandboxError, match="escapes"):
|
||||
await sbx.write("link/x.txt", b"x")
|
||||
|
||||
|
||||
async def test_tempdir_is_allowed_and_cleaned(tmp_path):
|
||||
box = sandbox.local(tmp_path)
|
||||
tmp = await box.tempdir()
|
||||
assert os.path.isdir(tmp)
|
||||
target = os.path.join(tmp, "config.toml")
|
||||
await box.write(target, b"k = 1")
|
||||
assert await box.read(target) == b"k = 1"
|
||||
await box.close()
|
||||
assert not os.path.exists(tmp)
|
||||
|
||||
|
||||
async def test_env_filters_provider_secrets(sbx, monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake")
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://x")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_fake")
|
||||
monkeypatch.setenv("HARNESS_TEST_PLAIN", "visible")
|
||||
script = (
|
||||
"import os;"
|
||||
"print(os.environ.get('ANTHROPIC_API_KEY','<none>'));"
|
||||
"print(os.environ.get('OPENAI_BASE_URL','<none>'));"
|
||||
"print(os.environ.get('GITHUB_TOKEN','<none>'));"
|
||||
"print(os.environ.get('HARNESS_TEST_PLAIN','<none>'));"
|
||||
"print(os.environ.get('ANTHROPIC_BASE_URL','<none>'))"
|
||||
)
|
||||
result = await sbx.run(
|
||||
[PY, "-c", script], env={"ANTHROPIC_BASE_URL": "http://127.0.0.1:1"}
|
||||
)
|
||||
assert result.stdout.split() == [
|
||||
"<none>",
|
||||
"<none>",
|
||||
"<none>",
|
||||
"visible",
|
||||
"http://127.0.0.1:1",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,secret",
|
||||
[
|
||||
("ANTHROPIC_API_KEY", True),
|
||||
("AWS_REGION", True),
|
||||
("VERTEXAI_PROJECT", True),
|
||||
("GOOGLE_APPLICATION_CREDENTIALS", True),
|
||||
("MY_API_KEY", True),
|
||||
("SLACK_BOT_TOKEN", True),
|
||||
("CLIENT_SECRET", True),
|
||||
("PATH", False),
|
||||
("HOME", False),
|
||||
],
|
||||
)
|
||||
def test_is_secret_env_name(name, secret):
|
||||
assert is_secret_env_name(name) is secret
|
||||
|
||||
|
||||
def test_filtered_environ_overlay_wins():
|
||||
env = filtered_environ(
|
||||
{"PATH": "/bin", "OPENAI_API_KEY": "x"}, {"PATH": "/usr/bin"}
|
||||
)
|
||||
assert env == {"PATH": "/usr/bin"}
|
||||
|
||||
|
||||
async def test_which_uses_filtered_path(sbx):
|
||||
assert await sbx.which("sh") is not None
|
||||
assert await sbx.which("definitely-not-a-binary-xyz") is None
|
||||
|
||||
|
||||
async def test_snapshot_skips_dirs(sbx):
|
||||
await sbx.write("keep.txt", b"k")
|
||||
await sbx.write(".git/HEAD", b"ref")
|
||||
await sbx.write("node_modules/x/index.js", b"x")
|
||||
snap = await sbx.snapshot()
|
||||
assert list(snap) == ["keep.txt"]
|
||||
129
tests/unit/harness/sandbox/test_snapshot.py
Normal file
129
tests/unit/harness/sandbox/test_snapshot.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import hashlib
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm import sandbox
|
||||
from litellm.constants import HARNESS_MAX_DIFF_BYTES
|
||||
from litellm.harness.sandbox.snapshot import (
|
||||
build_file_changes,
|
||||
capture_text_contents,
|
||||
diff_snapshots,
|
||||
snapshot_local,
|
||||
unified_diff,
|
||||
)
|
||||
from litellm.harness.types import FileChange
|
||||
|
||||
|
||||
def test_diff_snapshots_kinds():
|
||||
before = {"a": "1", "b": "2", "c": "3"}
|
||||
after = {"a": "1", "b": "9", "d": "4"}
|
||||
assert diff_snapshots(before, after) == [
|
||||
("b", "modified"),
|
||||
("c", "deleted"),
|
||||
("d", "created"),
|
||||
]
|
||||
|
||||
|
||||
async def test_snapshot_local_hashes_and_skips(tmp_path):
|
||||
(tmp_path / "x.txt").write_bytes(b"hello")
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "y.txt").write_bytes(b"y")
|
||||
(tmp_path / "__pycache__").mkdir()
|
||||
(tmp_path / "__pycache__" / "z.pyc").write_bytes(b"z")
|
||||
os.symlink(tmp_path / "x.txt", tmp_path / "link.txt")
|
||||
snap = await snapshot_local(str(tmp_path))
|
||||
assert snap == {
|
||||
"x.txt": hashlib.sha256(b"hello").hexdigest(),
|
||||
"sub/y.txt": hashlib.sha256(b"y").hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def test_unified_diff_created():
|
||||
diff = unified_diff("f.txt", None, "one\n")
|
||||
assert diff.startswith("--- /dev/null\n+++ b/f.txt\n")
|
||||
assert "+one\n" in diff
|
||||
|
||||
|
||||
async def test_created_modified_deleted_end_to_end(tmp_path):
|
||||
box = sandbox.local(tmp_path)
|
||||
try:
|
||||
await box.write("mod.txt", b"line1\nline2\n")
|
||||
await box.write("gone.txt", b"bye\n")
|
||||
await box.write("bin.dat", b"\x00\x01\x02")
|
||||
before = await box.snapshot()
|
||||
contents = await capture_text_contents(box, before)
|
||||
assert set(contents) == {"mod.txt", "gone.txt"}
|
||||
|
||||
await box.write("mod.txt", b"line1\nchanged\n")
|
||||
await box.write("new.txt", b"fresh\n")
|
||||
await box.write("bin.dat", b"\x00\x09")
|
||||
os.remove(os.path.join(box.workdir, "gone.txt"))
|
||||
after = await box.snapshot()
|
||||
|
||||
changes = await build_file_changes(box, before, after, contents)
|
||||
by_path = {c.path: c for c in changes}
|
||||
assert all(isinstance(c, FileChange) for c in changes)
|
||||
assert [(c.path, c.kind) for c in changes] == [
|
||||
("bin.dat", "modified"),
|
||||
("gone.txt", "deleted"),
|
||||
("mod.txt", "modified"),
|
||||
("new.txt", "created"),
|
||||
]
|
||||
assert by_path["bin.dat"].diff is None
|
||||
assert "-line2\n" in by_path["mod.txt"].diff
|
||||
assert "+changed\n" in by_path["mod.txt"].diff
|
||||
assert "+fresh\n" in by_path["new.txt"].diff
|
||||
assert "-bye\n" in by_path["gone.txt"].diff
|
||||
finally:
|
||||
await box.close()
|
||||
|
||||
|
||||
async def test_modified_without_before_contents_has_no_diff(tmp_path):
|
||||
box = sandbox.local(tmp_path)
|
||||
try:
|
||||
await box.write("f.txt", b"a\n")
|
||||
before = await box.snapshot()
|
||||
await box.write("f.txt", b"b\n")
|
||||
after = await box.snapshot()
|
||||
changes = await build_file_changes(box, before, after, None)
|
||||
assert changes == [FileChange(path="f.txt", kind="modified", diff=None)]
|
||||
finally:
|
||||
await box.close()
|
||||
|
||||
|
||||
async def test_large_text_file_has_no_diff(tmp_path):
|
||||
box = sandbox.local(tmp_path)
|
||||
try:
|
||||
before = await box.snapshot()
|
||||
await box.write("big.txt", b"a" * (HARNESS_MAX_DIFF_BYTES + 1))
|
||||
after = await box.snapshot()
|
||||
changes = await build_file_changes(box, before, after, {})
|
||||
assert changes == [FileChange(path="big.txt", kind="created", diff=None)]
|
||||
finally:
|
||||
await box.close()
|
||||
|
||||
|
||||
async def test_capture_respects_total_cap(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"litellm.harness.sandbox.snapshot.HARNESS_SNAPSHOT_MAX_TOTAL_BYTES", 10
|
||||
)
|
||||
box = sandbox.local(tmp_path)
|
||||
try:
|
||||
await box.write("a.txt", b"x" * 8)
|
||||
await box.write("b.txt", b"x" * 8)
|
||||
await box.write("c.txt", b"x" * 8)
|
||||
captured = await capture_text_contents(box, await box.snapshot())
|
||||
assert set(captured) == {"a.txt", "b.txt"}
|
||||
finally:
|
||||
await box.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data", [b"\xff\xfe bad utf8", b"has\x00nul"])
|
||||
async def test_capture_skips_binary(tmp_path, data):
|
||||
box = sandbox.local(tmp_path)
|
||||
try:
|
||||
await box.write("f", data)
|
||||
assert await capture_text_contents(box, {"f": "h"}) == {}
|
||||
finally:
|
||||
await box.close()
|
||||
359
tests/unit/harness/test_endpoint.py
Normal file
359
tests/unit/harness/test_endpoint.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import json
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.harness import endpoint as endpoint_module
|
||||
from litellm.harness.endpoint import (
|
||||
ModelEndpoint,
|
||||
SSEUsageParser,
|
||||
UsageTracker,
|
||||
compute_cost,
|
||||
usage_from_body,
|
||||
)
|
||||
from litellm.harness.errors import HarnessInstallFailed
|
||||
from litellm.harness.context import GatewayTarget
|
||||
from litellm.harness.types import Harness, Usage
|
||||
from litellm.types.utils import ModelResponse, ModelResponseStream
|
||||
|
||||
GATEWAY = GatewayTarget(api_base="https://gw.example.com", api_key="sk-gateway-secret")
|
||||
|
||||
ANTHROPIC_SSE = (
|
||||
b"event: message_start\n"
|
||||
b'data: {"type":"message_start","message":{"usage":{"input_tokens":11,"output_tokens":1}}}\n\n'
|
||||
b"event: content_block_delta\n"
|
||||
b'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n'
|
||||
b"event: message_delta\n"
|
||||
b'data: {"type":"message_delta","usage":{"output_tokens":7}}\n\n'
|
||||
b"event: message_stop\n"
|
||||
b'data: {"type":"message_stop"}\n\n'
|
||||
)
|
||||
CHAT_SSE = (
|
||||
b'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n'
|
||||
b'data: {"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":3}}\n\n'
|
||||
b"data: [DONE]\n\n"
|
||||
)
|
||||
RESPONSES_SSE = (
|
||||
b"event: response.output_text.delta\n"
|
||||
b'data: {"type":"response.output_text.delta","delta":"hi"}\n\n'
|
||||
b"event: response.completed\n"
|
||||
b'data: {"type":"response.completed","response":{"usage":{"input_tokens":20,"output_tokens":4}}}\n\n'
|
||||
)
|
||||
|
||||
|
||||
class Recorder:
|
||||
def __init__(self, response: httpx.Response) -> None:
|
||||
self.response = response
|
||||
self.requests: list[httpx.Request] = []
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
return self.response
|
||||
|
||||
|
||||
def sse_response(body: bytes, headers: dict[str, str] | None = None) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=body,
|
||||
headers={"content-type": "text/event-stream", **(headers or {})},
|
||||
)
|
||||
|
||||
|
||||
def gateway_endpoint(recorder: Recorder, **kwargs: Any) -> ModelEndpoint:
|
||||
return ModelEndpoint(
|
||||
Harness.CLAUDE_CODE,
|
||||
kwargs.pop("model", "claude-sonnet"),
|
||||
GATEWAY,
|
||||
client=httpx.AsyncClient(transport=httpx.MockTransport(recorder)),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def auth(ep: ModelEndpoint) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {ep.token}"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_real_cost(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"cost_per_token",
|
||||
lambda model, prompt_tokens, completion_tokens: (
|
||||
prompt_tokens * 0.001,
|
||||
completion_tokens * 0.002,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_rejects_bad_token_and_accepts_both_header_styles() -> None:
|
||||
recorder = Recorder(httpx.Response(200, json={"usage": {}}))
|
||||
async with gateway_endpoint(recorder) as ep:
|
||||
assert ep.url == f"http://127.0.0.1:{ep.port}" and ep.port > 0
|
||||
async with httpx.AsyncClient(base_url=ep.url) as client:
|
||||
missing = await client.post("/v1/messages", json={})
|
||||
wrong = await client.post(
|
||||
"/v1/messages", json={}, headers={"x-api-key": "nope"}
|
||||
)
|
||||
bearer = await client.post("/v1/messages", json={}, headers=auth(ep))
|
||||
api_key = await client.post(
|
||||
"/messages", json={}, headers={"x-api-key": ep.token}
|
||||
)
|
||||
assert missing.status_code == 401
|
||||
assert wrong.status_code == 401
|
||||
assert "error" in wrong.json()
|
||||
assert bearer.status_code == 200
|
||||
assert api_key.status_code == 200
|
||||
assert len(recorder.requests) == 2
|
||||
|
||||
|
||||
async def test_gateway_rewrites_headers_and_model() -> None:
|
||||
recorder = Recorder(
|
||||
httpx.Response(
|
||||
200,
|
||||
json={"id": "m", "usage": {"input_tokens": 3, "output_tokens": 2}},
|
||||
)
|
||||
)
|
||||
async with gateway_endpoint(recorder, metadata={"run": "abc"}) as ep:
|
||||
async with httpx.AsyncClient(base_url=ep.url) as client:
|
||||
resp = await client.post(
|
||||
"/v1/messages",
|
||||
json={"model": "whatever", "max_tokens": 5},
|
||||
headers={
|
||||
"x-api-key": ep.token,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-beta": "tools-2024",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
sent = recorder.requests[0]
|
||||
assert str(sent.url) == "https://gw.example.com/v1/messages"
|
||||
assert sent.headers["authorization"] == "Bearer sk-gateway-secret"
|
||||
assert "x-api-key" not in sent.headers
|
||||
assert sent.headers["x-litellm-tags"] == "harness,claude_code"
|
||||
assert json.loads(sent.headers["x-litellm-spend-logs-metadata"]) == {"run": "abc"}
|
||||
assert sent.headers["anthropic-version"] == "2023-06-01"
|
||||
assert sent.headers["anthropic-beta"] == "tools-2024"
|
||||
assert json.loads(sent.content)["model"] == "claude-sonnet"
|
||||
assert ep.usage.input_tokens == 3 and ep.usage.output_tokens == 2
|
||||
assert ep.usage.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path,body,expected",
|
||||
[
|
||||
("/v1/messages", ANTHROPIC_SSE, (11, 7)),
|
||||
("/v1/chat/completions", CHAT_SSE, (5, 3)),
|
||||
("/responses", RESPONSES_SSE, (20, 4)),
|
||||
],
|
||||
)
|
||||
async def test_gateway_sse_passthrough_and_usage(
|
||||
path: str, body: bytes, expected: tuple[int, int]
|
||||
) -> None:
|
||||
recorder = Recorder(sse_response(body))
|
||||
async with gateway_endpoint(recorder) as ep:
|
||||
async with httpx.AsyncClient(base_url=ep.url) as client:
|
||||
resp = await client.post(path, json={"stream": True}, headers=auth(ep))
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/event-stream")
|
||||
assert resp.content == body
|
||||
assert (ep.usage.input_tokens, ep.usage.output_tokens) == expected
|
||||
expected_cost = expected[0] * 0.001 + expected[1] * 0.002
|
||||
assert ep.usage.cost == pytest.approx(expected_cost)
|
||||
|
||||
|
||||
async def test_cost_header_preferred_over_computed() -> None:
|
||||
recorder = Recorder(
|
||||
httpx.Response(
|
||||
200,
|
||||
json={"usage": {"prompt_tokens": 100, "completion_tokens": 100}},
|
||||
headers={"x-litellm-response-cost": "0.42"},
|
||||
)
|
||||
)
|
||||
async with gateway_endpoint(recorder) as ep:
|
||||
async with httpx.AsyncClient(base_url=ep.url) as client:
|
||||
await client.post("/v1/chat/completions", json={}, headers=auth(ep))
|
||||
assert ep.usage.cost == pytest.approx(0.42)
|
||||
assert ep.usage.snapshot() == Usage(input_tokens=100, output_tokens=100, calls=1)
|
||||
|
||||
|
||||
async def test_gateway_error_status_preserved_and_not_counted() -> None:
|
||||
recorder = Recorder(httpx.Response(429, json={"error": "rate limited"}))
|
||||
async with gateway_endpoint(recorder) as ep:
|
||||
async with httpx.AsyncClient(base_url=ep.url) as client:
|
||||
resp = await client.post("/v1/chat/completions", json={}, headers=auth(ep))
|
||||
assert resp.status_code == 429
|
||||
assert ep.usage.calls == 0
|
||||
|
||||
|
||||
async def test_models_route() -> None:
|
||||
recorder = Recorder(httpx.Response(200))
|
||||
async with gateway_endpoint(recorder) as ep:
|
||||
async with httpx.AsyncClient(base_url=ep.url) as client:
|
||||
with_model = await client.get("/v1/models", headers=auth(ep))
|
||||
unauth = await client.get("/models")
|
||||
assert unauth.status_code == 401
|
||||
assert with_model.json()["object"] == "list"
|
||||
assert [m["id"] for m in with_model.json()["data"]] == ["claude-sonnet"]
|
||||
|
||||
async with ModelEndpoint(Harness.CODEX, None, None) as ep:
|
||||
async with httpx.AsyncClient(base_url=ep.url) as client:
|
||||
empty = await client.get("/models", headers=auth(ep))
|
||||
assert empty.json() == {"object": "list", "data": []}
|
||||
|
||||
|
||||
async def test_sdk_chat_non_stream(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def fake_acompletion(**kwargs: Any) -> ModelResponse:
|
||||
calls.append(kwargs)
|
||||
response = ModelResponse(
|
||||
model="gpt-x",
|
||||
choices=[{"message": {"role": "assistant", "content": "hello"}}],
|
||||
usage={"prompt_tokens": 9, "completion_tokens": 4, "total_tokens": 13},
|
||||
)
|
||||
response._hidden_params["response_cost"] = 0.5
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
|
||||
async with ModelEndpoint(
|
||||
Harness.OPENCODE, "openai/gpt-x", None, api_key="sk-real", api_base="https://x"
|
||||
) as ep:
|
||||
async with httpx.AsyncClient(base_url=ep.url) as client:
|
||||
resp = await client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "ignored",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
},
|
||||
headers=auth(ep),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["choices"][0]["message"]["content"] == "hello"
|
||||
assert calls[0]["model"] == "openai/gpt-x"
|
||||
assert calls[0]["api_key"] == "sk-real"
|
||||
assert calls[0]["api_base"] == "https://x"
|
||||
assert (ep.usage.input_tokens, ep.usage.output_tokens) == (9, 4)
|
||||
assert ep.usage.cost == pytest.approx(0.5)
|
||||
|
||||
|
||||
async def fake_chat_stream() -> AsyncIterator[ModelResponseStream]:
|
||||
yield ModelResponseStream(choices=[{"delta": {"content": "he"}}])
|
||||
yield ModelResponseStream(choices=[{"delta": {"content": "llo"}}])
|
||||
final = ModelResponseStream(choices=[])
|
||||
final.usage = litellm.Usage(prompt_tokens=6, completion_tokens=2, total_tokens=8)
|
||||
yield final
|
||||
|
||||
|
||||
async def test_sdk_chat_stream(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def fake_acompletion(**kwargs: Any) -> AsyncIterator[ModelResponseStream]:
|
||||
calls.append(kwargs)
|
||||
return fake_chat_stream()
|
||||
|
||||
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
|
||||
async with ModelEndpoint(Harness.OPENCODE, "openai/gpt-x", None) as ep:
|
||||
async with httpx.AsyncClient(base_url=ep.url) as client:
|
||||
resp = await client.post(
|
||||
"/chat/completions",
|
||||
json={"messages": [], "stream": True},
|
||||
headers=auth(ep),
|
||||
)
|
||||
assert resp.headers["content-type"].startswith("text/event-stream")
|
||||
lines = [line for line in resp.text.split("\n") if line.startswith("data: ")]
|
||||
assert lines[-1] == "data: [DONE]"
|
||||
assert json.loads(lines[0][6:])["choices"][0]["delta"]["content"] == "he"
|
||||
assert calls[0]["stream_options"] == {"include_usage": True}
|
||||
assert (ep.usage.input_tokens, ep.usage.output_tokens) == (6, 2)
|
||||
assert ep.usage.cost == pytest.approx(6 * 0.001 + 2 * 0.002)
|
||||
|
||||
|
||||
async def fake_anthropic_stream() -> AsyncIterator[Any]:
|
||||
yield {"type": "message_start", "message": {"usage": {"input_tokens": 4}}}
|
||||
yield b'event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":9}}\n\n'
|
||||
|
||||
|
||||
async def test_sdk_messages_stream_handles_dicts_and_bytes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def fake_acreate(**kwargs: Any) -> AsyncIterator[Any]:
|
||||
return fake_anthropic_stream()
|
||||
|
||||
monkeypatch.setattr(litellm.anthropic.messages, "acreate", fake_acreate)
|
||||
async with ModelEndpoint(Harness.CLAUDE_CODE, "anthropic/claude", None) as ep:
|
||||
async with httpx.AsyncClient(base_url=ep.url) as client:
|
||||
resp = await client.post(
|
||||
"/v1/messages", json={"stream": True}, headers=auth(ep)
|
||||
)
|
||||
assert "event: message_start" in resp.text
|
||||
assert "event: message_delta" in resp.text
|
||||
assert (ep.usage.input_tokens, ep.usage.output_tokens) == (4, 9)
|
||||
|
||||
|
||||
async def test_sdk_error_is_sanitized(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def failing(**kwargs: Any) -> Any:
|
||||
raise litellm.RateLimitError(
|
||||
message="too many requests for key sk-real",
|
||||
llm_provider="openai",
|
||||
model="gpt-x",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(litellm, "aresponses", failing)
|
||||
async with ModelEndpoint(Harness.CODEX, "gpt-x", None, api_key="sk-real") as ep:
|
||||
async with httpx.AsyncClient(base_url=ep.url) as client:
|
||||
resp = await client.post("/v1/responses", json={}, headers=auth(ep))
|
||||
assert resp.status_code == 429
|
||||
assert "sk-real" not in resp.text
|
||||
assert resp.json()["error"]["type"] == "RateLimitError"
|
||||
|
||||
|
||||
async def test_missing_server_deps_raises_install_failed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def missing() -> Any:
|
||||
raise HarnessInstallFailed(endpoint_module.MISSING_DEPS_MESSAGE)
|
||||
|
||||
monkeypatch.setattr(endpoint_module, "_load_server_deps", missing)
|
||||
with pytest.raises(HarnessInstallFailed, match="pip install starlette uvicorn"):
|
||||
async with ModelEndpoint(Harness.CODEX, None, None):
|
||||
pass
|
||||
|
||||
|
||||
def test_load_server_deps_maps_import_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setitem(sys.modules, "uvicorn", None)
|
||||
with pytest.raises(HarnessInstallFailed, match="starlette and uvicorn"):
|
||||
endpoint_module._load_server_deps()
|
||||
|
||||
|
||||
def test_usage_helpers() -> None:
|
||||
assert usage_from_body({"usage": {"prompt_tokens": 1, "completion_tokens": 2}}) == (
|
||||
1,
|
||||
2,
|
||||
)
|
||||
assert usage_from_body({"response": {"usage": {"input_tokens": 3}}}) == (3, 0)
|
||||
assert usage_from_body("nope") == (0, 0)
|
||||
|
||||
parser = SSEUsageParser()
|
||||
for i in range(0, len(ANTHROPIC_SSE), 7): # split across arbitrary chunk borders
|
||||
parser.feed(ANTHROPIC_SSE[i : i + 7])
|
||||
parser.close()
|
||||
assert (parser.input_tokens, parser.output_tokens) == (11, 7)
|
||||
|
||||
tracker = UsageTracker()
|
||||
tracker.add(1, 2, 0.1)
|
||||
tracker.add(3, 4, 0.2)
|
||||
assert tracker.snapshot() == Usage(input_tokens=4, output_tokens=6, calls=2)
|
||||
assert tracker.cost == pytest.approx(0.3)
|
||||
|
||||
|
||||
def test_compute_cost_never_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def boom(**kwargs: Any) -> Any:
|
||||
raise ValueError("unknown model")
|
||||
|
||||
monkeypatch.setattr(litellm, "cost_per_token", boom)
|
||||
assert compute_cost("mystery", 10, 10) == 0.0
|
||||
assert compute_cost(None, 10, 10) == 0.0
|
||||
95
tests/unit/harness/test_init.py
Normal file
95
tests/unit/harness/test_init.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Tests for litellm/harness/__init__.py: the public API surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from litellm import harness
|
||||
from tests.test_litellm_rust.support.child_interpreter import run_child_interpreter
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
PUBLIC_NAMES = [
|
||||
"Harness",
|
||||
"agent",
|
||||
"aagent",
|
||||
"agent_session",
|
||||
"aagent_session",
|
||||
"agent_resume",
|
||||
"aagent_resume",
|
||||
"agent_capabilities",
|
||||
"Result",
|
||||
"Usage",
|
||||
"State",
|
||||
"Capabilities",
|
||||
"Session",
|
||||
"EventStream",
|
||||
"Text",
|
||||
"Reasoning",
|
||||
"ToolCall",
|
||||
"ToolResult",
|
||||
"FileChange",
|
||||
"Compaction",
|
||||
"Approval",
|
||||
"Done",
|
||||
"Event",
|
||||
"ClaudeCodeOptions",
|
||||
"CodexOptions",
|
||||
"OpenCodeOptions",
|
||||
"DeepAgentsOptions",
|
||||
"HarnessError",
|
||||
"CapabilityUnsupported",
|
||||
"OptionsMismatch",
|
||||
"HarnessInstallFailed",
|
||||
"SandboxError",
|
||||
"SessionClosed",
|
||||
"StateIncompatible",
|
||||
"OutputInvalid",
|
||||
]
|
||||
ERROR_NAMES = [
|
||||
"CapabilityUnsupported",
|
||||
"OptionsMismatch",
|
||||
"HarnessInstallFailed",
|
||||
"SandboxError",
|
||||
"SessionClosed",
|
||||
"StateIncompatible",
|
||||
"OutputInvalid",
|
||||
]
|
||||
LAZY_IMPORT_CHECK = (
|
||||
"import sys, litellm\n"
|
||||
"assert 'litellm.harness' not in sys.modules\n"
|
||||
"h = litellm.harness\n"
|
||||
"assert h.Harness.CODEX.value == 'codex'\n"
|
||||
"assert 'starlette' not in sys.modules and 'uvicorn' not in sys.modules\n"
|
||||
"print('ok')\n"
|
||||
)
|
||||
|
||||
|
||||
def test_public_api_names_exported():
|
||||
missing = [name for name in PUBLIC_NAMES if not hasattr(harness, name)]
|
||||
assert missing == []
|
||||
assert set(PUBLIC_NAMES) <= set(harness.__all__)
|
||||
|
||||
|
||||
def test_errors_share_base_class():
|
||||
for name in ERROR_NAMES:
|
||||
assert issubclass(getattr(harness, name), harness.HarnessError)
|
||||
|
||||
|
||||
def test_litellm_harness_attribute_is_lazy():
|
||||
out = run_child_interpreter(LAZY_IMPORT_CHECK, timeout=120)
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert out.stdout.strip() == "ok"
|
||||
|
||||
|
||||
def test_adapter_registry_paths_cover_every_harness():
|
||||
for member in harness.Harness:
|
||||
config = ProviderConfigManager.get_provider_harness_config(member)
|
||||
assert config is not None and config.harness is member
|
||||
|
||||
|
||||
def test_litellm_agent_is_top_level_and_lazy():
|
||||
code = (
|
||||
"import sys, litellm; assert 'litellm.harness' not in sys.modules; "
|
||||
"assert litellm.agent is litellm.harness.agent; assert litellm.Harness.CODEX.value == 'codex'"
|
||||
)
|
||||
out = run_child_interpreter(code, timeout=120)
|
||||
assert out.returncode == 0, out.stderr
|
||||
621
tests/unit/harness/test_runtime.py
Normal file
621
tests/unit/harness/test_runtime.py
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
"""Tests for litellm/harness/runtime.py using a fake adapter, sandbox and endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.harness import runtime
|
||||
from litellm.harness.context import SessionContext
|
||||
from litellm.harness.errors import (
|
||||
CapabilityUnsupported,
|
||||
HarnessInstallFailed,
|
||||
OptionsMismatch,
|
||||
OutputInvalid,
|
||||
SessionClosed,
|
||||
StateIncompatible,
|
||||
)
|
||||
from litellm.harness.options import CodexOptions
|
||||
from litellm.harness.types import (
|
||||
Approval,
|
||||
Done,
|
||||
Event,
|
||||
FileChange,
|
||||
Harness,
|
||||
State,
|
||||
Text,
|
||||
ToolCall,
|
||||
)
|
||||
from tests.unit.harness.core_fakes import (
|
||||
NARROW_CAPS,
|
||||
FakeAdapter,
|
||||
FakeEndpoint,
|
||||
FakeSandbox,
|
||||
install_adapter,
|
||||
script_approval,
|
||||
wait_forever,
|
||||
)
|
||||
|
||||
|
||||
class Answer(BaseModel):
|
||||
value: int
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sandbox(tmp_path) -> FakeSandbox:
|
||||
return FakeSandbox(str(tmp_path))
|
||||
|
||||
|
||||
async def _collect(stream) -> list[Event]:
|
||||
return [event async for event in stream]
|
||||
|
||||
|
||||
# -- validation ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_string_harness_raises_type_error_with_hint(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
with pytest.raises(TypeError, match=r"Harness\.CODEX"):
|
||||
await runtime.aagent("codex", "hi", sandbox=sandbox) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_options_mismatch(monkeypatch, sandbox):
|
||||
adapter_cls = install_adapter(monkeypatch)
|
||||
with pytest.raises(OptionsMismatch, match="CodexOptions"):
|
||||
await runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, options=CodexOptions()
|
||||
)
|
||||
assert adapter_cls.instances == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"permissions": "edit"},
|
||||
{"output": Answer},
|
||||
{"tools": [print]},
|
||||
{"disable_tools": ["bash"]},
|
||||
{"permissions": "ask", "on_approval": lambda a: True},
|
||||
],
|
||||
)
|
||||
async def test_capability_errors_before_start(monkeypatch, sandbox, kwargs):
|
||||
adapter_cls = install_adapter(monkeypatch, caps=NARROW_CAPS)
|
||||
with pytest.raises(CapabilityUnsupported):
|
||||
await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, **kwargs)
|
||||
assert all("start" not in a.calls for a in adapter_cls.instances)
|
||||
assert FakeEndpoint.instances == []
|
||||
|
||||
|
||||
async def test_skills_capability_error_before_start(monkeypatch, sandbox, tmp_path):
|
||||
skill = tmp_path / "skill"
|
||||
skill.mkdir()
|
||||
(skill / "SKILL.md").write_text("# s")
|
||||
adapter_cls = install_adapter(monkeypatch, caps=NARROW_CAPS)
|
||||
with pytest.raises(CapabilityUnsupported, match="skills"):
|
||||
await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, skills=[skill])
|
||||
assert adapter_cls.instances == []
|
||||
|
||||
|
||||
async def test_skill_folder_without_skill_md_rejected(monkeypatch, sandbox, tmp_path):
|
||||
install_adapter(monkeypatch)
|
||||
with pytest.raises(ValueError, match=r"SKILL\.md"):
|
||||
await runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, skills=[tmp_path]
|
||||
)
|
||||
|
||||
|
||||
async def test_ask_without_handler_only_allowed_for_stream(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
with pytest.raises(ValueError, match="on_approval"):
|
||||
await runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, permissions="ask"
|
||||
)
|
||||
stream = runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, permissions="ask", stream=True
|
||||
)
|
||||
events = await _collect(stream)
|
||||
assert isinstance(events[-1], Done)
|
||||
|
||||
|
||||
async def test_invalid_permissions_value(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
with pytest.raises(ValueError, match="permissions"):
|
||||
await runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, permissions="yolo" # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
# -- gateway routing (litellm_proxy/ prefix) ---------------------------------
|
||||
|
||||
|
||||
def test_litellm_proxy_prefix_routes_through_gateway_env(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://gw.example.com/")
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test")
|
||||
model, gateway = runtime.resolve_model_route("litellm_proxy/coder", None, None)
|
||||
assert model == "coder"
|
||||
assert gateway == runtime.GatewayTarget(
|
||||
api_base="https://gw.example.com", api_key="sk-test"
|
||||
)
|
||||
|
||||
|
||||
def test_litellm_proxy_call_args_win_over_env(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://env.example.com")
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-env")
|
||||
_, gateway = runtime.resolve_model_route(
|
||||
"litellm_proxy/coder", "sk-arg", "https://arg.example.com"
|
||||
)
|
||||
assert gateway == runtime.GatewayTarget(
|
||||
api_base="https://arg.example.com", api_key="sk-arg"
|
||||
)
|
||||
|
||||
|
||||
def test_litellm_proxy_without_base_raises(monkeypatch):
|
||||
monkeypatch.delenv("LITELLM_PROXY_API_BASE", raising=False)
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test")
|
||||
with pytest.raises(ValueError, match="LITELLM_PROXY_API_BASE"):
|
||||
runtime.resolve_model_route("litellm_proxy/coder", None, None)
|
||||
|
||||
|
||||
def test_litellm_proxy_without_key_raises(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://gw.example.com")
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_KEY", " ")
|
||||
with pytest.raises(ValueError, match="LITELLM_PROXY_API_KEY"):
|
||||
runtime.resolve_model_route("litellm_proxy/coder", None, None)
|
||||
|
||||
|
||||
def test_plain_model_is_sdk_mode_even_with_gateway_env(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://gw.example.com")
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test")
|
||||
assert runtime.resolve_model_route("anthropic/claude-sonnet-4-5", None, None) == (
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def test_use_litellm_proxy_flag_routes_unprefixed_model(monkeypatch):
|
||||
monkeypatch.setattr(runtime.litellm, "use_litellm_proxy", True)
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://gw.example.com")
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test")
|
||||
model, gateway = runtime.resolve_model_route("coder", None, None)
|
||||
assert model == "coder" and gateway is not None
|
||||
|
||||
|
||||
async def test_gateway_passed_to_endpoint(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://gw.example.com")
|
||||
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test")
|
||||
await runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, model="litellm_proxy/m"
|
||||
)
|
||||
endpoint = FakeEndpoint.instances[0]
|
||||
assert endpoint.gateway.api_key == "sk-test"
|
||||
assert endpoint.model == "m"
|
||||
assert endpoint.entered and endpoint.exited
|
||||
|
||||
|
||||
# -- event flow ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_text_and_tool_events_flow_and_done_last(monkeypatch, sandbox):
|
||||
adapter_cls = install_adapter(monkeypatch)
|
||||
events = await _collect(
|
||||
runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, stream=True)
|
||||
)
|
||||
kinds = [type(e).__name__ for e in events]
|
||||
assert kinds == ["Text", "ToolCall", "ToolResult", "Text", "Done"]
|
||||
assert sum(isinstance(e, Done) for e in events) == 1
|
||||
result = events[-1].result
|
||||
assert result.text == "hello world"
|
||||
assert result.stop_reason == "done"
|
||||
assert result.usage.input_tokens == 10 and result.usage.output_tokens == 5
|
||||
assert result.cost == pytest.approx(0.25)
|
||||
assert adapter_cls.instances[0].calls == ["start", "turn", "stop"]
|
||||
|
||||
|
||||
async def test_arun_returns_result(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
result = await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
||||
assert result.text == "hello world"
|
||||
assert len(result.events) == 4
|
||||
|
||||
|
||||
async def test_final_text_from_ctx_preferred(monkeypatch, sandbox):
|
||||
async def script(adapter, ctx: SessionContext, prompt) -> AsyncIterator[Event]:
|
||||
yield Text("partial")
|
||||
ctx.final_text = "final answer"
|
||||
|
||||
install_adapter(monkeypatch, script=script)
|
||||
result = await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
||||
assert result.text == "final answer"
|
||||
|
||||
|
||||
async def test_endpointless_adapter_usage(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch, uses_endpoint=False)
|
||||
result = await runtime.aagent(Harness.DEEPAGENTS, "hi", sandbox=sandbox)
|
||||
assert FakeEndpoint.instances == []
|
||||
assert result.usage.calls == 1
|
||||
assert result.cost == pytest.approx(0.25)
|
||||
|
||||
|
||||
async def test_stream_result_property(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
stream = runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, stream=True)
|
||||
assert stream.result is None
|
||||
await _collect(stream)
|
||||
assert stream.result is not None and stream.result.text == "hello world"
|
||||
|
||||
|
||||
# -- stop reasons -------------------------------------------------------------
|
||||
|
||||
|
||||
async def _tool_loop(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
||||
for i in range(10):
|
||||
yield ToolCall(id=str(i), name="bash", native_name="Bash", input={})
|
||||
|
||||
|
||||
async def test_max_turns_stop(monkeypatch, sandbox):
|
||||
adapter_cls = install_adapter(monkeypatch, script=_tool_loop)
|
||||
result = await runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, max_turns=3
|
||||
)
|
||||
assert result.stop_reason == "max_turns"
|
||||
assert sum(isinstance(e, ToolCall) for e in result.events) == 3
|
||||
assert "stop" in adapter_cls.instances[0].calls
|
||||
|
||||
|
||||
async def _slow(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
||||
yield Text("thinking")
|
||||
await wait_forever()
|
||||
yield Text("never")
|
||||
|
||||
|
||||
async def test_timeout_stop(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch, script=_slow)
|
||||
result = await runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, timeout=0.2
|
||||
)
|
||||
assert result.stop_reason == "timeout"
|
||||
assert result.text == "thinking"
|
||||
|
||||
|
||||
async def test_cancel_stop(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch, script=_slow)
|
||||
stream = runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, stream=True)
|
||||
events = []
|
||||
async for event in stream:
|
||||
events.append(event)
|
||||
if isinstance(event, Text):
|
||||
stream.cancel()
|
||||
assert isinstance(events[-1], Done)
|
||||
assert events[-1].stop_reason == "cancelled"
|
||||
|
||||
|
||||
async def _crash(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
||||
yield Text("partial")
|
||||
raise RuntimeError("process exited with code 1")
|
||||
|
||||
|
||||
async def test_runtime_error_stop_reason(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch, script=_crash)
|
||||
result = await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
||||
assert result.stop_reason == "runtime_error"
|
||||
assert "process exited with code 1" in result.text
|
||||
|
||||
|
||||
async def _missing_binary(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
||||
raise HarnessInstallFailed("claude not found on PATH")
|
||||
yield Text("unreachable") # pragma: no cover
|
||||
|
||||
|
||||
async def test_install_failed_propagates(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch, script=_missing_binary)
|
||||
with pytest.raises(HarnessInstallFailed, match="claude"):
|
||||
await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
||||
assert FakeEndpoint.instances[0].exited
|
||||
|
||||
|
||||
# -- approvals ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_approval_on_approval_allow(monkeypatch, sandbox):
|
||||
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
||||
result = await runtime.aagent(
|
||||
Harness.CLAUDE_CODE,
|
||||
"hi",
|
||||
sandbox=sandbox,
|
||||
permissions="ask",
|
||||
on_approval=lambda approval: approval.tool == "bash",
|
||||
)
|
||||
assert adapter_cls.instances[0].approvals[0][0] is True
|
||||
assert result.text == "allowed"
|
||||
|
||||
|
||||
async def test_approval_async_handler_deny(monkeypatch, sandbox):
|
||||
async def handler(approval: Approval) -> bool:
|
||||
await asyncio.sleep(0)
|
||||
return False
|
||||
|
||||
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
||||
result = await runtime.aagent(
|
||||
Harness.CLAUDE_CODE,
|
||||
"hi",
|
||||
sandbox=sandbox,
|
||||
permissions="ask",
|
||||
on_approval=handler,
|
||||
)
|
||||
assert adapter_cls.instances[0].approvals[0][0] is False
|
||||
assert result.text == "denied"
|
||||
|
||||
|
||||
async def test_approval_handler_raises_denies(monkeypatch, sandbox):
|
||||
def handler(approval: Approval) -> bool:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
||||
result = await runtime.aagent(
|
||||
Harness.CLAUDE_CODE,
|
||||
"hi",
|
||||
sandbox=sandbox,
|
||||
permissions="ask",
|
||||
on_approval=handler,
|
||||
)
|
||||
allowed, reason = adapter_cls.instances[0].approvals[0]
|
||||
assert allowed is False and "boom" in reason
|
||||
assert result.stop_reason == "done"
|
||||
|
||||
|
||||
async def test_stream_consumer_answers_approval(monkeypatch, sandbox):
|
||||
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
||||
stream = runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, permissions="ask", stream=True
|
||||
)
|
||||
async for event in stream:
|
||||
if isinstance(event, Approval):
|
||||
event.allow()
|
||||
assert adapter_cls.instances[0].approvals[0][0] is True
|
||||
|
||||
|
||||
async def test_unanswered_approval_denied(monkeypatch, sandbox):
|
||||
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
||||
events = await _collect(
|
||||
runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, permissions="ask", stream=True
|
||||
)
|
||||
)
|
||||
allowed, reason = adapter_cls.instances[0].approvals[0]
|
||||
assert allowed is False and "not answered" in reason
|
||||
assert isinstance(events[-1], Done)
|
||||
|
||||
|
||||
async def test_approval_without_ask_denied_in_run(monkeypatch, sandbox):
|
||||
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
||||
await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
||||
assert adapter_cls.instances[0].approvals[0][0] is False
|
||||
|
||||
|
||||
# -- structured output --------------------------------------------------------
|
||||
|
||||
|
||||
def _answer_script(text: str, output_json: str | None = None):
|
||||
async def script(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
||||
yield Text(text)
|
||||
ctx.output_json = output_json
|
||||
|
||||
return script
|
||||
|
||||
|
||||
async def test_structured_output_from_text(monkeypatch, sandbox):
|
||||
install_adapter(
|
||||
monkeypatch, script=_answer_script('Sure. {"x": 1} then {"value": 42} done')
|
||||
)
|
||||
result = await runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, output=Answer
|
||||
)
|
||||
assert result.output == Answer(value=42)
|
||||
|
||||
|
||||
async def test_structured_output_from_ctx_output_json(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch, script=_answer_script("ok", '{"value": 7}'))
|
||||
result = await runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, output=Answer
|
||||
)
|
||||
assert result.output == Answer(value=7)
|
||||
|
||||
|
||||
async def test_structured_output_invalid_carries_result(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch, script=_answer_script('{"value": "nope"}'))
|
||||
with pytest.raises(OutputInvalid) as info:
|
||||
await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, output=Answer)
|
||||
assert info.value.raw == '{"value": "nope"}'
|
||||
assert info.value.result is not None
|
||||
assert info.value.result.text == '{"value": "nope"}'
|
||||
|
||||
|
||||
async def test_structured_output_missing_json(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch, script=_answer_script("no json here"))
|
||||
with pytest.raises(OutputInvalid, match="no JSON"):
|
||||
await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, output=Answer)
|
||||
|
||||
|
||||
async def test_stream_yields_done_before_output_invalid(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch, script=_answer_script("nothing"))
|
||||
stream = runtime.aagent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, output=Answer, stream=True
|
||||
)
|
||||
seen: list[object] = []
|
||||
with pytest.raises(OutputInvalid):
|
||||
await _drain_into(stream, seen)
|
||||
assert isinstance(seen[-1], Done)
|
||||
|
||||
|
||||
async def _drain_into(stream, seen: list[object]) -> None:
|
||||
async for event in stream:
|
||||
seen.append(event)
|
||||
|
||||
|
||||
def test_last_json_object():
|
||||
assert runtime.last_json_object('a {"a": {"b": 1}} b {"c": 2}') == '{"c": 2}'
|
||||
assert runtime.last_json_object("{broken") is None
|
||||
|
||||
|
||||
# -- files --------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _edit_files(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
||||
root = ctx.sandbox.workdir
|
||||
with open(os.path.join(root, "new.txt"), "w") as fh:
|
||||
fh.write("new\n")
|
||||
with open(os.path.join(root, "keep.txt"), "w") as fh:
|
||||
fh.write("changed\n")
|
||||
os.remove(os.path.join(root, "gone.txt"))
|
||||
yield FileChange(path="new.txt", kind="created", diff=None)
|
||||
yield Text("edited")
|
||||
|
||||
|
||||
async def test_file_changes_emitted_once(monkeypatch, sandbox, tmp_path):
|
||||
(tmp_path / "keep.txt").write_text("original\n")
|
||||
(tmp_path / "gone.txt").write_text("bye\n")
|
||||
install_adapter(monkeypatch, script=_edit_files)
|
||||
events = await _collect(
|
||||
runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, stream=True)
|
||||
)
|
||||
file_events = [e for e in events if isinstance(e, FileChange)]
|
||||
assert sorted((e.path, e.kind) for e in file_events) == [
|
||||
("gone.txt", "deleted"),
|
||||
("keep.txt", "modified"),
|
||||
("new.txt", "created"),
|
||||
]
|
||||
result = events[-1].result
|
||||
by_path = {f.path: f for f in result.files}
|
||||
assert set(by_path) == {"gone.txt", "keep.txt", "new.txt"}
|
||||
assert "+changed" in by_path["keep.txt"].diff
|
||||
assert "-bye" in by_path["gone.txt"].diff
|
||||
assert "+new" in by_path["new.txt"].diff
|
||||
assert isinstance(events[-1], Done)
|
||||
|
||||
|
||||
# -- sessions -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_session_multi_turn_cost(monkeypatch, sandbox):
|
||||
adapter_cls = install_adapter(monkeypatch)
|
||||
async with runtime.aagent_session(Harness.CLAUDE_CODE, sandbox=sandbox) as session:
|
||||
first = await session.arun("one")
|
||||
second = await session.arun("two")
|
||||
assert first.cost == pytest.approx(0.25)
|
||||
assert second.cost == pytest.approx(0.25)
|
||||
assert second.usage.input_tokens == 10
|
||||
assert session.cost == pytest.approx(0.5)
|
||||
assert session.usage.calls == 2
|
||||
assert await session.history() == [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "user", "content": "two"},
|
||||
]
|
||||
adapter = adapter_cls.instances[0]
|
||||
assert adapter.calls == ["start", "turn", "turn", "stop"]
|
||||
assert len(FakeEndpoint.instances) == 1
|
||||
with pytest.raises(SessionClosed):
|
||||
await session.arun("three")
|
||||
|
||||
|
||||
async def test_await_asession(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
session = await runtime.aagent_session(Harness.CLAUDE_CODE, sandbox=sandbox)
|
||||
result = await session.arun("hi")
|
||||
await session.aclose()
|
||||
assert result.text == "hello world"
|
||||
|
||||
|
||||
async def test_session_restarts_after_timeout(monkeypatch, sandbox):
|
||||
calls = {"n": 0}
|
||||
|
||||
async def script(adapter, ctx, prompt) -> AsyncIterator[Event]:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
await wait_forever()
|
||||
yield Text("ok")
|
||||
|
||||
adapter_cls = install_adapter(monkeypatch, script=script)
|
||||
async with runtime.aagent_session(
|
||||
Harness.CLAUDE_CODE, sandbox=sandbox, timeout=0.2
|
||||
) as session:
|
||||
assert (await session.arun("one")).stop_reason == "timeout"
|
||||
assert (await session.arun("two")).text == "ok"
|
||||
adapter = adapter_cls.instances[0]
|
||||
assert adapter.calls[:4] == ["start", "turn", "stop", "start"]
|
||||
assert adapter.resumed_with == "native-123"
|
||||
|
||||
|
||||
async def test_detach_state_round_trip_resume(monkeypatch, sandbox):
|
||||
adapter_cls = install_adapter(monkeypatch)
|
||||
async with runtime.aagent_session(
|
||||
Harness.CLAUDE_CODE, sandbox=sandbox, model="m1"
|
||||
) as session:
|
||||
await session.arun("one")
|
||||
state = await session.adetach()
|
||||
data = state.dumps()
|
||||
assert b"sk-" not in data
|
||||
restored = State.loads(data)
|
||||
assert restored == state and restored.native_session_id == "native-123"
|
||||
|
||||
async with runtime.aagent_resume(data, sandbox=sandbox) as resumed:
|
||||
await resumed.arun("two")
|
||||
new_adapter = adapter_cls.instances[-1]
|
||||
assert new_adapter.calls[:2] == ["start", "resume"]
|
||||
assert new_adapter.resumed_with == "native-123"
|
||||
assert resumed.config.model == "m1"
|
||||
|
||||
|
||||
async def test_resume_requires_capability(monkeypatch, sandbox):
|
||||
install_adapter(
|
||||
monkeypatch,
|
||||
caps=NARROW_CAPS.__class__(**{**NARROW_CAPS.__dict__, "resume": False}),
|
||||
)
|
||||
state = State(harness=Harness.CODEX, native_session_id="x", workdir="/tmp")
|
||||
with pytest.raises(CapabilityUnsupported, match="resume"):
|
||||
runtime.aagent_resume(state, sandbox=sandbox)
|
||||
|
||||
|
||||
async def test_resume_state_without_native_id(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
state = State(harness=Harness.CODEX, native_session_id=None, workdir="/tmp")
|
||||
with pytest.raises(StateIncompatible):
|
||||
runtime.aagent_resume(state, sandbox=sandbox)
|
||||
|
||||
|
||||
async def test_history_requires_capability(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch, caps=NARROW_CAPS)
|
||||
async with runtime.aagent_session(Harness.CLAUDE_CODE, sandbox=sandbox) as session:
|
||||
with pytest.raises(CapabilityUnsupported):
|
||||
await session.history()
|
||||
|
||||
|
||||
def test_capabilities_uses_registry(monkeypatch):
|
||||
install_adapter(monkeypatch, caps=NARROW_CAPS)
|
||||
assert runtime.agent_capabilities(Harness.CODEX) is NARROW_CAPS
|
||||
with pytest.raises(TypeError):
|
||||
runtime.agent_capabilities("codex") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_fake_adapter_is_a_harness_adapter():
|
||||
assert issubclass(FakeAdapter, runtime.BaseHarnessHandler)
|
||||
|
||||
|
||||
async def test_turn_keeps_every_event_when_queue_overflows(monkeypatch, sandbox):
|
||||
"""A turn that emits more events than the queue holds must not drop any of them."""
|
||||
total = 40
|
||||
monkeypatch.setattr(runtime, "HARNESS_EVENT_QUEUE_MAX_SIZE", 4)
|
||||
|
||||
async def burst(adapter, ctx, prompt):
|
||||
for i in range(total):
|
||||
yield Text(f"{i},")
|
||||
|
||||
install_adapter(monkeypatch, script=burst)
|
||||
result = await runtime.aagent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
||||
texts = [e.delta for e in result.events if isinstance(e, Text)]
|
||||
assert texts == [f"{i}," for i in range(total)]
|
||||
assert result.stop_reason == "done"
|
||||
114
tests/unit/harness/test_sync.py
Normal file
114
tests/unit/harness/test_sync.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Tests for litellm/harness/sync.py: the sync bridge over the async runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.harness import sync
|
||||
from litellm.harness.types import Approval, Done, Harness, State, Text
|
||||
from tests.unit.harness.core_fakes import (
|
||||
FakeSandbox,
|
||||
install_adapter,
|
||||
script_approval,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sandbox(tmp_path) -> FakeSandbox:
|
||||
return FakeSandbox(str(tmp_path))
|
||||
|
||||
|
||||
async def _call_run_in_loop(sandbox: FakeSandbox) -> None:
|
||||
sync.agent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
||||
|
||||
|
||||
def test_sync_run_from_plain_code(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
result = sync.agent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
||||
assert result.text == "hello world"
|
||||
assert result.stop_reason == "done"
|
||||
|
||||
|
||||
def test_sync_stream_from_plain_code(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
stream = sync.agent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, stream=True)
|
||||
events = list(stream)
|
||||
assert isinstance(events[-1], Done)
|
||||
assert [e.delta for e in events if isinstance(e, Text)] == ["hello ", "world"]
|
||||
assert stream.result is not None and stream.result.text == "hello world"
|
||||
assert list(stream) == []
|
||||
|
||||
|
||||
def test_sync_stream_answers_approval(monkeypatch, sandbox):
|
||||
adapter_cls = install_adapter(monkeypatch, script=script_approval)
|
||||
for event in sync.agent(
|
||||
Harness.CLAUDE_CODE, "hi", sandbox=sandbox, permissions="ask", stream=True
|
||||
):
|
||||
if isinstance(event, Approval):
|
||||
event.allow()
|
||||
assert adapter_cls.instances[0].approvals[0][0] is True
|
||||
|
||||
|
||||
def test_sync_stream_close_early(monkeypatch, sandbox):
|
||||
adapter_cls = install_adapter(monkeypatch)
|
||||
with sync.agent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, stream=True) as stream:
|
||||
next(stream)
|
||||
assert adapter_cls.instances[0].calls[-1] == "stop"
|
||||
|
||||
|
||||
def test_sync_validation_errors_raise_eagerly(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
with pytest.raises(TypeError, match=r"Harness\.OPENCODE"):
|
||||
sync.agent("opencode", "hi", sandbox=sandbox, stream=True) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_sync_session_multi_turn_and_detach(monkeypatch, sandbox):
|
||||
adapter_cls = install_adapter(monkeypatch)
|
||||
with sync.agent_session(Harness.CLAUDE_CODE, sandbox=sandbox) as session:
|
||||
session.run("one")
|
||||
events = list(session.stream("two"))
|
||||
assert isinstance(events[-1], Done)
|
||||
assert session.cost == pytest.approx(0.5)
|
||||
assert len(session.history()) == 2
|
||||
state = session.detach()
|
||||
assert isinstance(state, State)
|
||||
with sync.agent_resume(state.dumps(), sandbox=sandbox) as resumed:
|
||||
assert resumed.run("three").text == "hello world"
|
||||
assert adapter_cls.instances[-1].resumed_with == "native-123"
|
||||
|
||||
|
||||
def test_sync_session_stop_returns_state(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
session = sync.agent_session(Harness.CLAUDE_CODE, sandbox=sandbox).start()
|
||||
session.run("one")
|
||||
state = session.stop()
|
||||
assert state.native_session_id == "native-123"
|
||||
|
||||
|
||||
def test_single_background_loop_thread(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
sync.agent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
||||
first = sync._LOOP.loop()
|
||||
sync.agent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
||||
assert sync._LOOP.loop() is first
|
||||
names = [t.name for t in threading.enumerate()]
|
||||
assert names.count("litellm-harness-loop") == 1
|
||||
|
||||
|
||||
async def test_run_inside_event_loop_raises(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
with pytest.raises(RuntimeError, match="aagent"):
|
||||
sync.agent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox)
|
||||
with pytest.raises(RuntimeError, match="aagent"):
|
||||
sync.agent(Harness.CLAUDE_CODE, "hi", sandbox=sandbox, stream=True)
|
||||
with pytest.raises(RuntimeError, match="aagent_session"):
|
||||
sync.agent_session(Harness.CLAUDE_CODE, sandbox=sandbox)
|
||||
|
||||
|
||||
def test_run_inside_asyncio_run_raises(monkeypatch, sandbox):
|
||||
install_adapter(monkeypatch)
|
||||
with pytest.raises(RuntimeError, match=r"await litellm\.aagent"):
|
||||
asyncio.run(_call_run_in_loop(sandbox))
|
||||
90
tests/unit/harness/test_types.py
Normal file
90
tests/unit/harness/test_types.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Tests for litellm/harness/types.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.harness.errors import StateIncompatible
|
||||
from litellm.harness.types import (
|
||||
Approval,
|
||||
Done,
|
||||
Harness,
|
||||
Result,
|
||||
State,
|
||||
Usage,
|
||||
require_harness,
|
||||
)
|
||||
|
||||
|
||||
def test_harness_is_plain_enum():
|
||||
assert Harness.CODEX.value == "codex"
|
||||
assert not isinstance(Harness.CODEX, str)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"given,hint",
|
||||
[
|
||||
("codex", "Harness.CODEX"),
|
||||
("claude-code", "Harness.CLAUDE_CODE"),
|
||||
("OPENCODE", "Harness.OPENCODE"),
|
||||
],
|
||||
)
|
||||
def test_require_harness_hint(given, hint):
|
||||
with pytest.raises(TypeError, match=hint):
|
||||
require_harness(given)
|
||||
|
||||
|
||||
def test_require_harness_no_hint_for_unknown():
|
||||
with pytest.raises(TypeError) as info:
|
||||
require_harness(42)
|
||||
assert "Did you mean" not in str(info.value)
|
||||
assert require_harness(Harness.DEEPAGENTS) is Harness.DEEPAGENTS
|
||||
|
||||
|
||||
def test_usage_total_tokens():
|
||||
assert Usage(input_tokens=3, output_tokens=4, calls=1).total_tokens == 7
|
||||
|
||||
|
||||
def test_done_exposes_result_fields():
|
||||
result = Result(
|
||||
text="t",
|
||||
output=None,
|
||||
files=[],
|
||||
events=[],
|
||||
usage=Usage(1, 2, 1),
|
||||
cost=0.5,
|
||||
stop_reason="done",
|
||||
session_id="s",
|
||||
)
|
||||
done = Done(result)
|
||||
assert done.usage.total_tokens == 3
|
||||
assert done.cost == 0.5
|
||||
assert done.stop_reason == "done"
|
||||
|
||||
|
||||
def test_state_round_trip_and_errors():
|
||||
state = State(Harness.CODEX, "thread-1", "/work", model="gpt")
|
||||
assert State.loads(state.dumps()) == state
|
||||
with pytest.raises(StateIncompatible):
|
||||
State.loads(b"not json")
|
||||
with pytest.raises(StateIncompatible):
|
||||
State.loads(b'{"harness": "nope", "version": 1, "workdir": "/"}')
|
||||
with pytest.raises(StateIncompatible, match="version"):
|
||||
State.loads(b'{"harness": "codex", "version": 99, "workdir": "/"}')
|
||||
|
||||
|
||||
async def test_approval_allow_deny_once():
|
||||
approval = Approval(tool="bash", input={})
|
||||
assert not approval.answered
|
||||
approval.allow()
|
||||
approval.deny("late")
|
||||
assert await approval.wait() == (True, "")
|
||||
assert approval.answered
|
||||
|
||||
|
||||
async def test_approval_resolved_from_other_thread():
|
||||
approval = Approval(tool="bash", input={})
|
||||
await asyncio.to_thread(approval.deny, "nope")
|
||||
assert await approval.wait() == (False, "nope")
|
||||
|
|
@ -28,6 +28,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
|
|||
sanitize_messages_for_tool_calling,
|
||||
)
|
||||
from litellm.types.llms.openai import ChatCompletionToolMessage
|
||||
from litellm.utils import validate_and_fix_openai_messages
|
||||
|
||||
|
||||
def _get_gemini_function_response_inline_data_parts(result):
|
||||
|
|
@ -4095,3 +4096,168 @@ def test_is_unsignable_thinking_block_treats_whitespace_only_as_empty():
|
|||
}
|
||||
|
||||
assert is_unsignable_thinking_block(whitespace_only_block) is True
|
||||
|
||||
|
||||
_CONTENT_LESS_USER_MESSAGES: Final = ({"role": "user"}, {"role": "user", "content": None})
|
||||
_CONTENT_LESS_TOOL_MESSAGES: Final = (
|
||||
{"role": "tool", "tool_call_id": "call_1"},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": None},
|
||||
)
|
||||
_BOSTON_WEATHER_TOOL_CALL_TURN: Final = (
|
||||
{"role": "user", "content": "What is the weather in Boston?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": '{"city": "Boston"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _conversation_around(
|
||||
content_less_user_message: dict[str, object],
|
||||
) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
|
||||
with_message: Final = [
|
||||
{"role": "user", "content": "What is the capital of France?"},
|
||||
content_less_user_message,
|
||||
{"role": "assistant", "content": "Paris."},
|
||||
{"role": "user", "content": "And of Spain?"},
|
||||
]
|
||||
without_message: Final = [message for message in with_message if message is not content_less_user_message]
|
||||
return validate_and_fix_openai_messages(with_message), validate_and_fix_openai_messages(without_message)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content_less_user_message", _CONTENT_LESS_USER_MESSAGES)
|
||||
def test_bedrock_converse_messages_pt_user_message_without_content_adds_no_block(
|
||||
content_less_user_message: dict[str, object],
|
||||
):
|
||||
with_message, without_message = _conversation_around(content_less_user_message)
|
||||
|
||||
assert _bedrock_converse_messages_pt(
|
||||
messages=with_message, model="anthropic.claude-haiku-4-5", llm_provider="bedrock"
|
||||
) == _bedrock_converse_messages_pt(messages=without_message, model="anthropic.claude-haiku-4-5", llm_provider="bedrock")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("content_less_user_message", _CONTENT_LESS_USER_MESSAGES)
|
||||
async def test_bedrock_converse_messages_pt_async_user_message_without_content_adds_no_block(
|
||||
content_less_user_message: dict[str, object],
|
||||
):
|
||||
with_message, without_message = _conversation_around(content_less_user_message)
|
||||
|
||||
assert await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async(
|
||||
messages=with_message, model="anthropic.claude-haiku-4-5", llm_provider="bedrock"
|
||||
) == await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async(
|
||||
messages=without_message, model="anthropic.claude-haiku-4-5", llm_provider="bedrock"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content_less_tool_message", _CONTENT_LESS_TOOL_MESSAGES)
|
||||
def test_bedrock_converse_messages_pt_tool_message_without_content_yields_empty_tool_result(
|
||||
content_less_tool_message: dict[str, object],
|
||||
):
|
||||
result: Final = _bedrock_converse_messages_pt(
|
||||
messages=validate_and_fix_openai_messages([*_BOSTON_WEATHER_TOOL_CALL_TURN, content_less_tool_message]),
|
||||
model="anthropic.claude-haiku-4-5",
|
||||
llm_provider="bedrock",
|
||||
)
|
||||
|
||||
tool_result: Final = result[-1]["content"][0]["toolResult"]
|
||||
assert result[-1]["role"] == "user"
|
||||
assert tool_result["toolUseId"] == "call_1"
|
||||
assert tool_result["content"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("content_less_tool_message", _CONTENT_LESS_TOOL_MESSAGES)
|
||||
async def test_bedrock_converse_messages_pt_async_tool_message_without_content_yields_empty_tool_result(
|
||||
content_less_tool_message: dict[str, object],
|
||||
):
|
||||
result: Final = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async(
|
||||
messages=validate_and_fix_openai_messages([*_BOSTON_WEATHER_TOOL_CALL_TURN, content_less_tool_message]),
|
||||
model="anthropic.claude-haiku-4-5",
|
||||
llm_provider="bedrock",
|
||||
)
|
||||
|
||||
tool_result: Final = result[-1]["content"][0]["toolResult"]
|
||||
assert tool_result["toolUseId"] == "call_1"
|
||||
assert tool_result["content"] == []
|
||||
|
||||
|
||||
def test_bedrock_converse_messages_pt_blank_user_text_sends_the_continue_message_text():
|
||||
continue_message: Final = {"role": "user", "content": "Please continue."}
|
||||
blank_last_turn: Final = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi."},
|
||||
{"role": "user", "content": " "},
|
||||
]
|
||||
explicit_last_turn: Final = [*blank_last_turn[:2], continue_message]
|
||||
|
||||
assert _bedrock_converse_messages_pt(
|
||||
messages=blank_last_turn,
|
||||
model="anthropic.claude-haiku-4-5",
|
||||
llm_provider="bedrock",
|
||||
user_continue_message=continue_message,
|
||||
) == _bedrock_converse_messages_pt(
|
||||
messages=explicit_last_turn,
|
||||
model="anthropic.claude-haiku-4-5",
|
||||
llm_provider="bedrock",
|
||||
user_continue_message=continue_message,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content_less_user_message", _CONTENT_LESS_USER_MESSAGES)
|
||||
def test_bedrock_converse_messages_pt_lone_content_less_user_turn_sends_the_continue_message(
|
||||
content_less_user_message: dict[str, object],
|
||||
):
|
||||
continue_message: Final = {"role": "user", "content": "Please continue."}
|
||||
|
||||
assert _bedrock_converse_messages_pt(
|
||||
messages=validate_and_fix_openai_messages([content_less_user_message]),
|
||||
model="anthropic.claude-haiku-4-5",
|
||||
llm_provider="bedrock",
|
||||
user_continue_message=continue_message,
|
||||
) == _bedrock_converse_messages_pt(
|
||||
messages=[continue_message],
|
||||
model="anthropic.claude-haiku-4-5",
|
||||
llm_provider="bedrock",
|
||||
user_continue_message=continue_message,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("content_less_user_message", _CONTENT_LESS_USER_MESSAGES)
|
||||
async def test_bedrock_converse_messages_pt_async_lone_content_less_user_turn_continues_under_modify_params(
|
||||
content_less_user_message: dict[str, object], monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "modify_params", True)
|
||||
|
||||
assert await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async(
|
||||
messages=validate_and_fix_openai_messages([content_less_user_message]),
|
||||
model="anthropic.claude-haiku-4-5",
|
||||
llm_provider="bedrock",
|
||||
) == await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async(
|
||||
messages=[{"role": "user", "content": ""}],
|
||||
model="anthropic.claude-haiku-4-5",
|
||||
llm_provider="bedrock",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content_less_user_message", _CONTENT_LESS_USER_MESSAGES)
|
||||
def test_bedrock_converse_messages_pt_lone_content_less_user_turn_adds_no_block_without_a_continue_message(
|
||||
content_less_user_message: dict[str, object], monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "modify_params", False)
|
||||
|
||||
assert (
|
||||
_bedrock_converse_messages_pt(
|
||||
messages=validate_and_fix_openai_messages([content_less_user_message]),
|
||||
model="anthropic.claude-haiku-4-5",
|
||||
llm_provider="bedrock",
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import glob
|
|||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -1024,3 +1026,143 @@ class TestJWTKeyMappingCascade:
|
|||
f"{path} must declare onDelete: Cascade on the JWT key mapping "
|
||||
"relation (issue #33702)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
class TestBuildRequestLogIndexes:
|
||||
"""The migration job hands the index build the direct database URL and the schema
|
||||
the migrations target, waits for it, and reports its result."""
|
||||
|
||||
@pytest.fixture
|
||||
def builds(self):
|
||||
return []
|
||||
|
||||
@pytest.fixture
|
||||
def build(self, builds):
|
||||
def record(database_url: str, schema: str) -> bool:
|
||||
builds.append((database_url, schema))
|
||||
return True
|
||||
|
||||
return record
|
||||
|
||||
def test_the_build_gets_the_direct_url_without_prisma_params_and_the_prisma_schema(self, monkeypatch, builds, build):
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@pooler:6543/db?schema=tenant&pgbouncer=true")
|
||||
monkeypatch.setenv("DIRECT_URL", "postgresql://u:p@primary:5432/db?connection_limit=1")
|
||||
|
||||
assert ProxyExtrasDBManager.build_request_log_indexes(build=build) is True
|
||||
|
||||
assert builds == [("postgresql://u:p@primary:5432/db", "tenant")]
|
||||
|
||||
def test_the_build_defaults_to_the_database_url_and_the_public_schema(self, monkeypatch, builds, build):
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@primary:5432/db")
|
||||
monkeypatch.delenv("DIRECT_URL", raising=False)
|
||||
|
||||
assert ProxyExtrasDBManager.build_request_log_indexes(build=build) is True
|
||||
|
||||
assert builds == [("postgresql://u:p@primary:5432/db", "public")]
|
||||
|
||||
def test_a_build_that_leaves_indexes_missing_is_reported_so_the_job_reruns(self, monkeypatch):
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@primary:5432/db")
|
||||
|
||||
assert ProxyExtrasDBManager.build_request_log_indexes(build=lambda url, schema: False) is False
|
||||
|
||||
def test_without_a_database_url_nothing_is_built(self, monkeypatch, builds, build):
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
|
||||
assert ProxyExtrasDBManager.build_request_log_indexes(build=build) is True
|
||||
|
||||
assert builds == []
|
||||
|
||||
|
||||
class TestStartRequestLogIndexBuild:
|
||||
"""A serving proxy that ran the migrations starts the index build on a daemon thread
|
||||
and goes on to serve while it runs."""
|
||||
|
||||
def test_the_build_runs_on_a_daemon_thread_that_does_not_hold_up_the_caller(self):
|
||||
release: Final = threading.Event()
|
||||
builds: Final[list[str]] = [] # mutable-ok: the builder thread hands back the thread it ran on
|
||||
|
||||
def build() -> bool:
|
||||
assert release.wait(5), "the caller never came back from start_request_log_index_build"
|
||||
builds.append(threading.current_thread().name)
|
||||
return True
|
||||
|
||||
thread: Final = ProxyExtrasDBManager.start_request_log_index_build(build=build)
|
||||
|
||||
assert builds == [], "the build ran before start_request_log_index_build returned"
|
||||
assert thread.daemon is True
|
||||
release.set()
|
||||
thread.join(5)
|
||||
assert builds == ["litellm-request-log-indexes"]
|
||||
|
||||
|
||||
class TestRunMigrationJob:
|
||||
"""`run_migration_job` is `setup_database` followed by the index build, each step's
|
||||
result deciding whether the job reports success."""
|
||||
|
||||
@pytest.fixture
|
||||
def calls(self):
|
||||
return []
|
||||
|
||||
@pytest.fixture
|
||||
def setup(self, calls):
|
||||
def record(result: bool):
|
||||
def setup_database(use_migrate: bool, use_v2_resolver: bool) -> bool:
|
||||
calls.append(("setup", use_migrate, use_v2_resolver))
|
||||
return result
|
||||
|
||||
return setup_database
|
||||
|
||||
return record
|
||||
|
||||
@pytest.fixture
|
||||
def build(self, calls):
|
||||
def record(result: bool):
|
||||
def build_request_log_indexes() -> bool:
|
||||
calls.append(("build",))
|
||||
return result
|
||||
|
||||
return build_request_log_indexes
|
||||
|
||||
return record
|
||||
|
||||
def test_the_job_builds_the_indexes_after_the_migrations_succeed(self, calls, setup, build):
|
||||
assert ProxyExtrasDBManager.run_migration_job(True, False, setup=setup(True), build=build(True)) is True
|
||||
|
||||
assert calls == [("setup", True, False), ("build",)]
|
||||
|
||||
def test_the_job_fails_without_building_when_the_migrations_fail(self, calls, setup, build):
|
||||
assert ProxyExtrasDBManager.run_migration_job(True, True, setup=setup(False), build=build(True)) is False
|
||||
|
||||
assert calls == [("setup", True, True)]
|
||||
|
||||
def test_the_job_fails_when_an_index_could_not_be_built(self, calls, setup, build):
|
||||
assert ProxyExtrasDBManager.run_migration_job(True, True, setup=setup(True), build=build(False)) is False
|
||||
|
||||
assert calls == [("setup", True, True), ("build",)]
|
||||
|
||||
|
||||
class TestMigrationJobOwnedDrift:
|
||||
JOB_INDEXES = (
|
||||
"-- CreateIndex\n"
|
||||
'CREATE INDEX "LiteLLM_SpendLogs_litellm_call_id_idx" ON "LiteLLM_SpendLogs"("litellm_call_id");\n'
|
||||
"\n-- CreateIndex\n"
|
||||
'CREATE INDEX "LiteLLM_SpendLogs_api_key_startTime_idx" ON "LiteLLM_SpendLogs"("api_key", "startTime");\n'
|
||||
)
|
||||
|
||||
def test_a_plain_spend_logs_table_only_loses_the_migration_job_indexes(self):
|
||||
filtered = ProxyExtrasDBManager._filter_migration_job_owned_drift(
|
||||
_PARTITIONED_DRIFT_SQL + self.JOB_INDEXES, partitioned=False
|
||||
)
|
||||
assert "LiteLLM_SpendLogs_litellm_call_id_idx" not in filtered
|
||||
assert "LiteLLM_SpendLogs_api_key_startTime_idx" not in filtered
|
||||
assert 'PRIMARY KEY ("request_id")' in filtered
|
||||
|
||||
def test_a_partitioned_spend_logs_table_also_loses_its_partitioning_artifacts(self):
|
||||
filtered = ProxyExtrasDBManager._filter_migration_job_owned_drift(
|
||||
_PARTITIONED_DRIFT_SQL + self.JOB_INDEXES, partitioned=True
|
||||
)
|
||||
assert "LiteLLM_SpendLogs_litellm_call_id_idx" not in filtered
|
||||
assert 'PRIMARY KEY ("request_id")' not in filtered
|
||||
assert "LiteLLM_SpendLogs_legacy" not in filtered
|
||||
assert 'ALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;' in filtered
|
||||
|
|
|
|||
149
tests/unit/litellm_proxy_extras/test_request_log_indexes.py
Normal file
149
tests/unit/litellm_proxy_extras/test_request_log_indexes.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import re
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from litellm_proxy_extras.migration_recovery import is_inert_migration
|
||||
from litellm_proxy_extras.request_log_indexes import (
|
||||
REQUEST_LOG_INDEXES,
|
||||
RequestLogIndex,
|
||||
filter_request_log_index_diff,
|
||||
)
|
||||
|
||||
PACKAGE: Final = Path(__file__).resolve().parents[3] / "litellm-proxy-extras" / "litellm_proxy_extras"
|
||||
SCHEMA: Final = PACKAGE / "schema.prisma"
|
||||
INERT_MIGRATIONS: Final = (
|
||||
"20260823000000_add_spend_logs_api_key_starttime_index",
|
||||
"20260831120001_spend_logs_litellm_call_id_index",
|
||||
)
|
||||
CALL_ID_INDEX: Final = RequestLogIndex(
|
||||
"LiteLLM_SpendLogs", "LiteLLM_SpendLogs_litellm_call_id_idx", '("litellm_call_id")'
|
||||
)
|
||||
|
||||
|
||||
def _prisma_indexes_of(schema: str, model: str) -> frozenset[str]:
|
||||
"""The index names Prisma derives for a model's @@index declarations: <model>_<columns joined by _>_idx."""
|
||||
body: Final = re.search(rf"model {model} \{{(.*?)\n\}}", schema, re.DOTALL)
|
||||
assert body is not None, model
|
||||
declarations: Final[tuple[str, ...]] = tuple(
|
||||
match.group(1) for match in re.finditer(r"@@index\(\[([^\]]+)\]\)", body.group(1))
|
||||
)
|
||||
return frozenset(
|
||||
f"{model}_{'_'.join(column.strip() for column in columns.split(','))}_idx" for columns in declarations
|
||||
)
|
||||
|
||||
|
||||
class TestTheIndexList:
|
||||
def test_every_migration_job_index_is_declared_in_the_prisma_schema_under_the_same_name(self):
|
||||
schema: Final = SCHEMA.read_text()
|
||||
for index in REQUEST_LOG_INDEXES:
|
||||
assert index.name in _prisma_indexes_of(schema, index.table), index
|
||||
|
||||
@pytest.mark.parametrize("name", INERT_MIGRATIONS)
|
||||
def test_the_migrations_that_used_to_build_these_indexes_run_no_sql(self, name: str):
|
||||
assert is_inert_migration((PACKAGE / "migrations" / name / "migration.sql").read_text())
|
||||
|
||||
|
||||
class TestIsInertMigration:
|
||||
@pytest.mark.parametrize(
|
||||
"script",
|
||||
(
|
||||
"",
|
||||
"-- only a comment\n",
|
||||
"/* block */\n-- line\n",
|
||||
"-- a semicolon; in a comment\n",
|
||||
";\n;",
|
||||
"-- why\nSELECT 1;\n",
|
||||
"select 1",
|
||||
),
|
||||
ids=(
|
||||
"empty",
|
||||
"line-comment",
|
||||
"both-comments",
|
||||
"semicolon-in-comment",
|
||||
"bare-separators",
|
||||
"select-1",
|
||||
"lowercase",
|
||||
),
|
||||
)
|
||||
def test_comments_and_a_select_1_alone_are_inert(self, script: str):
|
||||
assert is_inert_migration(script) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"script",
|
||||
(
|
||||
"SELECT 2;",
|
||||
'SELECT 1 FROM "LiteLLM_SpendLogs";',
|
||||
'-- comment\nCREATE INDEX "ix" ON "t" ("a");',
|
||||
"/* c */ ALTER TABLE t ADD COLUMN a TEXT",
|
||||
'SELECT 1; DROP INDEX "ix";',
|
||||
),
|
||||
ids=("select-2", "select-from", "index-after-comment", "alter-after-block-comment", "drop-after-select-1"),
|
||||
)
|
||||
def test_any_statement_is_not_inert(self, script: str):
|
||||
assert is_inert_migration(script) is False
|
||||
|
||||
|
||||
class TestPartitionIndexName:
|
||||
def test_a_partition_gets_the_name_postgres_would_give_an_inherited_index(self):
|
||||
assert CALL_ID_INDEX.partition_index_name("LiteLLM_SpendLogs_p2026_09") == (
|
||||
"LiteLLM_SpendLogs_p2026_09_litellm_call_id_idx"
|
||||
)
|
||||
|
||||
def test_an_index_not_prefixed_by_its_table_keeps_its_whole_name(self):
|
||||
index = RequestLogIndex("LiteLLM_SpendLogs", "call_id_lookup", '("litellm_call_id")')
|
||||
assert index.partition_index_name("LiteLLM_SpendLogs_pdefault") == "LiteLLM_SpendLogs_pdefault_call_id_lookup"
|
||||
|
||||
def test_a_long_name_is_cut_to_63_bytes_with_a_digest_that_keeps_partitions_apart(self):
|
||||
first = CALL_ID_INDEX.partition_index_name("LiteLLM_SpendLogs_p" + "x" * 50 + "_2026_09")
|
||||
second = CALL_ID_INDEX.partition_index_name("LiteLLM_SpendLogs_p" + "x" * 50 + "_2026_10")
|
||||
assert len(first.encode()) == 63 and len(second.encode()) == 63
|
||||
assert first != second
|
||||
assert first.startswith("LiteLLM_SpendLogs_p") and first[-9] == "_"
|
||||
|
||||
def test_the_byte_limit_counts_multibyte_characters(self):
|
||||
name = CALL_ID_INDEX.partition_index_name("é" * 40)
|
||||
assert len(name.encode()) <= 63 and len(name) < 63
|
||||
|
||||
|
||||
class TestColumns:
|
||||
def test_the_columns_are_the_quoted_names_of_the_definition_in_order(self):
|
||||
index = RequestLogIndex(
|
||||
"LiteLLM_SpendLogs", "LiteLLM_SpendLogs_api_key_startTime_idx", '("api_key", "startTime")'
|
||||
)
|
||||
assert index.columns == ("api_key", "startTime")
|
||||
|
||||
def test_every_migration_job_index_names_at_least_one_column(self):
|
||||
assert all(index.columns for index in REQUEST_LOG_INDEXES)
|
||||
|
||||
|
||||
DRIFT_WITH_BOTH_INDEXES: Final = (
|
||||
"-- CreateIndex\n"
|
||||
'CREATE INDEX "LiteLLM_SpendLogs_litellm_call_id_idx" ON "LiteLLM_SpendLogs"("litellm_call_id");\n'
|
||||
"\n"
|
||||
"-- CreateIndex\n"
|
||||
'CREATE INDEX "LiteLLM_SpendLogs_api_key_startTime_idx" ON "LiteLLM_SpendLogs"("api_key", "startTime");\n'
|
||||
)
|
||||
|
||||
|
||||
class TestFilterRequestLogIndexDiff:
|
||||
def test_a_drift_script_that_only_creates_the_migration_job_indexes_becomes_empty(self):
|
||||
assert filter_request_log_index_diff(DRIFT_WITH_BOTH_INDEXES) == ""
|
||||
|
||||
def test_other_statements_survive_with_the_migration_job_indexes_removed(self):
|
||||
other: Final = '-- AlterTable\nALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;\n'
|
||||
filtered = filter_request_log_index_diff(other + DRIFT_WITH_BOTH_INDEXES)
|
||||
assert 'ALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;' in filtered
|
||||
assert "LiteLLM_SpendLogs_litellm_call_id_idx" not in filtered
|
||||
assert "LiteLLM_SpendLogs_api_key_startTime_idx" not in filtered
|
||||
|
||||
def test_an_index_of_another_name_on_spend_logs_is_kept(self):
|
||||
sql: Final = 'CREATE INDEX "LiteLLM_SpendLogs_end_user_idx" ON "LiteLLM_SpendLogs"("end_user");\n'
|
||||
assert filter_request_log_index_diff(sql) == sql
|
||||
|
||||
def test_a_drop_of_a_migration_job_index_is_kept_for_the_operator_to_see(self):
|
||||
sql: Final = 'DROP INDEX "LiteLLM_SpendLogs_litellm_call_id_idx";\n'
|
||||
assert filter_request_log_index_diff(sql) == sql
|
||||
|
||||
def test_an_empty_script_stays_empty(self):
|
||||
assert filter_request_log_index_diff("") == ""
|
||||
0
tests/unit/llms/base_llm/harness/__init__.py
Normal file
0
tests/unit/llms/base_llm/harness/__init__.py
Normal file
|
|
@ -7769,6 +7769,17 @@ def test_mid_conversation_system_entry_without_text_is_dropped(empty_content):
|
|||
assert out_messages == [{"role": "user", "content": "hi"}, {"role": "user", "content": "done"}]
|
||||
|
||||
|
||||
def test_system_entry_without_content_key_transforms_like_an_empty_one():
|
||||
config = AmazonConverseConfig()
|
||||
leading_without_key = [{"role": "system"}, {"role": "user", "content": "hi"}]
|
||||
leading_empty = [{"role": "system", "content": ""}, {"role": "user", "content": "hi"}]
|
||||
assert config._transform_system_message(leading_without_key) == config._transform_system_message(leading_empty)
|
||||
assert config._transform_system_message(leading_without_key) == ([{"role": "user", "content": "hi"}], [])
|
||||
mid_without_key = [{"role": "user", "content": "hi"}, {"role": "system"}, {"role": "user", "content": "done"}]
|
||||
mid_empty = [{"role": "user", "content": "hi"}, {"role": "system", "content": ""}, {"role": "user", "content": "done"}]
|
||||
assert config._transform_system_message(mid_without_key) == config._transform_system_message(mid_empty)
|
||||
|
||||
|
||||
def _thinking_reply(text: str) -> dict:
|
||||
return {
|
||||
"role": "assistant",
|
||||
|
|
|
|||
0
tests/unit/llms/claude_code/__init__.py
Normal file
0
tests/unit/llms/claude_code/__init__.py
Normal file
0
tests/unit/llms/claude_code/harness/__init__.py
Normal file
0
tests/unit/llms/claude_code/harness/__init__.py
Normal file
0
tests/unit/llms/claude_code/harness/fixtures/__init__.py
Normal file
0
tests/unit/llms/claude_code/harness/fixtures/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{"type": "system", "subtype": "init", "cwd": "/workspace", "session_id": "53af83ee-c3e1-4b96-a70a-f15b6cb6c794", "tools": ["Task", "Bash", "CronCreate", "CronDelete", "CronList", "Edit", "EnterWorktree", "ExitWorktree", "ListAgents", "NotebookEdit", "Read", "ReportFindings", "ScheduleWakeup", "SendMessage", "Skill", "TaskStop", "WebFetch", "WebSearch", "Workflow", "Write"], "mcp_servers": [], "model": "does-not-exist-model-xyz", "permissionMode": "bypassPermissions", "slash_commands": ["deep-research", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator", "agents", "auto-mode-setup", "autocompact", "clear", "color", "compact", "config", "output-style", "context", "effort", "fast", "focus", "heapdump", "init", "mcp", "model", "__remote-workflow", "workflow-launch-exec", "reload-plugins", "reload-skills", "rename", "security-review", "usage", "insights", "recap", "goal", "list-agents", "team-onboarding"], "terminal_slash_commands": ["doctor", "color", "focus", "reload-plugins"], "apiKeySource": "none", "claude_code_version": "2.1.285", "output_style": "default", "agents": ["claude", "Explore", "general-purpose", "Plan", "statusline-setup"], "skills": ["deep-research", "dataviz", "update-config", "verify", "debug", "code-review", "simplify", "batch", "fewer-permission-prompts", "doctor", "loop", "claude-api", "workflow-authoring", "run", "run-skill-generator"], "plugins": [{"name": "cc-plugin-agents-md", "path": "builtin", "source": "cc-plugin-agents-md@builtin"}], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": true, "product_feedback_disabled": true, "uuid": "a5308e12-af9c-41a0-97dc-91fc574b03dc", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "per_turn_effort_active": false, "view_mode": "default"}
|
||||
{"type": "assistant", "message": {"diagnostics": null, "id": "4a8ebe84-f673-472b-9f28-b38722e84b33", "container": null, "model": "<synthetic>", "role": "assistant", "stop_details": null, "stop_reason": "stop_sequence", "stop_sequence": "", "type": "message", "usage": {"output_tokens_details": null, "input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": null, "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": null, "iterations": null, "speed": null, "fallback_credit": null}, "content": [{"type": "text", "text": "API Error: 400 litellm.BadRequestError: You passed in model=does-not-exist-model-xyz. There are no healthy deployments for this model\n\nLiteLLM: model group 'does-not-exist-model-xyz' failed with the error above and no fallback model group was found for it, so the request was not retried on another model. Fallbacks are configured for: anthropic/*, anthropic/claude-opus-4-8, claude-mixed-router, anthropic/claude-fable-5, claude-opus-5, claude-sonnet-5, claude-fable-5, claude-fable-5-1, claude-haiku-4-5-20251001. Add a fallbacks entry for that model group (Router fallbacks or proxy router_settings.fallbacks) to retry on another model."}], "context_management": null}, "parent_tool_use_id": null, "session_id": "53af83ee-c3e1-4b96-a70a-f15b6cb6c794", "uuid": "f8c605c9-c1d6-43b0-89f8-aa64015d7895", "timestamp": "2026-09-30T17:00:14.185Z", "error": "unknown", "is_api_error_message": true}
|
||||
{"duration_api_ms": 0, "stop_reason": "stop_sequence", "session_id": "53af83ee-c3e1-4b96-a70a-f15b6cb6c794", "total_cost_usd": 0, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard", "fallback_credit": null}, "modelUsage": {}, "permission_denials": [], "terminal_reason": "api_error", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": true, "num_turns": 1, "subtype": "success", "api_error_status": 400, "result": "API Error: 400 litellm.BadRequestError: You passed in model=does-not-exist-model-xyz. There are no healthy deployments for this model\n\nLiteLLM: model group 'does-not-exist-model-xyz' failed with the error above and no fallback model group was found for it, so the request was not retried on another model. Fallbacks are configured for: anthropic/*, anthropic/claude-opus-4-8, claude-mixed-router, anthropic/claude-fable-5, claude-opus-5, claude-sonnet-5, claude-fable-5, claude-fable-5-1, claude-haiku-4-5-20251001. Add a fallbacks entry for that model group (Router fallbacks or proxy router_settings.fallbacks) to retry on another model.", "type": "result", "duration_ms": 6781, "uuid": "c38e7f6d-8920-44f4-bab5-a599535509a0", "queued_turn_count": 0, "result_index": 0}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue