From efbb6e80061d4f44a25f9a21605943b370be3e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Drey=C3=B8e=20Herfort?= Date: Tue, 29 Sep 2026 23:41:55 +0200 Subject: [PATCH 1/4] Restore lm15 subtree base from source pin git-subtree-dir: dspy/_vendor/lm15 git-subtree-split: 63b6bb319f38ad1d31060138da54cb8ac8718711 --- __init__.py | 298 ++++ _authlock.py | 332 ++++ _version.py | 3 + access.py | 788 +++++++++ adaptation.py | 218 +++ auth.py | 932 ++++++++++ authkit.py | 360 ++++ batch.py | 165 ++ cloud/__init__.py | 0 cloud/chains.py | 1464 ++++++++++++++++ cloud/hosts.py | 265 +++ cloud/rs256.py | 204 +++ cloud/sigv4.py | 153 ++ compat.py | 1247 +++++++++++++ credentials.py | 190 ++ doctor.py | 420 +++++ errors.py | 636 +++++++ features.py | 373 ++++ judgments.py | 312 ++++ live.py | 657 +++++++ lm15-python2.code-workspace | 11 + model_registry.py | 9 + models.py | 301 ++++ profiles.py | 294 ++++ protocols.py | 84 + providers/__init__.py | 48 + providers/anthropic.py | 1279 ++++++++++++++ providers/async_base.py | 1070 ++++++++++++ providers/base.py | 1441 +++++++++++++++ providers/claude_code.py | 69 + providers/common.py | 537 ++++++ providers/gemini.py | 1976 +++++++++++++++++++++ providers/openai.py | 2122 +++++++++++++++++++++++ providers/openai_chat.py | 2126 +++++++++++++++++++++++ providers/openai_codex.py | 78 + providers/typesafe.py | 341 ++++ providers/xai.py | 297 ++++ py.typed | 0 rate_limits.py | 94 + registry.py | 554 ++++++ result.py | 1211 +++++++++++++ router.py | 1423 +++++++++++++++ serde.py | 1363 +++++++++++++++ sse.py | 105 ++ testing.py | 168 ++ tools.py | 725 ++++++++ transports/__init__.py | 46 + transports/_async.py | 645 +++++++ transports/_exceptions.py | 40 + transports/_fetch.py | 208 +++ transports/_http11.py | 604 +++++++ transports/_limits.py | 83 + transports/_proxy.py | 98 ++ transports/_ssl.py | 110 ++ transports/_sync.py | 594 +++++++ transports/_timeouts.py | 53 + transports/_types.py | 206 +++ transports/_url.py | 103 ++ types.py | 3274 +++++++++++++++++++++++++++++++++++ vet.py | 883 ++++++++++ video_jobs.py | 135 ++ 61 files changed, 33825 insertions(+) create mode 100644 __init__.py create mode 100644 _authlock.py create mode 100644 _version.py create mode 100644 access.py create mode 100644 adaptation.py create mode 100644 auth.py create mode 100644 authkit.py create mode 100644 batch.py create mode 100644 cloud/__init__.py create mode 100644 cloud/chains.py create mode 100644 cloud/hosts.py create mode 100644 cloud/rs256.py create mode 100644 cloud/sigv4.py create mode 100644 compat.py create mode 100644 credentials.py create mode 100644 doctor.py create mode 100644 errors.py create mode 100644 features.py create mode 100644 judgments.py create mode 100644 live.py create mode 100644 lm15-python2.code-workspace create mode 100644 model_registry.py create mode 100644 models.py create mode 100644 profiles.py create mode 100644 protocols.py create mode 100644 providers/__init__.py create mode 100644 providers/anthropic.py create mode 100644 providers/async_base.py create mode 100644 providers/base.py create mode 100644 providers/claude_code.py create mode 100644 providers/common.py create mode 100644 providers/gemini.py create mode 100644 providers/openai.py create mode 100644 providers/openai_chat.py create mode 100644 providers/openai_codex.py create mode 100644 providers/typesafe.py create mode 100644 providers/xai.py create mode 100644 py.typed create mode 100644 rate_limits.py create mode 100644 registry.py create mode 100644 result.py create mode 100644 router.py create mode 100644 serde.py create mode 100644 sse.py create mode 100644 testing.py create mode 100644 tools.py create mode 100644 transports/__init__.py create mode 100644 transports/_async.py create mode 100644 transports/_exceptions.py create mode 100644 transports/_fetch.py create mode 100644 transports/_http11.py create mode 100644 transports/_limits.py create mode 100644 transports/_proxy.py create mode 100644 transports/_ssl.py create mode 100644 transports/_sync.py create mode 100644 transports/_timeouts.py create mode 100644 transports/_types.py create mode 100644 transports/_url.py create mode 100644 types.py create mode 100644 vet.py create mode 100644 video_jobs.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000000..63287be3bb --- /dev/null +++ b/__init__.py @@ -0,0 +1,298 @@ +"""lm15 — a provider-neutral, low-level foundation for talking to LLM APIs. + +lm15 is deliberately NOT a user-facing convenience layer. It is the dependency +for libraries that want to build their own take on the right Python API for +AI systems: one canonical representation (Request/Response/Message/Part, +stream events, errors), exact serde for it, and adapters that translate it to +and from each provider's wire format — stdlib-only, with its own HTTP +transport. Build your DSL on top; let lm15 handle the providers. + +Conformance to the canonical representation is pinned by the lm15-contract +corpus; this package is the reference implementation, not the spec. + +Quick tour: + + from lm15 import AnthropicLM, Request, Message + + lm = AnthropicLM(api_key="...") + response = lm.complete(Request(model="claude-sonnet-4-5", + messages=(Message.user("hello"),))) + response.text # canonical accessors + for event in lm.stream(request): ... # canonical stream events + +The top level is curated for application developers. Deeper audiences +import one module (API review, 2026-07-13): +`lm15.serde` (the complete to_dict/from_dict pairs), `lm15.providers` +(BaseProviderLM, ProviderDialect, transports protocols, Credential), +`lm15.errors` (map_http_error and friends), `lm15.registry` (the one +table of named providers: PROVIDERS / ProviderDefinition — dialect, +access policy, compat preset, console URL), `lm15.router` (the +RouteRule/DEFAULT_RULES tables and the ADAPTERS/CHAT_PRESET_ROUTES views +of the registry), +`lm15.profiles` / `lm15.compat` (profile and compat-policy machinery), +`lm15.sse`, `lm15.transports` (HTTP plumbing), `lm15.live` (realtime +sessions; optional `websockets` dependency), `lm15.auth` (local +subscription credentials: locked, atomic, double-checked refresh), +`lm15.authkit` (login-flow primitives: PKCE, device-code polling, +loopback listener, credential store), `lm15.doctor` (`explain_auth`: +rung-by-rung credential resolution, no secrets rendered), and +`lm15.vet` (the conformance shim CLI: `python -m lm15.vet`). + +Migrating from the OpenAI SDK or litellm: +`LMRouter().complete_from_openai_chat(model, messages, **kwargs)` takes +their supported request options (their model strings too) and answers with +a `Response`. With `stream=True` it returns a lazy `ResponseStream`: +iterate text, then read `.response`; use `with` for early-exit cleanup. +`RouterConfig(api_keys={"openai": key})` supplies both OpenAI APIs; +exact endpoint entries still override shared credentials. +Underneath: `request_from_openai_chat(body, compat=...)` reads the JSON a +client would POST to `/chat/completions` into a `Request`, or refuses +with the key named (MAP-12), and +`response_from_openai_chat(body)` reads a Chat Completions response body +(a server's, or litellm's `ModelResponse.model_dump()`) into a `Response` +with the same reader the OpenAI Chat adapter uses (docs/migrating-from- +openai-chat.md). They are the only place lm15 reads a foreign format. + +The lowercase part-factory helpers (`text`, `image`, `tool_call`, ...) live in +`lm15.types`, not at the top level — generic lowercase names at package top +level invite collisions with user code. +""" + +from ._version import __version__ + +# ── Canonical types ────────────────────────────────────────────────── +from .batch import AsyncBatchJob, BatchJob +from .video_jobs import AsyncVideoJob, VideoJob +from .live import Turn +from .types import ( + # request/response core + Request, + Response, + Message, + Usage, + Config, + CacheConfig, + Reasoning, + ToolChoice, + ErrorDetail, + ContinuationState, + # parts + TextPart, + ThinkingPart, + RefusalPart, + CitationPart, + ImagePart, + AudioPart, + VideoPart, + DocumentPart, + BinaryPart, + ToolCallPart, + ToolResultPart, + DataPart, + # tools + FunctionTool, + BuiltinTool, + ToolCallInfo, + # logprobs + TokenLogprob, + TopLogprob, + # streaming + StreamStartEvent, + StreamDeltaEvent, + StreamEndEvent, + StreamErrorEvent, + TextDelta, + ThinkingDelta, + AudioDelta, + ImageDelta, + ToolCallDelta, + CitationDelta, + ContinuationDelta, + # auxiliary endpoints + FileUploadRequest, + FileInfo, + FilePage, + CacheInfo, + CachePage, + CachedPrefix, + BatchRequest, + BatchEntry, + BatchJobInfo, + VideoGenerationRequest, + VideoJobInfo, + ImageGenerationRequest, + ImageGenerationResponse, + SpeechGenerationRequest, + SpeechGenerationResponse, + AudioFormat, + # live session types + LiveConfig, + # part factory for tool results (the one lowercase factory beginners + # need at the top level; the rest stay in lm15.types) + tool_result, + # vocabulary aliases + constants + Role, + PartType, + FinishReason, + ReasoningEffort, + ErrorCode, + StreamEventType, + ROLE_VALUES, + FINISH_REASONS, + ERROR_CODES, +) + +# ── Access policies (auth by composition; spec/auth.md AUTH-10) ────── +from .features import AccessPolicy, EndpointSupport + +# ── Errors ─────────────────────────────────────────────────────────── +from .errors import ( + LM15Error, + StreamAssemblyError, + CollectionLimitError, + StreamCleanupWarning, + TransportError, + LockTimeoutError, + ConfigurationError, + CapabilityError, + ProviderError, + AuthError, + BillingError, + RateLimitError, + InvalidRequestError, + ContextLengthError, + TimeoutError, + ServerError, + UnsupportedModelError, + UnsupportedFeatureError, + NotConfiguredError, + RETRYABLE_ERRORS, +) + +# ── Providers ──────────────────────────────────────────────────────── +from .providers import ( + OpenAILM, + OpenAIChatLM, + AnthropicLM, + GeminiLM, + ClaudeCodeLM, + OpenAICodexLM, + XaiLM, + TypeSafeLM, +) +from .providers.openai_chat import request_from_openai_chat, response_from_openai_chat +from .protocols import ProviderLM +from .providers.async_base import ( + AsyncOpenAILM, + AsyncOpenAIChatLM, + AsyncAnthropicLM, + AsyncGeminiLM, + AsyncClaudeCodeLM, + AsyncOpenAICodexLM, + AsyncXaiLM, + AsyncTypeSafeLM, +) + +# ── Stream assembly ────────────────────────────────────────────────── +from .result import ( + ResponseStream, + AsyncResponseStream, + amaterialize_response, + materialize_response, + response_to_events, +) + +# ── Model metadata (catalog hydration) ────────────────────────────── +from .models import ModelInfo, ModelRegistry + +# ── Router (lm15.router) ───────────────────────────────────────────── +from .router import ( + AmbiguousModelError, + AsyncLMRouter, + LMRouter, + MissingCredentialError, + Resolution, + RouterConfig, + UnknownModelError, +) + +# ── Connection budget (lm15.transports) ─────────────────────────────── +from .transports import Timeouts + +# ── MAP-13 adaptations (lm15.adaptation) ────────────────────────────── +from .adaptation import Adaptation, AdaptationPolicy + +# ── Tool derivation (lm15.tools) ───────────────────────────────────── +from .tools import ( + DerivedParam, + ToolConfig, + ToolDerivation, + ToolDerivationError, + tool, +) +from .tools import derive as derive_tool + +# ── Judgments (changes/2026-09-17-judgments.md) ───────────────────── +from .judgments import choice, judgments, score, yes_no +from .types import data + +__all__ = [ + "__version__", + # core + "Request", "Response", "Message", "Usage", "Config", "CacheConfig", + "Reasoning", "ToolChoice", "ErrorDetail", "ContinuationState", + # parts + "TextPart", "ThinkingPart", "RefusalPart", "CitationPart", "DataPart", "ImagePart", + "AudioPart", "VideoPart", "DocumentPart", "BinaryPart", "ToolCallPart", + "ToolResultPart", + # tools + "FunctionTool", "BuiltinTool", "ToolCallInfo", "tool_result", + # logprobs + "TokenLogprob", "TopLogprob", + # streaming + "StreamStartEvent", "StreamDeltaEvent", "StreamEndEvent", + "StreamErrorEvent", "TextDelta", "ThinkingDelta", "AudioDelta", + "ImageDelta", "ToolCallDelta", "CitationDelta", "ContinuationDelta", + # auxiliary endpoints + "FileUploadRequest", "FileInfo", "FilePage", + "CacheInfo", "CachePage", "CachedPrefix", + "BatchRequest", "BatchJobInfo", "BatchEntry", + "VideoGenerationRequest", "VideoJobInfo", "VideoJob", "AsyncVideoJob", + "BatchJob", "AsyncBatchJob", + "ImageGenerationRequest", "ImageGenerationResponse", + "SpeechGenerationRequest", "SpeechGenerationResponse", "AudioFormat", + "LiveConfig", "Turn", + # vocabularies + "Role", "PartType", "FinishReason", "ReasoningEffort", "ErrorCode", + "StreamEventType", "ROLE_VALUES", "FINISH_REASONS", "ERROR_CODES", + # errors (the catchable taxonomy + the retry predicate) + "LM15Error", "TransportError", "LockTimeoutError", "StreamAssemblyError", "CollectionLimitError", "StreamCleanupWarning", "ConfigurationError", "CapabilityError", + "ProviderError", "AuthError", "BillingError", "RateLimitError", + "InvalidRequestError", "ContextLengthError", "TimeoutError", + "ServerError", "UnsupportedModelError", "UnsupportedFeatureError", + "NotConfiguredError", "RETRYABLE_ERRORS", + "AccessPolicy", "EndpointSupport", + # providers + "choice", "judgments", "score", "yes_no", "data", + "OpenAILM", "OpenAIChatLM", "AnthropicLM", "GeminiLM", "ClaudeCodeLM", "OpenAICodexLM", "XaiLM", "TypeSafeLM", "AsyncTypeSafeLM", + "ProviderLM", + # the Chat Completions dialect read in: a request body -> Request (MAP-12), a response body -> Response + "request_from_openai_chat", "response_from_openai_chat", + # async mirror providers + "AsyncOpenAILM", "AsyncOpenAIChatLM", "AsyncAnthropicLM", "AsyncGeminiLM", + "AsyncClaudeCodeLM", "AsyncOpenAICodexLM", "AsyncXaiLM", + # stream assembly + "ResponseStream", "AsyncResponseStream", + "materialize_response", "amaterialize_response", "response_to_events", + # model metadata (catalog hydration) + "ModelInfo", "ModelRegistry", + # router (lm15.router; the rule/preset tables live there too) + "LMRouter", "AsyncLMRouter", "RouterConfig", "Resolution", + "Timeouts", + "Adaptation", "AdaptationPolicy", + "UnknownModelError", + "AmbiguousModelError", "MissingCredentialError", + # tool derivation (lm15.tools) + "tool", "derive_tool", "ToolConfig", "ToolDerivation", "DerivedParam", + "ToolDerivationError", +] diff --git a/_authlock.py b/_authlock.py new file mode 100644 index 0000000000..243508a76e --- /dev/null +++ b/_authlock.py @@ -0,0 +1,332 @@ +""" +lm15._authlock — cross-process credential-file locking and atomic writes. + +Internal module. Two primitives with strict semantics: + +- :func:`hold_file_lock` — an advisory, cross-process, exclusive lock scoped + to a credential file's real path. Locks live in an lm15-owned directory + (``$XDG_CACHE_HOME/lm15/locks`` by default), NOT next to the guarded file, + because credential files such as ``~/.claude/.credentials.json`` belong to + other tools whose directories lm15 must not populate. +- :func:`write_private_json_atomic` — write-to-temp + fsync + ``os.replace`` + so a crash mid-write can never leave a truncated or half-written + credential file. The temp file is created with mode 0600 before any secret + byte is written. + +Stated limitations (these are trade-offs, not oversights): + +- The lock is advisory and cooperative: it serializes lm15 processes against + each other. Foreign writers (the Claude Code CLI, the Codex CLI) do not + take this lock. Callers mitigate by re-reading the file inside the lock + before refreshing (double-checked refresh), so a foreign refresh that + landed while we waited is used instead of clobbered. +- ``flock`` semantics are unreliable on some network filesystems (NFS). +- On Windows the implementation falls back to ``msvcrt.locking`` byte locks; + same advisory semantics, best-effort. +""" + +from __future__ import annotations + +import errno +import errno +import hashlib +import json +import os +import stat +import tempfile +import time +from collections import deque +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +from .errors import LockTimeoutError, NotConfiguredError + +_DEFAULT_LOCK_TIMEOUT_S = 60.0 +_LOCK_POLL_INTERVAL_S = 0.05 + + +class CredentialLockTimeout(LockTimeoutError, TimeoutError): + """Could not acquire the credential-file lock within the deadline. + + An :class:`lm15.errors.LockTimeoutError` (ErrorCode ``lock_timeout``, + retryable; spec/auth.md AUTH-6) and the builtin ``TimeoutError`` at + once: inside the lm15 family for ``except LM15Error`` handlers, and a + ``TimeoutError`` for code written before the code existed. + """ + + +def _lock_dir() -> Path: + override = os.environ.get("LM15_LOCK_DIR") + if override: + return Path(override).expanduser() + cache_home = os.environ.get("XDG_CACHE_HOME") + if cache_home: + return Path(cache_home).expanduser() / "lm15" / "locks" + try: + base = Path("~/.cache").expanduser() + except RuntimeError as exc: # no home directory: a WASI guest, some containers + raise NotConfiguredError( + "No home directory to keep the credential lock in, so lm15 cannot " + "serialize credential refreshes against other processes. Reading " + "credentials still works; refreshing them from here does not.", + credential_hint="Set LM15_LOCK_DIR (or XDG_CACHE_HOME), or pass an explicit credential instead of a stored login", + ) from exc + return base / "lm15" / "locks" + + +def _strip_windows_verbatim(path: str) -> str: + path = path.replace("/", "\\") + if path[:8].lower() == "\\\\?\\unc\\": + return "\\\\" + path[8:] + if path.startswith("\\\\?\\"): + rest = path[4:] + if len(rest) < 3 or not rest[0].isascii() or not rest[0].isalpha() or rest[1:3] != ":\\": + raise ValueError("Unsupported Windows credential path namespace") + return rest + return path + + +def _windows_identity_key(canonical: str) -> str: + """Pure key spelling, also testable on POSIX; no lexical path resolution. + + Windows migration: old and new SDK processes MUST NOT overlap. Removing + verbatim prefixes and lowercasing changes previously published lock names. + Like normcase, lowercase deliberately overlocks case-sensitive directories. + Use Unicode string lowercase (not casefold or locale-sensitive lowercase) + in all SDKs, including Rust's str::to_lowercase. + """ + return _strip_windows_verbatim(canonical).lower() + + +def _real_path_allow_missing(target: str) -> str: + """Resolve components in filesystem order; only ENOENT is recoverable. + + Unlike abspath/normpath, never collapse symlink/.. before reading the + link. A missing component does not stop the walk: later .. can return + to an existing ancestor. Forty link expansions bound loops in every SDK. + """ + windows = os.name == "nt" + + def parts(value: str, base: str) -> tuple[str, deque[str]]: + if "\0" in value: + raise ValueError("NUL in credential path") + # Refuse unpaired surrogates rather than hash a lossy encoding. + value.encode("utf-8") + if windows: + value = _strip_windows_verbatim(value) + drive, tail = os.path.splitdrive(value) + unc = drive.startswith("\\\\") + if value.startswith("\\\\.\\") or (drive and not unc and not tail.startswith("\\")): + raise ValueError("Unsupported Windows credential path namespace or drive-relative path") + if unc: + roots = drive[2:].split("\\") + if len(roots) != 2 or any(not p or not p.isascii() or p in (".", "..") or "?" in p or ":" in p or p.endswith((".", " ")) for p in roots): + raise ValueError("Unsupported Windows credential path root") + elif drive and not (len(drive) == 2 and drive[1] == ":" and drive[0].isascii() and drive[0].isalpha()): + raise ValueError("Unsupported Windows credential path root") + if tail.startswith("\\") or unc: + base = (drive or os.path.splitdrive(base)[0]) + "\\" + base = _strip_windows_verbatim(os.path.realpath(base, strict=True)) + names = deque(p for p in tail.split("\\") if p) + for name in names: + if name in (".", ".."): + continue + stem = name.split(".", 1)[0].upper() + if (name.endswith((".", " ")) or ":" in name + or stem in {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"} + or (len(stem) == 4 and stem[:3] in {"COM", "LPT"} + and stem[3] in "123456789¹²³")): + raise ValueError("Unsupported Windows credential path component") + else: + if value.startswith("/"): + base = "/" + names = deque(p for p in value.split("/") if p) + return base, names + + if target.startswith("~") and target != "~" and not target.startswith(("~/", "~\\") if windows else ("~/",)): + raise ValueError("Named-user home expansion is unsupported for credential locks") + target = os.path.expanduser(target) + if target == "~" or target.startswith("~/") or (windows and target.startswith("~\\")): + raise ValueError("No home directory for credential lock path") + cwd = os.path.realpath(os.getcwd(), strict=True) + resolved, pending = parts(target, _strip_windows_verbatim(cwd) if windows else cwd) + links = 0 + while pending: + name = pending.popleft() + if name == ".": + continue + if name == "..": + resolved = os.path.dirname(resolved) + continue + candidate = os.path.join(resolved, name) + try: + info = os.lstat(candidate) + except FileNotFoundError: + # Windows compares with filesystem upcase tables, not Unicode + # lowercase (e.g. sigma/final sigma). With no on-disk spelling + # to canonicalize, refuse non-ASCII rather than risk underlocking. + if windows and not name.isascii(): + raise ValueError("Missing non-ASCII Windows credential path component is unsupported") from None + resolved = candidate + continue + if stat.S_ISLNK(info.st_mode) or (windows and getattr(info, "st_reparse_tag", 0) == 0xA0000003): + links += 1 + if links > 40: + raise OSError(errno.ELOOP, "Too many credential path symlinks", target) + resolved, linked = parts(os.readlink(candidate), resolved) + linked.extend(pending) + pending = linked + else: + if pending and not stat.S_ISDIR(info.st_mode): + raise NotADirectoryError(errno.ENOTDIR, "Credential path ancestor is not a directory", candidate) + resolved = _strip_windows_verbatim(os.path.realpath(candidate, strict=True)) if windows else candidate + return resolved + + +def lock_path_for(path: str | os.PathLike[str]) -> Path: + """AUTH-4 lock identity, including missing leaves and dangling symlinks. + + Ordinary POSIX realpath hashes are unchanged. Resolution errors fail + closed; a guessed identity must never allow concurrent credential refresh. + Windows old/new processes must not overlap (see _windows_identity_key). + """ + canonical = _real_path_allow_missing(os.fspath(path)) + if os.name == "nt": + canonical = _windows_identity_key(canonical) + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32] + return _lock_dir() / f"{digest}.lock" + + +# Chosen by which primitive the platform actually has, not by `os.name`: WASI reports +# "posix" and ships no fcntl, so a name test picks an implementation that cannot import. +try: + import fcntl +except ImportError: # pragma: no cover - Windows, and POSIX-ish builds without fcntl + fcntl = None + +try: + import msvcrt +except ImportError: # pragma: no cover - every non-Windows platform + msvcrt = None + + +if fcntl is not None: + + def _try_lock(fd: int) -> bool: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except OSError as exc: + if exc.errno in (errno.EACCES, errno.EAGAIN): + return False + raise NotConfiguredError("The filesystem could not acquire a credential lock; use a local locking-capable filesystem or an explicit credential") from exc + + def _unlock(fd: int) -> None: + fcntl.flock(fd, fcntl.LOCK_UN) + +elif msvcrt is not None: # pragma: no cover - exercised only on Windows + + def _try_lock(fd: int) -> bool: + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + return True + except OSError as exc: + if exc.errno in (errno.EACCES, errno.EAGAIN, errno.EDEADLK): + return False + raise NotConfiguredError("The filesystem could not acquire a credential lock; use a local locking-capable filesystem or an explicit credential") from exc + + def _unlock(fd: int) -> None: + try: + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + except OSError: + pass + +else: # pragma: no cover - platforms with neither primitive, such as WASI + + # NotConfiguredError, as in lm15-ts (`stores.ts`): a fact about this host's setup, + # not about a provider (CapabilityError) and not a timeout (LockTimeoutError). + def _try_lock(fd: int) -> bool: + raise NotConfiguredError( + "This platform provides no advisory file locking (neither fcntl nor msvcrt), " + "so lm15 cannot serialize credential refreshes against other processes. " + "Reading credentials still works; refreshing them from here does not.", + credential_hint="Pass an explicit credential instead of a stored login", + ) + + def _unlock(fd: int) -> None: + return None + + +@contextmanager +def hold_file_lock( + path: str | os.PathLike[str], + *, + timeout_s: float = _DEFAULT_LOCK_TIMEOUT_S, +) -> Iterator[None]: + """Hold the exclusive advisory lock for ``path``. + + Blocks up to ``timeout_s`` (polling, so it works on POSIX and Windows), + then raises :class:`CredentialLockTimeout`. Not re-entrant: a process + that already holds the lock must not re-enter; internal callers use the + ``*_unlocked`` write variants for that reason. + """ + lock_file = lock_path_for(path) + lock_file.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(lock_file, os.O_RDWR | os.O_CREAT, 0o600) + try: + deadline = time.monotonic() + timeout_s + while not _try_lock(fd): + if time.monotonic() >= deadline: + raise CredentialLockTimeout( + f"Could not lock credential file {path} within {timeout_s:.0f}s " + f"(lock file: {lock_file}). Another process may be refreshing " + "the same credential; retry after it finishes or stop the holder. " + "Never delete a lock file while processes may be using it.", + path=str(path), + lock_path=str(lock_file), + ) + time.sleep(_LOCK_POLL_INTERVAL_S) + try: + yield + finally: + _unlock(fd) + finally: + os.close(fd) + + +def write_private_json_atomic(path: Path, data: dict[str, Any]) -> None: + """Atomically replace ``path`` with ``data`` as private (0600) JSON. + + Durability order: write temp (created 0600 by ``mkstemp``) → flush → + fsync(temp) → ``os.replace`` → fsync(parent dir, POSIX). A reader + observes either the complete old file or the complete new file, never a + partial write. + """ + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(json.dumps(data, indent=2) + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_name, path) + except BaseException: + try: + os.unlink(temp_name) + except OSError: + pass + raise + try: + os.chmod(path, 0o600) + except OSError: # pragma: no cover - permission oddities are best-effort + pass + if os.name == "posix": + try: + dir_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + except OSError: # pragma: no cover - fsync of dir is best-effort + pass diff --git a/_version.py b/_version.py new file mode 100644 index 0000000000..d958a62d90 --- /dev/null +++ b/_version.py @@ -0,0 +1,3 @@ +"""The version of this source package, also used when building distributions.""" + +__version__ = "1.0.0rc3" diff --git a/access.py b/access.py new file mode 100644 index 0000000000..7539efbefd --- /dev/null +++ b/access.py @@ -0,0 +1,788 @@ +""" +lm15.access — how a dialect adapter reaches a backend, as a value. + +An lm15 adapter is three composed things: + +- the **dialect**: the class that speaks a wire format (Anthropic Messages, + OpenAI Responses, OpenAI Chat Completions, Gemini); +- for the chat dialect, a **compat** value describing a server's quirks + (``OpenAIChatCompat``); +- an **access policy**: this module. Which credential, which headers, + which endpoint surfaces, which backend variant, which login hint. + +Before 2026-09-02 the third thing was a subclass: ``ClaudeCodeLM`` extended +``AnthropicLM``, ``OpenAICodexLM`` extended ``OpenAILM``, ``XaiLM`` extended +``OpenAIChatLM``, each overriding headers, payload defaults, error hints, +and endpoint blocks. Go and Rust have no inheritance, so every port would +have invented its own shape for the same facts. An ``AccessPolicy`` is a +frozen value; a port copies the table below as data and consults it at the +same named points the reference does (spec/auth.md AUTH-9). + +What stays behaviour, deliberately: + +- ``backend`` names a variant the dialect adapter switches on at a small, + documented set of points (the ChatGPT Codex backend answers a different + error envelope, a different models endpoint, and is streaming-first). + That is a ``match`` in every language, keyed on data. +- Loading a stored subscription credential is per-language code, keyed by + ``provider`` (``_CREDENTIAL_LOADERS``); the policy says *that* a stored + credential is used, the loader says *how*. + +``ProviderManifest`` is the same class under its earlier name: an adapter's +manifest is its access policy. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Callable + +from .auth import ( + CLAUDE_CODE_LOGIN_HINT, + OPENAI_CODEX_LOGIN_HINT, + XAI_LOGIN_HINT, + extract_chatgpt_account_id, + get_claude_code_access_token, + get_codex_cli_access_token, + get_xai_access_token, + usable_xai_credential, +) +from .compat import ANTHROPIC_PRESET_BASE_URLS, OPENAI_CHAT_PRESET_BASE_URLS, OPENAI_RESPONSES_PRESET_BASE_URLS +from .errors import NotConfiguredError +from .credentials import AwsCredentials, BearerToken, CredentialLike, CredentialValue, coerce_credential +from .features import ( # noqa: F401 — re-exported + AccessPolicy, + AuthHeader, + AuthScheme, + CredentialPolicy, + EndpointSupport, + HostSetting, + HostSpec, +) + +# What ``api_key=`` accepts everywhere: a string, an AUTH-2 credential value, +# or a zero-arg provider returning either. ``Credential`` is the historical +# name for the same thing. +Credential = CredentialLike + +# ─── The table ─────────────────────────────────────────────────────── + +ANTHROPIC_API = AccessPolicy( + provider="anthropic", + supports=EndpointSupport(complete=True, stream=True, files=True, batches=True, models=True), + auth_modes=("x-api-key",), + env_keys=("ANTHROPIC_API_KEY",), + auth_scheme=("x-api-key",), +) + +# TypeSafe System One (Jev): one judgment call per request, no stream, a +# models list (changes/2026-09-17-judgments.md D1/D10/D11; receipts +# 2026-09-17). Bearer key from the console (console.typesafe.ai/keys). +TYPESAFE_API = AccessPolicy( + provider="typesafe", + supports=EndpointSupport(complete=True, stream=False, models=True), + auth_modes=("bearer",), + env_keys=("TYPESAFE_API_KEY",), + auth_scheme=("bearer",), +) + +DEFAULT_CLAUDE_CODE_VERSION = "2.1.170" +DEFAULT_CLAUDE_CODE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude." + +# models=True: the Anthropic /v1/models endpoint answers to the OAuth +# headers (validated live 2026-08-31, HTTP 200). Files and batch are +# API-key surfaces the subscription token does not carry. +CLAUDE_CODE = AccessPolicy( + provider="claude-code", + supports=EndpointSupport(complete=True, stream=True, models=True), + credential_policy="oauth", + auth_modes=("claude-code-oauth", "bearer-oauth"), + auth_scheme=("bearer",), + headers=( + ("anthropic-dangerous-direct-browser-access", "true"), + ("anthropic-beta", "claude-code-20250219,oauth-2025-04-20"), + ("x-app", "cli"), + ("user-agent", f"claude-cli/{DEFAULT_CLAUDE_CODE_VERSION}"), + ), + login_hint=CLAUDE_CODE_LOGIN_HINT, + backend="claude-code", + system_prefix=DEFAULT_CLAUDE_CODE_SYSTEM_PROMPT, +) + +OPENAI_API = AccessPolicy( + provider="openai", + supports=EndpointSupport( + complete=True, stream=True, live=True, files=True, batches=True, + images=True, speech=True, video=True, responses_api=True, models=True, + ), + auth_modes=("bearer",), + env_keys=("OPENAI_API_KEY",), + enterprise_variants=("azure-openai",), +) + +DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" +DEFAULT_CODEX_ORIGINATOR = "lm15" +DEFAULT_CODEX_INSTRUCTIONS = "You are a helpful assistant." +# The backend's /models endpoint requires a client_version query parameter +# (a Codex CLI release); any recent release is accepted. +DEFAULT_CODEX_CLIENT_VERSION = "0.147.0" + +OPENAI_CODEX = AccessPolicy( + provider="openai-codex", + supports=EndpointSupport(complete=True, stream=True, models=True), + credential_policy="oauth", + auth_modes=("chatgpt-oauth", "bearer-oauth"), + headers=( + ("OpenAI-Beta", "responses=experimental"), + ("originator", DEFAULT_CODEX_ORIGINATOR), + ), + login_hint=OPENAI_CODEX_LOGIN_HINT, + backend="chatgpt-codex", + backend_options={"client_version": DEFAULT_CODEX_CLIENT_VERSION}, + system_prefix=DEFAULT_CODEX_INSTRUCTIONS, + base_url=DEFAULT_CODEX_BASE_URL, +) + +OPENAI_CHAT_API = AccessPolicy( + provider="openai-chat", + supports=EndpointSupport(complete=True, stream=True, models=True), + auth_modes=("bearer",), + env_keys=("OPENAI_API_KEY",), +) + +DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1" + +XAI = AccessPolicy( + provider="xai", + supports=EndpointSupport(complete=True, stream=True, models=True, images=True, video=True), + credential_policy="oauth-unless-explicit", + auth_modes=("bearer", "xai-oauth"), + env_keys=("XAI_API_KEY",), + login_hint=XAI_LOGIN_HINT, + base_url=DEFAULT_XAI_BASE_URL, +) + +GEMINI_API = AccessPolicy( + provider="gemini", + supports=EndpointSupport( + complete=True, stream=True, live=True, files=True, batches=True, + images=True, speech=True, video=True, models=True, caches=True, + ), + auth_modes=("query-api-key", "x-goog-api-key"), + env_keys=("GEMINI_API_KEY", "GOOGLE_API_KEY"), + auth_scheme=("x-api-key",), # the dialect renders it as x-goog-api-key +) + +# ─── Responses servers reached through OpenAILM ──────────────────────── + +# Meta Model API (dev.meta.ai, scraped 2026-09-03 — lm15-contract/scrapes/ +# meta/pages). One bearer key (`LLM||`, authentication.md) +# opens three chat wires and the account surfaces. `meta` names the +# Responses wire — Meta's recommended default and the only one that carries +# reasoning across turns (protocols.md) — plus the OpenAI-shaped Files +# (files--upload.md: purpose user_data|batch), Images (images--create.md, +# images--edit.md; model muse-image-1.0) and Models (models--list.md) +# endpoints on the same root. No batch endpoint, no speech synthesis, no +# video generation, no explicit cache resource, and the realtime WebSocket +# is transcription-only (speech-to-text is a surface lm15 does not have). +# +# Env key: META_API_KEY only. Meta's own docs name the variable +# MODEL_API_KEY (authentication.md); lm15 does not read it: a vendor-less +# name set for some other tool would be picked up and sent to Meta without +# the user asking for it. The registry note names the documented variable +# so a quickstart user knows what to rename. +META_ENV_KEYS = ("META_API_KEY",) + +META = AccessPolicy( + provider="meta", + supports=EndpointSupport(complete=True, stream=True, files=True, images=True, responses_api=True, models=True), + auth_modes=("bearer",), + env_keys=META_ENV_KEYS, + base_url=OPENAI_RESPONSES_PRESET_BASE_URLS["meta"], +) + +# ─── Chat Completions servers reached through OpenAIChatLM ─────────── +# +# These policies are bound onto the chat dialect by the provider registry +# (lm15.registry): the class is OpenAIChatLM, the compat preset of the +# same name names the server's quirks, and this value names the credential +# and the surfaces. base_url points at the compat table so each URL has +# one copy. + +GROQ = AccessPolicy( + provider="groq", + supports=EndpointSupport(complete=True, stream=True, models=True), + auth_modes=("bearer",), + env_keys=("GROQ_API_KEY",), + base_url=OPENAI_CHAT_PRESET_BASE_URLS["groq"], +) + +OPENROUTER = AccessPolicy( + provider="openrouter", + supports=EndpointSupport(complete=True, stream=True, models=True), + auth_modes=("bearer",), + env_keys=("OPENROUTER_API_KEY",), + base_url=OPENAI_CHAT_PRESET_BASE_URLS["openrouter"], +) + +# DeepSeek (api-docs.deepseek.com, scraped 2026-09-03): bearer key from +# platform.deepseek.com, prepaid balance (a drained balance is HTTP 402, +# never a surprise bill). The same key also opens an Anthropic-format +# endpoint (/anthropic) and a Responses-format endpoint; `deepseek` names +# the Chat Completions wire only — a provider string never guesses a +# protocol. +DEEPSEEK = AccessPolicy( + provider="deepseek", + supports=EndpointSupport(complete=True, stream=True, models=True), + auth_modes=("bearer",), + env_keys=("DEEPSEEK_API_KEY",), + base_url=OPENAI_CHAT_PRESET_BASE_URLS["deepseek"], +) + +# Z.AI (docs.z.ai, scraped 2026-09-03): bearer key from z.ai/manage-apikey, +# general endpoint only. The GLM Coding Plan is a subscription with its own +# endpoint and its own terms (docs.z.ai/devpack); lm15 does not name it. +ZAI = AccessPolicy( + provider="zai", + supports=EndpointSupport(complete=True, stream=True, models=True), + auth_modes=("bearer",), + env_keys=("ZAI_API_KEY",), + base_url=OPENAI_CHAT_PRESET_BASE_URLS["zai"], +) + +# Moonshot AI / Kimi API Platform (platform.kimi.ai, scraped 2026-09-03): +# bearer key from platform.kimi.ai/console/api-keys, prepaid balance. +# `moonshotai` names the Chat Completions wire (api--overview.md: the +# documented primary; every model, every guide). The same key also opens a +# Responses wire (kimi-k3 only, `tool_choice: auto` only) and an Anthropic +# Messages wire at /anthropic; neither is registered yet — a provider +# string never guesses a protocol. +# +# Env keys, in order: MOONSHOTAI_API_KEY (lm15's `_API_KEY` +# convention, the name the maintainer set) then MOONSHOT_API_KEY (the name +# Moonshot's own docs and SDK examples use, api--overview.md). Both are +# vendor-named, so reading both cannot pick up another tool's secret (the +# reason Meta's vendor-less MODEL_API_KEY is not read). Stated trade-off: +# with both set to different keys, the lm15-named one wins silently; the +# doctor (AUTH-7) shows the shadowed rung. +MOONSHOTAI_ENV_KEYS = ("MOONSHOTAI_API_KEY", "MOONSHOT_API_KEY") + +MOONSHOTAI = AccessPolicy( + provider="moonshotai", + supports=EndpointSupport(complete=True, stream=True, models=True), + auth_modes=("bearer",), + env_keys=MOONSHOTAI_ENV_KEYS, + base_url=OPENAI_CHAT_PRESET_BASE_URLS["moonshotai"], +) + +# Moonshot's Responses wire (responses--create.md): the same key and root, +# kimi-k3 only, stateless (no store, no previous_response_id); reasoning +# returns as summary text and is replayed as-is. `models` is the same +# GET /models as the chat wire. +MOONSHOTAI_RESPONSES = AccessPolicy( + provider="moonshotai-responses", + supports=EndpointSupport(complete=True, stream=True, responses_api=True, models=True), + auth_modes=("bearer",), + env_keys=MOONSHOTAI_ENV_KEYS, + base_url=OPENAI_RESPONSES_PRESET_BASE_URLS["moonshotai"], +) + +# Meta's Chat Completions wire (protocols--chat-completions.md): the same +# key, the drop-in for existing messages-array code. Reasoning is not +# carried across turns here and search grounding is Responses-only; the +# account surfaces (files, images) are reached through `meta`. +META_CHAT = AccessPolicy( + provider="meta-chat", + supports=EndpointSupport(complete=True, stream=True, models=True), + auth_modes=("bearer",), + env_keys=META_ENV_KEYS, + base_url=OPENAI_CHAT_PRESET_BASE_URLS["meta"], +) + +# ─── Anthropic Messages servers reached through AnthropicLM ───────────── + +# DeepSeek's Anthropic-format endpoint (guide--anthropic-api.md; live +# 2026-09-03): the same DEEPSEEK_API_KEY, sent as x-api-key. No /models on +# this root (404) — list models through `deepseek`. A separate provider +# string because a provider name describes wire behavior: `deepseek` is the +# Chat Completions wire, `deepseek-anthropic` the Messages wire. +DEEPSEEK_ANTHROPIC = AccessPolicy( + provider="deepseek-anthropic", + supports=EndpointSupport(complete=True, stream=True), + auth_modes=("x-api-key",), + env_keys=("DEEPSEEK_API_KEY",), + auth_scheme=("x-api-key",), + base_url=ANTHROPIC_PRESET_BASE_URLS["deepseek"], +) + +# Meta's Anthropic-format wire (protocols--messages.md): the same key sent +# as a bearer token (the docs' SDK examples pass it as auth_token, not +# x-api-key). Stateless; thinking replays through redacted_thinking. The +# /models root answers on the same base URL with the OpenAI list shape, +# which the dialect's parser reads (live 2026-09-03, 7 entries). +META_ANTHROPIC = AccessPolicy( + provider="meta-anthropic", + supports=EndpointSupport(complete=True, stream=True, models=True), + auth_modes=("bearer",), + env_keys=META_ENV_KEYS, + auth_scheme=("bearer",), + base_url=ANTHROPIC_PRESET_BASE_URLS["meta"], +) + +# Moonshot's Anthropic-format wire (messages--create.md, securitySchemes +# bearerAuth; the Claude Code guide sets ANTHROPIC_AUTH_TOKEN): the same key +# as a bearer token, kimi-k3 only. Whether /models answers on the +# /anthropic root is a live finding recorded in the dossier. +MOONSHOTAI_ANTHROPIC = AccessPolicy( + provider="moonshotai-anthropic", + supports=EndpointSupport(complete=True, stream=True), + auth_modes=("bearer",), + env_keys=MOONSHOTAI_ENV_KEYS, + auth_scheme=("bearer",), + base_url=ANTHROPIC_PRESET_BASE_URLS["moonshotai"], +) + +# ─── Cloud hosts (spec/auth.md AUTH-10 host policies; changes/2026-09-03-cloud-hosts.md) ─── +# +# Every row below is declared from provider documentation and the cloud +# SDKs' resolver sources (lm15-contract/research/cloud-hosts/10-facts-*.md, +# file:line cited there) and awaits its live receipt. A host is the third +# use of the ``backend`` seam: the dialect is unchanged; the door changes +# the URL, the signing, a closed set of rewrites, and the stream framing. + +# Endpoint overrides (AUTH-10, amended 2026-09-19): the vendor's own +# variables naming a full URL root for a door, consulted by the router +# after an explicit ``base_urls`` entry and before the ``{resource}`` / +# ``{region}`` template. AWS: ``AWS_ENDPOINT_URL_`` then the +# generic ``AWS_ENDPOINT_URL`` (aws-sdkref-endpoints.md; the service id is +# the SigV4 service name upper-cased with ``-`` → ``_``, the SDK's rule — +# ``BEDROCK_MANTLE`` and ``AWS_EXTERNAL_ANTHROPIC`` follow the rule, not a +# cited page). Azure OpenAI: ``AZURE_OPENAI_ENDPOINT`` (the OpenAI SDK's +# ``AzureOpenAI`` reads it). Foundry Claude: ``ANTHROPIC_FOUNDRY_BASE_URL`` +# (anthropic-on-foundry.md:180-182). Vertex: no vendor variable is cited; +# ``base_urls`` only. +_AWS_ENDPOINT = ("AWS_ENDPOINT_URL_BEDROCK_RUNTIME", "AWS_ENDPOINT_URL") +_AWS_MANTLE_ENDPOINT = ("AWS_ENDPOINT_URL_BEDROCK_MANTLE", "AWS_ENDPOINT_URL") +_AWS_ANTHROPIC_ENDPOINT = ("AWS_ENDPOINT_URL_AWS_EXTERNAL_ANTHROPIC", "AWS_ENDPOINT_URL") +_AZURE_OPENAI_ENDPOINT = ("AZURE_OPENAI_ENDPOINT",) +_AZURE_FOUNDRY_ENDPOINT = ("ANTHROPIC_FOUNDRY_BASE_URL",) + +_AWS_REGION = HostSetting("region", env=("AWS_REGION", "AWS_DEFAULT_REGION")) # no default: a wrong region is a residency bug +_AWS_WORKSPACE = HostSetting("workspace", env=("ANTHROPIC_AWS_WORKSPACE_ID",)) +_GCP_PROJECT = HostSetting("project", env=("GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT")) +_GCP_LOCATION = HostSetting("location", env=("GOOGLE_CLOUD_LOCATION",), default="global") # stated trade-off: availability first; the doctor prints it +_AZURE_OPENAI_RESOURCE = HostSetting("resource", env=("AZURE_OPENAI_RESOURCE",)) +_AZURE_FOUNDRY_RESOURCE = HostSetting("resource", env=("ANTHROPIC_FOUNDRY_RESOURCE",)) +_AZURE_AUTHORITY = HostSetting("authority_host", env=("AZURE_AUTHORITY_HOST",), default="https://login.microsoftonline.com") +_AZURE_SCOPE = HostSetting("scope", env=(), default="https://ai.azure.com/.default") + +# Claude Platform on AWS: Anthropic-operated, the Claude API verbatim, +# SigV4 service aws-external-anthropic or an AWS-console API key as +# x-api-key; anthropic-workspace-id on every request; betas pass +# (anthropic-platform-on-aws.md:19-31, :216-218, :244-262, :266-271). +AWS_ANTHROPIC = AccessPolicy( + provider="aws-anthropic", + supports=EndpointSupport(complete=True, stream=True), # batches/models: live cells (the platform says "full Claude API") + credential_policy="aws-chain", + auth_modes=("sigv4", "x-api-key"), + env_keys=("ANTHROPIC_AWS_API_KEY",), + auth_scheme=("sigv4", "x-api-key"), + backend="aws-external-anthropic", + host=HostSpec( + base_url="https://aws-external-anthropic.{region}.api.aws/v1", + settings=(_AWS_REGION, _AWS_WORKSPACE), + required_headers=(("anthropic-workspace-id", "workspace"),), + sigv4_service="aws-external-anthropic", + endpoint_env=_AWS_ANTHROPIC_ENDPOINT, + ), +) + +# Claude in Amazon Bedrock ("mantle", Opus 4.7 and later): the Messages API +# at /anthropic/v1/messages on AWS-operated infrastructure, plain SSE, SigV4 +# service bedrock-mantle or a short-term bearer key as x-api-key; no +# structured outputs, URL/Files sources, server tools, batches, models, or +# anthropic-beta (anthropic-on-bedrock.md:58-70, :138-160, :322, :358-367). +BEDROCK_ANTHROPIC = AccessPolicy( + provider="bedrock-anthropic", + supports=EndpointSupport(complete=True, stream=True), + credential_policy="aws-chain", + auth_modes=("sigv4", "x-api-key"), + env_keys=("AWS_BEARER_TOKEN_BEDROCK",), + auth_scheme=("sigv4", "x-api-key"), + backend="bedrock-mantle", + host=HostSpec( + base_url="https://bedrock-mantle.{region}.api.aws/anthropic/v1", + settings=(_AWS_REGION,), + sigv4_service="bedrock-mantle", + endpoint_env=_AWS_MANTLE_ENDPOINT, + ), +) + +# Bedrock's OpenAI Chat Completions door on bedrock-runtime: SigV4 service +# bedrock or AWS_BEARER_TOKEN_BEDROCK (bedrock-openai-chat-completions.md:8, +# :151, :178-228; bedrock-api-keys.md:176-185). models=False: GET +# /openai/v1/models answers 404 UnknownOperationException under SigV4 +# (live 2026-09-03, receipts/2026-09-03-bedrock-chat/probe-models-list.json) +# and the same 404 under a short-term key (live 2026-09-04, +# probe-bearer-models.json), so the docs' listing claim does not hold. +# Live-verified 2026-09-03 (SigV4) and 2026-09-04 (bearer) with +# openai.gpt-oss-20b-1:0. +BEDROCK_CHAT = AccessPolicy( + provider="bedrock-chat", + supports=EndpointSupport(complete=True, stream=True), + credential_policy="aws-chain", + auth_modes=("sigv4", "bearer"), + env_keys=("AWS_BEARER_TOKEN_BEDROCK",), + auth_scheme=("sigv4", "bearer"), + backend="bedrock-runtime", + host=HostSpec( + base_url="https://bedrock-runtime.{region}.amazonaws.com/openai/v1", + settings=(_AWS_REGION,), + sigv4_service="bedrock", + endpoint_env=_AWS_ENDPOINT, + ), +) + +# Bedrock-mantle Chat Completions: a different host from BEDROCK_CHAT, not a +# setting. Live 2026-09-04 (receipts/2026-09-04-bedrock-chat/probe-mantle-chat-*): +# SigV4 service bedrock-mantle; un-versioned model ids (openai.gpt-oss-20b, +# not openai.gpt-oss-20b-1:0); GET /v1/models answers 200 with 55 entries +# under SigV4 and under a short-term key; gpt-oss returns message.reasoning +# (the runtime door inlines in content). Claude is listed but +# 400 "does not support /v1/chat/completions"; Nova is 404. AWS's Chat +# Completions page documents listing on this host, not on bedrock-runtime. +BEDROCK_MANTLE_CHAT = AccessPolicy( + provider="bedrock-mantle-chat", + supports=EndpointSupport(complete=True, stream=True, models=True), + credential_policy="aws-chain", + auth_modes=("sigv4", "bearer"), + env_keys=("AWS_BEARER_TOKEN_BEDROCK",), + auth_scheme=("sigv4", "bearer"), + backend="bedrock-mantle", + host=HostSpec( + base_url="https://bedrock-mantle.{region}.api.aws/v1", + settings=(_AWS_REGION,), + sigv4_service="bedrock-mantle", + endpoint_env=_AWS_MANTLE_ENDPOINT, + ), +) + +# Azure OpenAI v1: {resource}.openai.azure.com/openai/v1, no api-version, +# api-key header or an Entra bearer token; the model is the DEPLOYMENT name +# (azure-openai-api-lifecycle.md:59-106, :235-250; +# azure-openai-managed-identity.md:213-246). models=True: the docs call +# listing control-plane only, but GET /openai/v1/models answers 200 with the +# api-key — the resource's whole catalog (dall-e, whisper, gpt-*), not just its +# deployments (receipts/2026-09-04-azure/models.json; cases/azure/models.json). +# +# Host, decided 2026-09-19 (changes/2026-09-19-cloud-identity-and-endpoints.md +# D5): the template stays `{resource}.openai.azure.com` because it is the +# one host every resource kind answers on — a classic `OpenAI`-kind +# resource has no `services.ai.azure.com` name at all (DNS: NXDOMAIN for +# the lab's lm15-oai-* resource, 2026-09-19), while a Foundry +# (`AIServices`) resource answers on both. The Foundry console shows +# `https://{account}.services.ai.azure.com`: paste it as +# AZURE_OPENAI_ENDPOINT (or base_urls={"azure": ...}) and the door appends +# /openai/v1. That endpoint serves OpenAI and non-OpenAI deployments +# alike; the alias saves one internal hop for OpenAI models today +# (issue #10, Pamela Fox, 2026-09-18). +AZURE = AccessPolicy( + provider="azure", + # Live 2026-09-04 on an OpenAI-kind Azure resource: Files and Batches + # answer on /openai/v1; speech returns raw audio from /audio/speech. + # Images wait on deployment quota; video is deliberately out of scope. + supports=EndpointSupport(complete=True, stream=True, live=True, files=True, batches=True, speech=True, + responses_api=True, models=True), + credential_policy="azure-chain", + auth_modes=("api-key", "entra-oauth"), + env_keys=("AZURE_OPENAI_API_KEY",), + auth_scheme=("api-key", "bearer"), + backend="azure-openai", + host=HostSpec( + base_url="https://{resource}.openai.azure.com/openai/v1", + settings=(_AZURE_OPENAI_RESOURCE, _AZURE_AUTHORITY, _AZURE_SCOPE), + endpoint_env=_AZURE_OPENAI_ENDPOINT, + ), +) + +AZURE_CHAT = AccessPolicy( + provider="azure-chat", + supports=EndpointSupport(complete=True, stream=True, models=True), # same /models as `azure` + credential_policy="azure-chain", + auth_modes=("api-key", "entra-oauth"), + env_keys=("AZURE_OPENAI_API_KEY",), + auth_scheme=("api-key", "bearer"), + backend="azure-openai", + host=HostSpec( + base_url="https://{resource}.openai.azure.com/openai/v1", + settings=(_AZURE_OPENAI_RESOURCE, _AZURE_AUTHORITY, _AZURE_SCOPE), + endpoint_env=_AZURE_OPENAI_ENDPOINT, + ), +) + +# Claude in Microsoft Foundry: {resource}.services.ai.azure.com/anthropic/v1, +# x-api-key or an Entra bearer token; no batches, models, fallbacks. The docs +# also say `api-key`, but live on an AIServices resource it is 401 while the +# same key under `x-api-key` reaches deployment lookup (404 DeploymentNotFound; +# 2026-09-04, receipts/2026-09-04-azure-anthropic/). +# Sources: anthropic-on-foundry.md:106, :124-143, :176, :305-318, :637-658. +AZURE_ANTHROPIC = AccessPolicy( + provider="azure-anthropic", + supports=EndpointSupport(complete=True, stream=True), + credential_policy="azure-chain", + auth_modes=("x-api-key", "entra-oauth"), + env_keys=("ANTHROPIC_FOUNDRY_API_KEY",), + auth_scheme=("x-api-key", "bearer"), + backend="azure-foundry", + host=HostSpec( + base_url="https://{resource}.services.ai.azure.com/anthropic/v1", + settings=(_AZURE_FOUNDRY_RESOURCE, _AZURE_AUTHORITY, _AZURE_SCOPE), + endpoint_env=_AZURE_FOUNDRY_ENDPOINT, + ), +) + +# Gemini on Vertex ("Agent Platform"): project + location in the path, +# OAuth bearer with the cloud-platform scope; {location_host} is derived: +# global → aiplatform.googleapis.com, us|eu → aiplatform.{loc}.rep.googleapis.com, +# else {loc}-aiplatform.googleapis.com (vertex-locations.md:40-63, :91). +_VERTEX_BASE = "https://{location_host}/v1/projects/{project}/locations/{location}" + +VERTEX = AccessPolicy( + provider="vertex", + supports=EndpointSupport(complete=True, stream=True), # caches/batches/models: live cells + credential_policy="gcp-chain", + auth_modes=("google-oauth",), + env_keys=(), + auth_scheme=("bearer",), + backend="vertex", + host=HostSpec(base_url=_VERTEX_BASE + "/publishers/google", settings=(_GCP_PROJECT, _GCP_LOCATION)), +) + +# Express mode: an API key, no project or location (vertex-express-mode.md:84, :181). +VERTEX_EXPRESS = AccessPolicy( + provider="vertex-express", + supports=EndpointSupport(complete=True, stream=True), + credential_policy="key", + auth_modes=("query-api-key",), + env_keys=("GOOGLE_API_KEY",), + auth_scheme=("query-key",), + backend="vertex-express", + host=HostSpec(base_url="https://aiplatform.googleapis.com/v1/publishers/google"), +) + +# Claude on Vertex: model in the path (:rawPredict / :streamRawPredict), +# anthropic_version in the body as vertex-2023-10-16; no batches, models, +# Files sources (anthropic-on-vertex.md:9-10, :122-126, :358-365). +VERTEX_ANTHROPIC = AccessPolicy( + provider="vertex-anthropic", + supports=EndpointSupport(complete=True, stream=True), + credential_policy="gcp-chain", + auth_modes=("google-oauth",), + env_keys=(), + auth_scheme=("bearer",), + backend="vertex", + host=HostSpec( + base_url=_VERTEX_BASE, + settings=(_GCP_PROJECT, _GCP_LOCATION), + paths={ + "messages": "/publishers/anthropic/models/{model}:rawPredict", + "messages/stream": "/publishers/anthropic/models/{model}:streamRawPredict", + }, + model_in="path", + anthropic_version_in="body:vertex-2023-10-16", + ), +) + +CLOUD_HOST_POLICIES: tuple[AccessPolicy, ...] = ( + AZURE, AZURE_CHAT, AZURE_ANTHROPIC, + AWS_ANTHROPIC, BEDROCK_ANTHROPIC, BEDROCK_CHAT, BEDROCK_MANTLE_CHAT, + VERTEX, VERTEX_EXPRESS, VERTEX_ANTHROPIC, +) + +# Keyless local servers: no env key; the registry supplies the placeholder +# the server accepts when nothing is configured. +OLLAMA = AccessPolicy( + provider="ollama", + supports=EndpointSupport(complete=True, stream=True, models=True), + auth_modes=("bearer",), + base_url=OPENAI_CHAT_PRESET_BASE_URLS["ollama"], +) + +VLLM = AccessPolicy( + provider="vllm", + supports=EndpointSupport(complete=True, stream=True, models=True), + auth_modes=("bearer",), + base_url=OPENAI_CHAT_PRESET_BASE_URLS["vllm"], +) + +SGLANG = AccessPolicy( + provider="sglang", + supports=EndpointSupport(complete=True, stream=True, models=True), + auth_modes=("bearer",), + base_url=OPENAI_CHAT_PRESET_BASE_URLS["sglang"], +) + + +# ─── Credential loading, keyed by provider ─────────────────────────── + + +@dataclass(frozen=True, slots=True) +class LoadedCredential: + credential: Credential = field(repr=False) + account_id: str | None = None + source: str = "explicit" # "explicit" (caller-supplied) or "stored" (local login) + + +def _load_claude_code(path: str | os.PathLike[str] | None) -> LoadedCredential: + # Validate now (typed, re-login-guided errors), then re-resolve per + # request so a long-lived client always sends a fresh token. + get_claude_code_access_token(path) + return LoadedCredential(lambda: get_claude_code_access_token(path), source="stored") + + +def _load_openai_codex(path: str | os.PathLike[str] | None) -> LoadedCredential: + initial = get_codex_cli_access_token(path) + account_id = initial.account_id or extract_chatgpt_account_id(initial.access_token) + return LoadedCredential(lambda: get_codex_cli_access_token(path).access_token, account_id, source="stored") + + +def _load_xai(path: str | os.PathLike[str] | None) -> LoadedCredential: + get_xai_access_token(path) + return LoadedCredential(lambda: get_xai_access_token(path), source="stored") + + +_CREDENTIAL_LOADERS: dict[str, Callable[[str | os.PathLike[str] | None], LoadedCredential]] = { + "claude-code": _load_claude_code, + "openai-codex": _load_openai_codex, + "xai": _load_xai, +} + +_STORED_PROBES: dict[str, Callable[[], bool]] = { + "xai": usable_xai_credential, +} + + +def load_credential( + policy: AccessPolicy, + api_key: Credential | None, + *, + credentials_path: str | os.PathLike[str] | None = None, +) -> LoadedCredential: + """Resolve the credential an adapter will send under ``policy``. + + An explicit ``api_key`` always wins (AUTH-1). Otherwise a stored-login + policy loads through its provider's loader; a ``key`` policy with no + key is a typed not-configured error naming the env keys. + """ + if api_key is not None and api_key != "": + return LoadedCredential(api_key) + loader = _CREDENTIAL_LOADERS.get(policy.provider) if policy.credential_policy != "key" else None + if loader is not None: + return loader(credentials_path) + raise NotConfiguredError( + f"{policy.provider}: no credential given" + + (f"; set {' or '.join(policy.env_keys)} or pass api_key=" if policy.env_keys else "; pass api_key="), + provider=policy.provider, + env_keys=policy.env_keys, + credential_hint=policy.login_hint, + ) + + +def has_stored_credential(policy: AccessPolicy) -> bool: + """Offline probe (reads files, never the network) for the router's + ``oauth-unless-explicit`` chain: is a usable login stored locally?""" + probe = _STORED_PROBES.get(policy.provider) + return bool(probe()) if probe is not None else False + + +# AUTH-2 (ratified 2026-09-06, lm15-contract/changes/2026-09-06-decisions.md D1): the +# schemes each credential kind may travel under. An ApiKey takes the +# policy's first header-carrying scheme in POLICY order; a BearerToken takes +# `bearer` if listed, else `x-api-key` if listed — the TOKEN's order, not the +# policy's, so an Entra token on azure-anthropic (x-api-key, bearer) still +# goes as bearer while a Bedrock short-term key on bedrock-anthropic +# (sigv4, x-api-key) goes in the key header; AwsCredentials sign (sigv4) only. +_ACCEPTED_SCHEMES: dict[str, tuple[AuthScheme, ...]] = { + "api_key": ("bearer", "x-api-key", "api-key", "query-key"), + "bearer_token": ("bearer", "x-api-key"), + "aws": ("sigv4",), +} + + +def select_scheme(policy: AccessPolicy, credential: CredentialValue) -> AuthScheme: + """The scheme this credential kind travels under (AUTH-2, AUTH-10). + + ``ApiKey``: the first scheme in the policy's order that carries a key. + ``BearerToken``: ``bearer`` if the policy lists it, else ``x-api-key`` if + the policy lists it. ``AwsCredentials``: ``sigv4`` only. Anything else + raises ``NotConfiguredError`` naming the schemes the kind accepts and the + schemes the door offers. + """ + accepted = _ACCEPTED_SCHEMES[credential.kind] + if isinstance(credential, BearerToken): + for scheme in accepted: + if scheme in policy.auth_scheme: + return scheme + else: + for scheme in policy.auth_scheme: + if scheme in accepted: + return scheme + raise NotConfiguredError( + f"{policy.provider}: a {credential.kind} credential cannot travel under " + f"{'/'.join(policy.auth_scheme)}; it accepts {'/'.join(accepted)}", + provider=policy.provider, + env_keys=policy.env_keys, + ) + + +def _looks_like_jwt(text: str) -> bool: + """Three base64url segments whose first decodes to a JSON object with + ``alg`` — the JWS compact form every Entra/OAuth access token uses.""" + import base64 + import json + + parts = text.split(".") + if len(parts) != 3 or not all(parts): + return False + head = parts[0] + try: + decoded = base64.urlsafe_b64decode(head + "=" * (-len(head) % 4)) + header = json.loads(decoded) + except (ValueError, UnicodeDecodeError): + return False + return isinstance(header, dict) and "alg" in header + + +def auth_header( + policy: AccessPolicy, + credential: str | CredentialValue, + *, + api_key_header: str = "x-api-key", +) -> tuple[str, str] | None: + """The (name, value) header that carries ``credential`` under this + policy, or ``None`` when the scheme is not a header (``sigv4`` signs the + finished request; ``query-key`` is a query parameter) — the dialect's + ``_emit`` handles those.""" + value = coerce_credential(credential) + scheme = select_scheme(policy, value) + if scheme in ("api-key", "x-api-key") and "bearer" in policy.auth_scheme and _looks_like_jwt(value.value): + # A plain string reads as an API key, and on this door the key header + # comes before bearer. A JWT is never an API key on any door lm15 + # has: it is an Entra/OAuth access token that a token-provider + # callable (azure-identity's get_bearer_token_provider returns a + # str) handed over as a string. Sent as a key it is a bare 401 + # ("invalid subscription key", live 2026-09-04). AUTH-2, amended + # 2026-09-19: it travels as a bearer token — the only reading under + # which the request can succeed — and the doctor says so. Before + # that date this was a refusal naming the BearerToken wrap; the + # wrap is still accepted and still the form to use when nothing + # should be read from a token's shape. + scheme = "bearer" + if scheme == "bearer": + return ("Authorization", f"Bearer {value.value}") + if scheme == "x-api-key": + return (api_key_header, value.value) + if scheme == "api-key": + return ("api-key", value.value) + return None diff --git a/adaptation.py b/adaptation.py new file mode 100644 index 0000000000..855cc693b5 --- /dev/null +++ b/adaptation.py @@ -0,0 +1,218 @@ +""" +MAP-13 — adapt freely, never invisibly; refuse only when a guess could hurt. + +lm15 makes two promises, ranked: first, change the model or provider +string and the program keeps working; second, never change what the +caller asked for. The second is kept by VISIBILITY, not refusal. When a +wire cannot take a setting as asked, the adapter does the obvious thing +and records it here; the record rides ``Response.adaptations`` and +``StreamStartEvent.adaptations``, and ``lm.plan(request)`` previews it +with no network. + +Translations — the adapter's ordinary job (``stop`` → ``stop_sequences``, +effort → budget by the MAP-7 table) — are never recorded. A record exists +only where the wire got something other than what was asked. + +The policy (``AdaptationPolicy``) is set on the LM (``adaptations=``) and +on ``RouterConfig``: + +- ``"note"`` (default): adapt and record. +- ``"silent"``: adapt exactly as ``"note"`` does; the response carries no + record. The policy never changes what goes to the wire or what lm15 + does after it. +- ``"refuse"``: every DEVIATION — ``dropped``, ``clamped``, ``substituted``, + ``client_side`` — is an ``UnsupportedFeatureError`` before the wire (the + pre-2026-09-14 behaviour), carrying ``feature`` = the config path so a + policy layer can act without parsing prose. ``satisfied`` and + ``defaulted`` change nothing the caller asked for (the provider already + does it; the wire needed a value the caller left open) and are recorded + under every policy but ``"silent"``. + +Nothing here prints. The record is data on the response; a nagging +warning channel gets ignored, then switched off, and the drop is silent +again. + +How the record reaches the response without threading a parameter +through every builder: ``collecting(policy)`` opens a scope (a context +variable — thread-safe, task-safe, and copied into ``asyncio.to_thread``) +around one ``build_request`` call; ``adapt(...)`` inside a builder appends +to the open scope. ``BaseProviderLM._build`` is the only opener. A +builder called with no scope open (a test, the vet harness's request +direction) adapts under ``"note"`` and the record is simply not kept. +""" +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import Any, Iterator, Literal, get_args + +from .errors import UnsupportedFeatureError + +AdaptationAction = Literal["dropped", "clamped", "substituted", "client_side", "satisfied", "defaulted"] +ADAPTATION_ACTIONS: tuple[str, ...] = get_args(AdaptationAction) + +# The actions that change what the caller asked for; "refuse" refuses these. +DEVIATIONS: frozenset[str] = frozenset({"dropped", "clamped", "substituted", "client_side"}) + +AdaptationPolicy = Literal["note", "silent", "refuse"] +ADAPTATION_POLICIES: tuple[str, ...] = get_args(AdaptationPolicy) + + +@dataclass(frozen=True, slots=True) +class Adaptation: + """One thing the wire got that differs from what was asked. + + - ``field``: the config path (``config.seed``, ``config.temperature``, + ``config.reasoning.summary``, ``tools``). + - ``action``: ``dropped`` (a hint with no home), ``clamped`` (a dial + to its nearest level), ``substituted`` (the closest spelling), + ``client_side`` (lm15 does it after the wire), ``satisfied`` (the + provider's default already is what was asked), ``defaulted`` (the + wire requires a value the caller did not set). + - ``asked`` / ``applied``: what the caller set / what went to the wire. + ``asked`` is absent for ``defaulted``; ``applied`` is absent for + ``dropped`` and ``satisfied``. + - ``reason``: one sentence naming the provider fact. + """ + + field: str + action: AdaptationAction + reason: str + asked: Any = None + applied: Any = None + + def __post_init__(self) -> None: + if not isinstance(self.field, str) or not self.field: + raise ValueError("Adaptation.field must be a non-empty string") + if self.action not in ADAPTATION_ACTIONS: + raise ValueError(f"unsupported adaptation action: {self.action!r}") + if not isinstance(self.reason, str) or not self.reason: + raise ValueError("Adaptation.reason must be a non-empty string") + + +def check_policy(value: object) -> AdaptationPolicy: + if value not in ADAPTATION_POLICIES: + raise ValueError( + f"adaptations must be one of {', '.join(map(repr, ADAPTATION_POLICIES))}, got {value!r}" + ) + return value # type: ignore[return-value] + + +@dataclass(slots=True) +class AdaptationScope: + policy: AdaptationPolicy + provider: str | None + records: list[Adaptation] = field(default_factory=list) + # plan(): the wire request is built and discarded, so no credential is + # invoked and none is needed — like resolve(), plan() is offline. + planning: bool = False + + +_scope: ContextVar[AdaptationScope | None] = ContextVar("lm15_adaptation_scope", default=None) + + +@contextmanager +def collecting(policy: AdaptationPolicy, *, provider: str | None = None, planning: bool = False) -> Iterator[AdaptationScope]: + """Open a scope for one request build. Nested scopes are independent.""" + scope = AdaptationScope(policy=check_policy(policy), provider=provider, planning=planning) + token = _scope.set(scope) + try: + yield scope + finally: + _scope.reset(token) + + +def adapt( + field: str, + action: AdaptationAction, + reason: str, + *, + asked: Any = None, + applied: Any = None, + provider: str | None = None, +) -> None: + """Record one adaptation in the open scope, or raise under ``"refuse"``. + + Builders call this at the point where they would have raised before + MAP-13, then do the adapted thing. The message under ``"refuse"`` is + the same sentence the note carries, so the two policies never say + different things about the same fact. + """ + scope = _scope.get() + policy: AdaptationPolicy = scope.policy if scope is not None else "note" + who = provider or (scope.provider if scope is not None else None) + if policy == "refuse" and action in DEVIATIONS: + head = f"{who}: " if who else "" + raise UnsupportedFeatureError( + f"{head}{field} {_ACTION_VERB[action]}: {reason} (adaptations='refuse')", + provider=who, + feature=field, + ) + if scope is None: + return + # Every policy records into the scope: the adapter's own behaviour + # (a client-side stop, a narrowed tool list) is read from these + # records, so "silent" must not empty them — it hides them on the + # response instead (BaseProviderLM._build). A policy that changed + # behaviour would be a second, invisible setting. + scope.records.append(Adaptation(field=field, action=action, reason=reason, asked=asked, applied=applied)) + + +_ACTION_VERB = { + "dropped": "would be dropped", + "clamped": "would be clamped", + "substituted": "would be substituted", + "client_side": "would be applied client-side", + "satisfied": "is already satisfied here", + "defaulted": "would be defaulted", +} + + +def current_policy() -> AdaptationPolicy: + scope = _scope.get() + return scope.policy if scope is not None else "note" + + +def is_planning() -> bool: + """True inside ``plan()``: the build's bytes are discarded, so signing + and credential providers are skipped.""" + scope = _scope.get() + return scope is not None and scope.planning + + +def adaptation_to_dict(a: Adaptation) -> dict[str, Any]: + out: dict[str, Any] = {"field": a.field, "action": a.action, "reason": a.reason} + if a.asked is not None: + out["asked"] = a.asked + if a.applied is not None: + out["applied"] = a.applied + return out + + +def adaptation_from_dict(d: dict[str, Any]) -> Adaptation: + return Adaptation( + field=d["field"], + action=d["action"], + reason=d["reason"], + asked=d.get("asked"), + applied=d.get("applied"), + ) + + +# ─── Shared clamps ─────────────────────────────────────────────────── + +EFFORT_LADDER: tuple[str, ...] = ("minimal", "low", "medium", "high", "xhigh", "max") + + +def nearest_effort(asked: str, available: "tuple[str, ...] | list[str]") -> str: + """The closest level to ``asked`` among ``available`` on the ordinal + effort ladder. A tie goes to the lower level: the cheaper guess is + the one a caller who set a dial would rather see recorded.""" + levels = [lvl for lvl in available if lvl in EFFORT_LADDER] + if not levels: + raise ValueError(f"no comparable effort levels in {available!r}") + if asked in levels: + return asked + want = EFFORT_LADDER.index(asked) if asked in EFFORT_LADDER else 0 + return min(levels, key=lambda lvl: (abs(EFFORT_LADDER.index(lvl) - want), EFFORT_LADDER.index(lvl))) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000000..fe6565dc0f --- /dev/null +++ b/auth.py @@ -0,0 +1,932 @@ +""" +lm15.auth — Local subscription credential helpers. + +These helpers intentionally do not read ordinary provider API keys from the +environment. They only support explicit local developer credentials created +by provider CLIs: Claude Code (``~/.claude/.credentials.json``), the OpenAI +Codex CLI (``~/.codex/auth.json``) — plus xAI subscription OAuth, which lm15 +logs in itself (device-code flow) because xAI ships no CLI credential file +convention; see the xAI section at the bottom. + +Failure behavior is typed and helpful, never a raw JSON traceback: + +- missing / unreadable / malformed credential files raise + :class:`lm15.errors.NotConfiguredError` telling the user which CLI login to + run; +- an expired token that cannot be refreshed (no refresh token, or the refresh + call fails) raises :class:`lm15.errors.AuthError` with the same re-login + guidance; +- lock contention on a credential file raises + :class:`lm15.auth.CredentialLockTimeout` — an + :class:`lm15.errors.LockTimeoutError` (ErrorCode ``lock_timeout``, + retryable; spec/auth.md AUTH-6) and the builtin ``TimeoutError`` at + once: a local, transient condition, not a provider error, so it + deliberately does not wear the AuthError type. + +Token material never appears in error messages or reprs. + +Concurrency contract (lm15-contract spec/auth.md AUTH-3/AUTH-4, ratified +2026-08-31): + +- refresh runs under a cross-process advisory lock, with a re-read of the + file inside the lock (double-checked refresh) so a refresh that another + process completed while we waited is used, not repeated — repeating it + loses a rotated refresh token; +- all credential writes are atomic (temp file + rename) and private (0600); +- the lock is advisory and cooperative between lm15 processes. The Claude + Code and Codex CLIs do not take it; the double-checked re-read is the + mitigation for foreign writers, not a cure. The network refresh happens + while holding the lock — one slow refresh can therefore stall other lm15 + processes for up to the lock timeout; the alternative (refresh outside the + lock) double-spends rotated refresh tokens, which is worse. +""" + +from __future__ import annotations + +import base64 +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from ._authlock import CredentialLockTimeout, hold_file_lock, write_private_json_atomic +from .errors import AuthError, NotConfiguredError, UnsupportedFeatureError + +__all__ = [ + "CredentialLockTimeout", + "LocalOAuthCredential", + "XaiDeviceAuthorization", + "get_claude_code_access_token", + "get_codex_cli_access_token", + "get_xai_access_token", + "load_claude_code_credential", + "load_codex_cli_credential", + "load_xai_credential", + "login", + "login_xai", + "poll_xai_device_login", + "read_claude_code_credential", + "read_codex_cli_credential", + "read_xai_credential", + "refresh_claude_code_credential", + "refresh_codex_cli_credential", + "refresh_xai_credential", + "start_xai_device_login", + "usable_xai_credential", + "write_claude_code_credential", + "write_codex_cli_credential", + "write_xai_credential", +] + +def _user_path(*parts: str) -> Path: + """Resolve a path under the user's home without raising where there is none. + + Evaluated at import to define the credential-path constants below, so it must not + raise. `Path.expanduser()` raises RuntimeError wherever no home directory can be + determined -- a WASI guest, some container setups -- and these constants are + defaults a caller can always override, so an unexpanded path is a better answer + than a failed import. Anything that actually opens it still fails, and says why. + """ + try: + return Path("~", *parts).expanduser() + except RuntimeError: + return Path("~", *parts) + + +CLAUDE_CODE_CREDENTIALS_PATH = _user_path(".claude", ".credentials.json") +CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d5-88ed-5944d1962f5e" +CLAUDE_CODE_TOKEN_URL = "https://platform.claude.com/v1/oauth/token" +CLAUDE_CODE_LOGIN_HINT = "Log in again: run `claude` and use /login (Claude subscription auth)" + +CODEX_CLI_AUTH_PATH = _user_path(".codex", "auth.json") +OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +OPENAI_CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token" +OPENAI_CODEX_JWT_CLAIM_PATH = "https://api.openai.com/auth" +OPENAI_CODEX_LOGIN_HINT = "Log in again: run `codex login` (ChatGPT subscription auth)" + +_REFRESH_SKEW_MS = 5 * 60 * 1000 + + +@dataclass(frozen=True, slots=True) +class LocalOAuthCredential: + """A locally stored OAuth credential. Token fields are repr-suppressed.""" + + access_token: str = field(repr=False) + refresh_token: str | None = field(default=None, repr=False) + expires_at: int | None = None + account_id: str | None = None + + @property + def expired(self) -> bool: + return isinstance(self.expires_at, int) and int(time.time() * 1000) >= self.expires_at + + +def _not_configured(provider: str, message: str, hint: str) -> NotConfiguredError: + return NotConfiguredError(message, provider=provider, credential_hint=hint) + + +def _read_json_file(path: Path, *, provider: str, hint: str) -> dict[str, Any]: + """Read a credential file, raising typed errors instead of crashing.""" + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise _not_configured(provider, f"No credentials file at {path}.", hint) from exc + except OSError as exc: + raise _not_configured(provider, f"Could not read credentials file at {path}: {exc}", hint) from exc + try: + data = json.loads(text) + except ValueError as exc: + raise _not_configured(provider, f"Credentials file at {path} is not valid JSON.", hint) from exc + if not isinstance(data, dict): + raise _not_configured(provider, f"Credentials file at {path} has an unexpected shape.", hint) + return data + + +def _read_json_file_or_none(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + +def _write_private_json(path: Path, data: dict[str, Any]) -> None: + write_private_json_atomic(path, data) + + +def _base64url_json(segment: str) -> dict[str, Any]: + padding = "=" * (-len(segment) % 4) + decoded = base64.urlsafe_b64decode(segment + padding) + data = json.loads(decoded.decode("utf-8")) + return data if isinstance(data, dict) else {} + + +def decode_jwt_payload(token: str) -> dict[str, Any]: + parts = token.split(".") + if len(parts) != 3: + raise ValueError("Invalid JWT") + return _base64url_json(parts[1]) + + +def jwt_expires_at_ms(token: str) -> int | None: + try: + exp = decode_jwt_payload(token).get("exp") + except Exception: + return None + if isinstance(exp, (int, float)): + return int(exp * 1000) - _REFRESH_SKEW_MS + return None + + +def extract_chatgpt_account_id(token: str) -> str | None: + try: + payload = decode_jwt_payload(token) + except Exception: + return None + auth_claim = payload.get(OPENAI_CODEX_JWT_CLAIM_PATH) + if isinstance(auth_claim, dict): + account_id = auth_claim.get("chatgpt_account_id") + if isinstance(account_id, str) and account_id: + return account_id + return None + + +def _post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]: + body = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - provider token endpoint + data = json.loads(response.read().decode("utf-8")) + return data if isinstance(data, dict) else {} + + +def _post_form(url: str, payload: dict[str, Any]) -> dict[str, Any]: + body = urllib.parse.urlencode(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - provider token endpoint + data = json.loads(response.read().decode("utf-8")) + return data if isinstance(data, dict) else {} + + +def _coerce_path(path: str | os.PathLike[str] | None, default: Path) -> Path: + return Path(path).expanduser() if path is not None else default + + +# ─── Claude Code (~/.claude/.credentials.json) ─────────────────────── + + +def load_claude_code_credential( + credentials_path: str | os.PathLike[str] | None = None, +) -> LocalOAuthCredential: + """Load the Claude Code OAuth credential, raising typed errors.""" + path = _coerce_path(credentials_path, CLAUDE_CODE_CREDENTIALS_PATH) + data = _read_json_file(path, provider="claude-code", hint=CLAUDE_CODE_LOGIN_HINT) + raw = data.get("claudeAiOauth") + if not isinstance(raw, dict): + raise _not_configured( + "claude-code", + f"Credentials file at {path} has no claudeAiOauth section.", + CLAUDE_CODE_LOGIN_HINT, + ) + access = raw.get("accessToken") + if not isinstance(access, str) or not access: + raise _not_configured( + "claude-code", + f"Credentials file at {path} has no access token.", + CLAUDE_CODE_LOGIN_HINT, + ) + refresh = raw.get("refreshToken") + expires = raw.get("expiresAt") + return LocalOAuthCredential( + access_token=access, + refresh_token=refresh if isinstance(refresh, str) and refresh else None, + expires_at=int(expires) if isinstance(expires, (int, float)) else None, + ) + + +def read_claude_code_credential( + credentials_path: str | os.PathLike[str] | None = None, +) -> LocalOAuthCredential | None: + """Optional-style loader: None when no usable credential exists.""" + try: + return load_claude_code_credential(credentials_path) + except NotConfiguredError: + return None + + +def refresh_claude_code_credential(refresh_token: str) -> LocalOAuthCredential: + payload = _post_json( + CLAUDE_CODE_TOKEN_URL, + { + "grant_type": "refresh_token", + "client_id": CLAUDE_CODE_CLIENT_ID, + "refresh_token": refresh_token, + }, + ) + access = payload.get("access_token") + refresh = payload.get("refresh_token") + expires_in = payload.get("expires_in") + if not isinstance(access, str) or not isinstance(refresh, str) or not isinstance(expires_in, (int, float)): + raise RuntimeError("Claude Code token refresh response is missing required fields") + return LocalOAuthCredential( + access_token=access, + refresh_token=refresh, + expires_at=int(time.time() * 1000 + expires_in * 1000 - _REFRESH_SKEW_MS), + ) + + +def _write_claude_code_credential_unlocked(credential: LocalOAuthCredential, path: Path) -> None: + data = _read_json_file_or_none(path) or {} + raw = data.get("claudeAiOauth") + current = raw if isinstance(raw, dict) else {} + current["accessToken"] = credential.access_token + if credential.refresh_token: + current["refreshToken"] = credential.refresh_token + if credential.expires_at is not None: + current["expiresAt"] = credential.expires_at + data["claudeAiOauth"] = current + _write_private_json(path, data) + + +def write_claude_code_credential( + credential: LocalOAuthCredential, + credentials_path: str | os.PathLike[str] | None = None, +) -> None: + path = _coerce_path(credentials_path, CLAUDE_CODE_CREDENTIALS_PATH) + with hold_file_lock(path): + _write_claude_code_credential_unlocked(credential, path) + + +def get_claude_code_access_token( + credentials_path: str | os.PathLike[str] | None = None, + *, + refresh: bool = True, +) -> str: + """Return a usable Claude Code access token, refreshing it if expired. + + Raises NotConfiguredError when no credential exists, AuthError when the + credential is expired and cannot be refreshed. + """ + credential = load_claude_code_credential(credentials_path) + if not credential.expired: + return credential.access_token + if not refresh or not credential.refresh_token: + raise AuthError( + "Claude Code OAuth token is expired and no refresh token is available.", + provider="claude-code", + credential_hint=CLAUDE_CODE_LOGIN_HINT, + ) + path = _coerce_path(credentials_path, CLAUDE_CODE_CREDENTIALS_PATH) + with hold_file_lock(path): + # Double-checked refresh: another process may have refreshed (and + # rotated the refresh token) while we waited for the lock. + credential = load_claude_code_credential(path) + if not credential.expired: + return credential.access_token + if not credential.refresh_token: + raise AuthError( + "Claude Code OAuth token is expired and no refresh token is available.", + provider="claude-code", + credential_hint=CLAUDE_CODE_LOGIN_HINT, + ) + try: + refreshed = refresh_claude_code_credential(credential.refresh_token) + except Exception as exc: + raise AuthError( + "Claude Code OAuth token is expired and the refresh attempt failed.", + provider="claude-code", + credential_hint=CLAUDE_CODE_LOGIN_HINT, + ) from exc + _write_claude_code_credential_unlocked(refreshed, path) + return refreshed.access_token + + +# ─── OpenAI Codex CLI (~/.codex/auth.json) ─────────────────────────── + + +def load_codex_cli_credential( + auth_path: str | os.PathLike[str] | None = None, +) -> LocalOAuthCredential: + """Load the Codex CLI OAuth credential, raising typed errors.""" + path = _coerce_path(auth_path, CODEX_CLI_AUTH_PATH) + data = _read_json_file(path, provider="openai-codex", hint=OPENAI_CODEX_LOGIN_HINT) + tokens = data.get("tokens") + if not isinstance(tokens, dict): + raise _not_configured( + "openai-codex", + f"Credentials file at {path} has no tokens section.", + OPENAI_CODEX_LOGIN_HINT, + ) + access = tokens.get("access_token") + if not isinstance(access, str) or not access: + raise _not_configured( + "openai-codex", + f"Credentials file at {path} has no access token.", + OPENAI_CODEX_LOGIN_HINT, + ) + refresh = tokens.get("refresh_token") + account_id = tokens.get("account_id") or extract_chatgpt_account_id(access) + return LocalOAuthCredential( + access_token=access, + refresh_token=refresh if isinstance(refresh, str) and refresh else None, + expires_at=jwt_expires_at_ms(access), + account_id=account_id if isinstance(account_id, str) and account_id else None, + ) + + +def read_codex_cli_credential( + auth_path: str | os.PathLike[str] | None = None, +) -> LocalOAuthCredential | None: + """Optional-style loader: None when no usable credential exists.""" + try: + return load_codex_cli_credential(auth_path) + except NotConfiguredError: + return None + + +def refresh_codex_cli_credential(refresh_token: str) -> LocalOAuthCredential: + payload = _post_form( + OPENAI_CODEX_TOKEN_URL, + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": OPENAI_CODEX_CLIENT_ID, + }, + ) + access = payload.get("access_token") + refresh = payload.get("refresh_token") or refresh_token + if not isinstance(access, str) or not isinstance(refresh, str): + raise RuntimeError("Codex token refresh response is missing required fields") + account_id = extract_chatgpt_account_id(access) + return LocalOAuthCredential( + access_token=access, + refresh_token=refresh, + expires_at=jwt_expires_at_ms(access), + account_id=account_id, + ) + + +def write_codex_cli_credential( + credential: LocalOAuthCredential, + auth_path: str | os.PathLike[str] | None = None, + *, + id_token: str | None = None, +) -> None: + path = _coerce_path(auth_path, CODEX_CLI_AUTH_PATH) + with hold_file_lock(path): + _write_codex_cli_credential_unlocked(credential, path, id_token=id_token) + + +def _write_codex_cli_credential_unlocked( + credential: LocalOAuthCredential, + path: Path, + *, + id_token: str | None = None, +) -> None: + data = _read_json_file_or_none(path) or {} + tokens = data.get("tokens") + current = tokens if isinstance(tokens, dict) else {} + current["access_token"] = credential.access_token + if credential.refresh_token: + current["refresh_token"] = credential.refresh_token + if credential.account_id: + current["account_id"] = credential.account_id + if id_token is not None: + current["id_token"] = id_token + data["tokens"] = current + data.setdefault("auth_mode", "chatgpt") + data["last_refresh"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + _write_private_json(path, data) + + +def get_codex_cli_access_token( + auth_path: str | os.PathLike[str] | None = None, + *, + refresh: bool = True, +) -> LocalOAuthCredential: + """Return a usable Codex CLI credential, refreshing it if expired. + + Raises NotConfiguredError when no credential exists, AuthError when the + credential is expired and cannot be refreshed. + """ + credential = load_codex_cli_credential(auth_path) + if not credential.expired: + return credential + if not refresh or not credential.refresh_token: + raise AuthError( + "Codex CLI OAuth token is expired and no refresh token is available.", + provider="openai-codex", + credential_hint=OPENAI_CODEX_LOGIN_HINT, + ) + path = _coerce_path(auth_path, CODEX_CLI_AUTH_PATH) + with hold_file_lock(path): + # Double-checked refresh: see the Claude Code sibling above. + credential = load_codex_cli_credential(path) + if not credential.expired: + return credential + if not credential.refresh_token: + raise AuthError( + "Codex CLI OAuth token is expired and no refresh token is available.", + provider="openai-codex", + credential_hint=OPENAI_CODEX_LOGIN_HINT, + ) + try: + refreshed = refresh_codex_cli_credential(credential.refresh_token) + except Exception as exc: + raise AuthError( + "Codex CLI OAuth token is expired and the refresh attempt failed.", + provider="openai-codex", + credential_hint=OPENAI_CODEX_LOGIN_HINT, + ) from exc + original = _read_json_file_or_none(path) or {} + tokens = original.get("tokens") if isinstance(original.get("tokens"), dict) else {} + id_token = ( + tokens.get("id_token") + if isinstance(tokens, dict) and isinstance(tokens.get("id_token"), str) + else None + ) + _write_codex_cli_credential_unlocked(refreshed, path, id_token=id_token) + return refreshed + + +# ─── xAI Grok (device-code OAuth; lm15 store or Pi agent store) ────── +# +# Unlike Claude Code and Codex, xAI has no first-party CLI credential file +# convention lm15 can piggyback on. lm15 therefore owns a login +# (:func:`login_xai`, RFC 8628 device-code flow) writing to lm15's own +# credential store, and additionally reads the Pi coding agent's store +# (``~/.pi/agent/auth.json``) when present — both files share the same +# ``{"xai": {"type": "oauth", "access", "refresh", "expires"}}`` schema. +# Refreshes write back to whichever file the credential came from: xAI +# rotates refresh tokens, so a refresh that is not persisted to its source +# bricks that source's login. + +XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828" +XAI_DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code" +XAI_TOKEN_URL = "https://auth.x.ai/oauth2/token" +XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access" +XAI_LOGIN_HINT = "Log in again: run lm15.auth.login_xai() (SuperGrok / X Premium subscription auth)" +PI_AGENT_AUTH_PATH = _user_path(".pi", "agent", "auth.json") + +_XAI_PROVIDER_KEY = "xai" +_XAI_DEFAULT_TOKEN_LIFETIME_S = 3600 + + +def _xai_store_paths() -> tuple[Path, ...]: + from .authkit import default_credentials_path + + return (default_credentials_path(), PI_AGENT_AUTH_PATH) + + +def _xai_entry_to_credential(entry: Any) -> LocalOAuthCredential | None: + if not isinstance(entry, dict): + return None + access = entry.get("access") + if not isinstance(access, str) or not access: + return None + refresh = entry.get("refresh") + expires = entry.get("expires") + return LocalOAuthCredential( + access_token=access, + refresh_token=refresh if isinstance(refresh, str) and refresh else None, + expires_at=expires if isinstance(expires, int) and not isinstance(expires, bool) else None, + ) + + +def _xai_credential_to_entry(credential: LocalOAuthCredential, current: dict[str, Any] | None) -> dict[str, Any]: + entry = dict(current or {}) + entry["type"] = "oauth" + entry["access"] = credential.access_token + if credential.refresh_token: + entry["refresh"] = credential.refresh_token + if credential.expires_at is not None: + entry["expires"] = credential.expires_at + return entry + + +def _load_xai_with_source( + auth_path: str | os.PathLike[str] | None = None, +) -> tuple[LocalOAuthCredential, Path]: + paths = (Path(auth_path).expanduser(),) if auth_path is not None else _xai_store_paths() + for path in paths: + data = _read_json_file_or_none(path) + credential = _xai_entry_to_credential(data.get(_XAI_PROVIDER_KEY)) if data else None + if credential is not None: + return credential, path + checked = ", ".join(str(p) for p in paths) + raise _not_configured( + "xai", + f"No xAI OAuth credential found (checked: {checked}).", + XAI_LOGIN_HINT, + ) + + +def load_xai_credential( + auth_path: str | os.PathLike[str] | None = None, +) -> LocalOAuthCredential: + """Load the stored xAI OAuth credential, raising typed errors.""" + credential, _ = _load_xai_with_source(auth_path) + return credential + + +def read_xai_credential( + auth_path: str | os.PathLike[str] | None = None, +) -> LocalOAuthCredential | None: + """Optional-style loader: None when no usable credential exists.""" + try: + return load_xai_credential(auth_path) + except NotConfiguredError: + return None + + +def usable_xai_credential(auth_path: str | os.PathLike[str] | None = None) -> bool: + """True when a stored xAI subscription credential exists and is usable + (fresh, or expired with a refresh token to refresh it at request time). + + Reads files only — never the network — so the router can consult it + while staying offline. This is the probe behind the + ``oauth-unless-explicit`` credential policy (spec/auth.md AUTH-1): the + stored subscription wins over ambient environment keys exactly when + this returns True. + """ + credential = read_xai_credential(auth_path) + if credential is None: + return False + return not credential.expired or bool(credential.refresh_token) + + +def _xai_credential_from_token_response( + payload: dict[str, Any], + previous_refresh_token: str | None = None, +) -> LocalOAuthCredential: + access = payload.get("access_token") + if not isinstance(access, str) or not access: + raise RuntimeError("xAI token response is missing access_token") + refresh = payload.get("refresh_token") + if not isinstance(refresh, str) or not refresh: + # xAI may omit refresh_token when it does not rotate it. + refresh = previous_refresh_token + expires_in = payload.get("expires_in") + lifetime_s = ( + expires_in + if isinstance(expires_in, (int, float)) and not isinstance(expires_in, bool) and expires_in > 0 + else _XAI_DEFAULT_TOKEN_LIFETIME_S + ) + return LocalOAuthCredential( + access_token=access, + refresh_token=refresh, + expires_at=int(time.time() * 1000 + lifetime_s * 1000 - _REFRESH_SKEW_MS), + ) + + +def refresh_xai_credential(refresh_token: str) -> LocalOAuthCredential: + payload = _post_form( + XAI_TOKEN_URL, + { + "grant_type": "refresh_token", + "client_id": XAI_CLIENT_ID, + "refresh_token": refresh_token, + }, + ) + return _xai_credential_from_token_response(payload, refresh_token) + + +def write_xai_credential( + credential: LocalOAuthCredential, + auth_path: str | os.PathLike[str] | None = None, +) -> None: + """Write the credential into a store file (default: lm15's own store).""" + from .authkit import CredentialFileStore, default_credentials_path + + store = CredentialFileStore(auth_path if auth_path is not None else default_credentials_path()) + store.mutate(_XAI_PROVIDER_KEY, lambda current: _xai_credential_to_entry(credential, current)) + + +def get_xai_access_token( + auth_path: str | os.PathLike[str] | None = None, + *, + refresh: bool = True, +) -> str: + """Return a usable xAI access token, refreshing (and persisting) if expired. + + Raises NotConfiguredError when no credential exists, AuthError when the + credential is expired and cannot be refreshed. A refresh is written back + to the file the credential came from (lm15 store or Pi agent store) under + that file's lock, with a double-checked re-read: xAI rotates refresh + tokens, so losing the write would break every consumer of that file. + """ + from .authkit import CredentialFileStore + + credential, source = _load_xai_with_source(auth_path) + if not credential.expired: + return credential.access_token + if not refresh or not credential.refresh_token: + raise AuthError( + "xAI OAuth token is expired and no refresh token is available.", + provider="xai", + credential_hint=XAI_LOGIN_HINT, + ) + + result: dict[str, str] = {} + + def _refresh_entry(current: dict[str, Any] | None) -> dict[str, Any] | None: + # Double-checked refresh: another process may have refreshed (and + # rotated the refresh token) while we waited for the lock. + fresh = _xai_entry_to_credential(current) + if fresh is not None and not fresh.expired: + result["access"] = fresh.access_token + return None + refresh_token = (fresh.refresh_token if fresh else None) or credential.refresh_token + if not refresh_token: + raise AuthError( + "xAI OAuth token is expired and no refresh token is available.", + provider="xai", + credential_hint=XAI_LOGIN_HINT, + ) + try: + refreshed = refresh_xai_credential(refresh_token) + except AuthError: + raise + except Exception as exc: + raise AuthError( + "xAI OAuth token is expired and the refresh attempt failed.", + provider="xai", + credential_hint=XAI_LOGIN_HINT, + ) from exc + result["access"] = refreshed.access_token + return _xai_credential_to_entry(refreshed, current) + + CredentialFileStore(source).mutate(_XAI_PROVIDER_KEY, _refresh_entry) + return result["access"] + + +# ─── xAI device-code login (RFC 8628) ──────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class XaiDeviceAuthorization: + """One pending device authorization. The device code is repr-suppressed.""" + + user_code: str + verification_uri: str + verification_uri_complete: str | None + interval_s: float + expires_in_s: float + device_code: str = field(repr=False, default="") + + +def _https_or_raise(raw: Any) -> str: + # The verification URI is shown to (and often opened by) the user; refuse + # anything a malicious response could turn into a local scheme launch. + if isinstance(raw, str): + try: + parsed = urllib.parse.urlparse(raw) + except ValueError: + parsed = None + if parsed is not None and parsed.scheme == "https" and parsed.netloc: + return raw + raise AuthError("xAI device authorization returned an untrusted verification URI.", provider="xai") + + +def _parse_xai_device_authorization(payload: dict[str, Any]) -> XaiDeviceAuthorization: + device_code = payload.get("device_code") + user_code = payload.get("user_code") + expires_in = payload.get("expires_in") + if not isinstance(device_code, str) or not device_code or not isinstance(user_code, str) or not user_code: + raise AuthError("xAI device authorization response is missing required fields.", provider="xai") + if not isinstance(expires_in, (int, float)) or isinstance(expires_in, bool) or expires_in <= 0: + raise AuthError("xAI device authorization response is missing expires_in.", provider="xai") + interval = payload.get("interval") + interval_s = ( + float(interval) + if isinstance(interval, (int, float)) and not isinstance(interval, bool) and interval > 0 + else 5.0 + ) + complete = payload.get("verification_uri_complete") + return XaiDeviceAuthorization( + user_code=user_code, + verification_uri=_https_or_raise(payload.get("verification_uri")), + verification_uri_complete=_https_or_raise(complete) if isinstance(complete, str) and complete else None, + interval_s=interval_s, + expires_in_s=float(expires_in), + device_code=device_code, + ) + + +def _post_form_tolerant(url: str, payload: dict[str, Any]) -> tuple[bool, dict[str, Any]]: + """POST a form; return (ok, parsed body) even for 4xx responses. + + Device-code polling encodes protocol state (authorization_pending, + slow_down, ...) in 4xx bodies, so HTTP errors are data here. + """ + try: + return True, _post_form(url, payload) + except urllib.error.HTTPError as exc: + try: + data = json.loads(exc.read().decode("utf-8")) + except Exception: + data = {} + return False, data if isinstance(data, dict) else {} + + +def start_xai_device_login() -> XaiDeviceAuthorization: + """Request a device authorization; show the user code, then poll.""" + ok, payload = _post_form_tolerant( + XAI_DEVICE_CODE_URL, + {"client_id": XAI_CLIENT_ID, "scope": XAI_OAUTH_SCOPE, "referrer": "lm15"}, + ) + if not ok: + detail = payload.get("error_description") or payload.get("error") or "request failed" + raise AuthError(f"xAI device authorization failed: {detail}", provider="xai") + return _parse_xai_device_authorization(payload) + + +def poll_xai_device_login( + device: XaiDeviceAuthorization, + *, + sleep: Any = time.sleep, +) -> LocalOAuthCredential: + """Poll the token endpoint until the user approves; return the credential.""" + from .authkit import DeviceComplete, DeviceFailed, DevicePending, DeviceSlowDown, poll_device_code + + def _poll() -> Any: + ok, payload = _post_form_tolerant( + XAI_TOKEN_URL, + { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_id": XAI_CLIENT_ID, + "device_code": device.device_code, + }, + ) + if ok: + return DeviceComplete(_xai_credential_from_token_response(payload)) + error = payload.get("error") + if error == "authorization_pending": + return DevicePending() + if error == "slow_down": + interval = payload.get("interval") + return DeviceSlowDown( + float(interval) + if isinstance(interval, (int, float)) and not isinstance(interval, bool) and interval > 0 + else None + ) + if error in {"access_denied", "authorization_denied"}: + return DeviceFailed("xAI device authorization was denied.") + if error == "expired_token": + return DeviceFailed("xAI device code expired before it was approved.") + detail = payload.get("error_description") or error or "request failed" + return DeviceFailed(f"xAI device token polling failed: {detail}") + + return poll_device_code( + _poll, + interval_s=device.interval_s, + expires_in_s=device.expires_in_s, + provider="xai", + wait_before_first_poll=True, + sleep=sleep, + ) + + +def login_xai( + auth_path: str | os.PathLike[str] | None = None, + *, + echo: Any = print, +) -> LocalOAuthCredential: + """Interactive device-code login; persists the credential and returns it. + + Prints the verification URL and user code via ``echo`` and blocks until + the user approves in a browser (or the code expires). The credential is + written to lm15's own store unless ``auth_path`` overrides it. + """ + device = start_xai_device_login() + target = device.verification_uri_complete or device.verification_uri + echo(f"Open {target} and enter code: {device.user_code}") + credential = poll_xai_device_login(device) + write_xai_credential(credential, auth_path) + return credential + + +# ─── Uniform login entry point ─────────────────────────────────── +# +# One door, provider-dispatched. Only providers with an lm15-owned flow +# actually log in here (today: xai). Every other provider fails typed, +# naming the real path: the foreign CLI command that owns the flow, or the +# console URL where keys are created. These URLs are guidance strings, not +# wire facts — they can drift, and drifting costs a stale hint, not broken +# inference. + +_KEY_CONSOLE_URLS: dict[str, str] = { + "openai": "https://platform.openai.com/api-keys", + "openai-chat": "https://platform.openai.com/api-keys", + "anthropic": "https://console.anthropic.com", + "gemini": "https://aistudio.google.com/apikey", + "groq": "https://console.groq.com/keys", + "openrouter": "https://openrouter.ai/keys", + "xai": "https://console.x.ai", +} + +_CLI_LOGIN_HINTS: dict[str, str] = { + "claude-code": CLAUDE_CODE_LOGIN_HINT, + "openai-codex": OPENAI_CODEX_LOGIN_HINT, +} + +_KEYLESS_LOCAL_SERVERS = frozenset({"ollama", "vllm", "sglang"}) + + +def login( + provider: str, + *, + credentials_path: str | os.PathLike[str] | None = None, + echo: Any = print, +) -> LocalOAuthCredential: + """Run the login flow lm15 owns for ``provider``; fail typed otherwise. + + This is the uniform door: ``login("xai")`` runs the device-code flow and + returns the stored credential. Providers whose login lives elsewhere + raise :class:`lm15.errors.UnsupportedFeatureError` naming the exact fix + — the foreign CLI command (Claude Code, Codex) or the console URL where + an API key is created. Nothing here prompts, opens a browser, or + spends money except the one flow you explicitly asked for. + """ + canonical = provider.replace("_", "-") + if canonical == "xai": + return login_xai(credentials_path, echo=echo) + if canonical in _CLI_LOGIN_HINTS: + raise UnsupportedFeatureError( + f"lm15 does not own the {canonical!r} login flow — the provider CLI does. " + f"{_CLI_LOGIN_HINTS[canonical]}", + provider=canonical, + ) + if canonical in _KEYLESS_LOCAL_SERVERS: + raise UnsupportedFeatureError( + f"{canonical!r} is a keyless local server — there is nothing to log into. " + "The router sends the placeholder key the server expects.", + provider=canonical, + ) + if canonical in _KEY_CONSOLE_URLS: + raise UnsupportedFeatureError( + f"{canonical!r} offers no OAuth login flow — only manually created API keys. " + f"Create one at {_KEY_CONSOLE_URLS[canonical]} and set it in the environment " + f"or RouterConfig(api_keys={{{canonical!r}: \"...\"}}).", + provider=canonical, + ) + raise UnsupportedFeatureError( + f"lm15 has no login flow for {provider!r}. Supply an API key via the " + "environment or RouterConfig(api_keys=...).", + provider=canonical, + ) diff --git a/authkit.py b/authkit.py new file mode 100644 index 0000000000..d604044d81 --- /dev/null +++ b/authkit.py @@ -0,0 +1,360 @@ +""" +lm15.authkit — building blocks for login flows. No UI, no opinions. + +lm15 deliberately does not own a ``/login`` experience: apps and CLIs do. +This module gives those apps the pieces that are hard to get right and +painful to reimplement per language, all stdlib-only: + +- :func:`generate_pkce` / :func:`pkce_challenge` — RFC 7636 (S256 only; + ``plain`` is intentionally not offered because every current provider + supports S256 and ``plain`` exists only as a downgrade). +- :func:`poll_device_code` — the RFC 8628 device-authorization polling state + machine (pending / slow_down / complete / failure / expiry), with + injectable clock and sleep so it is testable without real time. +- :class:`OAuthCallbackListener` — a one-shot loopback HTTP listener for + authorization-code redirects, bound to ``127.0.0.1``, with state checking. +- :class:`CredentialFileStore` — an lm15-owned, locked, atomic, 0600 + credential file keyed by provider id (the ADC/pi ``auth.json`` pattern), + with a serialized read-modify-write ``mutate`` as the only compound write + path. + +Secrets never appear in reprs or exception messages raised by this module. +""" + +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +import os +import secrets +import time +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any, Callable, Union +from urllib.parse import parse_qs, urlsplit + +from ._authlock import hold_file_lock, write_private_json_atomic +from .errors import AuthError + +__all__ = [ + "CredentialFileStore", + "DeviceCodeExpiredError", + "DeviceComplete", + "DeviceFailed", + "DevicePending", + "DeviceSlowDown", + "OAuthCallbackListener", + "OAuthCallbackResult", + "PKCEPair", + "default_credentials_path", + "generate_pkce", + "pkce_challenge", + "poll_device_code", +] + + +# ─── PKCE (RFC 7636, S256) ─────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class PKCEPair: + verifier: str = field(repr=False) # secret until the code exchange + challenge: str + method: str = "S256" + + +def pkce_challenge(verifier: str) -> str: + """S256 code challenge for ``verifier`` (RFC 7636 §4.2).""" + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +def generate_pkce() -> PKCEPair: + """Fresh PKCE pair: 64 random bytes → 86-char base64url verifier.""" + verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii") + return PKCEPair(verifier=verifier, challenge=pkce_challenge(verifier)) + + +# ─── Device-code polling (RFC 8628 §3.5) ───────────────────────────── + + +@dataclass(frozen=True, slots=True) +class DevicePending: + """Authorization still pending; poll again after the interval.""" + + +@dataclass(frozen=True, slots=True) +class DeviceSlowDown: + """Server asked to slow down; interval grows by 5s unless it names one.""" + + interval_s: float | None = None + + +@dataclass(frozen=True, slots=True) +class DeviceComplete: + """Authorization finished; ``value`` is whatever the poll fn produced.""" + + value: Any = field(repr=False) # usually carries tokens + + +@dataclass(frozen=True, slots=True) +class DeviceFailed: + """Terminal denial/failure. ``message`` must not contain secrets.""" + + message: str + + +DevicePollResult = Union[DevicePending, DeviceSlowDown, DeviceComplete, DeviceFailed] + +_SLOW_DOWN_STEP_S = 5.0 # RFC 8628 §3.5: "increase ... by 5 seconds" + + +class DeviceCodeExpiredError(AuthError): + """The device code expired before the user approved it.""" + + +def poll_device_code( + poll: Callable[[], DevicePollResult], + *, + interval_s: float = 5.0, + expires_in_s: float = 900.0, + provider: str | None = None, + wait_before_first_poll: bool = False, + sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.monotonic, +) -> Any: + """Run the device-authorization polling loop; return the completed value. + + ``poll`` performs one token-endpoint request and classifies the outcome. + Raises :class:`DeviceCodeExpiredError` on expiry and :class:`AuthError` + on terminal failure. ``sleep``/``clock`` are injectable for tests. + """ + interval = max(interval_s, 0.0) + deadline = clock() + expires_in_s + first = True + while True: + if not first or wait_before_first_poll: + if clock() + interval > deadline: + raise DeviceCodeExpiredError( + "Device authorization expired before it was approved. Start the login again.", + provider=provider, + ) + sleep(interval) + first = False + if clock() > deadline: + raise DeviceCodeExpiredError( + "Device authorization expired before it was approved. Start the login again.", + provider=provider, + ) + result = poll() + if isinstance(result, DeviceComplete): + return result.value + if isinstance(result, DeviceFailed): + raise AuthError(result.message, provider=provider) + if isinstance(result, DeviceSlowDown): + interval = result.interval_s if result.interval_s else interval + _SLOW_DOWN_STEP_S + elif not isinstance(result, DevicePending): + raise TypeError(f"poll() must return a DevicePollResult, got {type(result).__name__}") + + +# ─── Loopback callback listener ────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class OAuthCallbackResult: + code: str = field(repr=False) # authorization codes are secrets + state: str | None = None + + +_SUCCESS_HTML = ( + "Signed in" + "

Authentication completed. You can close this window.

" +) +_ERROR_HTML = "Error

{message}

" + + +class OAuthCallbackListener: + """One-shot loopback listener for an authorization-code redirect. + + Usage:: + + with OAuthCallbackListener(expected_state=state) as listener: + url = build_authorize_url(redirect_uri=listener.redirect_uri, ...) + print(url) # or open a browser + result = listener.wait(timeout_s=300) + + Binds ``127.0.0.1`` (never a public interface) on an ephemeral port by + default. Wrong-path and wrong-state requests get an error page and the + listener keeps waiting; a provider ``error`` parameter or the deadline + ends the wait. Single-threaded by design: build and display the + authorization URL before calling :meth:`wait`. + """ + + def __init__( + self, + *, + expected_state: str | None = None, + host: str = "127.0.0.1", + port: int = 0, + path: str = "/callback", + ) -> None: + self._expected_state = expected_state + self._path = path + self._result: OAuthCallbackResult | None = None + self._error: str | None = None + listener = self + + class _Handler(BaseHTTPRequestHandler): + def log_message(self, *_args: Any) -> None: # no request logging: URLs carry codes + pass + + def do_GET(self) -> None: + split = urlsplit(self.path) + if split.path != listener._path: + self._reply(404, _ERROR_HTML.format(message="Callback route not found.")) + return + params = parse_qs(split.query) + error = params.get("error", [None])[0] + if error: + description = params.get("error_description", [error])[0] + listener._error = description or error + self._reply(400, _ERROR_HTML.format(message="Authorization was not completed.")) + return + state = params.get("state", [None])[0] + if listener._expected_state is not None and state != listener._expected_state: + self._reply(400, _ERROR_HTML.format(message="State mismatch.")) + return + code = params.get("code", [None])[0] + if not code: + self._reply(400, _ERROR_HTML.format(message="Missing authorization code.")) + return + listener._result = OAuthCallbackResult(code=code, state=state) + self._reply(200, _SUCCESS_HTML) + + def _reply(self, status: int, html: str) -> None: + body = html.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + self._server = HTTPServer((host, port), _Handler) + + @property + def redirect_uri(self) -> str: + host, port = self._server.server_address[:2] + return f"http://{host}:{port}{self._path}" + + def wait(self, *, timeout_s: float = 300.0) -> OAuthCallbackResult: + """Block until the redirect arrives; raise on error or timeout.""" + deadline = time.monotonic() + timeout_s + while self._result is None and self._error is None: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Timed out waiting for the OAuth redirect.") + self._server.timeout = min(remaining, 1.0) + self._server.handle_request() + if self._error is not None: + raise AuthError(f"Authorization failed: {self._error}") + assert self._result is not None + return self._result + + def close(self) -> None: + self._server.server_close() + + def __enter__(self) -> "OAuthCallbackListener": + return self + + def __exit__(self, *_exc: Any) -> None: + self.close() + + +# ─── lm15-owned credential store ───────────────────────────────────── + + +def default_credentials_path() -> Path: + """``$LM15_CREDENTIALS_PATH``, else ``$XDG_CONFIG_HOME/lm15/credentials.json``.""" + override = os.environ.get("LM15_CREDENTIALS_PATH") + if override: + return Path(override).expanduser() + config_home = os.environ.get("XDG_CONFIG_HOME") + base = Path(config_home).expanduser() if config_home else Path("~/.config").expanduser() + return base / "lm15" / "credentials.json" + + +class CredentialFileStore: + """A locked, atomic, private (0600) credential file keyed by provider id. + + The file is one JSON object: ``{"": {...credential...}}``. + ``mutate`` is the only compound write path — a serialized + read-modify-write under the cross-process lock, so refresh flows can + re-check state before writing (the double-checked pattern). + + The store deliberately does not interpret credential shapes; that is the + caller's schema. It only guarantees storage semantics. + """ + + def __init__(self, path: str | os.PathLike[str] | None = None) -> None: + self.path = Path(path).expanduser() if path is not None else default_credentials_path() + + def __repr__(self) -> str: # never show file contents + return f"CredentialFileStore(path={str(self.path)!r})" + + def _read_all(self) -> dict[str, Any]: + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except ValueError as exc: + raise ValueError(f"Credential store at {self.path} is not valid JSON.") from exc + if not isinstance(data, dict): + raise ValueError(f"Credential store at {self.path} must be a JSON object.") + return data + + def read(self, provider: str) -> dict[str, Any] | None: + """The stored credential for ``provider``, or None. Returns a copy.""" + credential = self._read_all().get(provider) + return copy.deepcopy(credential) if isinstance(credential, dict) else None + + def list(self) -> tuple[str, ...]: + """Provider ids with stored credentials. Never returns values.""" + return tuple(sorted(self._read_all())) + + def write(self, provider: str, credential: dict[str, Any]) -> None: + self.mutate(provider, lambda _current: credential) + + def delete(self, provider: str) -> None: + with hold_file_lock(self.path): + data = self._read_all() + if provider in data: + del data[provider] + write_private_json_atomic(self.path, data) + + def mutate( + self, + provider: str, + fn: Callable[[dict[str, Any] | None], dict[str, Any] | None], + ) -> dict[str, Any] | None: + """Serialized read-modify-write for one provider's credential. + + ``fn`` receives the current credential (or None) as read inside the + lock and returns the new credential, or None to leave the entry + unchanged. Returns the post-write credential. + """ + with hold_file_lock(self.path): + data = self._read_all() + current = data.get(provider) + current = copy.deepcopy(current) if isinstance(current, dict) else None + replacement = fn(current) + if replacement is None: + return current + if not isinstance(replacement, dict): + raise TypeError("mutate() callback must return a dict or None") + data[provider] = replacement + write_private_json_atomic(self.path, data) + return copy.deepcopy(replacement) diff --git a/batch.py b/batch.py new file mode 100644 index 0000000000..c412ca8dce --- /dev/null +++ b/batch.py @@ -0,0 +1,165 @@ +""" +lm15.batch — the BatchJob handle (ticket ergonomics over the pure ops). + +A batch job is a ticket: the provider holds the requests, ``job.id`` is +the ticket number, and the id is a plain string you can store anywhere. +The handle is per-language sugar over five pure operations +(``batch_submit`` / ``batch_status`` / ``batch_results`` / +``batch_cancel`` / ``batch_list``); ports implement the ops, the handle +idiom is theirs to choose. + +``refresh()`` / ``wait()`` / ``cancel()`` update the handle's snapshot in +place and return the handle — a mutating refresh avoids the +forgot-to-reassign-stale-status bug. The frozen-types culture applies to +canonical types (``BatchJobInfo``, ``BatchEntry``), not client handles. +""" + +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING, Any + +from .types import BatchEntry, BatchJobInfo, BatchStatus + +if TYPE_CHECKING: # pragma: no cover - annotations only + from .providers.async_base import AsyncBaseLM + from .providers.base import BaseProviderLM + +__all__ = ["AsyncBatchJob", "BatchJob"] + +_DEFAULT_POLL_SECONDS = 30.0 + + +class BatchJob: + """A live handle on one provider-side batch job.""" + + def __init__(self, lm: "BaseProviderLM", info: BatchJobInfo) -> None: + self._lm = lm + self._info = info + + # ─── Snapshot access ───────────────────────────────────────────── + + @property + def info(self) -> BatchJobInfo: + """The frozen snapshot from the last provider contact.""" + return self._info + + @property + def id(self) -> str: + return self._info.id + + @property + def status(self) -> BatchStatus: + return self._info.status + + @property + def label(self) -> str | None: + return self._info.label + + @property + def created_at(self) -> str | None: + return self._info.created_at + + @property + def provider_data(self) -> dict[str, Any] | None: + return self._info.provider_data + + @property + def done(self) -> bool: + return self._info.done + + def __repr__(self) -> str: + label = f" label={self._info.label!r}" if self._info.label else "" + return f"BatchJob(id={self._info.id!r}, status={self._info.status!r}{label})" + + # ─── Verbs (update the snapshot in place, return self) ─────────── + + def refresh(self) -> "BatchJob": + self._info = self._lm.batch_status(self._info.id) + return self + + def wait(self, poll_every: float = _DEFAULT_POLL_SECONDS, timeout: float | None = None) -> "BatchJob": + """Poll until the job is terminal. Convenience for small jobs and + notebooks; the primary pattern for real workloads is store-the-id + and re-attach.""" + deadline = None if timeout is None else time.monotonic() + timeout + while True: + if self._info.done: + return self + if deadline is not None and time.monotonic() >= deadline: + raise TimeoutError( + f"batch {self._info.id} still {self._info.status!r} after {timeout}s" + ) + time.sleep(poll_every) + self.refresh() + + def results(self) -> tuple[BatchEntry, ...]: + return self._lm.batch_results(self._info.id) + + def cancel(self) -> "BatchJob": + self._info = self._lm.batch_cancel(self._info.id) + return self + + +class AsyncBatchJob: + """Async twin of :class:`BatchJob`; identical types, awaitable verbs.""" + + def __init__(self, lm: "AsyncBaseLM", info: BatchJobInfo) -> None: + self._lm = lm + self._info = info + + @property + def info(self) -> BatchJobInfo: + return self._info + + @property + def id(self) -> str: + return self._info.id + + @property + def status(self) -> BatchStatus: + return self._info.status + + @property + def label(self) -> str | None: + return self._info.label + + @property + def created_at(self) -> str | None: + return self._info.created_at + + @property + def provider_data(self) -> dict[str, Any] | None: + return self._info.provider_data + + @property + def done(self) -> bool: + return self._info.done + + def __repr__(self) -> str: + label = f" label={self._info.label!r}" if self._info.label else "" + return f"AsyncBatchJob(id={self._info.id!r}, status={self._info.status!r}{label})" + + async def refresh(self) -> "AsyncBatchJob": + self._info = await self._lm.batch_status(self._info.id) + return self + + async def wait(self, poll_every: float = _DEFAULT_POLL_SECONDS, timeout: float | None = None) -> "AsyncBatchJob": + deadline = None if timeout is None else time.monotonic() + timeout + while True: + if self._info.done: + return self + if deadline is not None and time.monotonic() >= deadline: + raise TimeoutError( + f"batch {self._info.id} still {self._info.status!r} after {timeout}s" + ) + await asyncio.sleep(poll_every) + await self.refresh() + + async def results(self) -> tuple[BatchEntry, ...]: + return await self._lm.batch_results(self._info.id) + + async def cancel(self) -> "AsyncBatchJob": + self._info = await self._lm.batch_cancel(self._info.id) + return self diff --git a/cloud/__init__.py b/cloud/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cloud/chains.py b/cloud/chains.py new file mode 100644 index 0000000000..e21724e205 --- /dev/null +++ b/cloud/chains.py @@ -0,0 +1,1464 @@ +""" +lm15.cloud.chains — the three cloud credential chains as data over ten rung kinds. + +spec/auth.md AUTH-1 (``aws-chain``, ``azure-chain``, ``gcp-chain``) and +AUTH-11 (rung kinds). Every order, variable, path and endpoint here is +cited in lm15-contract/research/cloud-hosts/10-facts-*.md from the cloud +SDKs' own resolver sources. + +Two entry points: + +- ``explain(policy, ctx)`` — the offline doctor walk (AUTH-7): every rung + reports ``selected`` / ``shadowed`` / ``absent`` / ``unprobed``. No + network, no subprocess: a rung that needs either is ``unprobed`` when + its configuration is present. The first offline-usable rung is + ``selected``; an ``unprobed`` rung that precedes it may still win at + request time, and the report says so. +- ``credential_provider(policy, ctx, named=None)`` — the AUTH-2 provider + callable: resolves once, caches until the AUTH-3 skew window, + re-resolves after. Cache key = provider id + the identity-selecting + settings. Its ``source`` is the ``CredentialSource`` of the rung that + won (AUTH-1 provenance, amended 2026-09-19): every auth error names it. + +Named credentials (AUTH-1, amended 2026-09-19): ``named`` is one of +``platform`` / ``workload`` / ``environment`` / ``cli``. It selects the +rungs that mean that one thing on this cloud (``NAMED_RUNGS``) and the +chain is not walked: a named credential that is absent is a +``NotConfiguredError`` naming what was probed, never a fall-through to +another identity. + +Rungs that are declared but not implemented raise ``NotConfiguredError`` +naming the gap and the fix; they never fall through silently +(``aws login`` refresh with a DPoP key; Azure Service Fabric managed +identity; GCP ``external_account`` with an AWS source). +""" + +from __future__ import annotations + +import base64 +import configparser +import hashlib +import http.client +import json +import os +import subprocess +import threading +import urllib.parse +import urllib.request +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Mapping +from xml.etree import ElementTree + +from ..credentials import ApiKey, AwsCredentials, BearerToken, CredentialValue, parse_rfc3339 +from ..errors import AuthError, NotConfiguredError +from ..features import NAMED_CREDENTIALS, RUNG_KINDS, AccessPolicy +from . import rs256, sigv4 + +__all__ = ["ChainContext", "Rung", "Step", "CredentialSource", "chain_for", "named_rungs", "explain", + "credential_provider", "token_exchange_build", "token_exchange_parse", "RUNG_KINDS", "NAMED_RUNGS"] + + +_SKEW = timedelta(seconds=300) # AUTH-3 +_GCP_SCOPE = "https://www.googleapis.com/auth/cloud-platform" +_GCP_TOKEN_URL = "https://oauth2.googleapis.com/token" +_GCP_STS_URL = "https://sts.googleapis.com/v1/token" +_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer" +_CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + +# ─── Context: everything a chain touches, injectable ───────────────── + +HttpFn = Callable[[str, str, Mapping[str, str], bytes | None, float], tuple[int, dict[str, str], bytes]] + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + # Never forward credential headers or token bodies to another URL. + return None + + +def _default_http(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: float): + try: + req = urllib.request.Request(url, data=body, method=method, headers=dict(headers)) + with urllib.request.build_opener(_NoRedirect()).open(req, timeout=timeout) as r: + return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read() + except urllib.error.HTTPError as e: + with e: + return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read() + except (OSError, ValueError, http.client.HTTPException): + raise AuthError("credential HTTP request failed") from None + + +def _default_run(argv: list[str], timeout: float, *, env: Mapping[str, str] | None = None) -> str: + try: + out = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, check=False, env=env) + except (OSError, subprocess.SubprocessError): + raise AuthError("credential command failed") from None + if out.returncode != 0: + raise AuthError(f"credential command exited {out.returncode}") + return out.stdout + + +@dataclass(repr=False) +class ChainContext: + """What a chain reads. ``http`` / ``run`` = None means offline (the + doctor). ``files`` overrides the filesystem for the harness.""" + + env: Mapping[str, str] + home: Path = field(default_factory=Path.home) + files: Mapping[str, str] | None = None + http: HttpFn | None = None + run: Callable[[list[str], float], str] | None = None + now: Callable[[], datetime] = lambda: datetime.now(timezone.utc) + settings: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def online(cls, env: Mapping[str, str] | None = None, **kw: Any) -> "ChainContext": + values = dict(env if env is not None else os.environ) + if "home" not in kw: + kw["home"] = Path(values["HOME"]) if values.get("HOME") else Path.home() + return cls(env=values, http=_default_http, + run=lambda argv, timeout: _default_run(argv, timeout, env=values), **kw) + + def path(self, text: str) -> Path: + if text.startswith("~"): + return self.home / text[1:].lstrip("/\\") + return Path(text) + + def read(self, text: str) -> str | None: + if self.files is not None: + for key, content in self.files.items(): + if str(self.path(key)) == str(self.path(text)): + return content + return None + try: + return self.path(text).read_text(encoding="utf-8") + except OSError: + return None + + def exists(self, text: str) -> bool: + return self.read(text) is not None + + def on_path(self, command: str) -> str | None: + """Where ``command`` would run from, from the context's PATH only — + an offline file check, so the doctor can say "not installed".""" + if "/" in command or "\\" in command: + return command if self.exists(command) else None + path = self.env.get("PATH") or "" + if self.files is not None: + for directory in path.split(os.pathsep): + if directory and self.exists(f"{directory.rstrip('/')}/{command}"): + return f"{directory.rstrip('/')}/{command}" + return None + import shutil + + return shutil.which(command, path=path) if path else None + + @property + def offline(self) -> bool: + return self.http is None + + +# ─── Rungs and steps ───────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class Rung: + name: str # the fixture kind: "env:AWS_REGION", "assume-role", "imds", … + kind: str # RUNG_KINDS + source: str # human label + needs: str # "" (offline-usable) | "network" | "subprocess" + probe: Callable[["ChainContext"], tuple[str, str]] # → ("usable"|"configured"|"absent", detail) + acquire: Callable[["ChainContext"], CredentialValue | None] # online resolution + + def __post_init__(self) -> None: + if self.kind not in RUNG_KINDS: + raise ValueError(f"unknown rung kind {self.kind!r}") + + +@dataclass(frozen=True, slots=True) +class Step: + kind: str + source: str + detail: str + state: str # selected | shadowed | absent | unprobed + + +@dataclass(frozen=True, slots=True) +class CredentialSource: + """Where a resolved credential came from (AUTH-1 provenance). Never + the value: the rung's fixture kind, its human label, the name that + selected it (``platform`` …) when one did, and the expiry if known.""" + + rung: str + label: str + named: str | None = None + expires_at: datetime | None = None + + def describe(self, now: datetime | None = None) -> str: + text = self.label + if self.named: + text += f' (named credential "{self.named}")' + if self.expires_at is not None: + current = now if now is not None else datetime.now(timezone.utc) + left = int((self.expires_at - current).total_seconds()) + if left <= 0: + text += ", expired" + elif left < 3600: + text += f", expires in {max(left // 60, 1)} min" + else: + text += f", expires in {left // 3600} h {(left % 3600) // 60} min" + return text + + +# ─── Helpers ───────────────────────────────────────────────────────── + + +def _expires_from(now: datetime, seconds: Any) -> datetime | None: + try: + return now + timedelta(seconds=int(float(seconds))) + except (TypeError, ValueError, OverflowError): + return None + + +def _json_body(body: bytes) -> dict: + try: + data = json.loads(body.decode("utf-8", "replace")) + except ValueError: + return {} + return data if isinstance(data, dict) else {} + + +def _form(pairs: list[tuple[str, str]]) -> bytes: + return urllib.parse.urlencode(pairs).encode("ascii") + + +def _exchange(ctx: ChainContext, method: str, url: str, headers: Mapping[str, str], body: bytes | None, what: str) -> dict: + assert ctx.http is not None + status, _, raw = ctx.http(method, url, headers, body, 30.0) + data = _json_body(raw) + if not 200 <= status < 300: + raise AuthError(f"{what}: HTTP {status}") + return data + + +def _bearer_from_oauth(data: dict, now: datetime, what: str) -> BearerToken: + token = data.get("access_token") + if not isinstance(token, str) or not token: + raise AuthError(f"{what}: no valid access_token in response") + expires = None + if data.get("expires_on") not in (None, ""): + try: + expires = datetime.fromtimestamp(int(float(data["expires_on"])), tz=timezone.utc) + except (TypeError, ValueError, OverflowError, OSError): + expires = None + if expires is None and data.get("expires_in") not in (None, ""): + expires = _expires_from(now, data["expires_in"]) + return BearerToken(token, expires_at=expires) + + +# ─── AWS ───────────────────────────────────────────────────────────── + + +def _aws_config(ctx: ChainContext) -> tuple[configparser.ConfigParser, configparser.ConfigParser, str]: + profile = ctx.env.get("AWS_PROFILE") or "default" + creds = configparser.ConfigParser(interpolation=None) + conf = configparser.ConfigParser(interpolation=None) + text = ctx.read(ctx.env.get("AWS_SHARED_CREDENTIALS_FILE") or "~/.aws/credentials") + try: + if text: + creds.read_string(text) + text = ctx.read(ctx.env.get("AWS_CONFIG_FILE") or "~/.aws/config") + if text: + conf.read_string(text) + except configparser.Error: + raise NotConfiguredError("malformed AWS profile configuration; check the AWS config and credentials files") from None + return creds, conf, profile + + +def _aws_profile_section(conf: configparser.ConfigParser, profile: str) -> Mapping[str, str]: + name = profile if profile == "default" else f"profile {profile}" + if conf.has_section(name): + return conf[name] + if conf.has_section(profile): + return conf[profile] + return {} + + +def _aws_static(section: Mapping[str, str], expiry: str | None = None) -> AwsCredentials | None: + key, secret = section.get("aws_access_key_id"), section.get("aws_secret_access_key") + if not key or not secret: + return None + return AwsCredentials(key, secret, session_token=section.get("aws_session_token") or None) + + +def _aws_from_response(d: Mapping[str, Any]) -> AwsCredentials: + expires = None + raw = d.get("Expiration") or d.get("expiration") + if isinstance(raw, str): + expires = parse_rfc3339(raw) + elif isinstance(raw, (int, float)): + expires = datetime.fromtimestamp(raw / (1000 if raw > 10**11 else 1), tz=timezone.utc) + key = d.get("AccessKeyId") or d.get("accessKeyId") + secret = d.get("SecretAccessKey") or d.get("secretAccessKey") + if not isinstance(key, str) or not key or not isinstance(secret, str) or not secret: + raise AuthError("AWS credential response lacks access key id or secret access key") + return AwsCredentials( + key, + secret, + session_token=(d.get("SessionToken") or d.get("Token") or d.get("sessionToken")) or None, + expires_at=expires, + ) + + +def _sts_xml_credentials(raw: bytes) -> AwsCredentials: + root = ElementTree.fromstring(raw) + ns = {"sts": "https://sts.amazonaws.com/doc/2011-06-15/"} + node = root.find(".//sts:Credentials", ns) + if node is None: + raise AuthError("STS: no Credentials in response") + get = lambda tag: (node.findtext(f"sts:{tag}", namespaces=ns) or "") # noqa: E731 + return AwsCredentials(get("AccessKeyId"), get("SecretAccessKey"), session_token=get("SessionToken") or None, + expires_at=parse_rfc3339(get("Expiration")) if get("Expiration") else None) + + +def _aws_source_credentials(ctx: ChainContext, section: Mapping[str, str], depth: int = 0) -> AwsCredentials: + """Source credentials for assume-role: source_profile (recursive) or credential_source.""" + if depth > 5: + raise AuthError("assume-role: source_profile chain too deep") + source_profile = section.get("source_profile") + if source_profile: + creds, conf, _ = _aws_config(ctx) + sub = dict(_aws_profile_section(conf, source_profile)) + if creds.has_section(source_profile): + sub.update(creds[source_profile]) + if sub.get("role_arn"): + return _assume_role(ctx, sub, depth + 1) + static = _aws_static(sub) + if static is None: + raise NotConfiguredError(f"assume-role: source_profile {source_profile!r} has no keys") + return static + source = section.get("credential_source") + if source == "Environment": + static = _env_aws(ctx) + if static is None: + raise NotConfiguredError("assume-role: credential_source=Environment but AWS_ACCESS_KEY_ID is not set") + return static + if source == "EcsContainer": + got = _container_acquire(ctx) + if got is None: + raise NotConfiguredError("assume-role: credential_source=EcsContainer but no container endpoint is configured") + return got + if source == "Ec2InstanceMetadata": + got = _imds_acquire(ctx) + if got is None: + raise NotConfiguredError("assume-role: credential_source=Ec2InstanceMetadata but IMDS answered nothing") + return got + raise NotConfiguredError("assume-role: profile needs source_profile or credential_source") + + +def _assume_role(ctx: ChainContext, section: Mapping[str, str], depth: int = 0) -> AwsCredentials: + source = _aws_source_credentials(ctx, section, depth) + region = ctx.settings.get("region") or ctx.env.get("AWS_REGION") or ctx.env.get("AWS_DEFAULT_REGION") or section.get("region") or "us-east-1" + url = f"https://sts.{region}.amazonaws.com/" + pairs = [("Action", "AssumeRole"), ("Version", "2011-06-15"), ("RoleArn", section["role_arn"]), + ("RoleSessionName", section.get("role_session_name") or f"lm15-{uuid.uuid4().hex[:12]}")] + if section.get("external_id"): + pairs.append(("ExternalId", section["external_id"])) + if section.get("duration_seconds"): + pairs.append(("DurationSeconds", section["duration_seconds"])) + body = _form(pairs) + headers = {"content-type": "application/x-www-form-urlencoded"} + signed = sigv4.sign(method="POST", url=url, headers=headers, payload=body, credentials=source, region=region, service="sts", now=ctx.now()) + assert ctx.http is not None + status, _, raw = ctx.http("POST", url, signed.headers, body, 30.0) + if status >= 400: + raise AuthError(f"STS AssumeRole: HTTP {status}") + return _sts_xml_credentials(raw) + + +def _env_aws(ctx: ChainContext) -> AwsCredentials | None: + key, secret = ctx.env.get("AWS_ACCESS_KEY_ID"), ctx.env.get("AWS_SECRET_ACCESS_KEY") + if key and secret: + return AwsCredentials(key, secret, session_token=ctx.env.get("AWS_SESSION_TOKEN") or None) + return None + + +def _web_identity_config(ctx: ChainContext) -> tuple[str, str, str] | None: + token_file, role = ctx.env.get("AWS_WEB_IDENTITY_TOKEN_FILE"), ctx.env.get("AWS_ROLE_ARN") + session = ctx.env.get("AWS_ROLE_SESSION_NAME") or "" + if not (token_file and role): + _, conf, profile = _aws_config(ctx) + section = _aws_profile_section(conf, profile) + token_file, role = section.get("web_identity_token_file"), section.get("role_arn") + session = section.get("role_session_name") or "" + if not (token_file and role and not section.get("source_profile") and not section.get("credential_source")): + return None + return token_file, role, session + + +def _web_identity_acquire(ctx: ChainContext) -> AwsCredentials | None: + cfg = _web_identity_config(ctx) + if cfg is None: + return None + token_file, role, session = cfg + token = ctx.read(token_file) + if token is None: + raise NotConfiguredError(f"web identity token file {token_file} is unreadable") + region = ctx.settings.get("region") or ctx.env.get("AWS_REGION") or ctx.env.get("AWS_DEFAULT_REGION") or "us-east-1" + body = _form([("Action", "AssumeRoleWithWebIdentity"), ("Version", "2011-06-15"), ("RoleArn", role), + ("RoleSessionName", session or f"lm15-{uuid.uuid4().hex[:12]}"), ("WebIdentityToken", token.strip())]) + assert ctx.http is not None + status, _, raw = ctx.http("POST", f"https://sts.{region}.amazonaws.com/", {"content-type": "application/x-www-form-urlencoded"}, body, 30.0) + if status >= 400: + raise AuthError(f"STS AssumeRoleWithWebIdentity: HTTP {status}") + return _sts_xml_credentials(raw) + + +def _sso_config(ctx: ChainContext) -> dict[str, str] | None: + _, conf, profile = _aws_config(ctx) + section = dict(_aws_profile_section(conf, profile)) + if section.get("sso_session"): + name = section["sso_session"] + sess = dict(conf[f"sso-session {name}"]) if conf.has_section(f"sso-session {name}") else {} + if not sess.get("sso_start_url"): + return None + cache_key = hashlib.sha1(name.encode("utf-8")).hexdigest() + return {**sess, **section, "cache_key": cache_key, "session_name": name} + if section.get("sso_start_url"): + cache_key = hashlib.sha1(section["sso_start_url"].encode("utf-8")).hexdigest() + return {**section, "cache_key": cache_key} + return None + + +def _sso_acquire(ctx: ChainContext) -> AwsCredentials | None: + cfg = _sso_config(ctx) + if cfg is None: + return None + raw = ctx.read(f"~/.aws/sso/cache/{cfg['cache_key']}.json") + if raw is None: + raise NotConfiguredError("IAM Identity Center: no cached token; run `aws sso login`", credential_hint="aws sso login") + token = json.loads(raw) + now = ctx.now() + expires = parse_rfc3339(token["expiresAt"]) if token.get("expiresAt") else None + access = token.get("accessToken") + sso_region = cfg.get("sso_region") or "us-east-1" + assert ctx.http is not None + if not access or (expires is not None and expires - now <= _SKEW): + if not (token.get("refreshToken") and token.get("clientId") and token.get("clientSecret")): + raise NotConfiguredError("IAM Identity Center: token expired and not refreshable; run `aws sso login`", credential_hint="aws sso login") + data = _exchange(ctx, "POST", f"https://oidc.{sso_region}.amazonaws.com/token", {"content-type": "application/json"}, + json.dumps({"clientId": token["clientId"], "clientSecret": token["clientSecret"], + "grantType": "refresh_token", "refreshToken": token["refreshToken"]}).encode(), "sso-oidc CreateToken") + access = data.get("accessToken") + if not access: + raise AuthError("sso-oidc CreateToken: no accessToken") + account, role = cfg.get("sso_account_id"), cfg.get("sso_role_name") + if not (account and role): + raise NotConfiguredError("IAM Identity Center: profile needs sso_account_id and sso_role_name") + query = urllib.parse.urlencode({"role_name": role, "account_id": account}) + status, _, raw_creds = ctx.http("GET", f"https://portal.sso.{sso_region}.amazonaws.com/federation/credentials?{query}", + {"x-amz-sso_bearer_token": str(access)}, None, 30.0) + if status >= 400: + raise AuthError(f"sso GetRoleCredentials: HTTP {status}") + creds = _json_body(raw_creds).get("roleCredentials") or {} + return _aws_from_response(creds) + + +def _login_config(ctx: ChainContext) -> str | None: + _, conf, profile = _aws_config(ctx) + return _aws_profile_section(conf, profile).get("login_session") or None + + +def _login_cached(ctx: ChainContext) -> AwsCredentials | None: + session = _login_config(ctx) + if not session: + return None + directory = ctx.env.get("AWS_LOGIN_CACHE_DIRECTORY") or "~/.aws/login/cache" + raw = ctx.read(f"{directory}/{hashlib.sha256(session.encode('utf-8')).hexdigest()}.json") + if raw is None: + return None + token = json.loads(raw).get("accessToken") or {} + if not token.get("accessKeyId"): + return None + return _aws_from_response({"AccessKeyId": token["accessKeyId"], "SecretAccessKey": token.get("secretAccessKey"), + "SessionToken": token.get("sessionToken"), "Expiration": token.get("expiresAt")}) + + +def _login_acquire(ctx: ChainContext) -> AwsCredentials | None: + if _login_config(ctx) is None: + return None + cached = _login_cached(ctx) + if cached is not None and not cached.is_expired(ctx.now()): + return cached + # Refresh needs the signin CreateOAuth2Token call with a DPoP proof over + # the cached EC key (botocore credentials.py LoginCredentialFetcher): + # a stated gap; the hint names the command. + raise NotConfiguredError("AWS login session expired; run `aws login`", credential_hint="aws login") + + +def _process_acquire(ctx: ChainContext) -> AwsCredentials | None: + _, conf, profile = _aws_config(ctx) + command = _aws_profile_section(conf, profile).get("credential_process") + if not command: + return None + if ctx.run is None: + return None + import shlex + + out = ctx.run(shlex.split(command), 60.0) + data = _json_body(out.encode()) + if data.get("Version") != 1: + raise AuthError("credential_process: output Version must be 1") + return _aws_from_response(data) + + +_CONTAINER_ALLOWED = {"169.254.170.2", "169.254.170.23", "fd00:ec2::23", "localhost"} + + +def _container_config(ctx: ChainContext) -> str | None: + rel, full = ctx.env.get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"), ctx.env.get("AWS_CONTAINER_CREDENTIALS_FULL_URI") + if rel: + if not rel.startswith("/") or rel.startswith("//") or any(c in rel for c in "\\\r\n\t#"): + raise NotConfiguredError("container credentials relative URI must be an absolute path") + return f"http://169.254.170.2{rel}" + if full: + import ipaddress + + parsed = urllib.parse.urlsplit(full) + host = parsed.hostname or "" + if parsed.scheme not in ("http", "https") or not host or parsed.username is not None or parsed.fragment: + raise NotConfiguredError("container credentials URI must be HTTP(S), without userinfo or fragment") + loopback = False + try: + loopback = ipaddress.ip_address(host).is_loopback + except ValueError: + pass + if parsed.scheme != "https" and not loopback and host not in _CONTAINER_ALLOWED: + raise NotConfiguredError( + f"Unsupported host {host!r}. Can only retrieve metadata from a loopback address or one of these hosts: " + + ", ".join(sorted(_CONTAINER_ALLOWED)) + ) + return full + return None + + +def _container_acquire(ctx: ChainContext) -> AwsCredentials | None: + url = _container_config(ctx) + if url is None or ctx.http is None: + return None + headers: dict[str, str] = {} + token = ctx.env.get("AWS_CONTAINER_AUTHORIZATION_TOKEN") + token_file = ctx.env.get("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE") + if token_file and not token: + token = (ctx.read(token_file) or "").strip() + if token: + headers["Authorization"] = token + status, _, raw = ctx.http("GET", url, headers, None, 5.0) + if status >= 400: + raise AuthError(f"container credentials: HTTP {status}") + return _aws_from_response(_json_body(raw)) + + +def _imds_disabled(ctx: ChainContext) -> bool: + return (ctx.env.get("AWS_EC2_METADATA_DISABLED") or "").strip().lower() == "true" + + +def _imds_acquire(ctx: ChainContext) -> AwsCredentials | None: + if _imds_disabled(ctx) or ctx.http is None: + return None + base = ctx.env.get("AWS_EC2_METADATA_SERVICE_ENDPOINT") or ( + "http://[fd00:ec2::254]" if (ctx.env.get("AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE") or "").lower() == "ipv6" else "http://169.254.169.254" + ) + base = base.rstrip("/") + try: + status, _, tok = ctx.http("PUT", f"{base}/latest/api/token", {"X-aws-ec2-metadata-token-ttl-seconds": "21600"}, None, 1.0) + except Exception: # noqa: BLE001 - not on EC2: the rung is absent, not an error + return None + if status != 200: + return None + headers = {"X-aws-ec2-metadata-token": tok.decode("utf-8", "replace")} + status, _, role = ctx.http("GET", f"{base}/latest/meta-data/iam/security-credentials/", headers, None, 1.0) + if status != 200 or not role.strip(): + return None + status, _, raw = ctx.http("GET", f"{base}/latest/meta-data/iam/security-credentials/{role.decode().strip().splitlines()[0]}", headers, None, 1.0) + if status != 200: + return None + data = _json_body(raw) + if data.get("Code") not in (None, "Success"): + raise AuthError("IMDS rejected the credential request") + return _aws_from_response(data) + + +def _aws_chain(policy: AccessPolicy) -> list[Rung]: + door_key = policy.env_keys[0] if policy.env_keys else None + + def env_probe(var: str): + return lambda ctx: ("usable", "set (value never shown)") if ctx.env.get(var) else ("absent", "not set") + + def static_keys_probe(ctx: ChainContext): + return ("usable", "set (values never shown)") if _env_aws(ctx) else ("absent", "AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY not set") + + def assume_role_probe(ctx: ChainContext): + _, conf, profile = _aws_config(ctx) + section = _aws_profile_section(conf, profile) + if section.get("role_arn") and (section.get("source_profile") or section.get("credential_source")): + return ("configured", f"profile {profile!r} assumes {section['role_arn']} (STS call at request time)") + return ("absent", f"profile {profile!r} has no role_arn with a source") + + def assume_role_acquire(ctx: ChainContext): + _, conf, profile = _aws_config(ctx) + section = _aws_profile_section(conf, profile) + if section.get("role_arn") and (section.get("source_profile") or section.get("credential_source")): + return _assume_role(ctx, section) + return None + + def web_identity_probe(ctx: ChainContext): + cfg = _web_identity_config(ctx) + return ("configured", f"token file {cfg[0]} → {cfg[1]} (STS call at request time)") if cfg else ("absent", "AWS_WEB_IDENTITY_TOKEN_FILE / AWS_ROLE_ARN not set") + + def sso_probe(ctx: ChainContext): + cfg = _sso_config(ctx) + if cfg is None: + return ("absent", "no sso_session / sso_start_url in the profile") + cached = ctx.exists(f"~/.aws/sso/cache/{cfg['cache_key']}.json") + return ("configured", "cached token present (GetRoleCredentials at request time)" if cached else "no cached token; run `aws sso login`") + + def shared_file_probe(ctx: ChainContext): + creds, _, profile = _aws_config(ctx) + section = creds[profile] if creds.has_section(profile) else {} + return ("usable", f"profile {profile!r} (values never shown)") if _aws_static(section) else ("absent", f"no keys for profile {profile!r}") + + def shared_file_acquire(ctx: ChainContext): + creds, _, profile = _aws_config(ctx) + return _aws_static(creds[profile]) if creds.has_section(profile) else None + + def login_probe(ctx: ChainContext): + if _login_config(ctx) is None: + return ("absent", "no login_session in the profile") + cached = _login_cached(ctx) + if cached is not None and not cached.is_expired(ctx.now()): + return ("usable", "cached short-term credentials are fresh") + return ("configured", "cached credentials missing or expired; refresh needs `aws login`") + + def process_probe(ctx: ChainContext): + _, conf, profile = _aws_config(ctx) + cmd = _aws_profile_section(conf, profile).get("credential_process") + if not cmd: + return ("absent", "no credential_process in the profile") + import shlex + + executable = shlex.split(cmd)[0] + if ctx.on_path(executable) is None: + return ("absent", f"credential_process {executable!r} is not on PATH") + return ("configured", "credential_process configured (run at request time)") + + def config_file_probe(ctx: ChainContext): + _, conf, profile = _aws_config(ctx) + return ("usable", f"static keys in config for profile {profile!r}") if _aws_static(_aws_profile_section(conf, profile)) else ("absent", "no static keys in config") + + def config_file_acquire(ctx: ChainContext): + _, conf, profile = _aws_config(ctx) + return _aws_static(_aws_profile_section(conf, profile)) + + def container_probe(ctx: ChainContext): + try: + url = _container_config(ctx) + except NotConfiguredError as exc: + return ("absent", str(exc).splitlines()[0]) + return ("configured", "container endpoint configured (HTTP at request time)") if url else ("absent", "no AWS_CONTAINER_CREDENTIALS_* URI") + + def imds_probe(ctx: ChainContext): + return ("absent", "AWS_EC2_METADATA_DISABLED=true") if _imds_disabled(ctx) else ("configured", "instance metadata probed at request time") + + rungs: list[Rung] = [] + if door_key: + kind_cred = BearerToken if door_key == "AWS_BEARER_TOKEN_BEDROCK" else ApiKey + rungs.append(Rung(f"env:{door_key}", "env", f"env ${door_key}", "", env_probe(door_key), + lambda ctx, k=door_key, c=kind_cred: c(ctx.env[k]) if ctx.env.get(k) else None)) + rungs += [ + Rung("env:AWS_ACCESS_KEY_ID", "env", "env $AWS_ACCESS_KEY_ID (+SECRET, +SESSION_TOKEN)", "", static_keys_probe, _env_aws), + Rung("assume-role", "sigv4-sts", "profile assume-role via STS", "network", assume_role_probe, assume_role_acquire), + Rung("web-identity", "unsigned-sts", "web identity via STS", "network", web_identity_probe, _web_identity_acquire), + Rung("sso", "file-cache", "IAM Identity Center (~/.aws/sso/cache)", "network", sso_probe, _sso_acquire), + Rung("shared-credentials-file", "ini-profile", "~/.aws/credentials", "", shared_file_probe, shared_file_acquire), + Rung("login", "file-cache", "aws login session (~/.aws/login/cache)", "", login_probe, _login_acquire), + Rung("credential_process", "subprocess", "profile credential_process", "subprocess", process_probe, _process_acquire), + Rung("config-file", "ini-profile", "~/.aws/config static keys", "", config_file_probe, config_file_acquire), + Rung("container", "http-metadata", "container credentials endpoint", "network", container_probe, _container_acquire), + Rung("imds", "http-metadata", "EC2 instance metadata (IMDSv2)", "network", imds_probe, _imds_acquire), + ] + return rungs + + +# ─── Azure ─────────────────────────────────────────────────────────── + + +def _azure_authority(ctx: ChainContext) -> str: + return (ctx.settings.get("authority_host") or ctx.env.get("AZURE_AUTHORITY_HOST") or "https://login.microsoftonline.com").rstrip("/") + + +def _azure_scope(ctx: ChainContext) -> str: + return ctx.settings.get("scope") or "https://ai.azure.com/.default" + + +def _azure_token_url(ctx: ChainContext, tenant: str) -> str: + return f"{_azure_authority(ctx)}/{tenant}/oauth2/v2.0/token" + + +def azure_certificate_assertion(ctx: ChainContext, tenant: str, client_id: str, pem: str, *, jti: str | None = None, + send_chain: bool = False) -> str: + """The Entra client assertion: RS256 (as azure-identity signs it), ``x5t`` + = base64url SHA-1 of the DER certificate, claims in MSAL's order + (assertion.py:99-118), 600 s lifetime.""" + key = rs256.load_private_key(pem) + der = rs256.certificate_der(pem) + now = int(ctx.now().timestamp()) + header: dict[str, Any] = {"alg": "RS256", "typ": "JWT", "x5t": rs256.b64url(hashlib.sha1(der).digest())} + if send_chain: + header["x5c"] = [base64.b64encode(der).decode("ascii")] + payload = {"aud": _azure_token_url(ctx, tenant), "iss": client_id, "sub": client_id, + "exp": now + 600, "iat": now, "jti": jti or str(uuid.uuid4())} + return rs256.jwt_encode(header, payload, key) + + +def _azure_environment_kind(ctx: ChainContext) -> str | None: + e = ctx.env + if not (e.get("AZURE_TENANT_ID") and e.get("AZURE_CLIENT_ID")): + return None + if e.get("AZURE_CLIENT_SECRET"): + return "secret" + if e.get("AZURE_CLIENT_CERTIFICATE_PATH"): + return "certificate" + return None + + +def azure_environment_request(ctx: ChainContext, *, jti: str | None = None) -> tuple[str, list[tuple[str, str]]]: + """(token URL, form pairs) for the environment service principal.""" + e = ctx.env + kind = _azure_environment_kind(ctx) + tenant, client = e["AZURE_TENANT_ID"], e["AZURE_CLIENT_ID"] + url = _azure_token_url(ctx, tenant) + scope = _azure_scope(ctx) + if kind == "secret": + return url, [("client_id", client), ("scope", scope), ("client_secret", e["AZURE_CLIENT_SECRET"]), ("grant_type", "client_credentials")] + if kind == "certificate": + pem = ctx.read(e["AZURE_CLIENT_CERTIFICATE_PATH"]) + if pem is None: + raise NotConfiguredError(f"AZURE_CLIENT_CERTIFICATE_PATH {e['AZURE_CLIENT_CERTIFICATE_PATH']} is unreadable") + if e.get("AZURE_CLIENT_CERTIFICATE_PASSWORD"): + raise NotConfiguredError("password-protected certificates are not supported; decrypt with `openssl pkey`", + credential_hint="openssl pkey -in cert.pem -out cert-plain.pem") + send_chain = (e.get("AZURE_CLIENT_SEND_CERTIFICATE_CHAIN") or "").lower() in ("1", "true") + assertion = azure_certificate_assertion(ctx, tenant, client, pem, jti=jti, send_chain=send_chain) + return url, [("client_id", client), ("scope", scope), ("client_assertion_type", _CLIENT_ASSERTION_TYPE), + ("client_assertion", assertion), ("grant_type", "client_credentials")] + raise NotConfiguredError("Azure environment credential needs AZURE_CLIENT_SECRET or AZURE_CLIENT_CERTIFICATE_PATH") + + +def _azure_environment_acquire(ctx: ChainContext) -> BearerToken | None: + if _azure_environment_kind(ctx) is None: + return None + url, pairs = azure_environment_request(ctx) + data = _exchange(ctx, "POST", url, {"content-type": "application/x-www-form-urlencoded"}, _form(pairs), "Entra client credentials") + return _bearer_from_oauth(data, ctx.now(), "Entra") + + +def _azure_workload_config(ctx: ChainContext) -> bool: + e = ctx.env + return bool(e.get("AZURE_FEDERATED_TOKEN_FILE") and e.get("AZURE_CLIENT_ID") and e.get("AZURE_TENANT_ID")) + + +def _azure_workload_acquire(ctx: ChainContext) -> BearerToken | None: + if not _azure_workload_config(ctx): + return None + e = ctx.env + token = ctx.read(e["AZURE_FEDERATED_TOKEN_FILE"]) + if token is None: + raise NotConfiguredError(f"AZURE_FEDERATED_TOKEN_FILE {e['AZURE_FEDERATED_TOKEN_FILE']} is unreadable") + pairs = [("client_id", e["AZURE_CLIENT_ID"]), ("scope", _azure_scope(ctx)), ("client_assertion_type", _CLIENT_ASSERTION_TYPE), + ("client_assertion", token.strip()), ("grant_type", "client_credentials")] + data = _exchange(ctx, "POST", _azure_token_url(ctx, e["AZURE_TENANT_ID"]), {"content-type": "application/x-www-form-urlencoded"}, _form(pairs), "Entra workload identity") + return _bearer_from_oauth(data, ctx.now(), "Entra") + + +def _azure_msi_flavor(ctx: ChainContext) -> str: + e = ctx.env + if e.get("IDENTITY_ENDPOINT"): + if e.get("IDENTITY_HEADER"): + return "service-fabric" if e.get("IDENTITY_SERVER_THUMBPRINT") else "app-service" + if e.get("IMDS_ENDPOINT"): + return "azure-arc" + if e.get("MSI_ENDPOINT"): + return "azure-ml" if e.get("MSI_SECRET") else "cloud-shell" + return "imds" + + +def _azure_msi_acquire(ctx: ChainContext) -> BearerToken | None: + if ctx.http is None: + return None + e = ctx.env + flavor = _azure_msi_flavor(ctx) + resource = _azure_scope(ctx).removesuffix("/.default") + client_id = e.get("AZURE_CLIENT_ID") + now = ctx.now() + if flavor == "imds": + query = {"api-version": "2018-02-01", "resource": resource} + if client_id: + query["client_id"] = client_id + try: + status, _, raw = ctx.http("GET", "http://169.254.169.254/metadata/identity/oauth2/token?" + urllib.parse.urlencode(query), {"Metadata": "true"}, None, 1.0) + except Exception: # noqa: BLE001 - not on Azure: absent + return None + if status != 200: + return None + return _bearer_from_oauth(_json_body(raw), now, "managed identity") + if flavor == "app-service": + query = {"api-version": "2019-08-01", "resource": resource} + if client_id: + query["client_id"] = client_id + data = _exchange(ctx, "GET", e["IDENTITY_ENDPOINT"] + "?" + urllib.parse.urlencode(query), {"X-IDENTITY-HEADER": e["IDENTITY_HEADER"]}, None, "App Service managed identity") + return _bearer_from_oauth(data, now, "managed identity") + if flavor == "cloud-shell": + data = _exchange(ctx, "POST", e["MSI_ENDPOINT"], {"Metadata": "true", "content-type": "application/x-www-form-urlencoded"}, _form([("resource", resource)]), "Cloud Shell managed identity") + return _bearer_from_oauth(data, now, "managed identity") + if flavor == "azure-ml": + query = {"api-version": "2017-09-01", "resource": resource} + if client_id: + query["clientid"] = client_id + data = _exchange(ctx, "GET", e["MSI_ENDPOINT"] + "?" + urllib.parse.urlencode(query), {"secret": e["MSI_SECRET"]}, None, "Azure ML managed identity") + return _bearer_from_oauth(data, now, "managed identity") + if flavor == "azure-arc": + url = e["IDENTITY_ENDPOINT"] + "?" + urllib.parse.urlencode({"api-version": "2019-11-01", "resource": resource}) + status, headers, _ = ctx.http("GET", url, {"Metadata": "true"}, None, 5.0) + challenge = headers.get("www-authenticate", "") + if status != 401 or "realm=" not in challenge: + raise AuthError(f"Azure Arc managed identity: expected a 401 challenge, got {status}") + key_path = challenge.split("realm=", 1)[1].strip().strip('"') + directory = (Path(ctx.env.get("PROGRAMDATA", "C:/ProgramData")) / "AzureConnectedMachineAgent" / "Tokens" + if os.name == "nt" else Path("/var/opt/azcmagent/tokens")) + path = Path(key_path) + if path.parent != directory or path.suffix != ".key": + raise AuthError("Azure Arc managed identity: invalid challenge file location") + if ctx.files is None and path.resolve().parent != directory.resolve(): + raise AuthError("Azure Arc managed identity: invalid challenge file location") + secret = ctx.read(key_path) + if secret is None or len(secret) > 4096: + raise AuthError("Azure Arc managed identity: challenge file missing or too large") + data = _exchange(ctx, "GET", url, {"Metadata": "true", "Authorization": f"Basic {secret.strip()}"}, None, "Azure Arc managed identity") + return _bearer_from_oauth(data, now, "managed identity") + raise NotConfiguredError("Service Fabric managed identity (TLS thumbprint pinning) is not supported; use a certificate or secret") + + +def _az_cli_acquire(ctx: ChainContext) -> BearerToken | None: + if ctx.run is None or ctx.on_path("az") is None: + return None + argv = ["az", "account", "get-access-token", "--output", "json", "--scope", _azure_scope(ctx)] + tenant = ctx.env.get("AZURE_TENANT_ID") + if tenant: + argv += ["--tenant", tenant] + data = _json_body(ctx.run(argv, 30.0).encode()) + token = data.get("accessToken") + if token is None: + return None + parsed = _bearer_from_oauth({"access_token": token, "expires_on": data.get("expires_on")}, ctx.now(), "Azure CLI") + expires = parsed.expires_at + if expires is None and data.get("expiresOn"): + try: + expires = datetime.fromisoformat(str(data["expiresOn"])).astimezone(timezone.utc) + except (ValueError, OverflowError, OSError): + expires = None + return BearerToken(parsed.value, expires_at=expires) + + +def _pwsh_acquire(ctx: ChainContext) -> BearerToken | None: + if ctx.run is None or ctx.on_path("pwsh") is None: + return None + resource = _azure_scope(ctx).removesuffix("/.default").replace("'", "''") + script = f"Get-AzAccessToken -ResourceUrl '{resource}' -AsSecureString:$false | ConvertTo-Json -Compress" + data = _json_body(ctx.run(["pwsh", "-NoProfile", "-NonInteractive", "-Command", script], 30.0).encode()) + token = data.get("Token") + return _bearer_from_oauth({"access_token": token}, ctx.now(), "Azure PowerShell") if token is not None else None + + +def _azd_acquire(ctx: ChainContext) -> BearerToken | None: + if ctx.run is None or ctx.on_path("azd") is None: + return None + data = _json_body(ctx.run(["azd", "auth", "token", "--output", "json", "--scope", _azure_scope(ctx)], 30.0).encode()) + token = data.get("token") + if token is None: + return None + parsed = _bearer_from_oauth({"access_token": token}, ctx.now(), "Azure Developer CLI") + expires = None + if data.get("expiresOn"): + try: + expires = parse_rfc3339(str(data["expiresOn"])) + except ValueError: + expires = None + return BearerToken(parsed.value, expires_at=expires) + + +def _azure_chain(policy: AccessPolicy) -> list[Rung]: + door_key = policy.env_keys[0] if policy.env_keys else None + + def narrowed(ctx: ChainContext, name: str, developer: bool) -> bool: + """AZURE_TOKEN_CREDENTIALS=prod|dev| narrowing.""" + value = (ctx.env.get("AZURE_TOKEN_CREDENTIALS") or "").strip().lower() + if not value: + return False + if value == "prod": + return developer + if value == "dev": + return not developer + return value != name.lower() + + def env_probe(ctx: ChainContext): + if narrowed(ctx, "EnvironmentCredential", False): + return ("absent", "excluded by AZURE_TOKEN_CREDENTIALS") + kind = _azure_environment_kind(ctx) + return ("configured", f"service principal by {kind} (token exchange at request time)") if kind else ("absent", "AZURE_TENANT_ID/AZURE_CLIENT_ID + secret or certificate not set") + + def workload_probe(ctx: ChainContext): + if narrowed(ctx, "WorkloadIdentityCredential", False): + return ("absent", "excluded by AZURE_TOKEN_CREDENTIALS") + return ("configured", "federated token file present (exchange at request time)") if _azure_workload_config(ctx) else ("absent", "AZURE_FEDERATED_TOKEN_FILE not set") + + def msi_probe(ctx: ChainContext): + if narrowed(ctx, "ManagedIdentityCredential", False): + return ("absent", "excluded by AZURE_TOKEN_CREDENTIALS") + return ("configured", f"managed identity ({_azure_msi_flavor(ctx)}) probed at request time") + + def cli_probe(name: str, label: str, command: str): + def probe(ctx: ChainContext): + if narrowed(ctx, name, True): + return ("absent", "excluded by AZURE_TOKEN_CREDENTIALS") + if ctx.on_path(command) is None: + return ("absent", f"{command} is not on PATH") + return ("configured", f"{label} run at request time") + return probe + + def guard(fn, name: str, developer: bool): + return lambda ctx: None if narrowed(ctx, name, developer) else fn(ctx) + + rungs: list[Rung] = [] + if door_key: + rungs.append(Rung(f"env:{door_key}", "env", f"env ${door_key}", "", + lambda ctx, k=door_key: ("usable", "set (value never shown)") if ctx.env.get(k) else ("absent", "not set"), + lambda ctx, k=door_key: ApiKey(ctx.env[k]) if ctx.env.get(k) else None)) + rungs += [ + Rung("environment", "http-token-exchange", "Entra service principal from AZURE_* env", "network", env_probe, guard(_azure_environment_acquire, "EnvironmentCredential", False)), + Rung("workload-identity", "http-token-exchange", "Entra workload identity", "network", workload_probe, guard(_azure_workload_acquire, "WorkloadIdentityCredential", False)), + Rung("managed-identity", "http-metadata", "Azure managed identity", "network", msi_probe, guard(_azure_msi_acquire, "ManagedIdentityCredential", False)), + Rung("az", "subprocess", "az account get-access-token", "subprocess", cli_probe("AzureCliCredential", "`az`", "az"), guard(_az_cli_acquire, "AzureCliCredential", True)), + Rung("pwsh", "subprocess", "Azure PowerShell Get-AzAccessToken", "subprocess", cli_probe("AzurePowerShellCredential", "`pwsh`", "pwsh"), guard(_pwsh_acquire, "AzurePowerShellCredential", True)), + Rung("azd", "subprocess", "azd auth token", "subprocess", cli_probe("AzureDeveloperCliCredential", "`azd`", "azd"), guard(_azd_acquire, "AzureDeveloperCliCredential", True)), + ] + return rungs + + +# ─── Google Cloud ──────────────────────────────────────────────────── + + +def gcp_service_account_assertion(ctx: ChainContext, info: Mapping[str, Any], scope: str = _GCP_SCOPE) -> tuple[str, str]: + """(token_uri, JWT) for a ``service_account`` file: header typ/alg/kid, + claims iat, exp=iat+3600, iss, aud, scope (google-auth + service_account.py:393-420, jwt.py:75-107).""" + key = rs256.load_private_key(str(info["private_key"])) + now = int(ctx.now().timestamp()) + token_uri = str(info.get("token_uri") or _GCP_TOKEN_URL) + header: dict[str, Any] = {"alg": "RS256", "typ": "JWT"} + if info.get("private_key_id"): + header["kid"] = str(info["private_key_id"]) + payload = {"iat": now, "exp": now + 3600, "iss": str(info["client_email"]), "aud": token_uri, "scope": scope} + return token_uri, rs256.jwt_encode(header, payload, key) + + +def _gcp_credential_file(ctx: ChainContext, path: str) -> dict | None: + raw = ctx.read(path) + if raw is None: + return None + try: + data = json.loads(raw) + except ValueError: + raise NotConfiguredError(f"{path}: not valid JSON") from None + return data if isinstance(data, dict) else None + + +def _gcp_from_info(ctx: ChainContext, info: Mapping[str, Any], where: str) -> BearerToken: + kind = info.get("type") + now = ctx.now() + if kind == "authorized_user": + for k in ("refresh_token", "client_id", "client_secret"): + if not info.get(k): + raise NotConfiguredError(f"{where}: authorized_user file lacks {k}") + pairs = [("grant_type", "refresh_token"), ("client_id", str(info["client_id"])), ("client_secret", str(info["client_secret"])), + ("refresh_token", str(info["refresh_token"]))] + data = _exchange(ctx, "POST", str(info.get("token_uri") or _GCP_TOKEN_URL), {"content-type": "application/x-www-form-urlencoded"}, _form(pairs), "Google OAuth refresh") + return _bearer_from_oauth(data, now, "Google OAuth") + if kind == "service_account": + token_uri, assertion = gcp_service_account_assertion(ctx, info) + data = _exchange(ctx, "POST", token_uri, {"content-type": "application/x-www-form-urlencoded"}, + _form([("grant_type", _JWT_BEARER), ("assertion", assertion)]), "Google service account") + return _bearer_from_oauth(data, now, "Google service account") + if kind == "external_account": + return _gcp_external_account(ctx, info, where) + if kind == "impersonated_service_account": + source = info.get("source_credentials") + if not isinstance(source, dict): + raise NotConfiguredError(f"{where}: impersonated_service_account lacks source_credentials") + base = _gcp_from_info(ctx, source, f"{where}.source_credentials") + return _gcp_impersonate(ctx, base, str(info["service_account_impersonation_url"]), info.get("delegates") or []) + raise NotConfiguredError(f"{where}: credential type {kind!r} is not supported by lm15 " + "(external_account_authorized_user and gdch_service_account are stated gaps)") + + +def _gcp_impersonate(ctx: ChainContext, source: BearerToken, url: str, delegates: list) -> BearerToken: + body = json.dumps({"delegates": list(delegates), "scope": [_GCP_SCOPE], "lifetime": "3600s"}).encode() + data = _exchange(ctx, "POST", url, {"content-type": "application/json", "authorization": f"Bearer {source.value}"}, body, "generateAccessToken") + token = data.get("accessToken") + if not token: + raise AuthError("generateAccessToken: no accessToken") + expires = parse_rfc3339(str(data["expireTime"])) if data.get("expireTime") else None + return BearerToken(str(token), expires_at=expires) + + +def _gcp_external_account(ctx: ChainContext, info: Mapping[str, Any], where: str) -> BearerToken: + source = info.get("credential_source") or {} + if "environment_id" in source: + raise NotConfiguredError(f"{where}: external_account with an AWS credential_source is a stated gap in lm15; " + "use a file/url/executable source or a service account") + subject: str | None = None + fmt = source.get("format") or {} + if source.get("file"): + raw = ctx.read(str(source["file"])) + if raw is None: + raise NotConfiguredError(f"{where}: subject token file {source['file']} is unreadable") + subject = raw.strip() + elif source.get("url"): + status, _, raw = ctx.http("GET", str(source["url"]), {str(k): str(v) for k, v in (source.get("headers") or {}).items()}, None, 30.0) # type: ignore[misc] + if status >= 400: + raise AuthError(f"{where}: subject token url HTTP {status}") + subject = raw.decode("utf-8", "replace").strip() + elif source.get("executable"): + if ctx.run is None: + raise NotConfiguredError(f"{where}: executable credential source needs subprocess access") + if (ctx.env.get("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES") or "") != "1": + raise NotConfiguredError(f"{where}: set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1 to allow the executable source") + import shlex + + out = ctx.run(shlex.split(str(source["executable"]["command"])), float(source["executable"].get("timeout_millis", 30000)) / 1000) + data = _json_body(out.encode()) + if not data.get("success", True): + raise AuthError("external account executable reported failure") + subject = data.get("id_token") or data.get("saml_response") + fmt = {"type": "text"} + if subject is None: + raise NotConfiguredError(f"{where}: external_account credential_source is not file/url/executable") + if fmt.get("type") == "json": + field_name = str(fmt.get("subject_token_field_name") or "") + subject = str(_json_body(subject.encode()).get(field_name) or "") + body = json.dumps({ + "grantType": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": str(info["audience"]), + "scope": _GCP_SCOPE, + "requestedTokenType": "urn:ietf:params:oauth:token-type:access_token", + "subjectToken": subject, + "subjectTokenType": str(info["subject_token_type"]), + }).encode() + data = _exchange(ctx, "POST", str(info.get("token_url") or _GCP_STS_URL), {"content-type": "application/json"}, body, "Google STS exchange") + token = _bearer_from_oauth(data, ctx.now(), "Google STS") + if info.get("service_account_impersonation_url"): + return _gcp_impersonate(ctx, token, str(info["service_account_impersonation_url"]), []) + return token + + +def _gcp_metadata_acquire(ctx: ChainContext) -> BearerToken | None: + if ctx.http is None or (ctx.env.get("NO_GCE_CHECK") or "").lower() in ("1", "true"): + return None + host = ctx.env.get("GCE_METADATA_HOST") or ctx.env.get("GCE_METADATA_ROOT") or "metadata.google.internal" + try: + status, _, raw = ctx.http("GET", f"http://{host}/computeMetadata/v1/instance/service-accounts/default/token", {"Metadata-Flavor": "Google"}, None, 1.0) + except Exception: # noqa: BLE001 - not on GCE: absent + return None + if status != 200: + return None + return _bearer_from_oauth(_json_body(raw), ctx.now(), "GCE metadata") + + +def _gcloud_acquire(ctx: ChainContext) -> BearerToken | None: + if ctx.run is None or ctx.on_path("gcloud") is None: + return None + token = ctx.run(["gcloud", "auth", "print-access-token"], 30.0).strip() + return BearerToken(token) if token else None + + +def _adc_file_path(ctx: ChainContext) -> str: + base = ctx.env.get("CLOUDSDK_CONFIG") or "~/.config/gcloud" + return f"{base.rstrip('/')}/application_default_credentials.json" + + +def _gcp_chain(policy: AccessPolicy) -> list[Rung]: + door_key = policy.env_keys[0] if policy.env_keys else None + + def file_probe(label: str, path_fn): + def probe(ctx: ChainContext): + path = path_fn(ctx) + if not path: + return ("absent", f"{label} not set") + info = _gcp_credential_file(ctx, path) + if info is None: + return ("absent", f"{path} missing or unreadable") + return ("configured", f"{info.get('type', '?')} credentials in {path} (token exchange at request time)") + return probe + + def file_acquire(path_fn): + def acquire(ctx: ChainContext): + path = path_fn(ctx) + if not path: + return None + info = _gcp_credential_file(ctx, path) + return _gcp_from_info(ctx, info, path) if info else None + return acquire + + env_path = lambda ctx: ctx.env.get("GOOGLE_APPLICATION_CREDENTIALS") # noqa: E731 + adc_path = lambda ctx: _adc_file_path(ctx) # noqa: E731 + + def metadata_probe(ctx: ChainContext): + if (ctx.env.get("NO_GCE_CHECK") or "").lower() in ("1", "true"): + return ("absent", "NO_GCE_CHECK set") + return ("configured", "GCE metadata server probed at request time") + + rungs: list[Rung] = [] + if door_key: + rungs.append(Rung(f"env:{door_key}", "env", f"env ${door_key}", "", + lambda ctx, k=door_key: ("usable", "set (value never shown)") if ctx.env.get(k) else ("absent", "not set"), + lambda ctx, k=door_key: ApiKey(ctx.env[k]) if ctx.env.get(k) else None)) + rungs += [ + Rung("adc-env", "json-file", "GOOGLE_APPLICATION_CREDENTIALS file", "network", file_probe("GOOGLE_APPLICATION_CREDENTIALS", env_path), file_acquire(env_path)), + Rung("adc-file", "json-file", "gcloud application default credentials file", "network", file_probe("ADC file", adc_path), file_acquire(adc_path)), + Rung("metadata", "http-metadata", "GCE metadata server", "network", metadata_probe, _gcp_metadata_acquire), + Rung("gcloud", "subprocess", "gcloud auth print-access-token", "subprocess", + lambda ctx: ("configured", "`gcloud` run at request time") if ctx.on_path("gcloud") else ("absent", "gcloud is not on PATH"), + _gcloud_acquire), + ] + return rungs + + +# ─── Settings from the cloud profile (AUTH-10 fallbacks after env) ──── + + +def profile_settings(policy: AccessPolicy, ctx: ChainContext) -> Callable[[str], str | None]: + """The setting values the cloud's own config files carry: AWS + ``region`` from the active profile (`~/.aws/config`, then + `~/.aws/credentials`); GCP ``project`` from the ADC file's + ``quota_project_id`` / ``project_id``. Nothing for Azure.""" + + def lookup(name: str) -> str | None: + if policy.credential_policy == "aws-chain" and name == "region": + creds, conf, profile = _aws_config(ctx) + value = _aws_profile_section(conf, profile).get("region") + if not value and creds.has_section(profile): + value = creds[profile].get("region") + return value or None + if policy.credential_policy == "gcp-chain" and name == "project": + for path in (ctx.env.get("GOOGLE_APPLICATION_CREDENTIALS"), _adc_file_path(ctx)): + if path: + info = _gcp_credential_file(ctx, path) or {} + value = info.get("quota_project_id") or info.get("project_id") + if value: + return str(value) + return None + + return lookup + + +# ─── Chains ────────────────────────────────────────────────────────── + +_CHAINS = {"aws-chain": _aws_chain, "azure-chain": _azure_chain, "gcp-chain": _gcp_chain} + +# AUTH-1 named credentials (amended 2026-09-19): the rungs each name +# covers on each cloud. One word, the same on every cloud; the doctor and +# every auth error print the concrete mechanism, never just the word. +# ``platform`` on AWS covers two rungs (the container endpoint, then IMDS): +# both are the machine's own identity, boto3 tries them in this order, and +# the doctor says which answered. ``workload`` and ``environment`` on GCP +# are the same file rung told apart by the file's ``type`` +# (``external_account`` vs ``service_account``); the wrong type is refused +# by name, never read as the other. +NAMED_RUNGS: dict[str, dict[str, tuple[str, ...]]] = { + "aws-chain": { + "platform": ("container", "imds"), + "workload": ("web-identity",), + "environment": ("env:AWS_ACCESS_KEY_ID",), + "cli": ("assume-role", "sso", "shared-credentials-file", "login", "credential_process", "config-file"), + }, + "azure-chain": { + "platform": ("managed-identity",), + "workload": ("workload-identity",), + "environment": ("environment",), + "cli": ("az", "pwsh", "azd"), + }, + "gcp-chain": { + "platform": ("metadata",), + "workload": ("adc-env",), + "environment": ("adc-env",), + "cli": ("adc-file", "gcloud"), + }, +} + +_GCP_NAMED_TYPES = {"workload": ("external_account",), "environment": ("service_account", "impersonated_service_account")} + +# What each name means on each cloud, for the doctor and for errors. +NAMED_MEANING: dict[str, dict[str, str]] = { + "aws-chain": { + "platform": "the ECS/EKS container endpoint, else the EC2 instance role (IMDSv2)", + "workload": "web identity (AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN) via STS", + "environment": "AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY", + "cli": "the active `aws` profile (assume-role, SSO, shared files, `aws login`, credential_process)", + }, + "azure-chain": { + "platform": "Azure managed identity", + "workload": "Entra workload identity (AZURE_FEDERATED_TOKEN_FILE)", + "environment": "an Entra service principal from AZURE_TENANT_ID / AZURE_CLIENT_ID + secret or certificate", + "cli": "`az`, Azure PowerShell or `azd` sign-in", + }, + "gcp-chain": { + "platform": "the attached service account (GCE metadata server)", + "workload": "workload identity federation (GOOGLE_APPLICATION_CREDENTIALS, type external_account)", + "environment": "a service-account file (GOOGLE_APPLICATION_CREDENTIALS, type service_account)", + "cli": "`gcloud auth application-default login` (the ADC file) or `gcloud auth print-access-token`", + }, +} + + +def chain_for(policy: AccessPolicy) -> list[Rung]: + builder = _CHAINS.get(policy.credential_policy) + if builder is None: + raise ValueError(f"{policy.provider}: not a cloud chain policy") + return builder(policy) + + +def _gcp_typed(rung: Rung, name: str) -> Rung: + """The GOOGLE_APPLICATION_CREDENTIALS rung narrowed to the file types the + name means; another type is refused naming the name it belongs to.""" + allowed = _GCP_NAMED_TYPES[name] + other = "environment" if name == "workload" else "workload" + + def kind_of(ctx: ChainContext) -> tuple[str | None, str | None]: + path = ctx.env.get("GOOGLE_APPLICATION_CREDENTIALS") + if not path: + return None, None + info = _gcp_credential_file(ctx, path) + return path, (str(info.get("type")) if info else None) + + def probe(ctx: ChainContext): + path, kind = kind_of(ctx) + if path and kind and kind not in allowed: + return ("absent", f"{path} holds {kind} credentials; that is the named credential \"{other}\", not \"{name}\"") + return rung.probe(ctx) + + def acquire(ctx: ChainContext): + path, kind = kind_of(ctx) + if path and kind and kind not in allowed: + raise NotConfiguredError( + f"{path} holds {kind} credentials; that is the named credential \"{other}\", not \"{name}\"", + credential_hint=f'credentials={{"": "{other}"}}', + ) + return rung.acquire(ctx) + + return Rung(rung.name, rung.kind, rung.source, rung.needs, probe, acquire) + + +def named_rungs(policy: AccessPolicy, name: str) -> list[Rung]: + """The rungs a named credential covers on this door, in chain order.""" + if name not in NAMED_CREDENTIALS: + raise NotConfiguredError( + f"{policy.provider}: unknown named credential {name!r}; one of {', '.join(repr(n) for n in NAMED_CREDENTIALS)}", + provider=policy.provider, + ) + wanted = NAMED_RUNGS[policy.credential_policy][name] + rungs = [rung for rung in chain_for(policy) if rung.name in wanted] + if policy.credential_policy == "gcp-chain" and name in _GCP_NAMED_TYPES: + rungs = [_gcp_typed(rung, name) for rung in rungs] + return rungs + + +def named_meaning(policy: AccessPolicy, name: str) -> str: + return NAMED_MEANING[policy.credential_policy][name] + + +def _walk(policy: AccessPolicy, named: str | None) -> list[Rung]: + return named_rungs(policy, named) if named else chain_for(policy) + + +def explain(policy: AccessPolicy, ctx: ChainContext, *, explicit: bool, named: str | None = None) -> tuple[list[Step], bool]: + """The AUTH-7 walk. ``explicit`` = an api_keys entry exists (rung 0). + With ``named``, only the rungs the name covers are walked (the rest + are not steps at all: a named credential never falls through).""" + steps: list[Step] = [] + selected = False + if explicit: + steps.append(Step("api_keys", "explicit api_keys entry", "provided (value never shown)", "selected")) + selected = True + else: + steps.append(Step("api_keys", "explicit api_keys entry", "not provided", "absent")) + for rung in _walk(policy, named): + try: + verdict, detail = rung.probe(ctx) + except NotConfiguredError as exc: + verdict, detail = "absent", str(exc).splitlines()[0] + if verdict == "absent": + state = "absent" + elif verdict == "configured": + state = "shadowed" if selected else "unprobed" + else: + state = "shadowed" if selected else "selected" + selected = True + steps.append(Step(rung.name, rung.source, detail, state)) + # ``configured`` is true when something may supply the credential: a + # rung selected offline, or a configured network/subprocess rung the + # offline doctor could not probe. The report names which. + return steps, selected or any(s.state == "unprobed" for s in steps) + + +def _source_of(rung: Rung, value: CredentialValue, named: str | None) -> CredentialSource: + return CredentialSource(rung=rung.name, label=rung.source, named=named, expires_at=getattr(value, "expires_at", None)) + + +def _probed_summary(policy: AccessPolicy, ctx: ChainContext, rungs: list[Rung]) -> str: + parts = [] + for rung in rungs: + try: + _, detail = rung.probe(ctx) + except NotConfiguredError as exc: + detail = str(exc).splitlines()[0] + parts.append(f"{rung.source}: {detail}") + return "; ".join(parts) + + +def resolve(policy: AccessPolicy, ctx: ChainContext, *, named: str | None = None) -> tuple[CredentialValue, CredentialSource]: + """Walk the chain online; the first rung that yields wins, and the + result names the rung (AUTH-1 provenance). A rung that is configured + and fails raises. Azure developer commands are the AUTH-1 exception: + try all three before reporting their failure. Deployed Azure + credentials and AWS/GCP failures never fall through. With ``named``, + only that name's rungs run and nothing else is tried.""" + developer_failed = False + rungs = _walk(policy, named) + for rung in rungs: + try: + got = rung.acquire(ctx) + except AuthError: + if policy.credential_policy == "azure-chain" and rung.name in {"az", "pwsh", "azd"}: + developer_failed = True + continue + raise + if got is not None: + return got, _source_of(rung, got, named) + if developer_failed: + raise AuthError("Azure developer credentials failed; sign in with az, Azure PowerShell, or azd", + provider=policy.provider) + if named: + raise NotConfiguredError( + f'{policy.provider}: named credential "{named}" \u2014 {named_meaning(policy, named)} \u2014 answered nothing ' + f"({_probed_summary(policy, ctx, rungs)}). This door was told to use that identity only; it will not try " + f"the rest of the {policy.credential_policy} chain.", + provider=policy.provider, + credential_hint=f'credentials={{"{policy.provider}": ""}} names one identity; ' + f"omit it to walk the {policy.credential_policy} chain", + ) + raise NotConfiguredError( + f"{policy.provider}: no credential found in the {policy.credential_policy} chain" + + (f"; set {policy.env_keys[0]} or configure the cloud SDK" if policy.env_keys else "; configure the cloud SDK"), + provider=policy.provider, + env_keys=policy.env_keys, + ) + + +class _CachingProvider: + """AUTH-2/AUTH-3: resolve once, hand out until the skew window, then + re-resolve. In memory only; never written to a foreign file. + ``source`` is where the last resolution came from (AUTH-1 provenance); + ``None`` until the first request.""" + + def __init__(self, policy: AccessPolicy, ctx: ChainContext, named: str | None = None) -> None: + self._policy = policy + self._ctx = ctx + self._named = named + self._lock = threading.Lock() + self._value: CredentialValue | None = None + self.source: CredentialSource | None = None + + @property + def named(self) -> str | None: + return self._named + + def __call__(self) -> CredentialValue: + with self._lock: + if self._value is None or self._value.is_expired(self._ctx.now()): + value, source = resolve(self._policy, self._ctx, named=self._named) + self.source = source + if value.is_expired(self._ctx.now()): + raise AuthError("cloud credential is expired; renew the configured credential source" + f" (credential came from: {source.describe(self._ctx.now())})", + provider=self._policy.provider) + # CLI output without an expiry cannot safely be cached forever. + self._value = value if isinstance(value, (ApiKey, AwsCredentials)) or value.expires_at is not None else None + return value + return self._value + + def __repr__(self) -> str: + return f"" + + +def cache_key(policy: AccessPolicy, ctx: ChainContext) -> str: + """Provider id + the identity-selecting settings (AUTH-3).""" + e = ctx.env + parts = [policy.provider, ctx.env.get("AWS_PROFILE") or "", e.get("AZURE_TENANT_ID") or "", e.get("AZURE_CLIENT_ID") or "", + e.get("GOOGLE_APPLICATION_CREDENTIALS") or "", e.get("CLOUDSDK_CONFIG") or "", str(ctx.home)] + parts += [f"{k}={v}" for k, v in sorted(ctx.settings.items())] + return hashlib.sha256("\x1f".join(parts).encode()).hexdigest() + + +def credential_provider(policy: AccessPolicy, ctx: ChainContext, named: str | None = None) -> Callable[[], CredentialValue]: + if named is not None: + named_rungs(policy, named) # an unknown name fails at construction, not on the first request + return _CachingProvider(policy, ctx, named) + + +# ─── Harness ops (PROTOCOL.md token_exchange_build / token_exchange_parse) ── + + +def token_exchange_build(policy: AccessPolicy, rung: str, inputs: Mapping[str, Any], ctx: ChainContext) -> dict[str, Any]: + """The exact token-exchange request a rung would send under ``ctx.now``.""" + if rung in ("adc-env", "adc-file", "service-account"): + info = inputs["credential_file"] + scope = str(inputs.get("scope") or _GCP_SCOPE) + token_uri, assertion = gcp_service_account_assertion(ctx, info, scope) + return {"method": "POST", "url": token_uri, "headers": {"content-type": "application/x-www-form-urlencoded"}, + "body_encoding": "form", "body": {"grant_type": _JWT_BEARER, "assertion": assertion}} + if rung == "environment": + url, pairs = azure_environment_request(ctx, jti=inputs.get("jti")) + return {"method": "POST", "url": url, "headers": {"content-type": "application/x-www-form-urlencoded"}, + "body_encoding": "form", "body": dict(pairs)} + raise ValueError(f"token_exchange_build: rung {rung!r} has no deterministic request") + + +def token_exchange_parse(policy: AccessPolicy, rung: str, status: int, body: Mapping[str, Any], ctx: ChainContext) -> CredentialValue: + now = ctx.now() + if rung in ("adc-env", "adc-file", "service-account", "environment", "workload-identity", "managed-identity", "metadata"): + if not 200 <= status < 300: + raise AuthError(f"{rung}: HTTP {status}") + return _bearer_from_oauth(dict(body), now, rung) + if rung == "credential_process": + if status != 0 or body.get("Version") != 1: + raise AuthError("credential_process failed or returned an unsupported Version") + return _aws_from_response(body) + if rung in ("imds", "container"): + if not 200 <= status < 300: + raise AuthError(f"{rung}: HTTP {status}") + return _aws_from_response(body) + raise ValueError(f"token_exchange_parse: rung {rung!r} is not a parse vector") diff --git a/cloud/hosts.py b/cloud/hosts.py new file mode 100644 index 0000000000..2624c38953 --- /dev/null +++ b/cloud/hosts.py @@ -0,0 +1,265 @@ +""" +lm15.cloud.hosts — a dialect reaches a cloud door through a host (AUTH-10). + +Three pure functions, in the order an adapter calls them: + +1. ``resolve_settings(host, given, env)`` — the host's settings from the + caller's values, then the environment (router/doctor only; a bare + adapter is given its settings explicitly, like its credential), then + defaults. A required setting with no value raises ``NotConfiguredError`` + naming the variable: ``region`` and ``resource`` have no default on + purpose (a wrong-region default is a residency bug). With an + ``endpoint`` (a full URL root) the settings the URL alone needed are + not required (``HostSpec.url_only_settings``). +2. ``render_base_url(host, settings, endpoint=None)`` — the base URL for + the settings; ``{location_host}`` is derived from ``location``. An + endpoint replaces the template's root; the door's path is appended + unless the endpoint already ends with it or with a leading part of it + (``join_endpoint``; spec/auth.md AUTH-10, amended 2026-09-19). +3. ``finish_request(...)`` — the dialect built its request against that + base URL; this applies the host's closed set of rewrites (endpoint path + override, model into the path, ``anthropic_version`` into the body, + required headers, ``query-key``) and then signs (``sigv4``). Ports + implement exactly this function; the harness pins its output. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import re +from urllib.parse import quote, urlsplit +from typing import Any, Callable, Mapping + +from ..credentials import ApiKey, AwsCredentials, CredentialValue +from ..errors import NotConfiguredError, UnsupportedFeatureError +from ..features import AccessPolicy, HostSpec +from . import sigv4 + +__all__ = ["resolve_settings", "render_base_url", "join_endpoint", "endpoint_from_env", "location_host", + "finish_request", "Clock", "utc_now"] + +Clock = Callable[[], datetime] + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def resolve_settings( + host: HostSpec | None, + given: Mapping[str, str] | None, + env: Mapping[str, str] | None = None, + *, + provider: str = "", + profile: Callable[[str], str | None] | None = None, + endpoint: str | None = None, +) -> dict[str, str]: + """Explicit values, then ``env`` (when given), then the cloud profile + (``profile(name)``: the AWS shared config's ``region`` for the active + profile, the ADC file's ``quota_project_id`` — AUTH-10), then defaults. + With ``endpoint`` the settings only the URL root needed are optional.""" + out: dict[str, str] = {} + if host is None: + return dict(given or {}) + given = dict(given or {}) + relaxed = host.url_only_settings if endpoint else frozenset() + for setting in host.settings: + value = given.pop(setting.name, None) + if not value and env is not None: + for var in setting.env: + candidate = env.get(var) + if candidate: + value = candidate + break + if not value and profile is not None: + value = profile(setting.name) + if not value: + value = setting.default + if not value: + if setting.name in relaxed: + continue + hint = f"set {' or '.join(setting.env)}" if setting.env else f"pass settings={{'{setting.name}': ...}}" + if setting.name in host.url_only_settings and host.endpoint_env: + hint += f", or the endpoint: {' or '.join(host.endpoint_env)}" + raise NotConfiguredError( + f"{provider or 'host'}: setting {setting.name!r} is required and has no default; {hint}", + provider=provider or None, + credential_hint=hint, + ) + out[setting.name] = value + unknown = sorted(given) + if unknown: + raise ValueError(f"{provider or 'host'}: unknown host setting(s) {unknown}; known: {list(host.setting_names)}") + return out + + +def location_host(location: str) -> str: + """Vertex host for a location (vertex-locations.md:40-63, :91).""" + if location == "global": + return "aiplatform.googleapis.com" + if location in ("us", "eu"): + return f"aiplatform.{location}.rep.googleapis.com" + return f"{location}-aiplatform.googleapis.com" + + +def endpoint_from_env(host: HostSpec | None, env: Mapping[str, str] | None) -> str | None: + """The first non-empty vendor endpoint variable this door honours.""" + if host is None or env is None: + return None + for var in host.endpoint_env: + value = (env.get(var) or "").strip() + if value: + return value + return None + + +def join_endpoint(endpoint: str, path: str, *, provider: str = "") -> str: + """``endpoint`` (a URL root the caller or the vendor's variable named) + joined with the door's ``path``. + + The door's path is appended unless the endpoint already ends with it, + or with a leading part of it: the console shows an account root + (``https://acct.services.ai.azure.com``), Microsoft's own examples + show ``…/anthropic`` and ``…/openai/v1``, and all three must mean the + same door. Stated trade-off: a gateway whose own path happens to end + with a leading part of the door's path (``…/openai`` meaning + ``…/openai/openai/v1``) cannot be spelled; no such gateway is known. + """ + parts = urlsplit(endpoint.strip()) + if parts.scheme not in ("http", "https") or not parts.netloc: + raise NotConfiguredError(f"{provider or 'host'}: endpoint must be an http(s) URL with a host, got {endpoint!r}", + provider=provider or None) + if parts.query or parts.fragment or "@" in parts.netloc: + raise NotConfiguredError(f"{provider or 'host'}: endpoint must not carry a query, fragment or userinfo", + provider=provider or None) + given = [segment for segment in parts.path.split("/") if segment] + door = [segment for segment in path.split("/") if segment] + base = given + for k in range(min(len(given), len(door)), 0, -1): + if given[-k:] == door[:k]: + base = given[:-k] + break + joined = "/".join(base + door) + return f"{parts.scheme}://{parts.netloc}" + (f"/{joined}" if joined else "") + + +def render_base_url(host: HostSpec, settings: Mapping[str, str], endpoint: str | None = None, *, provider: str = "") -> str: + values = dict(settings) + for name in ("region", "resource", "location"): + if name in values and not re.fullmatch(r"[A-Za-z0-9-]+", values[name]): + raise NotConfiguredError(f"host setting {name!r} must be a DNS label") + if "project" in values: + values["project"] = quote(values["project"], safe="") + if "location" in values and "location_host" not in values: + values["location_host"] = location_host(values["location"]) + template = host.base_url if endpoint is None else host.path_template + try: + rendered = template.format(**values) + except KeyError as exc: + raise NotConfiguredError(f"host base URL needs setting {exc.args[0]!r}") from None + if endpoint is None: + return rendered + return join_endpoint(endpoint, rendered, provider=provider) + + +@dataclass(frozen=True, slots=True, repr=False) +class FinishedRequest: + url: str + headers: dict[str, str] + payload: Any + params: dict[str, str] + + +def finish_request( + policy: AccessPolicy, + settings: Mapping[str, str], + *, + base_url: str, + url: str, + headers: Mapping[str, str], + payload: Any, + params: Mapping[str, Any] | None, + endpoint: str | None, + stream: bool, + model: str | None, + credential: CredentialValue | None, +) -> FinishedRequest: + """Apply the host's rewrites before serialization. Signing happens + after serialization in ``sign_request``.""" + host = policy.host + out_headers = dict(headers) + out_params = {str(k): str(v) for k, v in (params or {}).items()} + if host is None: + return FinishedRequest(url=url, headers=out_headers, payload=payload, params=out_params) + + if host.stream_framing != "sse" and stream: + raise UnsupportedFeatureError( + f"{policy.provider}: {host.stream_framing} stream framing is not implemented yet (phase 2)", + provider=policy.provider, + ) + + key = f"{endpoint}/stream" if (endpoint and stream and f"{endpoint}/stream" in host.paths) else endpoint + if key is not None and key in host.paths: + if "{model}" in host.paths[key] and not model: + raise ValueError(f"{policy.provider}: endpoint {endpoint!r} needs the model in the path") + path_model = model or "" + if endpoint == "generateContent": + path_model = path_model.removeprefix("models/") + url = base_url.rstrip("/") + host.paths[key].format(model=quote(path_model, safe=":@")) + + if isinstance(payload, dict): + payload = dict(payload) + if host.model_in == "path": + payload.pop("model", None) + if host.anthropic_version_in.startswith("body:"): + payload["anthropic_version"] = host.anthropic_version_in[len("body:"):] + out_headers = {k: v for k, v in out_headers.items() if k.lower() != "anthropic-version"} + + for name, setting in host.required_headers: + value = settings.get(setting) + if not value: + raise NotConfiguredError(f"{policy.provider}: header {name} needs setting {setting!r}", provider=policy.provider) + out_headers[name] = value + + if credential is not None and isinstance(credential, ApiKey) and "query-key" in policy.auth_scheme: + from ..access import select_scheme + + if select_scheme(policy, credential) == "query-key": + out_params["key"] = credential.value + + return FinishedRequest(url=url, headers=out_headers, payload=payload, params=out_params) + + +def sign_request( + policy: AccessPolicy, + settings: Mapping[str, str], + *, + method: str, + url: str, + headers: list[tuple[str, str]], + body: bytes, + credential: CredentialValue | None, + now: datetime, +) -> list[tuple[str, str]]: + """The headers to send. ``sigv4`` replaces them with the signed set; + every other scheme was already applied by the dialect's auth header.""" + host = policy.host + if not isinstance(credential, AwsCredentials): + return headers + if host is None or host.sigv4_service is None: + raise NotConfiguredError(f"{policy.provider}: AWS credentials need a sigv4 host", provider=policy.provider) + region = settings.get("region") + if not region: + raise NotConfiguredError(f"{policy.provider}: sigv4 needs the region setting", provider=policy.provider) + signature = sigv4.sign( + method=method, + url=url, + headers={k: v for k, v in headers if k.lower() not in ("authorization", "x-api-key")}, + payload=body, + credentials=credential, + region=region, + service=host.sigv4_service, + now=now, + ) + return list(signature.headers.items()) diff --git a/cloud/rs256.py b/cloud/rs256.py new file mode 100644 index 0000000000..d06e38a594 --- /dev/null +++ b/cloud/rs256.py @@ -0,0 +1,204 @@ +""" +lm15.cloud.rs256 — RSASSA-PKCS1-v1_5 / SHA-256 signing, standard library only. + +Used for the two JWT assertions in spec/auth.md AUTH-11 (``jwt-rs256``): +Google service accounts (RS256 is "the only signing algorithm supported", +gcp-service-account-oauth.md:318-333) and Microsoft Entra certificate +credentials (azure-identity signs RS256, certificate.py:20). + +What is implemented, and only that: + +- PEM → DER for ``PRIVATE KEY`` (PKCS#8), ``RSA PRIVATE KEY`` (PKCS#1), + and ``CERTIFICATE`` blocks; +- a minimal DER reader (SEQUENCE, INTEGER, OCTET STRING, NULL, OID); +- EMSA-PKCS1-v1_5 encoding with the SHA-256 DigestInfo prefix (RFC 8017 + §9.2) and the RSASP1 primitive ``pow(m, d, n)``; +- the compact JWS serialization lm15 pins (compact JSON, keys in the + caller's order, base64url without padding). + +Stated trade-offs (changes/2026-09-03-cloud-hosts.md): + +- ``pow`` on Python integers is not constant-time and has no RSA blinding. + This implementation is not hardened against timing attacks. Use an + external credential provider when that threat matters. Ports with a + platform RSA (Go ``crypto/rsa``, Node ``crypto``) use it. +- Encrypted PEM and PKCS#12 are not parsed: the error names + ``openssl pkcs12 -nodes`` / ``openssl pkey``. +- The Chinese-remainder speed-up is not used: one 2048-bit signature is + about 75 ms in pure Python (measured 2026-09-03), once per token + lifetime of an hour. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from dataclasses import dataclass +from typing import Any, Mapping + +from ..errors import NotConfiguredError + +__all__ = ["RsaPrivateKey", "load_private_key", "certificate_der", "sign_pkcs1v15_sha256", "jwt_encode", "b64url"] + +# DigestInfo prefix for SHA-256 (RFC 8017 §9.2, note 1). +_SHA256_DIGEST_INFO = bytes.fromhex("3031300d060960864801650304020105000420") +_RSA_OID = bytes.fromhex("2a864886f70d010101") # 1.2.840.113549.1.1.1 + + +def b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +# ─── PEM / DER ─────────────────────────────────────────────────────── + + +def _pem_block(text: str, label: str) -> bytes | None: + head, tail = f"-----BEGIN {label}-----", f"-----END {label}-----" + start = text.find(head) + if start < 0: + return None + end = text.find(tail, start) + if end < 0: + raise ValueError(f"PEM block {label!r} has no END line") + body = "".join(text[start + len(head):end].split()) + return base64.b64decode(body) + + +def _der_read(data: bytes, pos: int) -> tuple[int, bytes, int]: + """(tag, value, next_pos) for the TLV at ``pos``.""" + if pos >= len(data): + raise ValueError("DER: unexpected end") + tag = data[pos] + pos += 1 + if pos >= len(data): + raise ValueError("DER: truncated length") + length = data[pos] + pos += 1 + if length & 0x80: + count = length & 0x7F + if count == 0 or count > 4: + raise ValueError("DER: unsupported length form") + if pos + count > len(data): + raise ValueError("DER: truncated length") + length = int.from_bytes(data[pos:pos + count], "big") + pos += count + value = data[pos:pos + length] + if len(value) != length: + raise ValueError("DER: truncated value") + return tag, value, pos + length + + +def _der_sequence(data: bytes) -> list[tuple[int, bytes]]: + tag, value, end = _der_read(data, 0) + if tag != 0x30: + raise ValueError(f"DER: expected SEQUENCE, got tag {tag:#x}") + if end != len(data): + raise ValueError("DER: trailing bytes after SEQUENCE") + return _der_items(value) + + +def _der_items(value: bytes) -> list[tuple[int, bytes]]: + """The TLVs inside a constructed value (a SEQUENCE's contents).""" + items: list[tuple[int, bytes]] = [] + pos = 0 + while pos < len(value): + item_tag, item_value, pos = _der_read(value, pos) + items.append((item_tag, item_value)) + return items + + +def _der_int(item: tuple[int, bytes]) -> int: + tag, value = item + if tag != 0x02: + raise ValueError(f"DER: expected INTEGER, got tag {tag:#x}") + return int.from_bytes(value, "big", signed=False) + + +@dataclass(frozen=True, slots=True) +class RsaPrivateKey: + n: int + e: int + d: int + + def __repr__(self) -> str: # AUTH-5 + return f"RsaPrivateKey(bits={self.n.bit_length()}, )" + + @property + def size_bytes(self) -> int: + return (self.n.bit_length() + 7) // 8 + + +def _rsa_from_pkcs1(der: bytes) -> RsaPrivateKey: + items = _der_sequence(der) + # RSAPrivateKey ::= SEQUENCE { version, n, e, d, p, q, dP, dQ, qInv } + if len(items) < 4: + raise ValueError("PKCS#1 RSAPrivateKey: too few fields") + return RsaPrivateKey(n=_der_int(items[1]), e=_der_int(items[2]), d=_der_int(items[3])) + + +def _rsa_from_pkcs8(der: bytes) -> RsaPrivateKey: + items = _der_sequence(der) + # PrivateKeyInfo ::= SEQUENCE { version, AlgorithmIdentifier, OCTET STRING privateKey } + if len(items) < 3 or items[1][0] != 0x30 or items[2][0] != 0x04: + raise ValueError("PKCS#8 PrivateKeyInfo: unexpected shape") + alg = _der_items(items[1][1]) + if not alg or alg[0][0] != 0x06 or alg[0][1] != _RSA_OID: + raise ValueError("PKCS#8: not an RSA key (only rsaEncryption is supported)") + return _rsa_from_pkcs1(items[2][1]) + + +def load_private_key(pem: str) -> RsaPrivateKey: + """Parse an unencrypted RSA private key from PEM (PKCS#8 or PKCS#1).""" + if "ENCRYPTED PRIVATE KEY" in pem or "Proc-Type: 4,ENCRYPTED" in pem: + raise NotConfiguredError( + "encrypted private keys are not supported; decrypt it first: " + "openssl pkey -in key.pem -out key-plain.pem", + credential_hint="openssl pkey -in key.pem -out key-plain.pem", + ) + der = _pem_block(pem, "PRIVATE KEY") + if der is not None: + return _rsa_from_pkcs8(der) + der = _pem_block(pem, "RSA PRIVATE KEY") + if der is not None: + return _rsa_from_pkcs1(der) + if "BEGIN EC PRIVATE KEY" in pem: + raise NotConfiguredError("EC private keys are not supported (RS256 needs an RSA key)") + raise NotConfiguredError( + "no PEM private key found; PKCS#12 (.pfx/.p12) is not parsed — convert with: " + "openssl pkcs12 -in cert.pfx -nodes -out cert.pem", + credential_hint="openssl pkcs12 -in cert.pfx -nodes -out cert.pem", + ) + + +def certificate_der(pem: str) -> bytes: + """The DER bytes of the first CERTIFICATE block (for ``x5t`` thumbprints).""" + der = _pem_block(pem, "CERTIFICATE") + if der is None: + raise NotConfiguredError("no PEM CERTIFICATE block found") + return der + + +# ─── Signing ───────────────────────────────────────────────────────── + + +def sign_pkcs1v15_sha256(key: RsaPrivateKey, message: bytes) -> bytes: + """RSASSA-PKCS1-v1_5-SIGN with SHA-256 (RFC 8017 §8.2.1).""" + k = key.size_bytes + digest = _SHA256_DIGEST_INFO + hashlib.sha256(message).digest() + if k < len(digest) + 11: + raise ValueError("RSA key too small for SHA-256 PKCS#1 v1.5") + padded = b"\x00\x01" + b"\xff" * (k - len(digest) - 3) + b"\x00" + digest + m = int.from_bytes(padded, "big") + s = pow(m, key.d, key.n) + return s.to_bytes(k, "big") + + +def jwt_encode(header: Mapping[str, Any], payload: Mapping[str, Any], key: RsaPrivateKey) -> str: + """Compact JWS: lm15 pins compact JSON with keys in the given order and + unpadded base64url (a canonical fact so four ports emit identical bytes; + RFC 7519 servers accept any valid JSON).""" + head = b64url(json.dumps(dict(header), separators=(",", ":")).encode("utf-8")) + body = b64url(json.dumps(dict(payload), separators=(",", ":")).encode("utf-8")) + signing_input = f"{head}.{body}".encode("ascii") + return f"{head}.{body}.{b64url(sign_pkcs1v15_sha256(key, signing_input))}" diff --git a/cloud/sigv4.py b/cloud/sigv4.py new file mode 100644 index 0000000000..8fc52772c6 --- /dev/null +++ b/cloud/sigv4.py @@ -0,0 +1,153 @@ +""" +lm15.cloud.sigv4 — AWS Signature Version 4, standard library only. + +Rules from the AWS reference (lm15-contract/research/cloud-hosts/sources/ +aws-sigv4-create-signed-request.md, lines 46–190), pinned by the AWS test +suite in lm15-contract/auth/sigv4-vectors.json: + +- canonical request = method, URI-encoded path, sorted+encoded query, + lowercase sorted headers (``host`` and every ``x-amz-*`` mandatory; + ``content-type`` when present), the signed-header list, hex SHA-256 of + the payload; +- string to sign = ``AWS4-HMAC-SHA256``, ``x-amz-date``, credential scope + ``YYYYMMDD/region/service/aws4_request``, hex SHA-256 of the canonical + request; +- signing key = HMAC chain ``AWS4``+secret → date → region → service → + ``aws4_request``; +- header values are trimmed and inner runs of whitespace collapsed to one + space (test-suite case ``get-header-value-trim``); +- ``x-amz-content-sha256`` is an S3 requirement and is NOT added here. + +Deterministic under a fixed clock and fixed keys: the harness compares the +``authorization`` header byte for byte. +""" + +from __future__ import annotations + +import hashlib +import hmac +import urllib.parse +from dataclasses import dataclass +from datetime import datetime, timezone + +from ..credentials import AwsCredentials + +__all__ = ["SigV4Signature", "sign", "canonicalize"] + +_ALGORITHM = "AWS4-HMAC-SHA256" +_SAFE = "-_.~" + + +@dataclass(frozen=True, slots=True, repr=False) +class SigV4Signature: + canonical_request: str + string_to_sign: str + authorization: str + headers: dict[str, str] # every header to send, lowercase names, including the new ones + + +def _encode(text: str) -> str: + return urllib.parse.quote(text, safe=_SAFE) + + +def _remove_dot_segments(path: str) -> str: + """RFC 3986 §5.2.4 as the AWS SDKs apply it to non-S3 paths: drop `.` + and empty segments, pop on `..`, keep a leading and a trailing slash. + Pinned by the AWS test-suite vectors get-relative*, get-slash*, + get-slashes and get-slash-pointless-dot (auth/sigv4-vectors.json).""" + kept: list[str] = [] + for segment in path.split("/"): + if segment == "..": + if kept: + kept.pop() + elif segment and segment != ".": + kept.append(segment) + first = "/" if path.startswith("/") else "" + last = "/" if path.endswith("/") and kept else "" + return first + "/".join(kept) + last + + +def _canonical_path(path: str) -> str: + if not path: + return "/" + normalized = _remove_dot_segments(path) or "/" + return "/".join(_encode(urllib.parse.unquote(seg)) for seg in normalized.split("/")) + + +def _canonical_query(query: str) -> str: + pairs = urllib.parse.parse_qsl(query, keep_blank_values=True) + encoded = sorted((_encode(k), _encode(v)) for k, v in pairs) + return "&".join(f"{k}={v}" for k, v in encoded) + + +def _trim(value: str) -> str: + return " ".join(value.split()) + + +def canonicalize( + method: str, + url: str, + headers: dict[str, str], + payload: bytes, +) -> tuple[str, str]: + """(canonical_request, signed_headers) for already-complete headers.""" + parts = urllib.parse.urlsplit(url) + lowered = {k.lower(): _trim(v) for k, v in headers.items()} + signed = ";".join(sorted(lowered)) + canonical_headers = "".join(f"{k}:{lowered[k]}\n" for k in sorted(lowered)) + canonical = "\n".join([ + method.upper(), + _canonical_path(parts.path), + _canonical_query(parts.query), + canonical_headers, + signed, + hashlib.sha256(payload).hexdigest(), + ]) + return canonical, signed + + +def sign( + *, + method: str, + url: str, + headers: dict[str, str], + payload: bytes, + credentials: AwsCredentials, + region: str, + service: str, + now: datetime, +) -> SigV4Signature: + """Sign a request. ``headers`` are the caller's headers (any case); the + result carries every header to send, lowercase, with ``host``, + ``x-amz-date``, ``x-amz-security-token`` (when a session token exists) + and ``authorization`` added.""" + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + now = now.astimezone(timezone.utc) + amz_date = now.strftime("%Y%m%dT%H%M%SZ") + date = now.strftime("%Y%m%d") + parts = urllib.parse.urlsplit(url) + + to_sign = {k.lower(): v for k, v in headers.items() if k.lower() != "authorization"} + to_sign["host"] = parts.netloc + to_sign["x-amz-date"] = amz_date + to_sign.pop("x-amz-security-token", None) + if credentials.session_token: + to_sign["x-amz-security-token"] = credentials.session_token + + canonical, signed = canonicalize(method, url, to_sign, payload) + scope = f"{date}/{region}/{service}/aws4_request" + string_to_sign = "\n".join([_ALGORITHM, amz_date, scope, hashlib.sha256(canonical.encode()).hexdigest()]) + + key = ("AWS4" + credentials.secret_access_key).encode() + for piece in (date, region, service, "aws4_request"): + key = hmac.new(key, piece.encode(), hashlib.sha256).digest() + signature = hmac.new(key, string_to_sign.encode(), hashlib.sha256).hexdigest() + + authorization = ( + f"{_ALGORITHM} Credential={credentials.access_key_id}/{scope}, " + f"SignedHeaders={signed}, Signature={signature}" + ) + out = {k: _trim(v) for k, v in to_sign.items()} + out["authorization"] = authorization + return SigV4Signature(canonical_request=canonical, string_to_sign=string_to_sign, authorization=authorization, headers=out) diff --git a/compat.py b/compat.py new file mode 100644 index 0000000000..7c3a554108 --- /dev/null +++ b/compat.py @@ -0,0 +1,1247 @@ +""" +lm15.compat — Typed provider/API compatibility policies. + +Compatibility policies describe how a provider adapter should serialize a +canonical lm15 request for a specific API dialect. They are intentionally +separate from lm15.types: Request/Response describe *what* the caller wants; +compat profiles describe provider wire-format quirks. + +Fields default to None, which means "inherit from the parent profile". The +string value "auto" is an explicit policy: ask the adapter to use its automatic +heuristic for that field. Keeping None distinct from "auto" matters because +profiles are layered. +""" + +from __future__ import annotations + +import dataclasses + +from dataclasses import dataclass, field, fields +from typing import Literal, TypeAlias, get_args + +from .types import JsonObject + + +# ─── Shared helpers ────────────────────────────────────────────────── + + +def _check_literal_or_none(value: object, literal_alias: object, field_name: str) -> None: + if value is not None and value not in get_args(literal_alias): # type: ignore[arg-type] + raise ValueError(f"unsupported {field_name}: {value!r}") + + +def _check_json_object_or_none(value: object, field_name: str) -> None: + if value is None: + return + if not isinstance(value, dict): + raise TypeError(f"{field_name} must be a JSON object or None") + + +def _merge_json_object(a: JsonObject | None, b: JsonObject | None) -> JsonObject | None: + if a is None: + return b + if b is None: + return a + return {**a, **b} + + +# ─── OpenAI Responses API compatibility ────────────────────────────── + +OpenAIResponsesDeveloperRole = Literal["auto", "developer", "system"] +OpenAIResponsesMaxOutputTokensField = Literal[ + "auto", + "max_output_tokens", + "max_completion_tokens", + "max_tokens", +] +OpenAIResponsesReasoningFormat = Literal[ + "auto", + "none", + "responses_reasoning", + "reasoning_effort", + "openrouter", + "deepseek", + "qwen", + "qwen_chat_template", + "zai", +] +OpenAIToolResultName = Literal["auto", "include", "omit"] +OpenAIStrictTools = Literal["auto", "include", "omit"] +# "openai": prompt_cache_key, prompt_cache_retention, and the explicit +# breakpoint mark (prompt_cache_breakpoint / prompt_cache_options, gpt-5.6+). +# "openai_implicit": the two documented fields only, on a server that caches +# every prefix automatically and would take a breakpoint mark without +# acting on it (Meta, guide--prompt-caching.md: "you do not … mark +# breakpoints"; live 2026-09-03 the mark answered 200 with no signal either +# way). As with "none", an explicit CacheConfig is not an error there — +# the prefix is cached regardless — but no undocumented field is sent. +OpenAICacheControl = Literal["auto", "none", "openai", "openai_implicit", "anthropic"] +# Whether a replayed assistant message that precedes a function_call in the +# same turn carries `phase: "commentary"`. Meta Model API stamps the field +# on the messages it emits before a tool call and documents replaying them +# untagged as HTTP 400 (protocols--responses.md § Message phase); live +# 2026-09-03 the untagged replay answered 200, so the tag is sent for the +# documented quality reason ("dropping it can degrade quality"), not to +# avoid an error. OpenAI has no such field: "omit". +OpenAIResponsesCommentaryPhase = Literal["auto", "omit", "tag"] +# The multipart key for input images on POST /images/edits. OpenAI takes +# `image[]` (pinned: cases/openai/image_edit.json); Meta rejects that key +# and asks for `image[0]`, `image[1]`, … (live 2026-09-03, HTTP 400 +# "`image[]` is not a valid image key; use `image[N]`"). +OpenAIResponsesEditImageField = Literal["auto", "array", "indexed"] +# Which server-executed tool types the canonical BuiltinTool names map to. +# "openai": OpenAI's Responses vocabulary (web_search_preview, +# code_interpreter, file_search, computer_use_preview). "verbatim": the +# canonical name IS the wire type — servers whose schema spells the tool +# `web_search` (Meta responses--schemas.md; Moonshot responses--create.md) +# — and a name the server lacks goes out unchanged and is refused loudly +# (both: HTTP 400, live 2026-09-03). Named for the shape, not the first +# server that needed it (renamed from "meta" on 2026-09-03, same day, before +# any port or release carried the old value). +OpenAIResponsesBuiltinTools = Literal["auto", "openai", "verbatim"] +# MAP-10 (2026-09-07): what a ToolResultPart's media parts may become on the +# wire. "native" = images and documents as native blocks inside the result +# item; "images" = images only (documents raise); "reject" = every media part +# raises before the wire. Measured per preset: +# lm15-contract/research/tool-result-content/30-model.md. +ToolResultMedia = Literal["auto", "native", "images", "reject"] + + +@dataclass(frozen=True, slots=True) +class OpenAIResponsesCompat: + """Partial compatibility policy for OpenAI Responses-family APIs. + + None means "inherit". Non-None values override parent profiles. The value + "auto" means "explicitly use adapter auto-detection". + """ + + developer_role: OpenAIResponsesDeveloperRole | None = None + max_output_tokens_field: OpenAIResponsesMaxOutputTokensField | None = None + reasoning_format: OpenAIResponsesReasoningFormat | None = None + tool_result_name: OpenAIToolResultName | None = None + strict_tools: OpenAIStrictTools | None = None + cache_control: OpenAICacheControl | None = None + # New knobs are keyword-only: routing/extensions keep their original slots. + commentary_phase: OpenAIResponsesCommentaryPhase | None = field(default=None, kw_only=True) + edit_image_field: OpenAIResponsesEditImageField | None = field(default=None, kw_only=True) + builtin_tools: OpenAIResponsesBuiltinTools | None = field(default=None, kw_only=True) + tool_result_media: ToolResultMedia | None = field(default=None, kw_only=True) + routing: JsonObject | None = None + extensions: JsonObject | None = None + + def __post_init__(self) -> None: + _check_literal_or_none(self.tool_result_media, ToolResultMedia, "tool_result_media") + _check_literal_or_none(self.developer_role, OpenAIResponsesDeveloperRole, "developer_role") + _check_literal_or_none(self.commentary_phase, OpenAIResponsesCommentaryPhase, "commentary_phase") + _check_literal_or_none(self.edit_image_field, OpenAIResponsesEditImageField, "edit_image_field") + _check_literal_or_none(self.builtin_tools, OpenAIResponsesBuiltinTools, "builtin_tools") + _check_literal_or_none( + self.max_output_tokens_field, + OpenAIResponsesMaxOutputTokensField, + "max_output_tokens_field", + ) + _check_literal_or_none(self.reasoning_format, OpenAIResponsesReasoningFormat, "reasoning_format") + _check_literal_or_none(self.tool_result_name, OpenAIToolResultName, "tool_result_name") + _check_literal_or_none(self.strict_tools, OpenAIStrictTools, "strict_tools") + _check_literal_or_none(self.cache_control, OpenAICacheControl, "cache_control") + _check_json_object_or_none(self.routing, "routing") + _check_json_object_or_none(self.extensions, "extensions") + + @classmethod + def preset(cls, name: str) -> "OpenAIResponsesCompat": + """The named server dialect from :data:`OPENAI_RESPONSES_PRESETS`. + + Accepts the permanent spelling aliases (``responses``, ``lm-studio``, + ``z.ai``); raises ``ValueError`` for an unknown name. + """ + key = _preset_key(name) + try: + return OPENAI_RESPONSES_PRESETS[key] + except KeyError: + raise ValueError(f"unknown OpenAIResponsesCompat preset: {name!r}") from None + + +# Named Responses-dialect servers. Same table shape as the chat dialect's +# OPENAI_CHAT_PRESETS (the if-chain it replaced was the fifth copy of +# provider knowledge the registry entry of 2026-09-03 set out to remove). +OPENAI_RESPONSES_PRESETS: dict[str, OpenAIResponsesCompat] = { + "openai": OpenAIResponsesCompat( + developer_role="developer", + max_output_tokens_field="max_output_tokens", + reasoning_format="responses_reasoning", + tool_result_name="omit", + strict_tools="omit", + cache_control="openai", + ), + "openrouter": OpenAIResponsesCompat( + developer_role="developer", + max_output_tokens_field="max_tokens", + reasoning_format="openrouter", + tool_result_name="omit", + strict_tools="omit", + cache_control="openai", + tool_result_media="reject", # MAP-10: no receipt on this door — reject until one exists + ), + "ollama": OpenAIResponsesCompat( + developer_role="system", + max_output_tokens_field="max_tokens", + reasoning_format="none", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + tool_result_media="reject", # MAP-10: no receipt on this door — reject until one exists + ), + "lmstudio": None, # type: ignore[dict-item] # filled below: ollama's policy object + "vllm": OpenAIResponsesCompat( + developer_role="system", + max_output_tokens_field="max_tokens", + reasoning_format="reasoning_effort", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + tool_result_media="reject", # MAP-10: no receipt on this door — reject until one exists + ), + "sglang": OpenAIResponsesCompat( + developer_role="system", + max_output_tokens_field="max_tokens", + reasoning_format="reasoning_effort", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + tool_result_media="reject", # MAP-10: no receipt on this door — reject until one exists + ), + "qwen": OpenAIResponsesCompat( + developer_role="system", + max_output_tokens_field="max_tokens", + reasoning_format="qwen", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + tool_result_media="reject", # MAP-10: no receipt on this door — reject until one exists + ), + "deepseek": OpenAIResponsesCompat( + developer_role="system", + max_output_tokens_field="max_tokens", + reasoning_format="deepseek", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + tool_result_media="reject", # MAP-10: its Chat and Messages doors both show the model [Unsupported Image] — reject + ), + "zai": OpenAIResponsesCompat( + developer_role="system", + max_output_tokens_field="max_tokens", + reasoning_format="zai", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + tool_result_media="reject", # MAP-10: no receipt on this door (zai Chat carries images) — reject until one exists + ), + # Meta Model API (dev.meta.ai, scraped 2026-09-03 — + # lm15-contract/scrapes/meta/pages): the Responses wire as OpenAI + # speaks it. `developer` role (protocols--responses.md), `max_output_ + # tokens` (min 16), `reasoning.effort` + `reasoning.summary` + # (guide--reasoning.md; `none` is refused loudly by Muse Spark), + # `prompt_cache_key` and `prompt_cache_retention` honoured (guide-- + # prompt-caching.md); the breakpoint mark is never sent (cache_control + # "openai_implicit": the server accepts it silently, live 2026-09-03). + "meta": OpenAIResponsesCompat( + developer_role="developer", + max_output_tokens_field="max_output_tokens", + reasoning_format="responses_reasoning", + tool_result_name="omit", + strict_tools="omit", + cache_control="openai_implicit", + commentary_phase="tag", + edit_image_field="indexed", + builtin_tools="verbatim", + tool_result_media="native", # MAP-10: FunctionCallOutputContentListParam; image/mixed/pair/pdf received (muse-spark-1.3) + ), + # Moonshot AI over the Responses wire (platform.kimi.ai responses-- + # create.md OpenAPI, scraped 2026-09-03): kimi-k3 only; `instructions` + # + `developer` role; `max_output_tokens`; `reasoning.effort` low|high| + # max; stateless (`store` always false, `previous_response_id` always + # null) and reasoning comes back as `summary` text with + # `encrypted_content: null`, replayed as-is; `prompt_cache_key` and + # `safety_identifier`; tools function|custom|namespace|web_search — the + # canonical name is the wire type (builtin_tools "verbatim"). + "moonshotai": OpenAIResponsesCompat( + developer_role="developer", + max_output_tokens_field="max_output_tokens", + reasoning_format="responses_reasoning", + tool_result_name="omit", + strict_tools="omit", + cache_control="openai_implicit", + builtin_tools="verbatim", + tool_result_media="images", # MAP-10: images received (kimi-k3); input_file is 400 "unknown content part type" + ), +} + +# Default base URLs for the Responses presets that name a server (the +# registry's access policies point here so each URL has one copy). +OPENAI_RESPONSES_PRESETS["lmstudio"] = OPENAI_RESPONSES_PRESETS["ollama"] + +# A server's OpenAI root is one address whichever OpenAI-shaped path is +# used; the local engines' roots are the chat table's. A preset that names +# a server absent here (qwen, deepseek, zai: no documented Responses root) +# is REFUSED at construction without an explicit base_url — never sent to +# the OpenAI cloud (preset_base_url below, 2026-09-11). +OPENAI_RESPONSES_PRESET_BASE_URLS: dict[str, str] = { + "openai": "https://api.openai.com/v1", + "ollama": "http://localhost:11434/v1", + "lmstudio": "http://localhost:1234/v1", + "vllm": "http://localhost:8000/v1", + "sglang": "http://localhost:30000/v1", + "openrouter": "https://openrouter.ai/api/v1", + # dev.meta.ai overview.md: one base URL for every Meta Model API surface. + "meta": "https://api.meta.ai/v1", + # platform.kimi.ai api--overview.md: /v1/responses on the OpenAI root. + "moonshotai": "https://api.moonshot.ai/v1", +} + + +@dataclass(frozen=True, slots=True) +class ResolvedOpenAIResponsesCompat: + """Fully resolved OpenAI Responses compatibility policy.""" + + developer_role: Literal["developer", "system"] = "developer" + max_output_tokens_field: Literal["max_output_tokens", "max_completion_tokens", "max_tokens"] = "max_output_tokens" + reasoning_format: Literal[ + "none", + "responses_reasoning", + "reasoning_effort", + "openrouter", + "deepseek", + "qwen", + "qwen_chat_template", + "zai", + ] = "responses_reasoning" + tool_result_name: Literal["include", "omit"] = "omit" + strict_tools: Literal["include", "omit"] = "omit" + cache_control: Literal["none", "openai", "openai_implicit", "anthropic"] = "openai" + commentary_phase: Literal["omit", "tag"] = field(default="omit", kw_only=True) + edit_image_field: Literal["array", "indexed"] = field(default="array", kw_only=True) + builtin_tools: Literal["openai", "verbatim"] = field(default="openai", kw_only=True) + # The documented Responses wire takes input_image and input_file in a + # function_call_output array (MAP-10; openai gpt-5.4 exact, 2026-09-07). + tool_result_media: Literal["native", "images", "reject"] = field(default="native", kw_only=True) + routing: JsonObject | None = None + extensions: JsonObject | None = None + + +# ─── OpenAI Chat Completions compatibility ─────────────────────────── + +# Knobs a per-model override may set (OpenAIChatCompat.model_overrides). +_CHAT_OVERRIDABLE: frozenset[str] = frozenset({ + "instruction_role", "max_tokens_field", "stream_usage", "thinking_format", "thinking_replay", + "assistant_reasoning_content", "strict_tools", "cache_control", "user_field", + "forced_tool_choice", "json_schema", "reasoning_efforts", "tool_result_media", +}) + +OpenAIChatInstructionRole = Literal["auto", "developer", "system"] +OpenAIChatMaxTokensField = Literal["auto", "max_completion_tokens", "max_tokens"] +OpenAIChatStreamUsage = Literal["auto", "include", "omit"] +OpenAIChatAssistantAfterToolResult = Literal["auto", "insert", "omit"] +OpenAIChatThinkingReplay = Literal["auto", "native", "as_text", "omit"] +OpenAIChatAssistantReasoningContent = Literal["auto", "include_empty", "omit"] +# "deepseek" names a wire SHAPE — thinking={"type": enabled|disabled} plus +# reasoning_effort — not a company: DeepSeek, xAI and Z.AI all speak it +# (docs.z.ai chat--create.md ChatThinking; the earlier "zai" value sent +# Qwen's enable_thinking and was never live-validated; removed 2026-09-03). +# "kimi" is the union of Moonshot's two documented shapes, split by intent +# rather than by model: an effort word goes out as top-level +# `reasoning_effort` alone (kimi-k3: low|high|max, api--models-overview.md), +# and off goes out as `thinking: {type: disabled}` alone (kimi-k2.6). Each +# family rejects the other family's field loudly (live 2026-09-03), so +# neither is ever sent alongside the one the docs name for that intent. +OpenAIChatThinkingFormat = Literal[ + "auto", + "none", + "reasoning_effort", + "openrouter", + "deepseek", + "kimi", + "qwen", + "qwen_chat_template", +] +# BuiltinTool policy for the chat dialect. The base Chat Completions wire +# carries function/custom tools ONLY (doc: chat--create.md), and some +# compat servers silently IGNORE unknown tool types (OpenRouter, verified +# live 2026-09-01) — so "reject" (raise) is the only safe default. +# "groq" maps canonical builtin names onto Groq's server-executed tool +# types (browser_search / code_interpreter, both verified live +# 2026-09-01). +OpenAIChatBuiltinTools = Literal["auto", "reject", "groq"] +# Which request field carries Config.user_id. OpenAI's dialect spells it +# `user`; DeepSeek documents `user_id` (content-safety, KV-cache and +# scheduling isolation, rate-limit.md) and accepts `user` silently (live +# 2026-09-03: 200 either way, no echo) — so the documented name is the +# only one that can be trusted to do anything. Meta deprecates `user` in +# favour of `safety_identifier` (protocols--chat-completions.md § Parameters), +# the same field the Responses dialect already sends. +OpenAIChatUserField = Literal["auto", "user", "user_id", "safety_identifier"] +# Whether the server honours a tool_choice other than "auto" (required, +# none, a named function, an allowlist). "reject" makes the adapter raise +# UnsupportedFeatureError before the wire. Z.AI documents auto only and +# ignores the rest silently (live 2026-09-03: mode=required answered text, +# mode=none called the tool) — a silent widen is worse than an error (MAP-8). +OpenAIChatForcedToolChoice = Literal["auto", "send", "reject"] +# Whether the server honours response_format.type=json_schema. "reject" +# raises before the wire. DeepSeek answers 400 (loud, nothing to do); Z.AI +# answers 200 with free-form, fenced JSON that ignores the schema (live +# 2026-09-03) — silent, so the adapter must refuse. +OpenAIChatJsonSchema = Literal["auto", "send", "reject"] +# MAP-14 §4: can the server score named tokens (``logprob_token_ids`` on +# the completions endpoint) so lm15 can deliver a distribution over +# declared keys? Receipted on vLLM 0.29.0 (honoured) and 0.25.1 (200, +# silently absent) 2026-09-17; the response-side check catches the latter. +OpenAIChatTokenScoring = Literal["auto", "none", "logprob_token_ids"] +# The server's native reasoning-effort levels, when the server does NOT +# refuse the others. MAP-7 rule 2: a word with no native level raises +# client-side rather than downgrading silently. Most servers answer 400 to +# an unknown word and need nothing here (None); Moonshot's kimi-k3 documents +# low|high|max and answers 200 to `medium` and to `bogus` alike (live +# 2026-09-03), so the preset lists the three. `off` is never in the list: +# the off switch is thinking_format's business. +OpenAIChatReasoningEfforts = tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class OpenAIChatCompat: + """Partial compatibility policy for OpenAI Chat Completions-family APIs. + + This class is consumed by OpenAIChatLM (lm15.providers.openai_chat); it is + kept separate so profiles can describe chat-completions style endpoints + without overloading OpenAIResponsesCompat. + """ + + instruction_role: OpenAIChatInstructionRole | None = None + max_tokens_field: OpenAIChatMaxTokensField | None = None + stream_usage: OpenAIChatStreamUsage | None = None + tool_result_name: OpenAIToolResultName | None = None + assistant_after_tool_result: OpenAIChatAssistantAfterToolResult | None = None + thinking_format: OpenAIChatThinkingFormat | None = None + thinking_replay: OpenAIChatThinkingReplay | None = None + assistant_reasoning_content: OpenAIChatAssistantReasoningContent | None = None + strict_tools: OpenAIStrictTools | None = None + builtin_tools: OpenAIChatBuiltinTools | None = None + tool_result_media: ToolResultMedia | None = field(default=None, kw_only=True) + cache_control: OpenAICacheControl | None = None + user_field: OpenAIChatUserField | None = None + forced_tool_choice: OpenAIChatForcedToolChoice | None = None + json_schema: OpenAIChatJsonSchema | None = None + token_scoring: OpenAIChatTokenScoring | None = field(default=None, kw_only=True) + reasoning_efforts: OpenAIChatReasoningEfforts | None = field(default=None, kw_only=True) + routing: JsonObject | None = None + extensions: JsonObject | None = None + # Per-model-family overrides on a door that forwards knobs to many + # vendors' models (Bedrock's Chat Completions door, live 2026-09-03: + # DeepSeek/Mistral/Qwen/Z.AI honour tool_choice=required and + # json_schema, gpt-oss ignores both, Gemma ignores tool_choice). Each + # entry is (model-id prefix, {knob: value}); the first matching prefix + # wins; the knobs are the fields above. Resolved per request. + model_overrides: tuple[tuple[str, dict[str, str]], ...] = field(default=(), kw_only=True) + + def __post_init__(self) -> None: + overrides = [] + for prefix, knobs in self.model_overrides: + if not isinstance(prefix, str) or not prefix: + raise ValueError("model_overrides: each prefix is a non-empty string") + knobs = dict(knobs) + for name in knobs: + if name not in _CHAT_OVERRIDABLE: + raise ValueError(f"model_overrides: {name!r} is not an overridable knob ({sorted(_CHAT_OVERRIDABLE)})") + overrides.append((prefix, knobs)) + object.__setattr__(self, "model_overrides", tuple(overrides)) + _check_literal_or_none(self.instruction_role, OpenAIChatInstructionRole, "instruction_role") + _check_literal_or_none(self.max_tokens_field, OpenAIChatMaxTokensField, "max_tokens_field") + _check_literal_or_none(self.stream_usage, OpenAIChatStreamUsage, "stream_usage") + _check_literal_or_none(self.tool_result_name, OpenAIToolResultName, "tool_result_name") + _check_literal_or_none( + self.assistant_after_tool_result, + OpenAIChatAssistantAfterToolResult, + "assistant_after_tool_result", + ) + _check_literal_or_none(self.thinking_format, OpenAIChatThinkingFormat, "thinking_format") + _check_literal_or_none(self.thinking_replay, OpenAIChatThinkingReplay, "thinking_replay") + _check_literal_or_none( + self.assistant_reasoning_content, + OpenAIChatAssistantReasoningContent, + "assistant_reasoning_content", + ) + _check_literal_or_none(self.strict_tools, OpenAIStrictTools, "strict_tools") + _check_literal_or_none(self.builtin_tools, OpenAIChatBuiltinTools, "builtin_tools") + _check_literal_or_none(self.tool_result_media, ToolResultMedia, "tool_result_media") + _check_literal_or_none(self.cache_control, OpenAICacheControl, "cache_control") + _check_literal_or_none(self.user_field, OpenAIChatUserField, "user_field") + _check_literal_or_none(self.token_scoring, OpenAIChatTokenScoring, "token_scoring") + _check_literal_or_none(self.forced_tool_choice, OpenAIChatForcedToolChoice, "forced_tool_choice") + _check_literal_or_none(self.json_schema, OpenAIChatJsonSchema, "json_schema") + if self.reasoning_efforts is not None: + from .types import REASONING_EFFORTS + + bad = [w for w in self.reasoning_efforts if w not in REASONING_EFFORTS or w == "off"] + if not isinstance(self.reasoning_efforts, tuple) or bad: + raise ValueError( + f"reasoning_efforts must be a tuple of ReasoningEffort words other than 'off'; got {self.reasoning_efforts!r}" + ) + _check_json_object_or_none(self.routing, "routing") + _check_json_object_or_none(self.extensions, "extensions") + + + def for_model(self, model: str) -> "OpenAIChatCompat": + """This compat with the first matching ``model_overrides`` entry applied.""" + for prefix, knobs in self.model_overrides: + if model.startswith(prefix): + return dataclasses.replace(self, model_overrides=(), **knobs) + return self + + @classmethod + def preset(cls, name: str) -> "OpenAIChatCompat": + """The named server dialect from :data:`OPENAI_CHAT_PRESETS`. + + Accepts the permanent spelling aliases (``lm-studio``, ``z.ai``, + ``openai_chat``); raises ``ValueError`` for an unknown name. + """ + key = _preset_key(name) + try: + return OPENAI_CHAT_PRESETS[key] + except KeyError: + raise ValueError(f"unknown OpenAIChatCompat preset: {name!r}") from None + + +def _preset_key(name: str) -> str: + key = name.lower().replace("-", "_").replace(" ", "_").replace(".", "_") + return _OPENAI_CHAT_PRESET_ALIASES.get(key, key) + + +def preset_base_url(table: dict[str, str], name: str, *, dialect: str, default_preset: str) -> str: + """The address a preset name supplies for one dialect. + + A name that names a server supplies that server's root. The dialect's + own default (``default_preset``: ``openai`` / ``anthropic``) is the + only name that resolves to the cloud default. Any other name with no + entry in ``table`` is refused with :class:`NotConfiguredError`: a + request meant for a named server must never be sent to the OpenAI + cloud with whatever key is around because a table lacked a row + (2026-09-11; before, ``compat="lmstudio"`` did exactly that). + """ + key = _preset_key(name) + try: + return table[key] + except KeyError: + pass + if key == default_preset: + return table[default_preset] + from .errors import NotConfiguredError + + raise NotConfiguredError( + f"compat {name!r} names a server whose {dialect} address lm15 does not " + f"know; pass base_url= (the server's OpenAI-compatible root, e.g. " + f"'http://localhost:PORT/v1')" + ) + + +# Spelling aliases → canonical preset key. Every alias is permanent. One +# map serves all three dialect tables: a name means the same server in each. +_OPENAI_CHAT_PRESET_ALIASES: dict[str, str] = { + "openai_chat": "openai", + "chat": "openai", + "chat_completions": "openai", + "responses": "openai", + "openai_responses": "openai", + "lm_studio": "lmstudio", + "dashscope_qwen": "qwen", + "z_ai": "zai", +} + +# The Chat Completions server dialects lm15 knows, as data. A preset +# describes ONE server's quirks (instruction role, token-limit field, +# thinking wire shape, …); it says nothing about credentials or routing — +# that is the provider registry (lm15.registry), which names a preset by +# key. Every field value here is pinned by a live receipt or the server's +# own documentation, cited inline; a preset changes with new evidence, not +# with a hunch. +OPENAI_CHAT_PRESETS: dict[str, OpenAIChatCompat] = { + "openai": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_completion_tokens", + stream_usage="include", + thinking_format="reasoning_effort", + tool_result_name="omit", + strict_tools="omit", + cache_control="openai", + tool_result_media="reject", # MAP-10: text-only tool row; gpt-5.4 received the USER image and not the tool image + ), + # ollama: max_tokens; reasoning_effort (and `reasoning: {effort}`) on + # the wire, mapped to Ollama's `think` by openai/openai.go + # `thinkFromReasoningEffort` (lm15-contract/research/tool-result-content/ + # sources/ollama.txt:536-560): none → think:false, minimal → low, + # low|medium|high|max verbatim, xhigh → max; an unknown word is a 400. + # Until 2026-09-14 this said thinking_format="none" with "no receipt"; + # THEORY.md §3.17. Source receipt; live receipt still owed. + "ollama": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_tokens", + stream_usage="include", + thinking_format="reasoning_effort", + reasoning_efforts=("minimal", "low", "medium", "high", "xhigh", "max"), + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + tool_result_media="reject", # MAP-10: openai.go builds image rows without ToolCallID; no receipt — reject until one exists + ), + # LM Studio: its own policy at http://localhost:1234/v1 + # (OPENAI_CHAT_PRESET_BASE_URLS). HYPOTHESIS, no receipt (THEORY.md + # §3.17): lmstudio.ai lists max_tokens and no reasoning dial, so + # thinking_format="none" — under MAP-13 a set dial is dropped and + # recorded, never refused on this unverified line. Until 2026-09-11 + # the name was an alias of "ollama". + "lmstudio": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_tokens", + stream_usage="include", + thinking_format="none", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + tool_result_media="reject", + ), + # Groq: server-executed builtin tools (browser_search / code_interpreter, + # live 2026-09-01); reasoning_effort dial; no cache_control field. + "groq": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_tokens", + stream_usage="include", + thinking_format="reasoning_effort", + tool_result_name="omit", + strict_tools="omit", + builtin_tools="groq", + cache_control="none", + tool_result_media="reject", # MAP-10: server 400 "messages[2].content must be a string" despite the SDK union + ), + # OpenRouter: unified reasoning object; OpenAI-shaped cache_control. + "openrouter": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_tokens", + stream_usage="include", + thinking_format="openrouter", + tool_result_name="omit", + strict_tools="omit", + cache_control="openai", + tool_result_media="reject", # MAP-10: schema says image; no receipt (401 during the pass) — reject until one exists + ), + # xAI, pinned live 2026-09-01 against grok-4.6: max_tokens accepted, + # reasoning arrives as message.reasoning_content (deepseek shape), + # stream_options.include_usage honored, no cache_control field (prompt + # caching is automatic; usage reports cached_tokens). + "xai": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_tokens", + stream_usage="include", + thinking_format="deepseek", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + tool_result_media="images", # MAP-10: image tool results received (grok-4.20); documents 400 "use /v1/responses" + ), + "vllm": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_tokens", + stream_usage="include", + thinking_format="reasoning_effort", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + tool_result_media="reject", # MAP-10: parser carries it; no server reachable in the pass — reject until a receipt + # MAP-14 §4, receipts/2026-09-17-judgments/vllm-0.29-lfm-trie.json (honoured) + # and vllm-0.25.1-qwen-trie-negative.json (silently absent; caught on parse). + token_scoring="logprob_token_ids", + ), + "sglang": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_tokens", + stream_usage="include", + thinking_format="reasoning_effort", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + tool_result_media="reject", # MAP-10: schema carries it; no server reachable in the pass — reject until a receipt + ), + # DeepSeek (api-docs.deepseek.com, scraped 2026-09-03 — + # lm15-contract/scrapes/deepseek/pages): thinking={"type": enabled| + # disabled} + reasoning_effort (guide--thinking-mode.md); max_tokens + # (chat--create.md); usage in the final stream chunk via + # stream_options.include_usage; context caching is automatic on disk, + # no cache_control field (guide--kv-cache.md). thinking_replay=native + # + include_empty: with tools present, every assistant turn must carry + # reasoning_content back — even turns that made no call — or the API + # answers 400 (guide--thinking-mode.md § Tool Calls). + "deepseek": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_tokens", + stream_usage="include", + thinking_format="deepseek", + thinking_replay="native", + assistant_reasoning_content="include_empty", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + user_field="user_id", + tool_result_media="reject", # MAP-10: HTTP 200 and the model sees [Unsupported Image] — silent degrade + ), + "qwen": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_tokens", + stream_usage="include", + thinking_format="qwen", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + ), + # Z.AI (docs.z.ai, scraped 2026-09-03 — lm15-contract/scrapes/zai/pages): + # thinking={"type": enabled|disabled} + reasoning_effort (chat--create.md + # ChatThinking, the deepseek wire shape; GLM-5.3 cannot disable and the + # request fails loudly — model--glm-5.3.md); max_tokens; interleaved + # thinking asks for reasoning_content to be returned with tool results + # (guide--thinking-mode.md); implicit context caching, usage in + # prompt_tokens_details.cached_tokens (guide--cache.md); user_id 6–128 + # chars is the documented identity field (chat--create.md). + # Amazon Bedrock's OpenAI Chat Completions door on bedrock-runtime + # (live 2026-09-03, openai.gpt-oss-20b-1:0, SigV4; receipts/2026-09-03- + # bedrock-chat/): system role, max_completion_tokens, usage on the final + # chunk, reasoning_effort accepted, `user` accepted. The door forwards + # tool_choice and response_format to every vendor's model; whether a + # model honours them is per family (six families probed, family-*.json): + # the two that ignore them are refused per model (MAP-8) below. + "bedrock": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_completion_tokens", + stream_usage="include", + thinking_format="reasoning_effort", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + user_field="user", + forced_tool_choice="send", + json_schema="send", + model_overrides=( + # receipts/2026-09-03-bedrock-chat/family-*.json: HTTP 200, the + # knob sent, the model answered as if it were absent. + ("openai.gpt-oss", {"forced_tool_choice": "reject", "json_schema": "reject"}), + ("google.gemma", {"forced_tool_choice": "reject"}), + ), + tool_result_media="reject", # MAP-10: 400 validation_error on the array form + ), + # Bedrock-mantle Chat Completions (live 2026-09-04, openai.gpt-oss-20b and + # deepseek.v3.2; receipts/2026-09-04-bedrock-chat/probe-mantle-chat-* and + # receipts/-bedrock-mantle-chat/). Same Chat Completions dialect as + # `bedrock`, a different host: un-versioned ids, a working /models list, + # gpt-oss reasoning in `message.reasoning` (the parser already reads that + # field). tool_choice / json_schema overrides land from the family + # probes of this door, not copied from `bedrock` — mantle translates, + # so a silent ignore on runtime is not evidence here. Live 2026-09-04 + # family probes: DeepSeek/Mistral/Qwen/Z.AI/Gemma honour both knobs + # (Gemma honours tool_choice here; it ignores it on bedrock-runtime); + # gpt-oss ignores both (HTTP 200, plain text / prose+JSON). + "bedrock_mantle": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_completion_tokens", + stream_usage="include", + thinking_format="reasoning_effort", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + user_field="user", + forced_tool_choice="send", + json_schema="send", + model_overrides=( + ("openai.gpt-oss", {"forced_tool_choice": "reject", "json_schema": "reject"}), + ), + ), + "zai": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_tokens", + stream_usage="include", + thinking_format="deepseek", + thinking_replay="native", + tool_result_name="omit", + strict_tools="omit", + cache_control="none", + user_field="user_id", + forced_tool_choice="reject", + json_schema="reject", + tool_result_media="images", # MAP-10: image tool results received (glm-4.6v); a file part is 400 (code 1210) + ), + # Meta Model API (dev.meta.ai, scraped 2026-09-03 — lm15-contract/scrapes/ + # meta/pages, protocols--chat-completions.md): `developer` is the + # documented instruction role (`system` is merged at the same level); + # `max_completion_tokens` (`max_tokens` is a deprecated alias); top-level + # `reasoning_effort` (Muse Spark always reasons — `none` is HTTP 400, + # loud); `stream_options.include_usage` documented (chat--schemas.md); + # `prompt_cache_key` honoured, caching otherwise automatic (guide-- + # prompt-caching.md); `safety_identifier` supersedes `user`. The + # `reasoning_content` field on this wire is redacted to empty for + # external keys (guide--reasoning.md § Chat Completions), so nothing is + # replayed: thinking_replay stays at the dialect default. + "meta": OpenAIChatCompat( + instruction_role="developer", + max_tokens_field="max_completion_tokens", + stream_usage="include", + thinking_format="reasoning_effort", + tool_result_name="omit", + strict_tools="omit", + cache_control="openai_implicit", + user_field="safety_identifier", + tool_result_media="reject", # MAP-10: 400 "did not match any supported type"; Meta's Responses door carries media + ), + # Moonshot AI / Kimi API Platform (platform.kimi.ai, scraped 2026-09-03 — + # lm15-contract/scrapes/moonshotai/pages, chat--create.md OpenAPI): + # `role: system` instructions; `max_completion_tokens` (`max_tokens` is + # marked deprecated); `stream_options.include_usage` documented; the + # reasoning dial is split by model family — kimi-k3 takes top-level + # `reasoning_effort` (low|high|max, always thinks), kimi-k2.6 takes + # `thinking: {type}` (guide--thinking-models.md) — which the "kimi" shape + # expresses by intent; every model returns `reasoning_content` and the + # docs require it back verbatim in multi-turn and tool loops + # (thinking_replay=native); context caching is automatic with + # `prompt_cache_key` as the only knob (guide--context-caching.md); + # `safety_identifier` is the documented identity field (no `user`). + "moonshotai": OpenAIChatCompat( + instruction_role="system", + max_tokens_field="max_completion_tokens", + stream_usage="include", + thinking_format="kimi", + thinking_replay="native", + tool_result_name="omit", + strict_tools="omit", + cache_control="openai_implicit", + user_field="safety_identifier", + reasoning_efforts=("low", "high", "max"), + tool_result_media="images", # MAP-10: image tool results received (kimi-k2.6); a file part is 400 + ), +} + + +# Default base URLs for the Chat Completions presets that name a server. +# Used by OpenAIChatLM when a compat preset is given by name and no +# explicit base_url overrides it; the provider registry's access policies +# point here so there is one copy of each URL. + +OPENAI_CHAT_PRESET_BASE_URLS: dict[str, str] = { + "openai": "https://api.openai.com/v1", + "ollama": "http://localhost:11434/v1", + # lmstudio.ai docs (Local Server): "http://localhost:1234/v1". + "lmstudio": "http://localhost:1234/v1", + "groq": "https://api.groq.com/openai/v1", + "openrouter": "https://openrouter.ai/api/v1", + "xai": "https://api.x.ai/v1", + "vllm": "http://localhost:8000/v1", + "sglang": "http://localhost:30000/v1", + # api-docs.deepseek.com/first-call.md: base_url https://api.deepseek.com + # (no /v1; the site also answers /v1, but the documented form wins). + "deepseek": "https://api.deepseek.com", + # docs.z.ai introduction.md: the general endpoint. The GLM Coding Plan + # uses a different, subscription-bound endpoint that lm15 does not name. + "zai": "https://api.z.ai/api/paas/v4", + # dev.meta.ai overview.md: one base URL for every Meta Model API surface. + "meta": "https://api.meta.ai/v1", + # platform.kimi.ai api--overview.md: the OpenAI-compatible root (Chat + # Completions and Responses); the Anthropic wire lives at /anthropic. + "moonshotai": "https://api.moonshot.ai/v1", +} + + +@dataclass(frozen=True, slots=True) +class ResolvedOpenAIChatCompat: + """Fully resolved OpenAI Chat Completions compatibility policy.""" + + instruction_role: Literal["developer", "system"] = "system" + max_tokens_field: Literal["max_completion_tokens", "max_tokens"] = "max_completion_tokens" + stream_usage: Literal["include", "omit"] = "include" + tool_result_name: Literal["include", "omit"] = "omit" + assistant_after_tool_result: Literal["insert", "omit"] = "omit" + thinking_format: Literal[ + "none", + "reasoning_effort", + "openrouter", + "deepseek", + "kimi", + "qwen", + "qwen_chat_template", + ] = "reasoning_effort" + thinking_replay: Literal["native", "as_text", "omit"] = "as_text" + assistant_reasoning_content: Literal["include_empty", "omit"] = "omit" + strict_tools: Literal["include", "omit"] = "omit" + builtin_tools: Literal["reject", "groq"] = "reject" + # The base Chat wire's tool row takes text only (OpenAI's own schema; a + # 200 with the image not received on gpt-5.4, 2026-09-07): reject unless + # a preset proved the array form live (MAP-10). + tool_result_media: Literal["native", "images", "reject"] = field(default="reject", kw_only=True) + cache_control: Literal["none", "openai", "openai_implicit", "anthropic"] = "openai" + user_field: Literal["user", "user_id"] = "user" + forced_tool_choice: Literal["send", "reject"] = "send" + json_schema: Literal["send", "reject"] = "send" + token_scoring: Literal["none", "logprob_token_ids"] = field(default="none", kw_only=True) + reasoning_efforts: tuple[str, ...] | None = field(default=None, kw_only=True) + routing: JsonObject | None = None + extensions: JsonObject | None = None + + +_CHAT_AUTO_DEFAULTS: dict[str, str] = { + "instruction_role": "system", + "max_tokens_field": "max_completion_tokens", + "stream_usage": "include", + "tool_result_name": "omit", + "assistant_after_tool_result": "omit", + "thinking_format": "reasoning_effort", + "thinking_replay": "as_text", # decision G (2026-09-01): unsigned thinking is replayed as text, never dropped + "assistant_reasoning_content": "omit", + "strict_tools": "omit", + "builtin_tools": "reject", + "tool_result_media": "reject", + "cache_control": "openai", + "user_field": "user", + "forced_tool_choice": "send", + "json_schema": "send", + "token_scoring": "none", +} + + +def merge_openai_chat_compat( + base: OpenAIChatCompat, + override: OpenAIChatCompat | None, +) -> OpenAIChatCompat: + """Merge partial OpenAI Chat compat objects. + + None fields inherit. Non-None fields, including "auto", override. + """ + if override is None: + return base + + kwargs = {} + for f in fields(OpenAIChatCompat): + value = getattr(override, f.name) + if f.name == "extensions": + kwargs[f.name] = _merge_json_object(base.extensions, override.extensions) + else: + kwargs[f.name] = getattr(base, f.name) if value is None else value + return OpenAIChatCompat(**kwargs) + + +def resolve_openai_chat_compat(partial: OpenAIChatCompat) -> ResolvedOpenAIChatCompat: + """Resolve a partial chat compat object into concrete serializer policy.""" + kwargs: dict[str, object] = {} + for field_name, default in _CHAT_AUTO_DEFAULTS.items(): + value = getattr(partial, field_name) + kwargs[field_name] = default if value in {None, "auto"} else value + return ResolvedOpenAIChatCompat( + reasoning_efforts=partial.reasoning_efforts, + routing=partial.routing, + extensions=partial.extensions, + **kwargs, # type: ignore[arg-type] + ) + + +# ─── Anthropic Messages compatibility ─────────────────────────────── +# +# The Messages wire has few dialects, so this policy is small. It exists +# for servers that speak the format but not all of it: DeepSeek's Anthropic +# endpoint (api-docs.deepseek.com guide--anthropic-api.md, live 2026-09-03) +# is the first. Fields default to None (inherit), "auto" is the dialect's +# own behavior, same convention as the OpenAI policies above. + +# How reasoning is switched and steered. "anthropic": the model-class rule +# (MAP-7 rule 10: manual class takes thinking.type=enabled + budget_tokens, +# adaptive class takes thinking.type=adaptive + output_config.effort; off +# is absence). "deepseek": thinking.type=enabled|disabled + +# output_config.effort, no budget (the server ignores budget_tokens), and +# off MUST be sent — absence means on for DeepSeek. "adaptive": every +# model is the adaptive class (thinking.type=adaptive + output_config. +# effort; `enabled` + budget_tokens is accepted but not translated) and +# off MUST be sent as thinking.type=disabled — the server refuses it with +# HTTP 400 rather than silently reasoning anyway (Meta Model API, +# protocols--messages.md § Reasoning). "effort": the server documents no +# `thinking` request field at all — output_config.effort is the whole dial +# and goes out alone; off is sent as thinking.type=disabled, which the +# server honours (Moonshot kimi-k3, messages--create.md; live 2026-09-03). +AnthropicThinkingFormat = Literal["auto", "anthropic", "deepseek", "adaptive", "effort"] +# How a ThinkingPart that carries no signature is replayed. "signed": the +# dialect's rule — only a signed block goes back as `thinking`; an unsigned +# one goes back as text (decision G: api.anthropic.com rejects unsigned +# thinking blocks). "unsigned": the block goes back as `thinking` without +# a signature — for servers that return `signature: ""` and accept the +# block back (Moonshot, live 2026-09-03), where a text replay would put +# the model's reasoning into its spoken turn. +AnthropicThinkingReplay = Literal["auto", "signed", "unsigned"] +# Whether temperature / top_p / top_k reach the wire. "reject" raises +# before the wire on a server that documents none of them and swallows +# them silently (Moonshot: `temperature: 0.5` is HTTP 200 here while the +# same server's chat wire answers "only 1 is allowed for this model" — +# live 2026-09-03; MAP-8 §2). +AnthropicSamplingParams = Literal["auto", "send", "reject"] +# Whether cache_control marks are placed. "none": the server ignores marks +# and caches implicitly; nothing is placed and, as on the chat dialect's +# "none", an explicit CacheConfig is not an error (implicit caching applies). +AnthropicCacheControl = Literal["auto", "anthropic", "none"] +# Whether output_config.format (a JSON schema) is honoured. "reject" raises +# before the wire: DeepSeek answers 200 and ignores the schema (live +# 2026-09-03) — silent, so the adapter refuses. +AnthropicStructuredOutput = Literal["auto", "send", "reject"] +# Whether disable_parallel_tool_use is honoured. DeepSeek documents it as +# ignored; a silent no-op on ToolChoice.parallel=False is refused (MAP-8 §2). +AnthropicParallelToolCalls = Literal["auto", "send", "reject"] + + +@dataclass(frozen=True, slots=True) +class AnthropicCompat: + """Partial compatibility policy for Anthropic Messages-family APIs. + + ``model_prefixes`` refuses, before the wire, any model id that does not + start with one of the prefixes: DeepSeek's endpoint silently serves + ``claude-opus*`` as ``deepseek-v4-pro`` and ``claude-haiku*`` / + ``claude-sonnet*`` as ``deepseek-v4-flash`` (docs and live 2026-09-03); + lm15 does not take part in a substitution the caller cannot see. + None means any model id goes out as typed. + """ + + thinking_format: AnthropicThinkingFormat | None = None + # Preserve the original thinking_format/cache_control/... positional API. + thinking_replay: AnthropicThinkingReplay | None = field(default=None, kw_only=True) + cache_control: AnthropicCacheControl | None = None + structured_output: AnthropicStructuredOutput | None = None + parallel_tool_calls: AnthropicParallelToolCalls | None = None + sampling_params: AnthropicSamplingParams | None = field(default=None, kw_only=True) + tool_result_media: ToolResultMedia | None = field(default=None, kw_only=True) + reasoning_efforts: tuple[str, ...] | None = field(default=None, kw_only=True) + model_prefixes: tuple[str, ...] | None = None + extensions: JsonObject | None = None + + def __post_init__(self) -> None: + _check_literal_or_none(self.tool_result_media, ToolResultMedia, "tool_result_media") + _check_literal_or_none(self.thinking_format, AnthropicThinkingFormat, "thinking_format") + _check_literal_or_none(self.thinking_replay, AnthropicThinkingReplay, "thinking_replay") + _check_literal_or_none(self.cache_control, AnthropicCacheControl, "cache_control") + _check_literal_or_none(self.structured_output, AnthropicStructuredOutput, "structured_output") + _check_literal_or_none(self.parallel_tool_calls, AnthropicParallelToolCalls, "parallel_tool_calls") + _check_literal_or_none(self.sampling_params, AnthropicSamplingParams, "sampling_params") + if self.reasoning_efforts is not None: + from .types import REASONING_EFFORTS + + bad = [w for w in self.reasoning_efforts if w not in REASONING_EFFORTS or w == "off"] + if not isinstance(self.reasoning_efforts, tuple) or bad: + raise ValueError( + f"reasoning_efforts must be a tuple of ReasoningEffort words other than 'off'; got {self.reasoning_efforts!r}" + ) + if self.model_prefixes is not None: + object.__setattr__(self, "model_prefixes", tuple(str(p) for p in self.model_prefixes)) + if not self.model_prefixes: + raise ValueError("model_prefixes must be None or a non-empty tuple") + _check_json_object_or_none(self.extensions, "extensions") + + @classmethod + def preset(cls, name: str) -> "AnthropicCompat": + key = _preset_key(name) + try: + return ANTHROPIC_PRESETS[key] + except KeyError: + raise ValueError(f"unknown AnthropicCompat preset: {name!r}") from None + + +ANTHROPIC_PRESETS: dict[str, AnthropicCompat] = { + "anthropic": AnthropicCompat(), + # DeepSeek over the Anthropic wire (api-docs.deepseek.com + # guide--anthropic-api.md; live 2026-09-03, receipts/2026-09-03-deepseek-anthropic): + # thinking={"type": enabled|disabled} + output_config.effort, budget_tokens + # ignored; cache_control ignored (implicit caching reports + # cache_read_input_tokens); output_config.format accepted and ignored; + # disable_parallel_tool_use ignored; claude-* model names silently + # remapped to DeepSeek models. + "deepseek": AnthropicCompat( + thinking_format="deepseek", + cache_control="none", + structured_output="reject", + parallel_tool_calls="reject", + model_prefixes=("deepseek-",), + tool_result_media="reject", # MAP-10: 200 and the model sees [Unsupported Image] — silent degrade + ), + # Meta Model API over the Anthropic wire (dev.meta.ai protocols-- + # messages.md, scraped 2026-09-03): thinking={"type": "adaptive"} + + # output_config.effort low|medium|high|xhigh, `disabled` is HTTP 400; + # caching is automatic (guide--prompt-caching.md names no cache_control + # block on this wire) so no marks are placed; output_config.format + # (json_schema) and disable_parallel_tool_use are documented as honoured. + "meta": AnthropicCompat( + thinking_format="adaptive", + cache_control="none", + structured_output="send", + parallel_tool_calls="send", + tool_result_media="native", # MAP-10: image/mixed/pair/pdf received (muse-spark-1.3) + ), + # Moonshot AI over the Anthropic wire (platform.kimi.ai messages-- + # create.md OpenAPI, scraped 2026-09-03): kimi-k3 only, always reasons; + # no `thinking` request field at all — `output_config.effort` low|high| + # max is the whole dial; `output_config.format` json_schema documented; + # caching automatic (no cache_control block documented); tool_choice + # auto|any|none. Live 2026-09-03 (receipts/2026-09-03-moonshotai- + # anthropic): `thinking: {type: disabled}` is honoured; `medium` and + # `bogus` efforts are accepted silently (allowlist); thinking blocks come + # back with `signature: ""` and an unsigned block is accepted back + # (replay "unsigned"); temperature and top_k are swallowed silently + # (reject); `disable_parallel_tool_use` is not in the schema (reject, + # the DeepSeek precedent). + "moonshotai": AnthropicCompat( + thinking_format="effort", + thinking_replay="unsigned", + cache_control="none", + structured_output="send", + parallel_tool_calls="reject", + sampling_params="reject", + reasoning_efforts=("low", "high", "max"), + model_prefixes=("kimi-",), + tool_result_media="images", # MAP-10: images received (kimi-k3); document block 400 "unsupported content type" + ), +} + +# Default base URLs for the Anthropic presets that name a server. +ANTHROPIC_PRESET_BASE_URLS: dict[str, str] = { + "anthropic": "https://api.anthropic.com/v1", + # api-docs.deepseek.com first-call.md: base_url https://api.deepseek.com/anthropic, + # which the Anthropic SDK completes to /anthropic/v1/messages (both the + # /anthropic and /anthropic/v1 roots answer, live 2026-09-03). + "deepseek": "https://api.deepseek.com/anthropic/v1", + # dev.meta.ai protocols--messages.md: base host https://api.meta.ai, the + # SDK appends /v1/messages — so the /v1 root, like the other two wires. + "meta": "https://api.meta.ai/v1", + # platform.kimi.ai api--overview.md: base_url https://api.moonshot.ai/anthropic, + # endpoint /anthropic/v1/messages — so the /anthropic/v1 root. + "moonshotai": "https://api.moonshot.ai/anthropic/v1", +} + + +@dataclass(frozen=True, slots=True) +class ResolvedAnthropicCompat: + """Fully resolved Anthropic Messages compatibility policy.""" + + thinking_format: Literal["anthropic", "deepseek", "adaptive", "effort"] = "anthropic" + thinking_replay: Literal["signed", "unsigned"] = field(default="signed", kw_only=True) + cache_control: Literal["anthropic", "none"] = "anthropic" + structured_output: Literal["send", "reject"] = "send" + parallel_tool_calls: Literal["send", "reject"] = "send" + sampling_params: Literal["send", "reject"] = field(default="send", kw_only=True) + # tool_result.content takes image and document blocks (SDK ToolResultBlockParam; + # anthropic/claude-code exact 2026-09-07): native unless a server proved otherwise. + tool_result_media: Literal["native", "images", "reject"] = field(default="native", kw_only=True) + reasoning_efforts: tuple[str, ...] | None = field(default=None, kw_only=True) + model_prefixes: tuple[str, ...] | None = None + extensions: JsonObject | None = None + + +_ANTHROPIC_AUTO_DEFAULTS: dict[str, str] = { + "thinking_format": "anthropic", + "thinking_replay": "signed", + "cache_control": "anthropic", + "structured_output": "send", + "parallel_tool_calls": "send", + "sampling_params": "send", + "tool_result_media": "native", +} + + +def resolve_anthropic_compat(partial: AnthropicCompat) -> ResolvedAnthropicCompat: + """Resolve a partial Anthropic compat object into concrete serializer policy.""" + kwargs: dict[str, object] = {} + for field_name, default in _ANTHROPIC_AUTO_DEFAULTS.items(): + value = getattr(partial, field_name) + kwargs[field_name] = default if value in {None, "auto"} else value + return ResolvedAnthropicCompat( + reasoning_efforts=partial.reasoning_efforts, + model_prefixes=partial.model_prefixes, + extensions=partial.extensions, + **kwargs, # type: ignore[arg-type] + ) + + +CompatProfile: TypeAlias = OpenAIResponsesCompat | OpenAIChatCompat | AnthropicCompat + + +# ─── Merge helpers ────────────────────────────────────────────────── + + +def merge_openai_responses_compat( + base: OpenAIResponsesCompat, + override: OpenAIResponsesCompat | None, +) -> OpenAIResponsesCompat: + """Merge partial OpenAI Responses compat objects. + + None fields inherit. Non-None fields, including "auto", override. + """ + if override is None: + return base + + kwargs = {} + for f in fields(OpenAIResponsesCompat): + value = getattr(override, f.name) + if f.name == "extensions": + kwargs[f.name] = _merge_json_object(base.extensions, override.extensions) + else: + kwargs[f.name] = getattr(base, f.name) if value is None else value + return OpenAIResponsesCompat(**kwargs) + + +def resolve_openai_responses_compat(partial: OpenAIResponsesCompat) -> ResolvedOpenAIResponsesCompat: + """Resolve a partial compat object into concrete serializer policy.""" + developer_role = partial.developer_role + if developer_role in {None, "auto"}: + developer_role = "developer" + + max_field = partial.max_output_tokens_field + if max_field in {None, "auto"}: + max_field = "max_output_tokens" + + reasoning_format = partial.reasoning_format + if reasoning_format in {None, "auto"}: + reasoning_format = "responses_reasoning" + + tool_result_name = partial.tool_result_name + if tool_result_name in {None, "auto"}: + tool_result_name = "omit" + + strict_tools = partial.strict_tools + if strict_tools in {None, "auto"}: + strict_tools = "omit" + + cache_control = partial.cache_control + if cache_control in {None, "auto"}: + cache_control = "openai" + + commentary_phase = partial.commentary_phase + if commentary_phase in {None, "auto"}: + commentary_phase = "omit" + + edit_image_field = partial.edit_image_field + if edit_image_field in {None, "auto"}: + edit_image_field = "array" + + builtin_tools = partial.builtin_tools + if builtin_tools in {None, "auto"}: + builtin_tools = "openai" + + tool_result_media = partial.tool_result_media + if tool_result_media in {None, "auto"}: + tool_result_media = "native" + + return ResolvedOpenAIResponsesCompat( + developer_role=developer_role, # type: ignore[arg-type] + max_output_tokens_field=max_field, # type: ignore[arg-type] + reasoning_format=reasoning_format, # type: ignore[arg-type] + tool_result_name=tool_result_name, # type: ignore[arg-type] + strict_tools=strict_tools, # type: ignore[arg-type] + cache_control=cache_control, # type: ignore[arg-type] + commentary_phase=commentary_phase, # type: ignore[arg-type] + edit_image_field=edit_image_field, # type: ignore[arg-type] + builtin_tools=builtin_tools, # type: ignore[arg-type] + tool_result_media=tool_result_media, # type: ignore[arg-type] + routing=partial.routing, + extensions=partial.extensions, + ) diff --git a/credentials.py b/credentials.py new file mode 100644 index 0000000000..120583b580 --- /dev/null +++ b/credentials.py @@ -0,0 +1,190 @@ +""" +lm15.credentials — a credential is a closed sum, not a string. + +spec/auth.md AUTH-2 (amended 2026-09-03, changes/2026-09-03-cloud-hosts.md): + + ApiKey {"kind": "api_key", "value"} + BearerToken {"kind": "bearer_token", "value", "expires_at"?} + AwsCredentials {"kind": "aws", "access_key_id", "secret_access_key", + "session_token"?, "expires_at"?} + +A credential *provider* (the AUTH-2 zero-arg callable) returns one of these. +A plain string anywhere a credential is accepted reads as an ``ApiKey`` — +the shape every provider used before cloud hosts existed, kept so nothing +a user wrote breaks. ``expires_at`` is an aware UTC datetime; absent +means non-expiring. + +Secrecy (AUTH-5): reprs never show values; ``to_dict`` is the only way out +and callers that serialize a credential are the ones sending it on the +wire or writing a fixture on purpose. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Callable, Literal, Union + +__all__ = [ + "ApiKey", + "AwsCredentials", + "BearerToken", + "CredentialKind", + "CredentialValue", + "CredentialProvider", + "CredentialLike", + "coerce_credential", + "credential_from_dict", + "credential_to_dict", + "parse_rfc3339", + "format_rfc3339", +] + +CredentialKind = Literal["api_key", "bearer_token", "aws"] +CREDENTIAL_KINDS: frozenset[str] = frozenset({"api_key", "bearer_token", "aws"}) + +_EXPIRY_SKEW_SECONDS = 300 # AUTH-3: inside the skew window counts as expired. + + +def parse_rfc3339(value: str) -> datetime: + """``2026-09-03T12:00:00Z`` (or an offset) → aware UTC datetime.""" + text = value.strip() + if text.endswith("Z") or text.endswith("z"): + text = text[:-1] + "+00:00" + parsed = datetime.fromisoformat(text) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def format_rfc3339(value: datetime) -> str: + """Aware datetime → ``YYYY-MM-DDTHH:MM:SSZ`` (whole seconds, UTC).""" + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _expired(expires_at: datetime | None, now: datetime | None) -> bool: + if expires_at is None: + return False + current = now if now is not None else datetime.now(timezone.utc) + return (expires_at - current).total_seconds() <= _EXPIRY_SKEW_SECONDS + + +@dataclass(frozen=True, slots=True) +class ApiKey: + value: str + + kind: Literal["api_key"] = field(default="api_key", init=False) + + def __post_init__(self) -> None: + if not isinstance(self.value, str) or not self.value: + raise ValueError("ApiKey.value must be a non-empty string") + + def __repr__(self) -> str: # AUTH-5 + return "ApiKey()" + + def is_expired(self, now: datetime | None = None) -> bool: + return False + + +@dataclass(frozen=True, slots=True) +class BearerToken: + value: str + expires_at: datetime | None = None + + kind: Literal["bearer_token"] = field(default="bearer_token", init=False) + + def __post_init__(self) -> None: + if not isinstance(self.value, str) or not self.value: + raise ValueError("BearerToken.value must be a non-empty string") + if self.expires_at is not None and self.expires_at.tzinfo is None: + raise ValueError("BearerToken.expires_at must be timezone-aware") + + def __repr__(self) -> str: # AUTH-5 + tail = f", expires_at={format_rfc3339(self.expires_at)}" if self.expires_at else "" + return f"BearerToken({tail})" + + def is_expired(self, now: datetime | None = None) -> bool: + return _expired(self.expires_at, now) + + +@dataclass(frozen=True, slots=True) +class AwsCredentials: + access_key_id: str + secret_access_key: str + session_token: str | None = None + expires_at: datetime | None = None + + kind: Literal["aws"] = field(default="aws", init=False) + + def __post_init__(self) -> None: + if not isinstance(self.access_key_id, str) or not self.access_key_id or not isinstance(self.secret_access_key, str) or not self.secret_access_key: + raise ValueError("AwsCredentials needs non-empty string access_key_id and secret_access_key") + if self.session_token is not None and (not isinstance(self.session_token, str) or not self.session_token): + raise ValueError("AwsCredentials.session_token must be a non-empty string") + if self.expires_at is not None and self.expires_at.tzinfo is None: + raise ValueError("AwsCredentials.expires_at must be timezone-aware") + + def __repr__(self) -> str: # AUTH-5: the key id is not secret, the rest is. + tail = f", expires_at={format_rfc3339(self.expires_at)}" if self.expires_at else "" + return f"AwsCredentials(access_key_id={self.access_key_id!r}, {tail})" + + def is_expired(self, now: datetime | None = None) -> bool: + return _expired(self.expires_at, now) + + +CredentialValue = Union[ApiKey, BearerToken, AwsCredentials] +CredentialProvider = Callable[[], Union[str, CredentialValue]] +# What every ``api_key=`` argument accepts: a string, a value, or a provider. +CredentialLike = Union[str, CredentialValue, CredentialProvider] + + +def coerce_credential(value: str | CredentialValue) -> CredentialValue: + """A string reads as an ``ApiKey``; a value passes through.""" + if isinstance(value, (ApiKey, BearerToken, AwsCredentials)): + return value + if isinstance(value, str): + return ApiKey(value) + raise TypeError(f"not a credential: {type(value).__name__}") + + +def credential_to_dict(value: CredentialValue) -> dict: + """Canonical JSON (AUTH-2). Absent fields are omitted, never null.""" + if isinstance(value, ApiKey): + return {"kind": "api_key", "value": value.value} + if isinstance(value, BearerToken): + out = {"kind": "bearer_token", "value": value.value} + if value.expires_at is not None: + out["expires_at"] = format_rfc3339(value.expires_at) + return out + if isinstance(value, AwsCredentials): + out = { + "kind": "aws", + "access_key_id": value.access_key_id, + "secret_access_key": value.secret_access_key, + } + if value.session_token is not None: + out["session_token"] = value.session_token + if value.expires_at is not None: + out["expires_at"] = format_rfc3339(value.expires_at) + return out + raise TypeError(f"not a credential: {type(value).__name__}") + + +def credential_from_dict(data: dict) -> CredentialValue: + kind = data.get("kind") + expires = parse_rfc3339(str(data["expires_at"])) if data.get("expires_at") is not None else None + if kind == "api_key": + return ApiKey(data["value"]) + if kind == "bearer_token": + return BearerToken(data["value"], expires_at=expires) + if kind == "aws": + token = data.get("session_token") + return AwsCredentials( + access_key_id=data["access_key_id"], + secret_access_key=data["secret_access_key"], + session_token=token, + expires_at=expires, + ) + raise ValueError("unknown credential kind") diff --git a/doctor.py b/doctor.py new file mode 100644 index 0000000000..63236e4895 --- /dev/null +++ b/doctor.py @@ -0,0 +1,420 @@ +""" +lm15.doctor — explain how a provider's credential would resolve. No secrets. + +:func:`explain_auth` answers "why is my key (not) being used?" without a +network call and without ever returning secret material. It walks the exact +chain the router's ``lm()`` walks — explicit ``api_keys`` entry, declared +environment variables in order, borrowed local CLI credentials for OAuth +providers, a local server's placeholder key — and reports the state of every +rung, including rungs that are set but shadowed by an earlier one. + +Purity note: both the router's lookup and ``explain_auth`` check +configured keys and environment presence, so secret values transit process +memory. Neither invokes credential providers during inspection. The doctor +also reads local credential files to report stored-login availability. +They are never stored on the report, never included in ``describe()``, and +never part of any repr. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +from .auth import ( + CLAUDE_CODE_CREDENTIALS_PATH, + CODEX_CLI_AUTH_PATH, + LocalOAuthCredential, + _load_xai_with_source, + _xai_store_paths, + read_claude_code_credential, + read_codex_cli_credential, +) +from .errors import NotConfiguredError +from .providers import Credential +from .router import ( + ADAPTERS, + RouterConfig, + Resolution, + _api_keys_source, + _canonical_provider, + _credential_policy, + _declared_env_keys, + _routable, +) + +__all__ = ["AuthReport", "AuthStep", "explain_auth"] + + +@dataclass(frozen=True, slots=True) +class AuthStep: + """One rung of the credential chain. + + ``state`` is one of: + + - ``"selected"`` — this rung supplies the credential; + - ``"shadowed"`` — usable, but an earlier rung wins; + - ``"absent"`` — nothing here; + - ``"unprobed"`` — a network or subprocess rung of a cloud chain whose + configuration is present; the offline doctor did not contact it + (AUTH-7). It runs before any later ``selected`` rung at request time + and may win. + + ``kind`` is the language-neutral source identifier from the contract + fixtures (lm15-contract/auth/resolution.json): ``"api_keys"``, + ``"env:"``, ``"placeholder"``, or ``"oauth-file"``. Conformance + compares kinds, never display strings. + + ``detail`` is human text and carries no secret material by construction. + """ + + kind: str + source: str + detail: str + state: str + + def describe(self) -> str: + marker = {"selected": "=> ", "shadowed": " ~ ", "absent": " - ", "unprobed": " ? "}[self.state] + return f"{marker}{self.source}: {self.detail}" + + +@dataclass(frozen=True, slots=True) +class AuthReport: + provider: str + steps: tuple[AuthStep, ...] + configured: bool + settings: tuple[tuple[str, str], ...] = () # resolved host settings (AUTH-7: printed by name and value) + # AUTH-1 named credential (amended 2026-09-19): the name and what it + # means on this cloud, when the config pins one; the steps are then + # only the rungs it covers. + named: str | None = None + named_meaning: str | None = None + # The base URL the door will send to, and where it came from + # (``"base_urls"``, an env variable name, or ``"template"``). + base_url: str | None = None + base_url_source: str | None = None + + @property + def selected(self) -> AuthStep | None: + for step in self.steps: + if step.state == "selected": + return step + return None + + def describe(self) -> str: + lines = [f"auth for provider {self.provider!r}:"] + if self.named: + lines.append(f' named credential "{self.named}": {self.named_meaning} — the chain is not walked') + lines += [f" {step.describe()}" for step in self.steps] + unprobed = [step for step in self.steps if step.state == "unprobed"] + if self.configured and self.selected is not None: + lines.append(f" configured: yes — {self.selected.source}") + if unprobed: + lines.append(f" note: {', '.join(s.source for s in unprobed)} run first at request time and may win") + elif self.configured: + lines.append(f" configured: probably — {', '.join(s.source for s in unprobed)} (unprobed offline)") + else: + lines.append(" configured: no") + for name, value in self.settings: + lines.append(f" setting {name}: {value}") + if self.base_url: + origin = f" (from {self.base_url_source})" if self.base_url_source else "" + lines.append(f" base url: {self.base_url}{origin}") + return "\n".join(lines) + + def __str__(self) -> str: + return self.describe() + + +def _expiry_detail(credential: LocalOAuthCredential) -> str: + if credential.expires_at is None: + return "no recorded expiry" + remaining_ms = credential.expires_at - int(time.time() * 1000) + if remaining_ms <= 0: + suffix = "refresh token present" if credential.refresh_token else "NO refresh token" + return f"expired, {suffix}" + minutes = remaining_ms // 60_000 + hours, minutes = divmod(minutes, 60) + span = f"{hours}h {minutes:02d}m" if hours else f"{minutes}m" + return f"fresh, expires in {span}" + + +def _usable_state(credential: LocalOAuthCredential, detail: str, shadowed: bool) -> str: + if "expired" in detail and not credential.refresh_token: + return "absent" + return "shadowed" if shadowed else "selected" + + +def _oauth_step(provider: str, path_override: str | None) -> AuthStep: + if provider == "claude-code": + default_path, reader = CLAUDE_CODE_CREDENTIALS_PATH, read_claude_code_credential + else: + default_path, reader = CODEX_CLI_AUTH_PATH, read_codex_cli_credential + path = Path(path_override).expanduser() if path_override else default_path + source = f"local OAuth credential {path}" + credential = reader(path) + if credential is None: + return AuthStep(kind="oauth-file", source=source, detail="missing or unreadable", state="absent") + detail = _expiry_detail(credential) + return AuthStep(kind="oauth-file", source=source, detail=detail, state=_usable_state(credential, detail, shadowed=False)) + + +def _xai_oauth_step(path_override: str | None, shadowed: bool) -> AuthStep: + """The stored xAI subscription login (lm15's own store, then the Pi + agent store) — the middle rung of the oauth-unless-explicit chain: + beaten only by an explicit api_keys entry, and itself beating env.""" + paths = (Path(path_override).expanduser(),) if path_override else _xai_store_paths() + try: + credential, path = _load_xai_with_source(path_override) + except NotConfiguredError: + checked = " or ".join(str(p) for p in paths) + return AuthStep(kind="oauth-file", source=f"local OAuth credential {checked}", detail="missing or unreadable", state="absent") + detail = _expiry_detail(credential) + return AuthStep( + kind="oauth-file", + source=f"local OAuth credential {path}", + detail=detail, + state=_usable_state(credential, detail, shadowed=shadowed), + ) + + +def explain_auth( + provider: str | Resolution, + *, + env: Mapping[str, str] | None = None, + api_keys: Mapping[str, Credential] | None = None, + claude_credentials_path: str | None = None, + codex_auth_path: str | None = None, + xai_credentials_path: str | None = None, + files: Mapping[str, str] | None = None, + home: str | None = None, + settings: Mapping[str, str] | None = None, + config: RouterConfig | None = None, + credential: str | None = None, + base_url: str | None = None, +) -> AuthReport: + """Explain, rung by rung, how ``provider``'s credential resolves. + + Accepts a provider name or a router ``Resolution``. A resolution + supplies only the provider identity, not credentials: pass + ``config=router.config`` to inspect that router's explicit settings. + + Mirrors the router's construction chain exactly; divergence between this + report and ``lm()`` behavior is a bug. ``env`` defaults to + ``os.environ`` (pass a mapping for hermetic tests). Never returns or + prints secret values, and performs no network I/O. + + ``config`` is the router's own ``RouterConfig`` (``router.config``): its + ``env``, ``api_keys``, ``credentials``, ``base_urls`` and the provider's + ``settings`` entry are read, so the report describes the router that + will send the request. An explicit ``env=``/``api_keys=``/``settings=`` + /``credential=``/``base_url=`` argument wins over the config's field. + + On a cloud door the report also carries the base URL the door will + send to and where it came from, and, under a named credential + (``credential="platform"`` …), only the rungs that name covers. + """ + import os + + if isinstance(provider, Resolution): + provider = provider.provider + if not isinstance(provider, str): + raise TypeError("provider must be a provider name or Resolution") + canonical = _canonical_provider(provider) + if config is not None: + if env is None: + env = config.env + if api_keys is None: + api_keys = config.api_keys + if settings is None and config.settings is not None: + settings = config.settings.get(canonical) or config.settings.get(provider) + if credential is None: + from .router import _credentials_entry + + credential = _credentials_entry(config, canonical) + if base_url is None: + from .router import _base_url_entry + + base_url = _base_url_entry(config, canonical) + if not _routable(canonical, ADAPTERS): + from .registry import PROVIDERS + + known = sorted(PROVIDERS) + raise ValueError(f"Unknown provider {provider!r}. Known providers: {', '.join(known)}") + + policy = _credential_policy(canonical) + if policy in ("aws-chain", "azure-chain", "gcp-chain") or ( + _bound_definition(canonical) is not None and _bound_definition(canonical).hosted + ): + return _explain_cloud(canonical, env=env, api_keys=api_keys, files=files, home=home, settings=settings, + credential=credential, base_url=base_url) + if credential is not None: + raise NotConfiguredError( + f"{canonical}: credential={credential!r} names a cloud identity, and this is not a cloud door", + provider=canonical, + ) + if policy == "oauth": + override = claude_credentials_path if canonical == "claude-code" else codex_auth_path + step = _oauth_step(canonical, override) + return AuthReport(provider=canonical, steps=(step,), configured=step.state == "selected") + + config = RouterConfig(env=env, api_keys=api_keys) + environment = env if env is not None else os.environ + steps: list[AuthStep] = [] + selected = False + + entry = _api_keys_source(config, canonical) + if entry is not None: + steps.append( + AuthStep( + kind="api_keys", + source=_entry_source(canonical, entry), + detail="provided (value never shown)", + state="selected", + ) + ) + selected = True + else: + steps.append( + AuthStep( + kind="api_keys", + source="explicit api_keys entry", + detail="not provided", + state="absent", + ) + ) + + if policy == "oauth-unless-explicit": + # The stored subscription login outranks env keys (AUTH-1): it + # spends no money per token. Only the explicit api_keys entry + # above can shadow it. + step = _xai_oauth_step(xai_credentials_path, shadowed=selected) + steps.append(step) + selected = selected or step.state == "selected" + + for key in _declared_env_keys(canonical, ADAPTERS): + if environment.get(key): + state = "shadowed" if selected else "selected" + steps.append( + AuthStep(kind=f"env:{key}", source=f"env ${key}", detail="set (value never shown)", state=state) + ) + selected = True + else: + steps.append( + AuthStep(kind=f"env:{key}", source=f"env ${key}", detail="not set", state="absent") + ) + + from .registry import PROVIDERS + + definition = PROVIDERS.get(canonical) + if definition is not None and definition.placeholder_key is not None: + state = "shadowed" if selected else "selected" + steps.append( + AuthStep( + kind="placeholder", + source="local-server placeholder key", + detail=f"preset default for keyless {canonical} servers", + state=state, + ) + ) + selected = True + + return AuthReport(provider=canonical, steps=tuple(steps), configured=selected) + + +def _entry_source(provider: str, entry: str | None) -> str: + source = "explicit api_keys entry" + if entry is not None and _canonical_provider(entry) != provider: + source += f" (via {entry!r}, shared env-key declarations)" + return source + + +def _bound_definition(canonical: str): + from .registry import PROVIDERS + + return PROVIDERS.get(canonical) + + +def _explain_cloud( + canonical: str, + *, + env: Mapping[str, str] | None, + api_keys: Mapping[str, Credential] | None, + files: Mapping[str, str] | None, + home: str | None, + settings: Mapping[str, str] | None, + credential: str | None = None, + base_url: str | None = None, +) -> AuthReport: + """The cloud-chain walk (AUTH-1 aws/azure/gcp chains) through + ``lm15.cloud.chains.explain``: offline, files from ``files`` when the + harness materialized them, host settings resolved and printed, the + base URL and its origin named.""" + import os + + from .cloud.chains import ChainContext, explain, named_meaning, profile_settings + from .cloud.hosts import endpoint_from_env, render_base_url, resolve_settings + + definition = _bound_definition(canonical) + policy = definition.access + environment = env if env is not None else os.environ + config = RouterConfig(env=env, api_keys=api_keys, credentials={canonical: credential} if credential else None) + if credential is not None: + from .router import _check_named_credential + + _check_named_credential(config, canonical, credential, ADAPTERS) + entry = _api_keys_source(config, canonical) + has_entry = entry is not None + resolved: dict[str, str] = {} + setting_error: str | None = None + ctx = ChainContext( + env=environment, + home=Path(home).expanduser() if home else (Path(environment["HOME"]) if environment.get("HOME") else Path.home()), + files=files, + ) + endpoint_source: str | None = None + if base_url is not None: + endpoint_source = "base_urls" + elif policy.host is not None: + base_url = endpoint_from_env(policy.host, environment) + if base_url is not None: + endpoint_source = next(var for var in policy.host.endpoint_env if (environment.get(var) or "").strip()) + endpoint_source = f"env ${endpoint_source}" + try: + resolved = resolve_settings(policy.host, settings, environment, provider=canonical, + profile=profile_settings(policy, ctx), endpoint=base_url) + except NotConfiguredError as exc: + setting_error = str(exc).splitlines()[0] + ctx.settings = resolved + rendered: str | None = None + if policy.host is not None and setting_error is None: + try: + rendered = render_base_url(policy.host, resolved, base_url, provider=canonical) + except NotConfiguredError as exc: + setting_error = str(exc).splitlines()[0] + if policy.cloud_chain: + steps, configured = explain(policy, ctx, explicit=has_entry, named=credential) + out = [AuthStep(kind=s.kind, source=_entry_source(canonical, entry) if s.kind == "api_keys" else s.source, + detail=s.detail, state=s.state) for s in steps] + else: + # A hosted door with the ordinary key chain (vertex-express). + out = [AuthStep("api_keys", _entry_source(canonical, entry), + "provided (value never shown)" if has_entry else "not provided", + "selected" if has_entry else "absent")] + configured = has_entry + for key in policy.env_keys: + if environment.get(key): + out.append(AuthStep(f"env:{key}", f"env ${key}", "set (value never shown)", "shadowed" if configured else "selected")) + configured = True + else: + out.append(AuthStep(f"env:{key}", f"env ${key}", "not set", "absent")) + shown = tuple(sorted(resolved.items())) + if setting_error: + shown = shown + (("error", setting_error),) + return AuthReport( + provider=canonical, steps=tuple(out), configured=configured, settings=shown, + named=credential, named_meaning=named_meaning(policy, credential) if credential else None, + base_url=rendered, base_url_source=(endpoint_source or "template") if rendered else None, + ) diff --git a/errors.py b/errors.py new file mode 100644 index 0000000000..ff51751d7d --- /dev/null +++ b/errors.py @@ -0,0 +1,636 @@ +""" +lm15.errors — Canonical error taxonomy. + +Every provider maps its idiosyncratic error shapes onto this hierarchy. +Error classes are the primary signal; canonical string codes exist for +serialization and wire formats. + +Hierarchy: + LM15Error + ├── TransportError (network/connection failures at the LM layer) + ├── LockTimeoutError (the credential-file lock could not be taken; local, transient) + ├── StreamAssemblyError (a stream cannot become a Response without inventing a fact; MAP-9) + ├── ConfigurationError (local SDK/configuration failures) + │ ├── NotConfiguredError (no API key or required provider config) + │ ├── UnknownModelError (the router: a model string that routes nowhere) + │ └── AmbiguousModelError (the router: a catalog match under more than one provider) + ├── CapabilityError (local provider-adapter capability failures) + │ └── UnsupportedFeatureError + └── ProviderError (provider returned an error response) + ├── AuthError (401/403 — bad or missing API key) + ├── BillingError (402 — payment/quota issue) + ├── RateLimitError (429 — too many requests) + ├── InvalidRequestError (4xx request-shape/resource errors) + │ ├── ContextLengthError (input too long for model) + │ └── UnsupportedModelError + ├── TimeoutError (408/504 — request timed out) + └── ServerError (5xx — provider-side failure) +""" + +from __future__ import annotations + +import builtins +from typing import TYPE_CHECKING, Mapping, Sequence + +from .rate_limits import diagnostics_text, freeze_rate_limits + +if TYPE_CHECKING: # pragma: no cover + from .types import Response + + +class LM15Error(Exception): + """Base for all lm15 errors. + + Errors keep the human-readable exception message in ``str(error)`` while + also exposing structured metadata for logging, telemetry, retries, and + programmatic handling. + """ + + default_code: str | None = None + + def __init__( + self, + message: str = "", + *, + code: str | None = None, + provider: str | None = None, + provider_code: str | None = None, + status: int | None = None, + request_id: str | None = None, + retry_after: float | None = None, + rate_limit_headers: Mapping[str, Sequence[str]] | None = None, + ) -> None: + self.rate_limit_headers = freeze_rate_limits(rate_limit_headers) + self.message = message + self.code = code or self.default_code + self.provider = provider + self.provider_code = provider_code + self.status = status + self.request_id = request_id + # Number rule (docs/serde-rules.md): retry_after is float-typed; + # same-valued int input (e.g. a Retry-After header of "30") coerces. + if type(retry_after) is int: + retry_after = float(retry_after) + self.retry_after = retry_after + super().__init__(message) + + +class TransportError(LM15Error): + """High-level LM transport failure. + + Provider LMs wrap lower-level ``lm15.transports.TransportError`` exceptions + into this class. + """ + + default_code = "transport" + + +class LockTimeoutError(LM15Error): + """The credential-file lock (spec/auth.md AUTH-4) could not be taken + within the timeout: another lm15 process is refreshing the same + credential (``lock_timeout``, 2026-09-08). + + Local and transient — a root-level class beside :class:`TransportError`, + never a :class:`ProviderError` (no provider was asked) and never an + :class:`AuthError` (nothing is wrong with the credential; AUTH-6). + Retryable. Carries ``path`` (the guarded file) and ``lock_path``. + :class:`lm15.auth.CredentialLockTimeout` is this class and the builtin + ``TimeoutError`` at once, so ``except TimeoutError`` keeps working. + """ + + default_code = "lock_timeout" + + def __init__( + self, + message: str = "", + *, + path: str = "", + lock_path: str = "", + **kwargs, + ) -> None: + self.path = path + self.lock_path = lock_path + super().__init__(message, **kwargs) + + +class CollectionLimitError(LM15Error): + """A local turn collector reached its budget, not a provider failure. + + Non-retryable. ``partial_events`` preserves the accepted events without + copying their payloads. ``partial`` materializes them on demand as an + incomplete Turn. A byte-limit failure also exposes ``rejected_event``: + it was received but not yielded or added to the collection. Process it + before resuming raw session reads if that content is needed. + """ + + default_code = "collection_limit" + + def __init__( + self, message: str = "", *, limit: str | None = None, + maximum: int | None = None, retained_bytes: int = 0, + partial_events: tuple = (), rejected_event=None, **kwargs, + ) -> None: + self.limit = limit + self.maximum = maximum + self.retained_bytes = retained_bytes + self.partial_events = tuple(partial_events) + self.retained_events = len(self.partial_events) + self.rejected_event = rejected_event + super().__init__(message, **kwargs) + + @property + def partial(self): + # Lazy: allocating combined text/audio while handling a collection + # limit would amplify memory pressure. Raw events are always available. + from dataclasses import replace + from .live import _materialize_turn + + return replace(_materialize_turn(self.partial_events), ended_by="incomplete") + + +class StreamAssemblyError(LM15Error): + """A stream could not be assembled into a Response without inventing a fact. + + Three defects raise it (MAP-9 and MAP-3, contract change + 2026-09-11-stream-completion): + + - a tool call's fragments never carried a name (MAP-9): an unnamed call + is not actionable (MAP-1), and guessing a name from the request + dispatches the wrong function silently; + - the stream ended without an end event: the finish reason and usage + never arrived, and reporting the text as a finished turn would invent + both; + - an event arrived after the end event (MAP-3): it has no place in the + Response, and dropping it would be silent loss. + + All three are adapter or source defects, not model behaviour — every + shipped dialect names a call on its first fragment and ends exactly + once — so the message points at the adapter. + + ``partial`` is everything that did assemble (text, thinking, other + parts, usage, finish reason) with the offending material left out, so a + caller that wants to salvage the turn can; ``part_index`` is the first + offending part (MAP-9 only). + + What does NOT raise it: a failure after the end event that is not an + event — the source raising while it drains, or its ``close()`` raising. + The Response is complete; it is returned, and the failure is reported + as a :class:`StreamCleanupWarning`. + """ + + default_code = "stream_assembly" + + def __init__( + self, + message: str = "", + *, + partial: "Response | None" = None, + part_index: int | None = None, + **kwargs, + ) -> None: + super().__init__(message, **kwargs) + self.partial = partial + self.part_index = part_index + + +class StreamCleanupWarning(RuntimeWarning): + """A stream's source failed after the Response was already complete. + + Emitted (``warnings.warn``) when, after the end event has been yielded, + the source raises while draining or its ``close()``/``aclose()`` raises. + The provider finished the turn and billed it; the Response is returned + unchanged. The failure is about the connection's afterlife, not the + answer, so it is never raised from ``response`` — a caller who wants it + programmatically reads ``ResponseStream.cleanup_errors``, and a caller + who wants it fatal runs with ``-W error::lm15.errors.StreamCleanupWarning``. + """ + + +class ConfigurationError(LM15Error): + """Local SDK or provider-adapter configuration failure.""" + + default_code = "not_configured" + + +class CapabilityError(LM15Error): + """Requested capability is not supported by this provider adapter. + + ``feature`` (MAP-13, 2026-09-14) is the config path of what was + refused — ``config.top_k``, ``config.reasoning.thinking_budget``, + ``messages[0].parts[1]``, ``tools[2]`` — so the caller's own policy + layer can drop it and retry without parsing the message. Absent + when the refusal is not about one addressable field. + """ + + default_code = "unsupported_feature" + + def __init__(self, message: str = "", *, feature: str | None = None, **kwargs) -> None: + self.feature = feature + super().__init__(message, **kwargs) + + +class ProviderError(LM15Error): + """The provider returned an error response.""" + + default_code = "provider" + + def __str__(self) -> str: + # The displayed form carries provider / HTTP status / request id so + # a bare relay like "model: x" still says who said it. The + # ``message`` FIELD stays untouched (it is what the contract pins). + context = ", ".join( + item + for item in ( + self.provider, + f"HTTP {self.status}" if self.status is not None else None, + f"request {self.request_id}" if self.request_id else None, + ) + if item + ) + base = self.message or self.code + head, sep, tail = base.partition("\n\n") + suffix = f" ({context})" if context else "" + details = diagnostics_text(self.rate_limit_headers, self.retry_after) + return f"{head}{suffix}{details}" + (f"\n\n{tail}" if sep else "") + + +class AuthError(ProviderError): + """Authentication failed — invalid, expired, or missing API key.""" + + default_code = "auth" + + def __init__( + self, + message: str = "", + *, + provider: str | None = None, + env_keys: tuple[str, ...] = (), + credential_hint: str | None = None, + **kwargs, + ) -> None: + if provider is not None: + kwargs.setdefault("provider", provider) + provider_name = provider or kwargs.get("provider") + self.env_keys = tuple(env_keys) + self.credential_hint = credential_hint + # AUTH-1 provenance: where the rejected credential came from (a + # label, never the value); set by ``with_credential_origin``. + self.credential_origin: str | None = None + + if credential_hint: + # Subscription/OAuth adapters: guidance is how to re-login, not + # which env var to set (there is none). + guidance = f"\n\n To fix:\n - {credential_hint}\n" + else: + guidance = ( + "\n\n" + " To fix:\n" + " - Check that your API key is correct and not expired\n" + ) + if self.env_keys: + keys = " or ".join(f"{key}=..." for key in self.env_keys) + guidance += f" - Pass the key explicitly (api_key, or RouterConfig api_keys), or on a host with an environment set {keys}\n" + else: + guidance += " - Pass the key explicitly (api_key, or RouterConfig api_keys)\n" + if provider_name: + guidance += f" - Verify your {provider_name} account/project has access\n" + + super().__init__(_append_guidance(message, guidance), **kwargs) + + +class RateLimitError(ProviderError): + """Rate limited by the provider (HTTP 429).""" + + default_code = "rate_limit" + + def __init__(self, message: str = "", **kwargs) -> None: + guidance = ( + "\n\n" + " To fix:\n" + " - Wait a moment and retry\n" + " - Retry with backoff in your application layer (lm15 never retries for you)\n" + " - Check the reported limits and deployment capacity; a 429 does not prove the endpoint is unsupported\n" + ) + super().__init__(_append_guidance(message, guidance), **kwargs) + + +class BillingError(ProviderError): + """402 — billing or payment issue.""" + + default_code = "billing" + + +class TimeoutError(ProviderError, builtins.TimeoutError): + """Provider request timed out. + + Also subclasses the builtin ``TimeoutError`` so a user's bare + ``except TimeoutError:`` catches lm15 timeouts. ``ProviderError`` comes + first in the MRO, so lm15 metadata (``code``, ``status``, ...) wins. + """ + + default_code = "timeout" + + +# Descriptive alias that avoids shadowing Python's built-in TimeoutError in new code. +RequestTimeoutError = TimeoutError + + +class InvalidRequestError(ProviderError): + """Bad request shape or invalid provider resource (4xx).""" + + default_code = "invalid_request" + + +class ContextLengthError(InvalidRequestError): + """The input exceeds the model's context window.""" + + default_code = "context_length" + + def __init__(self, message: str = "", **kwargs) -> None: + guidance = ( + "\n\n" + " To fix:\n" + " - Reduce the prompt or system prompt length\n" + " - Clear conversation history\n" + " - Use a model with a larger context window\n" + " - Lower max_tokens to leave more room for input\n" + ) + super().__init__(_append_guidance(message, guidance), **kwargs) + + +class UnsupportedModelError(InvalidRequestError): + """Model not found, unavailable, or unsupported by the provider.""" + + default_code = "unsupported_model" + + +class ServerError(ProviderError): + """Provider-side failure (5xx).""" + + default_code = "server" + + +class UnsupportedFeatureError(CapabilityError): + """Feature not supported by this provider adapter.""" + + default_code = "unsupported_feature" + + +class NotConfiguredError(ConfigurationError): + """No API key or required provider configuration was found.""" + + default_code = "not_configured" + + def __init__( + self, + message: str = "", + *, + provider: str | None = None, + env_keys: tuple[str, ...] = (), + credential_hint: str | None = None, + **kwargs, + ) -> None: + if provider is not None: + kwargs.setdefault("provider", provider) + provider_name = provider or kwargs.get("provider") + self.env_keys = tuple(env_keys) + self.credential_hint = credential_hint + + guidance = "" + if credential_hint: + guidance = f"\n\n To fix:\n - {credential_hint}\n" + elif self.env_keys or provider_name: + guidance = "\n\n To fix:\n" + if self.env_keys: + keys = " or ".join(f"{key}=..." for key in self.env_keys) + guidance += f" - Pass the key explicitly (api_key, or RouterConfig api_keys), or on a host with an environment set {keys}\n" + if provider_name: + guidance += f" - Configure credentials for {provider_name}\n" + + message = _append_guidance(message, guidance) if guidance else message + super().__init__(message, **kwargs) + + +class UnknownModelError(ConfigurationError): + """The router found no provider for a model string. + + No routable provider prefix, no catalog match, and no rule matched + (spec/vocabularies.md ``unknown_model``, 2026-09-08). Local and + pre-network: no provider was asked — a provider's own "no such model" + reply is :class:`UnsupportedModelError`. Carries ``model`` (the string + as requested); ``rules_tried`` and ``catalog_searched`` are the + reference's diagnostics, not contract payload. + """ + + default_code = "unknown_model" + + def __init__( + self, + message: str = "", + *, + model: str = "", + rules_tried: tuple = (), + catalog_searched: bool = False, + **kwargs, + ) -> None: + self.model = model + self.rules_tried = tuple(rules_tried) + self.catalog_searched = catalog_searched + super().__init__(message, **kwargs) + + +class AmbiguousModelError(ConfigurationError): + """The catalog matched a model string under more than one provider, or + under more than one entry of one provider (``ambiguous_model``, + 2026-09-08). The fix is an explicit ``provider:`` prefix. Carries + ``model`` and ``providers`` (every candidate, catalog order, + deduplicated). + """ + + default_code = "ambiguous_model" + + def __init__( + self, + message: str = "", + *, + model: str = "", + providers: tuple[str, ...] = (), + **kwargs, + ) -> None: + self.model = model + self.providers = tuple(providers) + self.candidates = self.providers # alias: full candidate list + super().__init__(message, **kwargs) + + +_GUIDANCE_MARKER = "\n\n To fix:" + + +_ORIGIN_MARKER = "\n\n credential came from: " + + +def with_credential_origin(error: ProviderError, origin: str) -> ProviderError: + """Name where an AuthError's credential came from (AUTH-1 provenance, + amended 2026-09-19): its own line under the provider's message (a + paragraph of its own, so ``__str__`` keeps the provider/HTTP suffix + on the provider's line), before the guidance, so a log line at 3 a.m. + answers "which identity?" without a second investigation. Non-auth + errors pass through.""" + if not isinstance(error, AuthError) or not origin: + return error + base = error.message.split(_GUIDANCE_MARKER, 1)[0] + if _ORIGIN_MARKER in base: + return error + out = AuthError( + base.rstrip() + _ORIGIN_MARKER + origin, + provider=error.provider, + env_keys=error.env_keys, + credential_hint=error.credential_hint, + provider_code=error.provider_code, + status=error.status, + request_id=error.request_id, + retry_after=error.retry_after, + rate_limit_headers=error.rate_limit_headers, + ) + out.credential_origin = origin + return out + + +def with_credential_hint(error: ProviderError, hint: str) -> ProviderError: + """Rewrite an AuthError's guidance for subscription (OAuth) adapters. + + API-key adapters point at env vars; subscription adapters have no env + var — the fix is re-running the provider CLI login. Non-auth errors pass + through unchanged. + """ + if not isinstance(error, AuthError): + return error + base = error.message.split(_GUIDANCE_MARKER, 1)[0] + return AuthError( + base, + provider=error.provider, + credential_hint=hint, + provider_code=error.provider_code, + status=error.status, + request_id=error.request_id, + retry_after=error.retry_after, + rate_limit_headers=error.rate_limit_headers, + ) + + +# ─── HTTP status → error class mapping ─────────────────────────────── + +def map_http_error( + status: int, + message: str, + *, + provider: str | None = None, + env_keys: tuple[str, ...] = (), + provider_code: str | None = None, + request_id: str | None = None, + retry_after: float | None = None, +) -> ProviderError: + """Map HTTP status + message to a typed ProviderError. + + Provider LMs extract the human-readable message and provider-specific code + from the provider's error body in their ``normalize_error`` override. This + function only maps HTTP status codes. + """ + kwargs = _metadata_kwargs( + provider=provider, + provider_code=provider_code, + status=status, + request_id=request_id, + retry_after=retry_after, + ) + if status in (401, 403): + return AuthError(message, env_keys=env_keys, **kwargs) + if status == 402: + return BillingError(message, **kwargs) + if status in (408, 504): + return TimeoutError(message, **kwargs) + if status == 429: + return RateLimitError(message, **kwargs) + if status in (400, 404, 409, 413, 422): + return InvalidRequestError(message, **kwargs) + if 500 <= status <= 599: + return ServerError(message, **kwargs) + return ProviderError(message, **kwargs) + + +# ─── Canonical error codes ─────────────────────────────────────────── + +# Bidirectional mapping between error classes and string codes. +# Codes are provider-agnostic and stable across LMs. More-specific classes must +# appear before their base classes. + +_CLASS_TO_CODE: dict[type[LM15Error], str] = { + ContextLengthError: "context_length", + UnsupportedModelError: "unsupported_model", + AuthError: "auth", + BillingError: "billing", + RateLimitError: "rate_limit", + InvalidRequestError: "invalid_request", + TimeoutError: "timeout", + ServerError: "server", + UnsupportedFeatureError: "unsupported_feature", + NotConfiguredError: "not_configured", + UnknownModelError: "unknown_model", + AmbiguousModelError: "ambiguous_model", + TransportError: "transport", + LockTimeoutError: "lock_timeout", + StreamAssemblyError: "stream_assembly", + CollectionLimitError: "collection_limit", + ProviderError: "provider", +} + +_CODE_TO_CLASS: dict[str, type[LM15Error]] = {v: k for k, v in _CLASS_TO_CODE.items()} + + +def canonical_error_code(error: type[LM15Error] | LM15Error) -> str: + """Return the canonical string code for an error class or instance.""" + cls = error if isinstance(error, type) else type(error) + for check_cls, code in _CLASS_TO_CODE.items(): + if issubclass(cls, check_cls): + return code + default_code = getattr(cls, "default_code", None) + return default_code or "provider" + + +def error_class_for_code(code: str) -> type[LM15Error]: + """Return the LM15Error subclass for a canonical string code.""" + return _CODE_TO_CLASS.get(code, ProviderError) + + +# Errors that are safe to retry; lm15 never retries itself — this +# classification is data for the caller's own retry policy. +RETRYABLE_ERRORS = (RateLimitError, TimeoutError, ServerError, TransportError, LockTimeoutError) + + +def _append_guidance(message: str, guidance: str) -> str: + """Append guidance once while preserving the original provider message.""" + if guidance.strip() in message: + return message + return message.rstrip() + guidance + + +def _metadata_kwargs( + *, + provider: str | None = None, + provider_code: str | None = None, + status: int | None = None, + request_id: str | None = None, + retry_after: float | None = None, +) -> dict[str, object]: + kwargs: dict[str, object] = {} + if provider: + kwargs["provider"] = provider + if provider_code: + kwargs["provider_code"] = provider_code + if status is not None: + kwargs["status"] = status + if request_id: + kwargs["request_id"] = request_id + if retry_after is not None: + kwargs["retry_after"] = retry_after + return kwargs diff --git a/features.py b/features.py new file mode 100644 index 0000000000..a3f0eca206 --- /dev/null +++ b/features.py @@ -0,0 +1,373 @@ +""" +lm15.features — Provider capability and endpoint declarations. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Literal, Mapping + +# How a provider's credential is obtained (spec/auth.md AUTH-1): +# +# - "key" — the ordinary chain: explicit api_keys entry, then the declared +# env vars in order, then (local-server presets only) the +# preset's placeholder key. +# - "oauth" — self-resolving local OAuth only. The adapter reads a local +# credential file; the chain above never runs and a failed OAuth +# load never falls back to an env var. +# - "oauth-unless-explicit" — an explicit api_keys entry wins; otherwise a +# usable stored OAuth login wins; env vars are consulted only +# when no usable login is stored. Rationale: deliberate +# in-process configuration always wins, but between two kinds of +# stored state — a subscription login and an ambient env var — +# the subscription wins because it spends no money per token +# (lm15's durable constraint: normal inference must not +# unexpectedly spend money). Stated trade-off: with a login +# stored, a set env key is silently ignored; pass the key +# explicitly to force it. Adapters declaring this policy MUST +# expose ``has_stored_credential()`` — an offline, classmethod +# probe the router calls to decide the chain. +# +# The router and doctor derive their behavior from this declaration; there +# is no parallel provider-name list to keep in sync. +# - "aws-chain" / "azure-chain" / "gcp-chain" — the cloud SDK's own default +# resolution order (spec/auth.md AUTH-1, amended 2026-09-03), +# rung by rung in ``lm15.cloud.chains``. Rung 0 is the explicit +# api_keys entry; the door's own key variable is rung 1; then +# the SDK chain. Same principal as boto3 / azure-identity / +# google-auth on the same machine — anything else is a security +# bug, not a convenience gap. +CredentialPolicy = Literal["key", "oauth", "oauth-unless-explicit", "aws-chain", "azure-chain", "gcp-chain"] +CREDENTIAL_POLICIES: frozenset[str] = frozenset( + {"key", "oauth", "oauth-unless-explicit", "aws-chain", "azure-chain", "gcp-chain"} +) +_CLOUD_CHAINS: frozenset[str] = frozenset({"aws-chain", "azure-chain", "gcp-chain"}) + +# spec/auth.md AUTH-11: the mechanisms a cloud chain is made of. A chain +# is data over these; a new kind is a spec change. +RungKind = Literal[ + "env", "ini-profile", "json-file", "subprocess", "http-metadata", + "http-token-exchange", "sigv4-sts", "unsigned-sts", "jwt-rs256", "file-cache", +] +RUNG_KINDS: frozenset[str] = frozenset({ + "env", "ini-profile", "json-file", "subprocess", "http-metadata", + "http-token-exchange", "sigv4-sts", "unsigned-sts", "jwt-rs256", "file-cache", +}) + +# spec/auth.md AUTH-7: what the doctor says about a rung. +AuthStepState = Literal["selected", "shadowed", "absent", "unprobed"] +AUTH_STEP_STATES: frozenset[str] = frozenset({"selected", "shadowed", "absent", "unprobed"}) + +# spec/auth.md AUTH-1 named credentials (amended 2026-09-19, +# changes/2026-09-19-cloud-identity-and-endpoints.md): the four names a +# caller may pin a cloud door to instead of walking the chain. One word, +# the same on every cloud; the chain module maps each to the rungs it +# covers, and a named credential never falls through to another rung. +NamedCredential = Literal["platform", "workload", "environment", "cli"] +NAMED_CREDENTIALS: tuple[str, ...] = ("platform", "workload", "environment", "cli") + + +@dataclass(frozen=True, slots=True) +class EndpointSupport: + complete: bool = True + stream: bool = True + live: bool = False + files: bool = False + batches: bool = False + images: bool = False + speech: bool = False + video: bool = False + responses_api: bool = False + models: bool = False + caches: bool = False + + # Escape hatch for endpoint names not yet promoted to typed booleans. + extra: frozenset[str] = field(default_factory=frozenset) + + def supports_endpoint(self, name: str) -> bool: + if name in self.extra: + return True + return bool(getattr(self, name, False)) + + +# How a credential travels (spec/vocabularies.md AuthScheme, AUTH-10). A +# policy lists the schemes its door accepts, in preference order; the +# credential KIND picks one: an ApiKey goes out under the first header +# scheme, AwsCredentials under ``sigv4``. +AuthScheme = Literal["bearer", "x-api-key", "api-key", "query-key", "sigv4"] +AUTH_SCHEMES: frozenset[str] = frozenset({"bearer", "x-api-key", "api-key", "query-key", "sigv4"}) +# The earlier two-value name, kept for callers that spelled the header. +AuthHeader = Literal["bearer", "x-api-key"] + +StreamFraming = Literal["sse", "aws-event-stream"] +STREAM_FRAMINGS: frozenset[str] = frozenset({"sse", "aws-event-stream"}) +ModelPlacement = Literal["body", "path"] +MODEL_PLACEMENTS: frozenset[str] = frozenset({"body", "path"}) + + +@dataclass(frozen=True, slots=True) +class HostSetting: + """One host setting: its name, the env variables consulted in order when + the caller did not pass it, and its default (``None`` = required).""" + + name: str + env: tuple[str, ...] = () + default: str | None = None + + +@dataclass(frozen=True, slots=True) +class HostSpec: + """How a dialect reaches a cloud door (spec/auth.md AUTH-10 ``host``). + + ``base_url`` template over the settings: ``{region}``, + ``{project}``, ``{location}``, ``{location_host}`` + (derived from ``location``), ``{resource}``. + ``settings`` the settings this host needs, with env fallbacks. + ``paths`` endpoint-path overrides keyed by the dialect's + endpoint name (``messages``, ``messages/stream``); + ``{model}`` is the request's model. Absent means + the dialect's own path under ``base_url``. + ``model_in`` ``body`` (default) or ``path`` — when ``path``, + the model field is removed from the payload. + ``anthropic_version_in`` ``header`` (default) or ``body:``. + ``stream_framing`` ``sse`` or ``aws-event-stream``. + ``required_headers`` ``(header name, setting name)`` pairs sent on every + request from the resolved settings. + ``sigv4_service`` the SigV4 credential-scope service name. + ``endpoint_env`` the cloud vendor's own variables, consulted in + order by the router, that name a full endpoint + root for this door (``AZURE_OPENAI_ENDPOINT``, + ``AWS_ENDPOINT_URL_BEDROCK_RUNTIME``). An + endpoint replaces the root of ``base_url`` (the + part before the first path segment); the door's + path is appended unless already present + (amended 2026-09-19). + """ + + base_url: str + settings: tuple[HostSetting, ...] = () + paths: Mapping[str, str] = field(default_factory=dict) + model_in: ModelPlacement = "body" + anthropic_version_in: str = "header" + stream_framing: StreamFraming = "sse" + required_headers: tuple[tuple[str, str], ...] = () + sigv4_service: str | None = None + endpoint_env: tuple[str, ...] = () + + def __post_init__(self) -> None: + if self.model_in not in MODEL_PLACEMENTS: + raise ValueError(f"HostSpec.model_in {self.model_in!r} not in {sorted(MODEL_PLACEMENTS)}") + if self.stream_framing not in STREAM_FRAMINGS: + raise ValueError(f"HostSpec.stream_framing {self.stream_framing!r} not in {sorted(STREAM_FRAMINGS)}") + if self.anthropic_version_in != "header" and not self.anthropic_version_in.startswith("body:"): + raise ValueError("HostSpec.anthropic_version_in is 'header' or 'body:'") + object.__setattr__(self, "settings", tuple(self.settings)) + object.__setattr__(self, "paths", dict(self.paths)) + object.__setattr__(self, "required_headers", tuple((str(k), str(v)) for k, v in self.required_headers)) + object.__setattr__(self, "endpoint_env", tuple(str(v) for v in self.endpoint_env)) + if "://" not in self.base_url: + raise ValueError(f"HostSpec.base_url must be an absolute URL template, got {self.base_url!r}") + + @property + def setting_names(self) -> tuple[str, ...]: + return tuple(s.name for s in self.settings) + + @property + def root_template(self) -> str: + """``base_url`` up to (not including) the first path segment: the + part an endpoint override replaces.""" + scheme, _, rest = self.base_url.partition("://") + host, _, _ = rest.partition("/") + return f"{scheme}://{host}" + + @property + def path_template(self) -> str: + """The door's path under the root (``/openai/v1``, + ``/v1/projects/{project}/locations/{location}/publishers/google``); + ``""`` when the template is a bare host.""" + _, _, rest = self.base_url.partition("://") + _, slash, path = rest.partition("/") + return f"/{path}" if slash else "" + + @property + def url_only_settings(self) -> frozenset[str]: + """Settings an endpoint override makes unnecessary: those that + appear in the root of the template and nowhere else — not in the + path, not in a required header, and not the SigV4 signing region + (AWS's own SDK requires a region even with ``endpoint_url``; the + signature's credential scope names it).""" + import re + + in_root = set(re.findall(r"{(\w+)}", self.root_template)) + if "location_host" in in_root: + in_root.discard("location_host") + in_root.add("location") + in_path = set(re.findall(r"{(\w+)}", self.path_template)) + in_headers = {setting for _, setting in self.required_headers} + out = in_root - in_path - in_headers + if self.sigv4_service is not None: + out.discard("region") + return frozenset(out) + + +@dataclass(frozen=True, slots=True, init=False) +class AccessPolicy: + """How an adapter reaches a backend. Pure data; ports copy it as a table. + + See ``lm15.access`` for the table of policies and the rationale + (spec/auth.md AUTH-9). + + The constructor retains the original positional ``auth_header`` slot. + Omitted (``None``), it leaves ``auth_scheme`` unchanged; explicitly supplied, + it replaces the schemes with that single header, including in ``replace``. + ``auth_scheme`` and ``host`` are keyword-only. + + ``provider`` canonical provider string (errors, routing, doctor). + ``supports`` endpoint surfaces this access path carries; a + dialect that implements a surface still RAISES when + the policy does not carry it (a subscription token + has no files or batch). + ``credential_policy`` spec/auth.md AUTH-1: key | oauth | + oauth-unless-explicit. + ``auth_modes``, ``env_keys``, ``enterprise_variants``: as before + (support-matrix pinned). + ``auth_scheme`` the schemes this door accepts, in preference order + (``bearer``, ``x-api-key``, ``api-key``, + ``query-key``, ``sigv4``); the credential kind + selects one. ``auth_header`` is the first header + scheme, kept for the dialects that spell it. + ``host`` the cloud door (``HostSpec``), or None for the + dialect's public API. + ``headers`` static headers on every request, in order. A header + the dialect also sets is merged by the dialect's + stated rule (Anthropic joins ``anthropic-beta``). + ``login_hint`` appended to auth errors when the credential is a + local login and there is no env var to set. + ``backend`` dialect-consulted variant name; ``"api"`` is the + provider's public API. + ``backend_options`` string knobs the backend variant needs (the Codex + models endpoint wants a ``client_version``). + ``system_prefix`` text the backend requires first in the system + prompt / instructions (Claude Code, Codex). + ``base_url`` this access path's default base URL, when it is + not the dialect's. + """ + + provider: str + supports: EndpointSupport = field(default_factory=EndpointSupport) + auth_modes: tuple[str, ...] = field(default_factory=tuple) + enterprise_variants: tuple[str, ...] = field(default_factory=tuple) + env_keys: tuple[str, ...] = field(default_factory=tuple) + credential_policy: CredentialPolicy = "key" + auth_scheme: tuple[AuthScheme, ...] = field(default=("bearer",), kw_only=True) + headers: tuple[tuple[str, str], ...] = () + host: HostSpec | None = field(default=None, kw_only=True) + login_hint: str | None = None + backend: str = "api" + backend_options: Mapping[str, str] = field(default_factory=dict) + system_prefix: str | None = None + base_url: str | None = None + + # Keep the original positional API, including pattern matching. The legacy + # header is a constructor input/property, NOT a second stored authority. + __match_args__ = ( + "provider", "supports", "auth_modes", "enterprise_variants", "env_keys", + "credential_policy", "auth_header", "headers", "login_hint", "backend", + "backend_options", "system_prefix", "base_url", + ) + + def __init__( + self, + provider: str, + supports: EndpointSupport | None = None, + auth_modes: tuple[str, ...] = (), + enterprise_variants: tuple[str, ...] = (), + env_keys: tuple[str, ...] = (), + credential_policy: CredentialPolicy = "key", + auth_header: AuthHeader | None = None, + headers: tuple[tuple[str, str], ...] = (), + login_hint: str | None = None, + backend: str = "api", + backend_options: Mapping[str, str] | None = None, + system_prefix: str | None = None, + base_url: str | None = None, + *, + auth_scheme: tuple[AuthScheme, ...] = ("bearer",), + host: HostSpec | None = None, + ) -> None: + # None means the legacy spelling was omitted (the effective default + # remains bearer). An explicit legacy header overrides the scheme input: + # replace(policy, auth_header=...) also supplies the stored auth_scheme. + # Keeping auth_header out of dataclass fields means ordinary replace and + # with_headers never feed its lossy projection back into auth_scheme. + if auth_header is not None: + if auth_header not in ("bearer", "x-api-key"): + raise ValueError(f"{provider}: unknown auth_header {auth_header!r}") + auth_scheme = (auth_header,) + object.__setattr__(self, "provider", provider) + object.__setattr__(self, "supports", EndpointSupport() if supports is None else supports) + object.__setattr__(self, "auth_modes", auth_modes) + object.__setattr__(self, "enterprise_variants", enterprise_variants) + object.__setattr__(self, "env_keys", env_keys) + object.__setattr__(self, "credential_policy", credential_policy) + object.__setattr__(self, "auth_scheme", auth_scheme) + object.__setattr__(self, "headers", headers) + object.__setattr__(self, "host", host) + object.__setattr__(self, "login_hint", login_hint) + object.__setattr__(self, "backend", backend) + object.__setattr__(self, "backend_options", {} if backend_options is None else backend_options) + object.__setattr__(self, "system_prefix", system_prefix) + object.__setattr__(self, "base_url", base_url) + self.__post_init__() + + def __post_init__(self) -> None: + if not self.provider: + raise ValueError("AccessPolicy.provider must be non-empty") + if self.credential_policy == "oauth" and self.env_keys: + # AUTH-1: an oauth manifest declares no environment keys. + raise ValueError(f"{self.provider}: an 'oauth' access policy declares no env_keys") + if self.credential_policy not in CREDENTIAL_POLICIES: + raise ValueError(f"{self.provider}: unknown credential_policy {self.credential_policy!r}") + schemes = (self.auth_scheme,) if isinstance(self.auth_scheme, str) else tuple(self.auth_scheme) + if not schemes: + raise ValueError(f"{self.provider}: auth_scheme must name at least one scheme") + for scheme in schemes: + if scheme not in AUTH_SCHEMES: + raise ValueError(f"{self.provider}: unknown auth_scheme {scheme!r}") + if "sigv4" in schemes and (self.host is None or self.host.sigv4_service is None): + raise ValueError(f"{self.provider}: sigv4 needs a host with sigv4_service") + if self.credential_policy in _CLOUD_CHAINS and self.host is None and self.provider != "vertex-express": + raise ValueError(f"{self.provider}: a cloud chain policy needs a host") + object.__setattr__(self, "auth_scheme", schemes) + object.__setattr__(self, "auth_modes", tuple(self.auth_modes)) + object.__setattr__(self, "env_keys", tuple(self.env_keys)) + object.__setattr__(self, "enterprise_variants", tuple(self.enterprise_variants)) + object.__setattr__(self, "headers", tuple((str(k), str(v)) for k, v in self.headers)) + object.__setattr__(self, "backend_options", dict(self.backend_options)) + + @property + def auth_header(self) -> AuthHeader: + """The first header-carrying scheme (``bearer`` or ``x-api-key``); + the two-value spelling the dialects consult for an ``ApiKey``. + ``api-key`` reports as ``x-api-key`` here — dialects that can send + either read ``auth_scheme`` directly.""" + for scheme in self.auth_scheme: + if scheme == "bearer": + return "bearer" + if scheme in ("x-api-key", "api-key"): + return "x-api-key" + return "bearer" + + @property + def cloud_chain(self) -> bool: + return self.credential_policy in _CLOUD_CHAINS + + def with_headers(self, headers: Mapping[str, str]) -> "AccessPolicy": + """A copy with these static headers replaced or appended (names compared case-insensitively).""" + lowered = {k.lower() for k in headers} + kept = tuple((k, v) for k, v in self.headers if k.lower() not in lowered) + return replace(self, headers=kept + tuple(headers.items())) + + +# The earlier name: an adapter's manifest is its access policy. +ProviderManifest = AccessPolicy diff --git a/judgments.py b/judgments.py new file mode 100644 index 0000000000..8fdf1946ec --- /dev/null +++ b/judgments.py @@ -0,0 +1,312 @@ +"""Judgments: declared keys in, a distribution out (MAP-14). + +changes/2026-09-17-judgments.md. A judgment is a top-level property of a +``json_schema`` ``response_format`` that declares its answer set: a +boolean, a string ``enum``/``anyOf``-of-``const``, or an ordered integer +``enum``/``anyOf``-of-``const`` ``0..n-1``. This module reads that +convention off a schema (§1), rewrites judgment properties for the two +wires that need it (§2), folds a model's JSON text into a ``DataPart`` +(§3), and offers the sugar that EMITS the convention (``choice``, +``yes_no``, ``score``, ``judgments``) the way ``tool(fn)`` emits a tool +schema. Nothing here touches the network. +""" +from __future__ import annotations + +import copy +import json +import math +from dataclasses import dataclass +from typing import Any, Literal, Mapping, Sequence + +from .adaptation import adapt +from .errors import UnsupportedFeatureError +from .types import DataPart, JsonObject, JsonValue, Request, TextPart + +JudgmentKind = Literal["boolean", "choice", "ordered"] + +MAX_ORDERED_LEVELS = 10 # Jev's Score ceiling (docs.typesafe.ai/primitives/score) +MAX_CHOICE_KEYS = 255 # Jev's Choice ceiling (docs.typesafe.ai/primitives/choice) + + +@dataclass(frozen=True, slots=True) +class Judgment: + """One declared judgment read off a schema property.""" + + name: str + kind: JudgmentKind + keys: tuple[str, ...] + instruction: str | None + descriptions: dict[str, str | None] + titles: dict[str, str | None] + + @property + def ordered(self) -> bool: + return self.kind == "ordered" + + +# ─── §1 reading the convention ──────────────────────────────────────── + + +def _const_branches(prop: Mapping[str, Any]) -> list[Mapping[str, Any]] | None: + branches = prop.get("anyOf") + if not isinstance(branches, list) or not branches: + return None + if not all(isinstance(b, dict) and "const" in b for b in branches): + return None + return branches + + +def _judgment_of(name: str, prop: Any) -> Judgment | None: + if not isinstance(prop, dict): + return None + instruction = prop.get("description") if isinstance(prop.get("description"), str) and prop.get("description") else None + if prop.get("type") == "boolean": + return Judgment(name, "boolean", ("true", "false"), instruction, {"true": None, "false": None}, {}) + enum = prop.get("enum") + branches = _const_branches(prop) + if isinstance(enum, list) and enum and branches is None: + values = enum + descs: dict[str, str | None] = {} + titles: dict[str, str | None] = {} + elif branches is not None and enum is None: + values = [b["const"] for b in branches] + descs = {str(b["const"]): (b.get("description") if isinstance(b.get("description"), str) else None) for b in branches} + titles = {str(b["const"]): (b.get("title") if isinstance(b.get("title"), str) else None) for b in branches} + else: + return None + if all(isinstance(v, str) and v for v in values): + if prop.get("type") not in (None, "string"): + return None + keys = tuple(values) + if len(set(keys)) != len(keys): + return None + return Judgment(name, "choice", keys, instruction, {k: descs.get(k) for k in keys}, {k: titles.get(k) for k in keys}) + if all(isinstance(v, int) and not isinstance(v, bool) for v in values): + if prop.get("type") not in (None, "integer"): + return None + if list(values) != list(range(len(values))) or len(values) < 2: + return None + keys = tuple(str(v) for v in values) + return Judgment(name, "ordered", keys, instruction, {k: descs.get(k) for k in keys}, {k: titles.get(k) for k in keys}) + return None + + +def judgments_in_schema(schema: Any) -> dict[str, Judgment]: + """The judgments a json_schema declares, in property order (MAP-14 §1). + + Any property that is not one of the three shapes is ordinary + structured output and is absent from the result. + """ + if not isinstance(schema, dict) or schema.get("type") not in (None, "object"): + return {} + props = schema.get("properties") + if not isinstance(props, dict): + return {} + out: dict[str, Judgment] = {} + for name, prop in props.items(): + if not isinstance(name, str): + continue + j = _judgment_of(name, prop) + if j is not None: + out[name] = j + return out + + +def request_judgments(request: Request) -> dict[str, Judgment]: + fmt = request.config.response_format + if not isinstance(fmt, dict) or fmt.get("type") != "json_schema": + return {} + return judgments_in_schema(fmt.get("schema")) + + +def non_judgment_properties(schema: Any, found: Mapping[str, Judgment]) -> tuple[str, ...]: + props = schema.get("properties") if isinstance(schema, dict) else None + if not isinstance(props, dict): + return () + return tuple(name for name in props if name not in found) + + +# ─── §2 what a wire that measures nothing does with ``probabilities`` ── + + +def note_unmeasurable_probabilities(request: Request, provider: str) -> None: + """MAP-14 §3 on a wire with no distribution: ``if_available`` records + ``dropped``; ``required`` refuses before the wire (MAP-13 b).""" + policy = request.config.probabilities + if policy in (None, "off") or not request_judgments(request): + return + if policy == "required": + raise UnsupportedFeatureError( + f"{provider}: config.probabilities='required' but this wire cannot measure a distribution " + "over the declared keys (it returns a pick only); use 'if_available' or a provider that can " + "(typesafe, or a vLLM/SGLang server that honours logprob_token_ids)", + provider=provider, + feature="config.probabilities", + ) + adapt( + "config.probabilities", + "dropped", + "this wire cannot measure a distribution over the declared keys; the answer carries the pick only", + asked=policy, + provider=provider, + ) + + +def anthropic_schema(schema: JsonObject, found: Mapping[str, Judgment]) -> JsonObject: + """A judgment property carrying both ``type`` and ``anyOf`` has its type + moved into every branch: the Messages wire answers 400 "For 'anyOf', + 'type' is not supported" otherwise (receipted 2026-09-17). Every other + keyword stays verbatim (INV-050 exception).""" + if not found: + return schema + out = copy.deepcopy(schema) + for name in found: + prop = out["properties"][name] + branches = prop.get("anyOf") + if "type" in prop and isinstance(branches, list): + kind = prop.pop("type") + for b in branches: + b.setdefault("type", kind) + return out + + +def gemini_schema(schema: JsonObject, found: Mapping[str, Judgment]) -> JsonObject: + """Judgment properties go as ``enum`` with the per-key descriptions folded + into the property description: ``responseJsonSchema`` ignores + ``anyOf``/``const`` (it answered "Bordeaux-blend" for a declared set; + receipted 2026-09-17) and honours ``enum``.""" + if not found: + return schema + out = copy.deepcopy(schema) + for name, j in found.items(): + prop = out["properties"][name] + if j.kind == "boolean" or "anyOf" not in prop: + continue + prop.pop("anyOf") + prop["type"] = "integer" if j.ordered else "string" + prop["enum"] = [int(k) for k in j.keys] if j.ordered else list(j.keys) + lines = [] + for k in j.keys: + label = j.titles.get(k) + desc = j.descriptions.get(k) + if label is None and desc is None: + lines.append(k) # a bare key still tells the model it is an option (receipted wire form) + continue + lines.append(f"{k} = " + (f"{label}: {desc}" if label and desc else (label or desc or ""))) + if any(j.titles.get(k) or j.descriptions.get(k) for k in j.keys): + head = prop.get("description") or "" + prop["description"] = (head + (" " if head else "") + ("Levels: " if j.ordered else "Options: ") + "; ".join(lines)).strip() + return out + + +# ─── §3 the answer ──────────────────────────────────────────────────── + + +def data_part_from_text(text: str, found: Mapping[str, Judgment]) -> DataPart | None: + """The model's JSON object as a DataPart (value only), or None when the + text is not a JSON object (a truncated answer stays a TextPart).""" + if not found: + return None + try: + value = json.loads(text.strip()) + except (ValueError, TypeError): + return None + if not isinstance(value, dict): + return None + return DataPart(value=value) + + +def replace_text_with_data(parts: Sequence[Any], found: Mapping[str, Judgment]) -> tuple[Any, ...]: + """Swap the single text part of a judgment answer for its DataPart.""" + if not found: + return tuple(parts) + texts = [p for p in parts if isinstance(p, TextPart)] + if len(texts) != 1: + return tuple(parts) + part = data_part_from_text(texts[0].text, found) + if part is None: + return tuple(parts) + if texts[0].continuation: + part = DataPart(value=part.value, continuation=texts[0].continuation) + return tuple(part if p is texts[0] else p for p in parts) + + +def normalize_logprobs(scores: Mapping[str, float]) -> dict[str, float]: + """Softmax over log-scores: one normalisation over the key set.""" + top = max(scores.values()) + weights = {k: math.exp(v - top) for k, v in scores.items()} + total = sum(weights.values()) + return {k: w / total for k, w in weights.items()} + + +def expected_level(distribution: Mapping[str, float]) -> float: + return sum(float(p) * int(k) for k, p in distribution.items()) + + +# ─── §4 sugar that emits the convention ─────────────────────────────── + + +def choice(instruction: str, options: Mapping[str, str | None] | Sequence[str]) -> JsonObject: + """A choice judgment property: ``{key: description-or-None}`` or a list of keys.""" + if isinstance(options, Mapping): + items = list(options.items()) + else: + items = [(k, None) for k in options] + if not items: + raise ValueError("choice needs at least one option") + if any(not isinstance(k, str) or not k for k, _ in items): + raise TypeError("choice option keys must be non-empty strings") + if len({k for k, _ in items}) != len(items): + raise ValueError("choice option keys must be unique") + prop: JsonObject = {"type": "string", "description": instruction} + if all(d is None for _, d in items): + prop["enum"] = [k for k, _ in items] + else: + prop["anyOf"] = [({"const": k, "description": d} if d else {"const": k}) for k, d in items] + return prop + + +def yes_no(instruction: str) -> JsonObject: + return {"type": "boolean", "description": instruction} + + +def score(instruction: str, levels: Mapping[str, str] | Sequence[str]) -> JsonObject: + """An ordered judgment: levels low → high; ``{name: description}`` or descriptions.""" + if isinstance(levels, Mapping): + items = list(levels.items()) + else: + items = [(None, d) for d in levels] + if len(items) < 2: + raise ValueError("score needs at least two levels") + if len(items) > MAX_ORDERED_LEVELS: + raise ValueError(f"score takes at most {MAX_ORDERED_LEVELS} levels") + branches = [] + for i, (name, desc) in enumerate(items): + b: JsonObject = {"const": i} + if name: + b["title"] = name + if desc: + b["description"] = desc + branches.append(b) + return {"type": "integer", "description": instruction, "anyOf": branches} + + +def judgments(*, name: str = "judgments", strict: bool = True, **properties: JsonObject) -> JsonObject: + """A ``response_format`` declaring the given judgment properties.""" + if not properties: + raise ValueError("judgments needs at least one property") + schema: JsonObject = { + "type": "object", + "properties": dict(properties), + "required": list(properties), + "additionalProperties": False, + } + return {"type": "json_schema", "name": name, "strict": strict, "schema": schema} + + +__all__ = [ + "Judgment", "judgments_in_schema", "request_judgments", "non_judgment_properties", + "note_unmeasurable_probabilities", "anthropic_schema", "gemini_schema", + "data_part_from_text", "replace_text_with_data", "normalize_logprobs", "expected_level", + "choice", "yes_no", "score", "judgments", +] diff --git a/live.py b/live.py new file mode 100644 index 0000000000..8ac14a2599 --- /dev/null +++ b/live.py @@ -0,0 +1,657 @@ +""" +lm15.live — WebSocket live session wrapper. + +Provider-agnostic session around a WebSocket connection for realtime +(live) interactions with foundation models. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import threading +from collections import deque +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any, Callable, Deque + +from .errors import CollectionLimitError, TransportError +from .types import ( + ErrorDetail, + LiveClientAudioEvent, + LiveClientEndAudioEvent, + LiveClientEvent, + LiveClientImageEvent, + LiveClientInterruptEvent, + LiveClientTextEvent, + LiveClientToolResultEvent, + LiveClientTurnEvent, + LiveServerEvent, + PART_CLASSES, + Part, + PartInput, + TextPart, + ToolCallInfo, + Usage, + _normalize_parts, +) + +EncodeEventFn = Callable[[LiveClientEvent], list[dict[str, Any]]] +DecodeEventFn = Callable[[str | bytes], list[LiveServerEvent]] + + +def require_websocket_sync_connect(): + """Return `websockets.sync.client.connect` or raise a helpful ImportError.""" + try: + from websockets.sync.client import connect # type: ignore + except Exception as exc: + raise ImportError( + "Live sessions require the optional 'websockets' dependency.\n\n" + " Install it with:\n" + " pip install lm15[live]\n" + ) from exc + return connect + + +def require_websocket_async_connect(): + """Return `websockets.asyncio.client.connect` or raise a helpful ImportError.""" + try: + from websockets.asyncio.client import connect # type: ignore + except Exception as exc: + raise ImportError( + "Live sessions require the optional 'websockets' dependency.\n\n" + " Install it with:\n" + " pip install lm15[live]\n" + ) from exc + return connect + + +# ─── Turn: half-duplex ergonomics over the event stream ───────────── +# +# A live session is FULL-duplex: with voice-activity detection the model +# can speak spontaneously and turns can overlap after interruptions. +# Plain session iteration is the primary surface for that. `turn()` and +# `Turn` serve the half-duplex idiom (send, then listen until the turn +# ends) — the shape every scripted recipe and turn-based voice app has. +# Their shared behavior is specified in the contract's +# 2026-09-11-shared-handles-and-profile-migration decision. They remain +# provisional collectors, not new canonical wire types. + +_TURN_TERMINAL = frozenset({"turn_end", "interrupted", "error"}) +DEFAULT_TURN_MAX_BYTES = 16 * 1024 * 1024 +DEFAULT_TURN_MAX_EVENTS = 10_000 + + +def _event_size(event: LiveServerEvent, remaining: int) -> int: + """Compact ASCII JSON byte charge, not a claim about resident memory. + + Count incrementally and stop as soon as the event would exceed the + remaining budget. No serialized copy of the whole collection is built. + """ + from .serde import live_server_event_to_dict + + encoder = json.JSONEncoder(ensure_ascii=True, separators=(",", ":"), allow_nan=False) + size = 0 + for chunk in encoder.iterencode(live_server_event_to_dict(event)): + size += len(chunk) # ASCII characters are exactly one byte each. + if size > remaining: + break + return size + + +@dataclass(frozen=True) +class Turn: + """One materialized turn. + + ``ended_by`` is one of ``turn_end`` / ``interrupted`` / ``error`` / + ``tool_call``. ``usage`` is the field-wise sum of every ``usage`` and + ``turn_end`` event the turn saw: a tool-call response's tokens arrive + as a ``usage`` event at the start of the continuation turn, and a + cancelled response's tokens precede its ``interrupted``. A ``tool_call`` ending mirrors the non-live + ``finish_reason="tool_call"`` contract: the model is waiting for + YOUR result — answer with ``send_tool_result()`` and materialize the + next turn. turn() collects within a configurable byte/event budget. + Materializing adds combined text/audio copies; use raw session events + when retaining a whole turn is not wanted. + """ + + ended_by: str + text: str = "" + audio: bytes = b"" + audio_media_type: str | None = None + tool_calls: tuple[ToolCallInfo, ...] = () + usage: "Usage | None" = None + error: "ErrorDetail | None" = None + events: tuple[LiveServerEvent, ...] = () + + @property + def ok(self) -> bool: + return self.ended_by == "turn_end" + + +def _sum_usage(acc: "Usage | None", more: Usage) -> Usage: + """Field-wise sum of two Usage values from one session (same provider, + same taxonomy). INV-029: a counter absent on either side is unknown + in the sum, never zero.""" + if acc is None: + return more + fields = ("input_tokens", "output_tokens", "total_tokens", "cache_read_tokens", "cache_write_tokens", + "reasoning_tokens", "input_audio_tokens", "output_audio_tokens") + values = {} + for name in fields: + a, b = getattr(acc, name), getattr(more, name) + values[name] = a + b if a is not None and b is not None else None + return Usage(**values) + + +def _materialize_turn(events: tuple[LiveServerEvent, ...]) -> Turn: + text_parts: list[str] = [] + audio = bytearray() + audio_media_type: str | None = None + tool_calls: list[ToolCallInfo] = [] + usage: Usage | None = None + error: ErrorDetail | None = None + for event in events: + if event.type == "text": + text_parts.append(event.text) + elif event.type == "audio": + if audio_media_type is not None and event.media_type is not None and audio_media_type != event.media_type: + raise ValueError("a Turn cannot concatenate different audio media types; consume raw events") + audio.extend(base64.b64decode(event.data, validate=True)) + if audio_media_type is None and event.media_type is not None: + audio_media_type = event.media_type + elif event.type == "tool_call": + tool_calls.append(ToolCallInfo(id=event.id, name=event.name, input=event.input)) + elif event.type in ("turn_end", "usage"): + # A turn's bill is every usage-bearing event it saw: the + # usage event of a tool-call response (which arrives after the + # tool_call that ended the previous result(), i.e. at the start + # of the continuation turn — the semantic turn stayed open) and + # the usage of a cancelled response before its interrupted. + usage = _sum_usage(usage, event.usage) + elif event.type == "error": + error = event.error + ended_by = events[-1].type if events and events[-1].type in (_TURN_TERMINAL | {"tool_call"}) else "incomplete" + return Turn( + ended_by=ended_by, + text="".join(text_parts), + audio=bytes(audio), + audio_media_type=audio_media_type, + tool_calls=tuple(tool_calls), + usage=usage, + error=error, + events=events, + ) + + +class _TurnState: + def __init__( + self, session: Any, *, max_bytes: int = DEFAULT_TURN_MAX_BYTES, + max_events: int = DEFAULT_TURN_MAX_EVENTS, + ) -> None: + for name, value in (("max_bytes", max_bytes), ("max_events", max_events)): + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"{name} must be a positive integer; use raw session events for unbuffered reading") + self._max_bytes = max_bytes + self._max_events = max_events + self._retained_bytes = 0 + self._session = session + self._done = False + self._events: list[LiveServerEvent] = [] + self._failure: Exception | None = None + self._result: Turn | None = None + self._reading = False + + @property + def retained_bytes(self) -> int: + """Accepted events' compact-JSON byte charge, not process memory.""" + return self._retained_bytes + + @property + def retained_events(self) -> int: + return len(self._events) + + def snapshot(self) -> Turn: + """Collected data so far; incomplete is not a successful turn.""" + if isinstance(self._failure, CollectionLimitError): + return self._failure.partial + return _materialize_turn(tuple(self._events)) + + def _limit_error(self, limit: str, maximum: int, event=None) -> CollectionLimitError: + self._done = True + return CollectionLimitError( + f"Live turn collection exceeded {limit}={maximum}; the turn is incomplete. " + "Raise this limit on session.turn(...), or process raw session events without collecting. " + "Inspect partial_events and rejected_event before continuing; the session remains open.", + limit=limit, maximum=maximum, retained_bytes=self._retained_bytes, + partial_events=tuple(self._events), rejected_event=event, + ) + + def _check_event_limit(self) -> None: + # Do not consume the next event when the count alone proves it + # cannot fit. This includes a terminal event, which also counts. + if len(self._events) >= self._max_events: + raise self._limit_error("max_events", self._max_events) + + def close(self) -> None: + """Stop this view, not the underlying live session.""" + if self._reading: + raise RuntimeError("stop the active turn reader before closing its view") + self._done = True + + def _accept(self, event: LiveServerEvent | None) -> LiveServerEvent: + if event is None: + raise TransportError("live session closed before the turn reached a boundary") + size = _event_size(event, self._max_bytes - self._retained_bytes) + if size > self._max_bytes - self._retained_bytes: + raise self._limit_error("max_bytes", self._max_bytes, event) + self._events.append(event) + self._retained_bytes += size + if event.type in _TURN_TERMINAL: + self._done = True + return event + + def _seal(self) -> Turn: + if self._failure is not None: + raise self._failure + result = self.snapshot() + if result.ended_by == "incomplete": + raise TransportError("turn view closed before the turn reached a boundary; inspect snapshot()") + self._done = True + self._result = result + return result + + +class TurnView(_TurnState): + """One buffered half-duplex view. Iteration stops on terminal events; + result also stops at a tool call. Already-yielded events stay in the result. + Defaults: 16 MiB of compact ASCII JSON event data, at most 10,000 events. + Limits are per view and configurable via max_bytes/max_events. On overflow + CollectionLimitError preserves partial events and any consumed rejected + event. The session stays open; this is not a cancellation or billing promise. + Use raw session iteration when buffering is not wanted. + """ + + def __iter__(self): + return self + + def __next__(self) -> LiveServerEvent: + if self._reading: + raise RuntimeError("turn view already has an active reader") + if self._failure is not None: + raise self._failure + if self._done: + raise StopIteration + self._reading = True + try: + self._check_event_limit() + return self._accept(self._session.recv()) + except Exception as exc: + self._failure = exc + raise + finally: + self._reading = False + + def result(self) -> Turn: + if self._result is not None: + return self._result + # A tool call just yielded by manual iteration must not be read past + # by result(): the application may still owe the model an answer. + if not self._events or self._events[-1].type != "tool_call": + for event in self: + if event.type == "tool_call": + break + return self._seal() + + +class AsyncTurnView(_TurnState): + """Native async twin; task cancellation propagates without inventing a turn.""" + + def __aiter__(self): + return self + + async def __anext__(self) -> LiveServerEvent: + if self._reading: + raise RuntimeError("turn view already has an active reader") + if self._failure is not None: + raise self._failure + if self._done: + raise StopAsyncIteration + self._reading = True + try: + self._check_event_limit() + return self._accept(await self._session.recv()) + except Exception as exc: + self._failure = exc + raise + finally: + self._reading = False + + async def result(self) -> Turn: + if self._result is not None: + return self._result + if not self._events or self._events[-1].type != "tool_call": + async for event in self: + if event.type == "tool_call": + break + return self._seal() + + +class WebSocketLiveSession: + """Provider-agnostic session wrapper around a WebSocket connection.""" + + def __init__( + self, + *, + ws: Any, + encode_event: EncodeEventFn, + decode_event: DecodeEventFn, + ) -> None: + self._ws = ws + self._encode_event = encode_event + self._decode_event = decode_event + self._pending: Deque[LiveServerEvent] = deque() + self._send_lock = threading.Lock() + self._closed = False + + def send( + self, + event: LiveClientEvent | None = None, + *, + audio: bytes | str | None = None, + audio_media_type: str = "audio/pcm;rate=16000", + image: bytes | str | None = None, + image_media_type: str = "image/jpeg", + text: str | None = None, + turn: PartInput | None = None, + tool_result: dict[str, Any] | None = None, + interrupt: bool = False, + end_audio: bool = False, + ) -> None: + if self._closed: + raise RuntimeError("live session is closed") + + if event is not None: + has_payload = any(x is not None for x in (audio, image, text, turn, tool_result)) + if has_payload or interrupt or end_audio: + raise ValueError("pass either `event` or keyword payload, not both") + events = [event] + else: + events = _events_from_kwargs( + audio=audio, + audio_media_type=audio_media_type, + image=image, + image_media_type=image_media_type, + text=text, + turn=turn, + tool_result=tool_result, + interrupt=interrupt, + end_audio=end_audio, + ) + + with self._send_lock: + for evt in events: + payloads = self._encode_event(evt) + for payload in payloads: + self._ws.send(json.dumps(payload)) + + def send_turn(self, content: PartInput, *, turn_complete: bool = True) -> None: + self.send(LiveClientTurnEvent(parts=_normalize_parts(content), turn_complete=turn_complete)) + + def send_audio(self, data: bytes | str, *, media_type: str = "audio/pcm;rate=16000") -> None: + self.send(LiveClientAudioEvent(data=_to_base64_str(data), media_type=media_type)) + + def send_image(self, data: bytes | str, *, media_type: str = "image/jpeg") -> None: + self.send(LiveClientImageEvent(data=_to_base64_str(data), media_type=media_type)) + + def send_text(self, text: str) -> None: + self.send(LiveClientTextEvent(text=text)) + + def send_tool_result(self, results: dict[str, Any]) -> None: + self.send(tool_result=results) + + def interrupt(self) -> None: + self.send(interrupt=True) + + def end_audio(self) -> None: + self.send(end_audio=True) + + def recv(self) -> LiveServerEvent: + if self._closed: + raise RuntimeError("live session is closed") + + while True: + if self._pending: + return self._pending.popleft() + + raw = self._ws.recv() + decoded = self._decode_event(raw) + if not decoded: + continue + + for event in decoded: + self._pending.append(event) + + def turn( + self, *, max_bytes: int = DEFAULT_TURN_MAX_BYTES, + max_events: int = DEFAULT_TURN_MAX_EVENTS, + ) -> TurnView: + """Collect one bounded turn. For unbuffered processing, iterate this session.""" + return TurnView(self, max_bytes=max_bytes, max_events=max_events) + + def close(self) -> None: + if self._closed: + return + self._closed = True + try: + self._ws.close() + except Exception: + return + + def __iter__(self): + return self + + def __next__(self) -> LiveServerEvent: + if self._closed: + raise StopIteration + try: + return self.recv() + except RuntimeError: + raise StopIteration + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + self.close() + + +def _events_from_kwargs( + *, + audio: bytes | str | None, + audio_media_type: str, + image: bytes | str | None, + image_media_type: str, + text: str | None, + turn: PartInput | None, + tool_result: dict[str, Any] | None, + interrupt: bool, + end_audio: bool, +) -> list[LiveClientEvent]: + events: list[LiveClientEvent] = [] + + if audio is not None: + events.append(LiveClientAudioEvent(data=_to_base64_str(audio), media_type=audio_media_type)) + if image is not None: + events.append(LiveClientImageEvent(data=_to_base64_str(image), media_type=image_media_type)) + if turn is not None: + events.append(LiveClientTurnEvent(parts=_normalize_parts(turn))) + if text is not None: + events.append(LiveClientTextEvent(text=text)) + + if tool_result: + for call_id, value in tool_result.items(): + content = tuple(_tool_result_parts(value)) + events.append(LiveClientToolResultEvent(id=call_id, content=content)) + + if interrupt: + events.append(LiveClientInterruptEvent()) + if end_audio: + events.append(LiveClientEndAudioEvent()) + + if not events: + raise ValueError("nothing to send") + return events + +class AsyncWebSocketLiveSession: + """Native async live session over `websockets.asyncio`. + + NOT a thread wrapper: a blocked sync ``recv()`` inside a worker + thread cannot be cancelled from the event loop, and cancellation + (barge-in, hangup) is the heart of realtime. Here ``recv()`` is a + real awaitable — cancelling the task cancels the read. The pure + encode/decode codecs are shared with the sync session verbatim. + """ + + def __init__( + self, + *, + ws: Any, + encode_event: EncodeEventFn, + decode_event: DecodeEventFn, + ) -> None: + self._ws = ws + self._encode_event = encode_event + self._decode_event = decode_event + self._pending: Deque[LiveServerEvent] = deque() + self._send_lock = asyncio.Lock() + self._closed = False + + async def send( + self, + event: LiveClientEvent | None = None, + *, + audio: bytes | str | None = None, + audio_media_type: str = "audio/pcm;rate=16000", + image: bytes | str | None = None, + image_media_type: str = "image/jpeg", + text: str | None = None, + turn: PartInput | None = None, + tool_result: dict[str, Any] | None = None, + interrupt: bool = False, + end_audio: bool = False, + ) -> None: + if self._closed: + raise RuntimeError("live session is closed") + + if event is not None: + has_payload = any(x is not None for x in (audio, image, text, turn, tool_result)) + if has_payload or interrupt or end_audio: + raise ValueError("pass either `event` or keyword payload, not both") + events = [event] + else: + events = _events_from_kwargs( + audio=audio, + audio_media_type=audio_media_type, + image=image, + image_media_type=image_media_type, + text=text, + turn=turn, + tool_result=tool_result, + interrupt=interrupt, + end_audio=end_audio, + ) + + async with self._send_lock: + for evt in events: + for payload in self._encode_event(evt): + await self._ws.send(json.dumps(payload)) + + async def send_turn(self, content: PartInput, *, turn_complete: bool = True) -> None: + await self.send(LiveClientTurnEvent(parts=_normalize_parts(content), turn_complete=turn_complete)) + + async def send_audio(self, data: bytes | str, *, media_type: str = "audio/pcm;rate=16000") -> None: + await self.send(LiveClientAudioEvent(data=_to_base64_str(data), media_type=media_type)) + + async def send_image(self, data: bytes | str, *, media_type: str = "image/jpeg") -> None: + await self.send(LiveClientImageEvent(data=_to_base64_str(data), media_type=media_type)) + + async def send_text(self, text: str) -> None: + await self.send(LiveClientTextEvent(text=text)) + + async def send_tool_result(self, results: dict[str, Any]) -> None: + await self.send(tool_result=results) + + async def interrupt(self) -> None: + await self.send(interrupt=True) + + async def end_audio(self) -> None: + await self.send(end_audio=True) + + async def recv(self) -> LiveServerEvent: + if self._closed: + raise RuntimeError("live session is closed") + + while True: + if self._pending: + return self._pending.popleft() + + raw = await self._ws.recv() + decoded = self._decode_event(raw) + if not decoded: + continue + + for event in decoded: + self._pending.append(event) + + def turn( + self, *, max_bytes: int = DEFAULT_TURN_MAX_BYTES, + max_events: int = DEFAULT_TURN_MAX_EVENTS, + ) -> AsyncTurnView: + """Async collection with the same limits and recovery as TurnView.""" + return AsyncTurnView(self, max_bytes=max_bytes, max_events=max_events) + + async def close(self) -> None: + if self._closed: + return + self._closed = True + try: + await self._ws.close() + except Exception: + return + + def __aiter__(self): + return self + + async def __anext__(self) -> LiveServerEvent: + if self._closed: + raise StopAsyncIteration + try: + return await self.recv() + except RuntimeError: + raise StopAsyncIteration + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args) -> None: + await self.close() + + +def _tool_result_parts(value: Any) -> list[Part]: + if isinstance(value, str): + return [TextPart(text=value)] + if isinstance(value, PART_CLASSES): + return [value] + if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)): + parts = list(value) + if all(isinstance(part, PART_CLASSES) for part in parts): + return parts + return [TextPart(text=str(value))] + + +def _to_base64_str(data: bytes | str) -> str: + if isinstance(data, bytes): + return base64.b64encode(data).decode("ascii") + return data diff --git a/lm15-python2.code-workspace b/lm15-python2.code-workspace new file mode 100644 index 0000000000..072451b859 --- /dev/null +++ b/lm15-python2.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": ".." + }, + { + "path": "../../curl-fixtures/api-references" + } + ], + "settings": {} +} \ No newline at end of file diff --git a/model_registry.py b/model_registry.py new file mode 100644 index 0000000000..c070687e93 --- /dev/null +++ b/model_registry.py @@ -0,0 +1,9 @@ +"""Compatibility wrapper for the model registry API. + +ModelRegistry lives in lm15.models alongside ModelInfo. This module exists for +users who prefer importing it from lm15.model_registry. +""" + +from .models import ModelRegistry + +__all__ = ["ModelRegistry"] diff --git a/models.py b/models.py new file mode 100644 index 0000000000..e8c9275296 --- /dev/null +++ b/models.py @@ -0,0 +1,301 @@ +""" +lm15.models — Optional model metadata and registry utilities. + +The canonical inference request remains Request(model="..."). ModelInfo and +ModelRegistry are optional helpers for model discovery, validation, routing, and +cost estimation. Model capabilities are endpoint-specific (`inference` today) +so new endpoint families can be described later without breaking the +inference model. Fine-tune PROVENANCE stays describable through ModelOrigin +(`type`, `base_model`) — lm15 does inference with tuned models, not training. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterable + +from .compat import CompatProfile +from .types import JsonObject + + +# ─── Validation helpers ────────────────────────────────────────────── + + +def _check_nonempty_text(value: object, field_name: str) -> None: + if not isinstance(value, str) or not value: + raise ValueError(f"{field_name} must be a non-empty string") + + +def _check_positive_or_none(value: int | None, field_name: str) -> None: + if value is not None and (not isinstance(value, int) or isinstance(value, bool) or value <= 0): + raise ValueError(f"{field_name} must be a positive integer or None") + + +def _check_non_negative_or_none(value: float | None, field_name: str) -> None: + if value is None: + return + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{field_name} must be a number or None") + if value < 0: + raise ValueError(f"{field_name} must be >= 0") + + +def _coerce_float_field(obj: object, field_name: str) -> None: + """Number rule (docs/serde-rules.md): float fields have ONE wire form. + + Same-valued int input is coerced (1 -> 1.0); bool never coerces. + """ + value = getattr(obj, field_name) + if type(value) is int: + object.__setattr__(obj, field_name, float(value)) + + +def _coerce_int_field(obj: object, field_name: str) -> None: + """Number rule: int fields coerce same-valued floats, reject the rest.""" + value = getattr(obj, field_name) + if type(value) is float: + if not value.is_integer(): + raise ValueError(f"{field_name} must be an integer") + object.__setattr__(obj, field_name, int(value)) + + +def _check_json_object_or_none(value: object, field_name: str) -> None: + if value is None: + return + if not isinstance(value, dict): + raise TypeError(f"{field_name} must be a JSON object or None") + + +# ─── Pricing ───────────────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class InferencePricing: + input_per_million: float | None = None + output_per_million: float | None = None + cache_read_per_million: float | None = None + cache_write_per_million: float | None = None + currency: str = "USD" + dimensions: JsonObject | None = None + + def __post_init__(self) -> None: + for name in ( + "input_per_million", + "output_per_million", + "cache_read_per_million", + "cache_write_per_million", + ): + _check_non_negative_or_none(getattr(self, name), name) + _coerce_float_field(self, name) + _check_nonempty_text(self.currency, "currency") + _check_json_object_or_none(self.dimensions, "dimensions") + + def estimate( + self, + *, + input_tokens: int | None = None, + output_tokens: int | None = None, + cache_read_tokens: int | None = None, + cache_write_tokens: int | None = None, + ) -> float: + """Estimate cost for the given token counts. + + ``None`` means "count unknown / not reported" (the ``Usage`` + zeros-vs-absent distinction) and is SKIPPED: an unknown dimension + contributes nothing to the estimate, the same as a dimension whose + rate is unset. It is NOT treated as zero tokens — the returned + figure is a lower bound when any dimension is unknown. Pass an + explicit ``0`` for a known-zero count. + """ + total = 0.0 + if self.input_per_million is not None and input_tokens is not None: + total += input_tokens * self.input_per_million / 1_000_000 + if self.output_per_million is not None and output_tokens is not None: + total += output_tokens * self.output_per_million / 1_000_000 + if self.cache_read_per_million is not None and cache_read_tokens is not None: + total += cache_read_tokens * self.cache_read_per_million / 1_000_000 + if self.cache_write_per_million is not None and cache_write_tokens is not None: + total += cache_write_tokens * self.cache_write_per_million / 1_000_000 + return total + + +# ─── Endpoint-specific model capabilities ──────────────────────────── + + +@dataclass(frozen=True, slots=True) +class InferenceModelInfo: + input_modalities: tuple[str, ...] = ("text",) + output_modalities: tuple[str, ...] = ("text",) + context_window: int | None = None + max_output_tokens: int | None = None + supports_reasoning: bool = False + reasoning_efforts: tuple[str, ...] = () + pricing: InferencePricing | None = None + extensions: JsonObject | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "input_modalities", tuple(self.input_modalities)) + object.__setattr__(self, "output_modalities", tuple(self.output_modalities)) + object.__setattr__(self, "reasoning_efforts", tuple(self.reasoning_efforts)) + if any(not isinstance(v, str) or not v for v in self.input_modalities): + raise ValueError("input_modalities must contain non-empty strings") + if any(not isinstance(v, str) or not v for v in self.output_modalities): + raise ValueError("output_modalities must contain non-empty strings") + if any(not isinstance(v, str) or not v for v in self.reasoning_efforts): + raise ValueError("reasoning_efforts must contain non-empty strings") + _coerce_int_field(self, "context_window") + _coerce_int_field(self, "max_output_tokens") + _check_positive_or_none(self.context_window, "context_window") + _check_positive_or_none(self.max_output_tokens, "max_output_tokens") + _check_json_object_or_none(self.extensions, "extensions") + + +@dataclass(frozen=True, slots=True) +class ModelOrigin: + type: str = "provider" + id: str | None = None + base_model: str | None = None + provider_data: JsonObject | None = None + + def __post_init__(self) -> None: + _check_nonempty_text(self.type, "ModelOrigin.type") + if self.id is not None: + _check_nonempty_text(self.id, "ModelOrigin.id") + if self.base_model is not None: + _check_nonempty_text(self.base_model, "ModelOrigin.base_model") + _check_json_object_or_none(self.provider_data, "provider_data") + + +@dataclass(frozen=True, slots=True) +class ModelInfo: + id: str + provider: str + api_family: str + aliases: tuple[str, ...] = () + origin: ModelOrigin = field(default_factory=ModelOrigin) + inference: InferenceModelInfo | None = None + compat: CompatProfile | None = None + extensions: JsonObject | None = None + + def __post_init__(self) -> None: + _check_nonempty_text(self.id, "ModelInfo.id") + _check_nonempty_text(self.provider, "ModelInfo.provider") + _check_nonempty_text(self.api_family, "ModelInfo.api_family") + object.__setattr__(self, "aliases", tuple(self.aliases)) + if any(not isinstance(a, str) or not a for a in self.aliases): + raise ValueError("ModelInfo.aliases must contain non-empty strings") + if not isinstance(self.origin, ModelOrigin): + raise TypeError("ModelInfo.origin must be a ModelOrigin") + _check_json_object_or_none(self.extensions, "extensions") + + +# ─── Registry ──────────────────────────────────────────────────────── + + +@dataclass(slots=True) +class ModelRegistry: + _models: dict[tuple[str, str], ModelInfo] = field(default_factory=dict) + _aliases: dict[tuple[str, str], tuple[str, str]] = field(default_factory=dict) + + def add(self, model: ModelInfo, *, replace: bool = True) -> None: + if not isinstance(model, ModelInfo): + raise TypeError("model must be a ModelInfo") + key = (model.provider, model.id) + if not replace and key in self._models: + raise ValueError(f"model already registered: {model.provider}/{model.id}") + self._models[key] = model + for alias in model.aliases: + self._aliases[(model.provider, alias)] = key + + def add_profile(self, profile: object, *, replace: bool = True) -> None: + models = getattr(profile, "models", None) + if models is None: + raise TypeError("profile must have a models attribute") + for model in models: + self.add(model, replace=replace) + + def get(self, provider: str, model: str) -> ModelInfo | None: + key = (provider, model) + if key in self._models: + return self._models[key] + target = self._aliases.get(key) + if target is not None: + return self._models.get(target) + return None + + def resolve(self, model: str, provider: str | None = None) -> ModelInfo | None: + if provider is not None: + return self.get(provider, model) + matches = [ + info + for (_provider, model_id), info in self._models.items() + if model_id == model or model in info.aliases + ] + return matches[0] if len(matches) == 1 else None + + def list(self, provider: str | None = None) -> tuple[ModelInfo, ...]: + values = tuple(self._models.values()) + if provider is None: + return values + return tuple(m for m in values if m.provider == provider) + + def providers(self) -> tuple[str, ...]: + return tuple(sorted({provider for provider, _ in self._models})) + + @classmethod + def from_profiles(cls, profiles: Iterable[object]) -> "ModelRegistry": + registry = cls() + for profile in profiles: + registry.add_profile(profile) + return registry + + @classmethod + def from_dicts(cls, dicts: Iterable[dict]) -> "ModelRegistry": + """Build a registry from canonical ModelInfo dicts. + + Each dict is validated through ``serde.model_info_from_dict``; the + ModelInfo constructors reject junk (negative prices, empty modality + names, missing id/provider) by raising ValueError/TypeError. + """ + from .serde import model_info_from_dict + + registry = cls() + for d in dicts: + registry.add(model_info_from_dict(d)) + return registry + + @classmethod + def discover(cls, *, group: str = "lm15.model_catalogs") -> "ModelRegistry": + """Hydrate a registry from installed entry-point catalogs. + + Each entry point in ``group`` must load to a zero-argument callable + returning an iterable of canonical ModelInfo dicts + (docs/model-hydration.md). Catalogs are processed sorted by + entry-point name; on duplicate (provider, id) keys the FIRST + occurrence wins. A catalog that raises is skipped with a warning — + discovery never crashes the host application. + + Hydrated data is ADVISORY metadata only: it must never change what + build_request produces. + """ + import warnings + from importlib.metadata import entry_points + + from .serde import model_info_from_dict + + registry = cls() + for ep in sorted(entry_points(group=group), key=lambda e: e.name): + try: + catalog = ep.load() + models = [model_info_from_dict(d) for d in catalog()] + except Exception as exc: + warnings.warn( + f"lm15 model catalog {ep.name!r} failed and was skipped: {exc}", + stacklevel=2, + ) + continue + for model in models: + if (model.provider, model.id) in registry._models: + continue # first occurrence wins + registry.add(model) + return registry diff --git a/profiles.py b/profiles.py new file mode 100644 index 0000000000..e77422522e --- /dev/null +++ b/profiles.py @@ -0,0 +1,294 @@ +""" +lm15.profiles — provider endpoint profiles (DEPRECATED) and the Responses +compat resolution that survives them. + +``ProviderProfile`` / ``EndpointProfile`` / ``OpenAILM.from_profile`` / +``OpenAILM(profile=...)`` are deprecated as of 1.0.0rc2 and removed in +1.0.0 (contract ``changes/2026-09-11-job-handles-live-turns-profiles.md`` +§ 3). Everything they expressed has one home now: + +- an endpoint's address and wire policy: ``OpenAILM(compat="ollama")`` + (a preset name supplies its own address) or ``compat=`` + ``base_url=``; +- a per-model policy: the chat dialect's ``OpenAIChatCompat.model_overrides``, + or per request ``Config.extensions["openai_responses_compat"]``; +- a compat guessed from the base URL: nothing — say ``compat="openrouter"``. + +What stays, in ``lm15.compat`` where it belongs: the partial-compat +semantics (``None`` inherits, ``"auto"`` is the adapter default), +``merge_openai_responses_compat``, and the request-level hatch +(:func:`openai_responses_compat_from_extensions`). ``ModelInfo`` and +``ModelRegistry`` were never profiles; they live in ``lm15.models``. +""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass, field +from typing import Any + +from .compat import ( + CompatProfile, + OpenAIChatCompat, + OpenAIResponsesCompat, + ResolvedOpenAIResponsesCompat, + merge_openai_responses_compat, + resolve_openai_responses_compat as resolve_openai_responses_compat_partial, +) +from .models import ( + InferenceModelInfo, + InferencePricing, + ModelInfo, + ModelOrigin, + ModelRegistry, +) +from .types import JsonObject + + +# Re-export commonly used model/compat classes from lm15.profiles for ergonomic +# imports while keeping their implementation in compat.py/models.py. +__all__ = [ + "CompatProfile", + "EndpointProfile", + "InferenceModelInfo", + "InferencePricing", + "ModelInfo", + "ModelOrigin", + "ModelRegistry", + "OpenAIChatCompat", + "OpenAIResponsesCompat", + "ProviderProfile", + "ResolvedOpenAIResponsesCompat", + "resolve_openai_responses_compat", +] + + +# ─── Validation helpers ────────────────────────────────────────────── + + +def _check_nonempty_text(value: object, field_name: str) -> None: + if not isinstance(value, str) or not value: + raise ValueError(f"{field_name} must be a non-empty string") + + +def _check_json_object_or_none(value: object, field_name: str) -> None: + if value is None: + return + if not isinstance(value, dict): + raise TypeError(f"{field_name} must be a JSON object or None") + + +# ─── Provider profiles ─────────────────────────────────────────────── + + +_DEPRECATION = ( + "lm15.profiles.{name} is deprecated and will be removed in lm15 1.0.0: " + "pass compat= (a preset name supplies its address) and base_url= to the " + "adapter, model_overrides on OpenAIChatCompat, or " + "Config.extensions['openai_responses_compat'] per request " + "(lm15-contract changes/2026-09-11-job-handles-live-turns-profiles.md § 3)" +) + + +def _deprecated(name: str) -> None: + warnings.warn(_DEPRECATION.format(name=name), DeprecationWarning, stacklevel=3) + + +@dataclass(frozen=True, slots=True) +class EndpointProfile: + """DEPRECATED (1.0.0rc2; removed in 1.0.0). See the module docstring.""" + + name: str + api_family: str + base_url: str | None = None + compat: CompatProfile | None = None + extensions: JsonObject | None = None + + def __post_init__(self) -> None: + _deprecated("EndpointProfile") + _check_nonempty_text(self.name, "EndpointProfile.name") + _check_nonempty_text(self.api_family, "EndpointProfile.api_family") + if self.base_url is not None: + _check_nonempty_text(self.base_url, "EndpointProfile.base_url") + _check_json_object_or_none(self.extensions, "EndpointProfile.extensions") + + +@dataclass(frozen=True, slots=True) +class ProviderProfile: + """DEPRECATED (1.0.0rc2; removed in 1.0.0). See the module docstring.""" + + provider: str + endpoints: dict[str, EndpointProfile] = field(default_factory=dict) + models: tuple[ModelInfo, ...] = () + extensions: JsonObject | None = None + + def __post_init__(self) -> None: + _deprecated("ProviderProfile") + _check_nonempty_text(self.provider, "ProviderProfile.provider") + object.__setattr__(self, "endpoints", dict(self.endpoints)) + object.__setattr__(self, "models", tuple(self.models)) + for name, endpoint in self.endpoints.items(): + _check_nonempty_text(name, "ProviderProfile endpoint name") + if not isinstance(endpoint, EndpointProfile): + raise TypeError("ProviderProfile.endpoints values must be EndpointProfile") + if not all(isinstance(m, ModelInfo) for m in self.models): + raise TypeError("ProviderProfile.models must contain ModelInfo objects") + _check_json_object_or_none(self.extensions, "ProviderProfile.extensions") + + @classmethod + def inference( + cls, + *, + provider: str, + api_family: str, + base_url: str | None = None, + compat: CompatProfile | None = None, + models: tuple[ModelInfo, ...] = (), + extensions: JsonObject | None = None, + ) -> "ProviderProfile": + return cls( + provider=provider, + endpoints={ + "inference": EndpointProfile( + name="inference", + api_family=api_family, + base_url=base_url, + compat=compat, + ) + }, + models=models, + extensions=extensions, + ) + + def endpoint(self, name: str = "inference") -> EndpointProfile | None: + return self.endpoints.get(name) + + def model(self, model_id: str) -> ModelInfo | None: + for model in self.models: + if model.id == model_id or model_id in model.aliases: + return model + return None + + +# ─── Compatibility resolution ──────────────────────────────────────── + + +def openai_responses_compat_from_extensions(extensions: JsonObject | None) -> OpenAIResponsesCompat | None: + """Read request-level OpenAI Responses compat from Config.extensions. + + Supported shapes: + + {"openai_responses_compat": {...}} + {"openai_compat": {...}} # backwards-friendly alias + {"compat": {"openai_responses": {...}}} + {"compat": {"openai": {...}}} # generic OpenAI alias + + The per-request hatch: the one place a Responses-dialect policy can + differ per model after profiles are gone. Normal configuration is + ``OpenAILM(compat=...)``. + """ + if not extensions: + return None + + raw: Any = extensions.get("openai_responses_compat") + if raw is None: + raw = extensions.get("openai_compat") + if raw is None: + compat = extensions.get("compat") + if isinstance(compat, dict): + raw = compat.get("openai_responses") or compat.get("openai") + + if not isinstance(raw, dict): + return None + + allowed = { + "developer_role", + "max_output_tokens_field", + "reasoning_format", + "tool_result_name", + "strict_tools", + "cache_control", + "commentary_phase", + "edit_image_field", + "builtin_tools", + "tool_result_media", + "routing", + "extensions", + } + kwargs = {k: v for k, v in raw.items() if k in allowed} + return OpenAIResponsesCompat(**kwargs) # type: ignore[arg-type] + + +def resolve_openai_responses_compat( + *, + base_url: str, + model: str, + profile: ProviderProfile | None, + request_extensions: JsonObject | None, + base: OpenAIResponsesCompat | None = None, +) -> ResolvedOpenAIResponsesCompat: + """Resolve effective OpenAI Responses compatibility policy. + + Layering: + + bound compat (``OpenAILM(compat=...)``), else the base URL default + < endpoint compat (deprecated with profiles) + < model compat (deprecated with profiles) + < request extension override + + The base-URL default is a guess about the server and is deprecated + with the profiles: when it would pick anything but OpenAI's own + policy it warns, naming the ``compat=`` spelling that says the same + thing explicitly. + """ + partial = base if base is not None else _default_openai_responses_compat_for_base_url(base_url) + + endpoint = profile.endpoint("inference") if profile else None + if isinstance(endpoint and endpoint.compat, OpenAIResponsesCompat): + partial = merge_openai_responses_compat(partial, endpoint.compat) + + model_info = profile.model(model) if profile else None + if isinstance(model_info and model_info.compat, OpenAIResponsesCompat): + partial = merge_openai_responses_compat(partial, model_info.compat) + + partial = merge_openai_responses_compat( + partial, + openai_responses_compat_from_extensions(request_extensions), + ) + return resolve_openai_responses_compat_partial(partial) + + +def _redacted_url(url: str) -> str: + """The URL with its userinfo and query dropped: a gateway address can + carry a token in either, and a warning is not the place to print it + (greptile on dspy#10409).""" + from urllib.parse import urlsplit, urlunsplit + + try: + parts = urlsplit(url) + # netloc is "user:pass@host:port"; keep only what follows the "@". + # (Reading parts.port instead raised on an out-of-range port and + # turned a warning into a failure — greptile on dspy#10409.) + host = parts.netloc.rsplit("@", 1)[-1] + return urlunsplit((parts.scheme, host, parts.path, "", "")) + except ValueError: + return "" + + +def _default_openai_responses_compat_for_base_url(base_url: str) -> OpenAIResponsesCompat: + lower = base_url.lower() + guessed: str | None = None + if "openrouter.ai" in lower: + guessed = "openrouter" + elif "api.meta.ai" in lower: + guessed = "meta" + if guessed is not None: + warnings.warn( + f"OpenAILM guessed compat={guessed!r} from base_url {_redacted_url(base_url)!r}; that guess is deprecated " + f"and will be removed in lm15 1.0.0 — pass compat={guessed!r} explicitly (it also supplies " + "the address)", + DeprecationWarning, + stacklevel=4, + ) + return OpenAIResponsesCompat.preset(guessed) + # OpenAI's own policy for OpenAI and for unknown endpoints, as before. + return OpenAIResponsesCompat.preset("openai") diff --git a/protocols.py b/protocols.py new file mode 100644 index 0000000000..1bb2471a26 --- /dev/null +++ b/protocols.py @@ -0,0 +1,84 @@ +""" +lm15.protocols — Protocol definitions for provider LMs and sessions. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterator, Protocol + +from .features import EndpointSupport, ProviderManifest +from .models import ModelInfo +from .types import ( + CachedPrefix, + CacheInfo, + CachePage, + VideoGenerationRequest, + VideoJobInfo, + VideoPart, + SpeechGenerationRequest, + SpeechGenerationResponse, + BatchRequest, + BatchEntry, + BatchJobInfo, + FileInfo, + FilePage, + FileUploadRequest, + ImageGenerationRequest, + ImageGenerationResponse, + LiveClientEvent, + LiveConfig, + LiveServerEvent, + PartInput, + Request, + Response, + StreamEvent, +) + + + +class LiveSession(Protocol): + def send(self, event: LiveClientEvent | None = None, **kwargs: Any) -> None: ... + def send_turn(self, content: PartInput, *, turn_complete: bool = True) -> None: ... + def send_audio(self, data: bytes | str, *, media_type: str = "audio/pcm;rate=16000") -> None: ... + def send_image(self, data: bytes | str, *, media_type: str = "image/jpeg") -> None: ... + def send_text(self, text: str) -> None: ... + def send_tool_result(self, results: dict[str, Any]) -> None: ... + def interrupt(self) -> None: ... + def end_audio(self) -> None: ... + def recv(self) -> LiveServerEvent: ... + def close(self) -> None: ... + + +class ProviderLM(Protocol): + provider: str + supports: EndpointSupport + manifest: ProviderManifest + + def complete(self, request: Request) -> Response: ... + def stream(self, request: Request) -> Iterator[StreamEvent]: ... + def live(self, config: LiveConfig) -> LiveSession: ... + def file_upload(self, request: FileUploadRequest) -> FileInfo: ... + def file_get(self, file_id: str) -> FileInfo: ... + def file_list(self, limit: int = 20, cursor: str | None = None) -> FilePage: ... + def file_delete(self, file_id: str) -> None: ... + def file_download(self, file_id: str) -> bytes: ... + def file_wait_ready(self, file_id: str, poll_every: float = 2.0, timeout: float | None = None) -> FileInfo: ... + def batch_submit(self, request: BatchRequest) -> BatchJobInfo: ... + def batch_status(self, batch_id: str) -> BatchJobInfo: ... + def batch_results(self, batch_id: str) -> tuple[BatchEntry, ...]: ... + def batch_cancel(self, batch_id: str) -> BatchJobInfo: ... + def batch_list(self, limit: int = 20) -> tuple[BatchJobInfo, ...]: ... + def image_generate(self, request: ImageGenerationRequest) -> ImageGenerationResponse: ... + def speech_generate(self, request: SpeechGenerationRequest) -> SpeechGenerationResponse: ... + def video_submit(self, request: VideoGenerationRequest) -> VideoJobInfo: ... + def video_status(self, video_id: str) -> VideoJobInfo: ... + def video_result(self, video_id: str) -> VideoPart: ... + def video_list(self, limit: int = 20, model: str | None = None) -> tuple[VideoJobInfo, ...]: ... + def list_models(self) -> tuple[ModelInfo, ...]: ... + def cache_create(self, prefix: Request, *, ttl_seconds: int | None = None, label: str | None = None) -> CacheInfo: ... + def cache_get(self, cache_id: str) -> CacheInfo: ... + def cache_list(self, limit: int = 20, cursor: str | None = None) -> CachePage: ... + def cache_delete(self, cache_id: str) -> None: ... + def cache_update(self, cache_id: str, *, ttl_seconds: int) -> CacheInfo: ... + def cache(self, prefix: Request, *, ttl_seconds: int | None = None, label: str | None = None) -> CachedPrefix: ... diff --git a/providers/__init__.py b/providers/__init__.py new file mode 100644 index 0000000000..2dc45bab83 --- /dev/null +++ b/providers/__init__.py @@ -0,0 +1,48 @@ +from .anthropic import AnthropicLM +from .async_base import ( + AsyncAnthropicLM, + AsyncBaseProviderLM, + AsyncClaudeCodeLM, + AsyncGeminiLM, + AsyncOpenAIChatLM, + AsyncOpenAICodexLM, + AsyncOpenAILM, + AsyncTransport, + AsyncTypeSafeLM, + AsyncXaiLM, +) +from .base import BaseProviderLM, Credential, HttpResponse, ProviderDialect, SyncTransport, resolve_credential +from .claude_code import ClaudeCodeLM +from .gemini import GeminiLM +from .openai import OpenAILM +from .openai_chat import OpenAIChatLM +from .openai_codex import OpenAICodexLM +from .typesafe import TypeSafeLM +from .xai import XaiLM + +__all__ = [ + "OpenAILM", + "OpenAIChatLM", + "AnthropicLM", + "GeminiLM", + "ClaudeCodeLM", + "OpenAICodexLM", + "XaiLM", + "AsyncXaiLM", + "TypeSafeLM", + "AsyncTypeSafeLM", + "AsyncOpenAILM", + "AsyncOpenAIChatLM", + "AsyncAnthropicLM", + "AsyncGeminiLM", + "AsyncClaudeCodeLM", + "AsyncOpenAICodexLM", + "ProviderDialect", + "BaseProviderLM", + "AsyncBaseProviderLM", + "HttpResponse", + "SyncTransport", + "AsyncTransport", + "Credential", + "resolve_credential", +] diff --git a/providers/anthropic.py b/providers/anthropic.py new file mode 100644 index 0000000000..1e3d6afd96 --- /dev/null +++ b/providers/anthropic.py @@ -0,0 +1,1279 @@ +from __future__ import annotations + +from datetime import datetime + +import json +import os +from dataclasses import dataclass, field, replace +from typing import Any, Callable, ClassVar, Iterator, Mapping + +from ..adaptation import AdaptationPolicy, adapt, check_policy, nearest_effort +from ..errors import ( + AuthError, + BillingError, + ContextLengthError, + InvalidRequestError, + ProviderError, + RateLimitError, + ServerError, + TimeoutError, + UnsupportedFeatureError, + UnsupportedModelError, + canonical_error_code, + map_http_error, +) +from ..access import ANTHROPIC_API +from ..compat import ANTHROPIC_PRESET_BASE_URLS, AnthropicCompat, ResolvedAnthropicCompat, preset_base_url, resolve_anthropic_compat +from ..features import ProviderManifest +from ..judgments import anthropic_schema, note_unmeasurable_probabilities, replace_text_with_data, request_judgments +from ..sse import SSEEvent +from ..transports import TransportRequest +from ..types import ( + DataPart, + BatchEntry, + BatchJobInfo, + BatchRequest, + BuiltinTool, + CitationDelta, + ContinuationDelta, + ContinuationState, + CitationPart, + DocumentPart, + ErrorDetail, + FileInfo, + FilePage, + FileUploadRequest, + FunctionTool, + ImagePart, + Message, + Request, + Response, + StreamDeltaEvent, + StreamEndEvent, + StreamErrorEvent, + StreamEvent, + StreamStartEvent, + TextDelta, + TextPart, + ThinkingDelta, + ThinkingPart, + ToolCallDelta, + continuation_data, + ToolCallPart, + ToolResultPart, + Usage, +) +from .base import ( + BaseProviderLM, + Credential, + HttpResponse, + SyncTransport, + batch_entry_http, + batch_entry_request, + default_transport, +) +from .common import EFFORT_THINKING_BUDGETS, MEDIA_KINDS, anthropic_source, check_tool_result_media, data_part_text, iso_utc, model_infos_from_entries, multipart_form_body, parts_to_text, path_id, unnamed_tool_call_error + +# Canonical builtin tool name → Anthropic tool format +_ANTHROPIC_BUILTIN_MAP: dict[str, str] = { + "web_search": "web_search_20250305", + "code_execution": "code_execution_20250522", +} + +ANTHROPIC_PROVIDER_EXECUTED_BLOCKS = { + "server_tool_use", + "web_search_tool_result", + "code_execution_tool_result", +} + +_DEFAULT_ANTHROPIC_THINKING_BUDGET = 1024 + + +def _attach_unmapped(provider_data: dict[str, Any], unmapped: list[dict[str, str]]) -> dict[str, Any]: + if not unmapped: + return provider_data + out = dict(provider_data) + out["_lm15_unmapped"] = unmapped + return out + + +def _record_unmapped(unmapped: list[dict[str, str]], path: str, typ: Any) -> None: + unmapped.append({"path": path, "type": str(typ or "")}) + + +def _builtin_to_anthropic(tool: BuiltinTool) -> dict[str, Any]: + out: dict[str, Any] = {"type": _ANTHROPIC_BUILTIN_MAP.get(tool.name, tool.name), "name": tool.name} + if tool.config: + out.update(tool.config) + return out + + +def _response_format_to_anthropic_output_config(format_config: dict[str, Any]) -> dict[str, Any]: + """Canonical response_format (INV-050) -> Anthropic output_config. + + The Messages API has no any-JSON mode: `format.schema` must be an object + schema with `additionalProperties: false` (HTTP 400 otherwise, live + 2026-09-02), so `json_object` RAISES. `strict` is satisfied (always + constrained); `name` is a label with no slot (dropped, stated in MAP-8). + """ + if format_config["type"] == "json_object": + # MAP-13 rule 4(c) until a live receipt shows an open schema is + # accepted (changes/2026-09-14-adapt-visibly.md §4 item 4). + raise UnsupportedFeatureError( + "anthropic: response_format json_object is not supported — the Messages API has no " + "any-JSON mode; give a json_schema (objects need additionalProperties: false)", + provider="anthropic", feature="config.response_format", + ) + return {"format": {"type": "json_schema", "schema": format_config["schema"]}} + + +def _reasoning_tokens(usage_payload: dict) -> int | None: + # The wire nests thinking spend under output_tokens_details; absent + # when thinking never ran, so None stays the honest "not reported". + details = usage_payload.get("output_tokens_details") + if isinstance(details, dict) and details.get("thinking_tokens") is not None: + return int(details["thinking_tokens"]) + return None + + +_ADAPTIVE_CLASS_MARKERS = ("sonnet-5", "opus-5", "sonnet-4-6", "opus-4-6", "opus-4-7", "opus-4-8", "fable", "mythos", "haiku-5") + + +def anthropic_adaptive_class(model: str) -> bool: + """True for models that take `thinking: {type: adaptive}` + `output_config.effort`. + + MAP-7 rule 10: a model-name table, receipted 2026-09-02 (Sonnet 5 accepts + adaptive and rejects `enabled`; Sonnet 4.5 and Haiku 4.5 do the reverse). + A table that rots: a new model outside it is treated as the manual class + and the server answers with a clear 400 ("thinking.type.enabled is not + supported for this model"); `extensions={"thinking": ...}` overrides. + """ + lowered = model.lower() + return any(marker in lowered for marker in _ADAPTIVE_CLASS_MARKERS) + + +def _reasoning_thinking_budget(request: Request) -> int | None: + """Manual class only: the budget on the wire (MAP-7 rules 3 and 5).""" + reasoning = request.config.reasoning + if reasoning is None or reasoning.is_off: + return None + if reasoning.thinking_budget is not None: + return reasoning.thinking_budget + return EFFORT_THINKING_BUDGETS[reasoning.effort] + + +# Output ceilings by model class, for the `max_tokens` the Messages API +# requires and the caller did not set (MAP-13 `defaulted`, decision +# 2026-09-14 §4.8). Until then the default was 1024, which cut ordinary +# answers off with nothing said. The 3.x classes have documented lower +# ceilings and a value above them is a 400; everything else (4.x and later, +# and any name this table does not know) gets 16384 — loud and actionable +# if a model's ceiling is lower ("max_tokens: 16384 > N"), never a silent +# truncation. A table that rots; `Config.max_tokens` overrides. +_DEFAULT_MAX_TOKENS_BY_CLASS: tuple[tuple[str, int], ...] = ( + ("claude-3-haiku", 4096), ("claude-3-opus", 4096), ("claude-3-sonnet", 4096), + ("claude-3-5-", 8192), ("claude-3.5-", 8192), +) +_DEFAULT_MAX_TOKENS = 16384 + + +def _default_max_tokens(model: str) -> int: + lowered = model.lower() + for marker, ceiling in _DEFAULT_MAX_TOKENS_BY_CLASS: + if marker in lowered: + return ceiling + return _DEFAULT_MAX_TOKENS + + +def _max_tokens_for_anthropic(request: Request, thinking_budget: int | None, *, provider: str = "anthropic") -> int: + """Manual class: max_tokens includes thinking, so the wire ceiling is the + budget plus the visible cap. Adaptive class (thinking_budget None): + Config.max_tokens is the total ceiling — provider semantics, stated in + spec/types.md. The Messages API requires the field: when the caller + set none, the class default is used and recorded (MAP-13).""" + visible = request.config.max_tokens + if visible is None: + visible = _default_max_tokens(request.model) + adapt("config.max_tokens", "defaulted", + "the Messages API requires max_tokens and none was set; the class default was used", + applied=visible, provider=provider) + if thinking_budget is None: + return visible + return thinking_budget + visible + + +def _finish_reason(stop_reason: str | None, *, has_tool_call: bool = False) -> str: + if has_tool_call: + return "tool_call" + reason = str(stop_reason or "").lower() + if reason in {"max_tokens", "model_context_window_exceeded"}: + return "length" + if reason in {"tool_use", "pause_turn"}: + return "tool_call" + if reason in {"refusal", "safety", "content_filter"}: + return "content_filter" + return "stop" + + +def _anthropic_batch_status(data: dict[str, Any]) -> str: + """Map an Anthropic Message Batch object to the canonical BatchStatus. + + ``ended`` is Anthropic's single terminal processing_status; the + canonical terminal splits on request_counts — all-cancelled → + ``cancelled``, all-expired → ``expired``, anything else → + ``completed`` (per-entry outcomes live in the results, not the job). + """ + status = str(data.get("processing_status") or "").lower() + if status == "in_progress": + return "running" + if status == "canceling": + return "cancelling" + if status == "ended": + counts = data.get("request_counts") or {} + + def n(key: str) -> int: + try: + return int(counts.get(key) or 0) + except (TypeError, ValueError): + return 0 + + if n("canceled") and not (n("succeeded") or n("errored") or n("expired")): + return "cancelled" + if n("expired") and not (n("succeeded") or n("errored") or n("canceled")): + return "expired" + return "completed" + return "queued" + + +def _citation_from_anthropic(citation: dict[str, Any]) -> CitationPart | None: + url = citation.get("url") or citation.get("uri") + title = citation.get("title") or citation.get("document_title") or citation.get("source_title") + text = citation.get("cited_text") or citation.get("text") or citation.get("quote") + url_s = str(url) if url else None + title_s = str(title) if title else None + text_s = str(text) if text else None + if url_s is None and title_s is None and text_s is None: + return None + return CitationPart(url=url_s, title=title_s, text=text_s) + + +_DEFAULT_BASE_URL = "https://api.anthropic.com/v1" + + +@dataclass(slots=True) +class AnthropicLM(BaseProviderLM): + """Anthropic Messages dialect, bound to an access policy. + + ``access`` defaults to the API-key policy (``lm15.access.ANTHROPIC_API``); + ``lm15.access.CLAUDE_CODE`` binds the same dialect to a local Claude + Code login (``ClaudeCodeLM`` is that binding under a name). The policy + is consulted at exactly these points: the auth header, static headers + (``anthropic-beta`` is joined with the dialect's own betas), the system + prefix, the login hint on errors, and the endpoint surfaces. + """ + + api_key: Credential | None = field(default=None, repr=False) + transport: SyncTransport = field(default_factory=default_transport) + base_url: str = _DEFAULT_BASE_URL + api_version: str = "2023-06-01" + compat: AnthropicCompat | str | None = None + access: ProviderManifest | None = None + credentials_path: "str | os.PathLike[str] | None" = field(default=None, repr=False) + settings: "Mapping[str, str] | None" = None + # A named cloud identity ("platform", "workload", "environment", "cli"; + # AUTH-1) on a cloud door, instead of api_key=. + credential: str | None = field(default=None, kw_only=True) + clock: "Callable[[], datetime] | None" = field(default=None, repr=False) + + # MAP-13 policy: "note" (adapt and record), "silent", or "refuse". + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + provider: str = field(default="anthropic", init=False) + account_id: str | None = field(default=None, init=False, repr=False) + manifest: ClassVar[ProviderManifest] = ANTHROPIC_API + _resolved_compat: ResolvedAnthropicCompat = field(init=False, repr=False, default=ResolvedAnthropicCompat()) + + def __post_init__(self) -> None: + check_policy(self.adaptations) + self._bind_access(self.access, credentials_path=self.credentials_path, default_base_url=_DEFAULT_BASE_URL, settings=self.settings, + credential=self.credential) + compat = self.compat if self.compat is not None else self._registry_compat() + if isinstance(compat, str): + # A preset name also supplies that server's default base_url; an + # explicit non-default base_url argument always wins (same rule + # as OpenAIChatLM). + resolved = resolve_anthropic_compat(AnthropicCompat.preset(compat)) + if self.base_url == _DEFAULT_BASE_URL: + self.base_url = preset_base_url( + ANTHROPIC_PRESET_BASE_URLS, compat, dialect="Messages", default_preset="anthropic" + ) + elif isinstance(compat, AnthropicCompat): + resolved = resolve_anthropic_compat(compat) + else: + resolved = resolve_anthropic_compat(AnthropicCompat()) + self._resolved_compat = resolved + + _error_type_map: ClassVar[dict[str, type[ProviderError]]] = { + "authentication_error": AuthError, + "permission_error": AuthError, + "billing_error": BillingError, + "rate_limit_error": RateLimitError, + "request_too_large": InvalidRequestError, + "not_found_error": InvalidRequestError, + "resource_not_found_error": InvalidRequestError, # Moonshot's Anthropic wire (errors.md) + "DeploymentNotFound": UnsupportedModelError, # Azure Foundry: top-level code, model string = deployment + "invalid_authentication_error": AuthError, # Moonshot's Anthropic wire (errors.md) + "invalid_request_error": InvalidRequestError, + "api_error": ServerError, + "overloaded_error": ServerError, + "timeout_error": TimeoutError, + } + + @staticmethod + def _is_context_length_message(msg: str) -> bool: + lowered = msg.lower() + return ( + "prompt is too long" in lowered + or "too many tokens" in lowered + or "context window" in lowered + or "context length" in lowered + or ("token" in lowered and ("limit" in lowered or "exceed" in lowered)) + ) + + @staticmethod + def _is_model_error(message: str) -> bool: + lowered = message.lower() + return "model" in lowered and any( + marker in lowered + for marker in ( + "not found", + "does not exist", + "not exist", + "not supported", + "unsupported", + "not available", + "unknown", + ) + ) + + def _error_detail(self, provider_code: str, message: str) -> ErrorDetail: + cls = self._error_type_map.get(provider_code, ProviderError) + if self._is_context_length_message(message): + cls = ContextLengthError + elif provider_code == "not_found_error" and self._is_model_error(message): + cls = UnsupportedModelError + return ErrorDetail( + code=canonical_error_code(cls), + message=message or provider_code or "provider error", + provider_code=provider_code or "provider", + ) + + def normalize_error(self, status: int, body: str) -> ProviderError: + try: + data = json.loads(body) + inner = data.get("error") if isinstance(data, dict) else None + # Anthropic's envelope nests under `error`; Azure Foundry's + # gateway uses top-level {code, message} before a deployment is + # reached (live 2026-09-04). Keep both shapes on one wire. + err = inner if isinstance(inner, (dict, str)) else data if isinstance(data, dict) else {} + msg = err.get("message", "") if isinstance(err, dict) else str(err) + err_type = str(err.get("type") or err.get("code") or "") if isinstance(err, dict) else "" + request_id = str(data.get("request_id") or "") if isinstance(data, dict) else "" + + if self._is_context_length_message(msg): + return self._provider_error( + ContextLengthError, + msg, + status=status, + provider_code=err_type or None, + request_id=request_id or None, + ) + # `resource_not_found_error` is Moonshot's spelling of the same + # 404 on its Anthropic wire ("Not found the model …", live + # 2026-09-03); the message rule decides, as on the chat wire. + if err_type == "DeploymentNotFound" or ( + err_type in ("not_found_error", "resource_not_found_error") and self._is_model_error(msg) + ): + return self._provider_error( + UnsupportedModelError, + msg, + status=status, + provider_code=err_type, + request_id=request_id or None, + ) + + cls = self._error_type_map.get(err_type) + if cls: + return self._provider_error( + cls, + msg, + status=status, + provider_code=err_type or None, + request_id=request_id or None, + ) + if err_type and err_type not in msg: + msg = f"{msg} ({err_type})" + except Exception: + msg = body.strip()[:500] or f"HTTP {status}" + err_type = "" + request_id = "" + return self._with_login_hint(map_http_error( + status, + msg, + provider=self.provider, + env_keys=self.access.env_keys, + provider_code=err_type or None, + request_id=request_id or None, + )) + + def _headers(self, request: Request | None = None) -> dict[str, str]: + # The auth header is the policy's business: _emit applies it once per + # request for every scheme (AUTH-2, AUTH-10). + headers: dict[str, str] = {} + headers["anthropic-version"] = self.api_version + headers["content-type"] = "application/json" + betas: list[str] = [] + for key, static in self.access.headers: + if key.lower() == "anthropic-beta": + betas.extend(b for b in static.split(",") if b) + else: + headers[key] = static + if request is not None and any( + isinstance(tool, BuiltinTool) and tool.name == "code_execution" + for tool in request.tools + ): + betas.append("code-execution-2025-05-22") + if betas: + headers["anthropic-beta"] = ",".join(betas) + return headers + + # ─── Request serialization ────────────────────────────────────── + + def _part(self, part) -> dict[str, Any]: + if isinstance(part, TextPart): + return {"type": "text", "text": part.text} + if isinstance(part, DataPart): + return {"type": "text", "text": data_part_text(part)} + if isinstance(part, ImagePart): + return {"type": "image", "source": anthropic_source(part)} + if isinstance(part, DocumentPart): + return {"type": "document", "source": anthropic_source(part)} + if isinstance(part, ToolCallPart): + return {"type": "tool_use", "id": part.id, "name": part.name, "input": part.input} + if isinstance(part, ToolResultPart): + check_tool_result_media(self.provider, part, self._resolved_compat.tool_result_media, wire="a tool_result block") + content_blocks = [self._tool_result_content(p) for p in part.content] + out: dict[str, Any] = {"type": "tool_result", "tool_use_id": part.id} + if content_blocks: + # Anthropic accepts either a string or content blocks. Blocks + # preserve image/document tool outputs when present. + if len(content_blocks) == 1 and content_blocks[0].get("type") == "text": + out["content"] = content_blocks[0]["text"] + else: + out["content"] = content_blocks + if part.is_error: + out["is_error"] = True + return out + if isinstance(part, ThinkingPart): + redacted = continuation_data(part, "anthropic", "redacted_thinking") + if redacted is not None: + return {"type": "redacted_thinking", **redacted} + signature = continuation_data(part, "anthropic", "thinking_signature") + if signature and signature.get("signature"): + return { + "type": "thinking", + "thinking": part.text, + "signature": signature["signature"], + } + if self._resolved_compat.thinking_replay == "unsigned" and part.text: + # The server signs nothing (`signature: ""`) and takes the + # block back unsigned (Moonshot, live 2026-09-03); a text + # replay would turn the reasoning into a spoken turn. + return {"type": "thinking", "thinking": part.text} + return {"type": "text", "text": part.text} + return {"type": "text", "text": getattr(part, "text", "") or ""} + + def _tool_result_content(self, part) -> dict[str, Any]: + if isinstance(part, TextPart): + return {"type": "text", "text": part.text} + if isinstance(part, DataPart): + return {"type": "text", "text": data_part_text(part)} + if isinstance(part, ImagePart): + return {"type": "image", "source": anthropic_source(part)} + if isinstance(part, DocumentPart): + return {"type": "document", "source": anthropic_source(part)} + if part.type in MEDIA_KINDS: + # ToolResultBlockParam takes text, image and document blocks only. + raise UnsupportedFeatureError( + f"{self.provider}: a {part.type} part cannot reach a tool_result block (text, image and document only; MAP-10)", + provider=self.provider, feature=f"messages[*].tool_result.content[{part.type}]", + ) + return {"type": "text", "text": parts_to_text((part,), provider=self.provider)} + + def _message(self, msg: Message) -> dict[str, Any]: + role = "assistant" if msg.role == "assistant" else "user" + parts = [self._part(part) for part in msg.parts] + if msg.role == "developer": + text = parts_to_text(msg.parts) + parts = [{"type": "text", "text": f"[developer]\n{text}"}] + return {"role": role, "content": parts} + + def _allowed_subset(self, request: Request) -> tuple[str, ...] | None: + """The names of a proper-subset allowlist, else None. MAP-13: the + Messages API cannot restrict to a subset, so the adapter sends + ONLY those tools — that is what "may only call these" means — and + records it as client_side.""" + tc = request.config.tool_choice + if tc is None or tc.mode == "none" or not tc.allowed: + return None + if len(tc.allowed) == 1 and tc.mode == "required": + return None + declared = {t.name for t in request.tools} + if set(tc.allowed) == declared: + return None + return tuple(tc.allowed) + + def _tool_choice_payload(self, request: Request) -> dict[str, Any] | None: + tc = request.config.tool_choice + if tc is None: + return None + + payload: dict[str, Any] = {} + if tc.mode == "none": + payload["type"] = "none" + elif tc.allowed: + # {"type": "tool", "name": ...} forces client tools AND server + # tools — verified live 2026-09-01 with web_search (the API + # reference is silent on server tools; the capture is the + # evidence). Builtin names ride the same form because + # _builtin_to_anthropic puts the canonical name on the wire. + if len(tc.allowed) == 1 and tc.mode == "required": + payload["type"] = "tool" + payload["name"] = tc.allowed[0] + else: + # Every declared tool, or (MAP-13) a proper subset that + # _payload has already narrowed the tools list to: either + # way the wire form is any/auto over the tools sent. + payload["type"] = "any" if tc.mode == "required" else "auto" + elif tc.mode == "required": + payload["type"] = "any" + else: + payload["type"] = "auto" + + if tc.parallel is False and payload["type"] != "none": + payload["disable_parallel_tool_use"] = True + + return payload + + def _payload(self, request: Request, stream: bool) -> dict[str, Any]: + compat = self._resolved_compat + if compat.model_prefixes is not None and not request.model.startswith(compat.model_prefixes): + # The server maps foreign model names onto its own models without + # saying so (DeepSeek: claude-opus* → deepseek-v4-pro, live + # 2026-09-03, the response `model` field is the only tell). + raise UnsupportedModelError( + f"{self.provider}: model {request.model!r} is not one this endpoint serves as typed " + f"(expected a prefix in {list(compat.model_prefixes)}); it would be silently " + "substituted by another model. Name the model you actually want.", + provider=self.provider, + ) + cache_cfg = request.config.cache + # cache_control="none": the server ignores marks and caches + # implicitly; nothing is placed and an explicit CacheConfig is not an + # error — the same rule as the chat dialect's "none" presets. + use_cache = cache_cfg is not None and cache_cfg.mode != "off" and compat.cache_control == "anthropic" + long_cache = cache_cfg is not None and cache_cfg.retention == "long" and compat.cache_control == "anthropic" + + messages = [self._message(m) for m in request.messages] + + # MAP-6 marks. prefix_until_index=N and prefix="history" (N = last + # message) put cache_control on the last block of message N; the + # explicit block form rather than the top-level automatic marker, + # which walks backwards silently when the last block is ineligible. + # prefix="stable" (and plain auto) mark the system block below. + # key / resource name mechanisms the Messages API does not have. + if use_cache and cache_cfg is not None: + if cache_cfg.key is not None: + # MAP-13: a best-effort routing hint by definition; no home here. + adapt("config.cache.key", "dropped", + "the Messages API has no cache affinity key (OpenAI's prompt_cache_key); " + "marks on blocks are its mechanism and were placed", + asked=cache_cfg.key, provider=self.provider) + if cache_cfg.resource is not None: + # MAP-13 rule 4(b): the program references a stored object + # that does not exist on this provider. + raise UnsupportedFeatureError( + "anthropic: cache.resource is not supported — the Messages API has no " + "stored-cache tier; it caches by marks on blocks", + provider=self.provider, feature="config.cache.resource", + ) + idx = None + if cache_cfg.prefix_until_index is not None: + idx = min(cache_cfg.prefix_until_index, len(messages) - 1) + elif cache_cfg.prefix == "history": + idx = len(messages) - 1 + if idx is not None and idx >= 0 and messages[idx].get("content"): + last_block = messages[idx]["content"][-1] + if isinstance(last_block, dict): + marker: dict[str, Any] = {"type": "ephemeral"} + if long_cache: + marker["ttl"] = "1h" + last_block.setdefault("cache_control", marker) + + reasoning = request.config.reasoning + deepseek_thinking = compat.thinking_format == "deepseek" + # "adaptive": every model on this server is the adaptive class (Meta + # Model API, protocols--messages.md § Reasoning) — no model-name table. + always_adaptive = compat.thinking_format == "adaptive" + # "effort": no `thinking` object exists on this server; the dial is + # output_config.effort alone (Moonshot messages--create.md). + effort_only = compat.thinking_format == "effort" + adaptive = ( + reasoning is not None and not reasoning.is_off + and (deepseek_thinking or always_adaptive or effort_only or anthropic_adaptive_class(request.model)) + ) + if reasoning is not None and not reasoning.is_off: + if compat.reasoning_efforts is not None and reasoning.effort not in compat.reasoning_efforts: + # MAP-13: an effort word with no level here is clamped to the + # nearest declared level (the dial is ordinal); the server + # would have accepted the word silently (Moonshot answered + # 200 to `medium` and to `bogus`, live 2026-09-03). + nearest = nearest_effort(reasoning.effort, compat.reasoning_efforts) + adapt("config.reasoning.effort", "clamped", + f"this server has no {reasoning.effort!r} level (it accepts " + f"{', '.join(compat.reasoning_efforts)}) and would have accepted the word silently", + asked=reasoning.effort, applied=nearest, provider=self.provider) + reasoning = replace(reasoning, effort=nearest) + if reasoning.summary in ("concise", "detailed"): + # MAP-13: a visibility level the wire lacks; thinking blocks + # are returned whenever thinking runs, which is "auto". + adapt("config.reasoning.summary", "substituted", + "the Messages API has no summary detail levels; it returns thinking blocks " + "whenever thinking runs, which is 'auto'", + asked=reasoning.summary, applied="auto", provider=self.provider) + reasoning = replace(reasoning, summary="auto") + if adaptive: + if reasoning.thinking_budget is not None: + # MAP-13: effort carries the intent (MAP-7 rule 5); the + # budget has no honoured field on this class. + adapt("config.reasoning.thinking_budget", "dropped", + ("this server ignores budget_tokens; effort is the dial" + if deepseek_thinking else + "this server accepts budget_tokens without translating it; effort is the dial " + "(protocols--messages.md)" + if always_adaptive else + f"{request.model} takes thinking.type 'adaptive' with output_config.effort; " + "budget_tokens is rejected by the API (live 2026-09-02)"), + asked=reasoning.thinking_budget, provider=self.provider) + reasoning = replace(reasoning, thinking_budget=None) + # MAP-7: the word goes verbatim on the always-adaptive server; + # it answers an unsupported level with a 400 of its own. + if reasoning.effort == "minimal" and not (deepseek_thinking or always_adaptive or effort_only): + adapt("config.reasoning.effort", "clamped", + "this model class has no 'minimal' level (output_config.effort is " + "low|medium|high|xhigh|max); 'low' is the floor", + asked="minimal", applied="low", provider=self.provider) + reasoning = replace(reasoning, effort="low") + request = replace(request, config=replace(request.config, reasoning=reasoning)) + thinking_budget = None if adaptive else _reasoning_thinking_budget(request) + payload: dict[str, Any] = { + "model": request.model, + "messages": messages, + "stream": stream, + "max_tokens": _max_tokens_for_anthropic(request, thinking_budget, provider=self.provider), + } + + if request.system: + system_text = request.system if isinstance(request.system, str) else parts_to_text(request.system) + if use_cache: + cache_marker: dict[str, Any] = {"type": "ephemeral"} + if long_cache: + cache_marker["ttl"] = "1h" + payload["system"] = [{"type": "text", "text": system_text, "cache_control": cache_marker}] + else: + payload["system"] = system_text + sampling_fixed = compat.sampling_params == "reject" + if sampling_fixed: + for name in ("temperature", "top_p", "top_k"): + if getattr(request.config, name) is not None: + # The server documents none of these and swallows them + # silently (Moonshot, live 2026-09-03). MAP-13: omit and + # record — the note supplies the visibility. + adapt(f"config.{name}", "dropped", + "this server ignores sampling parameters (the model's sampling is fixed)", + asked=getattr(request.config, name), provider=self.provider) + for name in ("seed", "frequency_penalty", "presence_penalty"): + if getattr(request.config, name) is not None: + # MAP-13: a sampling hint with no field on the Messages API. + adapt(f"config.{name}", "dropped", + f"the Messages API has no {name} field", + asked=getattr(request.config, name), provider=self.provider) + if request.config.temperature is not None and not sampling_fixed: + temperature = request.config.temperature + if temperature > 1.0: + # MAP-13: the canonical range is 0–2; this wire's ceiling is + # 1.0 and both scales default to 1.0, so "hotter than allowed" + # becomes the hottest. Never rescaled. + adapt("config.temperature", "clamped", + "the Messages API accepts temperature in [0, 1]; the canonical range is [0, 2]", + asked=temperature, applied=1.0, provider=self.provider) + temperature = 1.0 + payload["temperature"] = temperature + if request.config.top_p is not None and not sampling_fixed: + payload["top_p"] = request.config.top_p + if request.config.top_k is not None and not sampling_fixed: + payload["top_k"] = request.config.top_k + if request.config.stop: + payload["stop_sequences"] = list(request.config.stop) + if request.tools: + tools_wire: list[dict[str, Any]] = [] + allowed_subset = self._allowed_subset(request) + for tool in request.tools: + if allowed_subset is not None and tool.name not in allowed_subset: + continue + if isinstance(tool, FunctionTool): + tools_wire.append({"name": tool.name, "description": tool.description, "input_schema": tool.parameters}) + elif isinstance(tool, BuiltinTool): + tools_wire.append(_builtin_to_anthropic(tool)) + if allowed_subset is not None: + adapt("config.tool_choice.allowed", "client_side", + "the Messages API cannot restrict to a subset of the declared tools; " + "only the allowed tools were sent, which is what the allowlist means", + asked=list(request.config.tool_choice.allowed), applied=[t["name"] for t in tools_wire], + provider=self.provider) + payload["tools"] = tools_wire + tool_choice = self._tool_choice_payload(request) + if tool_choice is not None: + tc = request.config.tool_choice + if compat.parallel_tool_calls == "reject" and tc is not None and tc.parallel is not None: + # disable_parallel_tool_use is documented as ignored + # (guide--anthropic-api.md). MAP-13: omit it and say so. + adapt("config.tool_choice.parallel", "dropped", + "this server accepts disable_parallel_tool_use and does not apply it " + "(guide--anthropic-api.md); the model may return several calls", + asked=tc.parallel, provider=self.provider) + tool_choice.pop("disable_parallel_tool_use", None) + payload["tool_choice"] = tool_choice + if deepseek_thinking: + # DeepSeek over the Anthropic wire (guide--anthropic-api.md; live + # 2026-09-03): thinking is ON by default, so absence is not off — + # an explicit off must reach the wire. On: type=enabled and + # output_config.effort (budget_tokens is ignored by the server). + if reasoning is not None and reasoning.is_off: + payload["thinking"] = {"type": "disabled"} + elif reasoning is not None: + payload["thinking"] = {"type": "enabled"} + payload["output_config"] = {"effort": reasoning.effort} + elif effort_only: + # Moonshot over the Anthropic wire (messages--create.md): no + # thinking object is documented; output_config.effort alone. + # Off goes out as thinking.type=disabled, which the server + # honours (live 2026-09-03: no thinking block, no thinking_tokens). + if reasoning is not None and reasoning.is_off: + payload["thinking"] = {"type": "disabled"} + elif reasoning is not None: + payload["output_config"] = {"effort": reasoning.effort} + elif always_adaptive and reasoning is not None and reasoning.is_off: + # The server reasons by default and cannot stop (Meta: Muse Spark + # "always reasons"). Explicit off must reach the wire so the + # server refuses it loudly (HTTP 400) instead of spending hidden + # reasoning tokens the caller asked to disable. + payload["thinking"] = {"type": "disabled"} + elif adaptive: + # MAP-7 rule 2 on the adaptive class (live 2026-09-02, Sonnet 5): + # the model decides when to think; effort steers depth. + payload["thinking"] = {"type": "adaptive"} + payload["output_config"] = {"effort": reasoning.effort} + elif thinking_budget is not None: + payload["thinking"] = { + "type": "enabled", + "budget_tokens": thinking_budget, + } + if request.config.response_format: + if compat.structured_output == "reject": + # The server accepts output_config.format and ignores the + # schema (DeepSeek, live 2026-09-03: 200 with keys the schema + # never named). Silent, so refuse before the wire. + # MAP-13: omit and record; the caller can describe the shape in the prompt. + adapt("config.response_format", "dropped", + "this server accepts output_config.format and does not apply it; " + "describe the shape in the prompt", + asked=request.config.response_format, provider=self.provider) + else: + # MAP-14 §2: a judgment property carrying type+anyOf has its + # type moved into every branch (the wire 400s otherwise, + # receipted 2026-09-17); probabilities cannot be measured here. + note_unmeasurable_probabilities(request, self.provider) + found = request_judgments(request) + fmt = request.config.response_format + if found: + fmt = {**fmt, "schema": anthropic_schema(fmt["schema"], found)} + output_config = _response_format_to_anthropic_output_config(fmt) + payload["output_config"] = {**payload.get("output_config", {}), **output_config} + # Promoted cross-provider knobs (changes/2026-09-01-extensions-burn-down): + # user_id rides Anthropic's metadata.user_id; store has no Anthropic + # wire field — raise, never silently drop. + if request.config.service_tier is not None: + payload["service_tier"] = request.config.service_tier + if request.config.user_id is not None: + payload["metadata"] = {"user_id": request.config.user_id} + if request.config.store is False: + # MAP-13 "satisfied": the Messages API keeps no retrievable + # stored-response object, so an opt-out holds by construction. + adapt("config.store", "satisfied", + "the Messages API has no stored-response object to opt out of; " + "nothing retrievable is kept", + asked=False, provider=self.provider) + elif request.config.store is True: + adapt("config.store", "dropped", + "the Messages API has no stored-response object to opt into " + "(OpenAI and Gemini carry `store`)", + asked=True, provider=self.provider) + if request.config.logprobs is not None: + # MAP-13 (decision 2026-09-14 §4.1): Response.logprobs is optional; + # the program sees absence, not a later crash. + adapt("config.logprobs", "dropped", + "the Messages API does not expose token log probabilities " + "(OpenAI and Gemini carry them); Response.logprobs will be absent", + asked=request.config.logprobs, provider=self.provider) + if request.config.extensions: + passthrough = {k: v for k, v in request.config.extensions.items() if k != "prompt_caching"} + payload.update(passthrough) + if self.access.system_prefix: + # The access path requires this text first in the system prompt + # (Claude Code's backend checks for it); the caller's system + # follows, cache markers and all. + prefix = {"type": "text", "text": self.access.system_prefix} + existing = payload.get("system") + if existing is None: + payload["system"] = [prefix] + elif isinstance(existing, list): + payload["system"] = [prefix, *existing] + else: + payload["system"] = [prefix, {"type": "text", "text": str(existing)}] + return payload + + def build_request(self, request: Request, stream: bool) -> TransportRequest: + request = self._wire_request(request) + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/messages", + headers=self._headers(request), + payload=self._payload(request, stream=stream), + endpoint="messages", + stream=stream, + model=request.model, + ) + + # ─── Response parsing ─────────────────────────────────────────── + + def parse_response(self, request: Request, response: HttpResponse) -> Response: + request = self._wire_request(request) + data = response.json() + parts: list[Any] = [] + unmapped: list[dict[str, str]] = [] + for block_index, block in enumerate(data.get("content", []) or []): + if not isinstance(block, dict): + _record_unmapped(unmapped, f"content[{block_index}]", type(block).__name__) + continue + block_type = block.get("type") + if block_type == "text": + parts.append(TextPart(text=str(block.get("text") or ""))) + for citation_payload in block.get("citations", []) or []: + if not isinstance(citation_payload, dict): + continue + citation = _citation_from_anthropic(citation_payload) + if citation is not None: + parts.append(citation) + elif block_type == "tool_use": + if not block.get("name"): + raise unnamed_tool_call_error(self.provider, f"content[{block_index}]") + parts.append(ToolCallPart( + id=str(block.get("id") or f"tool_{len(parts)}"), + name=str(block["name"]), + input=block.get("input") if isinstance(block.get("input"), dict) else {}, + )) + elif block_type == "thinking": + continuation: tuple[ContinuationState, ...] = () + if block.get("signature"): + continuation = ( + ContinuationState( + provider="anthropic", + kind="thinking_signature", + data={"signature": str(block.get("signature"))}, + ), + ) + parts.append( + ThinkingPart( + text=str(block.get("thinking") or block.get("text") or ""), + continuation=continuation, + ) + ) + elif block_type == "redacted_thinking": + # MAP-7 rule 11: hidden thinking is empty text plus replay + # state; the blob goes back verbatim as redacted_thinking. + continuation = () + redacted_payload = block.get("data") + if redacted_payload is not None: + continuation = ( + ContinuationState( + provider="anthropic", + kind="redacted_thinking", + data={"data": redacted_payload}, + ), + ) + parts.append(ThinkingPart(text="", continuation=continuation)) + elif block_type in ANTHROPIC_PROVIDER_EXECUTED_BLOCKS: + continue + else: + _record_unmapped(unmapped, f"content[{block_index}]", block_type) + + if not parts: + parts = [TextPart(text="")] + + usage_payload = data.get("usage", {}) or {} + # INV-029: absent counters stay None; Usage sums the total itself + # only when both primaries are present. + usage = Usage( + input_tokens=usage_payload.get("input_tokens"), + output_tokens=usage_payload.get("output_tokens"), + cache_read_tokens=usage_payload.get("cache_read_input_tokens"), + cache_write_tokens=usage_payload.get("cache_creation_input_tokens"), + reasoning_tokens=_reasoning_tokens(usage_payload), + ) + has_tool = any(isinstance(part, ToolCallPart) for part in parts) + # D8 (2026-09-06): Response.id carries the message id; no + # message-level continuation state is minted for it. + return Response( + id=str(data.get("id")) if data.get("id") else None, + model=str(data.get("model") or request.model), + message=Message(role="assistant", parts=replace_text_with_data(parts, request_judgments(request))), + finish_reason=_finish_reason(data.get("stop_reason"), has_tool_call=has_tool), + usage=usage, + provider_data=_attach_unmapped(data, unmapped), + ) + + def parse_stream_events(self, request: Request, raw_event: SSEEvent) -> Iterator[StreamEvent]: + request = self._wire_request(request) + if not raw_event.data: + return + payload = json.loads(raw_event.data) + et = payload.get("type") + if et == "message_start": + msg = payload.get("message", {}) if isinstance(payload.get("message"), dict) else {} + yield StreamStartEvent( + id=str(msg.get("id")) if msg.get("id") else None, + model=str(msg.get("model") or request.model), + ) + return + if et == "content_block_start": + block = payload.get("content_block", {}) if isinstance(payload.get("content_block"), dict) else {} + if block.get("type") == "tool_use": + # A streamed tool_use block opens with input: {} and the + # arguments arrive as input_json_delta fragments; the empty + # object is a placeholder, not a fragment. Serialising it + # produced "{}" + '{"city": ...}' — unparseable — the day + # the first streaming tool call was pinned (2026-09-02). A + # non-empty input on the start frame is kept verbatim. + start_input = block.get("input") + yield StreamDeltaEvent( + delta=ToolCallDelta( + input=json.dumps(start_input, separators=(",", ":")) if isinstance(start_input, dict) and start_input else ("" if isinstance(start_input, dict) else str(start_input or "")), + part_index=int(payload.get("index", 0) or 0), + id=str(block.get("id") or "") or None, + name=str(block.get("name") or "") or None, + ) + ) + return + if block.get("type") == "redacted_thinking" and block.get("data") is not None: + # MAP-7 rule 11: an empty thinking delta opens the hidden + # block; its replay state is the block's only content. The + # block has no interior frames, so the state is emitted here + # (the adapter is stateless per frame) and the trace reads the + # same as an emission at content_block_stop. + idx = int(payload.get("index", 0) or 0) + yield StreamDeltaEvent(delta=ThinkingDelta(text="", part_index=idx)) + yield StreamDeltaEvent( + delta=ContinuationDelta( + provider="anthropic", + kind="redacted_thinking", + data={"data": block.get("data")}, + part_index=idx, + ) + ) + return + if et == "content_block_delta": + delta = payload.get("delta", {}) if isinstance(payload.get("delta"), dict) else {} + idx = int(payload.get("index", 0) or 0) + dtype = delta.get("type") + if dtype == "text_delta": + yield StreamDeltaEvent(delta=TextDelta(text=str(delta.get("text") or ""), part_index=idx)) + elif dtype == "input_json_delta": + yield StreamDeltaEvent(delta=ToolCallDelta(input=str(delta.get("partial_json") or ""), part_index=idx)) + elif dtype == "thinking_delta": + yield StreamDeltaEvent(delta=ThinkingDelta(text=str(delta.get("thinking") or ""), part_index=idx)) + elif dtype == "signature_delta" and delta.get("signature"): + yield StreamDeltaEvent( + delta=ContinuationDelta( + provider="anthropic", + kind="thinking_signature", + data={"signature": str(delta.get("signature"))}, + part_index=idx, + ) + ) + elif dtype in {"citation_delta", "citations_delta"}: + citation = delta.get("citation", {}) if isinstance(delta.get("citation"), dict) else delta + yield StreamDeltaEvent(delta=CitationDelta( + part_index=idx, + text=str(citation.get("cited_text") or citation.get("text") or "") or None, + url=str(citation.get("url") or "") or None, + title=str(citation.get("title") or "") or None, + )) + return + if et == "message_delta": + # Anthropic sends the authoritative stop_reason and final usage here; + # message_stop is just the terminator and carries neither. + delta = payload.get("delta", {}) if isinstance(payload.get("delta"), dict) else {} + usage_payload = payload.get("usage", {}) if isinstance(payload.get("usage"), dict) else {} + usage = None + if usage_payload: + usage = Usage( + input_tokens=usage_payload.get("input_tokens"), + output_tokens=usage_payload.get("output_tokens"), + cache_read_tokens=usage_payload.get("cache_read_input_tokens"), + cache_write_tokens=usage_payload.get("cache_creation_input_tokens"), + reasoning_tokens=_reasoning_tokens(usage_payload), + ) + stop_reason = delta.get("stop_reason") + if stop_reason is not None or usage is not None: + # MAP-3 (D9, 2026-09-06): this frame supplied usage (and the + # stop reason), so it is the end event's provider_data, + # verbatim; the bare message_stop contributes nothing. + yield StreamEndEvent( + finish_reason=_finish_reason(stop_reason) if stop_reason is not None else None, + usage=usage, + provider_data=payload, + ) + return + if et == "message_stop": + yield StreamEndEvent() + return + if et == "error": + err = payload.get("error") + if isinstance(err, dict): + provider_code = str(err.get("type") or err.get("code") or payload.get("code") or "provider") + message = str(err.get("message") or payload.get("message") or "") + else: + provider_code = str(payload.get("code") or payload.get("error_type") or "provider") + message = str(payload.get("message") or "") + yield StreamErrorEvent(error=self._error_detail(provider_code, message)) + + # ─── Other endpoints ──────────────────────────────────────────── + + # ─── Live model listing (provisional endpoint) ────────────────────── + + def _models_request(self): + # limit=1000 is the endpoint maximum; the catalog fits in one page + # today (has_more=false observed live 2026-08-31). + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/models", + params={"limit": 1000}, + headers=self._headers(), + ) + + def _models_from_body(self, body: str): + data = json.loads(body) + entries = data.get("data") if isinstance(data, dict) else None + return model_infos_from_entries( + entries, + provider=self.provider, + api_family="anthropic_messages", + id_of=lambda entry: entry.get("id"), + ) + + # ─── File hooks (Files API, GA) ────────────────────────────── + # + # Wire shapes verified live 2026-08-31 (curl-fixtures/files-2026-08-31/): + # multipart/form-data upload with NO beta header (GA), file objects + # carry mime_type / size_bytes / downloadable verbatim, list paginates + # with an opaque `next_page` token passed back as `?page=`, download + # is refused for non-tool-generated files (400, forwarded typed). + + def _file_upload_request(self, request: FileUploadRequest) -> TransportRequest: + content_type, body = multipart_form_body( + fields=[(k, str(v)) for k, v in (request.extensions or {}).items()], + files=[("file", request.filename, request.media_type, request.bytes)], + ) + headers = self._headers() + headers["content-type"] = content_type + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/files", + headers=list(headers.items()), + body=body, + ) + + def _file_info_from_body(self, body: str) -> FileInfo: + return self._file_info(json.loads(body)) + + def _file_info(self, data: dict[str, Any]) -> FileInfo: + file_id = data.get("id") + if not isinstance(file_id, str) or not file_id: + raise ProviderError("anthropic: file object carries no id", provider=self.provider) + filename = data.get("filename") + mime = data.get("mime_type") + return FileInfo( + id=file_id, + filename=filename if isinstance(filename, str) and filename else None, + media_type=mime if isinstance(mime, str) and mime else None, + size_bytes=data.get("size_bytes") if isinstance(data.get("size_bytes"), int) else None, + created_at=iso_utc(data.get("created_at")), + expires_at=iso_utc(data.get("expires_at")), + readiness="ready", # Anthropic files have no processing state + downloadable=data.get("downloadable") if isinstance(data.get("downloadable"), bool) else None, + provider_data=data, + ) + + def _file_get_request(self, file_id: str) -> TransportRequest: + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/files/{path_id(file_id)}", + headers=self._headers(), + ) + + def _file_list_request(self, limit: int, cursor: str | None) -> TransportRequest: + params: dict[str, Any] = {"limit": limit} + if cursor is not None: + params["page"] = cursor + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/files", + params=params, headers=self._headers(), + ) + + def _file_page_from_list_body(self, body: str) -> FilePage: + data = json.loads(body) + entries = data.get("data") if isinstance(data.get("data"), list) else [] + items = tuple(self._file_info(entry) for entry in entries if isinstance(entry, dict)) + cursor = data.get("next_page") + return FilePage(items=items, next_cursor=cursor if isinstance(cursor, str) and cursor else None) + + def _file_delete_request(self, file_id: str) -> TransportRequest: + return self._emit( + method="DELETE", url=f"{self.base_url.rstrip('/')}/files/{path_id(file_id)}", + headers=self._headers(), + ) + + def _file_download_request(self, file_id: str) -> TransportRequest: + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/files/{path_id(file_id)}/content", + headers=self._headers(), + ) + + # ─── Batch hooks (Message Batches API) ──────────────────────── + + def _batch_submit_request(self, request: BatchRequest, upload_body: dict[str, Any] | None) -> TransportRequest: + self._batch_preflight(request) + if request.label is not None: + adapt("label", "dropped", + "the Message Batches create body has no metadata field (verified live " + "2026-08-31); correlate by id", + asked=request.label, provider=self.provider) + payload = { + "requests": [ + {"custom_id": str(i), "params": self._payload(nested, stream=False)} + for i, nested in enumerate(request.requests) + ], + **(request.extensions or {}), + } + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/messages/batches", + headers=self._headers(), + payload=payload, + ) + + def _batch_job_from_body(self, body: str) -> BatchJobInfo: + return self._batch_job_info(json.loads(body)) + + def _batch_job_info(self, data: dict[str, Any]) -> BatchJobInfo: + batch_id = data.get("id") + if not isinstance(batch_id, str) or not batch_id: + raise ProviderError("anthropic: batch object carries no id", provider=self.provider) + return BatchJobInfo( + id=batch_id, + status=_anthropic_batch_status(data), + created_at=iso_utc(data.get("created_at")), + provider_data=data, + ) + + def _batch_status_request(self, batch_id: str) -> TransportRequest: + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/messages/batches/{path_id(batch_id)}", + headers=self._headers(), + ) + + def _batch_cancel_request(self, batch_id: str) -> TransportRequest: + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/messages/batches/{path_id(batch_id)}/cancel", + headers=self._headers(), + ) + + def _batch_result_fetches(self, status_body: dict[str, Any]) -> tuple[TransportRequest, ...]: + url = status_body.get("results_url") + if not isinstance(url, str) or not url: + raise ProviderError("anthropic: ended batch carries no results_url", provider=self.provider) + return (self._emit(method="GET", url=url, headers=self._headers()),) + + def _batch_entries(self, status_body: dict[str, Any], fetched: tuple[str, ...]) -> tuple[BatchEntry, ...]: + entries: list[BatchEntry] = [] + for line in fetched[0].splitlines(): + if not line.strip(): + continue + item = json.loads(line) + index = int(str(item.get("custom_id"))) + result = item.get("result") or {} + rtype = str(result.get("type") or "") + if rtype == "succeeded": + message = result.get("message") or {} + response = self.parse_response( + batch_entry_request(message.get("model")), batch_entry_http(message) + ) + entries.append(BatchEntry(index=index, outcome="succeeded", response=response)) + elif rtype == "errored": + raw = result.get("error") or {} + envelope = raw if isinstance(raw, dict) and "error" in raw else {"error": raw} + err = self.normalize_error(400, json.dumps(envelope)) + entries.append(BatchEntry( + index=index, + outcome="errored", + error=ErrorDetail( + code=canonical_error_code(err), + message=err.message or "batch entry errored", + provider_code=err.provider_code, + ), + )) + elif rtype == "canceled": + entries.append(BatchEntry(index=index, outcome="cancelled")) + elif rtype == "expired": + entries.append(BatchEntry(index=index, outcome="expired")) + else: + entries.append(BatchEntry( + index=index, + outcome="errored", + error=ErrorDetail(code="provider", message=f"unrecognized batch result type {rtype!r}"), + )) + return tuple(sorted(entries, key=lambda e: e.index)) + + def _batch_list_request(self, limit: int) -> TransportRequest: + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/messages/batches", + params={"limit": int(limit)}, + headers=self._headers(), + ) + + def _batch_jobs_from_list_body(self, body: str) -> tuple[BatchJobInfo, ...]: + data = json.loads(body) + items = data.get("data") if isinstance(data, dict) else None + return tuple(self._batch_job_info(item) for item in (items or []) if isinstance(item, dict)) diff --git a/providers/async_base.py b/providers/async_base.py new file mode 100644 index 0000000000..05441387b1 --- /dev/null +++ b/providers/async_base.py @@ -0,0 +1,1070 @@ +"""Async mirror provider LMs. + +Each Async* class is a perfect mirror of its sync sibling: same constructor +fields, same canonical Request in, same canonical Response / StreamEvents +out — ``await`` is the only user-visible difference. + +Design (see docs/design-rationale.md, "Async"): composition, not +inheritance. Subclassing the sync adapter and overriding sync methods with +async ones would be a typing violation (``complete`` would no longer be +substitutable). Instead each async class owns the async transport and +delegates ALL pure mapping — build_request, parse_response, +parse_stream_events, normalize_error, payload/header helpers — to an inner +instance of the sync adapter class, constructed with a transport that raises +if it is ever used: the inner adapter must never touch the network. +""" +from __future__ import annotations + +import asyncio +from datetime import datetime +import os +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Callable, ClassVar, Mapping, Protocol, TypeVar + +from ..adaptation import AdaptationPolicy, adapt, check_policy, collecting +from ..judgments import request_judgments +from ..errors import ( + ProviderError, + TransportError as LM15TransportError, + UnsupportedFeatureError, +) +from ..features import EndpointSupport, ProviderManifest +from ..sse import aparse_sse +from ..transports import ( + AsyncTransportResponse, + StdlibAsyncTransport, + TransportRequest, + TransportError as NetworkTransportError, +) +from ..types import ( + VideoGenerationRequest, + SpeechGenerationRequest, + BatchRequest, + FileUploadRequest, + ImageGenerationRequest, + LiveConfig, + Request, + Response, + StreamEvent, +) +from .anthropic import AnthropicLM +from .base import ( + BaseProviderLM, Credential, HttpResponse, _attach_error_metadata, _client_side_stop, + _parse_reply, _reply_error_metadata, _reply_object, _stream_error_metadata, +) +from .claude_code import DEFAULT_CLAUDE_CODE_VERSION, ClaudeCodeLM +from .gemini import GeminiLM +from .openai import OpenAILM +from .openai_chat import OpenAIChatLM +from .openai_codex import ( + DEFAULT_CODEX_BASE_URL, + DEFAULT_CODEX_CLIENT_VERSION, + DEFAULT_CODEX_ORIGINATOR, + OpenAICodexLM, +) +from .typesafe import TypeSafeLM +from .xai import DEFAULT_XAI_BASE_URL, XaiLM + +_T = TypeVar("_T") + + +class AsyncTransport(Protocol): + """Minimal async transport surface used by async provider LMs. + + ``stream`` returns an async context manager producing an + :class:`AsyncTransportResponse` (StdlibAsyncTransport's shape). + """ + + def stream(self, request: TransportRequest) -> Any: ... + + +def default_async_transport() -> AsyncTransport: + """Create the default async transport for standalone async provider LMs.""" + return StdlibAsyncTransport() + + +def _mirror_default(cls, name: str): + """Default value of a sync sibling's dataclass field (slots-safe).""" + return cls.__dataclass_fields__[name].default + + +class _ForbiddenTransport: + """Transport for the inner sync adapter: pure mapping only, no I/O.""" + + def stream(self, request: TransportRequest) -> Any: + raise RuntimeError( + "inner sync adapter of an Async* provider LM must never touch the " + "network; all I/O goes through the async transport" + ) + + +class AsyncBaseProviderLM: + """Shared asynchronous provider LM implementation. + + Mirrors :class:`BaseProviderLM` faithfully: build (delegated, pure) -> + await async transport -> parse (delegated, pure); status>=400 raises the + delegated normalize_error; transport errors are wrapped in + lm15.TransportError. Streaming applies MAP-3 via + :func:`lm15.result.acoalesce_stream`. + """ + + transport: AsyncTransport + _inner: BaseProviderLM # set by subclass __post_init__ + + # Mirrored metadata (subclasses override like their sync siblings). + provider: str = "unknown" + manifest: ClassVar[ProviderManifest] = ProviderManifest(provider="unknown") + + @property + def access(self) -> ProviderManifest: + """The bound access policy — the inner sync adapter's.""" + return self._inner.access + + @property + def supports(self) -> EndpointSupport: + return self._inner.access.supports + + @classmethod + def has_stored_credential(cls) -> bool: + from ..access import has_stored_credential + + return has_stored_credential(cls.manifest) + + def _mirror_binding(self) -> None: + """Copy what the inner adapter's access binding resolved: the + provider string, the credential (a per-request provider for stored + logins, repr-suppressed), and the base URL the policy chose.""" + self.provider = self._inner.provider + self.api_key = self._inner.api_key + self.base_url = self._inner.base_url + + async def _build(self, build: "Callable[..., _T]", *args: Any, **kwargs: Any) -> "_T": + """Run one of the inner adapter's request builders. + + A builder invokes the credential provider (AUTH-2) inside ``_emit``. + A cloud-chain provider may refresh over the network, synchronously; + run it in a worker thread so that refresh does not block the event + loop (D16, 2026-09-06). A static credential needs no thread. + """ + if callable(self._inner.api_key): + return await asyncio.to_thread(build, *args, **kwargs) + return build(*args, **kwargs) + + # MAP-13 policy; every async dataclass declares the field (constructor + # parity with its sync sibling) and this is the fallback. + adaptations: AdaptationPolicy = "note" + + def plan(self, request: Request): + """What a call WOULD adapt (MAP-13), no network, no credential; pure, so sync.""" + return self._inner.plan(request, policy=self.adaptations) + + async def complete(self, request: Request) -> Response: + req, adaptations = await self._build(self._inner._build, request, stream=False, policy=self.adaptations) + if _client_side_stop(adaptations): + # Mirror of BaseProviderLM.complete: a client-side stop streams + # and closes at the cut (MAP-13); see the sync comment. + from ..result import amaterialize_response + + return await amaterialize_response(self.stream(request), request) + resp = await self._send(req) + if resp.status >= 400: + raise self._inner._http_error(resp) + try: + return self._inner._finish_response(request, self._inner.parse_response(request, resp), adaptations, policy=self.adaptations) + except ProviderError as error: + _attach_error_metadata(error, resp.headers) + raise + + def stream(self, request: Request) -> AsyncIterator[StreamEvent]: + # MAP-3 (docs/mapping-rules.md): adapters may emit one end event per + # provider terminal frame; the coalescer merges them so the public + # stream yields exactly one final StreamEndEvent. + return self._astream(request) + + async def _astream(self, request: Request) -> AsyncIterator[StreamEvent]: + from ..result import acoalesce_stream, atruncate_stream_at_stop + req, adaptations = await self._build(self._inner._build, request, stream=True, policy=self.adaptations) + events = acoalesce_stream(self._stream_raw(request, req), model=self._inner._wire_request(request).model, adaptations=self._inner._visible(adaptations, policy=self.adaptations)) + if _client_side_stop(adaptations): + events = atruncate_stream_at_stop(events, request.config.stop) + try: + async for event in events: + yield event + finally: + aclose = getattr(events, "aclose", None) + if aclose is not None: + await aclose() + + async def _stream_raw(self, request: Request, req: "TransportRequest | None" = None) -> AsyncIterator[StreamEvent]: + if req is None: + req = await self._build(self._inner.build_request, request, stream=True) + try: + async with self.transport.stream(req) as resp: + if resp.status >= 400: + body = await resp.read() + error = self._inner.normalize_error( + resp.status, body.decode("utf-8", errors="replace") + ) + _attach_error_metadata(error, resp.headers) + raise error + try: + async for raw in aparse_sse(_aiter_lines(resp)): + for event in self._inner.parse_stream_events(request, raw): + if event is not None: + yield _stream_error_metadata(event, resp.headers) + except ProviderError as error: + _attach_error_metadata(error, resp.headers) + raise + except NetworkTransportError as exc: + raise LM15TransportError(str(exc)) from exc + + async def _send(self, request: TransportRequest) -> HttpResponse: + try: + async with self.transport.stream(request) as resp: + body = await resp.read() + return HttpResponse( + status=resp.status, + reason=resp.reason, + headers=resp.headers, + body=body, + http_version=resp.http_version, + provider=self.provider, + ) + except NetworkTransportError as exc: + raise LM15TransportError(str(exc)) from exc + + def normalize_error(self, status: int, body: str) -> ProviderError: + return self._inner.normalize_error(status, body) + + async def list_models(self): + """Async mirror of BaseProviderLM.list_models (canonical ModelInfo).""" + resp = await self._send(await self._build(self._inner._models_request)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._models_from_body(reply.text())) + + # ── Batch: async drivers over the sync adapter's pure hooks ────── + + async def batch_submit(self, request: "BatchRequest"): + self._inner._batch_preflight(request) + upload_body = None + upload_req = await self._build(self._inner._batch_upload_request, request) + if upload_req is not None: + resp = await self._send(upload_req) + if resp.status >= 400: + raise self._inner._http_error(resp) + upload_body = _parse_reply(resp, _reply_object) + try: + submit_req = await self._build(self._inner._batch_submit_request, request, upload_body) + except ProviderError as error: + if upload_req is not None: + _reply_error_metadata(error, resp) + raise + resp = await self._send(submit_req) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._batch_job_from_body(reply.text())) + + async def batch_status(self, batch_id: str): + resp = await self._send(await self._build(self._inner._batch_status_request, batch_id)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._batch_job_from_body(reply.text())) + + async def batch_results(self, batch_id: str): + from ..types import BATCH_TERMINAL_STATUSES + + resp = await self._send(await self._build(self._inner._batch_status_request, batch_id)) + if resp.status >= 400: + raise self._inner._http_error(resp) + job = _parse_reply(resp, lambda reply: self._inner._batch_job_from_body(reply.text())) + if job.status not in BATCH_TERMINAL_STATUSES: + raise ValueError( + f"batch {batch_id} is not finished (status={job.status!r}); " + f"await wait() or poll batch_status() until done" + ) + status_body = _parse_reply(resp, _reply_object) + try: + fetches = await self._build(self._inner._batch_result_fetches, status_body) + except ProviderError as error: + _reply_error_metadata(error, resp) + raise + texts = [] + for fetch in fetches: + fetched = await self._send(fetch) + if fetched.status >= 400: + raise self._inner._http_error(fetched) + texts.append(self._inner._batch_reply_text(status_body, fetched)) + return _parse_reply(resp, lambda reply: self._inner._batch_entries(status_body, tuple(texts)), json_body=False) + + async def batch_cancel(self, batch_id: str): + resp = await self._send(await self._build(self._inner._batch_cancel_request, batch_id)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._batch_job_from_body(reply.text())) + + async def batch_list(self, limit: int = 20): + resp = await self._send(await self._build(self._inner._batch_list_request, limit)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._batch_jobs_from_list_body(reply.text())) + + async def batch(self, requests, *, model: str | None = None, label: str | None = None, + extensions=None): + """Third execution mode, async twin: returns an AsyncBatchJob.""" + from ..batch import AsyncBatchJob + + if isinstance(requests, BatchRequest): + request = requests + else: + request = BatchRequest(model=model, requests=tuple(requests), label=label, extensions=extensions) + return AsyncBatchJob(self, await self.batch_submit(request)) + + async def batch_job(self, batch_id: str): + from ..batch import AsyncBatchJob + + return AsyncBatchJob(self, await self.batch_status(batch_id)) + + async def batches(self, limit: int = 20): + from ..batch import AsyncBatchJob + + return tuple(AsyncBatchJob(self, info) for info in await self.batch_list(limit)) + + # ── Cache resources: async drivers over the sync adapter's pure hooks ── + + async def cache_create(self, prefix: Request, *, ttl_seconds: int | None = None, label: str | None = None): + self._inner._check_cache_prefix(prefix, ttl_seconds) + resp = await self._send(await self._build(self._inner._cache_create_request, prefix, ttl_seconds, label)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._cache_info_from_body(reply.text())) + + async def cache_get(self, cache_id: str): + resp = await self._send(await self._build(self._inner._cache_get_request, cache_id)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._cache_info_from_body(reply.text())) + + async def cache_list(self, limit: int = 20, cursor: str | None = None): + resp = await self._send(await self._build(self._inner._cache_list_request, limit, cursor)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._cache_page_from_list_body(reply.text())) + + async def cache_delete(self, cache_id: str) -> None: + resp = await self._send(await self._build(self._inner._cache_delete_request, cache_id)) + if resp.status >= 400: + raise self._inner._http_error(resp) + + async def cache_update(self, cache_id: str, *, ttl_seconds: int): + if isinstance(ttl_seconds, bool) or not isinstance(ttl_seconds, int) or ttl_seconds <= 0: + raise ValueError("ttl_seconds must be a positive int") + resp = await self._send(await self._build(self._inner._cache_update_request, cache_id, ttl_seconds)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._cache_info_from_body(reply.text())) + + async def cache(self, prefix: Request, *, ttl_seconds: int | None = None, label: str | None = None): + from ..types import CachedPrefix + + wire = self._inner._wire_request(prefix) + provider = self.provider if wire.model != prefix.model else None + if self.supports.caches: + return CachedPrefix(wire, await self.cache_create(wire, ttl_seconds=ttl_seconds, label=label), provider=provider) + self._inner._check_cache_prefix(wire, ttl_seconds) + return CachedPrefix(wire, provider=provider) + + # ── Files: async drivers over the sync adapter's pure hooks ────── + + async def file_upload(self, request: "FileUploadRequest"): + resp = await self._send(await self._build(self._inner._file_upload_request, request)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._file_info_from_body(reply.text())) + + async def file_get(self, file_id: str): + resp = await self._send(await self._build(self._inner._file_get_request, file_id)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._file_info_from_body(reply.text())) + + async def file_list(self, limit: int = 20, cursor: str | None = None): + resp = await self._send(await self._build(self._inner._file_list_request, limit, cursor)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._file_page_from_list_body(reply.text())) + + async def file_delete(self, file_id: str) -> None: + resp = await self._send(await self._build(self._inner._file_delete_request, file_id)) + if resp.status >= 400: + raise self._inner._http_error(resp) + + async def file_download(self, file_id: str) -> bytes: + resp = await self._send(await self._build(self._inner._file_download_request, file_id)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return resp.body + + async def file_wait_ready(self, file_id: str, poll_every: float = 2.0, timeout: float | None = None): + import asyncio + import time as _time + + deadline = None if timeout is None else _time.monotonic() + timeout + info = await self.file_get(file_id) + while info.readiness == "pending": + if deadline is not None and _time.monotonic() >= deadline: + raise TimeoutError(f"file {file_id} still pending after {timeout}s") + await asyncio.sleep(poll_every) + info = await self.file_get(file_id) + return info + + async def aclose(self) -> None: + aclose = getattr(self.transport, "aclose", None) + if callable(aclose): + await aclose() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.aclose() + + # ── Non-chat endpoints: sync-only for now (honest surface) ────── + + def _async_unsupported(self, endpoint: str) -> UnsupportedFeatureError: + return UnsupportedFeatureError( + f"{self.provider}: {endpoint}: use the sync adapter for this " + "endpoint (async endpoints planned)", + provider=self.provider, + ) + + def live(self, config: LiveConfig): + raise self._async_unsupported("live") + + # ── Video generation: async drivers over the sync adapter's pure hooks ── + + async def video_submit(self, request: "VideoGenerationRequest"): + resp = await self._send(await self._build(self._inner._video_submit_request, request)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._video_job_from_body(reply.text())) + + async def video_status(self, video_id: str): + resp = await self._send(await self._build(self._inner._video_status_request, video_id)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._video_job_from_body(reply.text(), video_id)) + + async def video_result(self, video_id: str): + from ..types import VIDEO_TERMINAL_STATUSES + + resp = await self._send(await self._build(self._inner._video_status_request, video_id)) + if resp.status >= 400: + raise self._inner._http_error(resp) + job = _parse_reply(resp, lambda reply: self._inner._video_job_from_body(reply.text(), video_id)) + if job.status not in VIDEO_TERMINAL_STATUSES: + raise ValueError( + f"video {video_id} is not finished (status={job.status!r}); " + f"wait() or poll video_status() until done" + ) + status_body = _parse_reply(resp, _reply_object) + try: + fetch = await self._build(self._inner._video_result_fetch, status_body) + except ProviderError as error: + _reply_error_metadata(error, resp) + raise + fetched = None + if fetch is not None: + fetched = await self._send(fetch) + if fetched.status >= 400: + raise self._inner._http_error(fetched) + return _parse_reply(fetched or resp, lambda reply: self._inner._video_part(status_body, fetched), json_body=False) + + async def video_list(self, limit: int = 20, model: str | None = None): + resp = await self._send(await self._build(self._inner._video_list_request, limit, model)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._video_jobs_from_list_body(reply.text())) + + async def video_generate(self, request: "VideoGenerationRequest"): + from ..video_jobs import AsyncVideoJob + + return AsyncVideoJob(self, await self.video_submit(request)) + + async def video_job(self, video_id: str): + from ..video_jobs import AsyncVideoJob + + return AsyncVideoJob(self, await self.video_status(video_id)) + + async def video_jobs(self, limit: int = 20, model: str | None = None): + from ..video_jobs import AsyncVideoJob + + return tuple(AsyncVideoJob(self, info) for info in await self.video_list(limit, model)) + + # ── Media generation: async drivers over the sync adapter's pure hooks ── + + async def image_generate(self, request: ImageGenerationRequest): + resp = await self._send(await self._build(self._inner._image_generate_request, request)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._image_generation_from_response(request, reply)) + + async def speech_generate(self, request: SpeechGenerationRequest): + resp = await self._send(await self._build(self._inner._speech_generate_request, request)) + if resp.status >= 400: + raise self._inner._http_error(resp) + return _parse_reply(resp, lambda reply: self._inner._speech_generation_from_response(request, reply), json_body=False) + + +async def _aiter_lines(resp: AsyncTransportResponse) -> AsyncIterator[bytes]: + aiter_lines = getattr(resp, "aiter_lines", None) + if aiter_lines is not None: + async for line in aiter_lines(): + yield line + return + buf = bytearray() + async for chunk in resp: + if not chunk: + continue + buf.extend(chunk) + while True: + idx = buf.find(b"\n") + if idx < 0: + break + yield bytes(buf[: idx + 1]) + del buf[: idx + 1] + if buf: + yield bytes(buf) + + +# ─── Mirror classes ────────────────────────────────────────────────── + + +@dataclass(slots=True) +class AsyncOpenAILM(AsyncBaseProviderLM): + api_key: Credential | None = field(default=None, repr=False) + transport: AsyncTransport = field(default_factory=default_async_transport) + base_url: str = "https://api.openai.com/v1" + profile: Any | None = None + compat: Any | None = field(default=None, kw_only=True) + access: ProviderManifest | None = field(default=None, repr=False) + credentials_path: "str | os.PathLike[str] | None" = field(default=None, repr=False) + settings: "Mapping[str, str] | None" = field(default=None, kw_only=True) + credential: str | None = field(default=None, kw_only=True) + clock: "Callable[[], datetime] | None" = field(default=None, repr=False, kw_only=True) + account_id: str | None = None + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + + async def live(self, config: LiveConfig): + self._inner._require("live") + # Native async websocket (websockets.asyncio) — not a thread + # wrapper: a blocked sync recv in a worker thread cannot be + # cancelled from the event loop, and cancellation is the heart + # of realtime. Codecs are the inner adapter's pure functions. + import json as _json + + from ..live import AsyncWebSocketLiveSession, require_websocket_async_connect + + connect = require_websocket_async_connect() + inner = self._inner + ws = await connect(inner._live_url(config.model), additional_headers=inner._live_headers()) + for frame in inner._live_setup_frames(config): + await ws.send(_json.dumps(frame)) + return AsyncWebSocketLiveSession( + ws=ws, + encode_event=inner._live_encoder(config), + decode_event=inner._decode_live_server_event, + ) + + provider: str = field(default="openai", init=False) + manifest: ClassVar[ProviderManifest] = OpenAILM.manifest + + _inner: OpenAILM = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + self._inner = OpenAILM( + adaptations=self.adaptations, + api_key=self.api_key, + transport=_ForbiddenTransport(), + base_url=self.base_url, + profile=self.profile, + compat=self.compat, + access=self.access, + credentials_path=self.credentials_path, + settings=self.settings, + credential=self.credential, + clock=self.clock, + account_id=self.account_id, + ) + self._mirror_binding() + # The sync sibling resolves a preset name (and may supply that + # server's default base_url); mirror the resolved values. + self.base_url = self._inner.base_url + + +@dataclass(slots=True) +class AsyncAnthropicLM(AsyncBaseProviderLM): + api_key: Credential | None = field(default=None, repr=False) + transport: AsyncTransport = field(default_factory=default_async_transport) + base_url: str = "https://api.anthropic.com/v1" + api_version: str = "2023-06-01" + compat: Any | None = None + access: ProviderManifest | None = field(default=None, repr=False) + credentials_path: "str | os.PathLike[str] | None" = field(default=None, repr=False) + settings: "Mapping[str, str] | None" = None + credential: str | None = field(default=None, kw_only=True) + clock: "Callable[[], datetime] | None" = field(default=None, repr=False) + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + + provider: str = field(default="anthropic", init=False) + manifest: ClassVar[ProviderManifest] = AnthropicLM.manifest + + _inner: AnthropicLM = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + self._inner = AnthropicLM( + adaptations=self.adaptations, + api_key=self.api_key, + transport=_ForbiddenTransport(), + base_url=self.base_url, + api_version=self.api_version, + compat=self.compat, + access=self.access, + credentials_path=self.credentials_path, + settings=self.settings, + credential=self.credential, + clock=self.clock, + ) + self._mirror_binding() + # The sync sibling resolves compat presets (and that server's default + # base_url); mirror the resolved values, as AsyncOpenAIChatLM does. + self.base_url = self._inner.base_url + self.compat = self._inner.compat + + +@dataclass(slots=True) +class AsyncGeminiLM(AsyncBaseProviderLM): + api_key: Credential | None = field(default=None, repr=False) + transport: AsyncTransport = field(default_factory=default_async_transport) + base_url: str = "https://generativelanguage.googleapis.com/v1beta" + upload_base_url: str = "https://generativelanguage.googleapis.com/upload/v1beta" + access: ProviderManifest | None = field(default=None, repr=False) + credentials_path: "str | os.PathLike[str] | None" = field(default=None, repr=False) + settings: "Mapping[str, str] | None" = None + credential: str | None = field(default=None, kw_only=True) + clock: "Callable[[], datetime] | None" = field(default=None, repr=False) + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + + provider: str = field(default="gemini", init=False) + manifest: ClassVar[ProviderManifest] = GeminiLM.manifest + + _inner: GeminiLM = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + self._inner = GeminiLM( + adaptations=self.adaptations, + api_key=self.api_key, + transport=_ForbiddenTransport(), + base_url=self.base_url, + upload_base_url=self.upload_base_url, + access=self.access, + credentials_path=self.credentials_path, + settings=self.settings, + credential=self.credential, + clock=self.clock, + ) + self._mirror_binding() + + async def live(self, config: LiveConfig): + self._inner._require("live") + # Native async twin of GeminiLM.live: same pure setup frame, + # encoder, and setup-status classifier; only the socket awaits. + import json as _json + + from ..live import AsyncWebSocketLiveSession, require_websocket_async_connect + + connect = require_websocket_async_connect() + inner = self._inner + ws = await connect(inner._live_url()) + for frame in inner._live_setup_frames(config): + await ws.send(_json.dumps(frame)) + while not inner._live_setup_status(await ws.recv()): + pass + return AsyncWebSocketLiveSession( + ws=ws, + encode_event=inner._live_encoder(config), + decode_event=inner._decode_live_server_event, + ) + + +@dataclass(slots=True) +class AsyncOpenAIChatLM(AsyncBaseProviderLM): + api_key: Credential | None = field(default=None, repr=False) + transport: AsyncTransport = field(default_factory=default_async_transport) + base_url: str = _mirror_default(OpenAIChatLM, "base_url") + compat: Any | None = None + access: ProviderManifest | None = field(default=None, repr=False) + credentials_path: "str | os.PathLike[str] | None" = field(default=None, repr=False) + settings: "Mapping[str, str] | None" = None + credential: str | None = field(default=None, kw_only=True) + clock: "Callable[[], datetime] | None" = field(default=None, repr=False) + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + + provider: str = field(default="openai-chat", init=False) + manifest: ClassVar[ProviderManifest] = OpenAIChatLM.manifest + + _inner: OpenAIChatLM = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + self._inner = OpenAIChatLM( + adaptations=self.adaptations, + api_key=self.api_key, + transport=_ForbiddenTransport(), + base_url=self.base_url, + compat=self.compat, + access=self.access, + credentials_path=self.credentials_path, + settings=self.settings, + credential=self.credential, + clock=self.clock, + ) + self._mirror_binding() + # The sync sibling's __post_init__ resolves compat presets (and may + # supply that server's default base_url); mirror the resolved values. + self.base_url = self._inner.base_url + self.compat = self._inner.compat + + def request_from_openai_chat(self, body: "Mapping[str, Any]") -> Request: + """Pure; the sync sibling's, under this adapter's compat (MAP-12).""" + return self._inner.request_from_openai_chat(body) + + def response_from_openai_chat(self, body: "Mapping[str, Any]", *, model: str | None = None, choice: int | None = None) -> Response: + """Pure; the sync sibling's reader under this adapter's provider name.""" + return self._inner.response_from_openai_chat(body, model=model, choice=choice) + + async def complete(self, request: Request) -> Response: + """Mirror of OpenAIChatLM.complete: the judgment trie driver (MAP-14 + §4) over the inner adapter's pure hooks, else the ordinary path.""" + inner = self._inner + wire_request = inner._wire_request(request) + if not inner._judgments_via_token_scoring(wire_request): + return await AsyncBaseProviderLM.complete(self, request) + adaptations = inner._judgment_adaptations(wire_request, policy=self.adaptations) + found = request_judgments(request) + plan = await self._build(inner._judgment_plan, wire_request) + + async def tokens(req): + resp = await self._send(req) + if resp.status >= 400: + raise inner._http_error(resp) + return inner._judgment_tokens_from_body(resp) + + tokenized = [] + calls = 0 + for entry in plan: + prefix = await tokens(entry["prefill"]); calls += 1 + pairs = await asyncio.gather(*(asyncio.gather(tokens(o), tokens(c)) for o, c in entry["keys"].values())) + calls += 2 * len(pairs) + keys = {k: (list(o), list(c)) for k, (o, c) in zip(entry["keys"], pairs)} + tokenized.append((entry["judgment"], inner._judgment_paths(prefix, keys), prefix)) + prompts, meta, union, nodes_per = [], [], set(), [] + for index, (j, paths, prefix) in enumerate(tokenized): + nodes = inner._judgment_nodes(paths) + nodes_per.append(nodes) + for node_prefix, children in nodes.items(): + prompts.append(list(prefix) + list(node_prefix)) + meta.append((index, node_prefix)) + union |= children + scoring_request = await self._build(inner._judgment_score_request, wire_request.model, prompts, sorted(union)) + resp = await self._send(scoring_request) + if resp.status >= 400: + raise inner._http_error(resp) + scores, usage, model = inner._judgment_scores_from_body(resp, len(prompts)) + tables: list[dict] = [{} for _ in tokenized] + for (index, node_prefix), got in zip(meta, scores): + children = nodes_per[index][node_prefix] + if any(t not in got for t in children): + return await self._judgment_unmeasured(request, adaptations, usage) + tables[index][node_prefix] = {t: got[t] for t in children} + per = [(j, paths, tables[i]) for i, (j, paths, _) in enumerate(tokenized)] + try: + response = inner._judgment_fold(wire_request, found, per, usage, model, len(prompts), calls) + except ProviderError as error: + error.status = resp.status + _attach_error_metadata(error, resp.headers) + raise + from dataclasses import replace + + if inner._judgment_mixed(request): + generated, _ = await self._judgment_generate(request) + response = inner._judgment_merge(response, generated) + return replace(response, adaptations=inner._visible(adaptations, policy=self.adaptations)) + + async def _judgment_generate(self, request: Request, *, unmeasured: bool = False): + from dataclasses import replace + from ..result import amaterialize_response + + inner = self._inner + generated_request = inner._judgment_generated_request(request) + wire, built = await self._build(inner._build, generated_request, stream=False, policy=self.adaptations) + if _client_side_stop(built): + plain = replace(generated_request, config=replace(generated_request.config, response_format=None)) + response = await amaterialize_response(self.stream(generated_request), plain) + return inner._judgment_generated_value(generated_request, response, unmeasured=unmeasured), built + reply = await self._send(wire) + if reply.status >= 400: + raise inner._http_error(reply) + return inner._judgment_generated_response(generated_request, reply, built, policy=self.adaptations, unmeasured=unmeasured), built + + async def _judgment_unmeasured(self, request: Request, adaptations, usage) -> Response: + inner = self._inner + if request.config.probabilities == "required": + raise UnsupportedFeatureError( + f"{self.provider}: config.probabilities='required' but this server ignored logprob_token_ids " + "(vLLM < 0.29?); no distribution can be measured here", + provider=self.provider, feature="config.probabilities", + ) + with collecting(check_policy(self.adaptations), provider=self.provider) as scope: + adapt("config.probabilities", "dropped", + "the server accepted the request and returned no log-probs for the requested token ids (logprob_token_ids ignored); " + "answered by structured output instead", asked=request.config.probabilities, provider=self.provider) + from dataclasses import replace + + response, built = await self._judgment_generate(request, unmeasured=True) + response = inner._judgment_fallback_usage(response, usage) + records = tuple(adaptations) + tuple(scope.records) + tuple(a for a in built if a not in adaptations) + return replace(response, adaptations=inner._visible(records, policy=self.adaptations)) + + +# ─── Subscription mirrors (Claude Code / Codex CLI OAuth) ──────────── +# +# Same composition pattern: the inner sync adapter validates the local OAuth +# credential at construction time (typed, re-login-guided errors), then +# re-resolves it per request so long-lived clients stay fresh. Per-request +# resolution is a local file read; a refresh, when the token has expired, is +# one blocking token-endpoint call inside the sync mapping layer. Credential +# fields are repr-suppressed so secrets never leak. + + +@dataclass(slots=True) +class AsyncClaudeCodeLM(AsyncBaseProviderLM): + api_key: Credential | None = field(default=None, repr=False) + credentials_path: "str | os.PathLike[str] | None" = None + transport: AsyncTransport = field(default_factory=default_async_transport) + base_url: str = "https://api.anthropic.com/v1" + api_version: str = "2023-06-01" + claude_code_version: str = DEFAULT_CLAUDE_CODE_VERSION + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + + # Not constructor params on the sync sibling either (it is not a dataclass). + provider: str = field(default="claude-code", init=False) + manifest: ClassVar[ProviderManifest] = ClaudeCodeLM.manifest + + _inner: ClaudeCodeLM = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + self._inner = ClaudeCodeLM( + adaptations=self.adaptations, + api_key=self.api_key, + credentials_path=self.credentials_path, + transport=_ForbiddenTransport(), + base_url=self.base_url, + api_version=self.api_version, + claude_code_version=self.claude_code_version, + ) + self.api_key = self._inner.api_key # static key or per-request credential provider (repr-suppressed) + + # Files are an API-key surface; the subscription credential does not + # carry them. Block every inherited async driver, not just upload. + def file_upload(self, request: FileUploadRequest): + return self._inner.file_upload(request) # raises UnsupportedFeatureError + + def file_get(self, file_id: str): + return self._inner.file_get(file_id) # raises UnsupportedFeatureError + + def file_list(self, limit: int = 20, cursor: str | None = None): + return self._inner.file_list(limit, cursor) # raises UnsupportedFeatureError + + def file_delete(self, file_id: str): + return self._inner.file_delete(file_id) # raises UnsupportedFeatureError + + def file_download(self, file_id: str): + return self._inner.file_download(file_id) # raises UnsupportedFeatureError + + # Batch is an API-key surface; the subscription credential does not + # carry it. Block every inherited async driver, not just submit. + def batch_submit(self, request: BatchRequest): + return self._inner.batch_submit(request) # raises UnsupportedFeatureError + + def batch_status(self, batch_id: str): + return self._inner.batch_status(batch_id) # raises UnsupportedFeatureError + + def batch_results(self, batch_id: str): + return self._inner.batch_results(batch_id) # raises UnsupportedFeatureError + + def batch_cancel(self, batch_id: str): + return self._inner.batch_cancel(batch_id) # raises UnsupportedFeatureError + + def batch_list(self, limit: int = 20): + return self._inner.batch_list(limit) # raises UnsupportedFeatureError + + def live(self, config: LiveConfig): + return self._inner.live(config) # raises UnsupportedFeatureError + + +@dataclass(slots=True) +class AsyncOpenAICodexLM(AsyncBaseProviderLM): + api_key: Credential | None = field(default=None, repr=False) + account_id: str | None = None + auth_path: "str | os.PathLike[str] | None" = None + transport: AsyncTransport = field(default_factory=default_async_transport) + base_url: str = DEFAULT_CODEX_BASE_URL + originator: str = DEFAULT_CODEX_ORIGINATOR + client_version: str = DEFAULT_CODEX_CLIENT_VERSION + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + + # Not constructor params on the sync sibling either (it is not a dataclass). + provider: str = field(default="openai-codex", init=False) + manifest: ClassVar[ProviderManifest] = OpenAICodexLM.manifest + + _inner: OpenAICodexLM = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + self._inner = OpenAICodexLM( + adaptations=self.adaptations, + api_key=self.api_key, + account_id=self.account_id, + auth_path=self.auth_path, + transport=_ForbiddenTransport(), + base_url=self.base_url, + originator=self.originator, + client_version=self.client_version, + ) + self.api_key = self._inner.api_key # static key or per-request credential provider (repr-suppressed) + self.account_id = self._inner.account_id + + async def complete(self, request: Request) -> Response: + # Mirror of OpenAICodexLM.complete: the Codex subscription backend is + # streaming-first; materialize the (coalesced) stream. + from ..result import amaterialize_response + + return await amaterialize_response(self.stream(request), request) + + def live(self, config: LiveConfig): + return self._inner.live(config) # raises UnsupportedFeatureError + + # Files are an API-key surface; the subscription credential does not + # carry them. Block every inherited async driver, not just upload. + def file_upload(self, request: FileUploadRequest): + return self._inner.file_upload(request) # raises UnsupportedFeatureError + + def file_get(self, file_id: str): + return self._inner.file_get(file_id) # raises UnsupportedFeatureError + + def file_list(self, limit: int = 20, cursor: str | None = None): + return self._inner.file_list(limit, cursor) # raises UnsupportedFeatureError + + def file_delete(self, file_id: str): + return self._inner.file_delete(file_id) # raises UnsupportedFeatureError + + def file_download(self, file_id: str): + return self._inner.file_download(file_id) # raises UnsupportedFeatureError + + # Batch is an API-key surface; the subscription credential does not + # carry it. Block every inherited async driver, not just submit. + def batch_submit(self, request: BatchRequest): + return self._inner.batch_submit(request) # raises UnsupportedFeatureError + + def batch_status(self, batch_id: str): + return self._inner.batch_status(batch_id) # raises UnsupportedFeatureError + + def batch_results(self, batch_id: str): + return self._inner.batch_results(batch_id) # raises UnsupportedFeatureError + + def batch_cancel(self, batch_id: str): + return self._inner.batch_cancel(batch_id) # raises UnsupportedFeatureError + + def batch_list(self, limit: int = 20): + return self._inner.batch_list(limit) # raises UnsupportedFeatureError + + def image_generate(self, request: ImageGenerationRequest): + return self._inner.image_generate(request) # raises UnsupportedFeatureError + + def speech_generate(self, request: SpeechGenerationRequest): + return self._inner.speech_generate(request) # raises UnsupportedFeatureError + + +@dataclass(slots=True) +class AsyncXaiLM(AsyncBaseProviderLM): + """Async mirror of :class:`XaiLM` (subscription OAuth or bearer key).""" + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + + api_key: Credential | None = field(default=None, repr=False) + credentials_path: "str | os.PathLike[str] | None" = None + transport: AsyncTransport = field(default_factory=default_async_transport) + base_url: str = DEFAULT_XAI_BASE_URL + + # Not constructor params on the sync sibling either. + provider: str = field(default="xai", init=False) + manifest: ClassVar[ProviderManifest] = XaiLM.manifest + # Same offline probe as the sync sibling (oauth-unless-explicit policy). + has_stored_credential: ClassVar = XaiLM.has_stored_credential + + _inner: XaiLM = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + self._inner = XaiLM( + adaptations=self.adaptations, + api_key=self.api_key, + credentials_path=self.credentials_path, + transport=_ForbiddenTransport(), + base_url=self.base_url, + ) + self.api_key = self._inner.api_key # static key or per-request credential provider (repr-suppressed) + + +__all__ = [ + "AsyncBaseProviderLM", + "AsyncTransport", + "AsyncOpenAILM", + "AsyncAnthropicLM", + "AsyncGeminiLM", + "AsyncOpenAIChatLM", + "AsyncClaudeCodeLM", + "AsyncOpenAICodexLM", + "AsyncXaiLM", + "default_async_transport", +] + + +@dataclass +class AsyncTypeSafeLM(AsyncBaseProviderLM): + """Async mirror of :class:`TypeSafeLM` (composition; the inner adapter + cannot reach the network).""" + + api_key: Credential | None = field(default=None, repr=False) + transport: AsyncTransport = field(default_factory=default_async_transport) + base_url: str = "https://api.typesafe.ai" + access: ProviderManifest | None = field(default=None, repr=False) + credentials_path: "str | os.PathLike[str] | None" = field(default=None, repr=False) + settings: "Mapping[str, str] | None" = None + clock: "Callable[[], datetime] | None" = field(default=None, repr=False) + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + + provider: str = field(default="typesafe", init=False) + manifest: ClassVar[ProviderManifest] = TypeSafeLM.manifest + + _inner: TypeSafeLM = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + self._inner = TypeSafeLM( + adaptations=self.adaptations, + api_key=self.api_key, + transport=_ForbiddenTransport(), + base_url=self.base_url, + access=self.access, + credentials_path=self.credentials_path, + settings=self.settings, + clock=self.clock, + ) + self._mirror_binding() diff --git a/providers/base.py b/providers/base.py new file mode 100644 index 0000000000..9f599e4bd1 --- /dev/null +++ b/providers/base.py @@ -0,0 +1,1441 @@ +from __future__ import annotations + +import json +import math +import os +from dataclasses import dataclass, replace +from datetime import datetime +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator, Mapping, Protocol, Sequence, TypeVar + +if TYPE_CHECKING: # pragma: no cover - import cycle guard for annotations + from ..batch import BatchJob + from ..video_jobs import VideoJob + +from ..errors import ( + AuthError, + NotConfiguredError, + ProviderError, + TransportError as LM15TransportError, + UnsupportedFeatureError, + map_http_error, + with_credential_hint, +) +from ..adaptation import Adaptation, AdaptationPolicy, check_policy, collecting, is_planning +from ..credentials import AwsCredentials, CredentialLike, CredentialValue, coerce_credential +from ..features import EndpointSupport, ProviderManifest +from ..models import ModelInfo +from ..protocols import LiveSession +from ..sse import SSEEvent, parse_sse +from ..transports import TransportRequest +from ..transports import TransportResponse +from ..transports import StdlibTransport +from ..transports import TransportError as NetworkTransportError +from ..types import ( + CachedPrefix, + CacheInfo, + CachePage, + Config, + VIDEO_TERMINAL_STATUSES, + VideoGenerationRequest, + VideoJobInfo, + VideoPart, + SpeechGenerationRequest, + SpeechGenerationResponse, + BATCH_TERMINAL_STATUSES, + BatchEntry, + BatchJobInfo, + BatchRequest, + FileInfo, + FilePage, + FileUploadRequest, + ImageGenerationRequest, + ImageGenerationResponse, + LiveConfig, + Request, + Response, + StreamEvent, + StreamErrorEvent, +) + + +class SyncTransport(Protocol): + """Minimal sync transport surface used by provider LMs.""" + + def stream(self, request: TransportRequest) -> TransportResponse: ... + + +def default_transport() -> SyncTransport: + """Create the default sync transport for standalone provider LMs.""" + return StdlibTransport() + + +# A credential is a static key string, an AUTH-2 credential value (ApiKey, +# BearerToken, AwsCredentials — lm15.credentials), or a zero-argument +# provider callable returning either. Providers are invoked at +# request-build time, once per request, so rotating credentials (OAuth +# refresh, cloud chains) stay fresh in long-lived clients. Caching and +# refreshing belongs to the provider callable (AUTH-2); the adapter never +# caches what it returns. +Credential = CredentialLike + + +def resolve_credential_value(credential: Credential) -> CredentialValue: + """Invoke a provider callable if needed and coerce to a credential value.""" + raw = credential() if callable(credential) else credential + return coerce_credential(raw) + + +def _origin_label(credential: Credential | None, source: str) -> str: + """The provenance label for a credential the adapter was handed (AUTH-1 + provenance). Honest about what lm15 can see: a callable's identity is + not inspected.""" + if credential is None or credential == "": + return "no credential" + if source == "stored": + return "the stored local login (credentials file)" + if callable(credential): + return "an application-supplied callable (identity not inspected by lm15)" + return "an explicit api_key (value never shown)" + + +def resolve_credential(credential: Credential) -> str: + """The bearer/key STRING of a credential — the pre-2026-09-03 shape the + dialects' header code reads. AWS credentials have no single string; + they travel as a SigV4 signature (``_emit``), never through here.""" + value = resolve_credential_value(credential) + if isinstance(value, AwsCredentials): + raise NotConfiguredError("AWS credentials cannot be sent as a header value; the door must accept sigv4") + return value.value + + +def _retry_after_seconds(value: str | None) -> float | None: + """Parse an HTTP Retry-After header: delta-seconds or an HTTP-date.""" + if value is None or value == "" or isinstance(value, bool): + return None + try: + seconds = float(value) + except (TypeError, ValueError, OverflowError): + pass + else: + return seconds if math.isfinite(seconds) and seconds >= 0 else None + from datetime import datetime, timezone + from email.utils import parsedate_to_datetime + + try: + when = parsedate_to_datetime(value) + except Exception: + return None + if when is None: + return None + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + return max(0.0, (when - datetime.now(timezone.utc)).total_seconds()) + + +def _attach_retry_after(error: ProviderError, headers: "list[tuple[str, str]] | None") -> None: + """Populate error.retry_after from a Retry-After header. + + A valid provider-body-derived value wins; an invalid hint must never + become an infinite wait or replace the provider failure with a timer error.""" + hint = _retry_after_seconds(getattr(error, "retry_after", None)) + error.retry_after = hint + if hint is not None: + return + value = None + for key, val in headers or []: + if key.lower() == "retry-after": + value = val + break + seconds = _retry_after_seconds(value) + if seconds is not None: + error.retry_after = seconds + return + from ..rate_limits import milliseconds_seconds + + for name in ("retry-after-ms", "x-ms-retry-after-ms"): + value = next((v for k, v in headers or [] if k.lower() == name), None) + seconds = milliseconds_seconds(value) + if seconds is not None: + error.retry_after = seconds + return + + +def _attach_error_metadata(error: ProviderError, headers: "list[tuple[str, str]] | None") -> None: + """Fill HTTP diagnostics absent from the body; never invent absent fields.""" + from ..rate_limits import capture_rate_limits + + error.rate_limit_headers = capture_rate_limits(headers or []) + _attach_retry_after(error, headers) + if error.request_id is not None: + return + values = {} + for key, value in headers or []: + values.setdefault(key.lower(), value) + for name in ("x-request-id", "request-id", "x-amzn-requestid", "x-amz-request-id", "x-ms-request-id", "apim-request-id", "x-typesafe-request-id"): + if values.get(name): + error.request_id = values[name] + break + + +def _stream_error_metadata(event, headers): + """Handshake evidence on an in-stream error; never infer status 200.""" + if not isinstance(event, StreamErrorEvent): + return event + error = ProviderError() + _attach_error_metadata(error, headers) + http = {} + if error.request_id is not None: + http["request_id"] = error.request_id + if error.retry_after is not None: + http["retry_after"] = error.retry_after + if error.rate_limit_headers: + http["rate_limit_headers"] = {k: list(v) for k, v in error.rate_limit_headers.items()} + if not http: + return event + return replace(event, error=replace(event.error, http_response=http)) + + +@dataclass(frozen=True, slots=True) +class HttpResponse: + """Buffered provider-level HTTP response. + + The stdlib transport is streaming-first. LMs that implement ordinary + request/response endpoints buffer the body into this small value object so + their parsing code can stay pure and easy to test. + """ + + status: int + reason: str + headers: list[tuple[str, str]] + body: bytes + http_version: str = "HTTP/1.1" + provider: str | None = None # who answered; names the ProviderError json() raises + + def header(self, name: str) -> str | None: + lname = name.lower() + for key, value in self.headers: + if key.lower() == lname: + return value + return None + + def headers_all(self, name: str) -> list[str]: + lname = name.lower() + return [value for key, value in self.headers if key.lower() == lname] + + def text(self) -> str: + return self.body.decode("utf-8", errors="replace") + + def json(self): + """The body as JSON. A body that is not JSON — a gateway's HTML + error page behind a 200, a truncated reply, a captive portal — is + a provider reply that cannot become a Response without inventing + one, so it raises :class:`ProviderError` (ErrorCode ``provider``) + carrying the status and the first bytes of what arrived. A raw + ``JSONDecodeError`` would escape every ``except LM15Error`` and + say nothing about who sent what.""" + try: + return json.loads(self.body) + except (ValueError, UnicodeDecodeError) as exc: + error = ProviderError( + _non_json_message(self, exc), provider=self.provider, status=self.status, + ) + _attach_error_metadata(error, self.headers) + raise error from exc + + +def _non_json_message(response: "HttpResponse", exc: Exception) -> str: + content_type = response.header("content-type") or "no content-type" + excerpt = response.body[:200].decode("utf-8", errors="replace") + if len(response.body) > 200: + excerpt += "…" + return ( + f"the reply (HTTP {response.status}, {content_type}, {len(response.body)} bytes) is not JSON: " + f"{exc}. Body starts: {excerpt!r}. A gateway or proxy in front of the provider is the " + "usual cause; the request may or may not have been served." + ) + + +_Reply = TypeVar("_Reply") + + +def _reply_error_metadata(error: ProviderError, response: HttpResponse) -> None: + """Fill missing provider/status and attach the shared HTTP diagnostics.""" + if error.provider is None: + error.provider = response.provider + if error.status is None: + error.status = response.status + _attach_error_metadata(error, response.headers) + + +def _parse_reply( + response: HttpResponse, parser: Callable[[HttpResponse], _Reply], *, json_body: bool = True, +) -> _Reply: + """Pure reply-decoder boundary, shared by sync and async auxiliary drivers. + + Only pass parsing here, never request builders, credentials or user callbacks. + Binary media and JSONL opt out of whole-body JSON validation. Local control + errors (notably RuntimeError for a not-ready result) deliberately pass through. + """ + try: + if json_body: + response.json() + return parser(response) + except ProviderError as error: + _reply_error_metadata(error, response) + raise + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + error = ProviderError(_non_json_message(response, exc)) + _reply_error_metadata(error, response) + raise error from exc + except (TypeError, ValueError, KeyError, AttributeError, IndexError, OverflowError) as exc: + # Canonical constructors and provider field access can reject valid + # JSON with an unusable shape. This is a reply fault, not bad input. + content_type = response.header("content-type") or "no content-type" + excerpt = response.body[:200].decode("utf-8", errors="replace") + error = ProviderError( + f"malformed provider reply (HTTP {response.status}, {content_type}): " + f"{exc}. Body starts: {excerpt!r}" + ) + _reply_error_metadata(error, response) + raise error from exc + + +def _reply_object(response: HttpResponse) -> dict[str, Any]: + data = response.json() + if not isinstance(data, dict): + raise TypeError("expected a JSON object") + return data + + +def _jsonl_reply_text(response: HttpResponse) -> str: + # Decode strictly before the legacy text hooks can replace invalid UTF-8. + text = response.body.decode("utf-8") + for line in text.splitlines(): + if line.strip(): + json.loads(line) + return text + + +class ProviderDialect(Protocol): + provider: str + supports: EndpointSupport + manifest: ProviderManifest + + def build_request(self, request: Request, stream: bool) -> TransportRequest: ... + + def parse_response(self, request: Request, response: HttpResponse) -> Response: ... + + def parse_stream_events(self, request: Request, raw_event: SSEEvent) -> Iterator[StreamEvent]: ... + + def normalize_error(self, status: int, body: str) -> ProviderError: ... + + +_SURFACE_WORD = { + "files": "files", + "batches": "batch", + "images": "image generation", + "speech": "speech generation", + "video": "video generation", + "live": "live", + "caches": "caches", + "models": "model listing", +} + + +def _client_side_stop(adaptations: "tuple[Adaptation, ...]") -> bool: + return any(a.field == "config.stop" and a.action == "client_side" for a in adaptations) + + +class BaseProviderLM: + """Shared synchronous provider LM implementation. + + An adapter is a dialect (the class) bound to an access policy (a value, + ``lm15.access``): the policy says which credential travels in which + header, which static headers ride along, which endpoint surfaces this + access path carries, and which backend variant the dialect must switch + on. ``manifest`` is the class's default policy; ``access`` is the bound + one. Dialect code reads ``self.access`` at a small set of stated points + and never carries a subclass per access path. + """ + + transport: SyncTransport + provider: str = "unknown" + manifest: ClassVar[ProviderManifest] = ProviderManifest(provider="unknown") + access: ProviderManifest + account_id: str | None = None + # MAP-13 policy: "note" (adapt and record), "silent", "refuse". Each + # dialect declares it as a constructor field; this is the fallback. + adaptations: AdaptationPolicy = "note" + _credential_source: str = "explicit" + # AUTH-1 provenance (amended 2026-09-19): where the credential came + # from, as a human label, for auth errors. A static label for an + # explicit value / env key / stored login; a cloud chain provider + # carries its own (``.source``), read at error time so the label names + # the rung that actually won. + _credential_origin: str | None = None + # Cloud-host state (AUTH-10): the resolved host settings and the clock + # every time-dependent byte (SigV4 date, JWT iat/exp) is read from. The + # harness injects a fixed clock; users never touch it. + host_settings: "dict[str, str]" + clock: "Callable[[], datetime] | None" + # The header an ApiKey travels under when the policy says x-api-key + # (Anthropic ``x-api-key``, Gemini ``x-goog-api-key``). + _api_key_header: ClassVar[str] = "x-api-key" + + def _wire_request(self, request: Request) -> Request: + """Strip exactly this binding's prefix once, at a codec boundary. + + Keep the caller request in drivers so subsequent build/parse boundaries + each see the original spelling; other colon-bearing IDs are opaque. + """ + head, sep, model = request.model.partition(":") + if sep and model and head.replace("_", "-") == self.provider.replace("_", "-"): + from dataclasses import replace + + return replace(request, model=model) + return request + + @property + def supports(self) -> EndpointSupport: + """The endpoint surfaces this bound access path carries.""" + return self.access.supports + + @classmethod + def has_stored_credential(cls) -> bool: + """Offline probe for the router's ``oauth-unless-explicit`` chain + (spec/auth.md AUTH-1): is a usable login stored locally?""" + from ..access import has_stored_credential + + return has_stored_credential(cls.manifest) + + def _bind_access( + self, + access: ProviderManifest | None, + *, + credentials_path: "str | os.PathLike[str] | None" = None, + default_base_url: str | None = None, + settings: "Mapping[str, str] | None" = None, + credential: str | None = None, + ) -> None: + """Bind the access policy and resolve the credential it calls for. + + Called from each dialect's ``__post_init__``. Sets ``access``, + ``provider``, ``api_key`` (a callable for stored logins, re-read per + request so rotations and refreshes are picked up), ``account_id`` + when the credential carries one, ``host_settings`` (explicit values + and defaults only — the router fills env fallbacks, like it does for + the key), and ``base_url``: the policy's own, or the host template + rendered over the settings, when the caller left the dialect's + default; an explicit ``base_url`` on a cloud door is the endpoint + root, and the door's path is appended unless already present + (AUTH-10, amended 2026-09-19). + + ``credential`` names one identity on a cloud door (``platform``, + ``workload``, ``environment``, ``cli``; AUTH-1 named credentials): + that rung only, read from this process's environment, never the + chain. It cannot be combined with ``api_key``: two answers to "who + am I" is a configuration error, not a precedence question. + """ + from ..access import load_credential + from ..cloud.hosts import render_base_url, resolve_settings + + policy = access if access is not None else type(self).manifest + self.access = policy + self.provider = policy.provider + endpoint = self.base_url if (policy.host is not None and default_base_url is not None + and self.base_url != default_base_url) else None + if credential is not None: + if not policy.cloud_chain: + raise NotConfiguredError( + f"{policy.provider}: credential={credential!r} names a cloud identity, and this door is not a " + "cloud door; pass api_key= instead", + provider=policy.provider, + ) + if self.api_key is not None and self.api_key != "": + raise NotConfiguredError( + f"{policy.provider}: both api_key= and credential={credential!r} were given; a door has one " + "identity — pass the credential value, or name the identity, not both", + provider=policy.provider, + ) + from ..cloud.chains import ChainContext, credential_provider + + self.host_settings = resolve_settings(policy.host, settings, None, provider=policy.provider, endpoint=endpoint) + ctx = ChainContext.online() + ctx.settings = self.host_settings + self.api_key = credential_provider(policy, ctx, named=credential) + self._credential_source = "named" + else: + loaded = load_credential(policy, self.api_key, credentials_path=credentials_path) + self.api_key = loaded.credential + self._credential_source = loaded.source + if loaded.account_id is not None and self.account_id is None: + self.account_id = loaded.account_id + if loaded.credential is not None and not callable(loaded.credential): + # A static credential of the wrong kind for this door fails now, + # not on the first request (a provider callable is checked per call). + from ..access import select_scheme + + select_scheme(policy, coerce_credential(loaded.credential)) + self.host_settings = resolve_settings(policy.host, settings, None, provider=policy.provider, endpoint=endpoint) + if self._credential_origin is None: + self._credential_origin = _origin_label(self.api_key, self._credential_source) + if getattr(self, "clock", None) is None: + self.clock = None + if policy.host is not None: + self.base_url = render_base_url(policy.host, self.host_settings, endpoint, provider=policy.provider) + elif policy.base_url is not None and default_base_url is not None and self.base_url == default_base_url: + self.base_url = policy.base_url + + def credential_origin(self) -> str: + """Where this adapter's credential comes from, as a sentence fragment + with no secret in it (AUTH-1 provenance). For a cloud chain + provider this is the rung that last won, or what will be walked + when no request has been sent yet.""" + provider = self.api_key + if hasattr(provider, "source") and hasattr(provider, "named"): # a cloud chain provider + source = provider.source + if source is not None and hasattr(source, "describe"): + return source.describe(self._now()) + if provider.named: + from ..cloud.chains import named_meaning + + return f'named credential "{provider.named}" ({named_meaning(self.access, provider.named)}; not yet resolved)' + return f"the {self.access.credential_policy} (not yet resolved)" + return self._credential_origin or _origin_label(provider, self._credential_source) + + def _registry_compat(self) -> str | None: + """The compat preset the bound provider names in the registry, for a + dialect constructed directly with a bound access policy and no + ``compat=``: the policy names the provider; the provider names its + preset (found 2026-09-03 when a direct ``OpenAIChatLM(access= + BEDROCK_CHAT)`` skipped the bedrock per-model refusals).""" + if self.access is type(self).manifest: + return None + from ..registry import lookup + + definition = lookup(self.access.provider) + return definition.compat if definition is not None else None + + def _now(self) -> "datetime": + from ..cloud.hosts import utc_now + + return self.clock() if self.clock is not None else utc_now() + + def _emit( + self, + *, + method: str, + url: str, + headers: "dict[str, str] | list[tuple[str, str]] | None" = None, + params: "dict[str, Any] | None" = None, + payload: Any = None, + body: bytes | None = None, + endpoint: str | None = None, + stream: bool = False, + model: str | None = None, + connect_timeout: float | None = None, + read_timeout: float | None = None, + write_timeout: float | None = None, + ) -> TransportRequest: + """Finish a dialect-built request through the bound host (AUTH-10) + and sign it. With no host this is ``make_json_request`` — every + dialect routes its wire requests through here so a host never needs + a dialect branch.""" + from ..cloud.hosts import finish_request, sign_request + from .common import make_json_request + + from ..access import auth_header + + # plan() builds and discards: no credential provider is invoked, no + # header is signed — the record of adaptations does not depend on it. + planning = is_planning() + credential = resolve_credential_value(self.api_key) if self.api_key is not None and not planning else None + hdrs = dict(headers.items()) if isinstance(headers, dict) else dict(headers or []) + if credential is not None and not isinstance(credential, AwsCredentials): + pair = auth_header(self.access, credential, api_key_header=self._api_key_header) + if pair is not None and pair[0].lower() not in {k.lower() for k in hdrs}: + hdrs[pair[0]] = pair[1] + finished = finish_request( + self.access, + self.host_settings, + base_url=self.base_url, + url=url, + headers=hdrs, + payload=payload, + params=params, + endpoint=endpoint, + stream=stream, + model=model, + credential=credential, + ) + req = make_json_request( + method=method, + url=finished.url, + headers=finished.headers, + params=finished.params or None, + payload=finished.payload, + body=body, + connect_timeout=connect_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + if isinstance(credential, AwsCredentials): + req.headers = sign_request( + self.access, + self.host_settings, + method=req.method, + url=req.url, + headers=req.headers, + body=req.body, + credential=credential, + now=self._now(), + ) + return req + + def _require(self, surface: str) -> None: + """Raise unless the bound access path carries ``surface``. + + A dialect may implement a surface its access path does not carry + (Anthropic files on a Claude Code login); the policy, not the class, + decides. Message shape: ``: not supported``. + """ + if not self.access.supports.supports_endpoint(surface): + raise UnsupportedFeatureError( + f"{self.provider}: {_SURFACE_WORD.get(surface, surface)} not supported", + provider=self.provider, + ) + + # ─── MAP-13: build with adaptations, plan, client-side steps ─── + + def _build(self, request: Request, stream: bool, *, policy: "AdaptationPolicy | None" = None, planning: bool = False) -> "tuple[TransportRequest, tuple[Adaptation, ...]]": + """``build_request`` inside an adaptation scope: the wire request and + the record of what differs from what was asked. The one place a + scope is opened; builders record through ``lm15.adaptation.adapt``. + ``policy`` overrides the adapter's own (the async mirror passes its); + ``planning`` skips credentials and signing (the bytes are discarded).""" + with collecting(check_policy(policy if policy is not None else self.adaptations), provider=self.provider, planning=planning) as scope: + req = self.build_request(request, stream=stream) + return req, tuple(scope.records) + + def plan(self, request: Request, *, policy: "AdaptationPolicy | None" = None) -> "tuple[Adaptation, ...]": + """What a call with this request WOULD adapt, with no network and no + credential invoked (like ``resolve()``, offline). Raises what the + call would raise (a refusal under any policy, or every deviation + under ``adaptations="refuse"``). Returns the full record under + every policy, "silent" included: a preview that hid what it saw + would be no preview.""" + return self._build(request, stream=False, policy=policy, planning=True)[1] + + def _visible(self, adaptations: "tuple[Adaptation, ...]", *, policy: "AdaptationPolicy | None" = None) -> "tuple[Adaptation, ...]": + """What the response carries: everything under "note" (and "refuse", + which only ever holds satisfied/defaulted), nothing under "silent". + Behaviour is decided from the full record, never from this.""" + return () if (policy if policy is not None else self.adaptations) == "silent" else adaptations + + def _finish_response(self, request: Request, response: Response, adaptations: "tuple[Adaptation, ...]", *, policy: "AdaptationPolicy | None" = None) -> Response: + """Stamp the visible record on the response and apply client-side steps.""" + from ..result import apply_client_side_stop + + if _client_side_stop(adaptations): + response = apply_client_side_stop(response, request.config.stop) + visible = self._visible(adaptations, policy=policy) + if visible and not response.adaptations: + from dataclasses import replace + + response = replace(response, adaptations=visible) + return response + + def complete(self, request: Request) -> Response: + req, adaptations = self._build(request, stream=False) + if _client_side_stop(adaptations): + # MAP-13 (decision 2026-09-14): a stop sequence the wire cannot + # take is honoured by streaming under the hood and closing the + # connection at the cut. Whether the provider then stops + # generating (and billing) on a closed connection is its own + # behaviour, not a promise made here. The price is the usage + # report, which only the final frame carries; it is "not + # reported", never estimated. A stream that never hits the + # sequence completes normally, usage included. + from ..result import materialize_response + + return materialize_response(self.stream(request), request) + resp = self._send(req) + if resp.status >= 400: + raise self._http_error(resp) + try: + return self._finish_response(request, self.parse_response(request, resp), adaptations) + except ProviderError as error: + _attach_error_metadata(error, resp.headers) + raise + + def stream(self, request: Request) -> Iterator[StreamEvent]: + # MAP-3 (docs/mapping-rules.md): adapters may emit one end event per + # provider terminal frame; the coalescer merges them so the public + # stream yields exactly one final StreamEndEvent. + from ..result import coalesce_stream, truncate_stream_at_stop + + req, adaptations = self._build(request, stream=True) + events = coalesce_stream(self._stream_raw(request, req), model=self._wire_request(request).model, adaptations=self._visible(adaptations)) + if _client_side_stop(adaptations): + events = truncate_stream_at_stop(events, request.config.stop) + return events + + def _stream_raw(self, request: Request, req: "TransportRequest | None" = None) -> Iterator[StreamEvent]: + if req is None: + req = self.build_request(request, stream=True) + self._ensure_transport_open() + try: + with self.transport.stream(req) as resp: + if resp.status >= 400: + body = resp.read() + error = self.normalize_error( + resp.status, body.decode("utf-8", errors="replace") + ) + _attach_error_metadata(error, resp.headers) + raise error + lines = resp.iter_lines() if hasattr(resp, "iter_lines") else _iter_lines(resp) + try: + for raw in parse_sse(lines): + for event in self.parse_stream_events(request, raw): + if event is not None: + yield _stream_error_metadata(event, resp.headers) + except ProviderError as error: + _attach_error_metadata(error, resp.headers) + raise + except NetworkTransportError as exc: + raise LM15TransportError(str(exc)) from exc + + def _send(self, request: TransportRequest) -> HttpResponse: + self._ensure_transport_open() + try: + with self.transport.stream(request) as resp: + body = resp.read() + return HttpResponse( + status=resp.status, + reason=resp.reason, + headers=resp.headers, + body=body, + http_version=resp.http_version, + provider=self.provider, + ) + except NetworkTransportError as exc: + raise LM15TransportError(str(exc)) from exc + + def _http_error(self, response: HttpResponse) -> ProviderError: + """Normalize an HTTP failure without losing its retry hint.""" + error = self.normalize_error(response.status, response.text()) + _attach_error_metadata(error, response.headers) + return error + + def normalize_error(self, status: int, body: str) -> ProviderError: + return self._with_login_hint(map_http_error( + status, + body.strip()[:500] or f"HTTP {status}", + provider=self.provider, + env_keys=self.access.env_keys, + )) + + def _provider_error( + self, + cls: type[ProviderError], + message: str, + *, + status: int | None = None, + provider_code: str | None = None, + request_id: str | None = None, + retry_after: float | None = None, + ) -> ProviderError: + kwargs = { + "provider": self.provider, + "provider_code": provider_code or None, + "status": status, + "request_id": request_id or None, + "retry_after": retry_after, + } + kwargs = {key: value for key, value in kwargs.items() if value is not None} + if issubclass(cls, AuthError): + return self._with_login_hint(cls(message, env_keys=self.access.env_keys, **kwargs)) + return cls(message, **kwargs) + + def _with_login_hint(self, error: ProviderError) -> ProviderError: + """Auth errors guide the user to re-login when the credential is a + local login: always under an ``oauth`` policy (there is no env var), + and under ``oauth-unless-explicit`` only when the stored login was + the rung that won — an explicit key keeps the generic guidance. + ``with_credential_hint`` is a no-op on non-auth errors. + + Every auth error from the wire also names where the credential came + from (AUTH-1 provenance, amended 2026-09-19): the rung of a cloud + chain, the env variable, the explicit value, or "an + application-supplied callable" — never the value.""" + hint = self.access.login_hint + if hint and (self.access.credential_policy == "oauth" or self._credential_source == "stored"): + error = with_credential_hint(error, hint) + if isinstance(error, AuthError): + from ..errors import with_credential_origin + + try: + origin = self.credential_origin() + except Exception: # noqa: BLE001 - provenance must never mask the real error + origin = None + if origin: + error = with_credential_origin(error, origin) + return error + + def close(self) -> None: + close = getattr(self.transport, "close", None) + if callable(close): + close() + + def _ensure_transport_open(self) -> None: + """Recreate the default transport if it was closed by interactive tooling. + + Provider objects are often kept in notebook/REPL variables. Some + interactive runners eagerly close context-manager-like objects between + cells; when that happens, the default StdlibTransport can be safely + replaced before the next request. + """ + if isinstance(self.transport, StdlibTransport) and getattr(self.transport, "_closed", False): + self.transport = self.transport.copy() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + def live(self, config: LiveConfig) -> LiveSession: + self._require("live") + raise UnsupportedFeatureError(f"{self.provider}: live not supported", provider=self.provider) + + # ─── Media generation (images / speech) ──────────────────────── + # + # Same pure-hook pattern as files and batch: adapters implement the + # build/parse pair; the sync drivers below and the async mirrors both + # drive the same hooks. Parse hooks take the full HttpResponse, not + # just the body: OpenAI speech returns raw bytes whose media type + # lives in the content-type HEADER (captured 2026-09-01). Media + # types always come from the wire — hardcoding one is a lie on at + # least one provider (OpenAI png / Gemini png / xAI jpeg captured). + + def _generation_unsupported(self, kind: str) -> UnsupportedFeatureError: + return UnsupportedFeatureError(f"{self.provider}: {kind} generation not supported", provider=self.provider) + + def _image_generate_request(self, request: ImageGenerationRequest) -> TransportRequest: + raise self._generation_unsupported("image") + + def _image_generation_from_response(self, request: ImageGenerationRequest, resp: HttpResponse) -> ImageGenerationResponse: + raise self._generation_unsupported("image") + + def _speech_generate_request(self, request: SpeechGenerationRequest) -> TransportRequest: + raise self._generation_unsupported("speech") + + def _speech_generation_from_response(self, request: SpeechGenerationRequest, resp: HttpResponse) -> SpeechGenerationResponse: + raise self._generation_unsupported("speech") + + def image_generate(self, request: ImageGenerationRequest) -> ImageGenerationResponse: + """Generate (or, with ``request.images``, edit) images. + + Input images are ordinary ImageParts; adapters route them to the + provider's real edit door (OpenAI: ``/images/edits``; Gemini: the + same chat call; xAI: ``/images/edits``) and raise where the wire + has none — never silently ignore them. + """ + self._require("images") + resp = self._send(self._image_generate_request(request)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._image_generation_from_response(request, reply)) + + def speech_generate(self, request: SpeechGenerationRequest) -> SpeechGenerationResponse: + """Text-to-speech. Omitted ``voice``/``format`` mean the server's + defaults, honestly reported in the returned part's media_type — + lm15 injects no defaults of its own.""" + self._require("speech") + resp = self._send(self._speech_generate_request(request)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._speech_generation_from_response(request, reply), json_body=False) + + # ─── Video generation (job-shaped on every wire: Sora / Veo / grok) ───── + # + # Same pure-hook pattern as batch: submit returns a ticket, status + # polls it, result turns the terminal snapshot into a VideoPart — + # with an optional fetch step (Sora delivers bytes from a content + # endpoint; Veo and xAI put a URL in the terminal body). The + # VideoJob handle in lm15.video adds the wait/re-attach ergonomics. + + def _video_unsupported(self) -> UnsupportedFeatureError: + return UnsupportedFeatureError(f"{self.provider}: video generation not supported", provider=self.provider) + + def _video_submit_request(self, request: VideoGenerationRequest) -> TransportRequest: + raise self._video_unsupported() + + def _video_job_from_body(self, body: str, video_id: "str | None" = None) -> VideoJobInfo: + """Parse a submit or status body; ``video_id`` is the known ticket + id for wires whose status bodies do not echo it (xAI).""" + raise self._video_unsupported() + + def _video_status_request(self, video_id: str) -> TransportRequest: + raise self._video_unsupported() + + def _video_result_fetch(self, status_body: "dict[str, Any]") -> TransportRequest | None: + """Optional download step; None = the terminal body carries the URL.""" + raise self._video_unsupported() + + def _video_part(self, status_body: "dict[str, Any]", fetched: "HttpResponse | None") -> VideoPart: + raise self._video_unsupported() + + def _video_list_request(self, limit: int, model: str | None) -> TransportRequest: + raise self._video_unsupported() + + def _video_jobs_from_list_body(self, body: str) -> "tuple[VideoJobInfo, ...]": + raise self._video_unsupported() + + def video_submit(self, request: VideoGenerationRequest) -> VideoJobInfo: + """Submit a video job; returns the ticket snapshot.""" + self._require("video") + resp = self._send(self._video_submit_request(request)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._video_job_from_body(reply.text())) + + def video_status(self, video_id: str) -> VideoJobInfo: + self._require("video") + resp = self._send(self._video_status_request(video_id)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._video_job_from_body(reply.text(), video_id)) + + def video_result(self, video_id: str) -> VideoPart: + """The finished video as a VideoPart, in the provider's own + delivery mode (URL or bytes) — no silent re-hosting, no silent + gigabyte download; ``part.bytes`` fetches URL results on demand. + Raises ValueError while the job runs.""" + self._require("video") + resp = self._send(self._video_status_request(video_id)) + if resp.status >= 400: + raise self._http_error(resp) + job = _parse_reply(resp, lambda reply: self._video_job_from_body(reply.text(), video_id)) + if job.status not in VIDEO_TERMINAL_STATUSES: + raise ValueError( + f"video {video_id} is not finished (status={job.status!r}); " + f"wait() or poll video_status() until done" + ) + status_body = _parse_reply(resp, _reply_object) + try: + fetch = self._video_result_fetch(status_body) + except ProviderError as error: + _reply_error_metadata(error, resp) + raise + fetched = None + if fetch is not None: + fetched = self._send(fetch) + if fetched.status >= 400: + raise self._http_error(fetched) + return _parse_reply(fetched or resp, lambda reply: self._video_part(status_body, fetched), json_body=False) + + def video_list(self, limit: int = 20, model: str | None = None) -> "tuple[VideoJobInfo, ...]": + """One page of this credential's video jobs. ``model`` is required + on Gemini (operations list per model), ignored on OpenAI; xAI has + no list endpoint at all and raises.""" + self._require("video") + resp = self._send(self._video_list_request(limit, model)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._video_jobs_from_list_body(reply.text())) + + # Ergonomic verbs (ticket handles) --------------------------------------- + + def video_generate(self, request: VideoGenerationRequest) -> "VideoJob": + """Submit and wrap the ticket in a VideoJob handle.""" + from ..video_jobs import VideoJob + + return VideoJob(self, self.video_submit(request)) + + def video_job(self, video_id: str) -> "VideoJob": + """Re-attach to an existing job by id alone.""" + from ..video_jobs import VideoJob + + return VideoJob(self, self.video_status(video_id)) + + def video_jobs(self, limit: int = 20, model: str | None = None) -> "tuple[VideoJob, ...]": + """Enumerate this credential's video jobs, where the wire lists + them (OpenAI: account-wide; Gemini: per model, pass ``model``). + xAI has no list endpoint and raises: the ticket you stored is + the only copy.""" + from ..video_jobs import VideoJob + + return tuple(VideoJob(self, info) for info in self.video_list(limit, model)) + + # ─── Live model listing (provisional endpoint) ────────────────────────── + # + # Adapters that support their provider's list-models endpoint override the + # two hooks; list_models() itself is shared. Each canonical ModelInfo + # carries the usable Request.model string as `id` and the verbatim wire + # entry under `origin.provider_data` (opaque, never cleaned). Listing is + # ADVISORY metadata per docs/model-hydration.md: it never changes what + # build_request produces. + + def _models_request(self) -> TransportRequest: + raise UnsupportedFeatureError(f"{self.provider}: model listing not supported", provider=self.provider) + + def _models_from_body(self, body: str) -> "tuple[ModelInfo, ...]": + raise UnsupportedFeatureError(f"{self.provider}: model listing not supported", provider=self.provider) + + def list_models(self) -> "tuple[ModelInfo, ...]": + """Fetch the models this credential can use, as canonical ModelInfo.""" + self._require("models") + resp = self._send(self._models_request()) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._models_from_body(reply.text())) + + # ─── Batch jobs (third execution mode: complete / stream / batch) ─────── + # + # Adapters override the pure hooks; the five drivers (batch_submit, + # batch_status, batch_results, batch_cancel, batch_list) are shared, as + # are the ergonomic verbs (batch, batch_job, batches) returning BatchJob + # handles. Hooks are pure build/parse so the async twins and the future + # harness direction drive the same code. There is NO local fan-out + # fallback: batch() means a provider-side queue or an honest error. + + def _batch_unsupported(self) -> UnsupportedFeatureError: + return UnsupportedFeatureError(f"{self.provider}: batch not supported", provider=self.provider) + + def _batch_preflight(self, request: BatchRequest) -> None: + """Plan every item before credentials or paid work, including direct hooks.""" + for nested in request.requests: + records = self.plan(nested, policy="note") + if _client_side_stop(records): + raise UnsupportedFeatureError( + f"{self.provider}: batch cannot close the generation source at a client-side stop cut; " + "use a dialect with native stop support or individual complete()/stream() calls", + provider=self.provider, feature="config.stop", + ) + if self.adaptations == "refuse": + self.plan(nested, policy="refuse") + + def _batch_upload_request(self, request: BatchRequest) -> TransportRequest | None: + """Optional pre-submit upload step (OpenAI's JSONL file); None = single-step.""" + self._batch_preflight(request) + return None + + def _batch_submit_request(self, request: BatchRequest, upload_body: "dict[str, Any] | None") -> TransportRequest: + raise self._batch_unsupported() + + def _batch_job_from_body(self, body: str) -> BatchJobInfo: + raise self._batch_unsupported() + + def _batch_status_request(self, batch_id: str) -> TransportRequest: + raise self._batch_unsupported() + + def _batch_cancel_request(self, batch_id: str) -> TransportRequest: + raise self._batch_unsupported() + + def _batch_result_fetches(self, status_body: "dict[str, Any]") -> "tuple[TransportRequest, ...]": + raise self._batch_unsupported() + + def _batch_entries(self, status_body: "dict[str, Any]", fetched: "tuple[str, ...]") -> "tuple[BatchEntry, ...]": + raise self._batch_unsupported() + + def _batch_list_request(self, limit: int) -> TransportRequest: + raise self._batch_unsupported() + + def _batch_jobs_from_list_body(self, body: str) -> "tuple[BatchJobInfo, ...]": + raise self._batch_unsupported() + + def batch_submit(self, request: BatchRequest) -> BatchJobInfo: + """Submit to the provider's batch queue; returns the ticket snapshot.""" + self._require("batches") + self._batch_preflight(request) + upload_body = None + # MAP-13: the batch builders run under the adapter's policy so + # "refuse" refuses here too; a batch ticket has no adaptations field + # (provisional surface), so under "note" the record is not kept. + with collecting(check_policy(self.adaptations), provider=self.provider): + upload_req = self._batch_upload_request(request) + if upload_req is not None: + resp = self._send(upload_req) + if resp.status >= 400: + raise self._http_error(resp) + upload_body = _parse_reply(resp, _reply_object) + try: + with collecting(check_policy(self.adaptations), provider=self.provider): + submit_req = self._batch_submit_request(request, upload_body) + except ProviderError as error: + if upload_req is not None: + _reply_error_metadata(error, resp) + raise + resp = self._send(submit_req) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._batch_job_from_body(reply.text())) + + def batch_status(self, batch_id: str) -> BatchJobInfo: + self._require("batches") + resp = self._send(self._batch_status_request(batch_id)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._batch_job_from_body(reply.text())) + + def batch_results(self, batch_id: str) -> "tuple[BatchEntry, ...]": + """Entries in submission order; raises ValueError while the job runs.""" + self._require("batches") + resp = self._send(self._batch_status_request(batch_id)) + if resp.status >= 400: + raise self._http_error(resp) + job = _parse_reply(resp, lambda reply: self._batch_job_from_body(reply.text())) + if job.status not in BATCH_TERMINAL_STATUSES: + raise ValueError( + f"batch {batch_id} is not finished (status={job.status!r}); " + f"wait() or poll batch_status() until done" + ) + status_body = _parse_reply(resp, _reply_object) + try: + fetches = self._batch_result_fetches(status_body) + except ProviderError as error: + _reply_error_metadata(error, resp) + raise + texts = [] + for fetch in fetches: + fetched = self._send(fetch) + if fetched.status >= 400: + raise self._http_error(fetched) + texts.append(self._batch_reply_text(status_body, fetched)) + return _parse_reply(resp, lambda reply: self._batch_entries(status_body, tuple(texts)), json_body=False) + + def _batch_reply_text(self, status_body: dict[str, Any], response: HttpResponse) -> str: + """Validate each result file against its own HTTP evidence. + + Legacy hooks combine files and fill missing entries from job status. + Decode each file once in isolation before that combined pass so both + JSONL syntax and entry-shape faults retain the offending fetch's headers. + Blank lines remain valid. Hooks are pure; no request is repeated. + """ + text = _parse_reply(response, _jsonl_reply_text, json_body=False) + try: + _parse_reply(response, lambda reply: self._batch_entries(status_body, (text,)), json_body=False) + except ProviderError as error: + # Entry decoders may use synthetic HttpResponse(status=200). + # The actual reply containing the broken entry is this fetch. + error.status = response.status + raise + return text + + def batch_cancel(self, batch_id: str) -> BatchJobInfo: + """Request cancellation — a request, not a guarantee.""" + self._require("batches") + resp = self._send(self._batch_cancel_request(batch_id)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._batch_job_from_body(reply.text())) + + def batch_list(self, limit: int = 20) -> "tuple[BatchJobInfo, ...]": + """Enumerate this credential's batch jobs, newest first. + + The provider is the system of record; recovery from a lost id must + never depend on the user having been careful. + """ + self._require("batches") + resp = self._send(self._batch_list_request(limit)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._batch_jobs_from_list_body(reply.text())) + + def batch(self, requests: "BatchRequest | Sequence[Request]", *, model: str | None = None, + label: str | None = None, extensions: "dict[str, Any] | None" = None) -> "BatchJob": + """Third execution mode: many requests, later, ~half price.""" + from ..batch import BatchJob + + if isinstance(requests, BatchRequest): + request = requests + else: + request = BatchRequest(model=model, requests=tuple(requests), label=label, extensions=extensions) + return BatchJob(self, self.batch_submit(request)) + + def batch_job(self, batch_id: str) -> "BatchJob": + """Re-attach to a submitted batch from its id (one status round trip).""" + from ..batch import BatchJob + + return BatchJob(self, self.batch_status(batch_id)) + + def batches(self, limit: int = 20) -> "tuple[BatchJob, ...]": + """Lost the ticket? The queue remembers.""" + from ..batch import BatchJob + + return tuple(BatchJob(self, info) for info in self.batch_list(limit)) + + # ─── Cache resources (the stored tier of MAP-6) ──────────────────── + # + # Same pure-hook pattern as files. A stored cache belongs to one model + # on every provider that has the tier; the prefix Request supplies + # model, system, tools, and messages. Adapters without the tier leave + # the hooks raising and `cache()` returns a CachedPrefix without a + # resource — the marks-or-automatic path. Nothing here names a + # provider; id formats and TTL spellings are the adapter's. + + def _caches_unsupported(self) -> UnsupportedFeatureError: + return UnsupportedFeatureError(f"{self.provider}: stored caches not supported", provider=self.provider) + + def _cache_create_request(self, prefix: Request, ttl_seconds: int | None, label: str | None) -> TransportRequest: + raise self._caches_unsupported() + + def _cache_info_from_body(self, body: str) -> CacheInfo: + raise self._caches_unsupported() + + def _cache_get_request(self, cache_id: str) -> TransportRequest: + raise self._caches_unsupported() + + def _cache_list_request(self, limit: int, cursor: str | None) -> TransportRequest: + raise self._caches_unsupported() + + def _cache_page_from_list_body(self, body: str) -> CachePage: + raise self._caches_unsupported() + + def _cache_delete_request(self, cache_id: str) -> TransportRequest: + raise self._caches_unsupported() + + def _cache_update_request(self, cache_id: str, ttl_seconds: int) -> TransportRequest: + raise self._caches_unsupported() + + @staticmethod + def _check_cache_prefix(prefix: Request, ttl_seconds: int | None) -> None: + if prefix.config != Config(): + raise ValueError("cache_create: the prefix Request must carry a default Config (a stored cache has no generation settings)") + if ttl_seconds is not None and (isinstance(ttl_seconds, bool) or not isinstance(ttl_seconds, int) or ttl_seconds <= 0): + raise ValueError("ttl_seconds must be a positive int") + + def cache_create(self, prefix: Request, *, ttl_seconds: int | None = None, label: str | None = None) -> CacheInfo: + """Store the prefix (model, system, tools, messages) as a cache object.""" + self._require("caches") + self._check_cache_prefix(prefix, ttl_seconds) + resp = self._send(self._cache_create_request(prefix, ttl_seconds, label)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._cache_info_from_body(reply.text())) + + def cache_get(self, cache_id: str) -> CacheInfo: + self._require("caches") + resp = self._send(self._cache_get_request(cache_id)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._cache_info_from_body(reply.text())) + + def cache_list(self, limit: int = 20, cursor: str | None = None) -> CachePage: + self._require("caches") + resp = self._send(self._cache_list_request(limit, cursor)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._cache_page_from_list_body(reply.text())) + + def cache_delete(self, cache_id: str) -> None: + """Returning without an exception IS the confirmation (the files precedent).""" + self._require("caches") + resp = self._send(self._cache_delete_request(cache_id)) + if resp.status >= 400: + raise self._http_error(resp) + + def cache_update(self, cache_id: str, *, ttl_seconds: int) -> CacheInfo: + self._require("caches") + if isinstance(ttl_seconds, bool) or not isinstance(ttl_seconds, int) or ttl_seconds <= 0: + raise ValueError("ttl_seconds must be a positive int") + resp = self._send(self._cache_update_request(cache_id, ttl_seconds)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._cache_info_from_body(reply.text())) + + def cache(self, prefix: Request, *, ttl_seconds: int | None = None, label: str | None = None) -> CachedPrefix: + """Make a prompt beginning reusable with the best tier this provider has. + + Resource tier: creates the stored object (one network call, billed + per hour while it lives) and returns it inside the CachedPrefix. + Marks and automatic tiers: pure — the CachedPrefix only records the + boundary; `cached + messages` places the mark or sends nothing. + """ + wire = self._wire_request(prefix) + provider = self.provider if wire.model != prefix.model else None + if self.supports.caches: + return CachedPrefix(wire, self.cache_create(wire, ttl_seconds=ttl_seconds, label=label), provider=provider) + self._check_cache_prefix(wire, ttl_seconds) + return CachedPrefix(wire, provider=provider) + + # ─── Files (account-scoped storage: upload / get / list / delete / download) ─ + # + # Adapters override the pure build/parse hooks; the drivers are shared + # so the async twins and a future harness direction drive identical + # code. All five operations exist on all three first-party providers + # (verified live 2026-08-31); which FILES support download is per-file + # provider policy — lm15 forwards the provider's typed refusal instead + # of second-guessing it. + + def _files_unsupported(self) -> UnsupportedFeatureError: + return UnsupportedFeatureError(f"{self.provider}: files not supported", provider=self.provider) + + def _file_upload_request(self, request: FileUploadRequest) -> TransportRequest: + raise self._files_unsupported() + + def _file_info_from_body(self, body: str) -> FileInfo: + raise self._files_unsupported() + + def _file_get_request(self, file_id: str) -> TransportRequest: + raise self._files_unsupported() + + def _file_list_request(self, limit: int, cursor: str | None) -> TransportRequest: + raise self._files_unsupported() + + def _file_page_from_list_body(self, body: str) -> FilePage: + raise self._files_unsupported() + + def _file_delete_request(self, file_id: str) -> TransportRequest: + raise self._files_unsupported() + + def _file_download_request(self, file_id: str) -> TransportRequest: + raise self._files_unsupported() + + def file_upload(self, request: FileUploadRequest) -> FileInfo: + """Store a file with the provider; returns its canonical snapshot. + + ``FileInfo.id`` is the reference to place in a media Part's + ``file_id``. On Gemini the file may come back ``pending`` + (processing); ``file_wait_ready`` covers that. + """ + self._require("files") + resp = self._send(self._file_upload_request(request)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._file_info_from_body(reply.text())) + + def file_get(self, file_id: str) -> FileInfo: + self._require("files") + resp = self._send(self._file_get_request(file_id)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._file_info_from_body(reply.text())) + + def file_list(self, limit: int = 20, cursor: str | None = None) -> FilePage: + """One page of this credential's stored files. + + The provider is the system of record: a lost file id is recovered + by listing, never by client-side bookkeeping. ``cursor`` is the + opaque ``next_cursor`` of the previous page. + """ + self._require("files") + resp = self._send(self._file_list_request(limit, cursor)) + if resp.status >= 400: + raise self._http_error(resp) + return _parse_reply(resp, lambda reply: self._file_page_from_list_body(reply.text())) + + def file_delete(self, file_id: str) -> None: + """Delete a stored file. Returning without an exception IS the + confirmation; provider acknowledgement bodies differ and carry no + canonical information.""" + self._require("files") + resp = self._send(self._file_delete_request(file_id)) + if resp.status >= 400: + raise self._http_error(resp) + + def file_download(self, file_id: str) -> bytes: + """Download a file's content, when THIS file supports download. + + Every provider restricts which files are downloadable (Anthropic: + tool-generated only; OpenAI: by purpose; Gemini: generated only) + and refuses the rest with a typed error — forwarded, not masked. + """ + self._require("files") + resp = self._send(self._file_download_request(file_id)) + if resp.status >= 400: + raise self._http_error(resp) + return resp.body + + def file_wait_ready(self, file_id: str, poll_every: float = 2.0, timeout: float | None = None) -> FileInfo: + """Poll until the file leaves ``pending``; returns the terminal + snapshot (``ready`` or ``failed``) — check ``readiness``, mirroring + BatchJob.wait's return-don't-raise convention. Gemini uploads and + Azure OpenAI v1 uploads can be pending; the first poll usually returns.""" + self._require("files") + import time as _time + + deadline = None if timeout is None else _time.monotonic() + timeout + info = self.file_get(file_id) + while info.readiness == "pending": + if deadline is not None and _time.monotonic() >= deadline: + raise TimeoutError(f"file {file_id} still pending after {timeout}s") + _time.sleep(poll_every) + info = self.file_get(file_id) + return info + + +def batch_entry_request(model: object) -> Request: + """Synthetic Request for parsing a batch entry body. + + Batch results outlive the submitting process (re-attach by id), so the + original Request is not available; parse_response only reads + ``request.model`` as a fallback when the body lacks one, and every + batch entry body carries its model. + """ + from ..types import Message + + name = model if isinstance(model, str) and model else "batch" + return Request(model=name, messages=(Message.user("-"),)) + + +def batch_entry_http(body: "dict[str, Any]", status: int = 200) -> HttpResponse: + """Wrap a decoded batch entry body for the frozen parse_response path.""" + return HttpResponse( + status=status, + reason="OK" if status < 400 else "Error", + headers=[("content-type", "application/json")], + body=json.dumps(body).encode("utf-8"), + ) + + +class UnsupportedLiveSession: + def send(self, event=None, **kwargs): + raise UnsupportedFeatureError("live session not supported") + + def send_turn(self, *args, **kwargs): + raise UnsupportedFeatureError("live session not supported") + + def send_audio(self, *args, **kwargs): + raise UnsupportedFeatureError("live session not supported") + + def send_image(self, *args, **kwargs): + raise UnsupportedFeatureError("live session not supported") + + def send_text(self, *args, **kwargs): + raise UnsupportedFeatureError("live session not supported") + + def send_tool_result(self, *args, **kwargs): + raise UnsupportedFeatureError("live session not supported") + + def interrupt(self): + raise UnsupportedFeatureError("live session not supported") + + def end_audio(self): + raise UnsupportedFeatureError("live session not supported") + + def recv(self): + raise UnsupportedFeatureError("live session not supported") + + def close(self) -> None: + return + + +def _iter_lines(chunks: Iterator[bytes]) -> Iterator[bytes]: + """Split arbitrary byte chunks into newline-terminated lines for SSE.""" + + buf = bytearray() + for chunk in chunks: + if not chunk: + continue + buf.extend(chunk) + while True: + idx = buf.find(b"\n") + if idx < 0: + break + line = bytes(buf[: idx + 1]) + del buf[: idx + 1] + yield line + if buf: + yield bytes(buf) diff --git a/providers/claude_code.py b/providers/claude_code.py new file mode 100644 index 0000000000..23b55b3cfe --- /dev/null +++ b/providers/claude_code.py @@ -0,0 +1,69 @@ +""" +lm15.providers.claude_code — the Anthropic dialect on a Claude Code login. + +``ClaudeCodeLM`` is a *name*, not a behaviour: it is ``AnthropicLM`` bound +to ``lm15.access.CLAUDE_CODE``. Every wire difference from an API-key +client — bearer auth, the ``anthropic-beta`` and ``x-app``/``user-agent`` +headers, the required system-prompt prefix, the re-login hint on auth +errors, no files/batch/live — is a field of that policy, consulted by the +dialect at stated points. A port needs the policy table and the dialect, +not this class. +""" + +from __future__ import annotations + +import os +from typing import ClassVar + +from ..access import CLAUDE_CODE, DEFAULT_CLAUDE_CODE_SYSTEM_PROMPT, DEFAULT_CLAUDE_CODE_VERSION # noqa: F401 +from ..features import ProviderManifest +from .anthropic import AnthropicLM +from ..adaptation import AdaptationPolicy +from .base import Credential, SyncTransport, default_transport + + +class ClaudeCodeLM(AnthropicLM): + """Anthropic Messages adapter authenticated with local Claude Code OAuth.""" + + manifest: ClassVar[ProviderManifest] = CLAUDE_CODE + + def __init__( + self, + api_key: Credential | None = None, + *, + credentials_path: str | os.PathLike[str] | None = None, + transport: SyncTransport | None = None, + base_url: str = "https://api.anthropic.com/v1", + api_version: str = "2023-06-01", + claude_code_version: str = DEFAULT_CLAUDE_CODE_VERSION, + adaptations: "AdaptationPolicy" = "note", + ) -> None: + self.claude_code_version = claude_code_version + policy = CLAUDE_CODE + if claude_code_version != DEFAULT_CLAUDE_CODE_VERSION: + policy = policy.with_headers({"user-agent": f"claude-cli/{claude_code_version}"}) + super().__init__( + api_key=api_key, + transport=transport or default_transport(), + base_url=base_url, + api_version=api_version, + access=policy, + credentials_path=credentials_path, + adaptations=adaptations, + ) + + @classmethod + def from_claude_code( + cls, + *, + credentials_path: str | os.PathLike[str] | None = None, + transport: SyncTransport | None = None, + base_url: str = "https://api.anthropic.com/v1", + claude_code_version: str = DEFAULT_CLAUDE_CODE_VERSION, + ) -> "ClaudeCodeLM": + return cls( + credentials_path=credentials_path, + transport=transport, + base_url=base_url, + claude_code_version=claude_code_version, + ) diff --git a/providers/common.py b/providers/common.py new file mode 100644 index 0000000000..a0285ada12 --- /dev/null +++ b/providers/common.py @@ -0,0 +1,537 @@ +from __future__ import annotations + +import base64 +import json +import urllib.parse +from typing import Any + +from ..models import ModelInfo, ModelOrigin +from ..transports import TransportRequest +from ..types import ( + AudioPart, + BinaryPart, + CitationPart, + DataPart, + DocumentPart, + ImagePart, + Message, + Part, + TextPart, + ThinkingPart, + FileReadiness, + TokenLogprob, + ToolResultPart, + TopLogprob, + VideoPart, +) + +JsonPayload = dict[str, Any] | list[Any] + + +# The FileReadiness fold for every OpenAI-shaped file object (api.openai.com, +# Azure OpenAI v1, Meta): spec/vocabularies.md FileReadiness, ratified +# 2026-09-06 (lm15-contract/changes/2026-09-06-decisions.md D6). Azure says `pending` after +# a 201 upload where api.openai.com says `uploaded` (live 2026-09-04); the +# `status` field is deprecated upstream, so absent and unknown words read as +# ready. +_OPENAI_FILE_READINESS: dict[str, FileReadiness] = { + "uploaded": "pending", + "pending": "pending", + "error": "failed", + "failed": "failed", + "processed": "ready", +} + + +def openai_file_readiness(status: object) -> FileReadiness: + """``uploaded | pending -> pending``, ``error | failed -> failed``, + ``processed | absent | unknown -> ready``.""" + if not isinstance(status, str): + return "ready" + return _OPENAI_FILE_READINESS.get(status, "ready") + + +# The part kinds that carry bytes/addresses rather than words (MAP-10: a +# native block or a raise; never text). +MEDIA_KINDS: frozenset[str] = frozenset({"image", "audio", "video", "document", "binary"}) + + +def parts_to_text(parts: tuple[Part, ...], *, provider: str | None = None, + where: str = "a text-only wire field") -> str: + """Text rendering for wire fields that take text only. + + Renders text-bearing parts (text, thinking, citation). A media part + RAISES `UnsupportedFeatureError` before any wire (MAP-10 rule 2): the + field cannot carry it, and rendering a caption or a type name in its + place is the silent substitution the rule forbids. Callers that own a + slot for the part must not be here. + """ + from ..errors import UnsupportedFeatureError + + out: list[str] = [] + for part in parts: + if part.type in MEDIA_KINDS: + head = f"{provider}: " if provider else "" + raise UnsupportedFeatureError( + f"{head}a {part.type} part cannot reach {where}, which takes text only; " + "no text rendering of a media part is made (MAP-10)", + provider=provider, feature=f"messages[*].parts[{part.type}]", + ) + if isinstance(part, TextPart): + out.append(part.text) + elif isinstance(part, DataPart): + out.append(data_part_text(part)) + elif isinstance(part, ThinkingPart) and part.text: + out.append(part.text) + elif isinstance(part, CitationPart): + bits = [x for x in (part.title, part.url, part.text) if x] + if bits: + out.append(" — ".join(bits)) + return "\n".join(out) + + +def message_text(msg: Message) -> str: + return parts_to_text(msg.parts) + + +def data_part_text(part: DataPart) -> str: + """A data part on a wire that takes only text: its ``value`` as compact + canonical JSON, nothing added (changes/2026-09-19-jev-state.md D3; + types.md §DataPart). An opaque payload: numbers as written.""" + return json.dumps(part.value, separators=(",", ":"), ensure_ascii=False) + + +def media_base64(part: ImagePart | AudioPart | VideoPart | DocumentPart | BinaryPart) -> str: + """The part's bytes as base64: inline `data`, or the `path` read now. + A url/file_id-addressed part has no bytes here; the caller maps those.""" + if part.data is not None: + return part.data + if part.path is not None: + from ..adaptation import is_planning + if is_planning(): + return "" # Discarded preview bytes, never a sendable request. + return base64.b64encode(part.path.read_bytes()).decode("ascii") + raise ValueError(f"{part.type} part has no inline data or path") + + +def media_data_uri(part: ImagePart | AudioPart | VideoPart | DocumentPart | BinaryPart) -> str: + return f"data:{part.media_type};base64,{media_base64(part)}" + + +# ─── MAP-10: tool-result media policy ────────────────────────────── + +# Which part kinds a `tool_result_media` value admits inside a result item. +_TOOL_RESULT_MEDIA_ADMITS: dict[str, frozenset[str]] = { + "native": frozenset({"image", "document"}), + "images": frozenset({"image"}), + "reject": frozenset(), +} + +# The door that carries the part, named in the refusal (MAP-10 rule 3). +_MEDIA_DOORS: dict[str, str] = { + "image": "the OpenAI Responses, Anthropic Messages and Gemini dialects (and the xai/moonshotai/zai chat presets)", + "document": "the OpenAI Responses, Anthropic Messages and Gemini dialects", +} + + +def check_tool_result_media(provider: str, part: ToolResultPart, policy: str, *, wire: str) -> None: + """Raise before any wire when `part.content` carries a part kind the + preset's `tool_result_media` policy does not admit (MAP-10 rules 1–3). + `wire` names the field for the message ("a tool row", "function_call_output").""" + from ..errors import UnsupportedFeatureError + + admits = _TOOL_RESULT_MEDIA_ADMITS[policy] + for p in part.content: + if p.type in MEDIA_KINDS and p.type not in admits: + why = ("this server takes text-only tool results" if policy == "reject" + else f"this server carries images but not {p.type} parts in a tool result") + raise UnsupportedFeatureError( + f"{provider}: a {p.type} part in tool_result {part.id!r} cannot reach {wire} — {why} " + f"(compat tool_result_media={policy!r}, measured: lm15-contract/research/tool-result-content/). " + f"Carried natively by {_MEDIA_DOORS.get(p.type, 'no lm15 door yet')}; " + "or render the part to text yourself before building the tool result (MAP-10)", + provider=provider, feature=f"messages[*].tool_result[{part.id}].content[{p.type}]", + ) + + +def tool_result_error_text(part: ToolResultPart, text: str) -> str: + """MAP-10 rule 5 on wires with no error flag: the text carries it.""" + return f"[error] {text}" if part.is_error else text + + +def media_bytes(part: ImagePart | AudioPart | VideoPart | DocumentPart | BinaryPart) -> bytes: + from ..adaptation import is_planning + if part.path is not None and is_planning(): + return b"" + return part.bytes + + +def extension_config(value: dict[str, Any] | None) -> dict[str, Any]: + return dict(value or {}) + + +def json_dumps(value: Any) -> bytes: + """Canonical request bytes. Text that is not valid Unicode (a lone + surrogate, U+D800..U+DFFF unpaired) has no UTF-8 form and cannot be + sent to ANY provider; it is refused here, before the wire, as the + input error it is — the alternative is an encode failure deep in the + transport that reads as a network fault.""" + text = json.dumps(value, separators=(",", ":"), ensure_ascii=False) + try: + return text.encode("utf-8") + except UnicodeEncodeError as exc: + bad = text[exc.start:exc.end] + codepoints = " ".join(f"U+{ord(ch):04X}" for ch in bad) + raise ValueError( + f"request contains text that is not valid Unicode (lone surrogate {codepoints}), " + "which no provider can receive; repair the text first, e.g. " + "text.encode('utf-8', 'replace').decode('utf-8')" + ) from exc + + +def path_id(value: str, *, resource_name: bool = False) -> str: + """A provider id placed in a URL path (docs/mapping-rules.md MAP-11). + + RFC 3986 percent-encoding over UTF-8: every byte outside the unreserved + set becomes ``%XX``. ``resource_name`` keeps ``/`` literal for wires + whose ids are resource names (Gemini ``files/abc``); a flat-id wire + (OpenAI, Anthropic, xAI) encodes ``/`` too, since a literal slash would + turn one operation into another on the same route table. The id is + never decoded first: a pre-encoded id is double-encoded and answers 404 + (loud), where a raw reserved byte would misroute (silent). + """ + return urllib.parse.quote(value, safe="/" if resource_name else "") + + +def build_url(url: str, params: dict[str, Any] | None = None) -> str: + if not params: + return url + clean = {k: v for k, v in params.items() if v is not None} + if not clean: + return url + sep = "&" if "?" in url else "?" + return f"{url}{sep}{urllib.parse.urlencode(clean)}" + + +def make_json_request( + *, + method: str, + url: str, + headers: dict[str, str] | list[tuple[str, str]] | None = None, + params: dict[str, Any] | None = None, + payload: JsonPayload | None = None, + body: bytes | None = None, + connect_timeout: float | None = None, + read_timeout: float | None = None, + write_timeout: float | None = None, +) -> TransportRequest: + hdrs = list(headers.items()) if isinstance(headers, dict) else list(headers or []) + if payload is not None: + body = json_dumps(payload) + if not any(k.lower() == "content-type" for k, _ in hdrs): + hdrs.append(("Content-Type", "application/json")) + return TransportRequest( + method=method, + url=build_url(url, params), + headers=hdrs, + body=body or b"", + connect_timeout=connect_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + + +def model_infos_from_entries( + entries: Any, + *, + provider: str, + api_family: str, + id_of: "callable", +) -> tuple[ModelInfo, ...]: + """Map a provider's list-models entries to canonical ModelInfo. + + ``id`` is the usable Request.model string (``id_of`` extracts it); the + verbatim wire entry is preserved under ``origin.provider_data`` (opaque, + never cleaned). Entries without a usable id are skipped. + """ + if not isinstance(entries, list): + return () + out: list[ModelInfo] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + model_id = id_of(entry) + if not isinstance(model_id, str) or not model_id: + continue + out.append( + ModelInfo( + id=model_id, + provider=provider, + api_family=api_family, + origin=ModelOrigin(type="provider", provider_data=entry), + ) + ) + return tuple(out) + + +def part_to_openai_input(part: Part, *, provider: str | None = None) -> dict[str, Any]: + """One prompt part → one Responses input block. Every media source form + maps (url, file_id, inline data, a path read now); a part with no slot + RAISES (MAP-10) instead of becoming empty text.""" + from ..errors import UnsupportedFeatureError + + if isinstance(part, TextPart): + return {"type": "input_text", "text": part.text} + if isinstance(part, DataPart): + return {"type": "input_text", "text": data_part_text(part)} + + if isinstance(part, ImagePart): + if part.file_id is not None: + return {"type": "input_image", "file_id": part.file_id} + payload = {"type": "input_image", "image_url": part.url if part.url is not None else media_data_uri(part)} + if part.detail: + payload["detail"] = part.detail + return payload + + if isinstance(part, AudioPart): + if part.url is not None: + return {"type": "input_audio", "audio_url": part.url} + if part.file_id is not None: + return {"type": "input_audio", "file_id": part.file_id} + media = (part.media_type or "audio/wav").split("/", 1)[-1] + if media in {"mpeg", "mp3"}: + media = "mp3" + return {"type": "input_audio", "audio": media_base64(part), "format": media} + + if isinstance(part, (DocumentPart, BinaryPart)): + if part.url is not None: + return {"type": "input_file", "file_url": part.url} + if part.file_id is not None: + return {"type": "input_file", "file_id": part.file_id} + # OpenAI requires a filename alongside inline file_data (live + # 2026-06-11: 400 missing_required_parameter without one); derive + # a deterministic name from the media-type subtype. + ext = (part.media_type or "application/octet-stream").split("/", 1)[-1].split("+", 1)[0] or "bin" + return {"type": "input_file", "filename": f"file.{ext}", "file_data": media_data_uri(part)} + + if isinstance(part, VideoPart): + if part.url is not None: + return {"type": "input_video", "video_url": part.url} + if part.file_id is not None: + return {"type": "input_video", "file_id": part.file_id} + return {"type": "input_video", "video_data": media_data_uri(part)} + + if isinstance(part, (CitationPart, ThinkingPart)): + return {"type": "input_text", "text": parts_to_text((part,))} + + head = f"{provider}: " if provider else "" + raise UnsupportedFeatureError( + f"{head}a {part.type} part has no input block on the Responses wire (MAP-10)", provider=provider, + feature=f"messages[*].parts[{part.type}]", + ) + + +def tool_result_output_openai(provider: str, part: ToolResultPart, policy: str) -> str | list[dict[str, Any]]: + """`function_call_output.output` (MAP-10): a string when the content is + text-only, the documented array of input_text/input_image/input_file + blocks otherwise; a media part the preset does not admit raises first. + `is_error` rides as an `[error] ` prefix on the text (rule 5).""" + check_tool_result_media(provider, part, policy, wire="function_call_output") + if all(p.type not in MEDIA_KINDS for p in part.content): + return tool_result_error_text(part, parts_to_text(part.content, provider=provider, where="function_call_output")) + blocks: list[dict[str, Any]] = [] + for p in part.content: + block = part_to_openai_input(p, provider=provider) + blocks.append(block) + if part.is_error: + first = next((b for b in blocks if b.get("type") == "input_text"), None) + if first is None: + blocks.insert(0, {"type": "input_text", "text": "[error]"}) + else: + first["text"] = "[error] " + first["text"] + return blocks + + +def message_to_openai_input(msg: Message) -> dict[str, Any]: + return {"role": msg.role, "content": [part_to_openai_input(p) for p in msg.parts]} + + +def anthropic_source(part: ImagePart | DocumentPart | BinaryPart) -> dict[str, Any]: + if part.url is not None: + return {"type": "url", "url": part.url} + if part.file_id is not None: + return {"type": "file", "file_id": part.file_id} + if part.data is not None: + return {"type": "base64", "media_type": part.media_type, "data": part.data} + if part.path is not None: + return {"type": "base64", "media_type": part.media_type, "data": media_base64(part)} + raise ValueError(f"{part.type} part has no usable source") + + +def openai_token_logprobs(entries: Any) -> tuple[TokenLogprob, ...]: + """Map OpenAI-style logprob entries to canonical TokenLogprob tuples. + + Both OpenAI wire dialects share the entry shape (verified live + 2026-09-01): ``{token, logprob, bytes, top_logprobs: [{token, logprob, + bytes}]}``. Non-list input and malformed entries yield (). + """ + if not isinstance(entries, list): + return () + out: list[TokenLogprob] = [] + for entry in entries: + if not isinstance(entry, dict) or "token" not in entry or "logprob" not in entry: + continue + top: list[TopLogprob] = [] + for alt in entry.get("top_logprobs") or []: + if not isinstance(alt, dict) or "token" not in alt or "logprob" not in alt: + continue + alt_bytes = alt.get("bytes") + top.append( + TopLogprob( + token=str(alt["token"]), + logprob=float(alt["logprob"]), + bytes=tuple(alt_bytes) if isinstance(alt_bytes, list) else None, + ) + ) + entry_bytes = entry.get("bytes") + out.append( + TokenLogprob( + token=str(entry["token"]), + logprob=float(entry["logprob"]), + bytes=tuple(entry_bytes) if isinstance(entry_bytes, list) else None, + top=tuple(top), + ) + ) + return tuple(out) + + +def unnamed_tool_call_error(provider: str, path: str) -> "ProviderError": + """MAP-9 on the complete path (changes/2026-09-07-complete-tool-call-no-guess.md): + a tool call the provider sent without a name is not actionable, and lm15 + never guesses which tool the model meant.""" + from ..errors import ProviderError + + return ProviderError( + f"{provider}: {path} is a tool call with no name; lm15 does not guess " + "which tool the model meant (MAP-9)", + provider=provider, + ) + + +def parse_json_object(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str) and value: + try: + parsed = json.loads(value) + except Exception: + return {"partial_json": value} + if isinstance(parsed, dict): + return parsed + return {"value": parsed} + return {} + + +def iso_utc(value: object) -> str | None: + """Normalize a provider timestamp (unix epoch or ISO-8601 string) to + canonical ``YYYY-MM-DDTHH:MM:SSZ``. Returns None when unparseable — + the raw value stays available in provider_data.""" + import datetime + + try: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, (int, float)): + dt = datetime.datetime.fromtimestamp(float(value), tz=datetime.timezone.utc) + elif isinstance(value, str) and value: + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + # datetime.fromisoformat rejects >6 fractional digits (Gemini + # emits nanoseconds); trim the fraction to microseconds. + import re + + text = re.sub(r"\.(\d{6})\d+", r".\1", text) + dt = datetime.datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + dt = dt.astimezone(datetime.timezone.utc) + else: + return None + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + except (ValueError, OverflowError, OSError): + return None + + +def multipart_form_body( + *, + fields: list[tuple[str, str]] | None = None, + files: list[tuple[str, str, str, bytes]] | None = None, +) -> tuple[str, bytes]: + """Build a multipart/form-data body (OpenAI and Anthropic file uploads). + + ``files`` entries are ``(field_name, filename, content_type, data)``. + Returns ``(content_type_header_value, body)``. + """ + import uuid + + boundary = f"lm15-{uuid.uuid4().hex}" + chunks: list[bytes] = [] + for name, value in fields or []: + chunks.append(f"--{boundary}\r\n".encode("utf-8")) + chunks.append(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("utf-8")) + chunks.append(f"{value}\r\n".encode("utf-8")) + for name, filename, content_type, data in files or []: + safe_filename = filename.replace('"', "%22") + chunks.append(f"--{boundary}\r\n".encode("utf-8")) + chunks.append( + f'Content-Disposition: form-data; name="{name}"; filename="{safe_filename}"\r\n'.encode("utf-8") + ) + chunks.append(f"Content-Type: {content_type}\r\n\r\n".encode("utf-8")) + chunks.append(data) + chunks.append(b"\r\n") + chunks.append(f"--{boundary}--\r\n".encode("utf-8")) + return f"multipart/form-data; boundary={boundary}", b"".join(chunks) + + +def multipart_related_body( + *, + metadata: dict[str, Any], + media_type: str, + data: bytes, +) -> tuple[str, bytes]: + """Build a multipart/related body (Gemini media upload: JSON metadata + part followed by one media part). Returns ``(content_type, body)``.""" + import uuid + + boundary = f"lm15-{uuid.uuid4().hex}" + chunks: list[bytes] = [ + f"--{boundary}\r\n".encode("utf-8"), + b"Content-Type: application/json; charset=UTF-8\r\n\r\n", + json_dumps(metadata), + b"\r\n", + f"--{boundary}\r\n".encode("utf-8"), + f"Content-Type: {media_type}\r\n\r\n".encode("utf-8"), + data, + b"\r\n", + f"--{boundary}--\r\n".encode("utf-8"), + ] + return f"multipart/related; boundary={boundary}", b"".join(chunks) + + +# ─── MAP-7: the one effort→budget grading table ────────────────────── +# +# Budget-only model classes (Anthropic 4.5 and earlier, Gemini 2.5) have +# no effort level on the wire; the universal dial is expressed as a +# thinking-token budget through this single, stated table. Receipted on +# both providers 2026-09-02 (research/reasoning). Provider floors and +# ceilings are the provider's: Anthropic rejects < 1024, Gemini 2.5 Flash +# rejects > 24576 — loudly. +EFFORT_THINKING_BUDGETS: dict[str, int] = { + "minimal": 1024, + "low": 2048, + "medium": 8192, + "high": 16384, + "xhigh": 24576, + "max": 32768, +} diff --git a/providers/gemini.py b/providers/gemini.py new file mode 100644 index 0000000000..f15e9a6f88 --- /dev/null +++ b/providers/gemini.py @@ -0,0 +1,1976 @@ +from __future__ import annotations + +from datetime import datetime + +import base64 +import json +import os +import struct +import urllib.parse +from dataclasses import dataclass, field, replace +from typing import Any, Callable, ClassVar, Iterator, Mapping + +from ..adaptation import AdaptationPolicy, adapt, check_policy +from ..errors import ( + AuthError, + BillingError, + ContextLengthError, + InvalidRequestError, + ProviderError, + RateLimitError, + ServerError, + TimeoutError, + UnsupportedFeatureError, + UnsupportedModelError, + canonical_error_code, + map_http_error, +) +from ..access import GEMINI_API +from ..features import ProviderManifest +from ..judgments import gemini_schema, note_unmeasurable_probabilities, replace_text_with_data, request_judgments +from ..live import WebSocketLiveSession, require_websocket_sync_connect +from ..sse import SSEEvent +from ..transports import TransportRequest +from ..types import ( + DataPart, + CacheInfo, + CachePage, + VideoGenerationRequest, + VideoJobInfo, + VideoPart, + AudioDelta, + SpeechGenerationRequest, + SpeechGenerationResponse, + AudioPart, + BatchEntry, + BatchJobInfo, + BatchRequest, + ContinuationDelta, + ContinuationState, + BinaryPart, + BuiltinTool, + CitationPart, + Config, + DocumentPart, + ErrorDetail, + FileInfo, + FilePage, + FileUploadRequest, + FunctionTool, + ImageDelta, + ImageGenerationRequest, + ImageGenerationResponse, + ImagePart, + LiveClientAudioEvent, + LiveClientEndAudioEvent, + LiveClientEvent, + LiveClientImageEvent, + LiveClientInterruptEvent, + LiveClientTextEvent, + LiveClientToolResultEvent, + LiveClientTurnEvent, + LiveConfig, + LiveServerAudioEvent, + LiveServerErrorEvent, + LiveServerInterruptedEvent, + LiveServerTextEvent, + LiveServerToolCallEvent, + LiveServerTurnEndEvent, + LiveServerUsageEvent, + Message, + Request, + Response, + StreamDeltaEvent, + StreamEndEvent, + StreamErrorEvent, + StreamEvent, + StreamStartEvent, + TextDelta, + TextPart, + ThinkingDelta, + ThinkingPart, + TokenLogprob, + ToolCallDelta, + ToolCallPart, + ToolResultPart, + TopLogprob, + continuation_data, + Usage, +) +from .base import ( + BaseProviderLM, + Credential, + HttpResponse, + SyncTransport, + batch_entry_http, + batch_entry_request, + default_transport, + resolve_credential, +) +from .common import EFFORT_THINKING_BUDGETS, MEDIA_KINDS, build_url, data_part_text, iso_utc, media_base64, model_infos_from_entries, multipart_related_body, parts_to_text, path_id, unnamed_tool_call_error + +# Canonical builtin tool name → Gemini tool key +_GEMINI_BUILTIN_MAP: dict[str, str] = { + "web_search": "googleSearch", + "code_execution": "codeExecution", +} + +GEMINI_PROVIDER_EXECUTED_PART_KEYS = { + "executableCode", + "codeExecutionResult", +} + +def gemini_level_class(model: str) -> bool: + """True for the Gemini 3.x class: `thinkingLevel`, no full off (MAP-7 rule 10). + + Model-name table, receipted 2026-09-02: 2.5 models reject thinkingLevel, + 3.x models take it; 3.7 Flash accepts thinkingBudget: 0 and still spends + thinking tokens. A table that rots; `extensions` overrides. + """ + lowered = model.lower() + return lowered.startswith("models/gemini-3") or lowered.startswith("gemini-3") + + + + +def _attach_unmapped(provider_data: dict[str, Any], unmapped: list[dict[str, str]]) -> dict[str, Any]: + if not unmapped: + return provider_data + out = dict(provider_data) + out["_lm15_unmapped"] = unmapped + return out + + +def _record_unmapped(unmapped: list[dict[str, str]], path: str, typ: Any) -> None: + unmapped.append({"path": path, "type": str(typ or "")}) + + +def _builtin_to_gemini(tool: BuiltinTool) -> dict[str, Any]: + return {_GEMINI_BUILTIN_MAP.get(tool.name, tool.name): tool.config or {}} + + +def _gemini_token_logprobs(logprobs_result: Any) -> tuple[TokenLogprob, ...]: + """Map Gemini logprobsResult to canonical TokenLogprob tuples. + + Doc-based mapping (generate-content reference — LogprobsResult / + TopCandidates / Candidate): chosenCandidates[i] pairs with + topCandidates[i] per decoding step. No live capture exists because + every currently served model rejects responseLogprobs (2026-09-01). + Derived aggregates (logProbabilitySum, avgLogprobs) stay in + provider_data — they are computable from the per-token values. + """ + if not isinstance(logprobs_result, dict): + return () + chosen = logprobs_result.get("chosenCandidates") or [] + top_steps = logprobs_result.get("topCandidates") or [] + out: list[TokenLogprob] = [] + for i, cand in enumerate(chosen): + if not isinstance(cand, dict): + continue + top: list[TopLogprob] = [] + step = top_steps[i] if i < len(top_steps) and isinstance(top_steps[i], dict) else {} + for alt in step.get("candidates") or []: + if not isinstance(alt, dict): + continue + top.append( + TopLogprob( + token=str(alt.get("token") or ""), + logprob=float(alt.get("logProbability", 0.0) or 0.0), + token_id=alt.get("tokenId"), + ) + ) + out.append( + TokenLogprob( + token=str(cand.get("token") or ""), + logprob=float(cand.get("logProbability", 0.0) or 0.0), + token_id=cand.get("tokenId"), + top=tuple(top), + ) + ) + return tuple(out) + + +def _gemini_schema_field(schema: dict[str, Any]) -> str: + """Pick Gemini's schema field for an lm15 JSON schema. + + ``responseSchema`` is Gemini's OpenAPI-ish schema type and rejects JSON + Schema keywords such as ``additionalProperties``. ``responseJsonSchema`` + accepts those keywords, so use it when the schema needs full JSON Schema. + """ + return "responseJsonSchema" if _contains_key(schema, "additionalProperties") else "responseSchema" + + +def _contains_key(value: Any, key: str) -> bool: + if isinstance(value, dict): + return key in value or any(_contains_key(v, key) for v in value.values()) + if isinstance(value, list): + return any(_contains_key(v, key) for v in value) + return False + + +def _gemini_number(value: float) -> float | int: + """Gemini wire dialect: integral float knobs are sent in integer form. + + The canonical model declares temperature/top_p as float fields (Number + rule, docs/serde-rules.md), but Gemini's proto3-JSON wire form for + integral doubles is the integer digits (live capture: + lm15-contract/cases/gemini/temperature.json sends ``"temperature": 1``). + This is a provider wire-dialect mapping, not a canonical form. + """ + if isinstance(value, float) and value.is_integer(): + return int(value) + return value + + +def _response_format_to_gemini_config(format_config: dict[str, Any]) -> dict[str, Any]: + """Canonical response_format (INV-050) -> Gemini generationConfig. + + `responseJsonSchema` accepts JSON Schema keywords; `responseSchema` is + the OpenAPI subset — `_gemini_schema_field` picks by the presence of + `additionalProperties` (pinned by gemini.response_schema and the + 2026-09-02 receipts). `strict` is satisfied (always constrained); + `name` is a label with no slot. + """ + if format_config["type"] == "json_object": + return {"responseMimeType": "application/json"} + schema = format_config["schema"] + return {"responseMimeType": "application/json", _gemini_schema_field(schema): schema} + + +def _gemini_usage(usage_payload: Any, *, output_keys: tuple[str, ...]) -> Usage: + """Map a Gemini ``usageMetadata`` object to canonical Usage (INV-029). + + Gemini's proto3-JSON wire omits zero-valued fields, so inside a present + ``usageMetadata`` an absent primary counter means "0", not "not + reported" (pinned: lm15-contract goldens/gemini/max_output_tokens.json, + where ``candidatesTokenCount`` is absent and ``totalTokenCount`` equals + prompt + thoughts). When ``usageMetadata`` itself is missing nothing + was reported and every counter stays None. Secondary counters are + conditional on a feature (cache, thinking) and stay verbatim: absent is + "not reported". ``output_keys`` lists the wire names for output tokens + in priority order (``candidatesTokenCount`` on generateContent, + ``responseTokenCount`` on Live). + """ + if not isinstance(usage_payload, dict) or not usage_payload: + return Usage() + output_tokens: Any = 0 + for key in output_keys: + if key in usage_payload: + output_tokens = usage_payload[key] + break + return Usage( + input_tokens=usage_payload.get("promptTokenCount", 0), + output_tokens=output_tokens, + total_tokens=usage_payload.get("totalTokenCount"), + cache_read_tokens=usage_payload.get("cachedContentTokenCount"), + reasoning_tokens=usage_payload.get("thoughtsTokenCount"), + # Modality breakdowns: {modality, tokenCount} entries. AUDIO has a + # canonical slot on both sides (OpenAI fills the same slots); IMAGE + # and VIDEO do not and stay in provider_data. Independent review + # 2026-09-02: three goldens carried audio counts nowhere. + input_audio_tokens=_modality_tokens(usage_payload.get("promptTokensDetails"), "AUDIO"), + output_audio_tokens=_modality_tokens( + usage_payload.get("candidatesTokensDetails") or usage_payload.get("responseTokensDetails"), "AUDIO" + ), + ) + + +def _modality_tokens(details: Any, modality: str) -> int | None: + """Sum of ``tokenCount`` over the entries for ``modality``; None when the + breakdown has no such entry (not reported, per INV-029).""" + if not isinstance(details, list): + return None + counts = [e.get("tokenCount", 0) for e in details if isinstance(e, dict) and e.get("modality") == modality] + return sum(counts) if counts else None + + +def _thought_signature_state(part: dict[str, Any]) -> tuple[ContinuationState, ...]: + """The part's ``thoughtSignature`` as canonical replay state, or ().""" + signature = part.get("thoughtSignature") + if signature is None: + return () + return (ContinuationState(provider="gemini", kind="thought_signature", data={"value": str(signature)}),) + + +def _finish_reason(reason: str | None, *, has_tool_call: bool = False) -> str: + if has_tool_call: + return "tool_call" + r = str(reason or "").upper() + if r == "MAX_TOKENS": + return "length" + if r in {"SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"}: + return "content_filter" + return "stop" + + +def _gemini_batch_status(data: dict[str, Any]) -> str: + """Map a Gemini batch operation to the canonical BatchStatus. + + States observed live 2026-08-31: BATCH_STATE_PENDING / RUNNING / + SUCCEEDED (wire fact — the docs' JOB_STATE_* naming is wrong for + this endpoint). + """ + state = str((data.get("metadata") or {}).get("state") or "").upper() + mapping = { + "BATCH_STATE_PENDING": "queued", + "BATCH_STATE_RUNNING": "running", + "BATCH_STATE_CANCELLING": "cancelling", + "BATCH_STATE_SUCCEEDED": "completed", + "BATCH_STATE_FAILED": "failed", + "BATCH_STATE_CANCELLED": "cancelled", + "BATCH_STATE_EXPIRED": "expired", + } + if state in mapping: + return mapping[state] + return "completed" if data.get("done") else "queued" + + +def _int_or_none(value: Any) -> int | None: + if isinstance(value, bool) or value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _gemini_segment_text(segment: dict[str, Any], full_text: str) -> str | None: + text = segment.get("text") + if isinstance(text, str) and text: + return text + start = _int_or_none(segment.get("startIndex")) + end = _int_or_none(segment.get("endIndex")) + if start is not None and end is not None and 0 <= start < end <= len(full_text): + return full_text[start:end] + return None + + +def _gemini_grounding_chunk(chunks: list[Any], index: Any) -> dict[str, Any]: + idx = _int_or_none(index) + if idx is None or idx < 0 or idx >= len(chunks): + return {} + chunk = chunks[idx] + return chunk if isinstance(chunk, dict) else {} + + +def _gemini_citations(candidate: dict[str, Any], full_text: str) -> list[CitationPart]: + grounding = candidate.get("groundingMetadata") + if not isinstance(grounding, dict): + return [] + + chunks = grounding.get("groundingChunks") or [] + if not isinstance(chunks, list): + chunks = [] + supports = grounding.get("groundingSupports") or [] + if not isinstance(supports, list): + return [] + + citations: list[CitationPart] = [] + seen: set[tuple[str | None, str | None, str | None]] = set() + for support in supports: + if not isinstance(support, dict): + continue + segment = support.get("segment") if isinstance(support.get("segment"), dict) else {} + cited_text = _gemini_segment_text(segment, full_text) + indices = support.get("groundingChunkIndices") or [] + if not isinstance(indices, list): + continue + for index in indices: + chunk = _gemini_grounding_chunk(chunks, index) + source = chunk.get("web") or chunk.get("retrievedContext") or chunk.get("googleSearch") or {} + if not isinstance(source, dict): + source = {} + url = source.get("uri") or source.get("url") + title = source.get("title") or source.get("name") + url_s = str(url) if url else None + title_s = str(title) if title else None + key = (url_s, title_s, cited_text) + if key in seen or (url_s is None and title_s is None and cited_text is None): + continue + seen.add(key) + citations.append(CitationPart(url=url_s, title=title_s, text=cited_text)) + return citations + + +_DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" + + +@dataclass(slots=True) +class GeminiLM(BaseProviderLM): + api_key: Credential | None = field(default=None, repr=False) + transport: SyncTransport = field(default_factory=default_transport) + base_url: str = _DEFAULT_BASE_URL + upload_base_url: str = "https://generativelanguage.googleapis.com/upload/v1beta" + access: ProviderManifest | None = field(default=None, repr=False) + credentials_path: "str | os.PathLike[str] | None" = field(default=None, repr=False) + settings: "Mapping[str, str] | None" = None + # A named cloud identity ("platform", "workload", "environment", "cli"; + # AUTH-1) on a cloud door, instead of api_key=. + credential: str | None = field(default=None, kw_only=True) + clock: "Callable[[], datetime] | None" = field(default=None, repr=False) + + # MAP-13 policy: "note" (adapt and record), "silent", or "refuse". + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + provider: str = field(default="gemini", init=False) + account_id: str | None = field(default=None, init=False, repr=False) + manifest: ClassVar[ProviderManifest] = GEMINI_API + _api_key_header: ClassVar[str] = "x-goog-api-key" + + def __post_init__(self) -> None: + check_policy(self.adaptations) + self._bind_access(self.access, credentials_path=self.credentials_path, default_base_url=_DEFAULT_BASE_URL, settings=self.settings, + credential=self.credential) + + _error_status_map: ClassVar[dict[str, type[ProviderError]]] = { + "INVALID_ARGUMENT": InvalidRequestError, + "FAILED_PRECONDITION": BillingError, + "PERMISSION_DENIED": AuthError, + "UNAUTHENTICATED": AuthError, + "NOT_FOUND": InvalidRequestError, + "RESOURCE_EXHAUSTED": RateLimitError, + "INTERNAL": ServerError, + "UNAVAILABLE": ServerError, + "DEADLINE_EXCEEDED": TimeoutError, + } + + @staticmethod + def _is_context_length_message(msg: str) -> bool: + lowered = msg.lower() + return ( + ("token" in lowered and ("limit" in lowered or "exceed" in lowered)) + or "too long" in lowered + or "context is too long" in lowered + or "context length" in lowered + ) + + @staticmethod + def _is_model_error(message: str) -> bool: + lowered = message.lower() + return "model" in lowered and any( + marker in lowered + for marker in ( + "not found", + "does not exist", + "not exist", + "not supported", + "unsupported", + "not available", + "unknown", + ) + ) + + @staticmethod + def _is_candidate_finish_error(finish_reason: str) -> bool: + return finish_reason in { + "SAFETY", + "RECITATION", + "LANGUAGE", + "BLOCKLIST", + "PROHIBITED_CONTENT", + "SPII", + "MALFORMED_FUNCTION_CALL", + "IMAGE_SAFETY", + "IMAGE_PROHIBITED_CONTENT", + "IMAGE_OTHER", + "NO_IMAGE", + "IMAGE_RECITATION", + "UNEXPECTED_TOOL_CALL", + "TOO_MANY_TOOL_CALLS", + "MISSING_THOUGHT_SIGNATURE", + "MALFORMED_RESPONSE", + } + + def _error_detail(self, provider_code: str, message: str) -> ErrorDetail: + cls = self._error_status_map.get(provider_code, ProviderError) + if self._is_context_length_message(message): + cls = ContextLengthError + elif provider_code == "NOT_FOUND" and self._is_model_error(message): + cls = UnsupportedModelError + return ErrorDetail( + code=canonical_error_code(cls), + message=message or provider_code or "provider error", + provider_code=provider_code or "provider", + ) + + def _inband_error(self, data: dict[str, Any]) -> ProviderError | None: + prompt_feedback = data.get("promptFeedback") + if isinstance(prompt_feedback, dict): + block_reason = str(prompt_feedback.get("blockReason") or "") + if block_reason and block_reason != "BLOCK_REASON_UNSPECIFIED": + return self._provider_error( + InvalidRequestError, + f"Prompt blocked: {block_reason}", + provider_code="promptFeedback", + ) + + candidate = (data.get("candidates") or [{}])[0] + if isinstance(candidate, dict): + finish_reason = str(candidate.get("finishReason") or "") + if self._is_candidate_finish_error(finish_reason): + finish_message = str(candidate.get("finishMessage") or "") + return self._provider_error( + InvalidRequestError, + finish_message or f"Candidate blocked: {finish_reason}", + provider_code=finish_reason or "finishReason", + ) + return None + + def normalize_error(self, status: int, body: str) -> ProviderError: + try: + data = json.loads(body) + err = data.get("error", {}) if isinstance(data, dict) else {} + msg = err.get("message", "") if isinstance(err, dict) else str(err) + err_status = str(err.get("status") or "") if isinstance(err, dict) else "" + if self._is_context_length_message(msg): + return self._provider_error( + ContextLengthError, + msg, + status=status, + provider_code=err_status or None, + ) + if err_status == "NOT_FOUND" and self._is_model_error(msg): + return self._provider_error( + UnsupportedModelError, + msg, + status=status, + provider_code=err_status, + ) + cls = self._error_status_map.get(err_status) + if cls: + return self._provider_error( + cls, + msg, + status=status, + provider_code=err_status or None, + ) + if err_status and err_status not in msg: + msg = f"{msg} ({err_status})" + except Exception: + msg = body.strip()[:500] or f"HTTP {status}" + err_status = "" + return map_http_error( + status, + msg, + provider=self.provider, + env_keys=self.access.env_keys, + provider_code=err_status or None, + ) + + # ─── Request serialization ────────────────────────────────────── + + def _model_path(self, model: str) -> str: + model = urllib.parse.quote(model, safe="/:@") + return model if model.startswith("models/") else f"models/{model}" + + def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: + headers: dict[str, str] = {} # auth is applied once, in _emit (AUTH-2) + if extra: + headers.update(extra) + return headers + + def _auth_params(self, extra: dict[str, Any] | None = None) -> dict[str, Any]: + return dict(extra or {}) + + def _function_response(self, part: ToolResultPart, names: dict[str, str] | None) -> dict[str, Any]: + """`functionResponse` (MAP-10 on this wire): text in `response.result` + (`response.error` when is_error), media nested under `parts[]` as + inlineData/fileData in the caller's order; the name resolved from the + transcript's matching functionCall when the caller gave none (rule 6). + Every model takes the shape; Gemini 2.5 answers HTTP 400 itself.""" + text_parts = tuple(p for p in part.content if p.type not in MEDIA_KINDS) + media_parts = [p for p in part.content if p.type in MEDIA_KINDS] + for p in media_parts: + if p.type not in ("image", "document"): + raise UnsupportedFeatureError( + f"{self.provider}: a {p.type} part in tool_result {part.id!r} cannot reach a functionResponse — " + "multimodal function responses take images (png/jpeg/webp) and documents (pdf, text/plain) only (MAP-10)", + provider=self.provider, feature=f"messages[*].tool_result[{part.id}].content[{p.type}]", + ) + name = part.name or (names or {}).get(part.id) + if not name: + raise UnsupportedFeatureError( + f"{self.provider}: tool_result {part.id!r} needs a function name on the Gemini wire and no preceding " + "assistant tool_call with that id is in the transcript; set ToolResultPart.name (MAP-10 rule 6)", + provider=self.provider, feature=f"messages[*].tool_result[{part.id}].name", + ) + text = parts_to_text(text_parts, provider=self.provider, where="functionResponse.response") + if part.is_error: + response: dict[str, Any] = {"error": text} + elif media_parts and not text_parts: + response = {} # the media IS the result; no fabricated text (live 2026-09-07: accepted) + else: + response = {"result": text} + fr: dict[str, Any] = {"name": name, "response": response} + if part.id: + fr["id"] = part.id + if media_parts: + fr["parts"] = [self._part(p) for p in media_parts] + return {"functionResponse": fr} + + def _part(self, part, names: dict[str, str] | None = None) -> dict[str, Any]: + if isinstance(part, DataPart): + return {"text": data_part_text(part)} + if isinstance(part, TextPart): + out: dict[str, Any] = {"text": part.text} + thought = continuation_data(part, "gemini", "thought_signature") + if thought and thought.get("value"): + out["thoughtSignature"] = thought["value"] + return out + if isinstance(part, (ImagePart, AudioPart, VideoPart, DocumentPart, BinaryPart)): + mime = part.media_type or "application/octet-stream" + if part.url is not None: + return {"fileData": {"mimeType": mime, "fileUri": part.url}} + if part.file_id is not None: + return {"fileData": {"mimeType": mime, "fileUri": part.file_id}} + if part.data is not None: + return {"inlineData": {"mimeType": mime, "data": part.data}} + if part.path is not None: + return {"inlineData": {"mimeType": mime, "data": media_base64(part)}} + if isinstance(part, ToolCallPart): + out: dict[str, Any] = {"functionCall": {"name": part.name, "args": part.input}} + if part.id: + out["functionCall"]["id"] = part.id + thought = continuation_data(part, "gemini", "thought_signature") + if thought and thought.get("value"): + out["thoughtSignature"] = thought["value"] + return out + if isinstance(part, ToolResultPart): + return self._function_response(part, names) + if isinstance(part, ThinkingPart): + out: dict[str, Any] = {"text": part.text} + thought = continuation_data(part, "gemini", "thought_signature") + if thought and thought.get("value"): + out["thought"] = True + out["thoughtSignature"] = thought["value"] + return out + return {"text": getattr(part, "text", "") or ""} + + def _message(self, msg: Message, names: dict[str, str] | None = None) -> dict[str, Any]: + role = "model" if msg.role == "assistant" else "user" + if msg.role == "developer": + return {"role": "user", "parts": [{"text": f"[developer]\n{parts_to_text(msg.parts, provider=self.provider, where='a developer turn')}"}]} + return {"role": role, "parts": [self._part(part, names) for part in msg.parts]} + + @staticmethod + def _call_names(messages) -> dict[str, str]: + """tool_call id → function name, over the transcript (MAP-10 rule 6).""" + names: dict[str, str] = {} + for m in messages: + for p in m.parts: + if isinstance(p, ToolCallPart) and p.id: + names[p.id] = p.name + return names + + def _tool_config_payload(self, request: Request) -> dict[str, Any] | None: + tc = request.config.tool_choice + if tc is None: + return None + if tc.parallel is False: + # MAP-13: no wire knob (live 2026-09-02: two calls came back on + # 2.5 and 3.7 with the preference set). A preference; agent + # loops iterate tool-call parts as a list anyway. Dropped and + # recorded — the record is the visibility MAP-8 rule 2 wanted. + adapt("config.tool_choice.parallel", "dropped", + "GenerateContent has no parallel-tool-calls knob and may return several calls " + "(OpenAI and Anthropic carry it)", + asked=False, provider=self.provider) + mode = {"none": "NONE", "required": "ANY", "auto": "AUTO"}[tc.mode] + cfg: dict[str, Any] = {"mode": mode} + if tc.allowed: + by_name = {t.name: t for t in request.tools} + builtins = [name for name in tc.allowed if isinstance(by_name.get(name), BuiltinTool)] + if builtins: + # MAP-13 rule 4(b): the program depends on the forced tool running. + raise UnsupportedFeatureError( + f"gemini: cannot force builtin tools {builtins} — " + "functionCallingConfig addresses function declarations only; " + "googleSearch/codeExecution have no tool_choice form " + "(OpenAI Responses and Anthropic carry builtin forcing)", + provider=self.provider, feature="config.tool_choice.allowed", + ) + cfg["allowedFunctionNames"] = list(tc.allowed) + if tc.mode == "auto": + # allowedFunctionNames is only legal with ANY or VALIDATED. + # VALIDATED = "function call or text, restricted to the + # allowlist" — exactly canonical mode=auto + allowed. + cfg["mode"] = "VALIDATED" + return {"functionCallingConfig": cfg} + + def _payload(self, request: Request) -> dict[str, Any]: + extensions = dict(request.config.extensions or {}) + cache_cfg = request.config.cache + # MAP-6 on Gemini. Automatic tier: nothing to send; prefix intents + # fall back to it (no cost, visible in usageMetadata). Resource + # tier: `cachedContent` names a stored object that already holds + # system, tools, and the prefix messages, so the wire carries only + # the suffix — the server rejects system/tools next to a cache. + # key / retention name mechanisms this wire does not have. + resource: str | None = None + suffix_from = 0 + if cache_cfg is not None and cache_cfg.mode != "off": + if cache_cfg.key is not None: + adapt("config.cache.key", "dropped", + "GenerateContent has no cache affinity key; implicit caching applies, and a " + "stored cache (lm.cache(prefix), cache.resource) is the explicit tier", + asked=cache_cfg.key, provider=self.provider) + if cache_cfg.retention is not None and cache_cfg.retention != "short": + adapt("config.cache.retention", "dropped", + "GenerateContent takes no lifetime in-request; it belongs to the stored cache " + "(cache_create(..., ttl_seconds=...) / cache_update)", + asked=cache_cfg.retention, provider=self.provider) + if cache_cfg.resource is not None: + resource = cache_cfg.resource + if cache_cfg.prefix_until_index is not None: + suffix_from = min(cache_cfg.prefix_until_index, len(request.messages) - 1) + 1 + wire_messages = request.messages[suffix_from:] + if resource is not None and not wire_messages: + raise ValueError("gemini: a request against a stored cache needs at least one message after the prefix") + + names = self._call_names(request.messages) + payload: dict[str, Any] = {"contents": [self._message(m, names) for m in wire_messages]} + if resource is not None: + payload["cachedContent"] = self._cache_resource(resource) + if request.system and resource is None: + text = request.system if isinstance(request.system, str) else parts_to_text(request.system) + payload["systemInstruction"] = {"parts": [{"text": text}]} + + generation_config: dict[str, Any] = {} + if request.config.temperature is not None: + generation_config["temperature"] = _gemini_number(request.config.temperature) + if request.config.max_tokens is not None: + generation_config["maxOutputTokens"] = request.config.max_tokens + if request.config.top_p is not None: + generation_config["topP"] = _gemini_number(request.config.top_p) + if request.config.top_k is not None: + generation_config["topK"] = request.config.top_k + if request.config.seed is not None: + generation_config["seed"] = request.config.seed + if request.config.frequency_penalty is not None: + generation_config["frequencyPenalty"] = _gemini_number(request.config.frequency_penalty) + if request.config.presence_penalty is not None: + generation_config["presencePenalty"] = _gemini_number(request.config.presence_penalty) + if request.config.stop: + generation_config["stopSequences"] = list(request.config.stop) + if request.config.logprobs is not None: + # Documented wire knobs (generate-content reference). Live note + # 2026-09-01: every currently served Gemini model rejects this + # with "Logprobs is not enabled" — the mapping is doc-based and + # the rejection surfaces as the provider's InvalidRequestError. + generation_config["responseLogprobs"] = True + if request.config.logprobs > 0: + generation_config["logprobs"] = request.config.logprobs + if request.config.response_format: + # MAP-14 §2: judgment properties go as enum with descriptions + # folded into the property description — responseJsonSchema + # ignores anyOf/const (receipted 2026-09-17: "Bordeaux-blend"); + # probabilities cannot be measured here. + note_unmeasurable_probabilities(request, self.provider) + found = request_judgments(request) + fmt = request.config.response_format + if found: + fmt = {**fmt, "schema": gemini_schema(fmt["schema"], found)} + generation_config.update(_response_format_to_gemini_config(fmt)) + if request.config.reasoning is not None: + reasoning = request.config.reasoning + level_class = gemini_level_class(request.model) + if reasoning.is_off and level_class: + # MAP-13 (decision 2026-09-14 §4.2): the Gemini 3 class has no + # honoured off switch (thinkingBudget 0 accepted, 58 tokens + # still spent on 3.7 Flash, live 2026-09-02); the closest to + # "none" is the lowest level, and the spend shows in + # usage.reasoning_tokens. + adapt("config.reasoning.effort", "substituted", + f"{request.model} cannot disable thinking (the Gemini 3 class honours no off switch); " + "the lowest level was sent and the thinking spend is visible in usage", + asked="off", applied="minimal", provider=self.provider) + reasoning = replace(reasoning, effort="minimal") + if reasoning.is_off: + generation_config["thinkingConfig"] = {"thinkingBudget": 0} + else: + if reasoning.summary in ("concise", "detailed"): + adapt("config.reasoning.summary", "substituted", + "GenerateContent has includeThoughts only, no detail levels; 'auto' shows the thoughts", + asked=reasoning.summary, applied="auto", provider=self.provider) + reasoning = replace(reasoning, summary="auto") + thinking: dict[str, Any] = {} + if reasoning.summary is not None: + thinking["includeThoughts"] = True # MAP-7 rule 7: only when asked + if reasoning.thinking_budget is not None: + thinking["thinkingBudget"] = reasoning.thinking_budget # 3.x: accepted, docs warn + elif level_class: + effort = reasoning.effort + if effort in ("xhigh", "max"): + adapt("config.reasoning.effort", "clamped", + "the Gemini 3 class has thinkingLevel minimal|low|medium|high; 'high' is the ceiling", + asked=effort, applied="high", provider=self.provider) + effort = "high" + thinking["thinkingLevel"] = effort + else: + thinking["thinkingBudget"] = EFFORT_THINKING_BUDGETS[reasoning.effort] + generation_config["thinkingConfig"] = thinking + if generation_config: + payload["generationConfig"] = generation_config + + if request.tools and resource is None: + function_declarations = [ + {"name": t.name, "description": t.description, "parameters": t.parameters} + for t in request.tools + if isinstance(t, FunctionTool) + ] + tools_wire: list[dict[str, Any]] = [] + if function_declarations: + tools_wire.append({"functionDeclarations": function_declarations}) + for tool in request.tools: + if isinstance(tool, BuiltinTool): + tools_wire.append(_builtin_to_gemini(tool)) + payload["tools"] = tools_wire + + tool_config = self._tool_config_payload(request) if resource is None else None + if tool_config is not None: + payload["toolConfig"] = tool_config + + output = extensions.get("output") + if output == "image": + payload.setdefault("generationConfig", {})["responseModalities"] = ["IMAGE"] + elif output == "audio": + payload.setdefault("generationConfig", {})["responseModalities"] = ["AUDIO"] + + # Promoted cross-provider knobs (changes/2026-09-01-extensions-burn-down): + # store maps verbatim (same wire key as OpenAI, verified live). + # service_tier maps to the top-level `serviceTier` enum + # (unspecified|standard|flex|priority) — added to GenerateContent + # between the April and September 2026 doc snapshots and verified + # live 2026-09-01 (accepted + echoed in usageMetadata.serviceTier; + # an unknown value is rejected with HTTP 400 INVALID_ARGUMENT, so + # the server validates the field). user_id has no Gemini wire + # field — raise, never silently drop. + if request.config.store is not None: + payload["store"] = request.config.store + if request.config.service_tier is not None: + payload["serviceTier"] = request.config.service_tier + if request.config.user_id is not None: + # MAP-13 (decision 2026-09-14 §4.5): attribution has no field + # here and nothing in the program depends on it at run time; a + # compliance policy sets adaptations="refuse". + adapt("config.user_id", "dropped", + "GenerateContent has no end-user attribution field (OpenAI and Anthropic carry it)", + asked=request.config.user_id, provider=self.provider) + + if extensions: + passthrough = {k: v for k, v in extensions.items() if k not in {"prompt_caching", "output"}} + payload.update(passthrough) + return payload + + def build_request(self, request: Request, stream: bool) -> TransportRequest: + request = self._wire_request(request) + endpoint = "streamGenerateContent" if stream else "generateContent" + params = self._auth_params({"alt": "sse"} if stream else None) + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/{self._model_path(request.model)}:{endpoint}", + endpoint="generateContent", + stream=stream, + model=request.model, + headers=self._auth_headers({"Content-Type": "application/json"}), + params=params, + payload=self._payload(request), + ) + + # ─── Response parsing ─────────────────────────────────────────── + + def _parse_candidate_parts( + self, + parts_payload: list[dict[str, Any]], + *, + unmapped: list[dict[str, str]] | None = None, + path_prefix: str = "parts", + ) -> list[Any]: + parts: list[Any] = [] + for part_index, part in enumerate(parts_payload): + if not isinstance(part, dict): + if unmapped is not None: + _record_unmapped(unmapped, f"{path_prefix}[{part_index}]", type(part).__name__) + continue + if part.get("thought") and "text" in part: + # A thought part is classified by its flag, not by non-empty + # text: 3.x can send {thought: true, text: "", thoughtSignature} + # and the signature is the replay state (MAP-7 rule 8). + parts.append(ThinkingPart(text=str(part.get("text") or ""), continuation=_thought_signature_state(part))) + elif "text" in part: + # On Gemini 3.x the final answer text carries the turn's + # thoughtSignature (independent review 2026-09-02: dropped + # silently before). It is replay state exactly as on a + # thought or functionCall part, and goes back on the wire. + parts.append(TextPart(text=str(part.get("text") or ""), continuation=_thought_signature_state(part))) + elif "functionCall" in part and isinstance(part["functionCall"], dict): + fc = part["functionCall"] + continuation: tuple[ContinuationState, ...] = () + thought_signature = part.get("thoughtSignature") or fc.get("thoughtSignature") + if thought_signature is not None: + continuation = ( + ContinuationState( + provider="gemini", + kind="thought_signature", + data={"value": str(thought_signature)}, + ), + ) + if not fc.get("name"): + raise unnamed_tool_call_error(self.provider, f"{path_prefix}[{part_index}]") + parts.append(ToolCallPart( + # MAP-9: a missing call id is the lm15 correlator tool_call_, + # the same one the stream assembler mints (INV-051 parity). + id=str(fc.get("id") or f"tool_call_{len(parts)}"), + name=str(fc["name"]), + input=fc.get("args") if isinstance(fc.get("args"), dict) else {}, + continuation=continuation, + )) + elif "inlineData" in part and isinstance(part["inlineData"], dict): + inline = part["inlineData"] + mime = str(inline.get("mimeType") or "application/octet-stream") + data = str(inline.get("data") or "") + if not data: + continue + if mime.startswith("image/"): + parts.append(ImagePart(media_type=mime, data=data)) + elif mime.startswith("audio/"): + parts.append(AudioPart(media_type=mime, data=data)) + else: + parts.append(DocumentPart(media_type=mime, data=data)) + elif "fileData" in part and isinstance(part["fileData"], dict): + fd = part["fileData"] + uri = str(fd.get("fileUri") or "") + mime = str(fd.get("mimeType") or "application/octet-stream") + if not uri: + continue + if mime.startswith("image/"): + parts.append(ImagePart(media_type=mime, url=uri)) + elif mime.startswith("audio/"): + parts.append(AudioPart(media_type=mime, url=uri)) + else: + parts.append(DocumentPart(media_type=mime, url=uri)) + elif any(key in part for key in GEMINI_PROVIDER_EXECUTED_PART_KEYS): + continue + elif unmapped is not None: + _record_unmapped(unmapped, f"{path_prefix}[{part_index}]", "+".join(sorted(part)) or "") + return parts + + def parse_response(self, request: Request, response: HttpResponse) -> Response: + request = self._wire_request(request) + data = response.json() + inband = self._inband_error(data) + if inband is not None: + raise inband + candidate = (data.get("candidates") or [{}])[0] + candidate = candidate if isinstance(candidate, dict) else {} + content = candidate.get("content", {}) if isinstance(candidate.get("content"), dict) else {} + unmapped: list[dict[str, str]] = [] + parts = self._parse_candidate_parts( + content.get("parts", []) or [], + unmapped=unmapped, + path_prefix="candidates[0].content.parts", + ) + full_text = "".join(part.text for part in parts if isinstance(part, TextPart)) + parts.extend(_gemini_citations(candidate, full_text)) + if not parts: + parts = [TextPart(text="")] + usage = _gemini_usage(data.get("usageMetadata"), output_keys=("candidatesTokenCount", "responseTokenCount")) + has_tool = any(isinstance(part, ToolCallPart) for part in parts) + # D8 (2026-09-06): Response.id carries responseId; no message-level + # continuation state is minted for it. + logprob_seq = _gemini_token_logprobs(candidate.get("logprobsResult")) + return Response( + id=str(data.get("responseId")) if data.get("responseId") else None, + model=request.model, + message=Message(role="assistant", parts=replace_text_with_data(parts, request_judgments(request))), + finish_reason=_finish_reason(candidate.get("finishReason"), has_tool_call=has_tool), + usage=usage, + logprobs=logprob_seq or None, + provider_data=_attach_unmapped(data, unmapped), + ) + + def parse_stream_events(self, request: Request, raw_event: SSEEvent) -> Iterator[StreamEvent]: + request = self._wire_request(request) + if not raw_event.data: + return + payload = json.loads(raw_event.data) + if not isinstance(payload, dict): + return + if "error" in payload: + err = payload["error"] + provider_code = str(err.get("status") or err.get("code") or "provider") if isinstance(err, dict) else "provider" + message = str(err.get("message") or "") if isinstance(err, dict) else "" + yield StreamErrorEvent(error=self._error_detail(provider_code, message)) + return + + inband = self._inband_error(payload) + if inband is not None: + yield StreamErrorEvent(error=ErrorDetail(code=canonical_error_code(inband), provider_code="inband_finish_reason", message=str(inband))) + return + + candidates = payload.get("candidates") or [] + candidate = candidates[0] if candidates and isinstance(candidates[0], dict) else None + yielded_delta = False + saw_tool = False + finish = None + if candidate is not None: + content = candidate.get("content", {}) if isinstance(candidate.get("content"), dict) else {} + # Chunk-level decoding telemetry rides the chunk's first text + # delta (doc-based; no model currently serves logprobs live). + chunk_logprobs = _gemini_token_logprobs(candidate.get("logprobsResult")) + for idx, part in enumerate(content.get("parts", []) or []): + if not isinstance(part, dict): + continue + if part.get("thought") and "text" in part: + yielded_delta = True + yield StreamDeltaEvent(delta=ThinkingDelta(text=str(part.get("text") or ""), part_index=idx)) + if part.get("thoughtSignature") is not None: + yield StreamDeltaEvent( + delta=ContinuationDelta( + provider="gemini", + kind="thought_signature", + data={"value": str(part.get("thoughtSignature"))}, + part_index=idx, + ) + ) + elif "text" in part: + yielded_delta = True + yield StreamDeltaEvent(delta=TextDelta(text=str(part.get("text") or ""), part_index=idx, logprobs=chunk_logprobs)) + chunk_logprobs = () + if part.get("thoughtSignature") is not None: + # Same replay state on an answer-text part (3.x). + yield StreamDeltaEvent( + delta=ContinuationDelta( + provider="gemini", + kind="thought_signature", + data={"value": str(part.get("thoughtSignature"))}, + part_index=idx, + ) + ) + elif "functionCall" in part and isinstance(part["functionCall"], dict): + fc = part["functionCall"] + saw_tool = True + yielded_delta = True + yield StreamDeltaEvent(delta=ToolCallDelta( + input=json.dumps(fc.get("args", {}), separators=(",", ":")), + part_index=idx, + id=str(fc.get("id") or "") or None, + name=str(fc.get("name") or "") or None, + )) + thought_signature = part.get("thoughtSignature") or fc.get("thoughtSignature") + if thought_signature is not None: + yield StreamDeltaEvent( + delta=ContinuationDelta( + provider="gemini", + kind="thought_signature", + data={"value": str(thought_signature)}, + part_index=idx, + ) + ) + elif "inlineData" in part and isinstance(part["inlineData"], dict): + inline = part["inlineData"] + mime = str(inline.get("mimeType") or "application/octet-stream") + data = str(inline.get("data") or "") + if mime.startswith("audio/"): + yielded_delta = True + yield StreamDeltaEvent(delta=AudioDelta(data=data, part_index=idx, media_type=mime)) + elif mime.startswith("image/"): + yielded_delta = True + yield StreamDeltaEvent(delta=ImageDelta(data=data, part_index=idx, media_type=mime)) + finish = candidate.get("finishReason") + + if finish: + yield StreamEndEvent(finish_reason=_finish_reason(finish, has_tool_call=saw_tool), usage=self._usage_from_payload(payload), provider_data=payload) + elif not yielded_delta and "usageMetadata" in payload: + yield StreamEndEvent(finish_reason="stop", usage=self._usage_from_payload(payload), provider_data=payload) + + def _usage_from_payload(self, payload: dict[str, Any]) -> Usage: + return _gemini_usage(payload.get("usageMetadata"), output_keys=("candidatesTokenCount", "responseTokenCount")) + + # ─── Streaming via Gemini Live for live models ────────────────── + + def stream(self, request: Request) -> Iterator[StreamEvent]: + wire = self._wire_request(request) + if self._should_use_live_completion(wire): + yield from self._stream_via_live_completion(wire) + return + yield from BaseProviderLM.stream(self, request) + + def _should_use_live_completion(self, request: Request) -> bool: + extensions = request.config.extensions or {} + transport_mode = str(extensions.get("transport") or "").lower() + if transport_mode in {"live", "websocket", "ws"}: + return True + model_name = request.model.lower() + return "-live" in model_name or model_name.endswith("live") + + @staticmethod + def _is_audio_native_live_model(model: str) -> bool: + lowered = model.lower() + return "live-preview" in lowered or "native-audio" in lowered + + @staticmethod + def _wav_to_pcm(data: bytes) -> tuple[bytes, int]: + if len(data) >= 44 and data[:4] == b"RIFF" and data[8:12] == b"WAVE": + sample_rate = struct.unpack_from(" Iterator[StreamEvent]: + ws = self._live_connect(self._live_url()) + saw_tool_call = False + audio_native = self._is_audio_native_live_model(request.model) + acc_usage = Usage() + try: + setup_payload = self._live_setup_payload_from_request(request) + setup_inner = setup_payload.setdefault("setup", {}) + if not audio_native: + setup_inner.setdefault("generationConfig", {}).setdefault("responseModalities", ["TEXT"]) + ws.send(json.dumps(setup_payload)) + self._wait_for_setup_complete(ws) + for msg in self._live_client_content_payload_from_request(request): + ws.send(json.dumps(msg)) + + yield StreamStartEvent(model=request.model) + while True: + raw = ws.recv() + events, turn_complete, usage = self._decode_live_completion_stream_events(raw) + acc_usage = Usage( + input_tokens=max(acc_usage.input_tokens, usage.input_tokens), + output_tokens=max(acc_usage.output_tokens, usage.output_tokens), + total_tokens=max(acc_usage.total_tokens or 0, usage.total_tokens or 0), + ) + for event in events: + if event.type == "delta" and isinstance(event.delta, ToolCallDelta): + saw_tool_call = True + if event.type == "error": + yield event + return + yield event + if turn_complete: + yield StreamEndEvent(finish_reason="tool_call" if saw_tool_call else "stop", usage=acc_usage) + return + finally: + try: + ws.close() + except Exception: + pass + + def _live_setup_payload_from_request(self, request: Request) -> dict[str, Any]: + extensions = dict(request.config.extensions or {}) + extensions.pop("transport", None) + extensions.pop("prompt_caching", None) + extensions.pop("output", None) + config = LiveConfig(model=request.model, system=request.system, tools=request.tools, extensions=extensions or None) + payload = self._live_setup_payload(config) + output = (request.config.extensions or {}).get("output") + audio_native = self._is_audio_native_live_model(request.model) + if output == "audio" or audio_native: + setup = payload.setdefault("setup", {}) + setup.setdefault("generationConfig", {})["responseModalities"] = ["AUDIO"] + if output != "audio": + setup["outputAudioTranscription"] = {} + has_media = any(isinstance(p, (AudioPart, VideoPart)) for m in request.messages for p in m.parts) + if has_media: + setup.setdefault("realtimeInputConfig", {}).setdefault("automaticActivityDetection", {})["disabled"] = True + elif output == "image": + payload.setdefault("setup", {}).setdefault("generationConfig", {})["responseModalities"] = ["IMAGE"] + return payload + + def _live_client_content_payload_from_request(self, request: Request) -> list[dict[str, Any]]: + if self._is_audio_native_live_model(request.model): + return self._build_realtime_input_payloads(request) + if len(request.messages) == 1 and request.messages[0].role == "user" and all(isinstance(p, TextPart) for p in request.messages[0].parts): + return [{"realtimeInput": {"text": parts_to_text(request.messages[0].parts)}}] + names = self._call_names(request.messages) + return [{"clientContent": {"turns": [self._message(m, names) for m in request.messages], "turnComplete": True}}] + + def _build_realtime_input_payloads(self, request: Request) -> list[dict[str, Any]]: + text_payloads: list[dict[str, Any]] = [] + media_payloads: list[dict[str, Any]] = [] + content_parts: list[dict[str, Any]] = [] + sent_audio_or_video = False + for msg in request.messages: + for part in msg.parts: + if isinstance(part, TextPart) and part.text: + text_payloads.append({"realtimeInput": {"text": part.text}}) + elif isinstance(part, AudioPart): + if part.data is not None or part.path is not None: + mime = part.media_type or "audio/pcm" + raw = part.bytes + if "wav" in mime or "wave" in mime: + pcm, rate = self._wav_to_pcm(raw) + data = base64.b64encode(pcm).decode("ascii") + media_payloads.append({"realtimeInput": {"audio": {"mimeType": f"audio/pcm;rate={rate}", "data": data}}}) + else: + data = part.data or base64.b64encode(raw).decode("ascii") + media_payloads.append({"realtimeInput": {"audio": {"mimeType": mime, "data": data}}}) + sent_audio_or_video = True + elif isinstance(part, VideoPart): + if part.data is not None or part.path is not None: + data = part.data or base64.b64encode(part.path.read_bytes()).decode("ascii") + media_payloads.append({"realtimeInput": {"video": {"mimeType": part.media_type or "video/mp4", "data": data}}}) + sent_audio_or_video = True + elif isinstance(part, (ImagePart, DocumentPart, BinaryPart)): + content_parts.append(self._part(part)) + payloads: list[dict[str, Any]] = [] + if content_parts: + payloads.append({"clientContent": {"turns": [{"role": "user", "parts": content_parts}], "turnComplete": False}}) + payloads.extend(text_payloads + media_payloads) + if sent_audio_or_video: + payloads.insert(0, {"realtimeInput": {"activityStart": {}}}) + payloads.append({"realtimeInput": {"activityEnd": {}}}) + if not payloads: + payloads.append({"realtimeInput": {"text": ""}}) + return payloads + + def _decode_live_completion_stream_events(self, raw: str | bytes) -> tuple[list[StreamEvent], bool, Usage]: + try: + payload = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw) + except Exception: + return [], False, Usage() + if not isinstance(payload, dict): + return [], False, Usage() + if "error" in payload: + err = payload["error"] + provider_code = str(err.get("status") or err.get("code") or "provider") if isinstance(err, dict) else "provider" + message = str(err.get("message") or "") if isinstance(err, dict) else "" + return [StreamErrorEvent(error=self._error_detail(provider_code, message))], False, Usage() + events: list[StreamEvent] = [] + tool_call = payload.get("toolCall") + if isinstance(tool_call, dict): + for idx, fc in enumerate(tool_call.get("functionCalls") or []): + if isinstance(fc, dict): + events.append(StreamDeltaEvent(delta=ToolCallDelta(input=json.dumps(fc.get("args", {})), part_index=idx, id=str(fc.get("id") or f"fc_{idx}"), name=str(fc.get("name") or "tool")))) + server = payload.get("serverContent") + if not isinstance(server, dict): + return events, False, self._live_usage(payload, None) + model_turn = server.get("modelTurn", {}) + if isinstance(model_turn, dict): + for idx, part in enumerate(model_turn.get("parts", []) or []): + if "text" in part: + events.append(StreamDeltaEvent(delta=TextDelta(text=str(part.get("text") or ""), part_index=idx))) + elif "functionCall" in part and isinstance(part["functionCall"], dict): + fc = part["functionCall"] + events.append(StreamDeltaEvent(delta=ToolCallDelta(input=json.dumps(fc.get("args", {})), part_index=idx, id=str(fc.get("id") or "fc_0"), name=str(fc.get("name") or "tool")))) + elif "inlineData" in part and isinstance(part["inlineData"], dict): + inline = part["inlineData"] + mime = str(inline.get("mimeType") or "") + data = str(inline.get("data") or "") + if mime.startswith("audio/"): + events.append(StreamDeltaEvent(delta=AudioDelta(data=data, part_index=idx, media_type=mime))) + elif mime.startswith("image/"): + events.append(StreamDeltaEvent(delta=ImageDelta(data=data, part_index=idx, media_type=mime))) + out_tx = server.get("outputTranscription") + if isinstance(out_tx, dict) and out_tx.get("text"): + events.append(StreamDeltaEvent(delta=TextDelta(text=str(out_tx["text"])))) + return events, bool(server.get("turnComplete")), self._live_usage(payload, server) + + # ─── Live sessions ────────────────────────────────────────────── + + def live(self, config: LiveConfig): + self._require("live") + ws = self._live_connect(self._live_url()) + for frame in self._live_setup_frames(config): + ws.send(json.dumps(frame)) + self._wait_for_setup_complete(ws) + return WebSocketLiveSession( + ws=ws, + encode_event=self._live_encoder(config), + decode_event=self._decode_live_server_event, + ) + + def _live_connect(self, url: str): + connect = require_websocket_sync_connect() + return connect(url) + + # Pure pieces shared by the sync session, the native async twin, and + # the vet shim's replay_live op (uniform hook: _live_setup_frames). + + def _live_setup_frames(self, config: LiveConfig) -> list[dict[str, Any]]: + return [self._live_setup_frame(config)] + + def _live_setup_frame(self, config: LiveConfig) -> dict[str, Any]: + payload = self._live_setup_payload(config) + if self._is_audio_native_live_model(config.model): + payload.setdefault("setup", {})["outputAudioTranscription"] = {} + return payload + + def _live_encoder(self, config: LiveConfig): + audio_native = self._is_audio_native_live_model(config.model) + + def encode_event(event: LiveClientEvent) -> list[dict[str, Any]]: + if audio_native and isinstance(event, LiveClientTextEvent): + return [{"realtimeInput": {"text": event.text}}] + return self._encode_live_client_event(event) + + return encode_event + + def _live_setup_status(self, raw: str | bytes) -> bool: + """True = setupComplete, False = keep waiting; raises typed on error.""" + try: + payload = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw) + except Exception: + return False + if isinstance(payload, dict) and "setupComplete" in payload: + return True + if isinstance(payload, dict) and "error" in payload: + err = payload["error"] + msg = err.get("message", "") if isinstance(err, dict) else str(err) + provider_code = str(err.get("status") or "live_setup") if isinstance(err, dict) else "live_setup" + raise self._provider_error( + InvalidRequestError, + f"Live setup failed: {msg}", + provider_code=provider_code, + ) + return False + + def _wait_for_setup_complete(self, ws: Any) -> None: + while not self._live_setup_status(ws.recv()): + pass + + def _live_url(self) -> str: + parsed = urllib.parse.urlparse(self.base_url) + scheme = "wss" if parsed.scheme == "https" else "ws" + path = "/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent" + query = urllib.parse.urlencode({"key": resolve_credential(self.api_key)}) + return urllib.parse.urlunparse((scheme, parsed.netloc, path, "", query, "")) + + def _live_setup_payload(self, config: LiveConfig) -> dict[str, Any]: + setup: dict[str, Any] = {"model": self._model_path(config.model)} + if config.system: + setup["systemInstruction"] = {"parts": [{"text": config.system if isinstance(config.system, str) else parts_to_text(config.system)}]} + function_tools = [ + {"name": t.name, "description": t.description, "parameters": t.parameters} + for t in config.tools + if isinstance(t, FunctionTool) + ] + if function_tools: + setup["tools"] = [{"functionDeclarations": function_tools}] + generation_config: dict[str, Any] = {} + if config.output_format is not None: + generation_config["responseModalities"] = ["AUDIO"] + elif self._is_audio_native_live_model(config.model): + generation_config["responseModalities"] = ["AUDIO"] + if config.voice: + generation_config.setdefault("speechConfig", {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": config.voice}}}) + if generation_config: + setup["generationConfig"] = generation_config + if config.extensions: + setup.update(config.extensions) + return {"setup": setup} + + def _live_usage(self, payload: dict[str, Any], server: dict[str, Any] | None) -> Usage: + usage_payload = payload.get("usageMetadata") + if not isinstance(usage_payload, dict) and isinstance(server, dict): + usage_payload = server.get("usageMetadata") + return _gemini_usage(usage_payload, output_keys=("responseTokenCount", "candidatesTokenCount")) + + def _encode_live_client_event(self, event: LiveClientEvent) -> list[dict[str, Any]]: + if isinstance(event, LiveClientTurnEvent): + return [{"clientContent": {"turns": [{"role": "user", "parts": [self._part(part) for part in event.parts]}], "turnComplete": event.turn_complete}}] + if isinstance(event, LiveClientAudioEvent): + return [{"realtimeInput": {"audio": {"mimeType": event.media_type, "data": event.data}}}] + if isinstance(event, LiveClientImageEvent): + return [{"realtimeInput": {"video": {"mimeType": event.media_type, "data": event.data}}}] + if isinstance(event, LiveClientInterruptEvent): + return [{"clientContent": {"turnComplete": True}}] + if isinstance(event, LiveClientEndAudioEvent): + return [{"realtimeInput": {"audioStreamEnd": True}}] + if isinstance(event, LiveClientTextEvent): + return [{"clientContent": {"turns": [{"role": "user", "parts": [{"text": event.text}]}], "turnComplete": True}}] + if isinstance(event, LiveClientToolResultEvent): + response_parts = [{"text": parts_to_text(event.content)}] + return [{"toolResponse": {"functionResponses": [{"id": event.id, "response": {"output": response_parts}}]}}] + return [] + + def _decode_live_server_event(self, raw: str | bytes): + try: + payload = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw) + except Exception: + return [] + if not isinstance(payload, dict): + return [] + if "error" in payload: + err = payload.get("error") + provider_code = str(err.get("status") or err.get("code") or "provider") if isinstance(err, dict) else "provider" + message = str(err.get("message") or "") if isinstance(err, dict) else "" + return [LiveServerErrorEvent(error=self._error_detail(provider_code, message))] + events: list[Any] = [] + tool_call = payload.get("toolCall") + if isinstance(tool_call, dict): + for fc in tool_call.get("functionCalls") or []: + if isinstance(fc, dict): + events.append(LiveServerToolCallEvent(id=str(fc.get("id") or "fc_0"), name=str(fc.get("name") or "tool"), input=fc.get("args") if isinstance(fc.get("args"), dict) else {})) + server = payload.get("serverContent") + if not isinstance(server, dict): + return events + model_turn = server.get("modelTurn", {}) + if isinstance(model_turn, dict): + for part in model_turn.get("parts", []) or []: + if "text" in part: + events.append(LiveServerTextEvent(text=str(part.get("text") or ""))) + elif "inlineData" in part and isinstance(part["inlineData"], dict): + inline = part["inlineData"] + mime = str(inline.get("mimeType") or "") + if mime.startswith("audio/"): + events.append(LiveServerAudioEvent(data=str(inline.get("data") or ""), media_type=mime or None)) + elif "functionCall" in part and isinstance(part["functionCall"], dict): + fc = part["functionCall"] + events.append(LiveServerToolCallEvent(id=str(fc.get("id") or "fc_0"), name=str(fc.get("name") or "tool"), input=fc.get("args") if isinstance(fc.get("args"), dict) else {})) + out_tx = server.get("outputTranscription") + if isinstance(out_tx, dict) and out_tx.get("text"): + events.append(LiveServerTextEvent(text=str(out_tx["text"]))) + has_usage = isinstance(payload.get("usageMetadata"), dict) or isinstance(server.get("usageMetadata"), dict) + if has_usage and not server.get("turnComplete"): + # Usage reported on a frame that does not end the turn (an + # interrupted or tool-call frame) rides a usage event; the + # pinned Gemini transcripts report usage only on turnComplete, + # so this is the rule, not a receipt. + events.append(LiveServerUsageEvent(usage=self._live_usage(payload, server))) + if server.get("interrupted"): + events.append(LiveServerInterruptedEvent()) + if server.get("turnComplete"): + events.append(LiveServerTurnEndEvent(usage=self._live_usage(payload, server))) + return events + + # ─── Other endpoints ──────────────────────────────────────────── + + # ─── Live model listing (provisional endpoint) ────────────────────── + + def _models_request(self): + # pageSize=1000 covers the catalog in one page (53 models observed + # live 2026-08-31, no nextPageToken). + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/models", + params={"pageSize": 1000}, + headers=self._auth_headers(), + ) + + def _models_from_body(self, body: str): + data = json.loads(body) + entries = data.get("models") if isinstance(data, dict) else None + + def id_of(entry: dict) -> str | None: + # The wire name is "models/"; the usable Request.model string + # is the bare id (build_request re-prefixes via _model_path). + name = entry.get("name") + if isinstance(name, str) and name.startswith("models/"): + return name[len("models/"):] + return name if isinstance(name, str) else None + + return model_infos_from_entries( + entries, + provider=self.provider, + api_family="gemini_generate_content", + id_of=id_of, + ) + + # ─── File hooks (Files API) ───────────────────────────────── + # + # Wire shapes verified live 2026-08-31 (curl-fixtures/files-2026-08-31/): + # multipart/related upload (metadata display_name + media) so the + # filename survives the round trip; upload wraps the object in + # {"file": {...}} while get/list return it bare; list paginates with + # pageToken; download (`:download?alt=media`) EXISTS but the server + # refuses non-generated files (400, forwarded typed). + # + # FileInfo.id is the file's `uri` VERBATIM — Gemini model requests + # address files by URI (the frozen chat mapping places Part.file_id + # into fileData.fileUri), while the REST resource lives at the + # `files/` name; _file_resource() derives one from the other. + + @staticmethod + def _file_resource(file_id: str) -> str: + """`files/` resource path from a canonical id (URI or name).""" + if "://" in file_id: + tail = file_id.rstrip("/").rsplit("/files/", 1) + if len(tail) == 2 and tail[1]: + return f"files/{tail[1]}" + if file_id.startswith("files/"): + return file_id + return f"files/{file_id}" + + def _file_upload_request(self, request: FileUploadRequest) -> TransportRequest: + url = build_url(f"{self.upload_base_url.rstrip('/')}/files", request.extensions) + content_type, body = multipart_related_body( + metadata={"file": {"display_name": request.filename}}, + media_type=request.media_type, + data=request.bytes, + ) + return self._emit( + method="POST", + url=url, + headers=list(self._auth_headers({ + "X-Goog-Upload-Protocol": "multipart", + "Content-Type": content_type, + }).items()), + body=body, + ) + + def _file_info_from_body(self, body: str) -> FileInfo: + data = json.loads(body) + if isinstance(data.get("file"), dict): # upload wraps; get/list do not + return self._file_info(data["file"]) + return self._file_info(data) + + def _file_info(self, data: dict[str, Any]) -> FileInfo: + uri = data.get("uri") + name = data.get("name") + file_id = uri if isinstance(uri, str) and uri else name + if not isinstance(file_id, str) or not file_id: + raise ProviderError("gemini: file object carries no uri or name", provider=self.provider) + state = str(data.get("state") or "") + if state.endswith("PROCESSING"): + readiness = "pending" + elif state.endswith("FAILED"): + readiness = "failed" + else: # ACTIVE, absent, or unknown + readiness = "ready" + if isinstance(data.get("downloadUri"), str) and data.get("downloadUri"): + downloadable: bool | None = True + elif data.get("source") == "UPLOADED": + downloadable = False # the server's stated rule: only GENERATED files download + else: + downloadable = None + display_name = data.get("displayName") + mime = data.get("mimeType") + size_raw = data.get("sizeBytes") # the wire carries int64 as a string + try: + size_bytes = int(size_raw) if isinstance(size_raw, (str, int)) and not isinstance(size_raw, bool) else None + except ValueError: + size_bytes = None + return FileInfo( + id=file_id, + filename=display_name if isinstance(display_name, str) and display_name else None, + media_type=mime if isinstance(mime, str) and mime else None, + size_bytes=size_bytes, + created_at=iso_utc(data.get("createTime")), + expires_at=iso_utc(data.get("expirationTime")), + readiness=readiness, + downloadable=downloadable, + provider_data=data, + ) + + def _file_get_request(self, file_id: str) -> TransportRequest: + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/{path_id(self._file_resource(file_id), resource_name=True)}", + headers=self._auth_headers(), + ) + + def _file_list_request(self, limit: int, cursor: str | None) -> TransportRequest: + params: dict[str, Any] = {"pageSize": limit} + if cursor is not None: + params["pageToken"] = cursor + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/files", + params=params, headers=self._auth_headers(), + ) + + def _file_page_from_list_body(self, body: str) -> FilePage: + data = json.loads(body) + entries = data.get("files") if isinstance(data.get("files"), list) else [] + items = tuple(self._file_info(entry) for entry in entries if isinstance(entry, dict)) + cursor = data.get("nextPageToken") + return FilePage(items=items, next_cursor=cursor if isinstance(cursor, str) and cursor else None) + + def _file_delete_request(self, file_id: str) -> TransportRequest: + return self._emit( + method="DELETE", url=f"{self.base_url.rstrip('/')}/{path_id(self._file_resource(file_id), resource_name=True)}", + headers=self._auth_headers(), + ) + + def _file_download_request(self, file_id: str) -> TransportRequest: + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/{path_id(self._file_resource(file_id), resource_name=True)}:download?alt=media", + headers=self._auth_headers(), + ) + + # ─── Cache resource hooks (cachedContents; the stored tier of MAP-6) ── + # + # Wire shapes verified live 2026-09-01 (research/caching/receipts/ + # gemini__explicit-resource__*): POST /cachedContents {model, contents, + # systemInstruction?, tools?, toolConfig?, ttl "Ns", displayName?} -> + # the object with name, model, createTime, expireTime, + # usageMetadata.totalTokenCount; GET/PATCH(ttl)/DELETE by name; list + # -> {cachedContents: [...], nextPageToken}. The object pins its + # model and owns system/tools: a request may not repeat them. + # CacheInfo.id is the resource name verbatim ("cachedContents/"). + + @staticmethod + def _cache_resource(cache_id: str) -> str: + return cache_id if cache_id.startswith("cachedContents/") else f"cachedContents/{cache_id}" + + def _cache_create_request(self, prefix: Request, ttl_seconds: int | None, label: str | None) -> TransportRequest: + body: dict[str, Any] = { + "model": self._model_path(prefix.model), + "contents": [self._message(m, self._call_names(prefix.messages)) for m in prefix.messages], + } + if prefix.system: + text = prefix.system if isinstance(prefix.system, str) else parts_to_text(prefix.system) + body["systemInstruction"] = {"parts": [{"text": text}]} + if prefix.tools: + declarations = [ + {"name": t.name, "description": t.description, "parameters": t.parameters} + for t in prefix.tools if isinstance(t, FunctionTool) + ] + tools_wire: list[dict[str, Any]] = [] + if declarations: + tools_wire.append({"functionDeclarations": declarations}) + tools_wire += [_builtin_to_gemini(t) for t in prefix.tools if isinstance(t, BuiltinTool)] + body["tools"] = tools_wire + if ttl_seconds is not None: + body["ttl"] = f"{ttl_seconds}s" + if label is not None: + body["displayName"] = label + return self._emit( + method="POST", url=f"{self.base_url.rstrip('/')}/cachedContents", + headers=self._auth_headers({"Content-Type": "application/json"}), payload=body, + ) + + def _cache_info_from_body(self, body: str) -> CacheInfo: + return self._cache_info(json.loads(body)) + + def _cache_info(self, data: dict[str, Any]) -> CacheInfo: + name = data.get("name") + if not isinstance(name, str) or not name: + raise ProviderError("gemini: cache object carries no name", provider=self.provider) + model = str(data.get("model") or "") + model = model[len("models/"):] if model.startswith("models/") else model + if not model: + raise ProviderError("gemini: cache object carries no model", provider=self.provider) + usage = data.get("usageMetadata") if isinstance(data.get("usageMetadata"), dict) else {} + tokens = usage.get("totalTokenCount") + label = data.get("displayName") + return CacheInfo( + id=name, + model=model, + tokens=int(tokens) if isinstance(tokens, (int, str)) and str(tokens).isdigit() else None, + created_at=iso_utc(data.get("createTime")), + expires_at=iso_utc(data.get("expireTime")), + label=label if isinstance(label, str) and label else None, + provider_data=data, + ) + + def _cache_get_request(self, cache_id: str) -> TransportRequest: + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/{path_id(self._cache_resource(cache_id), resource_name=True)}", + headers=self._auth_headers(), + ) + + def _cache_list_request(self, limit: int, cursor: str | None) -> TransportRequest: + params: dict[str, Any] = {"pageSize": int(limit)} + if cursor is not None: + params["pageToken"] = cursor + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/cachedContents", + params=params, headers=self._auth_headers(), + ) + + def _cache_page_from_list_body(self, body: str) -> CachePage: + data = json.loads(body) + entries = data.get("cachedContents") if isinstance(data.get("cachedContents"), list) else [] + items = tuple(self._cache_info(e) for e in entries if isinstance(e, dict)) + cursor = data.get("nextPageToken") + return CachePage(items=items, next_cursor=cursor if isinstance(cursor, str) and cursor else None) + + def _cache_delete_request(self, cache_id: str) -> TransportRequest: + return self._emit( + method="DELETE", url=f"{self.base_url.rstrip('/')}/{path_id(self._cache_resource(cache_id), resource_name=True)}", + headers=self._auth_headers(), + ) + + def _cache_update_request(self, cache_id: str, ttl_seconds: int) -> TransportRequest: + return self._emit( + method="PATCH", url=f"{self.base_url.rstrip('/')}/{path_id(self._cache_resource(cache_id), resource_name=True)}", + headers=self._auth_headers({"Content-Type": "application/json"}), + payload={"ttl": f"{ttl_seconds}s"}, + ) + + # ─── Batch hooks (Batch Mode, inline requests) ─────────────────── + # + # Wire shapes verified live 2026-08-31: submit → POST + # {model}:batchGenerateContent, job id = operation "name" + # ("batches/"), inline results under + # response.inlinedResponses.inlinedResponses[] with metadata.key + # correlation, list → GET /batches?pageSize=N → {"operations": [...]}. + + def _batch_submit_request(self, request: BatchRequest, upload_body: dict[str, Any] | None) -> TransportRequest: + self._batch_preflight(request) + model = request.model or request.requests[0].model + batch: dict[str, Any] = { + "inputConfig": { + "requests": { + "requests": [ + {"request": self._payload(nested), "metadata": {"key": str(i)}} + for i, nested in enumerate(request.requests) + ] + } + }, + } + if request.label is not None: + batch["displayName"] = request.label + payload = {"batch": batch, **(request.extensions or {})} + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/{self._model_path(model)}:batchGenerateContent", + headers=self._auth_headers({"Content-Type": "application/json"}), + payload=payload, + ) + + def _batch_job_from_body(self, body: str) -> BatchJobInfo: + return self._batch_job_info(json.loads(body)) + + def _batch_job_info(self, data: dict[str, Any]) -> BatchJobInfo: + name = data.get("name") + if not isinstance(name, str) or not name: + raise ProviderError("gemini: batch operation carries no name", provider=self.provider) + metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} + label = metadata.get("displayName") + return BatchJobInfo( + id=name, + status=_gemini_batch_status(data), + label=label if isinstance(label, str) and label else None, + created_at=iso_utc(metadata.get("createTime")), + provider_data=data, + ) + + def _batch_status_request(self, batch_id: str) -> TransportRequest: + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/{path_id(batch_id, resource_name=True)}", + headers=self._auth_headers(), + ) + + def _batch_cancel_request(self, batch_id: str) -> TransportRequest: + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/{path_id(batch_id, resource_name=True)}:cancel", + headers=self._auth_headers({"Content-Type": "application/json"}), + payload={}, + ) + + def _batch_result_fetches(self, status_body: dict[str, Any]) -> tuple[TransportRequest, ...]: + # Inline submissions carry their results in the operation body. + return () + + def _batch_entries(self, status_body: dict[str, Any], fetched: tuple[str, ...]) -> tuple[BatchEntry, ...]: + response_obj = status_body.get("response") if isinstance(status_body.get("response"), dict) else {} + inlined = response_obj.get("inlinedResponses") + if isinstance(inlined, dict): + inlined = inlined.get("inlinedResponses") + entries: list[BatchEntry] = [] + for position, item in enumerate(inlined or []): + if not isinstance(item, dict): + continue + metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {} + try: + index = int(str(metadata.get("key"))) + except (TypeError, ValueError): + index = position + if isinstance(item.get("response"), dict): + body_obj = item["response"] + response = self.parse_response( + batch_entry_request(body_obj.get("modelVersion")), batch_entry_http(body_obj) + ) + entries.append(BatchEntry(index=index, outcome="succeeded", response=response)) + else: + # Per-entry failures arrive as a google.rpc.Status, not the + # HTTP error envelope; map directly. + err = item.get("error") if isinstance(item.get("error"), dict) else {} + provider_code = err.get("status") or err.get("code") + entries.append(BatchEntry( + index=index, + outcome="errored", + error=ErrorDetail( + code="provider", + message=str(err.get("message") or "batch entry errored"), + provider_code=str(provider_code) if provider_code is not None else None, + ), + )) + return tuple(sorted(entries, key=lambda e: e.index)) + + def _batch_list_request(self, limit: int) -> TransportRequest: + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/batches", + params={"pageSize": int(limit)}, + headers=self._auth_headers(), + ) + + def _batch_jobs_from_list_body(self, body: str) -> tuple[BatchJobInfo, ...]: + data = json.loads(body) + items = data.get("operations") if isinstance(data, dict) else None + return tuple(self._batch_job_info(item) for item in (items or []) if isinstance(item, dict)) + + # ─── Video generation (Veo; captured live 2026-09-01) ─────────────── + # + # predictLongRunning returns an operation name (the ticket); polling + # the operation yields done + response.generateVideoResponse with a + # file URI that is KEY-BOUND (403 without the header, verified), so + # the result step fetches the bytes — a URL the user cannot open is + # not an honest VideoPart. Listing is per model. + + def _video_submit_request(self, request: VideoGenerationRequest) -> TransportRequest: + if request.images: + # Veo's image input (instances[].image) is documented but not yet + # live-receipted; the generations-ignores-input trap on xAI made + # unverified media-input mappings a named hazard. Raise for now. + raise UnsupportedFeatureError( + "gemini: video input images are not mapped yet; " + "use extensions until the mapping is live-receipted", + provider=self.provider, + ) + instance: dict[str, Any] = {"prompt": request.prompt} + parameters: dict[str, Any] = {} + if request.seconds is not None: + parameters["durationSeconds"] = request.seconds + payload: dict[str, Any] = {"instances": [instance], **(request.extensions or {})} + if parameters: + payload.setdefault("parameters", parameters) + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/{self._model_path(request.model)}:predictLongRunning", + headers=self._auth_headers({"Content-Type": "application/json"}), + payload=payload, + ) + + def _video_job_from_body(self, body: str, video_id: "str | None" = None) -> VideoJobInfo: + return self._video_job_info(json.loads(body)) + + def _video_job_info(self, data: dict[str, Any]) -> VideoJobInfo: + name = data.get("name") + if not isinstance(name, str) or not name: + raise ProviderError("gemini: video operation carries no name", provider=self.provider) + if data.get("done") is True: + status = "failed" if isinstance(data.get("error"), dict) else "completed" + else: + # Operations expose no queued/running distinction before done. + status = "running" + return VideoJobInfo(id=name, status=status, provider_data=data) + + def _video_status_request(self, video_id: str) -> TransportRequest: + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/{path_id(video_id, resource_name=True)}", + headers=self._auth_headers({}), + ) + + def _video_result_uri(self, status_body: dict[str, Any]) -> str: + response = status_body.get("response") if isinstance(status_body.get("response"), dict) else {} + gvr = response.get("generateVideoResponse") if isinstance(response.get("generateVideoResponse"), dict) else {} + samples = gvr.get("generatedSamples") + if isinstance(samples, list) and samples: + video = samples[0].get("video") if isinstance(samples[0], dict) else None + uri = video.get("uri") if isinstance(video, dict) else None + if isinstance(uri, str) and uri: + return uri + raise ProviderError("gemini: terminal video operation carries no video uri", provider=self.provider) + + def _video_result_fetch(self, status_body: dict[str, Any]) -> TransportRequest: + return self._emit( + method="GET", + url=self._video_result_uri(status_body), + headers=self._auth_headers({}), + ) + + def _video_part(self, status_body: dict[str, Any], fetched: "HttpResponse | None") -> VideoPart: + if fetched is None: + raise ProviderError("gemini: video content fetch is required", provider=self.provider) + content_type = (fetched.header("content-type") or "").split(";", 1)[0].strip() + if not content_type: + raise ProviderError("gemini: video download carries no content-type", provider=self.provider) + return VideoPart(media_type=content_type, data=base64.b64encode(fetched.body).decode("ascii")) + + def _video_list_request(self, limit: int, model: str | None) -> TransportRequest: + if not model: + raise UnsupportedFeatureError( + "gemini: video jobs list per model — pass model= (operations live under models//operations)", + provider=self.provider, + ) + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/{self._model_path(model)}/operations", + params={"pageSize": int(limit)}, + headers=self._auth_headers({}), + ) + + def _video_jobs_from_list_body(self, body: str) -> tuple[VideoJobInfo, ...]: + data = json.loads(body) + ops = data.get("operations") if isinstance(data, dict) else None + return tuple(self._video_job_info(op) for op in (ops or []) if isinstance(op, dict)) + + # ─── Media generation (captured live 2026-09-01) ──────────────────── + # + # Gemini has no dedicated generation endpoints: image models and TTS + # models answer the ordinary generateContent call. The hooks compose + # the frozen chat mapping (build_request / parse_response), so the + # shared sync drivers and the async mirrors both work unchanged. + # Input images (edits) are ordinary parts in the same call — verified + # honored by pixel check. Image responses routinely carry narration + # text next to the image; it lands in ImageGenerationResponse.text. + + def _image_generation_lm_request(self, request: ImageGenerationRequest) -> Request: + extensions = dict(request.extensions or {}) + if request.size is not None: + generation_config = dict(extensions.get("generationConfig") or {}) + image_config = dict(generation_config.get("imageConfig") or {}) + image_config.setdefault("aspectRatio", request.size) + generation_config["imageConfig"] = image_config + extensions["generationConfig"] = generation_config + parts = (TextPart(text=request.prompt), *request.images) + return Request( + model=request.model, + messages=(Message(role="user", parts=parts),), + config=Config(extensions=extensions) if extensions else Config(), + ) + + def _image_generate_request(self, request: ImageGenerationRequest) -> TransportRequest: + return self.build_request(self._image_generation_lm_request(request), stream=False) + + def _image_generation_from_response(self, request: ImageGenerationRequest, resp: HttpResponse) -> ImageGenerationResponse: + chat = self.parse_response(self._image_generation_lm_request(request), resp) + images = tuple(part for part in chat.message.parts if isinstance(part, ImagePart)) + if not images: + raise ProviderError("gemini: model returned no image parts", provider=self.provider) + texts = [part.text for part in chat.message.parts if isinstance(part, TextPart) and part.text] + return ImageGenerationResponse( + images=images, + text="".join(texts) or None, + id=chat.id, + model=chat.model, + usage=chat.usage, + provider_data=chat.provider_data, + ) + + def _speech_generation_lm_request(self, request: SpeechGenerationRequest) -> Request: + if request.format is not None: + # No wire slot: Gemini TTS always answers PCM (captured + # audio/L16;codec=pcm;rate=24000). Raising beats dropping. + raise UnsupportedFeatureError( + "gemini: speech format cannot be chosen; the wire always returns PCM", + provider=self.provider, + ) + generation_config: dict[str, Any] = {"responseModalities": ["AUDIO"]} + if request.voice is not None: + generation_config["speechConfig"] = {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": request.voice}}} + extensions = {"generationConfig": generation_config, **(request.extensions or {})} + return Request(model=request.model, messages=(Message.user(request.prompt),), config=Config(extensions=extensions)) + + def _speech_generate_request(self, request: SpeechGenerationRequest) -> TransportRequest: + return self.build_request(self._speech_generation_lm_request(request), stream=False) + + def _speech_generation_from_response(self, request: SpeechGenerationRequest, resp: HttpResponse) -> SpeechGenerationResponse: + chat = self.parse_response(self._speech_generation_lm_request(request), resp) + audio = chat.message.first(AudioPart) + if audio is None: + raise ProviderError("gemini: model returned no audio part", provider=self.provider) + return SpeechGenerationResponse(audio=audio, id=chat.id, model=chat.model, usage=chat.usage, provider_data=chat.provider_data) diff --git a/providers/openai.py b/providers/openai.py new file mode 100644 index 0000000000..4674c5f480 --- /dev/null +++ b/providers/openai.py @@ -0,0 +1,2122 @@ +from __future__ import annotations + +from datetime import datetime + +import base64 +import json +import os +import re +import urllib.parse +from dataclasses import dataclass, field +from typing import Any, Callable, ClassVar, Iterator, Mapping + +from ..adaptation import AdaptationPolicy, adapt, check_policy +from ..errors import ( + AuthError, + BillingError, + ContextLengthError, + InvalidRequestError, + NotConfiguredError, + ProviderError, + RateLimitError, + ServerError, + TimeoutError, + UnsupportedFeatureError, + UnsupportedModelError, + canonical_error_code, + map_http_error, +) +from ..access import OPENAI_API, auth_header +from ..auth import extract_chatgpt_account_id +from ..compat import OPENAI_RESPONSES_PRESET_BASE_URLS, OpenAIResponsesCompat, preset_base_url +from ..features import ProviderManifest +from ..judgments import note_unmeasurable_probabilities, replace_text_with_data, request_judgments +from ..result import materialize_response +from ..live import WebSocketLiveSession, require_websocket_sync_connect +from ..profiles import ProviderProfile, ResolvedOpenAIResponsesCompat, resolve_openai_responses_compat +from ..sse import SSEEvent +from ..transports import TransportRequest +from ..types import ( + continuation_data, + VideoGenerationRequest, + VideoJobInfo, + VideoPart, + AudioDelta, + AudioFormat, + SpeechGenerationRequest, + SpeechGenerationResponse, + AudioPart, + BatchEntry, + BatchJobInfo, + BatchRequest, + BuiltinTool, + CitationDelta, + ContinuationDelta, + ContinuationState, + CitationPart, + ErrorDetail, + FileInfo, + FilePage, + FileUploadRequest, + FunctionTool, + ImageDelta, + ImageGenerationRequest, + ImageGenerationResponse, + ImagePart, + LiveClientAudioEvent, + LiveClientEndAudioEvent, + LiveClientEvent, + LiveClientImageEvent, + LiveClientInterruptEvent, + LiveClientTextEvent, + LiveClientToolResultEvent, + LiveClientTurnEvent, + LiveConfig, + LiveServerAudioEvent, + LiveServerErrorEvent, + LiveServerInterruptedEvent, + LiveServerTextEvent, + LiveServerToolCallDeltaEvent, + LiveServerToolCallEvent, + LiveServerTurnEndEvent, + LiveServerUsageEvent, + Message, + RefusalPart, + Request, + Response, + StreamDeltaEvent, + StreamEndEvent, + StreamErrorEvent, + StreamEvent, + StreamStartEvent, + TextDelta, + TextPart, + ThinkingDelta, + ThinkingPart, + ToolCallDelta, + ToolCallPart, + ToolResultPart, + Usage, +) +from .base import ( + BaseProviderLM, + Credential, + HttpResponse, + SyncTransport, + batch_entry_http, + batch_entry_request, + default_transport, + resolve_credential_value, +) +from .common import ( + tool_result_error_text, + tool_result_output_openai, + iso_utc, + model_infos_from_entries, + multipart_form_body, + openai_file_readiness, + openai_token_logprobs, + parse_json_object, + part_to_openai_input, + path_id, + unnamed_tool_call_error, + parts_to_text, +) + +# Canonical builtin tool name → OpenAI Responses API tool type +_OPENAI_BUILTIN_MAP: dict[str, str] = { + "web_search": "web_search_preview", + "code_execution": "code_interpreter", + "file_search": "file_search", + "computer_use": "computer_use_preview", +} + +# Per compat `builtin_tools` value: canonical name → server tool type. A +# name absent from the table goes out verbatim and the server answers for +# itself (Meta and Moonshot: HTTP 400, live 2026-09-03 — loud, so no +# pre-wire refusal). "verbatim" is the empty table: the canonical name is +# the wire type (`web_search` on both servers' schemas). +_BUILTIN_MAPS: dict[str, dict[str, str]] = { + "openai": _OPENAI_BUILTIN_MAP, + "verbatim": {}, +} + + +def _builtin_type(tool: BuiltinTool, compat: "ResolvedOpenAIResponsesCompat") -> str: + return _BUILTIN_MAPS[compat.builtin_tools].get(tool.name, tool.name) + +OPENAI_PROVIDER_EXECUTED_ITEMS = { + "web_search_call", + "file_search_call", + "code_interpreter_call", + "computer_call", + "computer_use_call", +} + + +def _attach_unmapped(provider_data: dict[str, Any], unmapped: list[dict[str, str]]) -> dict[str, Any]: + if not unmapped: + return provider_data + out = dict(provider_data) + out["_lm15_unmapped"] = unmapped + return out + + +_GPT_VERSION_RE = re.compile(r"^gpt-(\d+)\.(\d+)") + + +def openai_model_has_cache_options(model: str) -> bool: + """True for the GPT-5.6-and-later model class (MAP-6, mode="off"). + + The off switch for cache WRITES is ``prompt_cache_options`` and only + that class accepts it (older models reject it with HTTP 400, and their + writes are free anyway). Deciding client-side needs a model check — + a table that rots. Stated trade-off: a future family whose name does + not match ``gpt-.`` keeps the provider's implicit writes + on ``mode="off"``; ``extensions={"prompt_cache_options": ...}`` is the + override. + """ + m = _GPT_VERSION_RE.match(model.lower()) + return bool(m) and (int(m.group(1)), int(m.group(2))) >= (5, 6) + + +def _cache_breakpoint_index(request: Request, cache_control: str) -> int | None: + """Message index that carries the explicit prompt-cache breakpoint. + + ``CacheConfig.prefix_until_index`` marks the end of the reusable prefix. + Both OpenAI dialects place ``prompt_cache_breakpoint`` on a text + content block (gpt-5.6+); the mapping is active only when the compat + policy names OpenAI's cache control (compat servers that declare + ``cache_control="none"`` get nothing, as for prompt_cache_key). The + index is clamped to the last message, the Anthropic adapter's + precedent. ``prefix="history"`` sends nothing: OpenAI's implicit + trailing breakpoint already marks the latest message (MAP-6 A2). + ``prefix="stable"`` is handled by the system-message rendering, not + here. + """ + cache_cfg = request.config.cache + if cache_cfg is None or cache_cfg.mode == "off" or cache_cfg.prefix_until_index is None: + return None + if cache_control != "openai": + return None + asked = min(cache_cfg.prefix_until_index, len(request.messages) - 1) + # MAP-13: the wire carries the mark on a text block of a user/developer + # message only. A mark asked for elsewhere walks back to the nearest + # eligible message ("cache up to here" — the nearest boundary before + # "here" is the obvious answer); with none, the mark is dropped and + # implicit caching still applies. + for index in range(asked, -1, -1): + msg = request.messages[index] + if msg.role in ("assistant", "tool") or not msg.parts or not isinstance(msg.parts[-1], TextPart): + continue + if index != asked: + adapt("config.cache.prefix_until_index", "substituted", + f"message {asked} is a {request.messages[asked].role} message or does not end with " + "text; the Responses wire marks text blocks of user/developer messages only, so the " + "mark moved to the nearest eligible message before it", + asked=asked, applied=index) + return index + adapt("config.cache.prefix_until_index", "dropped", + f"no user/developer message ending with text at or before message {asked}; the Responses " + "wire marks text blocks only (implicit caching still applies)", + asked=asked) + return None + + +def _has_explicit_breakpoint(request: Request, cache_control: str, *, breakpoint_index: int | None = None) -> bool: + """True when this request places a prompt_cache_breakpoint (prefix="stable" + or prefix_until_index) — the cases where explicit mode belongs with it. + ``breakpoint_index`` is the build's one computation of the index (it + records an adaptation when the mark moves; computing it twice recorded + it twice — greptile on dspy#10409).""" + # prefix="stable" places its mark on the system prompt; with no system + # there is no mark, and explicit mode with no mark would cache nothing. + return breakpoint_index is not None or ( + _cache_stable_prefix(request, cache_control) and bool(request.system) + ) + + +def _cache_stable_prefix(request: Request, cache_control: str) -> bool: + """``prefix="stable"``: mark the end of system + tools (MAP-6 A2).""" + cache_cfg = request.config.cache + return ( + cache_cfg is not None and cache_cfg.mode != "off" and cache_cfg.prefix == "stable" + and cache_control == "openai" + ) + + +def _cache_common_payload(request: Request, payload: dict, cache_control: str, provider: str, *, breakpoint_index: int | None = None) -> None: + """Shared MAP-6 fields for both OpenAI dialects: off switch, key, retention. + + ``cache_control="openai_implicit"`` forwards the key and the retention + hint and nothing else: no off switch (the server has none; MAP-6 A2 + treats an explicit CacheConfig on an implicit-only server as accepted, + not an error) and no breakpoint mark (an undocumented field the server + swallows silently — Meta, live 2026-09-03). + """ + cache_cfg = request.config.cache + if cache_cfg is None or cache_control not in ("openai", "openai_implicit"): + return + if cache_control == "openai_implicit": + if cache_cfg.mode != "off": + if cache_cfg.key: + payload["prompt_cache_key"] = cache_cfg.key + if cache_cfg.retention == "long": + payload["prompt_cache_retention"] = "24h" + if cache_cfg.resource is not None: + raise UnsupportedFeatureError( + f"{provider}: cache.resource is not supported — this provider has no stored-cache " + "tier; it caches every prompt prefix automatically", + provider=provider, feature="config.cache.resource", + ) + return + if cache_cfg.mode == "off": + # Option 2 (ratified 2026-09-01): the real off switch where the + # model class has one; nothing where writes are free anyway. + if openai_model_has_cache_options(request.model): + payload["prompt_cache_options"] = {"mode": "explicit"} + return + if cache_cfg.key: + payload["prompt_cache_key"] = cache_cfg.key + if cache_cfg.retention == "long": + # Every OpenAI model class takes prompt_cache_retention="24h". The + # gpt-5.6 class used to RAISE here on a doc line about + # prompt_cache_options.ttl (30m only) — a different field. Live + # 2026-09-02 (review probe 2): gpt-5.6-sol answers 200 and echoes + # prompt_cache_retention: "24h"; every pinned 5.6 body already + # echoes 24h as its default. Sending it is honest and harmless. + payload["prompt_cache_retention"] = "24h" + if openai_model_has_cache_options(request.model) and _has_explicit_breakpoint(request, cache_control, breakpoint_index=breakpoint_index): + # A placed breakpoint means "cache up to here". Without explicit + # mode the 5.6 class also writes the volatile suffix at 1.25x on + # every warm call (pinned: openai.prompt_cache_breakpoint wrote 18 + # after reading 3066). With mode=explicit the warm call writes 0 + # (review probe 3, 2026-09-02). The mark and the mode go together. + payload["prompt_cache_options"] = {"mode": "explicit"} + if cache_cfg.resource is not None: + raise UnsupportedFeatureError( + f"{provider}: cache.resource is not supported — this provider has no stored-cache " + "tier; it caches by marks on blocks (prefix / prefix_until_index) and automatically", + provider=provider, feature="config.cache.resource", + ) + + +def _breakpoint_unsupported(provider: str, index: int, role: str) -> UnsupportedFeatureError: + return UnsupportedFeatureError( + f"{provider}: cache.prefix_until_index={index} points at a {role} message " + "whose last block is not text — the wire carries prompt_cache_breakpoint " + "on text input blocks only. Point the prefix at a user/developer message " + "that ends with text, or omit prefix_until_index (implicit caching still " + "applies).", + provider=provider, + ) + + +def _record_unmapped(unmapped: list[dict[str, str]], path: str, typ: Any) -> None: + unmapped.append({"path": path, "type": str(typ or "")}) + + +def _builtin_to_openai(tool: BuiltinTool, compat: "ResolvedOpenAIResponsesCompat") -> dict[str, Any]: + out: dict[str, Any] = {"type": _builtin_type(tool, compat)} + if tool.config: + out.update(tool.config) + return out + + +def _response_format_to_openai_text(format_config: dict[str, Any]) -> dict[str, Any]: + """Canonical response_format (INV-050) -> OpenAI Responses `text` config.""" + if format_config["type"] == "json_object": + return {"format": {"type": "json_object"}} + fmt: dict[str, Any] = {"type": "json_schema", "name": format_config.get("name") or "response", "schema": format_config["schema"]} + if "strict" in format_config: + fmt["strict"] = format_config["strict"] + return {"format": fmt} + + +def _finish_from_status(data: dict[str, Any], *, has_tool_call: bool = False) -> str: + if has_tool_call: + return "tool_call" + status = str(data.get("status") or "").lower() + incomplete = data.get("incomplete_details") or {} + reason = str(incomplete.get("reason") or "").lower() if isinstance(incomplete, dict) else "" + if status == "incomplete" and "token" in reason: + return "length" + if "content_filter" in reason or "safety" in reason: + return "content_filter" + return "stop" + + +def _openai_batch_status(status: str) -> str: + """Map an OpenAI Batch status to the canonical BatchStatus.""" + status = status.lower() + if status in {"completed", "failed", "cancelled", "expired"}: + return status + if status in {"cancelling", "canceling"}: + return "cancelling" + if status in {"in_progress", "finalizing"}: + return "running" + return "queued" # validating / queued / anything pre-run + + +def _str_or_none(value: Any) -> str | None: + if value is None or value == "": + return None + return str(value) + + +def _int_or_none(value: Any) -> int | None: + if isinstance(value, bool) or value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _annotation_text(annotation: dict[str, Any], source_text: str | None) -> str | None: + for key in ("text", "snippet", "cited_text", "quote"): + text = _str_or_none(annotation.get(key)) + if text is not None: + return text + + start = _int_or_none(annotation.get("start_index")) + end = _int_or_none(annotation.get("end_index")) + if source_text is not None and start is not None and end is not None: + if 0 <= start < end <= len(source_text): + return source_text[start:end] + return None + + +def _citation_from_openai_annotation(annotation: dict[str, Any], source_text: str | None) -> CitationPart | None: + url = _str_or_none(annotation.get("url") or annotation.get("uri")) + title = _str_or_none( + annotation.get("title") + or annotation.get("filename") + or annotation.get("file_id") + ) + text = _annotation_text(annotation, source_text) + if url is None and title is None and text is None: + return None + return CitationPart(url=url, title=title, text=text) + + +def _citation_delta_from_openai_annotation( + annotation: dict[str, Any], + *, + part_index: int, + source_text: str | None = None, +) -> CitationDelta | None: + citation = _citation_from_openai_annotation(annotation, source_text) + if citation is None: + return None + return CitationDelta( + text=citation.text, + url=citation.url, + title=citation.title, + part_index=part_index, + ) + + +_DEFAULT_BASE_URL = "https://api.openai.com/v1" +CODEX_BACKEND = "chatgpt-codex" + + +def _is_codex(lm: BaseProviderLM) -> bool: + # A function, not a method: OpenAIChatLM borrows normalize_error. + return lm.access.backend == CODEX_BACKEND +MODEL_LIST_HINT = "List the models your subscription accepts: call .list_models() on this client." + + +def _live_usage_from_response(response: dict[str, Any]) -> Usage | None: + """Usage from a Realtime ``response.done`` payload, or None when absent.""" + usage_data = response.get("usage") if isinstance(response, dict) else None + if not isinstance(usage_data, dict): + return None + u_in = usage_data.get("input_token_details") or usage_data.get("input_tokens_details") or {} + u_out = usage_data.get("output_token_details") or usage_data.get("output_tokens_details") or {} + return Usage( + input_tokens=usage_data.get("input_tokens"), + output_tokens=usage_data.get("output_tokens"), + total_tokens=usage_data.get("total_tokens"), + reasoning_tokens=u_out.get("reasoning_tokens"), + cache_read_tokens=u_in.get("cached_tokens"), + cache_write_tokens=u_in.get("cache_write_tokens"), + input_audio_tokens=u_in.get("audio_tokens"), + output_audio_tokens=u_out.get("audio_tokens"), + ) + + +@dataclass(slots=True) +class OpenAILM(BaseProviderLM): + """OpenAI Responses dialect, bound to an access policy. + + ``access`` defaults to the API-key policy (``lm15.access.OPENAI_API``); + ``lm15.access.OPENAI_CODEX`` binds the same dialect to the ChatGPT Codex + backend on a local Codex CLI login (``OpenAICodexLM`` is that binding + under a name). Policy consult points: the auth header and static + headers, the ``chatgpt-account-id`` header when the credential carries + an account, the login hint on errors, the endpoint surfaces, and the + ``backend`` switch at four stated places — payload defaults + (instructions prefix, ``store: false``, streaming-only, no max-token + knob), streaming-first ``complete``, the ``{"detail": ...}`` error + envelope, and the ``/models`` endpoint shape. + """ + + api_key: Credential | None = field(default=None, repr=False) + transport: SyncTransport = field(default_factory=default_transport) + base_url: str = _DEFAULT_BASE_URL + profile: ProviderProfile | None = None + # An OpenAIResponsesCompat, a preset name (``"openai"``, ``"meta"``, + # ``"openrouter"``, …) or None. Same contract as the chat and + # Anthropic dialects: a preset name also supplies that server's default + # base_url; an explicit non-default base_url argument always wins. A + # profile and request extensions still layer on top (lm15.profiles). + compat: OpenAIResponsesCompat | str | None = field(default=None, kw_only=True) + access: ProviderManifest | None = field(default=None, repr=False) + credentials_path: "str | os.PathLike[str] | None" = field(default=None, repr=False) + settings: "Mapping[str, str] | None" = field(default=None, kw_only=True) + # A named cloud identity ("platform", "workload", "environment", "cli"; + # AUTH-1) on a cloud door, instead of api_key=. + credential: str | None = field(default=None, kw_only=True) + clock: "Callable[[], datetime] | None" = field(default=None, repr=False, kw_only=True) + account_id: str | None = None + + # MAP-13 policy: "note" (adapt and record), "silent", or "refuse". + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + provider: str = field(default="openai", init=False) + manifest: ClassVar[ProviderManifest] = OPENAI_API + _compat_base: OpenAIResponsesCompat | None = field(init=False, repr=False, default=None) + + def __post_init__(self) -> None: + check_policy(self.adaptations) + if self.profile is not None: + import warnings + + warnings.warn( + "OpenAILM(profile=...) is deprecated and will be removed in lm15 1.0.0: pass compat= " + "(and base_url=) instead; per-request policy goes in Config.extensions['openai_responses_compat']", + DeprecationWarning, + stacklevel=3, + ) + self._bind_access(self.access, credentials_path=self.credentials_path, default_base_url=_DEFAULT_BASE_URL, settings=self.settings, + credential=self.credential) + compat = self.compat if self.compat is not None else self._registry_compat() + if isinstance(compat, str): + self._compat_base = OpenAIResponsesCompat.preset(compat) + if self.base_url == _DEFAULT_BASE_URL: + self.base_url = preset_base_url( + OPENAI_RESPONSES_PRESET_BASE_URLS, compat, dialect="Responses", default_preset="openai" + ) + elif isinstance(compat, OpenAIResponsesCompat): + self._compat_base = compat + elif compat is not None: + raise TypeError("OpenAILM.compat must be an OpenAIResponsesCompat, a preset name, or None") + if self.access.backend == CODEX_BACKEND: + if not self.account_id and isinstance(self.api_key, str): + self.account_id = extract_chatgpt_account_id(self.api_key) + if not self.account_id: + raise NotConfiguredError( + "No ChatGPT account id found in the Codex OAuth token.", + provider=self.provider, + credential_hint=self.access.login_hint, + ) + + @property + def _codex(self) -> bool: + return _is_codex(self) + + _response_error_code_map: ClassVar[dict[str, type[ProviderError]]] = { + "server_error": ServerError, + "rate_limit_exceeded": RateLimitError, + # Azure documents these on Responses error frames even under HTTP 200. + "no_capacity": RateLimitError, + "too_many_requests": RateLimitError, + "invalid_prompt": InvalidRequestError, + "vector_store_timeout": TimeoutError, + "invalid_image": InvalidRequestError, + "invalid_image_format": InvalidRequestError, + "invalid_base64_image": InvalidRequestError, + "invalid_image_url": InvalidRequestError, + "image_too_large": InvalidRequestError, + "image_too_small": InvalidRequestError, + "image_parse_error": InvalidRequestError, + "image_content_policy_violation": InvalidRequestError, + "invalid_image_mode": InvalidRequestError, + "image_file_too_large": InvalidRequestError, + "unsupported_image_media_type": InvalidRequestError, + "empty_image_file": InvalidRequestError, + "failed_to_download_image": InvalidRequestError, + "image_file_not_found": InvalidRequestError, + "model_not_found": UnsupportedModelError, + "model_not_available": UnsupportedModelError, + "unsupported_model": UnsupportedModelError, + # Azure OpenAI: the model string IS the deployment name, so an unknown + # deployment is an unknown model (HTTP 404, "The API deployment for + # this resource does not exist"; live 2026-09-04, + # lm15-contract/errors/cases/azure.json). + "DeploymentNotFound": UnsupportedModelError, + } + + _model_error_codes: ClassVar[frozenset[str]] = frozenset( + {"model_not_found", "model_not_available", "unsupported_model", "DeploymentNotFound"} + ) + + _stream_error_code_map: ClassVar[dict[str, type[ProviderError]]] = { + **_response_error_code_map, + "context_length_exceeded": ContextLengthError, + "invalid_api_key": AuthError, + "insufficient_quota": BillingError, + "1113": BillingError, # Z.AI insufficient balance (docs.z.ai api-code.md) + "exceeded_current_quota_error": BillingError, # Moonshot: balance/quota, rides HTTP 429 (platform.kimi.ai errors.md) + "authentication_error": AuthError, + "rate_limit_error": RateLimitError, + } + + @classmethod + def from_profile( + cls, + *, + api_key: Credential, + profile: ProviderProfile, + transport: SyncTransport | None = None, + ) -> "OpenAILM": + """DEPRECATED (1.0.0rc2; removed in 1.0.0): ``OpenAILM(api_key=..., compat=..., base_url=...)``.""" + import warnings + + warnings.warn( + "OpenAILM.from_profile is deprecated and will be removed in lm15 1.0.0: " + "use OpenAILM(api_key=..., compat=, base_url=...)", + DeprecationWarning, + stacklevel=2, + ) + endpoint = profile.endpoint("inference") + base_url = endpoint.base_url if endpoint and endpoint.base_url else "https://api.openai.com/v1" + return cls( + api_key=api_key, + transport=transport or default_transport(), + base_url=base_url, + profile=profile, + ) + + def _headers(self, content_type: str = "application/json") -> dict[str, str]: + headers: dict[str, str] = {} # auth is applied once, in _emit (AUTH-2) + headers["Content-Type"] = content_type + if self._codex and self.account_id: + headers["chatgpt-account-id"] = self.account_id + for key, static in self.access.headers: + headers[key] = static + return headers + + @staticmethod + def _is_model_error(message: str, *codes: str) -> bool: + lowered = " ".join(value for value in (message, *codes) if value).lower() + return "model" in lowered and any( + marker in lowered + for marker in ( + "not found", + "does not exist", + "not exist", + "not supported", + "unsupported", + "not available", + "unknown", + ) + ) + + def _response_error(self, code: str, message: str) -> ProviderError: + cls = self._response_error_code_map.get(code, ServerError) + msg = message or code or "provider error" + return self._provider_error(cls, msg, provider_code=code or None) + + def _error_detail(self, provider_code: str, message: str) -> ErrorDetail: + cls = self._stream_error_code_map.get(provider_code, ProviderError) + return ErrorDetail( + code=canonical_error_code(cls), + message=message or provider_code or "provider error", + provider_code=provider_code or "provider", + ) + + def normalize_error(self, status: int, body: str) -> ProviderError: + """Extract message from OpenAI error shape.""" + if _is_codex(self): + # The ChatGPT Codex backend does not always use the OpenAI error + # envelope ({"error": {...}}); rejections arrive as + # {"detail": "..."} (e.g. an unknown model slug -> HTTP 400 with + # a plain-text reason). Recover and classify that first. + detail_error = self._normalize_detail_error(status, body) + if detail_error is not None: + return detail_error + try: + data = json.loads(body) + err = data.get("error", {}) if isinstance(data, dict) else {} + msg = err.get("message", "") if isinstance(err, dict) else str(err) + code = str(err.get("code") or "") if isinstance(err, dict) else "" + err_type = str(err.get("type") or "") if isinstance(err, dict) else "" + + provider_code = code or err_type or None + + if code == "context_length_exceeded": + return self._provider_error( + ContextLengthError, + msg, + status=status, + provider_code=provider_code, + ) + if code in self._model_error_codes or ( + status == 404 and self._is_model_error(msg, code, err_type) + ): + return self._provider_error( + UnsupportedModelError, + msg, + status=status, + provider_code=provider_code, + ) + # Billing before rate-limit: both can ride HTTP 429, and only one + # is retryable. "insufficient_quota" is OpenAI's spelling; "1113" + # is Z.AI's ("Insufficient balance or no resource package", + # docs.z.ai api-code.md; observed live 2026-09-03 as 429); + # "exceeded_current_quota_error" is Moonshot's type for an + # insufficient balance or a disabled account, also HTTP 429 + # (platform.kimi.ai errors.md — documentation-evidenced: a funded + # account cannot trigger it on purpose). + if code in {"insufficient_quota", "1113"} or err_type in {"insufficient_quota", "exceeded_current_quota_error"}: + return self._provider_error( + BillingError, + msg, + status=status, + provider_code=provider_code, + ) + if code == "invalid_api_key" or err_type == "authentication_error": + return self._provider_error( + AuthError, + msg, + status=status, + provider_code=provider_code, + ) + if code == "rate_limit_exceeded" or err_type == "rate_limit_error": + return self._provider_error( + RateLimitError, + msg, + status=status, + provider_code=provider_code, + ) + if code and code not in msg: + msg = f"{msg} ({code})" + except Exception: + msg = body.strip()[:500] or f"HTTP {status}" + provider_code = None + return self._with_login_hint(map_http_error( + status, + msg, + provider=self.provider, + env_keys=self.access.env_keys, + provider_code=provider_code, + )) + + def _normalize_detail_error(self, status: int, body: str) -> ProviderError | None: + try: + data = json.loads(body) + except ValueError: + return None + if not isinstance(data, dict): + return None + detail = data.get("detail") + if not isinstance(detail, str) or not detail.strip(): + return None + detail = detail.strip() + if self._is_model_error(detail): + return self._provider_error(UnsupportedModelError, f"{detail}\n{MODEL_LIST_HINT}", status=status) + return self._with_login_hint(map_http_error(status, detail, provider=self.provider)) + + # ─── Request serialization ────────────────────────────────────── + + def _compat(self, request: Request) -> ResolvedOpenAIResponsesCompat: + return resolve_openai_responses_compat( + base_url=self.base_url, + model=request.model, + profile=self.profile, + request_extensions=request.config.extensions, + base=self._compat_base, + ) + + def _build_input( + self, + messages: tuple[Message, ...], + compat: ResolvedOpenAIResponsesCompat, + *, + breakpoint_index: int | None = None, + ) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for msg_index, msg in enumerate(messages): + if msg_index == breakpoint_index and msg.role in ("assistant", "tool"): + raise _breakpoint_unsupported(self.provider, msg_index, msg.role) + if msg.role == "tool": + for part in msg.parts: + if isinstance(part, ToolResultPart): + # MAP-10: text-only → string; media → the documented + # input_text/input_image/input_file array; a part + # the preset does not admit raised before this line. + item = { + "type": "function_call_output", + "call_id": part.id, + "output": tool_result_output_openai(self.provider, part, compat.tool_result_media), + } + if compat.tool_result_name == "include" and part.name: + item["name"] = part.name + items.append(item) + continue + + if msg.role == "assistant": + content_parts = [] + for part in msg.parts: + if isinstance(part, TextPart): + content_parts.append({"type": "output_text", "text": part.text}) + elif isinstance(part, RefusalPart): + content_parts.append({"type": "refusal", "refusal": part.text}) + elif isinstance(part, ThinkingPart): + state = continuation_data(part, "openai", "reasoning_item") + if state: + # Native replay (MAP-7 rule 8): the reasoning item + # goes back as its own input item, before the + # message it preceded. + item = {"type": "reasoning", **{k: v for k, v in state.items() if k in ("id", "encrypted_content")}} + # `summary` is required on a replayed item, even + # empty (HTTP 400 "Missing required parameter: + # input[1].summary", live 2026-09-02). + item["summary"] = [{"type": "summary_text", "text": part.text}] if part.text else [] + items.append(item) + elif part.text: + # No native state: replay as assistant text + # (decision G, 2026-09-01) rather than drop it. + content_parts.append({"type": "output_text", "text": part.text}) + else: + content_parts = [ + part_to_openai_input(part, provider=self.provider) + for part in msg.parts + if not isinstance(part, (ToolCallPart, ToolResultPart)) + ] + if msg_index == breakpoint_index: + # CacheConfig.prefix_until_index -> an explicit prompt-cache + # breakpoint on the last text block of that message (the + # Anthropic cache_control precedent). The Responses wire + # carries prompt_cache_breakpoint on input_text blocks only + # (gpt-5.6+, verified live 2026-09-01: cache_write_tokens on + # the first call, cached_tokens on the next; pre-5.6 models + # reject with HTTP 400 — that loud failure is the contract). + if not content_parts or content_parts[-1].get("type") != "input_text": + raise _breakpoint_unsupported(self.provider, msg_index, msg.role) + content_parts[-1]["prompt_cache_breakpoint"] = {"mode": "explicit"} + if content_parts: + role = msg.role + if role == "developer": + role = compat.developer_role + item = {"role": role, "content": content_parts} + if ( + compat.commentary_phase == "tag" + and msg.role == "assistant" + and any(isinstance(p, ToolCallPart) for p in msg.parts) + ): + # Assistant text that precedes a function_call in the same + # turn is "commentary" on this server (Meta, + # protocols--responses.md § Message phase); the server + # stamped it on the way out and asks for it back. + item["phase"] = "commentary" + items.append(item) + + for part in msg.parts: + if isinstance(part, ToolCallPart): + items.append( + { + "type": "function_call", + "call_id": part.id, + "name": part.name, + "arguments": json.dumps(part.input, separators=(",", ":")), + } + ) + return items + + def _tool_choice_payload(self, request: Request, compat: "ResolvedOpenAIResponsesCompat") -> Any: + tc = request.config.tool_choice + if tc is None: + return None + if tc.mode == "none": + return "none" + if tc.allowed: + # Resolve names against Request.tools (INV-031 guarantees + # presence) and emit the kind-correct wire form. Verified live + # 2026-09-01: {"type": "web_search_preview"} forcing and mixed + # allowed_tools both accepted. + by_name = {t.name: t for t in request.tools} + entries = [by_name[name] for name in tc.allowed] + if len(entries) == 1 and tc.mode == "required": + tool = entries[0] + if isinstance(tool, BuiltinTool): + return {"type": _builtin_type(tool, compat)} + return {"type": "function", "name": tool.name} + # mode="auto" restriction or multi-tool subset: allowed_tools + # keeps auto semantics honest — the old single-name mapping + # forced a call even when the caller said mode="auto". + wire_tools: list[dict[str, Any]] = [] + for tool in entries: + if isinstance(tool, BuiltinTool): + wire_tools.append({"type": _builtin_type(tool, compat)}) + else: + wire_tools.append({"type": "function", "name": tool.name}) + return {"type": "allowed_tools", "mode": tc.mode, "tools": wire_tools} + if tc.mode == "required": + return "required" + return "auto" + + def _payload(self, request: Request, stream: bool) -> dict[str, Any]: + compat = self._compat(request) + breakpoint_index = _cache_breakpoint_index(request, compat.cache_control) # once: it may record + payload: dict[str, Any] = { + "model": request.model, + "input": self._build_input( + request.messages, + compat, + breakpoint_index=breakpoint_index, + ), + "stream": stream, + } + if request.system: + system_text = request.system if isinstance(request.system, str) else parts_to_text(request.system) + if _cache_stable_prefix(request, compat.cache_control): + # Top-level `instructions` cannot carry a breakpoint + # (prompt-caching guide: "place them in an input_text block + # inside a developer message"). The stable-prefix intent + # therefore renders the system prompt as the first input + # item with the mark on it. + payload["input"].insert(0, { + "role": compat.developer_role, + "content": [{"type": "input_text", "text": system_text, "prompt_cache_breakpoint": {"mode": "explicit"}}], + }) + else: + payload["instructions"] = system_text + if request.config.max_tokens is not None: + payload[compat.max_output_tokens_field] = request.config.max_tokens + if request.config.temperature is not None: + payload["temperature"] = request.config.temperature + if request.config.top_p is not None: + payload["top_p"] = request.config.top_p + if request.config.stop: + # MAP-13 client_side: the Responses wire has no stop field; the + # text is cut at the first sequence after the wire (complete) + # or as it streams (the source is closed at the cut). + adapt("config.stop", "client_side", + "the Responses wire has no stop field; the reply is streamed and the connection " + "closed at the first stop sequence (whether the provider then stops generating, " + "and billing, is its own behaviour); the usage report rides only the final frame, " + "so it is not reported when the cut happens (never estimated)", + asked=list(request.config.stop), applied=list(request.config.stop), provider=self.provider) + if request.config.top_k is not None: + adapt("config.top_k", "dropped", + "the Responses wire has no top_k (Anthropic and Gemini carry it)", + asked=request.config.top_k, provider=self.provider) + for name in ("seed", "frequency_penalty", "presence_penalty"): + if getattr(request.config, name) is not None: + # The Responses API dropped these from the Chat Completions + # wire (no field in the reference); the chat dialect carries them. + adapt(f"config.{name}", "dropped", + f"the Responses wire has no {name} field (the Chat Completions dialect carries it)", + asked=getattr(request.config, name), provider=self.provider) + if request.config.logprobs is not None: + # Verified live 2026-09-01: include triggers per-token logprobs; + # top_logprobs (0–20) controls the alternatives count. + payload["top_logprobs"] = request.config.logprobs + payload["include"] = ["message.output_text.logprobs"] + if request.tools: + tools_wire: list[dict[str, Any]] = [] + for tool in request.tools: + if isinstance(tool, FunctionTool): + tool_payload = { + "type": "function", + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + } + if compat.strict_tools == "include": + tool_payload["strict"] = False + tools_wire.append(tool_payload) + elif isinstance(tool, BuiltinTool): + tools_wire.append(_builtin_to_openai(tool, compat)) + payload["tools"] = tools_wire + tool_choice = self._tool_choice_payload(request, compat) + if tool_choice is not None: + payload["tool_choice"] = tool_choice + if request.config.tool_choice and request.config.tool_choice.parallel is not None: + payload["parallel_tool_calls"] = request.config.tool_choice.parallel + if request.config.response_format: + # MAP-14: the judgment convention goes verbatim (strict honours + # anyOf/const/title, receipted 2026-09-17); probabilities cannot + # be measured here. + note_unmeasurable_probabilities(request, self.provider) + payload["text"] = _response_format_to_openai_text(request.config.response_format) + if request.config.reasoning: + reasoning = request.config.reasoning + if not reasoning.is_off: + # MAP-7: the word goes verbatim; models reject unsupported + # levels with a 400 that lists the supported set (live + # 2026-09-02: gpt-5.6-sol rejects minimal, gpt-5.4-mini + # rejects max). No budget exists on this wire. + if reasoning.thinking_budget is not None: + # MAP-13: effort carries the intent (MAP-7 rule 5); no + # budget field exists on this wire. + adapt("config.reasoning.thinking_budget", "dropped", + "this wire has no thinking token budget; effort carries the intent " + "(Anthropic's manual class and Gemini take a budget)", + asked=reasoning.thinking_budget, provider=self.provider) + effort = reasoning.effort + if reasoning.summary in ("concise", "detailed") and compat.reasoning_format != "responses_reasoning": + adapt("config.reasoning.summary", "substituted", + "this wire has no summary detail levels; 'auto' is what it shows", + asked=reasoning.summary, applied="auto", provider=self.provider) + if compat.reasoning_format == "responses_reasoning": + reasoning_payload: dict[str, Any] = {"effort": effort} + if reasoning.summary is not None: + reasoning_payload["summary"] = reasoning.summary + payload["reasoning"] = reasoning_payload + elif compat.reasoning_format == "reasoning_effort": + payload["reasoning_effort"] = effort + elif compat.reasoning_format == "openrouter": + payload["reasoning"] = {"effort": effort} + elif compat.reasoning_format == "deepseek": + payload["thinking"] = {"type": "enabled"} + payload["reasoning_effort"] = effort + elif compat.reasoning_format in {"qwen", "zai"}: + payload["enable_thinking"] = True + elif compat.reasoning_format == "qwen_chat_template": + payload["chat_template_kwargs"] = { + "enable_thinking": True, + "preserve_thinking": True, + } + else: + # Explicit off must reach the wire. Sending nothing lets + # reasoning-by-default models (gpt-5 family, o-series) burn + # hidden reasoning tokens the caller asked to disable — + # verified live 2026-09-01: gpt-5-mini spent 64 reasoning + # tokens on a request with effort="off" when the field was + # omitted. Models whose floor is "minimal" reject + # "none" with a clear 400; that loud failure is deliberate + # (a silent default-effort run spends money on hidden + # reasoning tokens the caller asked to disable). + if compat.reasoning_format == "responses_reasoning": + payload["reasoning"] = {"effort": "none"} + elif compat.reasoning_format == "reasoning_effort": + payload["reasoning_effort"] = "none" + elif compat.reasoning_format == "openrouter": + payload["reasoning"] = {"enabled": False} + elif compat.reasoning_format == "deepseek": + payload["thinking"] = {"type": "disabled"} + elif compat.reasoning_format in {"qwen", "zai"}: + payload["enable_thinking"] = False + elif compat.reasoning_format == "qwen_chat_template": + payload["chat_template_kwargs"] = {"enable_thinking": False} + + # Prompt caching (MAP-6): off switch, key, retention, resource. + _cache_common_payload(request, payload, compat.cache_control, self.provider, breakpoint_index=breakpoint_index) + + if compat.routing is not None: + payload["provider"] = compat.routing + + # Promoted cross-provider knobs (changes/2026-09-01-extensions-burn-down): + # canonical spelling in, provider spelling out. user_id maps to + # safety_identifier — OpenAI's current end-user attribution field + # (`user` is the deprecated legacy spelling; still available verbatim + # through extensions). + if request.config.service_tier is not None: + payload["service_tier"] = request.config.service_tier + if request.config.user_id is not None: + payload["safety_identifier"] = request.config.user_id + if request.config.store is not None: + payload["store"] = request.config.store + + if request.config.extensions: + reserved = { + "prompt_caching", + "cache", + "compat", + "openai_compat", + "openai_responses_compat", + } + passthrough = {k: v for k, v in request.config.extensions.items() if k not in reserved} + payload.update(passthrough) + if self._codex: + # Backend facts (live 2026-08-31): the Codex backend is + # streaming-only, rejects store=true and every max-token knob, + # and expects instructions to be present. + if self.access.system_prefix: + payload.setdefault("instructions", self.access.system_prefix) + payload["store"] = False + payload["stream"] = True + payload.pop("max_output_tokens", None) + payload.pop("max_completion_tokens", None) + payload.pop("max_tokens", None) + return payload + + def build_request(self, request: Request, stream: bool) -> TransportRequest: + request = self._wire_request(request) + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/responses", + endpoint="responses", + stream=stream, + model=request.model, + headers=self._headers(), + payload=self._payload(request, stream=stream), + ) + + # ─── Response parsing ─────────────────────────────────────────── + + def parse_response(self, request: Request, response: HttpResponse) -> Response: + request = self._wire_request(request) + data = response.json() + + resp_error = data.get("error") if isinstance(data, dict) else None + if isinstance(resp_error, dict): + raise self._response_error( + str(resp_error.get("code") or ""), + str(resp_error.get("message") or resp_error), + ) + + parts: list[Any] = [] + unmapped: list[dict[str, str]] = [] + logprob_seq: list[Any] = [] + for item_index, item in enumerate(data.get("output", []) or []): + if not isinstance(item, dict): + _record_unmapped(unmapped, f"output[{item_index}]", type(item).__name__) + continue + item_type = item.get("type") + if item_type == "message": + for content_index, content in enumerate(item.get("content", []) or []): + if not isinstance(content, dict): + _record_unmapped(unmapped, f"output[{item_index}].content[{content_index}]", type(content).__name__) + continue + ctype = content.get("type") + if ctype in ("output_text", "text"): + text = str(content.get("text") or "") + parts.append(TextPart(text=text)) + # Per-block wire lists concatenate in document order + # into the message-level canonical sequence. + logprob_seq.extend(openai_token_logprobs(content.get("logprobs"))) + for annotation in content.get("annotations", []) or []: + if not isinstance(annotation, dict): + continue + citation = _citation_from_openai_annotation(annotation, text) + if citation is not None: + parts.append(citation) + elif ctype == "refusal": + text = str(content.get("refusal") or content.get("text") or "") + parts.append(RefusalPart(text=text) if text else TextPart(text="")) + elif ctype == "output_image": + b64 = content.get("b64_json") or content.get("image_base64") or "" + if b64: + parts.append(ImagePart(media_type="image/png", data=str(b64))) + elif ctype == "output_audio": + audio_payload = content.get("audio") if isinstance(content.get("audio"), dict) else {} + b64 = audio_payload.get("data") or content.get("b64_json") or "" + if b64: + parts.append(AudioPart(media_type="audio/wav", data=str(b64))) + else: + _record_unmapped(unmapped, f"output[{item_index}].content[{content_index}]", ctype) + elif item_type == "function_call": + if not item.get("name"): + raise unnamed_tool_call_error(self.provider, f"output[{item_index}]") + parts.append( + ToolCallPart( + id=str(item.get("call_id") or item.get("id") or f"call_{len(parts)}"), + name=str(item["name"]), + input=parse_json_object(item.get("arguments")), + ) + ) + elif item_type == "reasoning": + # MAP-7 rule 8: the reasoning item is replay state. Its + # summary (when requested) is the visible text; its id and + # encrypted_content ride as continuation so the next turn + # can send the item back verbatim (live 2026-09-02: + # encrypted_content is present on every reasoning item). + summary = item.get("summary") + if isinstance(summary, list): + text = "\n".join(str(x.get("text") if isinstance(x, dict) else x) for x in summary) + else: + text = str(summary or item.get("text") or "") + state: dict[str, Any] = {} + if item.get("id"): + state["id"] = str(item["id"]) + if item.get("encrypted_content"): + state["encrypted_content"] = str(item["encrypted_content"]) + continuation = (ContinuationState(provider="openai", kind="reasoning_item", data=state),) if state else () + if text or continuation: + parts.append(ThinkingPart(text=text, continuation=continuation)) + elif item_type in OPENAI_PROVIDER_EXECUTED_ITEMS: + continue + else: + _record_unmapped(unmapped, f"output[{item_index}]", item_type) + + if not parts: + parts = [TextPart(text=str(data.get("output_text") or ""))] + + usage_data = data.get("usage", {}) or {} + input_details = usage_data.get("input_tokens_details") or {} + output_details = usage_data.get("output_tokens_details") or {} + usage = Usage( + input_tokens=usage_data.get("input_tokens"), + output_tokens=usage_data.get("output_tokens"), + total_tokens=usage_data.get("total_tokens"), + reasoning_tokens=output_details.get("reasoning_tokens"), + cache_read_tokens=input_details.get("cached_tokens"), + cache_write_tokens=input_details.get("cache_write_tokens"), + input_audio_tokens=input_details.get("audio_tokens"), + output_audio_tokens=output_details.get("audio_tokens"), + ) + + has_tool = any(isinstance(part, ToolCallPart) for part in parts) + # D8 (2026-09-06): Response.id carries the response id; no + # message-level continuation state is minted for it. + return Response( + id=str(data.get("id")) if data.get("id") else None, + model=str(data.get("model") or request.model), + message=Message(role="assistant", parts=replace_text_with_data(parts, request_judgments(request))), + finish_reason=_finish_from_status(data, has_tool_call=has_tool), + usage=usage, + logprobs=tuple(logprob_seq) if logprob_seq else None, + provider_data=_attach_unmapped(data, unmapped), + ) + + def parse_stream_events(self, request: Request, raw_event: SSEEvent) -> Iterator[StreamEvent]: + request = self._wire_request(request) + payload = json.loads(raw_event.data) if raw_event.data and raw_event.data != "[DONE]" else None + if isinstance(payload, dict) and payload.get("type") in {"response.output_item.added", "response.output_item.done"}: + item = payload.get("item") + if isinstance(item, dict) and item.get("type") == "reasoning": + index = int(payload.get("output_index", 0) or 0) + if payload["type"] == "response.output_item.added": + # MAP-7.9: even an item with no visible summary is thinking. + yield StreamDeltaEvent(delta=ThinkingDelta(text="", part_index=index)) + else: + # Final replay state can arrive after all summary fragments. + # Do not repeat those fragments from the completed snapshot. + state = {key: item[key] for key in ("id", "encrypted_content") if item.get(key)} + if state: + yield StreamDeltaEvent(delta=ContinuationDelta( + provider="openai", kind="reasoning_item", data=state, part_index=index, + )) + return + event = self._parse_single_stream_event(request, raw_event, payload=payload) + if event is not None: + yield event + + def _parse_single_stream_event(self, request: Request, raw_event: SSEEvent, *, payload: JsonObject | None = None) -> StreamEvent | None: + if not raw_event.data: + return None + if raw_event.data == "[DONE]": + # A bare terminator: it carries no finish reason and no usage. + # response.completed already said how the turn ended; claiming + # "stop" here would overwrite "tool_call" in the coalesced end. + return StreamEndEvent() + if payload is None: + payload = json.loads(raw_event.data) + et = str(payload.get("type") or "") + + if et == "response.created": + response = payload.get("response", {}) if isinstance(payload.get("response"), dict) else {} + return StreamStartEvent( + id=str(response.get("id")) if response.get("id") else None, + model=str(response.get("model") or request.model), + ) + + if et in {"response.output_text.delta", "response.refusal.delta"}: + return StreamDeltaEvent( + delta=TextDelta( + text=str(payload.get("delta") or ""), + part_index=int(payload.get("output_index", 0) or 0), + # Verified live 2026-09-01: each output_text.delta event + # carries the logprobs for exactly its own tokens. + logprobs=openai_token_logprobs(payload.get("logprobs")), + ) + ) + + if et in {"response.reasoning_summary_text.delta", "response.reasoning_text.delta"}: + return StreamDeltaEvent( + delta=ThinkingDelta( + text=str(payload.get("delta") or ""), + part_index=int(payload.get("output_index", 0) or 0), + ) + ) + + if et == "response.output_text.annotation.added": + annotation = payload.get("annotation") + if isinstance(annotation, dict): + delta = _citation_delta_from_openai_annotation( + annotation, + part_index=int(payload.get("output_index", 0) or 0), + ) + if delta is not None: + return StreamDeltaEvent(delta=delta) + return None + + if et == "response.output_audio.delta": + return StreamDeltaEvent( + delta=AudioDelta( + data=str(payload.get("delta") or ""), + part_index=int(payload.get("output_index", 0) or 0), + media_type="audio/wav", + ) + ) + + if et in {"response.output_image.delta", "response.image.delta"}: + return StreamDeltaEvent( + delta=ImageDelta( + data=str(payload.get("delta") or ""), + part_index=int(payload.get("output_index", 0) or 0), + media_type="image/png", + ) + ) + + if et == "response.output_item.added": + item = payload.get("item", {}) if isinstance(payload.get("item"), dict) else {} + if item.get("type") == "function_call": + return StreamDeltaEvent( + delta=ToolCallDelta( + input=str(item.get("arguments") or ""), + part_index=int(payload.get("output_index", 0) or 0), + id=str(item.get("call_id") or item.get("id") or "") or None, + name=str(item.get("name") or "") or None, + ) + ) + return None + + if et == "response.function_call_arguments.delta": + return StreamDeltaEvent( + delta=ToolCallDelta( + input=str(payload.get("delta") or ""), + part_index=int(payload.get("output_index", 0) or 0), + id=str(payload.get("call_id") or payload.get("id") or "") or None, + name=str(payload.get("name") or "") or None, + ) + ) + + if et == "response.completed": + response = payload.get("response", {}) if isinstance(payload.get("response"), dict) else {} + usage_data = response.get("usage", {}) if isinstance(response, dict) else {} + input_details = usage_data.get("input_tokens_details") or {} + output_details = usage_data.get("output_tokens_details") or {} + usage = Usage( + input_tokens=usage_data.get("input_tokens"), + output_tokens=usage_data.get("output_tokens"), + total_tokens=usage_data.get("total_tokens"), + reasoning_tokens=output_details.get("reasoning_tokens"), + cache_read_tokens=input_details.get("cached_tokens"), + cache_write_tokens=input_details.get("cache_write_tokens"), + input_audio_tokens=input_details.get("audio_tokens"), + output_audio_tokens=output_details.get("audio_tokens"), + ) + output = response.get("output", []) if isinstance(response, dict) else [] + has_tool = any(isinstance(item, dict) and item.get("type") == "function_call" for item in output) + return StreamEndEvent( + finish_reason="tool_call" if has_tool else "stop", + usage=usage, + provider_data=response if isinstance(response, dict) else None, + ) + + if et in {"response.error", "error"}: + err = payload.get("error") + if isinstance(err, dict): + provider_code = str(err.get("code") or err.get("type") or payload.get("code") or "provider") + message = str(err.get("message") or payload.get("message") or "") + else: + provider_code = str(payload.get("code") or payload.get("error_type") or "provider") + message = str(payload.get("message") or "") + return StreamErrorEvent(error=self._error_detail(provider_code, message)) + + return None + + # ─── Streaming over OpenAI Realtime for live models ────────────── + + def complete(self, request: Request) -> Response: + if self._codex: + # Streaming-first backend: materialize the stream so callers get + # the same synchronous complete() surface. + return materialize_response(self.stream(request), request) + return BaseProviderLM.complete(self, request) + + def stream(self, request: Request) -> Iterator[StreamEvent]: + wire = self._wire_request(request) + if not self._codex and self._should_use_live_completion(wire): + yield from self._stream_via_live_completion(wire) + return + # BaseProviderLM.stream applies the MAP-3 coalescer: the Codex + # backend sends response.completed (usage) and then [DONE], two + # adapter-level end frames merged into one. + yield from BaseProviderLM.stream(self, request) + + def _should_use_live_completion(self, request: Request) -> bool: + extensions = request.config.extensions or {} + transport_mode = str(extensions.get("transport") or "").lower() + if transport_mode in {"live", "websocket", "ws"}: + return True + model_name = request.model.lower() + return "realtime" in model_name or "-live" in model_name + + def _stream_via_live_completion(self, request: Request) -> Iterator[StreamEvent]: + ws = self._live_connect(self._live_url(request.model), self._live_headers()) + saw_tool_call = False + usage = Usage() + try: + ws.send(json.dumps(self._live_session_update_from_request(request))) + for frame in self._live_message_frames_for_request(request): + ws.send(json.dumps(frame)) + + yield StreamStartEvent(model=request.model) + while True: + raw = ws.recv() + for event in self._decode_live_completion_stream_events(request, raw): + if event.type == "delta": + if isinstance(event.delta, ToolCallDelta): + saw_tool_call = True + yield event + elif event.type == "error": + yield event + return + elif event.type == "end": + if event.usage is not None: + usage = event.usage + yield StreamEndEvent( + finish_reason="tool_call" if saw_tool_call else (event.finish_reason or "stop"), + usage=usage, + ) + return + finally: + try: + ws.close() + except Exception: + pass + + def _live_session_update_from_request(self, request: Request) -> dict[str, Any]: + extensions = dict(request.config.extensions or {}) + extensions.pop("transport", None) + extensions.pop("prompt_caching", None) + extensions.pop("output", None) + config = LiveConfig( + model=request.model, + system=request.system, + tools=request.tools, + extensions=extensions or None, + ) + return self._live_session_update_payload(config) + + def _live_message_frames_for_request(self, request: Request) -> list[dict[str, Any]]: + frames: list[dict[str, Any]] = [] + for message in request.messages: + if message.role == "tool": + for part in message.parts: + if not isinstance(part, ToolResultPart): + continue + # Realtime's function_call_output is a string; a media part raises (MAP-10). + output = tool_result_error_text(part, parts_to_text(part.content, provider=self.provider, where="a Realtime function_call_output")) + frames.append({"type": "conversation.item.create", "item": {"type": "function_call_output", "call_id": part.id, "output": output}}) + continue + + content = [part_to_openai_input(p, provider=self.provider) for p in message.parts if not isinstance(p, (ToolCallPart, ToolResultPart))] + if content: + frames.append({"type": "conversation.item.create", "item": {"type": "message", "role": message.role, "content": content}}) + for part in message.parts: + if isinstance(part, ToolCallPart): + frames.append({"type": "conversation.item.create", "item": {"type": "function_call", "call_id": part.id, "name": part.name, "arguments": json.dumps(part.input)}}) + + response_create: dict[str, Any] = {"type": "response.create"} + if (request.config.extensions or {}).get("output") == "audio": + response_create["response"] = {"output_modalities": ["audio"]} # GA name + frames.append(response_create) + return frames + + def _decode_live_completion_stream_events(self, request: Request, raw: str | bytes) -> list[StreamEvent]: + try: + payload = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw) + except Exception: + return [] + if not isinstance(payload, dict): + return [] + et = str(payload.get("type") or "") + + if et in {"response.output_text.delta", "response.text.delta", + "response.output_audio_transcript.delta", "response.audio_transcript.delta"}: + delta = str(payload.get("delta") or payload.get("text") or "") + return [StreamDeltaEvent(delta=TextDelta(text=delta))] if delta else [] + if et == "response.output_audio.delta": + delta = str(payload.get("delta") or "") + return [StreamDeltaEvent(delta=AudioDelta(data=delta, media_type="audio/wav"))] if delta else [] + if et in {"response.output_item.added", "response.function_call_arguments.delta", "response.function_call_arguments.done", "response.output_item.done"}: + if et in {"response.output_item.added", "response.output_item.done"}: + item = payload.get("item", {}) if isinstance(payload.get("item"), dict) else {} + if item.get("type") != "function_call": + return [] + call_id = str(item.get("call_id") or item.get("id") or "") + name = str(item.get("name") or "tool") + arguments = item.get("arguments") or "" + else: + call_id = str(payload.get("call_id") or payload.get("id") or "") + name = str(payload.get("name") or "tool") + arguments = payload.get("delta") if et.endswith("delta") else payload.get("arguments") + return [StreamDeltaEvent(delta=ToolCallDelta(input=arguments if isinstance(arguments, str) else json.dumps(arguments or {}), id=call_id or None, name=name))] + if et in {"response.done", "response.completed"}: + response = payload.get("response", {}) if isinstance(payload.get("response"), dict) else {} + usage_data = response.get("usage", {}) if isinstance(response, dict) else {} + u_in = usage_data.get("input_token_details") or usage_data.get("input_tokens_details") or {} + u_out = usage_data.get("output_token_details") or usage_data.get("output_tokens_details") or {} + usage = Usage( + input_tokens=usage_data.get("input_tokens"), + output_tokens=usage_data.get("output_tokens"), + total_tokens=usage_data.get("total_tokens"), + reasoning_tokens=u_out.get("reasoning_tokens"), + cache_read_tokens=u_in.get("cached_tokens"), + cache_write_tokens=u_in.get("cache_write_tokens"), + input_audio_tokens=u_in.get("audio_tokens"), + output_audio_tokens=u_out.get("audio_tokens"), + ) + return [StreamEndEvent(finish_reason="stop", usage=usage, provider_data=response if isinstance(response, dict) else None)] + if et in {"error", "response.error"}: + err = payload.get("error") + if isinstance(err, dict): + provider_code = str(err.get("code") or err.get("type") or payload.get("code") or "provider") + message = str(err.get("message") or payload.get("message") or "") + else: + provider_code = str(payload.get("code") or payload.get("error_type") or "provider") + message = str(payload.get("message") or "") + return [StreamErrorEvent(error=self._error_detail(provider_code, message))] + return [] + + # ─── Live sessions ────────────────────────────────────────────── + + def live(self, config: LiveConfig): + self._require("live") + ws = self._live_connect(self._live_url(config.model), self._live_headers()) + for frame in self._live_setup_frames(config): + ws.send(json.dumps(frame)) + + return WebSocketLiveSession( + ws=ws, + encode_event=self._live_encoder(config), + decode_event=self._decode_live_server_event, + ) + + # Pure live-codec hooks (uniform across providers; the vet shim's + # replay_live op and the async twin drive these, never the socket). + + def _live_setup_frames(self, config: LiveConfig) -> list[dict[str, Any]]: + return [self._live_session_update_payload(config)] + + def _live_encoder(self, config: LiveConfig): + return self._encode_live_client_event + + def _live_connect(self, url: str, headers: dict[str, str]): + connect = require_websocket_sync_connect() + return connect(url, additional_headers=headers) + + def _live_url(self, model: str) -> str: + parsed = urllib.parse.urlparse(self.base_url) + scheme = "wss" if parsed.scheme == "https" else "ws" + base_path = parsed.path.rstrip("/") + path = f"{base_path}/realtime" if base_path else "/realtime" + query = urllib.parse.urlencode({"model": model}) + return urllib.parse.urlunparse((scheme, parsed.netloc, path, "", query, "")) + + def _live_headers(self) -> dict[str, str]: + # GA Realtime: the beta header now HARD-CLOSES the socket (4000 + # `beta_api_shape_disabled`, observed live 2026-09-01). Use the + # access policy as ordinary HTTP does: Azure API keys travel under + # `api-key`; OpenAI and Entra tokens use Authorization. + value = resolve_credential_value(self.api_key) + pair = auth_header(self.access, value, api_key_header=self._api_key_header) + headers = dict(self.access.headers) + if pair is not None: + headers[pair[0]] = pair[1] + return headers + + @staticmethod + def _live_audio_format(fmt: AudioFormat) -> dict[str, Any]: + # GA wire format objects: {"type": "audio/pcm", "rate": N} (pcmu/ + # pcma carry no rate). Canonical pcm16 maps to audio/pcm. + if fmt.encoding == "pcm16": + return {"type": "audio/pcm", "rate": fmt.sample_rate} + return {"type": f"audio/{fmt.encoding}"} + + def _live_session_update_payload(self, config: LiveConfig) -> dict[str, Any]: + # GA Realtime session shape (verified live 2026-09-01, + # curl-fixtures/live-2026-09-01/): session.type is required, + # `output_modalities` replaces `modalities`, and audio config + # nests under session.audio.{input,output}. + session: dict[str, Any] = {"type": "realtime"} + if config.system: + session["instructions"] = config.system if isinstance(config.system, str) else parts_to_text(config.system) + audio: dict[str, Any] = {} + if config.output_format is not None or config.voice: + session["output_modalities"] = ["audio"] + output: dict[str, Any] = {} + if config.output_format is not None: + output["format"] = self._live_audio_format(config.output_format) + if config.voice: + output["voice"] = config.voice + audio["output"] = output + else: + session["output_modalities"] = ["text"] + if config.input_format is not None: + # turn_detection null = server VAD OFF: a turn happens exactly + # when the caller sends end_audio() (commit + response.create), + # deterministic library behavior. Re-enable VAD through + # extensions when you want the server to segment speech. + audio["input"] = {"format": self._live_audio_format(config.input_format), "turn_detection": None} + if audio: + session["audio"] = audio + if config.tools: + session["tools"] = [ + {"type": "function", "name": t.name, "description": t.description, "parameters": t.parameters} + for t in config.tools + if isinstance(t, FunctionTool) + ] + if config.extensions: + session.update(config.extensions) + return {"type": "session.update", "session": session} + + def _encode_live_client_event(self, event: LiveClientEvent) -> list[dict[str, Any]]: + if isinstance(event, LiveClientAudioEvent): + return [{"type": "input_audio_buffer.append", "audio": event.data}] + if isinstance(event, LiveClientEndAudioEvent): + return [{"type": "input_audio_buffer.commit"}, {"type": "response.create"}] + if isinstance(event, LiveClientInterruptEvent): + return [{"type": "response.cancel"}] + if isinstance(event, LiveClientTextEvent): + return [ + {"type": "conversation.item.create", "item": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": event.text}]}}, + {"type": "response.create"}, + ] + if isinstance(event, LiveClientTurnEvent): + return [ + {"type": "conversation.item.create", "item": {"type": "message", "role": "user", "content": [part_to_openai_input(part) for part in event.parts]}}, + {"type": "response.create"}, + ] if event.turn_complete else [ + {"type": "conversation.item.create", "item": {"type": "message", "role": "user", "content": [part_to_openai_input(part) for part in event.parts]}}, + ] + if isinstance(event, LiveClientImageEvent): + return [ + {"type": "conversation.item.create", "item": {"type": "message", "role": "user", "content": [{"type": "input_image", "image_url": f"data:{event.media_type};base64,{event.data}"}]}}, + {"type": "response.create"}, + ] + if isinstance(event, LiveClientToolResultEvent): + output = parts_to_text(event.content, provider=self.provider, where="a Realtime function_call_output") + return [ + {"type": "conversation.item.create", "item": {"type": "function_call_output", "call_id": event.id, "output": output}}, + {"type": "response.create"}, + ] + return [] + + def _decode_live_server_event(self, raw: str | bytes): + try: + payload = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw) + except Exception: + return [] + if not isinstance(payload, dict): + return [] + et = str(payload.get("type") or "") + events: list[Any] = [] + # GA server event names verified live 2026-09-01 + # (curl-fixtures/live-2026-09-01/); legacy beta names kept where + # harmless. Audio-native turns speak through + # `response.output_audio_transcript.delta` — mapped to text so + # transcripts arrive uniformly across providers. + if et in {"response.output_text.delta", "response.text.delta", + "response.output_audio_transcript.delta", "response.audio_transcript.delta"}: + delta = str(payload.get("delta") or payload.get("text") or "") + if delta: + events.append(LiveServerTextEvent(text=delta)) + elif et == "response.output_audio.delta": + delta = str(payload.get("delta") or "") + if delta: + events.append(LiveServerAudioEvent(data=delta)) + elif et == "response.function_call_arguments.delta": + delta = str(payload.get("delta") or "") + if delta: + events.append(LiveServerToolCallDeltaEvent(input_delta=delta, id=str(payload.get("call_id") or payload.get("id") or "") or None, name=str(payload.get("name") or "") or None)) + elif et == "response.output_item.done": + # The ONLY tool-call emission point. `function_call_arguments.done` + # also carries the full call, but mapping both double-fires the + # event and double-sends tool results (observed live 2026-09-01: + # both frames arrive for one call). + item = payload.get("item", {}) if isinstance(payload.get("item"), dict) else {} + if item.get("type") == "function_call": + call_id = str(item.get("call_id") or item.get("id") or "") + if call_id: + events.append(LiveServerToolCallEvent( + id=call_id, name=str(item.get("name") or "tool"), + input=parse_json_object(item.get("arguments")))) + elif et in {"response.done", "response.completed"}: + response = payload.get("response", {}) if isinstance(payload.get("response"), dict) else {} + output = response.get("output") if isinstance(response.get("output"), list) else [] + usage = _live_usage_from_response(response) + if str(response.get("status") or "") == "cancelled": + # GA signals barge-in via response.done status=cancelled + # (status_details.reason=client_cancelled); parallel to + # Gemini's interrupted frame. The cancelled response still + # consumed tokens (pinned: 143 in openai.live_interrupt); they + # ride a usage event before the interrupt signal, so the + # session's bill is the sum of usage + turn_end events. + if usage is not None: + events.append(LiveServerUsageEvent(usage=usage)) + events.append(LiveServerInterruptedEvent()) + elif any(isinstance(i, dict) and i.get("type") == "function_call" for i in output): + # A response that requests tool calls does NOT end the turn: + # the model is waiting for results, and the continuation + # arrives as a further wire response (observed live + # 2026-09-01). Gemini keeps the turn open here; emitting + # turn_end would break the shared tool-dispatch loop. Its + # tokens (pinned: 75 in openai.live_tools) ride a usage event. + if usage is not None: + events.append(LiveServerUsageEvent(usage=usage)) + else: + events.append(LiveServerTurnEndEvent(usage=usage or Usage())) + elif et in {"response.cancelled", "response.canceled"}: + events.append(LiveServerInterruptedEvent()) + elif et in {"error", "response.error"}: + err = payload.get("error") + if isinstance(err, dict): + provider_code = str(err.get("code") or err.get("type") or payload.get("code") or "provider") + message = str(err.get("message") or payload.get("message") or "") + else: + provider_code = str(payload.get("code") or payload.get("error_type") or "provider") + message = str(payload.get("message") or "") + if provider_code == "response_cancel_not_active": + # Benign barge-in race (captured live 2026-09-01): the + # response finished before the cancel arrived, or interrupt() + # was pressed twice. Gemini tolerates repeated interrupts; + # surfacing this as an error event breaks the parallel. + return events + events.append(LiveServerErrorEvent(error=self._error_detail(provider_code, message))) + return events + + # ─── Other endpoints ──────────────────────────────────────────── + + def _models_request(self): + params = None + if self._codex: + # The Codex backend's /models requires a client_version query + # parameter (a Codex CLI release); the policy carries it. + params = {"client_version": self.access.backend_options.get("client_version", "")} + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/models", + params=params, + headers=self._headers(), + ) + + def _models_from_body(self, body: str): + data = json.loads(body) + if self._codex: + # The Codex backend's usable model names are the `slug` values, + # and the list lives under "models" (not the OpenAI "data" envelope). + entries = data.get("models") if isinstance(data, dict) else None + return model_infos_from_entries( + entries, provider=self.provider, api_family="openai_responses", id_of=lambda entry: entry.get("slug"), + ) + entries = data.get("data") if isinstance(data, dict) else None + return model_infos_from_entries( + entries, + provider=self.provider, + api_family="openai_responses", + id_of=lambda entry: entry.get("id"), + ) + + # ─── File hooks (Files API) ───────────────────────────────── + # + # Wire shapes verified live 2026-08-31 (curl-fixtures/files-2026-08-31/): + # multipart upload with a required `purpose` form field (an OpenAI + # storage classification, NOT part of the portable surface — lm15 + # defaults to `user_data` per current OpenAI guidance and sets `batch` + # itself for batch inputs; extensions["purpose"] overrides). List + # pages with `after=` + `has_more`. Download is refused for + # some purposes (observed: 400 for user_data) — forwarded typed. + + def _file_upload_request(self, request: FileUploadRequest) -> TransportRequest: + extensions = dict(request.extensions or {}) + purpose = str(extensions.pop("purpose", "user_data")) + fields = [("purpose", purpose)] + [(k, str(v)) for k, v in extensions.items()] + content_type, body = multipart_form_body( + fields=fields, + files=[("file", request.filename, request.media_type, request.bytes)], + ) + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/files", + headers=list(self._headers(content_type=content_type).items()), + body=body, + ) + + def _file_info_from_body(self, body: str) -> FileInfo: + return self._file_info(json.loads(body)) + + def _file_info(self, data: dict[str, Any]) -> FileInfo: + file_id = data.get("id") + if not isinstance(file_id, str) or not file_id: + raise ProviderError("openai: file object carries no id", provider=self.provider) + readiness = openai_file_readiness(data.get("status")) # D6 fold table + filename = data.get("filename") + return FileInfo( + id=file_id, + filename=filename if isinstance(filename, str) and filename else None, + media_type=None, # OpenAI file metadata reports no MIME type + size_bytes=data.get("bytes") if isinstance(data.get("bytes"), int) else None, + created_at=iso_utc(data.get("created_at")), + expires_at=iso_utc(data.get("expires_at")), + readiness=readiness, + downloadable=None, # purpose-dependent policy, not reported per file + provider_data=data, + ) + + def _file_get_request(self, file_id: str) -> TransportRequest: + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/files/{path_id(file_id)}", + headers=self._headers(), + ) + + def _file_list_request(self, limit: int, cursor: str | None) -> TransportRequest: + params: dict[str, Any] = {"limit": limit} + if cursor is not None: + params["after"] = cursor + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/files", + params=params, headers=self._headers(), + ) + + def _file_page_from_list_body(self, body: str) -> FilePage: + data = json.loads(body) + entries = data.get("data") if isinstance(data.get("data"), list) else [] + items = tuple(self._file_info(entry) for entry in entries if isinstance(entry, dict)) + cursor = data.get("last_id") if data.get("has_more") and items else None + return FilePage(items=items, next_cursor=cursor if isinstance(cursor, str) and cursor else None) + + def _file_delete_request(self, file_id: str) -> TransportRequest: + return self._emit( + method="DELETE", url=f"{self.base_url.rstrip('/')}/files/{path_id(file_id)}", + headers=self._headers(), + ) + + def _file_download_request(self, file_id: str) -> TransportRequest: + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/files/{path_id(file_id)}/content", + headers=self._headers(), + ) + + # ─── Batch hooks (Batch API over /v1/responses) ────────────────── + # + # OpenAI batches are two-step: the requests travel as an uploaded JSONL + # file, then /batches references it. The file is wire syntax, not + # semantics — batch() owns the upload; the file id stays visible in + # provider_data. lm15 assigns positional custom_ids and re-sorts + # results so entry order always equals submission order. + + def _batch_upload_request(self, request: BatchRequest) -> TransportRequest: + self._batch_preflight(request) + lines = [] + for i, nested in enumerate(request.requests): + lines.append(json.dumps({ + "custom_id": str(i), + "method": "POST", + "url": "/v1/responses", + "body": self._payload(nested, stream=False), + }, separators=(",", ":"), ensure_ascii=False)) + data = ("\n".join(lines) + "\n").encode("utf-8") + content_type, body = multipart_form_body( + fields=[("purpose", "batch")], + files=[("file", "lm15-batch.jsonl", "application/jsonl", data)], + ) + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/files", + headers=list(self._headers(content_type=content_type).items()), + body=body, + ) + + def _batch_submit_request(self, request: BatchRequest, upload_body: dict[str, Any] | None) -> TransportRequest: + self._batch_preflight(request) + input_file_id = (upload_body or {}).get("id") + if not isinstance(input_file_id, str) or not input_file_id: + raise ProviderError("openai: batch input file upload returned no id", provider=self.provider) + extensions = dict(request.extensions or {}) + payload: dict[str, Any] = { + "input_file_id": input_file_id, + "endpoint": extensions.pop("endpoint", "/v1/responses"), + "completion_window": extensions.pop("completion_window", "24h"), + } + if request.label is not None: + payload["metadata"] = {"label": request.label} + payload.update(extensions) + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/batches", + headers=self._headers(), + payload=payload, + ) + + def _batch_job_from_body(self, body: str) -> BatchJobInfo: + return self._batch_job_info(json.loads(body)) + + def _batch_job_info(self, data: dict[str, Any]) -> BatchJobInfo: + batch_id = data.get("id") + if not isinstance(batch_id, str) or not batch_id: + raise ProviderError("openai: batch object carries no id", provider=self.provider) + metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} + label = metadata.get("label") + return BatchJobInfo( + id=batch_id, + status=_openai_batch_status(str(data.get("status") or "")), + label=label if isinstance(label, str) and label else None, + created_at=iso_utc(data.get("created_at")), + provider_data=data, + ) + + def _batch_status_request(self, batch_id: str) -> TransportRequest: + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/batches/{path_id(batch_id)}", + headers=self._headers(), + ) + + def _batch_cancel_request(self, batch_id: str) -> TransportRequest: + return self._emit( + method="POST", url=f"{self.base_url.rstrip('/')}/batches/{path_id(batch_id)}/cancel", + headers=self._headers(), + ) + + def _batch_result_fetches(self, status_body: dict[str, Any]) -> tuple[TransportRequest, ...]: + fetches = [] + for key in ("output_file_id", "error_file_id"): + file_id = status_body.get(key) + if isinstance(file_id, str) and file_id: + fetches.append(self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/files/{path_id(file_id)}/content", + headers=self._headers(), + )) + return tuple(fetches) + + def _batch_entries(self, status_body: dict[str, Any], fetched: tuple[str, ...]) -> tuple[BatchEntry, ...]: + job_status = _openai_batch_status(str(status_body.get("status") or "")) + found: dict[int, BatchEntry] = {} + for text in fetched: + for line in text.splitlines(): + if not line.strip(): + continue + item = json.loads(line) + index = int(str(item.get("custom_id"))) + response_obj = item.get("response") if isinstance(item.get("response"), dict) else {} + status_code = int(response_obj.get("status_code") or 0) + body_obj = response_obj.get("body") if isinstance(response_obj.get("body"), dict) else {} + if status_code == 200 and body_obj: + response = self.parse_response( + batch_entry_request(body_obj.get("model")), batch_entry_http(body_obj) + ) + found[index] = BatchEntry(index=index, outcome="succeeded", response=response) + else: + err_source = body_obj or item.get("error") or {} + err = self.normalize_error(status_code or 400, json.dumps(err_source)) + found[index] = BatchEntry( + index=index, + outcome="errored", + error=ErrorDetail( + code=canonical_error_code(err), + message=err.message or "batch entry errored", + provider_code=err.provider_code, + ), + ) + # Entries the output files never mention (an expired or cancelled + # batch stops mid-flight): fill from the job's terminal status. + # Live edge (observed 2026-09-01): a batch cancelled during + # `validating` reports request_counts.total=0 — the provider never + # registered the requests — so results() is honestly EMPTY; lm15 + # does not fabricate entries from the input file side-channel. + counts = status_body.get("request_counts") if isinstance(status_body.get("request_counts"), dict) else {} + total = int(counts.get("total") or 0) or (max(found) + 1 if found else 0) + fill: str = "expired" if job_status == "expired" else "cancelled" if job_status == "cancelled" else "errored" + entries = [] + for index in range(total): + if index in found: + entries.append(found[index]) + elif fill == "errored": + entries.append(BatchEntry( + index=index, outcome="errored", + error=ErrorDetail(code="provider", message="entry missing from batch output files"), + )) + else: + entries.append(BatchEntry(index=index, outcome=fill)) + return tuple(entries) + + def _batch_list_request(self, limit: int) -> TransportRequest: + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/batches", + params={"limit": int(limit)}, headers=self._headers(), + ) + + def _batch_jobs_from_list_body(self, body: str) -> tuple[BatchJobInfo, ...]: + data = json.loads(body) + items = data.get("data") if isinstance(data, dict) else None + return tuple(self._batch_job_info(item) for item in (items or []) if isinstance(item, dict)) + + # ─── Video generation (Sora; captured live 2026-09-01) ────────────── + # + # Jobs at /v1/videos: submit -> {status: queued, progress}, poll -> + # in_progress -> completed, then /videos/{id}/content streams the MP4 + # bytes (media type from the content-type header). The list endpoint + # is account-wide. + + _VIDEO_STATUS_MAP: ClassVar[dict[str, str]] = { + "queued": "queued", + "in_progress": "running", + "completed": "completed", + "failed": "failed", + "cancelled": "cancelled", + } + + def _video_submit_request(self, request: VideoGenerationRequest) -> TransportRequest: + if request.images: + # Sora's image input (input_reference) is a multipart upload; the + # mapping is unverified against the live wire, and Sora also + # constrains reference sizes. Raising beats shipping a guess. + raise UnsupportedFeatureError( + "openai: video input images (input_reference) are not mapped yet; " + "use the provider door until the mapping is live-receipted", + provider=self.provider, + ) + payload: dict[str, Any] = {"model": request.model, "prompt": request.prompt, **(request.extensions or {})} + if request.seconds is not None: + payload["seconds"] = str(request.seconds) # the wire wants a string enum + return self._emit(method="POST", url=f"{self.base_url.rstrip('/')}/videos", headers=self._headers(), payload=payload) + + def _video_job_from_body(self, body: str, video_id: "str | None" = None) -> VideoJobInfo: + return self._video_job_info(json.loads(body)) + + def _video_job_info(self, data: dict[str, Any]) -> VideoJobInfo: + video_id = data.get("id") + if not isinstance(video_id, str) or not video_id: + raise ProviderError("openai: video object carries no id", provider=self.provider) + wire_status = str(data.get("status") or "") + status = self._VIDEO_STATUS_MAP.get(wire_status) + if status is None: + raise ProviderError(f"openai: unknown video status {wire_status!r}", provider=self.provider) + progress = data.get("progress") + return VideoJobInfo( + id=video_id, + status=status, + progress=int(progress) if isinstance(progress, (int, float)) and not isinstance(progress, bool) else None, + created_at=iso_utc(data.get("created_at")), + model=data.get("model"), + provider_data=data, + ) + + def _video_status_request(self, video_id: str) -> TransportRequest: + return self._emit(method="GET", url=f"{self.base_url.rstrip('/')}/videos/{path_id(video_id)}", headers=self._headers()) + + def _video_result_fetch(self, status_body: dict[str, Any]) -> TransportRequest: + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/videos/{path_id(str(status_body.get('id')))}/content", + headers=self._headers(), + ) + + def _video_part(self, status_body: dict[str, Any], fetched: "HttpResponse | None") -> VideoPart: + if fetched is None: + raise ProviderError("openai: video content fetch is required", provider=self.provider) + content_type = (fetched.header("content-type") or "").split(";", 1)[0].strip() + if not content_type: + raise ProviderError("openai: video content carries no content-type", provider=self.provider) + return VideoPart(media_type=content_type, data=base64.b64encode(fetched.body).decode("ascii")) + + def _video_list_request(self, limit: int, model: str | None) -> TransportRequest: + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/videos", + params={"limit": int(limit)}, headers=self._headers(), + ) + + def _video_jobs_from_list_body(self, body: str) -> tuple[VideoJobInfo, ...]: + data = json.loads(body) + items = data.get("data") if isinstance(data, dict) else None + return tuple(self._video_job_info(item) for item in (items or []) if isinstance(item, dict)) + + # ─── Media generation (captured live 2026-09-01) ──────────────────── + # + # Images: /images/generations (JSON) or, when input images are present, + # /images/edits (multipart) — verified honored by pixel check. The + # response states the format in `output_format`; the media type is read + # from the wire, never assumed. Usage carries real token counts. + # Speech: /audio/speech returns RAW BYTES; the media type lives in the + # content-type header (server default is audio/mpeg — lm15 injects no + # voice/format defaults of its own). + + def _image_generate_request(self, request: ImageGenerationRequest) -> TransportRequest: + base = self.base_url.rstrip("/") + compat = resolve_openai_responses_compat( + base_url=self.base_url, model=request.model, profile=self.profile, + request_extensions=None, base=self._compat_base, + ) + if not request.images: + payload = {"model": request.model, "prompt": request.prompt, "size": request.size, **(request.extensions or {})} + payload = {k: v for k, v in payload.items() if v is not None} + return self._emit(method="POST", url=f"{base}/images/generations", headers=self._headers(), payload=payload) + # Edits are multipart: the wire takes uploaded bytes only. + for part in request.images: + if part.data is None and part.path is None: + raise UnsupportedFeatureError( + "openai: image edits take inline data or a local path; " + "url/file_id-addressed input images have no wire slot", + provider=self.provider, + ) + fields = [("model", str(request.model)), ("prompt", request.prompt)] + if request.size is not None: + fields.append(("size", request.size)) + fields += [(k, str(v)) for k, v in (request.extensions or {}).items()] + # The multipart key is the server's: OpenAI takes `image[]`; Meta + # takes `image[0]`, `image[1]`, … (compat edit_image_field). + files = [ + (f"image[{i}]" if compat.edit_image_field == "indexed" else "image[]", f"image-{i}", part.media_type, part.bytes) + for i, part in enumerate(request.images) + ] + content_type, body = multipart_form_body(fields=fields, files=files) + return self._emit( + method="POST", + url=f"{base}/images/edits", + headers=list(self._headers(content_type=content_type).items()), + body=body, + ) + + def _image_generation_from_response(self, request: ImageGenerationRequest, resp: HttpResponse) -> ImageGenerationResponse: + data = resp.json() + output_format = data.get("output_format") + media_type = f"image/{output_format}" if isinstance(output_format, str) and output_format else None + images: list[ImagePart] = [] + for item in data.get("data", []) or []: + if not isinstance(item, dict): + continue + if item.get("b64_json"): + images.append(ImagePart(media_type=media_type or "application/octet-stream", data=str(item["b64_json"]))) + elif item.get("url"): + images.append(ImagePart(media_type=media_type or "application/octet-stream", url=str(item["url"]))) + usage_obj = data.get("usage") if isinstance(data.get("usage"), dict) else {} + usage = Usage( + input_tokens=usage_obj.get("input_tokens"), + output_tokens=usage_obj.get("output_tokens"), + total_tokens=usage_obj.get("total_tokens"), + ) + # Captured: the images response carries no id and no model echo. + return ImageGenerationResponse(images=tuple(images), usage=usage, provider_data=data) + + def _speech_generate_request(self, request: SpeechGenerationRequest) -> TransportRequest: + payload: dict[str, Any] = {"model": request.model, "input": request.prompt, **(request.extensions or {})} + if request.voice is not None: + payload["voice"] = request.voice + if request.format is not None: + payload["response_format"] = request.format + return self._emit(method="POST", url=f"{self.base_url.rstrip('/')}/audio/speech", headers=self._headers(), payload=payload) + + def _speech_generation_from_response(self, request: SpeechGenerationRequest, resp: HttpResponse) -> SpeechGenerationResponse: + content_type = (resp.header("content-type") or "").split(";", 1)[0].strip() + if not content_type: + raise ProviderError("openai: speech response carries no content-type", provider=self.provider) + audio = AudioPart(media_type=content_type, data=base64.b64encode(resp.body).decode("ascii")) + # The body is raw media: no usage, no id, no model echo exist. + return SpeechGenerationResponse(audio=audio, provider_data={"content_type": content_type}) diff --git a/providers/openai_chat.py b/providers/openai_chat.py new file mode 100644 index 0000000000..f5a6eab2d6 --- /dev/null +++ b/providers/openai_chat.py @@ -0,0 +1,2126 @@ +""" +lm15.providers.openai_chat — OpenAI Chat Completions adapter. + +The Chat Completions dialect is the wire format spoken by OpenAI's legacy +endpoint and by most OpenAI-compatible servers: ollama, Groq, OpenRouter, +vLLM, SGLang, DeepSeek, and friends. Provider quirks are described by +``OpenAIChatCompat`` policies (see lm15.compat); named presets bundle a +policy with that server's default base URL. +""" + +from __future__ import annotations + +from datetime import datetime + +import json +import math +import mimetypes +import os +from dataclasses import dataclass, field, replace +from typing import Any, Callable, ClassVar, Iterator, Mapping + +from ..compat import ( + OPENAI_CHAT_PRESET_BASE_URLS, + preset_base_url, + OpenAIChatCompat, + ResolvedOpenAIChatCompat, + resolve_openai_chat_compat, +) +from ..adaptation import Adaptation, AdaptationPolicy, adapt, check_policy, collecting, nearest_effort +from ..errors import ProviderError, ServerError, UnsupportedFeatureError +from ..access import OPENAI_CHAT_API +from ..features import ProviderManifest +from ..judgments import Judgment, judgments_in_schema, non_judgment_properties, normalize_logprobs, note_unmeasurable_probabilities, replace_text_with_data, request_judgments +from ..sse import SSEEvent +from ..transports import TransportRequest +from ..types import ( + DataPart, + BuiltinTool, + CacheConfig, + CitationPart, + Config, + FunctionTool, + ImagePart, + Message, + Part, + Reasoning, + RefusalPart, + Request, + Response, + StreamDeltaEvent, + StreamEndEvent, + StreamErrorEvent, + StreamEvent, + TextDelta, + TextPart, + ThinkingDelta, + ThinkingPart, + ToolCallDelta, + ToolCallPart, + ToolChoice, + ToolResultPart, + Usage, + audio, + document, + image, +) +from .base import BaseProviderLM, Credential, HttpResponse, SyncTransport, default_transport, _attach_error_metadata +from .common import ( + MEDIA_KINDS, + check_tool_result_media, + media_data_uri, + tool_result_error_text, + model_infos_from_entries, + openai_token_logprobs, + parse_json_object, + unnamed_tool_call_error, + parts_to_text, + data_part_text, +) +from .openai import ( + OpenAILM, + _attach_unmapped, + _breakpoint_unsupported, + _cache_breakpoint_index, + _cache_common_payload, + _cache_stable_prefix, + _record_unmapped, +) + +_DEFAULT_BASE_URL = "https://api.openai.com/v1" + +# Canonical builtin tool name → Groq server-executed tool type (compat +# builtin_tools="groq"; both verified live 2026-09-01: Groq runs them +# server-side and reports the trace in message.executed_tools, which +# stays in provider_data per MAP-1). +_GROQ_BUILTIN_MAP: dict[str, str] = { + "web_search": "browser_search", + "code_execution": "code_interpreter", +} + +_FINISH_REASON_MAP: dict[str, str] = { + "stop": "stop", + "length": "length", + "tool_calls": "tool_call", + "function_call": "tool_call", + "content_filter": "content_filter", +} + + +def _chat_image_block(part: Any, provider: str) -> dict[str, Any]: + """`{"type": "image_url", "image_url": {url, detail?}}`: a URL verbatim, + inline data or a path as a data URI. `file_id` has no slot on this wire.""" + if part.file_id is not None: + raise UnsupportedFeatureError( + f"{provider}: an image addressed by file_id cannot be sent on the Chat Completions wire " + "(no file reference form); pass a URL or inline data", provider=provider, + ) + payload: dict[str, Any] = {"url": part.url if part.url is not None else media_data_uri(part)} + if getattr(part, "detail", None): + payload["detail"] = part.detail + return {"type": "image_url", "image_url": payload} + + +def _chat_content_parts(msg: Message, *, force_array: bool = False, provider: str = "openai-chat") -> str | list[dict[str, Any]]: + """Map non-assistant message parts to chat-completions content. + + Single text part → plain string; anything multimodal → content array. + ``force_array`` keeps the array form for a lone text part (a cache + breakpoint rides on a text content block, never on a bare string). + A part the wire has no slot for RAISES (MAP-10); it is never rendered + as text or dropped. + """ + parts = [p for p in msg.parts if not isinstance(p, (ToolCallPart, ToolResultPart))] + if len(parts) == 1 and isinstance(parts[0], TextPart) and not force_array: + return parts[0].text + if len(parts) == 1 and isinstance(parts[0], DataPart) and not force_array: + return data_part_text(parts[0]) # a data part is text on this wire (D3): the same string form as a lone text part + out: list[dict[str, Any]] = [] + for part in parts: + if isinstance(part, TextPart): + out.append({"type": "text", "text": part.text}) + elif part.type == "image": + out.append(_chat_image_block(part, provider)) + elif isinstance(part, ThinkingPart): + continue # thinking is never replayed as user content + elif part.type in MEDIA_KINDS: + raise UnsupportedFeatureError( + f"{provider}: a {part.type} part in a {msg.role} message has no slot on the Chat Completions wire " + "(text and image_url only); the OpenAI Responses, Anthropic and Gemini dialects carry it (MAP-10)", + provider=provider, feature=f"messages[*].parts[{part.type}]", + ) + else: + out.append({"type": "text", "text": parts_to_text((part,), provider=provider)}) + return out + + +def _tool_row_content(provider: str, part: ToolResultPart, policy: str) -> str | list[dict[str, Any]]: + """A `role: tool` row's content (MAP-10): a string when text-only; on a + preset that proved the array form live, text and image_url blocks; a + media part the preset does not admit raises first. `is_error` rides + as an `[error] ` prefix (rule 5: the wire has no flag).""" + check_tool_result_media(provider, part, policy, wire="a Chat Completions tool row") + if all(p.type not in MEDIA_KINDS for p in part.content): + return tool_result_error_text(part, parts_to_text(part.content, provider=provider, where="a Chat Completions tool row")) + blocks: list[dict[str, Any]] = [] + for p in part.content: + if p.type == "image": + blocks.append(_chat_image_block(p, provider)) + else: + blocks.append({"type": "text", "text": parts_to_text((p,), provider=provider)}) + if part.is_error: + first = next((b for b in blocks if b["type"] == "text"), None) + if first is None: + blocks.insert(0, {"type": "text", "text": "[error]"}) + else: + first["text"] = "[error] " + first["text"] + return blocks + + +def _response_format_to_chat(format_config: dict[str, Any]) -> dict[str, Any]: + """Canonical response_format (INV-050) -> chat-completions response_format.""" + if format_config["type"] == "json_object": + return {"type": "json_object"} + inner: dict[str, Any] = {"name": format_config.get("name") or "response", "schema": format_config["schema"]} + if "strict" in format_config: + inner["strict"] = format_config["strict"] + return {"type": "json_schema", "json_schema": inner} + + +# ─── Ingest: a Chat Completions request body → canonical Request (MAP-12) ── +# +# The decoder for the encoders above and for OpenAIChatLM._build_messages / +# _payload below. It reads ONE preset's spellings (the same +# ResolvedOpenAIChatCompat the builder writes with), so for every canonical +# Request r that the builder can carry losslessly, +# request_from_openai_chat(build(r)) == r; the lossy cells are enumerated in +# docs/mapping-rules.md MAP-12 and pinned per case by the contract's `ingest` +# direction. Every wire key has exactly one verdict +# (lm15-contract/tools/openai-chat-ingest-verdicts.json): it maps to a +# canonical field, it passes verbatim through config.extensions, it is +# refused with UnsupportedFeatureError, or — for the four call-mode / +# default-valued keys only — it is ignored by rule. A key with no verdict is +# refused: lm15 never drops silently (port.md rule 4). +# +# Malformed input (a wrong JSON type, a missing required key, an unparsable +# tool-call argument string) is a ValueError / TypeError, like serde. + +# Top-level keys forwarded verbatim into config.extensions: generation knobs +# OpenAI documents that no canonical field expresses and that the builder +# re-emits verbatim (payload.update(extensions)), so they round-trip. +_INGEST_EXTENSIONS_KEYS: frozenset[str] = frozenset({ + "logit_bias", "metadata", "verbosity", "moderation", "provider", + # prediction: a latency hint (predicted outputs); harmless verbatim on + # OpenAI, dropped-with-note elsewhere (decision 2026-09-14 §4.9). + "prediction", +}) + +# Top-level keys refused with the reason a canonical Request cannot carry them. +_INGEST_REFUSED_KEYS: dict[str, str] = { + "n": "lm15 reads one choice per response; n>1 would silently lose choices — fan out in the caller", + "audio": "audio output parameters have no canonical slot on the chat surface", + "modalities": "output modality selection has no canonical slot on the chat surface", + "web_search_options": "a server-executed search the chat dialect cannot map to parts (MAP-1); the Responses dialect carries web_search as a BuiltinTool", +} + +# Call-mode keys: they say HOW the request is sent, not WHAT is asked. A +# canonical Request has no stream flag (stream=... is an argument of +# complete()/stream()), so these are read and dropped — the one place ingest +# drops anything, stated in MAP-12. +_INGEST_CALL_MODE_KEYS: frozenset[str] = frozenset({"stream", "stream_options"}) + +# Keys the config decoder consumes itself (every other key is looked up in +# the tables above, and an unlisted key is refused). +_INGEST_CONFIG_KEYS: frozenset[str] = frozenset({ + "model", "messages", "tools", "tool_choice", "parallel_tool_calls", + # functions / function_call: the deprecated function-calling shape, + # translated to tools / tool_choice (MAP-13: a pure spelling change). + "functions", "function_call", + "max_completion_tokens", "max_tokens", "temperature", "top_p", "top_k", "stop", + "seed", "frequency_penalty", "presence_penalty", + "logprobs", "top_logprobs", "response_format", "service_tier", "store", + "user", "safety_identifier", "user_id", + "reasoning_effort", "reasoning", "thinking", "enable_thinking", "chat_template_kwargs", "reasoning_format", + "prompt_cache_key", "prompt_cache_retention", "prompt_cache_options", +}) + +_INGEST_GROQ_BUILTIN_INVERSE: dict[str, str] = {wire: name for name, wire in _GROQ_BUILTIN_MAP.items()} + +_INGEST_AUDIO_MEDIA_TYPES: dict[str, str] = {"wav": "audio/wav", "mp3": "audio/mpeg"} + + +def _ingest_unsupported(provider: str, what: str, why: str) -> UnsupportedFeatureError: + return UnsupportedFeatureError(f"{provider}: {what} cannot be carried by a canonical Request — {why}", provider=provider) + + +def _ingest_str(value: Any, where: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{where} must be a string, got {type(value).__name__}") + return value + + +def _ingest_object(value: Any, where: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise TypeError(f"{where} must be a JSON object, got {type(value).__name__}") + return value + + +def _ingest_only_keys(provider: str, obj: Mapping[str, Any], allowed: frozenset[str], where: str) -> None: + """An unlisted key inside a block or object is a refusal, never a drop.""" + extra = sorted(set(obj) - allowed) + if extra: + raise _ingest_unsupported(provider, f"{where} key {extra[0]!r}", "no canonical slot for it") + + +def _ingest_data_uri(value: str, where: str) -> tuple[str, str]: + """``data:;base64,`` → (media_type, payload). The + inverse of media_data_uri; anything else is malformed here.""" + if not value.startswith("data:"): + raise ValueError(f"{where} must be a base64 data URI") + head, sep, payload = value[5:].partition(",") + if not sep or not head.endswith(";base64") or not payload: + raise ValueError(f"{where} must be a base64 data URI (data:;base64,)") + media_type = head[: -len(";base64")] + if not media_type: + raise ValueError(f"{where} data URI has no media type") + return media_type, payload + + +def _ingest_image_block(provider: str, block: Mapping[str, Any], where: str) -> ImagePart: + """Inverse of _chat_image_block. A data URI becomes inline data with the + URI's media type; any other URL stays a URL (media_type guessed from the + path, else the ImagePart default — the wire carries no type for a URL).""" + _ingest_only_keys(provider, block, frozenset({"type", "image_url", "prompt_cache_breakpoint"}), where) + spec = _ingest_object(block.get("image_url"), f"{where}.image_url") + _ingest_only_keys(provider, spec, frozenset({"url", "detail"}), f"{where}.image_url") + url = _ingest_str(spec.get("url"), f"{where}.image_url.url") + detail = spec.get("detail") + if url.startswith("data:"): + media_type, payload = _ingest_data_uri(url, f"{where}.image_url.url") + return image(data=payload, media_type=media_type, detail=detail) + guessed = mimetypes.guess_type(url)[0] + return image(url=url, media_type=guessed if guessed and guessed.startswith("image/") else None, detail=detail) + + +def _ingest_text_block(provider: str, block: Mapping[str, Any], where: str) -> TextPart: + _ingest_only_keys(provider, block, frozenset({"type", "text", "prompt_cache_breakpoint"}), where) + return TextPart(text=_ingest_str(block.get("text"), f"{where}.text")) + + +def _ingest_has_breakpoint(block: Mapping[str, Any], where: str) -> bool: + mark = block.get("prompt_cache_breakpoint") + if mark is None: + return False + mark = _ingest_object(mark, f"{where}.prompt_cache_breakpoint") + if mark != {"mode": "explicit"}: + raise ValueError(f"{where}.prompt_cache_breakpoint must be {{\"mode\": \"explicit\"}}") + if block.get("type") != "text": + # The builder places a mark on a text block only (CacheConfig table). + raise ValueError(f"{where}: a prompt_cache_breakpoint rides on a text block, not {block.get('type')!r}") + return True + + +def _ingest_content_blocks( + provider: str, content: Any, *, role: str, where: str, +) -> tuple[list[Part], bool]: + """A row's ``content`` → parts, plus whether its LAST block carries the + prompt-cache breakpoint. A string is one TextPart. Which block types a + role admits follows the wire's own schema (chat--create.md).""" + if isinstance(content, str): + return [TextPart(text=content)], False + if not isinstance(content, list): + raise TypeError(f"{where}.content must be a string or an array of content parts") + parts: list[Part] = [] + breakpoint_at_end = False + for index, block in enumerate(content): + block = _ingest_object(block, f"{where}.content[{index}]") + block_where = f"{where}.content[{index}]" + kind = block.get("type") + marked = _ingest_has_breakpoint(block, block_where) + if marked and index != len(content) - 1: + raise ValueError(f"{block_where}: a prompt_cache_breakpoint marks the end of a message; it must be on the last block") + breakpoint_at_end = breakpoint_at_end or marked + if kind == "text": + parts.append(_ingest_text_block(provider, block, block_where)) + elif kind == "image_url" and role in ("user", "tool"): + parts.append(_ingest_image_block(provider, block, block_where)) + elif kind == "input_audio" and role == "user": + _ingest_only_keys(provider, block, frozenset({"type", "input_audio", "prompt_cache_breakpoint"}), block_where) + spec = _ingest_object(block.get("input_audio"), f"{block_where}.input_audio") + _ingest_only_keys(provider, spec, frozenset({"data", "format"}), f"{block_where}.input_audio") + fmt = _ingest_str(spec.get("format"), f"{block_where}.input_audio.format") + media_type = _INGEST_AUDIO_MEDIA_TYPES.get(fmt) + if media_type is None: + raise ValueError(f"{block_where}.input_audio.format must be one of {sorted(_INGEST_AUDIO_MEDIA_TYPES)}") + parts.append(audio(data=_ingest_str(spec.get("data"), f"{block_where}.input_audio.data"), media_type=media_type)) + elif kind == "file" and role == "user": + _ingest_only_keys(provider, block, frozenset({"type", "file", "prompt_cache_breakpoint"}), block_where) + spec = _ingest_object(block.get("file"), f"{block_where}.file") + _ingest_only_keys(provider, spec, frozenset({"file_data", "file_id", "filename"}), f"{block_where}.file") + if spec.get("filename") is not None: + raise _ingest_unsupported(provider, f"{block_where}.file.filename", "DocumentPart has no filename slot") + if spec.get("file_id") is not None and spec.get("file_data") is None: + parts.append(document(file_id=_ingest_str(spec["file_id"], f"{block_where}.file.file_id"))) + elif spec.get("file_data") is not None and spec.get("file_id") is None: + media_type, payload = _ingest_data_uri(_ingest_str(spec["file_data"], f"{block_where}.file.file_data"), f"{block_where}.file.file_data") + parts.append(document(data=payload, media_type=media_type)) + else: + raise ValueError(f"{block_where}.file needs exactly one of file_data / file_id") + elif kind == "refusal" and role == "assistant": + _ingest_only_keys(provider, block, frozenset({"type", "refusal"}), block_where) + parts.append(RefusalPart(text=_ingest_str(block.get("refusal"), f"{block_where}.refusal"))) + else: + raise _ingest_unsupported( + provider, f"{block_where} of type {kind!r} in a {role} message", + "no canonical part for that block on this wire (a part is not a knob: there is no extensions door for content)", + ) + return parts, breakpoint_at_end + + +# Assistant-row keys that are a client library's object model, not the wire +# (litellm's ChatCompletionMessage dumped back into history). Their null or +# empty form carries nothing and reads as absent; a non-empty one is refused. +_INGEST_CLIENT_OBJECT_KEYS: frozenset[str] = frozenset({"provider_specific_fields", "thinking_blocks", "images"}) + + +def _ingest_all_empty(value: Any) -> bool: + """A dict whose every value is null/empty (litellm: {"refusal": null}).""" + return isinstance(value, dict) and all(v is None or v == [] or v == {} for v in value.values()) + + +def _ingest_annotations(provider: str, raw: Any, content_text: str | None, where: str) -> list[CitationPart]: + """OpenAI's assistant ``annotations`` (url_citation entries with a span + into the content) → CitationParts; an empty list is nothing.""" + if not isinstance(raw, list): + raise TypeError(f"{where}.annotations must be an array") + out: list[CitationPart] = [] + for index, entry in enumerate(raw): + entry_where = f"{where}.annotations[{index}]" + entry = _ingest_object(entry, entry_where) + if entry.get("type") != "url_citation": + raise _ingest_unsupported(provider, f"{entry_where} of type {entry.get('type')!r}", "only url_citation annotations have a canonical part (CitationPart)") + _ingest_only_keys(provider, entry, frozenset({"type", "url_citation"}), entry_where) + spec = _ingest_object(entry.get("url_citation"), f"{entry_where}.url_citation") + _ingest_only_keys(provider, spec, frozenset({"url", "title", "start_index", "end_index"}), f"{entry_where}.url_citation") + text: str | None = None + start, end = spec.get("start_index"), spec.get("end_index") + if content_text is not None and isinstance(start, int) and isinstance(end, int) and 0 <= start <= end <= len(content_text): + text = content_text[start:end] or None + out.append(CitationPart( + url=_ingest_str(spec.get("url"), f"{entry_where}.url_citation.url"), + title=_ingest_str(spec["title"], f"{entry_where}.url_citation.title") if spec.get("title") is not None else None, + text=text, + )) + return out + + +def _ingest_tool_calls(provider: str, calls: Any, where: str) -> list[ToolCallPart]: + if not isinstance(calls, list): + raise TypeError(f"{where}.tool_calls must be an array") + out: list[ToolCallPart] = [] + for index, call in enumerate(calls): + call = _ingest_object(call, f"{where}.tool_calls[{index}]") + call_where = f"{where}.tool_calls[{index}]" + kind = call.get("type", "function") + if kind != "function": + raise _ingest_unsupported(provider, f"{call_where} of type {kind!r}", "only function tool calls have a canonical part") + _ingest_only_keys(provider, call, frozenset({"id", "type", "function"}), call_where) + function = _ingest_object(call.get("function"), f"{call_where}.function") + _ingest_only_keys(provider, function, frozenset({"name", "arguments"}), f"{call_where}.function") + arguments = function.get("arguments", "{}") + if isinstance(arguments, str): + # The builder writes json.dumps(input); the inverse is exact, and + # a caller-authored string that is not a JSON object is malformed + # (the lenient parse_json_object is for PROVIDER output only). + try: + parsed = json.loads(arguments) if arguments else {} + except ValueError as exc: + raise ValueError(f"{call_where}.function.arguments is not JSON: {exc}") from None + else: + parsed = arguments + if not isinstance(parsed, dict): + raise ValueError(f"{call_where}.function.arguments must encode a JSON object") + out.append(ToolCallPart( + id=_ingest_str(call.get("id"), f"{call_where}.id"), + name=_ingest_str(function.get("name"), f"{call_where}.function.name"), + input=parsed, + )) + return out + + +def _ingest_messages( + provider: str, rows: Any, compat: ResolvedOpenAIChatCompat, +) -> tuple[str | tuple[Part, ...] | None, list[Message], bool, int | None]: + """Wire rows → (system, messages, system_breakpoint, breakpoint_message_index). + + Rules (MAP-12): the first row, when system/developer, is Request.system + (the builder renders system there under compat.instruction_role, so the + wire cannot tell a system prompt from a leading developer message); + a later system/developer row is a developer Message; consecutive tool + rows form one tool Message (the builder emits one row per result part); + a `name` on any row is refused (no canonical slot).""" + if not isinstance(rows, list): + raise TypeError("messages must be an array") + system: str | tuple[Part, ...] | None = None + system_breakpoint = False + breakpoint_index: int | None = None + messages: list[Message] = [] + pending_results: list[ToolResultPart] = [] + + def flush_results() -> None: + if pending_results: + messages.append(Message(role="tool", parts=tuple(pending_results))) + pending_results.clear() + + for index, row in enumerate(rows): + row = _ingest_object(row, f"messages[{index}]") + where = f"messages[{index}]" + role = row.get("role") + if row.get("name") is not None and role != "tool": + raise _ingest_unsupported(provider, f"{where}.name", "a per-message participant name has no canonical slot") + + if role in ("system", "developer"): + flush_results() + _ingest_only_keys(provider, row, frozenset({"role", "content"}), where) + parts, marked = _ingest_content_blocks(provider, row.get("content"), role="system", where=where) + if index == 0: + if marked: + system_breakpoint = True + if len(parts) == 1 and isinstance(parts[0], TextPart): + system = parts[0].text + else: + system = tuple(parts) + else: + if marked: + breakpoint_index = len(messages) + messages.append(Message(role="developer", parts=tuple(parts))) + continue + + if role == "user": + flush_results() + _ingest_only_keys(provider, row, frozenset({"role", "content", "name"}), where) + parts, marked = _ingest_content_blocks(provider, row.get("content"), role="user", where=where) + if marked: + if breakpoint_index is not None or system_breakpoint: + raise ValueError(f"{where}: a request carries at most one prompt_cache_breakpoint") + breakpoint_index = len(messages) + messages.append(Message(role="user", parts=tuple(parts))) + continue + + if role == "assistant": + flush_results() + _ingest_only_keys( + provider, row, + frozenset({"role", "content", "tool_calls", "refusal", "reasoning_content", "name", "audio", "function_call", + "annotations", *_INGEST_CLIENT_OBJECT_KEYS}), + where, + ) + if row.get("audio") is not None: + raise _ingest_unsupported(provider, f"{where}.audio", "an assistant audio reference has no canonical part") + if row.get("function_call") is not None: + raise _ingest_unsupported(provider, f"{where}.function_call", "the deprecated function-calling shape; use tool_calls") + for key in _INGEST_CLIENT_OBJECT_KEYS: + # A client library's object model (litellm) dumped into history: + # null or empty carries nothing; anything else has no mapping. + value = row.get(key) + if value is not None and value != [] and value != {} and not _ingest_all_empty(value): + raise _ingest_unsupported(provider, f"{where}.{key}", "a client library's own field with no canonical part; only its empty form reads as absent") + parts: list[Part] = [] + reasoning_text = row.get("reasoning_content") + if reasoning_text is not None: + parts.append(ThinkingPart(text=_ingest_str(reasoning_text, f"{where}.reasoning_content"))) + content = row.get("content") + if content is not None: + text_parts, marked = _ingest_content_blocks(provider, content, role="assistant", where=where) + if marked: + raise ValueError(f"{where}: a prompt_cache_breakpoint cannot mark an assistant message (the builder refuses the same cell)") + parts.extend(text_parts) + refusal_text = row.get("refusal") + if refusal_text is not None: + parts.append(RefusalPart(text=_ingest_str(refusal_text, f"{where}.refusal"))) + if row.get("tool_calls") is not None: + parts.extend(_ingest_tool_calls(provider, row["tool_calls"], where)) + if row.get("annotations") is not None: + parts.extend(_ingest_annotations(provider, row["annotations"], content if isinstance(content, str) else None, where)) + if not parts: + parts.append(TextPart(text="")) # the never-empty rule (MAP-2), applied to history + messages.append(Message(role="assistant", parts=tuple(parts))) + continue + + if role == "tool": + _ingest_only_keys(provider, row, frozenset({"role", "content", "tool_call_id", "name"}), where) + parts, marked = _ingest_content_blocks(provider, row.get("content"), role="tool", where=where) + if marked: + raise ValueError(f"{where}: a prompt_cache_breakpoint cannot mark a tool message (the builder refuses the same cell)") + name = row.get("name") + pending_results.append(ToolResultPart( + id=_ingest_str(row.get("tool_call_id"), f"{where}.tool_call_id"), + content=tuple(parts), + name=_ingest_str(name, f"{where}.name") if name is not None else None, + )) + continue + + if role == "function": + raise _ingest_unsupported(provider, f"{where} with role 'function'", "the deprecated function-calling shape; use a tool row with tool_call_id") + raise ValueError(f"{where}.role must be one of system, developer, user, assistant, tool; got {role!r}") + + flush_results() + return system, messages, system_breakpoint, breakpoint_index + + +def _ingest_tools(provider: str, raw: Any, compat: ResolvedOpenAIChatCompat) -> list[FunctionTool | BuiltinTool]: + if raw is None: + return [] + if not isinstance(raw, list): + raise TypeError("tools must be an array") + tools: list[FunctionTool | BuiltinTool] = [] + for index, entry in enumerate(raw): + entry = _ingest_object(entry, f"tools[{index}]") + where = f"tools[{index}]" + kind = entry.get("type") + if kind == "function": + _ingest_only_keys(provider, entry, frozenset({"type", "function"}), where) + function = _ingest_object(entry.get("function"), f"{where}.function") + _ingest_only_keys(provider, function, frozenset({"name", "description", "parameters", "strict"}), f"{where}.function") + if function.get("strict") is True: + # A per-tool strict flag has no canonical slot; dropping it + # would lose an enforcement the caller asked for. `false` is + # the wire default and carries nothing. + raise _ingest_unsupported(provider, f"{where}.function.strict = true", "no per-tool strict slot (compat.strict_tools is a preset policy)") + kwargs: dict[str, Any] = {"name": _ingest_str(function.get("name"), f"{where}.function.name")} + if function.get("description") is not None: + kwargs["description"] = _ingest_str(function["description"], f"{where}.function.description") + if function.get("parameters") is not None: + kwargs["parameters"] = _ingest_object(function["parameters"], f"{where}.function.parameters") + tools.append(FunctionTool(**kwargs)) + elif kind in _INGEST_GROQ_BUILTIN_INVERSE and compat.builtin_tools == "groq": + config = {k: v for k, v in entry.items() if k != "type"} + tools.append(BuiltinTool(name=_INGEST_GROQ_BUILTIN_INVERSE[kind], config=config or None)) + else: + raise _ingest_unsupported(provider, f"{where} of type {kind!r}", "only function tools (and, on the groq preset, its server-executed tools) have a canonical form") + return tools + + +def _tool_choice_from_function_call(raw: Any) -> Any: + """The deprecated ``function_call`` spelling → the ``tool_choice`` shape.""" + if raw in ("none", "auto"): + return raw + if isinstance(raw, Mapping) and "name" in raw: + return {"type": "function", "function": {"name": raw["name"]}} + raise ValueError(f"function_call must be 'none', 'auto', or {{name}}; got {raw!r}") + + +def _ingest_tool_choice(provider: str, raw: Any, parallel: Any) -> ToolChoice | None: + """Inverse of OpenAIChatLM._tool_choice_payload plus parallel_tool_calls.""" + mode: str | None = None + allowed: tuple[str, ...] = () + if raw is not None: + if raw in ("none", "auto", "required"): + mode = str(raw) + elif isinstance(raw, dict): + kind = raw.get("type") + if kind == "function": + _ingest_only_keys(provider, raw, frozenset({"type", "function"}), "tool_choice") + function = _ingest_object(raw.get("function"), "tool_choice.function") + _ingest_only_keys(provider, function, frozenset({"name"}), "tool_choice.function") + mode, allowed = "required", (_ingest_str(function.get("name"), "tool_choice.function.name"),) + elif kind == "allowed_tools": + _ingest_only_keys(provider, raw, frozenset({"type", "allowed_tools"}), "tool_choice") + spec = _ingest_object(raw.get("allowed_tools"), "tool_choice.allowed_tools") + _ingest_only_keys(provider, spec, frozenset({"mode", "tools"}), "tool_choice.allowed_tools") + mode = _ingest_str(spec.get("mode"), "tool_choice.allowed_tools.mode") + entries = spec.get("tools") + if not isinstance(entries, list) or not entries: + raise ValueError("tool_choice.allowed_tools.tools must be a non-empty array") + names: list[str] = [] + for index, entry in enumerate(entries): + entry = _ingest_object(entry, f"tool_choice.allowed_tools.tools[{index}]") + if entry.get("type") != "function": + raise _ingest_unsupported(provider, f"tool_choice.allowed_tools.tools[{index}] of type {entry.get('type')!r}", "only function tools can be allowed on this wire") + function = _ingest_object(entry.get("function"), f"tool_choice.allowed_tools.tools[{index}].function") + names.append(_ingest_str(function.get("name"), f"tool_choice.allowed_tools.tools[{index}].function.name")) + allowed = tuple(names) + elif kind == "custom": + raise _ingest_unsupported(provider, "tool_choice of type 'custom'", "custom tools have no canonical form") + else: + raise ValueError(f"tool_choice.type must be function or allowed_tools; got {kind!r}") + else: + raise ValueError("tool_choice must be none, auto, required, or an object") + if parallel is not None and not isinstance(parallel, bool): + raise TypeError("parallel_tool_calls must be a boolean") + if mode is None and parallel is None: + return None + return ToolChoice(mode=mode or "auto", allowed=allowed, parallel=parallel) + + +def _ingest_response_format(provider: str, raw: Any) -> dict[str, Any] | None: + """Inverse of _response_format_to_chat (INV-050 shapes). ``{type: text}`` + is the wire default and reads as absent. A ``name`` of exactly + "response" is the builder's default label for an unnamed schema, so it + reads as absent too: the wire bytes are identical either way.""" + raw = _ingest_object(raw, "response_format") + kind = raw.get("type") + if kind == "text": + _ingest_only_keys(provider, raw, frozenset({"type"}), "response_format") + return None + if kind == "json_object": + _ingest_only_keys(provider, raw, frozenset({"type"}), "response_format") + return {"type": "json_object"} + if kind == "json_schema": + _ingest_only_keys(provider, raw, frozenset({"type", "json_schema"}), "response_format") + inner = _ingest_object(raw.get("json_schema"), "response_format.json_schema") + _ingest_only_keys(provider, inner, frozenset({"name", "schema", "strict", "description"}), "response_format.json_schema") + if inner.get("description") is not None: + raise _ingest_unsupported(provider, "response_format.json_schema.description", "the canonical response_format has no description slot (INV-050)") + out: dict[str, Any] = {"type": "json_schema", "schema": _ingest_object(inner.get("schema"), "response_format.json_schema.schema")} + name = inner.get("name") + if name is not None and name != "response": + out["name"] = _ingest_str(name, "response_format.json_schema.name") + if inner.get("strict") is not None: + if not isinstance(inner["strict"], bool): + raise TypeError("response_format.json_schema.strict must be a boolean") + out["strict"] = inner["strict"] + return out + raise ValueError(f"response_format.type must be text, json_object or json_schema; got {kind!r}") + + +def _ingest_reasoning(provider: str, body: Mapping[str, Any], compat: ResolvedOpenAIChatCompat) -> tuple[Reasoning | None, dict[str, Any]]: + """Inverse of the reasoning block of _payload for THIS preset's + thinking_format (MAP-7 spellings). Returns the Reasoning and any + extensions entries the spelling implies (groq's `reasoning_format` + alone is the documented extensions door).""" + fmt = compat.thinking_format + present = {k for k in ("reasoning_effort", "reasoning", "thinking", "enable_thinking", "chat_template_kwargs", "reasoning_format") if k in body} + if not present: + return None, {} + spelled_by = { + "reasoning_effort": {"reasoning_effort"}, + "openrouter": {"reasoning"}, + "deepseek": {"thinking", "reasoning_effort"}, + "kimi": {"thinking", "reasoning_effort"}, + "qwen": {"enable_thinking"}, + "qwen_chat_template": {"chat_template_kwargs"}, + "none": set(), + }[fmt] + if compat.builtin_tools == "groq": + spelled_by = spelled_by | {"reasoning_format"} + foreign = sorted(present - spelled_by) + if foreign: + raise _ingest_unsupported( + provider, f"{foreign[0]!r}", + f"this server's reasoning dial is spelled {sorted(spelled_by) or 'nowhere (no dial)'}; another server's spelling would be sent and ignored", + ) + extensions: dict[str, Any] = {} + effort: str | None = None + off = False + + if "reasoning_effort" in present: + word = _ingest_str(body["reasoning_effort"], "reasoning_effort") + if word == "none": + off = True + else: + effort = word + if "thinking" in present: + spec = _ingest_object(body["thinking"], "thinking") + _ingest_only_keys(provider, spec, frozenset({"type"}), "thinking") + if spec.get("type") == "disabled": + if effort is not None: + raise ValueError("thinking.type=disabled next to a reasoning_effort level is contradictory") + off = True + elif spec.get("type") == "enabled": + if effort is None and not off: + raise _ingest_unsupported(provider, "thinking.type=enabled without reasoning_effort", "lm15's dial is a level (MAP-7); set config.reasoning with an effort word") + else: + raise ValueError(f"thinking.type must be enabled or disabled; got {spec.get('type')!r}") + if "reasoning" in present: + spec = _ingest_object(body["reasoning"], "reasoning") + _ingest_only_keys(provider, spec, frozenset({"effort", "enabled"}), "reasoning") + if spec.get("enabled") is False: + off = True + elif spec.get("effort") is not None: + effort = _ingest_str(spec["effort"], "reasoning.effort") + else: + raise ValueError("reasoning must carry effort or enabled: false") + if "enable_thinking" in present: + flag = body["enable_thinking"] + if flag is False: + off = True + elif flag is True: + raise _ingest_unsupported(provider, "enable_thinking = true", "this wire has no effort level; lm15's dial is a level (MAP-7) — set config.reasoning yourself") + else: + raise TypeError("enable_thinking must be a boolean") + if "chat_template_kwargs" in present: + spec = _ingest_object(body["chat_template_kwargs"], "chat_template_kwargs") + _ingest_only_keys(provider, spec, frozenset({"enable_thinking", "preserve_thinking"}), "chat_template_kwargs") + if spec.get("enable_thinking") is False: + off = True + elif spec.get("enable_thinking") is True: + raise _ingest_unsupported(provider, "chat_template_kwargs.enable_thinking = true", "this wire has no effort level; lm15's dial is a level (MAP-7) — set config.reasoning yourself") + else: + raise TypeError("chat_template_kwargs.enable_thinking must be a boolean") + + summary: str | None = None + if "reasoning_format" in present: + value = body["reasoning_format"] + if value != "parsed": + raise _ingest_unsupported(provider, f"reasoning_format = {value!r}", "only 'parsed' maps (Reasoning.summary='auto', MAP-7 rule 7)") + if effort is None: + # The documented door: extensions={"reasoning_format": "parsed"} + # with reasoning absent (the Qwen dial has no levels). + extensions["reasoning_format"] = value + else: + summary = "auto" + + if off: + return Reasoning(effort="off"), extensions + if effort is None: + return None, extensions + return Reasoning(effort=effort, summary=summary), extensions + + +def _ingest_cache( + provider: str, body: Mapping[str, Any], compat: ResolvedOpenAIChatCompat, + *, system_breakpoint: bool, breakpoint_index: int | None, +) -> CacheConfig | None: + """Inverse of _cache_common_payload and the breakpoint placement (MAP-6 + on the OpenAI classes). Under a preset with no OpenAI cache control the + keys are refused: the server has no such field.""" + keys = {k for k in ("prompt_cache_key", "prompt_cache_retention", "prompt_cache_options") if k in body} + marked = system_breakpoint or breakpoint_index is not None + if not keys and not marked: + return None + if compat.cache_control not in ("openai", "openai_implicit"): + what = sorted(keys)[0] if keys else "prompt_cache_breakpoint" + raise _ingest_unsupported(provider, f"{what!r}", "this server has no OpenAI prompt-cache control (compat.cache_control)") + if marked and compat.cache_control != "openai": + raise _ingest_unsupported(provider, "prompt_cache_breakpoint", "this server swallows an explicit breakpoint silently (compat.cache_control=openai_implicit)") + key = body.get("prompt_cache_key") + retention = None + if "prompt_cache_retention" in body: + if body["prompt_cache_retention"] != "24h": + raise _ingest_unsupported(provider, f"prompt_cache_retention = {body['prompt_cache_retention']!r}", "only '24h' has a canonical value (CacheConfig.retention='long')") + retention = "long" + explicit = False + if "prompt_cache_options" in body: + spec = _ingest_object(body["prompt_cache_options"], "prompt_cache_options") + _ingest_only_keys(provider, spec, frozenset({"mode", "ttl"}), "prompt_cache_options") + if spec.get("ttl") is not None: + raise _ingest_unsupported(provider, "prompt_cache_options.ttl", "CacheConfig.retention names 24h only") + if spec.get("mode") == "explicit": + explicit = True + elif spec.get("mode") == "implicit": + raise _ingest_unsupported(provider, "prompt_cache_options.mode = 'implicit'", "the server default; a canonical CacheConfig names auto or off") + else: + raise ValueError(f"prompt_cache_options.mode must be explicit or implicit; got {spec.get('mode')!r}") + if explicit and not marked: + # Explicit mode with no mark is the cache-WRITE off switch (MAP-6 rule 2). + if key is not None or retention is not None: + raise ValueError("prompt_cache_options.mode=explicit with no breakpoint is the off switch; it cannot carry a key or retention (INV-027)") + return CacheConfig(mode="off") + if system_breakpoint: + return CacheConfig(prefix="stable", key=key, retention=retention) + if breakpoint_index is not None: + return CacheConfig(prefix_until_index=breakpoint_index, key=key, retention=retention) + return CacheConfig(key=key, retention=retention) + + +def _ingest_config( + provider: str, body: Mapping[str, Any], compat: ResolvedOpenAIChatCompat, + *, system_breakpoint: bool, breakpoint_index: int | None, +) -> Config: + kwargs: dict[str, Any] = {} + if "max_completion_tokens" in body or "max_tokens" in body: + values = {k: body[k] for k in ("max_completion_tokens", "max_tokens") if k in body} + if len(set(map(repr, values.values()))) > 1: + raise ValueError(f"max_tokens and max_completion_tokens disagree: {values}") + kwargs["max_tokens"] = next(iter(values.values())) + for key in ("temperature", "top_p", "top_k", "seed", "frequency_penalty", "presence_penalty", "service_tier", "store"): + if key in body: + kwargs[key] = body[key] + if "stop" in body: + kwargs["stop"] = body["stop"] + if body.get("logprobs") is True: + top = body.get("top_logprobs", 0) + kwargs["logprobs"] = top + elif body.get("logprobs") not in (None, False): + raise TypeError("logprobs must be a boolean") + elif "top_logprobs" in body: + raise ValueError("top_logprobs requires logprobs: true") + if "response_format" in body: + kwargs["response_format"] = _ingest_response_format(provider, body["response_format"]) + if "function_call" in body and "tool_choice" in body: + raise ValueError("function_call and tool_choice cannot both be given") + kwargs["tool_choice"] = _ingest_tool_choice( + provider, _tool_choice_from_function_call(body["function_call"]) if "function_call" in body else body.get("tool_choice"), + body.get("parallel_tool_calls"), + ) + + user_keys = [k for k in ("user", "safety_identifier", "user_id") if k in body] + if "user_id" in user_keys and compat.user_field != "user_id": + raise _ingest_unsupported(provider, "'user_id'", f"this server spells the end-user field {compat.user_field!r}") + if len(user_keys) > 1: + raise ValueError(f"one end-user identifier only; got {user_keys}") + if user_keys: + kwargs["user_id"] = body[user_keys[0]] + + reasoning, extensions = _ingest_reasoning(provider, body, compat) + kwargs["reasoning"] = reasoning + kwargs["cache"] = _ingest_cache(provider, body, compat, system_breakpoint=system_breakpoint, breakpoint_index=breakpoint_index) + for key in body: + if key in _INGEST_EXTENSIONS_KEYS: + extensions[key] = body[key] + kwargs["extensions"] = extensions or None + return Config(**kwargs) + + +def _ingest_openai_chat(provider: str, body: Mapping[str, Any], compat: ResolvedOpenAIChatCompat) -> Request: + if not isinstance(body, Mapping): + raise TypeError(f"a Chat Completions request body is a JSON object, got {type(body).__name__}") + for key in body: + if key in _INGEST_REFUSED_KEYS: + raise _ingest_unsupported(provider, f"{key!r}", _INGEST_REFUSED_KEYS[key]) + if key not in _INGEST_CONFIG_KEYS and key not in _INGEST_EXTENSIONS_KEYS and key not in _INGEST_CALL_MODE_KEYS: + raise _ingest_unsupported(provider, f"{key!r}", "no verdict for this key (lm15-contract/tools/openai-chat-ingest-verdicts.json); lm15 never drops a key silently") + model = body.get("model") + if not isinstance(model, str) or not model: + raise ValueError("model must be a non-empty string") + if "messages" not in body: + raise ValueError("messages is required") + system, messages, system_breakpoint, breakpoint_index = _ingest_messages(provider, body["messages"], compat) + if "functions" in body and "tools" in body: + raise ValueError("functions and tools cannot both be given") + raw_tools = body.get("tools") + if "functions" in body: + if not isinstance(body["functions"], list): + raise TypeError("functions must be an array") + raw_tools = [{"type": "function", "function": fn} for fn in body["functions"]] + tools = _ingest_tools(provider, raw_tools, compat) + config = _ingest_config(provider, body, compat, system_breakpoint=system_breakpoint, breakpoint_index=breakpoint_index) + return Request(model=model, messages=tuple(messages), system=system, tools=tuple(tools), config=config) + + +def request_from_openai_chat(body: Mapping[str, Any], *, compat: OpenAIChatCompat | str | None = None) -> Request: + """A Chat Completions request body → the canonical :class:`Request` (MAP-12). + + ``body`` is the JSON object a client would POST to ``/chat/completions`` + (``model``, ``messages``, ``tools``, generation knobs). ``compat`` names + the server dialect whose spellings are read — a preset name + (``"openai"``, ``"groq"``, ``"deepseek"``, …), an + :class:`OpenAIChatCompat`, or None for OpenAI's own — the same policy + :class:`OpenAIChatLM` writes with, so what that adapter emits for a + Request reads back as that Request wherever the wire can carry it. + + Every key has one verdict: it maps to a canonical field, it passes + verbatim through ``config.extensions`` (``seed``, ``logit_bias``, + ``presence_penalty``, ``frequency_penalty``, ``metadata``, …), or it is + refused with :class:`UnsupportedFeatureError` naming the key (``n``, + the deprecated ``functions`` shape, a content block with no part, a + spelling another server owns). ``stream`` / ``stream_options`` are + call-mode keys with no place on a Request and are read and dropped. + Malformed input raises ``ValueError`` / ``TypeError``. + + On the adapter, ``lm.request_from_openai_chat(body)`` is the same + function under that adapter's compat, including its per-model overrides. + """ + if isinstance(compat, str): + partial = OpenAIChatCompat.preset(compat) + elif isinstance(compat, OpenAIChatCompat): + partial = compat + elif compat is None: + partial = OpenAIChatCompat() + else: + raise TypeError("compat must be a preset name, an OpenAIChatCompat, or None") + model = body.get("model") if isinstance(body, Mapping) else None + resolved = resolve_openai_chat_compat(partial.for_model(model) if isinstance(model, str) else partial) + return _ingest_openai_chat("openai-chat", body, resolved) + + +def _response_from_chat_body( + provider: str, data: Any, *, model: str | None, choice: int | None, + on_error: Callable[[str, str], ProviderError], +) -> Response: + """The one Chat Completions response reader: ``parse_response`` for + provider traffic and :func:`response_from_openai_chat` for a foreign + dict share it. ``choice`` names the choice to read; ``None`` means + "the only one", and a body with several choices is then refused + rather than silently reduced to its first (the reading-side twin of + MAP-12's refusal of ``n``).""" + if not isinstance(data, Mapping): + raise TypeError(f"a Chat Completions response body is a JSON object, got {type(data).__name__}") + resp_error = data.get("error") + if isinstance(resp_error, dict): + raise on_error(str(resp_error.get("code") or ""), str(resp_error.get("message") or resp_error)) + + unmapped: list[dict[str, str]] = [] + choices = data.get("choices") or [] + if not isinstance(choices, list): + raise TypeError("choices must be an array") + if choice is None: + if len(choices) > 1: + raise UnsupportedFeatureError( + f"{provider}: the body carries {len(choices)} choices; a canonical Response is one message — " + "name the choice to read (choice=i) and read each one, or send no n", + provider=provider, feature="n", + ) + index = 0 + else: + if choice < 0 or choice >= len(choices): + raise ValueError(f"choice={choice} but the body carries {len(choices)} choice(s)") + index = choice + path = f"choices[{index}]" + chosen = choices[index] if choices and isinstance(choices[index], dict) else {} + if choices and not isinstance(choices[index], dict): + _record_unmapped(unmapped, path, type(choices[index]).__name__) + message = chosen.get("message") if isinstance(chosen.get("message"), dict) else {} + + parts: list[Any] = [] + reasoning_text = message.get("reasoning_content") or message.get("reasoning") + if reasoning_text: + parts.append(ThinkingPart(text=str(reasoning_text))) + + content = message.get("content") + if isinstance(content, str): + if content: + parts.append(TextPart(text=content)) + elif isinstance(content, list): + for content_index, item in enumerate(content): + if isinstance(item, dict) and item.get("type") == "text": + parts.append(TextPart(text=str(item.get("text") or ""))) + else: + _record_unmapped( + unmapped, + f"{path}.message.content[{content_index}]", + item.get("type") if isinstance(item, dict) else type(item).__name__, + ) + elif content is not None: + _record_unmapped(unmapped, f"{path}.message.content", type(content).__name__) + + refusal = message.get("refusal") + if refusal: + parts.append(RefusalPart(text=str(refusal))) + + for call_index, call in enumerate(message.get("tool_calls") or []): + if not isinstance(call, dict): + _record_unmapped(unmapped, f"{path}.message.tool_calls[{call_index}]", type(call).__name__) + continue + call_type = call.get("type") or "function" + if call_type != "function": + _record_unmapped(unmapped, f"{path}.message.tool_calls[{call_index}]", call_type) + continue + function = call.get("function") if isinstance(call.get("function"), dict) else {} + if not function.get("name"): + raise unnamed_tool_call_error(provider, f"{path}.message.tool_calls[{call_index}]") + parts.append( + ToolCallPart( + id=str(call.get("id") or f"call_{len(parts)}"), + name=str(function["name"]), + input=parse_json_object(function.get("arguments")), + ) + ) + + if not parts: + # MAP-2: a response message is never empty. + parts = [TextPart(text="")] + + has_tool = any(isinstance(part, ToolCallPart) for part in parts) + usage = _usage_from_chat(data.get("usage") or {}) + # choices[i].logprobs.content is the message-level token sequence. + # Refusal logprobs (choices[i].logprobs.refusal) stay in + # provider_data — no canonical refusal-token concept. + logprobs_payload = chosen.get("logprobs") if isinstance(chosen.get("logprobs"), dict) else {} + logprob_seq = openai_token_logprobs(logprobs_payload.get("content")) + resolved_model = data.get("model") or model + if not resolved_model: + raise ValueError("the body carries no model; pass model=") + return Response( + id=str(data.get("id")) if data.get("id") else None, + model=str(resolved_model), + message=Message(role="assistant", parts=tuple(parts)), + finish_reason=OpenAIChatLM._finish_reason(chosen.get("finish_reason"), has_tool_call=has_tool, unmapped=unmapped, path=path), + usage=usage, + logprobs=logprob_seq or None, + provider_data=_attach_unmapped(dict(data), unmapped), + ) + + +def _fold_judgments(resp: Response, response_format: Mapping[str, Any] | None) -> Response: + """MAP-14 §3: the single text part of a judgment answer becomes a DataPart.""" + if not isinstance(response_format, Mapping) or response_format.get("type") != "json_schema": + return resp + found = judgments_in_schema(response_format.get("schema")) + if not found: + return resp + parts = replace_text_with_data(resp.message.parts, found) + return replace(resp, message=Message(role="assistant", parts=parts, continuation=resp.message.continuation)) + + +def response_from_openai_chat(body: Mapping[str, Any], *, model: str | None = None, choice: int | None = None, + response_format: Mapping[str, Any] | None = None) -> Response: + """A Chat Completions response body → the canonical :class:`Response`. + + The reading-side twin of :func:`request_from_openai_chat`: ``body`` is + the JSON object a Chat Completions server (or a client library that + imitates one — litellm's ``ModelResponse.model_dump()``) returned. It + is the same reader :meth:`OpenAIChatLM.parse_response` runs on provider + traffic, so what it maps (text, ``reasoning_content`` → thinking, tool + calls, refusal, usage, logprobs, finish reason) and what it records as + unmapped (``provider_data["_lm15_unmapped"]``) are pinned by the + contract's ``response`` direction. An error envelope raises the typed + provider error. + + ``model`` fills ``Response.model`` when the body carries none. + ``choice`` names the choice to read; left unset, a body with more than + one choice is refused (a Response is one message; read each choice by + index instead) — the twin of the request side's refusal of ``n``. + Keys the reader does not know are neither refused nor lost: the whole + body is ``provider_data``. No compat is taken: the Chat Completions + response shape does not vary by server the way the request does. + """ + def on_error(code: str, message: str) -> ProviderError: + cls = OpenAILM._response_error_code_map.get(code, ServerError) + return cls(message or code or "provider error", provider="openai-chat", provider_code=code or None) + + return _fold_judgments(_response_from_chat_body("openai-chat", body, model=model, choice=choice, on_error=on_error), response_format) + + +def _usage_from_chat(usage_data: dict[str, Any]) -> Usage: + prompt_details = usage_data.get("prompt_tokens_details") or {} + completion_details = usage_data.get("completion_tokens_details") or {} + return Usage( + input_tokens=usage_data.get("prompt_tokens"), + output_tokens=usage_data.get("completion_tokens"), + total_tokens=usage_data.get("total_tokens"), + reasoning_tokens=completion_details.get("reasoning_tokens"), + cache_read_tokens=prompt_details.get("cached_tokens"), + cache_write_tokens=prompt_details.get("cache_write_tokens"), + input_audio_tokens=prompt_details.get("audio_tokens"), + output_audio_tokens=completion_details.get("audio_tokens"), + ) + + +@dataclass(slots=True) +class OpenAIChatLM(BaseProviderLM): + """Adapter for the OpenAI Chat Completions wire dialect. + + ``compat`` may be an :class:`OpenAIChatCompat`, a preset name + (``"ollama"``, ``"groq"``, ``"openrouter"``, ``"vllm"``, ``"sglang"``, + ``"openai"``, …), or None (plain OpenAI policy). A preset name also + supplies that server's default ``base_url``; an explicit non-default + ``base_url`` argument always wins. + """ + + api_key: Credential | None = field(default=None, repr=False) + transport: SyncTransport = field(default_factory=default_transport) + base_url: str = _DEFAULT_BASE_URL + compat: OpenAIChatCompat | str | None = None + access: ProviderManifest | None = field(default=None, repr=False) + credentials_path: "str | os.PathLike[str] | None" = field(default=None, repr=False) + settings: "Mapping[str, str] | None" = None + # A named cloud identity ("platform", "workload", "environment", "cli"; + # AUTH-1) on a cloud door, instead of api_key=. + credential: str | None = field(default=None, kw_only=True) + clock: "Callable[[], datetime] | None" = field(default=None, repr=False) + + # MAP-13 policy: "note" (adapt and record), "silent", or "refuse". + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + provider: str = field(default="openai-chat", init=False) + account_id: str | None = field(default=None, init=False, repr=False) + manifest: ClassVar[ProviderManifest] = OPENAI_CHAT_API + + # OpenAI-compatible servers reuse the same error envelope family; + # share the Responses adapter's mapping verbatim. + _response_error_code_map: ClassVar[dict[str, type[ProviderError]]] = OpenAILM._response_error_code_map + _model_error_codes: ClassVar[frozenset[str]] = OpenAILM._model_error_codes + _stream_error_code_map: ClassVar[dict[str, type[ProviderError]]] = OpenAILM._stream_error_code_map + + _is_model_error = staticmethod(OpenAILM._is_model_error) + _response_error = OpenAILM._response_error + _error_detail = OpenAILM._error_detail + normalize_error = OpenAILM.normalize_error + + def __post_init__(self) -> None: + check_policy(self.adaptations) + self._bind_access(self.access, credentials_path=self.credentials_path, default_base_url=_DEFAULT_BASE_URL, settings=self.settings, + credential=self.credential) + compat = self.compat if self.compat is not None else self._registry_compat() + if isinstance(compat, str): + partial = OpenAIChatCompat.preset(compat) + if self.base_url == _DEFAULT_BASE_URL: + self.base_url = preset_base_url( + OPENAI_CHAT_PRESET_BASE_URLS, compat, dialect="Chat Completions", default_preset="openai" + ) + elif isinstance(compat, OpenAIChatCompat): + partial = compat + else: + partial = OpenAIChatCompat() + self._compat_partial = partial + self._resolved_compat = resolve_openai_chat_compat(partial) + + _resolved_compat: ResolvedOpenAIChatCompat = field(init=False, repr=False, default=ResolvedOpenAIChatCompat()) + _compat_partial: OpenAIChatCompat | None = field(init=False, repr=False, default=None) + + def _headers(self) -> dict[str, str]: + headers: dict[str, str] = {} # auth is applied once, in _emit (AUTH-2) + headers["Content-Type"] = "application/json" + for key, static in self.access.headers: + headers[key] = static + return headers + + # ─── Live model listing (provisional endpoint) ────────────────────── + + def _models_request(self): + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/models", + headers=self._headers(), + ) + + def _models_from_body(self, body: str): + data = json.loads(body) + entries = data.get("data") if isinstance(data, dict) else None + return model_infos_from_entries( + entries, + provider=self.provider, + api_family="openai_chat", + id_of=lambda entry: entry.get("id"), + ) + + # ─── Request serialization ────────────────────────────────────── + + def _build_messages(self, request: Request, compat: ResolvedOpenAIChatCompat, *, breakpoint_index: int | None = None) -> list[dict[str, Any]]: + messages: list[dict[str, Any]] = [] + if request.system: + system_text = request.system if isinstance(request.system, str) else parts_to_text(request.system) + if _cache_stable_prefix(request, compat.cache_control): + # prefix="stable": the mark rides on the system message's + # text content part (array form; a bare string cannot carry it). + messages.append({"role": compat.instruction_role, "content": [ + {"type": "text", "text": system_text, "prompt_cache_breakpoint": {"mode": "explicit"}} + ]}) + else: + messages.append({"role": compat.instruction_role, "content": system_text}) + + for msg_index, msg in enumerate(request.messages): + if msg_index == breakpoint_index and msg.role in ("assistant", "tool"): + raise _breakpoint_unsupported(self.provider, msg_index, msg.role) + if msg.role == "tool": + for part in msg.parts: + if isinstance(part, ToolResultPart): + item: dict[str, Any] = { + "role": "tool", + "tool_call_id": part.id, + "content": _tool_row_content(self.provider, part, compat.tool_result_media), + } + if compat.tool_result_name == "include" and part.name: + item["name"] = part.name + messages.append(item) + continue + + if msg.role == "assistant": + text_bits: list[str] = [] + for part in msg.parts: + if isinstance(part, TextPart): + text_bits.append(part.text) + elif isinstance(part, RefusalPart) and part.text: + text_bits.append(part.text) + elif isinstance(part, ThinkingPart) and compat.thinking_replay == "as_text" and part.text: + text_bits.append(part.text) + tool_calls = [ + { + "id": part.id, + "type": "function", + "function": { + "name": part.name, + "arguments": json.dumps(part.input, separators=(",", ":")), + }, + } + for part in msg.parts + if isinstance(part, ToolCallPart) + ] + item = {"role": "assistant", "content": "\n".join(text_bits) if text_bits else None} + if compat.thinking_replay == "native": + thinking = "\n".join(p.text for p in msg.parts if isinstance(p, ThinkingPart) and p.text) + if thinking or compat.assistant_reasoning_content == "include_empty": + item["reasoning_content"] = thinking + if tool_calls: + item["tool_calls"] = tool_calls + messages.append(item) + continue + + role = compat.instruction_role if msg.role == "developer" else msg.role + at_breakpoint = msg_index == breakpoint_index + content = _chat_content_parts(msg, force_array=at_breakpoint, provider=self.provider) + if at_breakpoint: + # Same rule as the Responses dialect: the breakpoint rides on + # the last text content block of the prefix message + # (chat--create.md: ChatCompletionContentPartText carries + # prompt_cache_breakpoint). + if not isinstance(content, list) or not content or content[-1].get("type") != "text": + raise _breakpoint_unsupported(self.provider, msg_index, msg.role) + content[-1]["prompt_cache_breakpoint"] = {"mode": "explicit"} + if content or content == "": + messages.append({"role": role, "content": content}) + return messages + + def request_from_openai_chat(self, body: Mapping[str, Any]) -> Request: + """The inverse of :meth:`build_request`'s body under this adapter's + compat (MAP-12): a Chat Completions request body → canonical + :class:`Request`. See :func:`request_from_openai_chat`.""" + model = body.get("model") if isinstance(body, Mapping) else None + compat = self._compat_for(model) if isinstance(model, str) else self._resolved_compat + return _ingest_openai_chat(self.provider, body, compat) + + def _builtin_tool_payload(self, tool: BuiltinTool, compat: ResolvedOpenAIChatCompat) -> dict[str, Any]: + """Map a BuiltinTool for the chat dialect, or raise. + + The base Chat Completions wire carries function/custom tools only, + and some compat servers silently IGNORE unknown tool types + (OpenRouter returned 200 with no search, verified live 2026-09-01) + — passthrough would fabricate wire shapes and silent no-ops, so + every unproven target raises. + """ + if compat.builtin_tools == "groq": + wire_type = _GROQ_BUILTIN_MAP.get(tool.name) + if wire_type is None: + raise UnsupportedFeatureError( + f"{self.provider}: builtin tool {tool.name!r} has no Groq wire " + f"mapping — supported: {sorted(_GROQ_BUILTIN_MAP)}", + provider=self.provider, feature=f"tools[{tool.name}]", + ) + entry: dict[str, Any] = {"type": wire_type} + if tool.config: + entry.update(tool.config) + return entry + raise UnsupportedFeatureError( + f"{self.provider}: builtin tool {tool.name!r} is not supported on this " + "server — the Chat Completions wire carries function tools only, and " + "unproven servers may silently ignore unknown tool types. Use " + "compat='groq' for Groq's server-executed tools, or the OpenAI " + "Responses / Anthropic / Gemini providers", + provider=self.provider, feature=f"tools[{tool.name}]", + ) + + def _tool_choice_payload(self, request: Request) -> Any: + tc = request.config.tool_choice + if tc is None: + return None + if tc.mode == "none": + return "none" + if tc.allowed: + by_name = {t.name: t for t in request.tools} + entries = [by_name[name] for name in tc.allowed] + builtins = [t.name for t in entries if isinstance(t, BuiltinTool)] + if builtins: + raise UnsupportedFeatureError( + f"{self.provider}: cannot force builtin tools {builtins} — the " + "Chat Completions wire has no hosted-tool tool_choice form " + "(OpenAI Responses and Anthropic carry it)", + provider=self.provider, feature="config.tool_choice.allowed", + ) + if len(entries) == 1 and tc.mode == "required": + return {"type": "function", "function": {"name": entries[0].name}} + # mode="auto" restriction or multi-tool subset: the dialect's + # allowed_tools form (nested spelling, function tools only). + return { + "type": "allowed_tools", + "allowed_tools": { + "mode": tc.mode, + "tools": [ + {"type": "function", "function": {"name": t.name}} for t in entries + ], + }, + } + if tc.mode == "required": + return "required" + return "auto" + + def _compat_for(self, model: str) -> ResolvedOpenAIChatCompat: + """The resolved compat for this model: the preset's per-family + overrides applied (OpenAIChatCompat.model_overrides).""" + partial = self._compat_partial + if partial is None or not partial.model_overrides: + return self._resolved_compat + return resolve_openai_chat_compat(partial.for_model(model)) + + def _payload(self, request: Request, stream: bool) -> dict[str, Any]: + compat = self._compat_for(request.model) + # Streaming uses generated JSON, never the non-streamable trie driver. + # Check before building messages or invoking credentials, including on + # servers that would otherwise ignore response_format. + if stream or not self._scores_named_tokens(request.model): + if stream and self._judgments_via_token_scoring(request): + self._judgment_stream_policy(request) + else: + note_unmeasurable_probabilities(request, self.provider) + breakpoint_index = _cache_breakpoint_index(request, compat.cache_control) # once: it may record + payload: dict[str, Any] = { + "model": request.model, + "messages": self._build_messages(request, compat, breakpoint_index=breakpoint_index), + } + if stream: + payload["stream"] = True + if compat.stream_usage == "include": + payload["stream_options"] = {"include_usage": True} + if request.config.max_tokens is not None: + payload[compat.max_tokens_field] = request.config.max_tokens + if request.config.temperature is not None: + payload["temperature"] = request.config.temperature + if request.config.top_p is not None: + payload["top_p"] = request.config.top_p + if request.config.top_k is not None: + # MAP-13: a sampling hint with no field on this wire; servers + # that take one do so through extensions. + adapt("config.top_k", "dropped", + "the Chat Completions wire has no top_k (Anthropic and Gemini carry it; servers " + "that accept it take it through extensions)", + asked=request.config.top_k, provider=self.provider) + for name in ("seed", "frequency_penalty", "presence_penalty"): + if getattr(request.config, name) is not None: + payload[name] = getattr(request.config, name) + if request.config.stop: + payload["stop"] = list(request.config.stop) + if request.config.logprobs is not None: + # Verified live 2026-09-01: logprobs=true alone returns chosen + # tokens only; top_logprobs adds the alternatives. + payload["logprobs"] = True + if request.config.logprobs > 0: + payload["top_logprobs"] = request.config.logprobs + if request.tools: + tools_wire: list[dict[str, Any]] = [] + for tool in request.tools: + if isinstance(tool, FunctionTool): + function_payload: dict[str, Any] = { + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + } + if compat.strict_tools == "include": + function_payload["strict"] = False + tools_wire.append({"type": "function", "function": function_payload}) + elif isinstance(tool, BuiltinTool): + tools_wire.append(self._builtin_tool_payload(tool, compat)) + if tools_wire: + payload["tools"] = tools_wire + tool_choice = self._tool_choice_payload(request) + if tool_choice is not None: + tc = request.config.tool_choice + if compat.forced_tool_choice == "reject" and (tc.mode != "auto" or tc.allowed): + # The server documents tool_choice=auto only and ignores every + # other form without an error (Z.AI, live 2026-09-03: required + # → text answer, none → a tool call). MAP-13: "none" and an + # allowlist have a client-side form — send no tools / only + # those tools — and are recorded; "required" cannot be forced + # and the program depends on the call (rule 4b): refused. + if tc.mode == "required": + raise UnsupportedFeatureError( + f"{self.provider}: tool_choice mode='required' is silently ignored by this server " + "(only 'auto' is honoured) and a forced call cannot be reproduced client-side", + provider=self.provider, feature="config.tool_choice.mode", + ) + if tc.mode == "none": + adapt("config.tool_choice.mode", "client_side", + "this server ignores tool_choice='none'; no tools were sent, which is the same outcome", + asked="none", applied="no tools sent", provider=self.provider) + payload.pop("tools", None) + else: + kept = [t for t in payload.get("tools", []) if t.get("function", {}).get("name") in tc.allowed] + adapt("config.tool_choice.allowed", "client_side", + "this server ignores tool_choice allowlists; only the allowed tools were sent, " + "which is what the allowlist means", + asked=list(tc.allowed), applied=[t["function"]["name"] for t in kept], provider=self.provider) + payload["tools"] = kept + tool_choice = "auto" + payload["tool_choice"] = tool_choice + if request.config.tool_choice and request.config.tool_choice.parallel is not None: + payload["parallel_tool_calls"] = request.config.tool_choice.parallel + if request.config.response_format: + if compat.json_schema == "reject" and request.config.response_format["type"] != "json_object": + # The server accepts response_format.type=json_schema and + # ignores it (Z.AI, live 2026-09-03: HTTP 200, fenced JSON with + # keys the schema never named). json_object is honoured. + adapt("config.response_format", "dropped", + f"this server accepts response_format type {request.config.response_format['type']!r} " + "and does not apply it; use {'type': 'json_object'} and describe the shape in the prompt", + asked=request.config.response_format, provider=self.provider) + else: + # MAP-14: the judgment convention goes verbatim on the chat + # dialect (api.openai.com strict honours it, receipted + # 2026-09-17); a server that scores named tokens delivers + # probabilities through the trie driver, every other one + # answers with the pick only. + payload["response_format"] = _response_format_to_chat(request.config.response_format) + if request.config.reasoning: + reasoning = request.config.reasoning + if compat.thinking_format == "none": + # No reasoning dial on this server. MAP-13: the dial is + # dropped and recorded — the model may reason at its own + # default and the tokens show in usage. (The 2026-09-11 + # refusal here rested on "ollama has no dial", which was a + # preset written without a receipt; Ollama maps + # reasoning_effort to `think` — THEORY.md §3.17.) + adapt("config.reasoning", "dropped", + "this server has no reasoning dial on its wire (compat thinking_format='none'); " + "the model reasons at its own default; pass the server's own knob through extensions", + asked={"effort": reasoning.effort}, provider=self.provider) + reasoning = None + if reasoning is not None and not reasoning.is_off: + # MAP-7: verbatim effort; no budget on this wire; summary + # levels are Responses-only; "auto" maps to the dialect's + # visibility knob where one exists (Groq include_reasoning). + if reasoning.thinking_budget is not None: + adapt("config.reasoning.thinking_budget", "dropped", + "the Chat Completions wire has no thinking token budget; effort carries the intent", + asked=reasoning.thinking_budget, provider=self.provider) + if reasoning.summary in ("concise", "detailed"): + adapt("config.reasoning.summary", "substituted", + "the Chat Completions wire has no summary detail levels; 'auto' is what it shows", + asked=reasoning.summary, applied="auto", provider=self.provider) + reasoning = replace(reasoning, summary="auto") + effort = reasoning.effort + if compat.reasoning_efforts is not None and effort not in compat.reasoning_efforts: + # MAP-13: clamp to the nearest declared level; the server + # would have accepted the word silently (Moonshot kimi-k3 + # answered 200 to `medium` and to `bogus`, live 2026-09-03). + nearest = nearest_effort(effort, compat.reasoning_efforts) + adapt("config.reasoning.effort", "clamped", + f"this server has no {effort!r} level (it accepts " + f"{', '.join(compat.reasoning_efforts)}) and would have accepted the word silently", + asked=effort, applied=nearest, provider=self.provider) + effort = nearest + if compat.builtin_tools == "groq" and reasoning.summary == "auto": + # Groq's visibility knob (MAP-7 rule 7): "parsed" returns + # the trace as message.reasoning. Live 2026-09-02: Qwen + # 3.6's default leaks a raw block into + # message.content; this is the wire's fix. Qwen's dial + # accepts only none|default, so an effort word still + # fails loudly there — extensions={"reasoning_format": + # "parsed"} with reasoning absent is the documented door. + payload["reasoning_format"] = "parsed" + if compat.thinking_format == "reasoning_effort": + payload["reasoning_effort"] = effort + elif compat.thinking_format == "openrouter": + payload["reasoning"] = {"effort": effort} + elif compat.thinking_format == "deepseek": + payload["thinking"] = {"type": "enabled"} + payload["reasoning_effort"] = effort + elif compat.thinking_format == "kimi": + # Moonshot: the effort word alone, the field kimi-k3 + # documents (guide--reasoning-effort.md). A `thinking` + # object beside it is accepted (live 2026-09-03) but the + # docs say not to send it, and it would carry nothing. + # Stated trade-off: kimi-k2.6 has no levels and ignores + # the word silently (live 2026-09-03, still 44 reasoning + # tokens at `low`); the adapter does not sniff model + # names, so the docs say: do not set effort on K2.x. + payload["reasoning_effort"] = effort + elif compat.thinking_format == "qwen": + payload["enable_thinking"] = True + elif compat.thinking_format == "qwen_chat_template": + payload["chat_template_kwargs"] = { + "enable_thinking": True, + "preserve_thinking": True, + } + elif reasoning is not None: + # Explicit off must reach the wire; omission lets + # reasoning-by-default models spend hidden reasoning tokens + # (verified live 2026-09-01 on Groq: gpt-oss-20b spent 45 + # reasoning tokens when the field was omitted). Servers + # whose models cannot disable reasoning reject "none" with + # a clear 400 — loud failure over a silent paid no-op. + if compat.thinking_format == "reasoning_effort": + payload["reasoning_effort"] = "none" + elif compat.thinking_format == "openrouter": + payload["reasoning"] = {"enabled": False} + elif compat.thinking_format in ("deepseek", "kimi"): + # "kimi": the K2.x family's documented off switch; the + # docs say kimi-k3 "always reasons", but live 2026-09-03 + # it honoured the object too (200, no reasoning_content, + # no reasoning_tokens) — pinned as moonshotai.reasoning_off. + payload["thinking"] = {"type": "disabled"} + elif compat.thinking_format == "qwen": + payload["enable_thinking"] = False + elif compat.thinking_format == "qwen_chat_template": + payload["chat_template_kwargs"] = {"enable_thinking": False} + + # Prompt caching (MAP-6): off switch, key, retention, resource. + _cache_common_payload(request, payload, compat.cache_control, self.provider, breakpoint_index=breakpoint_index) + + if compat.routing is not None: + payload["provider"] = compat.routing + + # Promoted cross-provider knobs (changes/2026-09-01-extensions-burn-down): + # Chat Completions spellings — user_id rides the dialect's `user` + # field, or the server's own name when the compat says so (DeepSeek: + # `user_id`; Meta: `safety_identifier`, which supersedes `user` on + # that server — protocols--chat-completions.md). Servers that + # do not implement a field reject it themselves; the dialect adapter + # cannot know each compat server's support statically. + if request.config.service_tier is not None: + payload["service_tier"] = request.config.service_tier + if request.config.user_id is not None: + payload[compat.user_field] = request.config.user_id + if request.config.store is not None: + payload["store"] = request.config.store + + if request.config.extensions: + reserved = { + "prompt_caching", + "cache", + "compat", + "openai_compat", + "openai_chat_compat", + } + passthrough = {k: v for k, v in request.config.extensions.items() if k not in reserved} + payload.update(passthrough) + return payload + + def build_request(self, request: Request, stream: bool) -> TransportRequest: + request = self._wire_request(request) + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/chat/completions", + endpoint="chat/completions", + stream=stream, + model=request.model, + headers=self._headers(), + payload=self._payload(request, stream=stream), + ) + + # ─── Response parsing ─────────────────────────────────────────── + + @staticmethod + def _finish_reason(raw: Any, *, has_tool_call: bool, unmapped: list[dict[str, str]], path: str = "choices[0]") -> str: + if has_tool_call: + return "tool_call" + if raw is None or raw == "": + return "stop" + mapped = _FINISH_REASON_MAP.get(str(raw)) + if mapped is None: + _record_unmapped(unmapped, f"{path}.finish_reason", raw) + return "stop" + return mapped + + def parse_response(self, request: Request, response: HttpResponse) -> Response: + request = self._wire_request(request) + resp = _response_from_chat_body( + self.provider, response.json(), model=request.model, + choice=None, on_error=self._response_error, + ) + return _fold_judgments(resp, request.config.response_format) + + # ─── Judgments by candidate-sequence likelihood (MAP-14 §4) ─────── + # + # A server that scores named tokens (compat token_scoring = + # "logprob_token_ids": vLLM ≥ 0.29, receipted 2026-09-17) can deliver a + # distribution over the declared keys of every judgment. Pure hooks + # below; the driver sequences them like files and batch: + # 1. two /tokenize calls per key plus one per judgment prefill, so + # the server's chat template is honoured and the key path is + # read in context (terminator included: prefix-free paths); + # 2. ONE /v1/completions call carrying every trie node as a prompt + # (token ids), max_tokens 1, logprob_token_ids = union of child + # tokens; raw log-probs sum along each path, one normalisation. + + _JUDGMENT_PREFILL = "Answer:" + + def _judgment_ask(self, j: "Judgment") -> str: + keys = [] + for k in j.keys: + label = j.titles.get(k) + desc = j.descriptions.get(k) + tail = f": {label} - {desc}" if label and desc else (f": {label or desc}" if (label or desc) else "") + keys.append(f"- {k}{tail}") + instruction = j.instruction or j.name + return f"{instruction}\nOptions:\n" + "\n".join(keys) + "\nAnswer with the option only, spelled exactly as listed." + + def _judgment_messages(self, request: Request, j: "Judgment", answer: str) -> list[dict[str, Any]]: + """The conversation, the judgment's question as a final user turn, + and the assistant's answer so far (prefill + key).""" + compat = self._compat_for(request.model) + messages = self._build_messages(replace(request, config=Config()), compat) + messages.append({"role": "user", "content": self._judgment_ask(j)}) + messages.append({"role": "assistant", "content": answer}) + return messages + + def _judgment_tokenize_request(self, model: str, messages: list[dict[str, Any]], *, continue_final: bool) -> TransportRequest: + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/').removesuffix('/v1')}/tokenize", + endpoint="tokenize", + model=model, + headers=self._headers(), + payload={"model": model, "messages": messages, "add_generation_prompt": False, + "continue_final_message": continue_final}, + ) + + def _judgment_reply_error(self, response: HttpResponse, detail: str) -> ProviderError: + error = ProviderError(f"malformed judgment reply: {detail}", provider=self.provider, status=response.status) + _attach_error_metadata(error, response.headers) + return error + + def _judgment_tokens_from_body(self, response: HttpResponse) -> list[int]: + data = response.json() + tokens = data.get("tokens") if isinstance(data, dict) else None + if not isinstance(tokens, list) or not tokens or not all(type(t) is int and t >= 0 for t in tokens): + raise self._judgment_reply_error(response, "tokenize reply carries no non-negative integer token list") + return tokens + + def _judgment_score_request(self, model: str, prompts: list[list[int]], token_ids: list[int]) -> TransportRequest: + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/completions", + endpoint="completions", + model=model, + headers=self._headers(), + payload={"model": model, "prompt": prompts, "max_tokens": 1, "temperature": 1.0, "logprobs": 0, + "return_tokens_as_token_ids": True, "logprob_token_ids": sorted(token_ids)}, + ) + + def _judgment_scores_from_body(self, response: HttpResponse, n_prompts: int) -> tuple[list[dict[int, float]], Usage, str | None]: + """Per prompt, measured token log-probs; missing ids follow MAP-14 §4.""" + data = response.json() + choices = data.get("choices") if isinstance(data, dict) else None + if (not isinstance(choices, list) or len(choices) != n_prompts + or any(not isinstance(c, dict) or type(c.get("index")) is not int for c in choices) + or {c["index"] for c in choices} != set(range(n_prompts))): + raise self._judgment_reply_error(response, "choices must contain every prompt index exactly once") + out: list[dict[int, float]] = [] + for choice in sorted(choices, key=lambda c: c["index"]): + logprobs = choice.get("logprobs") + if logprobs is None: + logprobs = {} + if not isinstance(logprobs, dict): + raise self._judgment_reply_error(response, "logprobs must be an object or null") + top_list = logprobs.get("top_logprobs") + if top_list is None or top_list == []: + top = {} + elif isinstance(top_list, list) and len(top_list) == 1 and (top_list[0] is None or isinstance(top_list[0], dict)): + top = top_list[0] if top_list[0] is not None else {} + else: + raise self._judgment_reply_error(response, "expected one top_logprobs object") + scores: dict[int, float] = {} + for token, value in top.items(): + if isinstance(token, str) and token.startswith("token_id:") and token[9:].isascii() and token[9:].isdigit(): + # -inf is a zero-likelihood token; NaN/+inf, positive log + # probabilities, booleans and string coercions are not measurements. + if (isinstance(value, bool) or not isinstance(value, (int, float)) + or value > 0 or (isinstance(value, float) and math.isnan(value))): + raise self._judgment_reply_error(response, f"invalid log probability for {token}") + try: + scores[int(token[9:])] = float(value) + except (ValueError, OverflowError) as exc: + raise self._judgment_reply_error(response, f"invalid token score for {token}") from exc + out.append(scores) + usage_raw = data.get("usage") + if usage_raw is None: + usage_raw = {} + if not isinstance(usage_raw, dict): + raise self._judgment_reply_error(response, "usage must be an object or null") + for name in ("prompt_tokens_details", "completion_tokens_details"): + if usage_raw.get(name) is not None and not isinstance(usage_raw[name], dict): + raise self._judgment_reply_error(response, f"{name} must be an object or null") + try: + usage = _usage_from_chat(usage_raw) + except (ValueError, TypeError, OverflowError) as exc: + raise self._judgment_reply_error(response, f"invalid usage: {exc}") from exc + model = data.get("model") + if model is not None and (not isinstance(model, str) or not model): + raise self._judgment_reply_error(response, "model must be a non-empty string") + return out, usage, model + + def _judgment_plan(self, request: Request) -> "list[dict[str, Any]]": + """One entry per judgment: the tokenize requests it needs. Pure.""" + plan = [] + for name, j in request_judgments(request).items(): + plan.append({ + "judgment": j, + "prefill": self._judgment_tokenize_request(request.model, self._judgment_messages(request, j, self._JUDGMENT_PREFILL), continue_final=True), + "keys": {k: (self._judgment_tokenize_request(request.model, self._judgment_messages(request, j, f"{self._JUDGMENT_PREFILL} {k}"), continue_final=True), + self._judgment_tokenize_request(request.model, self._judgment_messages(request, j, f"{self._JUDGMENT_PREFILL} {k}"), continue_final=False)) + for k in j.keys}, + }) + return plan + + @staticmethod + def _judgment_paths(prefix: list[int], keys: dict[str, tuple[list[int], list[int]]]) -> dict[str, tuple[int, ...]]: + """Each key's token path after the prefill, terminator included.""" + paths: dict[str, tuple[int, ...]] = {} + for key, (open_tokens, closed_tokens) in keys.items(): + if open_tokens[:len(prefix)] != prefix or closed_tokens[:len(open_tokens)] != open_tokens: + raise UnsupportedFeatureError( + f"openai-chat: key {key!r} does not tokenize as an extension of the prefill in this chat template; " + "candidate-sequence scoring cannot place it (rename the key or use a provider that classifies natively)", + provider="openai-chat", feature="config.response_format", + ) + body = open_tokens[len(prefix):] + terminator = closed_tokens[len(open_tokens):len(open_tokens) + 1] + if not body or not terminator: + raise UnsupportedFeatureError( + f"openai-chat: key {key!r} yields no scorable tokens (empty key or no end-of-turn token in the template)", + provider="openai-chat", feature="config.response_format", + ) + paths[key] = tuple(body) + tuple(terminator) + return paths + + @staticmethod + def _judgment_nodes(paths: dict[str, tuple[int, ...]]) -> dict[tuple[int, ...], set[int]]: + nodes: dict[tuple[int, ...], set[int]] = {} + for seq in paths.values(): + for i in range(len(seq)): + nodes.setdefault(seq[:i], set()).add(seq[i]) + return nodes + + def _judgment_fold(self, request: Request, found: "dict[str, Judgment]", per_judgment: list[tuple["Judgment", dict[str, tuple[int, ...]], dict[tuple[int, ...], dict[int, float]]]], + usage: Usage, model: str | None, n_nodes: int, tokenize_calls: int) -> Response: + value: dict[str, Any] = {} + probabilities: dict[str, dict[str, float]] = {} + coverage: dict[str, float] = {} + for j, paths, table in per_judgment: + raw = {key: sum(table[seq[:i]][seq[i]] for i in range(len(seq))) for key, seq in paths.items()} + if not any(math.isfinite(v) for v in raw.values()): + raise ProviderError( + f"judgment {j.name!r} has zero likelihood for every declared key; cannot normalize", + provider=self.provider, + ) + coverage[j.name] = sum(math.exp(v) for v in raw.values()) + dist = normalize_logprobs(raw) + probabilities[j.name] = dist + best = max(dist, key=dist.get) + value[j.name] = (best == "true") if j.kind == "boolean" else (int(best) if j.ordered else best) + part = DataPart(value=value, probabilities=probabilities, method="candidate_sequence_likelihood") + return Response( + id=None, + model=str(model or request.model), + message=Message(role="assistant", parts=(part,)), + finish_reason="stop", + usage=usage, + provider_data={"coverage": coverage, "judgments": {"nodes": n_nodes, "tokenize_calls": tokenize_calls, "method": "candidate_sequence_likelihood"}}, + ) + + def _judgments_via_token_scoring(self, request: Request) -> bool: + return (self._scores_named_tokens(request.model) and request.config.probabilities in ("if_available", "required") + and bool(request_judgments(request))) + + def _validate_judgment_scoring(self, request: Request) -> None: + """Harmful omissions are refusals, not silent scoring adaptations.""" + def refuse(feature: str, reason: str) -> None: + raise UnsupportedFeatureError( + f"{self.provider}: {reason}; use generated JSON with probabilities='off' " + "or a separate scoring request", + provider=self.provider, feature=feature, + ) + + if request.tools: + refuse("tools", "candidate scoring cannot execute tools; the program may depend on their results") + if request.config.tool_choice is not None: + refuse("config.tool_choice", "candidate scoring cannot preserve tool/action semantics") + if request.config.cache is not None and request.config.cache.resource is not None: + refuse("config.cache.resource", "candidate scoring cannot read a stored cache object; omitting it would lose prompt content") + n = (request.config.extensions or {}).get("n") + if isinstance(n, (int, float)) and not isinstance(n, bool) and n > 1: + refuse("config.extensions.n", "n > 1 has no canonical multiple-response representation") + for name in ("store", "user_id", "service_tier"): + if getattr(request.config, name) is not None: + refuse(f"config.{name}", "measurement endpoints have no established mapping for this privacy, safety or billing control") + cache = request.config.cache + if cache is not None: + if cache.mode == "off": + refuse("config.cache.mode", "measurement endpoints cannot guarantee cache writes are disabled") + if cache.retention is not None: + refuse("config.cache.retention", "measurement endpoints cannot preserve cache lifetime and billing intent") + harmless = {"temperature", "top_p", "top_k", "seed", "frequency_penalty", "presence_penalty"} + for name, value in (request.config.extensions or {}).items(): + numeric = type(value) is int or (isinstance(value, float) and math.isfinite(value)) + if (name == "n" and numeric and value == 1) or (name in harmless and numeric): + continue + refuse(f"config.extensions.{name}", "unknown measurement extension semantics; dropping it could lose privacy, money or action controls") + + @staticmethod + def _judgment_mixed(request: Request) -> bool: + return bool(non_judgment_properties(request.config.response_format["schema"], request_judgments(request))) + + @staticmethod + def _judgment_generated_request(request: Request) -> Request: + return replace(request, config=replace(request.config, probabilities="off")) + + def _judgment_stream_policy(self, request: Request) -> None: + if request.config.probabilities == "required": + raise UnsupportedFeatureError( + f"{self.provider}: candidate scoring produces a non-streamable DataPart; " + "use complete() for required probabilities or a separate scoring request", + provider=self.provider, feature="config.probabilities", + ) + adapt("config.probabilities", "dropped", + "stream() uses generated JSON, not candidate scoring; the answer carries an unmeasured pick only; use complete() for scoring", + asked=request.config.probabilities, provider=self.provider) + + def _judgment_adaptations(self, request: Request, *, policy: AdaptationPolicy | None = None) -> "tuple[Adaptation, ...]": + # Shared offline preflight for plan, complete and async complete. No + # tokenization builder, credential provider or transport runs here. + self._validate_judgment_scoring(request) + with collecting(check_policy(policy if policy is not None else self.adaptations), provider=self.provider, planning=True) as scope: + from ..serde import config_to_dict + + mixed = self._judgment_mixed(request) + if mixed: + adapt("config.response_format", "client_side", + "an additional structured-output call answers ordinary properties, which are never scored", + provider=self.provider) + config = config_to_dict(request.config) + for name in ("max_tokens", "temperature", "top_p", "top_k", "stop", "seed", "frequency_penalty", + "presence_penalty", "reasoning", "logprobs", "cache", "extensions"): + if name in config: + adapt(f"config.{name}", "dropped", + "candidate likelihood measures unmodified next-token probabilities with max_tokens=1 per trie node; " + "this generation setting has no measurement slot (any generated JSON call still uses its usual mapping)", + asked=config[name], provider=self.provider) + if mixed: + self._payload(self._judgment_generated_request(request), stream=False) + self._build_messages(replace(request, config=Config()), self._compat_for(request.model)) + return tuple(scope.records) + + def plan(self, request: Request, *, policy: AdaptationPolicy | None = None) -> "tuple[Adaptation, ...]": + wire_request = self._wire_request(request) + if self._judgments_via_token_scoring(wire_request): + return self._judgment_adaptations(wire_request, policy=policy) + return BaseProviderLM.plan(self, request, policy=policy) + + def complete(self, request: Request) -> Response: + wire_request = self._wire_request(request) + if not self._judgments_via_token_scoring(wire_request): + return BaseProviderLM.complete(self, request) + adaptations = self._judgment_adaptations(wire_request) + found = request_judgments(request) + plan = self._judgment_plan(wire_request) + tokenized: list[tuple["Judgment", dict[str, tuple[int, ...]], list[int]]] = [] + calls = 0 + for entry in plan: + prefix = self._judgment_tokens_from_body(self._send_ok(entry["prefill"])); calls += 1 + keys = {} + for key, (open_req, closed_req) in entry["keys"].items(): + keys[key] = (self._judgment_tokens_from_body(self._send_ok(open_req)), + self._judgment_tokens_from_body(self._send_ok(closed_req))); calls += 2 + tokenized.append((entry["judgment"], self._judgment_paths(prefix, keys), prefix)) + return self._judgment_score_and_fold(request, found, tokenized, calls, adaptations) + + def _send_ok(self, req: TransportRequest) -> HttpResponse: + resp = self._send(req) + if resp.status >= 400: + raise self._http_error(resp) + return resp + + def _judgment_score_and_fold(self, request: Request, found, tokenized, calls: int, adaptations) -> Response: + wire_request = self._wire_request(request) + prompts: list[list[int]] = [] + meta: list[tuple[int, tuple[int, ...]]] = [] + union: set[int] = set() + nodes_per: list[dict[tuple[int, ...], set[int]]] = [] + for index, (j, paths, prefix) in enumerate(tokenized): + nodes = self._judgment_nodes(paths) + nodes_per.append(nodes) + for node_prefix, children in nodes.items(): + prompts.append(list(prefix) + list(node_prefix)) + meta.append((index, node_prefix)) + union |= children + req = self._judgment_score_request(wire_request.model, prompts, sorted(union)) + resp = self._send_ok(req) + scores, usage, model = self._judgment_scores_from_body(resp, len(prompts)) + tables: list[dict[tuple[int, ...], dict[int, float]]] = [{} for _ in tokenized] + for (index, node_prefix), got in zip(meta, scores): + children = nodes_per[index][node_prefix] + if any(t not in got for t in children): + return self._judgment_unmeasured(request, adaptations, usage) + tables[index][node_prefix] = {t: got[t] for t in children} + per = [(j, paths, tables[i]) for i, (j, paths, _) in enumerate(tokenized)] + try: + response = self._judgment_fold(wire_request, found, per, usage, model, len(prompts), calls) + except ProviderError as error: + error.status = resp.status + _attach_error_metadata(error, resp.headers) + raise + if self._judgment_mixed(request): + generated, _ = self._judgment_generate(request) + response = self._judgment_merge(response, generated) + return replace(response, adaptations=self._visible(adaptations)) + + def _judgment_generate(self, request: Request, *, unmeasured: bool = False): + from .base import _client_side_stop + from ..result import materialize_response + + generated_request = self._judgment_generated_request(request) + wire, built = self._build(generated_request, stream=False) + if _client_side_stop(built): + # Preserve the ordinary driver's close-at-stop billing semantics. + plain = replace(generated_request, config=replace(generated_request.config, response_format=None)) + response = materialize_response(self.stream(generated_request), plain) + return self._judgment_generated_value(generated_request, response, unmeasured=unmeasured), built + reply = self._send_ok(wire) + return self._judgment_generated_response(generated_request, reply, built, unmeasured=unmeasured), built + + def _judgment_generated_response(self, request: Request, reply: HttpResponse, adaptations, *, policy=None, unmeasured: bool = False) -> Response: + """A strict semantic boundary: never repair an incomplete JSON answer.""" + try: + # Read raw text first: the general judgment fold is deliberately lenient. + plain = replace(request, config=replace(request.config, response_format=None)) + body = reply.json() + if isinstance(body, dict) and isinstance(body.get("error"), dict): + self.parse_response(plain, reply) # preserve in-band provider errors + choices = body.get("choices") if isinstance(body, dict) else None + if (not isinstance(choices, list) or len(choices) != 1 or not isinstance(choices[0], dict) + or choices[0].get("finish_reason") != "stop"): + raise ValueError("generated judgment reply needs one complete choice with finish_reason='stop'") + response = self.parse_response(plain, reply) + response = self._finish_response(request, response, adaptations, policy=policy) + return self._judgment_generated_value(request, response, unmeasured=unmeasured) + except ProviderError as error: + error.status = reply.status + _attach_error_metadata(error, reply.headers) + raise + except (ValueError, TypeError, KeyError, AttributeError, IndexError, OverflowError) as error: + raise self._judgment_reply_error(reply, str(error)) from error + + def _judgment_generated_value(self, request: Request, response: Response, *, unmeasured: bool = False) -> Response: + try: + if response.finish_reason != "stop": + raise ValueError("generated judgment answer did not finish completely") + texts = [p for p in response.message.parts if isinstance(p, TextPart)] + if len(texts) != 1: + raise ValueError("generated judgment answer needs one JSON object") + def invalid_constant(value): + raise ValueError(f"non-finite JSON constant {value}") + value = json.loads(texts[0].text, parse_constant=invalid_constant) + if not isinstance(value, dict): + raise ValueError("generated judgment answer needs a JSON object") + schema = request.config.response_format["schema"] + measured_fields = request_judgments(request) + for name in schema.get("required", ()): + if (unmeasured or name not in measured_fields) and name not in value: + raise ValueError(f"generated answer is missing required property {name!r}") + part = DataPart(value=value, continuation=texts[0].continuation) + parts = tuple(part if p is texts[0] else p for p in response.message.parts) + return replace(response, message=replace(response.message, parts=parts), adaptations=()) + except (ValueError, TypeError, OverflowError) as error: + raise ProviderError(f"malformed generated judgment JSON: {error}", provider=self.provider) from error + + @staticmethod + def _judgment_sum_usage(scoring: Usage, generated: Usage) -> Usage: + return Usage(**{name: (getattr(scoring, name) + getattr(generated, name) + if getattr(scoring, name) is not None and getattr(generated, name) is not None else None) + for name in Usage.__dataclass_fields__}) + + def _judgment_merge(self, measured: Response, generated: Response) -> Response: + from ..serde import response_to_dict, usage_to_dict + + original = generated.message.first(DataPart) + scored = measured.message.first(DataPart) + part = replace(scored, value={**original.value, **scored.value}, continuation=original.continuation) + return replace(measured, id=generated.id, finish_reason=generated.finish_reason, + message=replace(generated.message, parts=tuple(part if p is original else p for p in generated.message.parts)), + usage=self._judgment_sum_usage(measured.usage, generated.usage), + provider_data={**(measured.provider_data or {}), "scoring_usage": usage_to_dict(measured.usage), + "generated_response": response_to_dict(generated, include_provider_data=True)}) + + def _judgment_fallback_usage(self, generated: Response, usage: Usage) -> Response: + from ..serde import usage_to_dict + + return replace(generated, usage=self._judgment_sum_usage(usage, generated.usage), + provider_data={**(generated.provider_data or {}), "scoring_usage": usage_to_dict(usage)}) + + def _judgment_unmeasured(self, request: Request, adaptations, usage: Usage) -> Response: + """The server answered 200 without the requested token ids: it dropped + ``logprob_token_ids`` (receipted on vLLM 0.25.1). ``required`` refuses; + ``if_available`` answers by structured output instead and records it.""" + if request.config.probabilities == "required": + raise UnsupportedFeatureError( + f"{self.provider}: config.probabilities='required' but this server ignored logprob_token_ids " + "(vLLM < 0.29?); no distribution can be measured here", + provider=self.provider, feature="config.probabilities", + ) + with collecting(check_policy(self.adaptations), provider=self.provider) as scope: + adapt("config.probabilities", "dropped", + "the server accepted the request and returned no log-probs for the requested token ids (logprob_token_ids ignored); " + "answered by structured output instead", asked=request.config.probabilities, provider=self.provider) + response, built = self._judgment_generate(request, unmeasured=True) + response = self._judgment_fallback_usage(response, usage) + records = tuple(adaptations) + tuple(scope.records) + tuple(a for a in built if a not in adaptations) + return replace(response, adaptations=self._visible(records)) + + def _scores_named_tokens(self, model: str | None = None) -> bool: + """Whether the selected model can deliver a measured distribution.""" + compat = self._compat_for(model) if model is not None else self._resolved_compat + return getattr(compat, "token_scoring", "none") == "logprob_token_ids" + + def response_from_openai_chat(self, body: Mapping[str, Any], *, model: str | None = None, choice: int | None = None, + response_format: Mapping[str, Any] | None = None) -> Response: + """A Chat Completions response body → canonical :class:`Response` + under this adapter's provider name and error mapping; see + :func:`response_from_openai_chat`. ``response_format`` (the + request's) folds a judgment answer into a DataPart (MAP-14).""" + resp = _response_from_chat_body(self.provider, body, model=model, choice=choice, on_error=self._response_error) + return _fold_judgments(resp, response_format) + + # ─── Stream parsing ────────────────────────────────────────────── + + def parse_stream_events(self, request: Request, raw_event: SSEEvent) -> Iterator[StreamEvent]: + request = self._wire_request(request) + if not raw_event.data: + return + if raw_event.data == "[DONE]": + yield StreamEndEvent() + return + payload = json.loads(raw_event.data) + if not isinstance(payload, dict): + return + + err = payload.get("error") + if isinstance(err, dict): + provider_code = str(err.get("code") or err.get("type") or "provider") + yield StreamErrorEvent(error=self._error_detail(provider_code, str(err.get("message") or ""))) + return + + choices = payload.get("choices") or [] + choice = choices[0] if choices and isinstance(choices[0], dict) else {} + delta = choice.get("delta") if isinstance(choice.get("delta"), dict) else {} + + reasoning_text = delta.get("reasoning_content") or delta.get("reasoning") + if reasoning_text: + yield StreamDeltaEvent(delta=ThinkingDelta(text=str(reasoning_text))) + + content = delta.get("content") + if isinstance(content, str) and content: + logprobs_payload = choice.get("logprobs") if isinstance(choice.get("logprobs"), dict) else {} + yield StreamDeltaEvent( + delta=TextDelta( + text=content, + logprobs=openai_token_logprobs(logprobs_payload.get("content")), + ) + ) + + for call in delta.get("tool_calls") or []: + if not isinstance(call, dict): + continue + function = call.get("function") if isinstance(call.get("function"), dict) else {} + yield StreamDeltaEvent( + delta=ToolCallDelta( + input=str(function.get("arguments") or ""), + part_index=int(call.get("index", 0) or 0), + id=str(call.get("id") or "") or None, + name=str(function.get("name") or "") or None, + ) + ) + + # MAP-3 (D9, 2026-09-06): the end event's provider_data is the frame + # that supplied usage, verbatim, else the frame that supplied + # finish_reason; [DONE] contributes nothing. The coalescer keeps the + # usage frame's when both arrive. + finish_raw = choice.get("finish_reason") + usage_data = payload.get("usage") + if finish_raw: + yield StreamEndEvent( + finish_reason=_FINISH_REASON_MAP.get(str(finish_raw), "stop"), + usage=_usage_from_chat(usage_data) if isinstance(usage_data, dict) else None, + provider_data=payload, + ) + elif isinstance(usage_data, dict): + # Final usage-only chunk (stream_options.include_usage). + yield StreamEndEvent(usage=_usage_from_chat(usage_data), provider_data=payload) diff --git a/providers/openai_codex.py b/providers/openai_codex.py new file mode 100644 index 0000000000..eac3872228 --- /dev/null +++ b/providers/openai_codex.py @@ -0,0 +1,78 @@ +""" +lm15.providers.openai_codex — the OpenAI Responses dialect on a Codex login. + +``OpenAICodexLM`` is a *name*, not a behaviour: it is ``OpenAILM`` bound to +``lm15.access.OPENAI_CODEX``. The ChatGPT Codex backend's differences — +bearer OAuth with a ``chatgpt-account-id``, the ``OpenAI-Beta`` and +``originator`` headers, the instructions prefix, ``store: false``, +streaming-only, the ``{"detail": ...}`` error envelope, the ``/models`` +shape, no files/batch/images/speech/live — are fields of that policy or +branches on its ``backend`` inside the dialect, at stated points. A port +needs the policy table and the dialect, not this class. +""" + +from __future__ import annotations + +import os +from typing import ClassVar + +from ..access import ( # noqa: F401 + DEFAULT_CODEX_BASE_URL, + DEFAULT_CODEX_CLIENT_VERSION, + DEFAULT_CODEX_INSTRUCTIONS, + DEFAULT_CODEX_ORIGINATOR, + OPENAI_CODEX, +) +from ..features import ProviderManifest +from ..adaptation import AdaptationPolicy +from .base import Credential, SyncTransport, default_transport +from .openai import MODEL_LIST_HINT, OpenAILM # noqa: F401 + + +class OpenAICodexLM(OpenAILM): + """OpenAI Responses adapter authenticated with local Codex CLI OAuth.""" + + manifest: ClassVar[ProviderManifest] = OPENAI_CODEX + + def __init__( + self, + api_key: Credential | None = None, + *, + account_id: str | None = None, + auth_path: str | os.PathLike[str] | None = None, + transport: SyncTransport | None = None, + base_url: str = DEFAULT_CODEX_BASE_URL, + originator: str = DEFAULT_CODEX_ORIGINATOR, + client_version: str = DEFAULT_CODEX_CLIENT_VERSION, + adaptations: "AdaptationPolicy" = "note", + ) -> None: + self.originator = originator + self.client_version = client_version + policy = OPENAI_CODEX + if originator != DEFAULT_CODEX_ORIGINATOR: + policy = policy.with_headers({"originator": originator}) + if client_version != DEFAULT_CODEX_CLIENT_VERSION: + from dataclasses import replace + + policy = replace(policy, backend_options={**policy.backend_options, "client_version": client_version}) + super().__init__( + api_key=api_key, + transport=transport or default_transport(), + base_url=base_url, + profile=None, + access=policy, + credentials_path=auth_path, + account_id=account_id, + adaptations=adaptations, + ) + + @classmethod + def from_codex_cli( + cls, + *, + auth_path: str | os.PathLike[str] | None = None, + transport: SyncTransport | None = None, + base_url: str = DEFAULT_CODEX_BASE_URL, + originator: str = DEFAULT_CODEX_ORIGINATOR, + ) -> "OpenAICodexLM": + return cls(auth_path=auth_path, transport=transport, base_url=base_url, originator=originator) diff --git a/providers/typesafe.py b/providers/typesafe.py new file mode 100644 index 0000000000..0206501c9b --- /dev/null +++ b/providers/typesafe.py @@ -0,0 +1,341 @@ +"""TypeSafe System One (Jev) — provider ``typesafe``. + +changes/2026-09-17-judgments.md. One ``POST /v1/systemone`` per Request: +the messages become Jev's ``state`` (D6), the judgment properties of the +``json_schema`` become its ``questions`` (MAP-14 §2), and the answers come +back as one ``DataPart`` with the distribution per judgment and +``method="provider_classification"`` (§3). Jev generates no text: a +request without judgments, with tools, or with media is refused before +the wire (D8). Wire facts: receipts/2026-09-17-judgments/ (jev-*.json). +""" +from __future__ import annotations + +import json +import math +import os +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Callable, ClassVar, Iterator, Mapping + +from ..access import TYPESAFE_API +from ..adaptation import AdaptationPolicy, adapt, check_policy +from ..errors import ( + AuthError, + InvalidRequestError, + ProviderError, + RateLimitError, + ServerError, + UnsupportedFeatureError, + UnsupportedModelError, + map_http_error, +) +from ..features import ProviderManifest +from ..judgments import MAX_CHOICE_KEYS, MAX_ORDERED_LEVELS, Judgment, non_judgment_properties, request_judgments +from ..transports import TransportRequest +from ..types import DataPart, Message, Request, Response, StreamEvent, TextPart, Usage +from .base import BaseProviderLM, Credential, HttpResponse, SyncTransport, default_transport, _attach_error_metadata +from .common import model_infos_from_entries +from ..sse import SSEEvent + +_DEFAULT_BASE_URL = "https://api.typesafe.ai" + +# Config knobs with no home on the systemone wire (D8): dropped with a record. +_DROPPED_KNOBS: tuple[str, ...] = ( + "max_tokens", "temperature", "top_p", "top_k", "stop", "seed", "frequency_penalty", + "presence_penalty", "reasoning", "logprobs", "store", "user_id", "service_tier", "cache", +) + + +@dataclass +class TypeSafeLM(BaseProviderLM): + """TypeSafe System One dialect (``POST /v1/systemone``).""" + + api_key: Credential | None = field(default=None, repr=False) + transport: SyncTransport = field(default_factory=default_transport) + base_url: str = _DEFAULT_BASE_URL + access: ProviderManifest | None = None + credentials_path: "str | os.PathLike[str] | None" = field(default=None, repr=False) + settings: "Mapping[str, str] | None" = None + clock: "Callable[[], datetime] | None" = field(default=None, repr=False) + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + provider: str = field(default="typesafe", init=False) + account_id: str | None = field(default=None, init=False, repr=False) + manifest: ClassVar[ProviderManifest] = TYPESAFE_API + + def __post_init__(self) -> None: + check_policy(self.adaptations) + self._bind_access(self.access, credentials_path=self.credentials_path, + default_base_url=_DEFAULT_BASE_URL, settings=self.settings) + + # ─── Request building (pure) ──────────────────────────────────── + + def _refuse(self, feature: str, why: str) -> UnsupportedFeatureError: + return UnsupportedFeatureError(f"{self.provider}: {why}", provider=self.provider, feature=feature) + + def _state(self, request: Request) -> Any: + """changes/2026-09-19-jev-state.md D1/D2: the state is the one user + part, verbatim — a text's string or a data part's value. Jev has + no system prompt and no conversation; anything else is refused + with the native place named, never merged into a shape of ours. + A media part is refused first, as the specific fault it is (MAP-10).""" + for m_index, message in enumerate(request.messages): + for p_index, part in enumerate(message.parts): + if not isinstance(part, (TextPart, DataPart)): + raise self._refuse( + f"messages[{m_index}].parts[{p_index}]", + f"a {part.type} part has no slot on the systemone wire (MAP-10); Jev reads text or data", + ) + if request.system is not None: + raise self._refuse( + "system", + "Jev has no system prompt; put context in the state as a named key " + "(Message.user(data({\"policy\": ..., \"note\": ...}))), or the framing in each " + "question's description (changes/2026-09-19-jev-state.md D2)", + ) + if len(request.messages) != 1: + raise self._refuse( + "messages", + f"Jev judges one state, got {len(request.messages)} messages; put a transcript in the " + "state as an array or object (Message.user(data({\"messages\": [...]}))), where a " + "question can point at a turn with a backtick path (changes/2026-09-19-jev-state.md D2)", + ) + message = request.messages[0] + if message.role != "user": + raise self._refuse("messages[0].role", f"Jev's state is a user message, got role {message.role!r}") + if len(message.parts) != 1: + raise self._refuse( + "messages[0].parts", + f"Jev's state is one text or data part, got {len(message.parts)} parts; put several pieces " + "in one data part as named keys", + ) + only = message.parts[0] + return only.text if isinstance(only, TextPart) else only.value # type: ignore[union-attr] + + def _questions(self, request: Request) -> dict[str, Any]: + fmt = request.config.response_format + if not isinstance(fmt, dict) or fmt.get("type") != "json_schema": + raise self._refuse( + "config.response_format", + "Jev answers declared judgments only; give a json_schema response_format whose " + "properties are enums / booleans / ordered levels (MAP-14), e.g. lm15.judgments(...)", + ) + found = request_judgments(request) + extra = non_judgment_properties(fmt.get("schema"), found) + if not found or extra: + what = f"properties {list(extra)} are free-form" if extra else "no property declares a judgment" + raise self._refuse( + "config.response_format", + f"{what}; Jev cannot generate values, only pick among declared keys (MAP-14 §1)", + ) + questions: dict[str, Any] = {} + for name, j in found.items(): + instruction = j.instruction + if instruction is None: + adapt( + f"config.response_format.schema.properties.{name}.description", + "defaulted", + "a judgment without a description: the property name goes as the instruction (Jev never sees property names)", + applied=name, + provider=self.provider, + ) + instruction = name + if j.kind == "boolean": + questions[name] = {"type": "noul", "instructions": instruction} + elif j.kind == "choice": + if len(j.keys) > MAX_CHOICE_KEYS: + raise self._refuse(f"config.response_format.schema.properties.{name}", + f"a Jev choice takes at most {MAX_CHOICE_KEYS} keys, got {len(j.keys)}") + questions[name] = {"type": "choice", "instructions": instruction, + "criteria": {k: j.descriptions.get(k) for k in j.keys}} + else: + if len(j.keys) > MAX_ORDERED_LEVELS: + raise self._refuse(f"config.response_format.schema.properties.{name}", + f"a Jev score takes at most {MAX_ORDERED_LEVELS} levels, got {len(j.keys)}") + criteria = [j.descriptions.get(k) or k for k in j.keys] + questions[name] = {"type": "score", "instructions": instruction, "criteria": criteria} + return questions + + def _payload(self, request: Request) -> dict[str, Any]: + if request.tools: + raise self._refuse("tools", "tools have no slot on the systemone wire") + cfg = request.config + if cfg.tool_choice is not None: + raise self._refuse("config.tool_choice", "tool_choice has no slot on the systemone wire") + for name in _DROPPED_KNOBS: + value = getattr(cfg, name) + if value is None or value == (): + continue + adapt(f"config.{name}", "dropped", "no such control on the systemone wire (Jev returns decisions, not samples)", + asked=value if isinstance(value, (int, float, str, bool)) else str(value), provider=self.provider) + questions = self._questions(request) + payload: dict[str, Any] = {"model": request.model, "state": self._state(request), "questions": questions} + if cfg.extensions: + for key, value in cfg.extensions.items(): + if key == "n" and isinstance(value, (int, float)) and value > 1: + raise self._refuse("config.extensions.n", "n > 1 has no canonical multiple-response representation") + payload[key] = value + return payload + + def build_request(self, request: Request, stream: bool) -> TransportRequest: + request = self._wire_request(request) + if stream: + raise self._refuse("stream", "systemone answers in one piece; there is no stream to wrap") + return self._emit( + method="POST", + url=f"{self.base_url.rstrip('/')}/v1/systemone", + endpoint="systemone", + model=request.model, + headers={"Content-Type": "application/json"}, + payload=self._payload(request), + ) + + # ─── Response parsing (pure) ──────────────────────────────────── + + def parse_response(self, request: Request, response: HttpResponse) -> Response: + request = self._wire_request(request) + data = response.json() + found = request_judgments(request) + + def invalid(path: str, detail: str) -> ProviderError: + error = ProviderError( + f"malformed systemone reply at {path}: {detail}", + provider=self.provider, status=response.status, + request_id=self._request_id(response), + ) + _attach_error_metadata(error, response.headers) + return error + + def probability(raw: Any, path: str) -> float: + # Check bounds before converting: arbitrarily large JSON integers + # must be provider faults too, not float-conversion OverflowError. + if (isinstance(raw, bool) or not isinstance(raw, (int, float)) + or not 0 <= raw <= 1 or not math.isfinite(raw)): + raise invalid(path, "expected a finite number in [0, 1]") + return float(raw) + + if not isinstance(data, dict): + raise invalid("$", "expected an object") + answers = data.get("answers") + if not isinstance(answers, dict): + raise invalid("answers", "expected an object containing every declared judgment") + if set(answers) != set(found): + raise invalid("answers", "keys must match the declared judgments exactly") + value: dict[str, Any] = {} + probabilities: dict[str, dict[str, float]] = {} + for name, j in found.items(): + answer = answers[name] + path = f"answers.{name}" + if not isinstance(answer, dict): + raise invalid(path, "expected an answer object") + expected = {"boolean": "noul", "choice": "choice", "ordered": "score"}[j.kind] + if answer.get("type") != expected: + raise invalid(f"{path}.type", f"expected {expected!r}") + if j.kind == "boolean": + p = probability(answer.get("noul"), f"{path}.noul") + value[name] = p >= 0.5 + probabilities[name] = {"true": p, "false": 1.0 - p} + continue + dist = answer.get("probabilities") + if not isinstance(dist, dict) or set(dist) != set(j.keys): + raise invalid(f"{path}.probabilities", "expected one probability for every declared key, and no other keys") + # INV-052: validate measurements individually, NEVER their total. + probs = {k: probability(dist[k], f"{path}.probabilities.{k}") for k in j.keys} + probabilities[name] = probs + if j.kind == "choice": + pick = answer.get("choice") + if not isinstance(pick, str) or pick not in j.keys: + raise invalid(f"{path}.choice", "expected a declared choice key") + value[name] = pick + else: + value[name] = int(max(probs, key=probs.get)) + try: + part = DataPart(value=value, probabilities=probabilities or None, + method="provider_classification" if probabilities else None) + except (TypeError, ValueError, OverflowError) as exc: + raise invalid("answers", str(exc)) from exc + usage_raw = data.get("usage") + if usage_raw is None: + usage_raw = {} + if not isinstance(usage_raw, dict): + raise invalid("usage", "expected an object or null") + try: + usage = Usage(input_tokens=usage_raw.get("input_tokens"), output_tokens=usage_raw.get("output_tokens")) + except (TypeError, ValueError, OverflowError) as exc: + raise invalid("usage", str(exc)) from exc + model = data.get("model") + if model is not None and (not isinstance(model, str) or not model): + raise invalid("model", "expected a non-empty string") + provider_data: dict[str, Any] = {"typesafe": {"answers": answers}} + try: + return Response( + id=self._request_id(response), + model=model if model is not None else request.model, + message=Message(role="assistant", parts=(part,)), + finish_reason="stop", + usage=usage, + provider_data=provider_data, + ) + except (TypeError, ValueError, OverflowError) as exc: + raise invalid("$", str(exc)) from exc + + @staticmethod + def _request_id(response: HttpResponse) -> str | None: + for key, val in response.headers: + if key.lower() == "x-typesafe-request-id" and val: + return val + return None + + def parse_stream_events(self, request: Request, raw_event: SSEEvent) -> Iterator[StreamEvent]: + raise self._refuse("stream", "systemone has no stream") + + # ─── Errors ───────────────────────────────────────────────────── + + def normalize_error(self, status: int, body: str) -> ProviderError: + message = body.strip()[:500] or f"HTTP {status}" + code: str | None = None + try: + payload = json.loads(body) + except (ValueError, TypeError): + payload = None + detail = payload.get("detail") if isinstance(payload, dict) else None + if isinstance(detail, dict): + code = detail.get("error_type") if isinstance(detail.get("error_type"), str) else None + if isinstance(detail.get("message"), str): + message = detail["message"] + elif isinstance(detail, list) and detail: + # pydantic validation: [{type, loc, msg, input}] + first = detail[0] if isinstance(detail[0], dict) else {} + loc = ".".join(str(x) for x in first.get("loc", []) if x != "body") + message = f"{loc}: {first.get('msg', 'validation error')}" if loc else str(first.get("msg", message)) + kwargs: dict[str, Any] = {"provider": self.provider, "provider_code": code, "status": status} + if status == 401 or code == "authentication_error": + return AuthError(message, env_keys=self.access.env_keys, **kwargs) + if status == 429: + return RateLimitError(message, **kwargs) + if status == 400 and "unknown model" in message.lower(): + return UnsupportedModelError(message, **kwargs) + if status in (400, 422): + return InvalidRequestError(message, **kwargs) + if status >= 500: + return ServerError(message, **kwargs) + return self._with_login_hint(map_http_error(status, message, provider=self.provider, + env_keys=self.access.env_keys, provider_code=code)) + + # ─── Models ───────────────────────────────────────────────────── + + def _models_request(self) -> TransportRequest: + return self._emit( + method="GET", + url=f"{self.base_url.rstrip('/')}/v1/models", + headers={"Content-Type": "application/json"}, + ) + + def _models_from_body(self, body: str): + data = json.loads(body) + entries = data.get("models") if isinstance(data, dict) else None + return model_infos_from_entries( + entries, provider=self.provider, api_family="typesafe_systemone", id_of=lambda e: e.get("name"), + ) + + +__all__ = ["TypeSafeLM"] diff --git a/providers/xai.py b/providers/xai.py new file mode 100644 index 0000000000..404c868a81 --- /dev/null +++ b/providers/xai.py @@ -0,0 +1,297 @@ +""" +lm15.providers.xai — xAI Grok adapter (Chat Completions dialect). + +xAI's API speaks the Chat Completions dialect at ``https://api.x.ai/v1`` +(compat preset ``"xai"``, pinned live 2026-09-01). What makes it a +first-class adapter instead of a preset route is authentication: xAI sells +subscription access (SuperGrok / X Premium) through a device-code OAuth +flow, and the resulting access token is sent as an ordinary bearer key. + +Credential resolution order (``oauth-unless-explicit`` policy, spec/auth.md +AUTH-1) — through the router: + +1. an explicit ``RouterConfig.api_keys`` entry (or an ``api_key`` argument + when constructing this class directly): deliberate, in-process + configuration always wins; +2. the stored subscription OAuth login when one is usable: lm15's own + credential store, then the Pi agent store (``~/.pi/agent/auth.json``); + refreshed tokens are written back to their source file (xAI rotates + refresh tokens); +3. ``XAI_API_KEY`` from the environment, only when no usable subscription + login is stored. + +Why the subscription outranks the env var: both are stored state, but using +the subscription costs nothing per token while a key bills every call — and +lm15's durable constraint is that normal inference must not unexpectedly +spend money. Stated residual trade-off: with a subscription stored, a set +``XAI_API_KEY`` is silently ignored; if you need that key's account, pass it +explicitly (``api_key=`` / ``RouterConfig.api_keys``). Run +``lm15.doctor.explain_auth("xai")`` to see which rung won. +""" + +from __future__ import annotations + +from dataclasses import replace + +import base64 +import json +import os +from typing import Any, ClassVar + +from ..access import DEFAULT_XAI_BASE_URL, XAI +from ..adaptation import adapt +from ..errors import ProviderError, UnsupportedFeatureError +from ..features import ProviderManifest +from ..transports import TransportRequest +from .common import path_id +from ..types import ImageGenerationRequest, ImageGenerationResponse, ImagePart, Request, Usage, VideoGenerationRequest, VideoJobInfo, VideoPart +from .base import Credential, HttpResponse, SyncTransport, default_transport +from .openai_chat import OpenAIChatLM + + +class XaiLM(OpenAIChatLM): + """Chat Completions adapter for xAI, with subscription OAuth fallback. + + xAI is a provider, not an access path: its image and video wire, and + its refusals (reasoning off, logprobs, the MAP-8 cells), are provider + facts and live here. Only the credential path is composed: the + ``lm15.access.XAI`` policy carries the ``oauth-unless-explicit`` chain, + the login hint, and the endpoint surfaces. + """ + + manifest: ClassVar[ProviderManifest] = XAI + + def __init__( + self, + api_key: Credential | None = None, + *, + credentials_path: str | os.PathLike[str] | None = None, + transport: SyncTransport | None = None, + base_url: str = DEFAULT_XAI_BASE_URL, + adaptations: "AdaptationPolicy" = "note", + ) -> None: + super().__init__( + api_key=api_key, + transport=transport or default_transport(), + base_url=base_url, + compat="xai", + access=XAI, + credentials_path=credentials_path, + adaptations=adaptations, + ) + + def _payload(self, request: Request, stream: bool) -> dict[str, Any]: + # Grok reasoning models have no off switch. The inherited deepseek + # wire shape (thinking={"type": "disabled"}) is accepted by + # api.x.ai but silently ignored — verified live 2026-09-01: + # grok-4.6 still spent 158 reasoning tokens. A silent paid no-op + # on an explicit disable is worse than an error, so raise. For + # non-reasoning Grok variants, omit the reasoning config + # (Config(reasoning=None)) — they never reason anyway. + reasoning = request.config.reasoning + if reasoning is not None and reasoning.is_off: + # MAP-13 (decision 2026-09-14 §4.2): no off switch exists; the + # lowest level is the closest and the spend shows in usage. + adapt("config.reasoning.effort", "substituted", + "Grok reasoning models have no off switch and api.x.ai ignores disable fields " + "(158 reasoning tokens on an explicit off, live 2026-09-01); the lowest level was sent", + asked="off", applied="low", provider=self.provider) + request = replace(request, config=replace(request.config, reasoning=replace(reasoning, effort="low"))) + # docs.x.ai (models page, 2026-09-01): "logprobs and top_logprobs are + # not supported by models grok-4.20 and newer. These fields will be + # silently ignored if set." Verified live 2026-09-01 on grok-4.6: + # HTTP 200, the choice carries no logprobs key at all. Every Grok + # model served today is 4.20 or newer, so sending the field is a + # guaranteed silent no-op — raise instead. + if request.config.logprobs is not None: + adapt("config.logprobs", "dropped", + "grok-4.20 and newer ignore logprobs/top_logprobs (docs.x.ai, live 2026-09-01); " + "Response.logprobs will be absent (OpenAI and Gemini carry them)", + asked=request.config.logprobs, provider=self.provider) + request = replace(request, config=replace(request.config, logprobs=None)) + tc = request.config.tool_choice + if tc is not None and tc.allowed and not (len(tc.allowed) == 1 and tc.mode == "required"): + # api.x.ai accepts allowed_tools and ignores it (live 2026-09-02: + # with {lookup} allowed and weather asked, it called weather). + # MAP-13 client_side: send only the allowed tools — what the + # allowlist means — and record it. + kept = tuple(t for t in request.tools if t.name in tc.allowed) + adapt("config.tool_choice.allowed", "client_side", + "api.x.ai ignores tool_choice allowlists (live 2026-09-02); only the allowed tools " + "were sent, which is what the allowlist means", + asked=list(tc.allowed), applied=[t.name for t in kept], provider=self.provider) + request = replace(request, tools=kept, + config=replace(request.config, tool_choice=replace(tc, allowed=()))) + tc = request.config.tool_choice + if tc is not None and tc.mode == "required" and request.config.response_format is not None: + # MAP-8 rule 3 (live 2026-09-02): a forced tool next to a + # response_format returned JSON text and no call. + # MAP-13 rule 4(b): the program depends on the call. + raise UnsupportedFeatureError( + "xai: a forced tool (mode='required') cannot be combined with response_format — " + "api.x.ai returns JSON text and drops the call (verified live 2026-09-02)", + provider=self.provider, feature="config.tool_choice.mode", + ) + return super()._payload(request, stream) + + def _image_generate_request(self, request: ImageGenerationRequest) -> TransportRequest: + base = self.base_url.rstrip("/") + payload: dict[str, Any] = {"model": request.model, "prompt": request.prompt, **(request.extensions or {})} + if request.size is not None: + # No wire slot: xAI sizes through quality/resolution knobs with + # their own names (extensions). Raising beats guessing a mapping. + raise UnsupportedFeatureError( + "xai: size has no wire slot; use extensions for xAI's quality/resolution fields", + provider=self.provider, + ) + if not request.images: + return self._emit(method="POST", url=f"{base}/images/generations", headers=self._headers(), payload=payload) + if len(request.images) > 1: + raise UnsupportedFeatureError( + "xai: image edits take exactly one input image; the wire has no slot for more", + provider=self.provider, + ) + payload["image"] = _xai_image_input(request.images[0], self.provider) + return self._emit(method="POST", url=f"{base}/images/edits", headers=self._headers(), payload=payload) + + def _image_generation_from_response(self, request: ImageGenerationRequest, resp: HttpResponse) -> ImageGenerationResponse: + data = resp.json() + images: list[ImagePart] = [] + for item in data.get("data", []) or []: + if not isinstance(item, dict): + continue + mime = item.get("mime_type") + media_type = mime if isinstance(mime, str) and mime else "application/octet-stream" + if item.get("b64_json"): + images.append(ImagePart(media_type=media_type, data=str(item["b64_json"]))) + elif item.get("url"): + images.append(ImagePart(media_type=media_type, url=str(item["url"]))) + if not images: + raise ProviderError("xai: image response carries no images", provider=self.provider) + # Captured: usage reports cost_in_usd_ticks only — no token counts + # exist, so Usage stays empty and the figure lives in provider_data. + return ImageGenerationResponse(images=tuple(images), usage=Usage(), provider_data=data) + + # ─── Video generation (grok-imagine; captured live 2026-09-01) ────── + # + # POST /videos/generations -> {"request_id"}; GET /videos/{id} -> + # pending + progress %, then done + a PUBLIC MP4 URL (downloads with + # no auth, verified) — so the result is URL-addressed, no fetch step. + # There is NO list endpoint (probed: 404): the ticket you store is + # the only copy. + + _VIDEO_STATUS_MAP: ClassVar[dict[str, str]] = { + "pending": "running", + "done": "completed", + "failed": "failed", + } + + def _video_submit_request(self, request: VideoGenerationRequest) -> TransportRequest: + if request.seconds is not None: + raise UnsupportedFeatureError( + "xai: video duration has no wire slot", provider=self.provider, + ) + if request.images: + # The image-generation wire silently IGNORES unknown fields + # (pixel-verified 2026-09-01); an unverified image-input mapping + # here could silently produce prompt-only videos. Raise until + # the field is live-receipted. + raise UnsupportedFeatureError( + "xai: video input images are not mapped yet; " + "use extensions until the mapping is live-receipted", + provider=self.provider, + ) + payload: dict[str, Any] = {"model": request.model, "prompt": request.prompt, **(request.extensions or {})} + return self._emit( + method="POST", url=f"{self.base_url.rstrip('/')}/videos/generations", + headers=self._headers(), payload=payload, + ) + + def _video_job_from_body(self, body: str, video_id: "str | None" = None) -> VideoJobInfo: + data = json.loads(body) + request_id = data.get("request_id") + if isinstance(request_id, str) and request_id: + # The submit acknowledgement: a bare ticket, not yet started. + return VideoJobInfo(id=request_id, status="queued", provider_data=data) + if video_id is None: + raise ProviderError("xai: video body carries no request_id", provider=self.provider) + return self._video_status_info(video_id, data) + + def _video_status_request(self, video_id: str) -> TransportRequest: + return self._emit( + method="GET", url=f"{self.base_url.rstrip('/')}/videos/{path_id(video_id)}", + headers=self._headers(), + ) + + def _video_status_info(self, video_id: str, data: "dict[str, Any]") -> VideoJobInfo: + wire_status = str(data.get("status") or "") + status = self._VIDEO_STATUS_MAP.get(wire_status) + if status is None: + raise ProviderError(f"xai: unknown video status {wire_status!r}", provider=self.provider) + progress = data.get("progress") + return VideoJobInfo( + id=video_id, + status=status, + progress=int(progress) if isinstance(progress, (int, float)) and not isinstance(progress, bool) else None, + model=data.get("model"), + provider_data=data, + ) + + def _video_list_request(self, limit: int, model: "str | None") -> TransportRequest: + raise UnsupportedFeatureError( + "xai: the wire has no video list endpoint (probed 2026-09-01: 404) — " + "the ticket you stored is the only copy", + provider=self.provider, + ) + + def _video_result_fetch(self, status_body: "dict[str, Any]") -> None: + return None # the terminal body carries a public URL + + def _video_part(self, status_body: "dict[str, Any]", fetched: object) -> VideoPart: + video = status_body.get("video") if isinstance(status_body.get("video"), dict) else {} + url = video.get("url") + if not isinstance(url, str) or not url: + raise ProviderError("xai: terminal video carries no url", provider=self.provider) + return VideoPart(media_type="video/mp4", url=url) + + def normalize_error(self, status: int, body: str) -> ProviderError: + # xAI's own envelope is {"code": str, "error": str} (captured + # 2026-09-01: model-not-found 400, unauthenticated 401) — refold it + # into the OpenAI shape so the shared mapping preserves the wire + # code as provider_code instead of dropping it. + try: + data = json.loads(body) + if isinstance(data, dict) and isinstance(data.get("error"), str): + body = json.dumps({"error": {"message": data["error"], "code": data.get("code")}}) + except ValueError: + pass + # Auth failures on the subscription path guide the user back to + # login (the access policy's hint, applied by the shared mapping + # when the stored login was the rung that won). + return super().normalize_error(status, body) + + +# ─── Media generation (captured live 2026-09-01) ───────────────────── +# +# Images: /images/generations for text-to-image; /images/edits for +# image-to-image. The split matters: `generations` silently IGNORES +# input images (verified by pixel check), so edits must never route +# there. The edit input is `image:{url|file_id}` — an https URL, a +# data URI (verified honored), or an xAI file id. Exactly one input +# image; the wire has no slot for more. Responses state `mime_type` +# per image (JPEG, captured); usage reports only a dollar-tick cost, +# which stays verbatim in provider_data (no token counts exist). +# Speech: no endpoint (voice is app-only) — the base hooks raise. + + +def _xai_image_input(part: "ImagePart", provider: str) -> dict[str, str]: + if part.url is not None: + return {"url": part.url} + if part.file_id is not None: + return {"file_id": part.file_id} + if part.data is not None: + return {"url": f"data:{part.media_type};base64,{part.data}"} + if part.path is not None: + encoded = base64.b64encode(part.path.read_bytes()).decode("ascii") + return {"url": f"data:{part.media_type};base64,{encoded}"} + raise UnsupportedFeatureError(f"{provider}: input image carries no content", provider=provider) diff --git a/py.typed b/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rate_limits.py b/rate_limits.py new file mode 100644 index 0000000000..83512e84d1 --- /dev/null +++ b/rate_limits.py @@ -0,0 +1,94 @@ +"""Bounded provider evidence, not a quota model or a retry policy. + +Contract: docs/error-diagnostics.md (2026-09-19). Values retain their vendor +units and duplicates. No credential/header outside the closed set is copied. +""" +from __future__ import annotations + +import json +import math +import re +from types import MappingProxyType +from typing import Iterable, Mapping, Sequence + +RATE_LIMIT_HEADERS = frozenset({ + "retry-after", "retry-after-ms", "x-ms-retry-after-ms", + "x-ratelimit-type", "x-ratelimit-abusepenalty-active", + *(f"x-ratelimit-{field}-{unit}" + for field in ("limit", "remaining", "reset", "renewalperiod") + for unit in ("requests", "tokens")), + *(f"anthropic-ratelimit-{unit}-{field}" + for unit in ("requests", "tokens", "input-tokens", "output-tokens") + for field in ("limit", "remaining", "reset")), +}) + + +def capture_rate_limits(headers: Iterable[tuple[str, str]]) -> Mapping[str, tuple[str, ...]]: + out: dict[str, list[str]] = {} + for name, value in headers: + if not isinstance(name, str) or not isinstance(value, str): + continue + name = name.lower() + if name not in RATE_LIMIT_HEADERS or not 1 <= len(value) <= 256: + continue + if any(ord(c) < 32 or ord(c) > 126 for c in value): + continue + values = out.setdefault(name, []) + if len(values) < 4: + values.append(value) + return MappingProxyType({name: tuple(values) for name, values in out.items()}) + + +def freeze_rate_limits(snapshot: Mapping[str, Sequence[str]] | None) -> Mapping[str, tuple[str, ...]]: + if not isinstance(snapshot, Mapping): + return capture_rate_limits(()) + return capture_rate_limits((name, value) for name, values in snapshot.items() + if isinstance(values, (list, tuple)) for value in values) + + +def milliseconds_seconds(value: object) -> float | None: + if not isinstance(value, str) or len(value) > 256: + return None + if not re.fullmatch(r"\+?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?", value.strip(), flags=re.ASCII): + return None + number = float(value) + return number / 1000 if math.isfinite(number) and number >= 0 else None + + +def normalize_http_response(value: object) -> dict: + """Canonical ErrorDetail handshake block: three optional fields only.""" + if not isinstance(value, dict): + raise TypeError("ErrorDetail.http_response must be an object") + if set(value) - {"request_id", "retry_after", "rate_limit_headers"}: + raise ValueError("unknown ErrorDetail.http_response field") + out = {} + request_id = value.get("request_id") + if request_id is not None: + if not isinstance(request_id, str) or not request_id: + raise ValueError("http_response.request_id must be a non-empty string") + out["request_id"] = request_id + wait = value.get("retry_after") + if wait is not None: + if isinstance(wait, bool) or not isinstance(wait, (int, float)) or not math.isfinite(wait) or wait < 0: + raise ValueError("http_response.retry_after must be finite nonnegative seconds") + out["retry_after"] = float(wait) + if "rate_limit_headers" in value: + headers = value["rate_limit_headers"] + if not isinstance(headers, dict) or any(not isinstance(v, (list, tuple)) or any(not isinstance(s, str) for s in v) for v in headers.values()): + raise TypeError("http_response.rate_limit_headers must map names to string arrays") + snapshot = freeze_rate_limits(headers) + if snapshot: + out["rate_limit_headers"] = {k: list(v) for k, v in snapshot.items()} + return out + + +def diagnostics_text(snapshot: Mapping[str, Sequence[str]], retry_after: float | None) -> str: + pieces = [] + if isinstance(retry_after, (int, float)) and not isinstance(retry_after, bool) and math.isfinite(retry_after) and retry_after >= 0: + pieces.append(f"Retry advice: {retry_after:g} seconds (not a guarantee).") + if snapshot: + raw = json.dumps(dict(snapshot), sort_keys=True, ensure_ascii=True) + if len(raw) > 2048: + raw = raw[:2048] + "... [full retained values in rate_limit_headers]" + pieces.append("Provider rate-limit headers (raw; advisory): " + raw) + return "\n\n " + "\n ".join(pieces) if pieces else "" diff --git a/registry.py b/registry.py new file mode 100644 index 0000000000..d17ee5ca8f --- /dev/null +++ b/registry.py @@ -0,0 +1,554 @@ +""" +lm15.registry — the one table of named providers. + +A routable provider string names a :class:`ProviderDefinition`: a wire +dialect (the adapter class that speaks it), an :class:`AccessPolicy` +(credential chain, static headers, endpoint surfaces, default base URL) +and — for the Chat Completions dialect — the compat preset that names the +server's quirks. The router, the doctor, the vet surface dump (and +through it the contract's support matrix) and the docs tables all read +this table. Nothing else lists providers. + +A third source of entries, with the same shape, is the caller: +``RouterConfig(providers=(ProviderDefinition.chat(...), ...))`` declares a +provider this table does not list (a hosted gateway, a service lm15 has +not receipted yet). A declared entry names its compat *object* rather +than a preset name, is routable only through routers built with that +config, and answers ``Resolution.declared`` — it carries no live receipt, +and lm15 says so rather than pretend. + +Two kinds of entries share one shape: + +- **adapter-owned** — the dialect class carries its own manifest + (``OpenAILM``, ``AnthropicLM``, ``GeminiLM``, ``XaiLM``, ``ClaudeCodeLM``, + ``OpenAICodexLM``). The entry points at the class; ``access`` IS the + class manifest. +- **bound** — a dialect class with an access policy bound at construction + plus a compat preset: ``OpenAIChatLM`` for ``groq``, ``openrouter``, + ``deepseek``, ``zai``, ``ollama``, ``vllm``, ``sglang``; ``AnthropicLM`` + for ``deepseek-anthropic``. Pure data: adding one is a declaration in + this file plus a live receipt in the contract, never a new class. + +Rules this table enforces (``tests/test_registry.py``): + +- a provider string names ONE wire behavior — the same service reachable + over two dialects is two entries (spec rule: a provider name describes + wire behavior, not credential ownership); +- ``access.provider`` equals the entry id (hyphenated form; the underscore + form is an input alias only — spec/vocabularies.md § Open string + namespaces, 2026-09-08); +- a bound entry's ``access.base_url`` equals its dialect's compat table + URL for the same preset — one copy of each URL; +- an entry with ``placeholder_key`` declares no env keys (keyless local + servers), and vice versa. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import MappingProxyType +from typing import Literal, Mapping + +from . import access as _access +from .compat import ( + ANTHROPIC_PRESET_BASE_URLS, + OPENAI_CHAT_PRESET_BASE_URLS, + OPENAI_RESPONSES_PRESET_BASE_URLS, + AnthropicCompat, + OpenAIChatCompat, + OpenAIResponsesCompat, +) +from .features import AccessPolicy, CredentialPolicy, EndpointSupport +from .providers import ( + AnthropicLM, + AsyncAnthropicLM, + AsyncClaudeCodeLM, + AsyncGeminiLM, + AsyncOpenAIChatLM, + AsyncOpenAICodexLM, + AsyncOpenAILM, + AsyncTypeSafeLM, + AsyncXaiLM, + ClaudeCodeLM, + GeminiLM, + OpenAIChatLM, + OpenAICodexLM, + OpenAILM, + TypeSafeLM, + XaiLM, +) + +__all__ = [ + "Compat", + "Dialect", + "ProviderDefinition", + "PROVIDERS", + "canonical_provider", + "lookup", +] + +# The wire formats lm15 speaks. A dialect is a class; a provider is a +# dialect plus an access policy (plus a compat preset for the chat dialect). +Dialect = Literal["openai-responses", "openai-chat", "anthropic", "gemini", "typesafe"] + +# A compat value: a preset name for a registry entry (validated against +# the dialect's table below), or the object itself for a declared entry. +Compat = OpenAIResponsesCompat | OpenAIChatCompat | AnthropicCompat + +# Per dialect: the compat class a bound entry may carry as an object, and +# the sync/async classes that speak it. A dialect absent here cannot bind. +_DIALECTS: dict[str, tuple[type, type, type]] = { + "openai-responses": (OpenAIResponsesCompat, OpenAILM, AsyncOpenAILM), + "openai-chat": (OpenAIChatCompat, OpenAIChatLM, AsyncOpenAIChatLM), + "anthropic": (AnthropicCompat, AnthropicLM, AsyncAnthropicLM), +} + + +# Per dialect: the compat preset constructor and the preset → base URL table +# a bound entry is validated against. A dialect absent here cannot bind. +_COMPAT_TABLES: dict[str, tuple] = { + "openai-responses": (OpenAIResponsesCompat.preset, OPENAI_RESPONSES_PRESET_BASE_URLS), + "openai-chat": (OpenAIChatCompat.preset, OPENAI_CHAT_PRESET_BASE_URLS), + "anthropic": (AnthropicCompat.preset, ANTHROPIC_PRESET_BASE_URLS), +} + + +def canonical_provider(name: str) -> str: + """Provider strings are hyphenated (``openai-chat``); the underscore + spelling is accepted everywhere as a permanent alias.""" + return name.replace("_", "-") + + +@dataclass(frozen=True, slots=True) +class ProviderDefinition: + """Everything lm15 knows about one named provider, as a value. + + ``id`` canonical provider string (hyphenated). + ``dialect`` the wire format; names which adapter class speaks it. + ``adapter`` sync LM class; ``async_adapter`` its async mirror. + ``access`` the credential chain, headers, surfaces and default + base URL (``lm15.access``); for a bound entry the + router passes this to the dialect constructor. + ``compat`` compat preset name (bound registry entries), or the + compat object itself (a declared entry, whose + preset lives in the caller's code, not lm15's + tables). + ``aliases`` extra input spellings of the provider, canonical + form (``fireworks-ai`` for litellm's + ``fireworks_ai/``); accepted as ``alias:model`` + and ``alias/model``, never emitted. + ``placeholder_key`` the key a keyless local server accepts when nothing + is configured (AUTH-1 last rung); None otherwise. + ``console_url`` where a human gets a key (docs, doctor hints). + ``note`` one-line human rationale, surfaced in docs and + ``Resolution.describe``. + """ + + id: str + dialect: Dialect + adapter: type + async_adapter: type + access: AccessPolicy + compat: str | Compat | None = None + placeholder_key: str | None = None + console_url: str | None = None + note: str = "" + aliases: tuple[str, ...] = () + + def __post_init__(self) -> None: + if self.id != canonical_provider(self.id): + raise ValueError(f"provider id must be hyphenated: {self.id!r}") + if canonical_provider(self.access.provider) != self.id: + raise ValueError(f"{self.id}: access policy names provider {self.access.provider!r}") + if self.placeholder_key is not None and self.access.env_keys: + raise ValueError(f"{self.id}: a keyless local server declares no env_keys") + if not isinstance(self.aliases, tuple) or not all(isinstance(a, str) and a for a in self.aliases): + raise TypeError(f"{self.id}: aliases is a tuple of non-empty strings") + canonical = tuple(canonical_provider(a) for a in self.aliases) + if canonical != self.aliases: + raise ValueError(f"{self.id}: aliases are hyphenated: {canonical!r}") + if len(set(self.aliases)) != len(self.aliases) or self.id in self.aliases: + raise ValueError(f"{self.id}: aliases repeat a spelling") + if self.compat is not None and not isinstance(self.compat, str): + # A compat object: the dialect's own class, and a place to send + # the request (a bound entry has no preset table row to read a + # URL from; a hosted entry renders its host template). + if self.dialect not in _DIALECTS: + raise ValueError(f"{self.id}: dialect {self.dialect!r} takes no compat") + compat_cls = _DIALECTS[self.dialect][0] + if not isinstance(self.compat, compat_cls): + raise TypeError( + f"{self.id}: compat for dialect {self.dialect!r} is a {compat_cls.__name__}, " + f"got {type(self.compat).__name__}" + ) + if not self.hosted and not self.access.base_url: + raise ValueError(f"{self.id}: a declared provider names its base_url on the access policy") + if not self.hosted and self.access.credential_policy != "key": + # The router binds a bound entry as cls(api_key=..., access=..., + # compat=...); the OAuth policies build self-resolving + # constructors that take no access policy, so a bound OAuth + # entry would silently lose its declaration. + raise ValueError( + f"{self.id}: a declared provider is key-based; credential_policy " + f"{self.access.credential_policy!r} needs its own adapter class" + ) + return + if self.hosted: + # A cloud door (AUTH-10 host): the base URL is a template rendered + # over the host settings at construction, so the compat-table URL + # rule does not apply; a compat preset is optional and, when + # named, must exist for the dialect. + if self.compat is not None: + presets, _ = _COMPAT_TABLES[self.dialect] + presets(self.compat) + return + if self.bound: + if self.compat is None: + raise ValueError(f"{self.id}: a bound entry names its compat preset") + presets, urls = _COMPAT_TABLES[self.dialect] + presets(self.compat) # raises for an unknown preset + expected = urls.get(self.compat) + if self.access.base_url != expected: + raise ValueError( + f"{self.id}: access.base_url {self.access.base_url!r} != " + f"compat table {expected!r} for preset {self.compat!r}" + ) + + @property + def hosted(self) -> bool: + """True when the access policy names a cloud host (AUTH-10).""" + return self.access.host is not None + + @property + def bound(self) -> bool: + """True when the router binds ``access`` onto the dialect class at + construction (the class's own manifest is not this provider).""" + return self.access is not self.adapter.manifest + + @property + def env_keys(self) -> tuple[str, ...]: + return self.access.env_keys + + @property + def credential_policy(self) -> CredentialPolicy: + return self.access.credential_policy + + @property + def supports(self) -> EndpointSupport: + return self.access.supports + + @property + def base_url(self) -> str | None: + return self.access.base_url + + @property + def spellings(self) -> tuple[str, ...]: + """Every canonical input spelling: the id, then the aliases.""" + return (self.id, *self.aliases) + + # ─── declaring a provider (RouterConfig(providers=...)) ───────────── + + @classmethod + def chat( + cls, + access: AccessPolicy, + *, + compat: OpenAIChatCompat | str, + aliases: tuple[str, ...] = (), + placeholder_key: str | None = None, + console_url: str | None = None, + note: str = "", + ) -> ProviderDefinition: + """A provider speaking the OpenAI Chat Completions wire: ``access`` + names it (``AccessPolicy(provider=..., env_keys=..., base_url=...)``) + and ``compat`` describes the server's spellings + (:class:`~lm15.compat.OpenAIChatCompat`).""" + return cls._bound("openai-chat", access, compat=compat, aliases=aliases, placeholder_key=placeholder_key, + console_url=console_url, note=note) + + @classmethod + def responses( + cls, + access: AccessPolicy, + *, + compat: OpenAIResponsesCompat | str, + aliases: tuple[str, ...] = (), + console_url: str | None = None, + note: str = "", + ) -> ProviderDefinition: + """A provider speaking the OpenAI Responses wire.""" + return cls._bound("openai-responses", access, compat=compat, aliases=aliases, console_url=console_url, note=note) + + @classmethod + def anthropic( + cls, + access: AccessPolicy, + *, + compat: AnthropicCompat | str, + aliases: tuple[str, ...] = (), + console_url: str | None = None, + note: str = "", + ) -> ProviderDefinition: + """A provider speaking the Anthropic Messages wire.""" + return cls._bound("anthropic", access, compat=compat, aliases=aliases, console_url=console_url, note=note) + + @classmethod + def _bound(cls, dialect: Dialect, access: AccessPolicy, *, compat, aliases=(), placeholder_key=None, + console_url=None, note="") -> ProviderDefinition: + _, adapter, async_adapter = _DIALECTS[dialect] + return cls( + id=canonical_provider(access.provider), + dialect=dialect, + adapter=adapter, + async_adapter=async_adapter, + access=access, + compat=compat, + placeholder_key=placeholder_key, + console_url=console_url, + note=note, + aliases=tuple(aliases), + ) + + +def _adapter_owned( + id: str, + dialect: Dialect, + adapter: type, + async_adapter: type, + *, + console_url: str | None = None, + note: str = "", +) -> ProviderDefinition: + return ProviderDefinition( + id=id, + dialect=dialect, + adapter=adapter, + async_adapter=async_adapter, + access=adapter.manifest, + console_url=console_url, + note=note, + ) + + +def _responses_bound( + access: AccessPolicy, + *, + compat: str, + console_url: str | None = None, + note: str = "", +) -> ProviderDefinition: + return ProviderDefinition.responses(access, compat=compat, console_url=console_url, note=note) + + +def _chat_bound( + access: AccessPolicy, + *, + compat: str | None = None, + placeholder_key: str | None = None, + console_url: str | None = None, + note: str = "", +) -> ProviderDefinition: + return ProviderDefinition.chat(access, compat=compat or access.provider, placeholder_key=placeholder_key, + console_url=console_url, note=note) + + +def _anthropic_bound( + access: AccessPolicy, + *, + compat: str, + console_url: str | None = None, + note: str = "", +) -> ProviderDefinition: + return ProviderDefinition.anthropic(access, compat=compat, console_url=console_url, note=note) + + +def _hosted( + access: AccessPolicy, + dialect: Dialect, + adapter: type, + async_adapter: type, + *, + compat: str | None = None, + console_url: str | None = None, + note: str = "", +) -> ProviderDefinition: + """A cloud door: an existing dialect behind a host (changes/2026-09-03-cloud-hosts.md). + Declared from documentation until its live receipt lands.""" + return ProviderDefinition( + id=access.provider, + dialect=dialect, + adapter=adapter, + async_adapter=async_adapter, + access=access, + compat=compat, + console_url=console_url, + note=note, + ) + + +# Declaration order is presentation order (docs tables, `known providers` +# lists are sorted separately). Adapter-owned entries first, then the +# chat-bound services, then the keyless local servers. +_DEFINITIONS: tuple[ProviderDefinition, ...] = ( + _adapter_owned( + "openai", "openai-responses", OpenAILM, AsyncOpenAILM, + console_url="https://platform.openai.com/api-keys", + note="OpenAI Responses API", + ), + _adapter_owned( + "openai-chat", "openai-chat", OpenAIChatLM, AsyncOpenAIChatLM, + console_url="https://platform.openai.com/api-keys", + note="OpenAI Chat Completions dialect (the de-facto standard other servers speak)", + ), + _adapter_owned( + "anthropic", "anthropic", AnthropicLM, AsyncAnthropicLM, + console_url="https://console.anthropic.com", + note="Anthropic Messages API", + ), + _adapter_owned( + "gemini", "gemini", GeminiLM, AsyncGeminiLM, + console_url="https://aistudio.google.com/apikey", + note="Google Gemini API", + ), + _adapter_owned( + "xai", "openai-chat", XaiLM, AsyncXaiLM, + console_url="https://console.x.ai", + note="xAI Grok (Chat Completions dialect; XAI_API_KEY or subscription OAuth)", + ), + _adapter_owned( + "typesafe", "typesafe", TypeSafeLM, AsyncTypeSafeLM, + console_url="https://console.typesafe.ai/keys", + note="TypeSafe System One (Jev): judgments over declared keys with probabilities; no text generation", + ), + _adapter_owned( + "claude-code", "anthropic", ClaudeCodeLM, AsyncClaudeCodeLM, + note="Claude subscription through the local `claude` CLI login", + ), + _adapter_owned( + "openai-codex", "openai-responses", OpenAICodexLM, AsyncOpenAICodexLM, + note="ChatGPT subscription through the local `codex` CLI login", + ), + _chat_bound( + _access.GROQ, + console_url="https://console.groq.com/keys", + note="Groq Cloud (Chat Completions dialect)", + ), + _chat_bound( + _access.OPENROUTER, + console_url="https://openrouter.ai/keys", + note="OpenRouter (Chat Completions dialect)", + ), + _chat_bound( + _access.DEEPSEEK, + console_url="https://platform.deepseek.com/api_keys", + note="DeepSeek (Chat Completions dialect; thinking mode on by default)", + ), + _anthropic_bound( + _access.DEEPSEEK_ANTHROPIC, + compat="deepseek", + console_url="https://platform.deepseek.com/api_keys", + note="DeepSeek over the Anthropic Messages wire (same key as `deepseek`; no model listing)", + ), + _chat_bound( + _access.ZAI, + console_url="https://z.ai/manage-apikey/apikey-list", + note="Z.AI GLM (Chat Completions dialect; general endpoint, not the Coding Plan)", + ), + _chat_bound( + _access.MOONSHOTAI, + console_url="https://platform.kimi.ai/console/api-keys", + note="Moonshot AI Kimi (Chat Completions dialect; kimi-k3 takes reasoning effort low|high|max, kimi-k2.6 takes effort off; " + "Moonshot's docs call the key MOONSHOT_API_KEY — read after MOONSHOTAI_API_KEY)", + ), + _responses_bound( + _access.MOONSHOTAI_RESPONSES, + compat="moonshotai", + console_url="https://platform.kimi.ai/console/api-keys", + note="Moonshot AI Kimi over the Responses wire (same key as `moonshotai`; kimi-k3 only; stateless — reasoning replays as summary text; web_search built-in)", + ), + _anthropic_bound( + _access.MOONSHOTAI_ANTHROPIC, + compat="moonshotai", + console_url="https://platform.kimi.ai/console/api-keys", + note="Moonshot AI Kimi over the Anthropic Messages wire (same key as `moonshotai`, bearer token; kimi-k3 only)", + ), + _responses_bound( + _access.META, + compat="meta", + console_url="https://dev.meta.ai/", + note="Meta Model API — Muse Spark over the Responses wire (reasoning replay, web_search), plus Files, Images (muse-image-1.0) and Models; Meta's docs call the key MODEL_API_KEY — export it as META_API_KEY", + ), + _chat_bound( + _access.META_CHAT, + compat="meta", + console_url="https://dev.meta.ai/", + note="Meta Model API over the Chat Completions wire (same key as `meta`; no cross-turn reasoning)", + ), + _anthropic_bound( + _access.META_ANTHROPIC, + compat="meta", + console_url="https://dev.meta.ai/", + note="Meta Model API over the Anthropic Messages wire (same key as `meta`; bearer token)", + ), + # ─── Cloud hosts (documentation-evidenced; changes/2026-09-03-cloud-hosts.md) ─── + _hosted( + _access.AZURE, "openai-responses", OpenAILM, AsyncOpenAILM, + console_url="https://portal.azure.com/", + note="Azure OpenAI v1 Responses wire ({resource}.openai.azure.com, or AZURE_OPENAI_ENDPOINT = the Foundry root the console shows; model = deployment name; api-key or Entra token)", + ), + _hosted( + _access.AZURE_CHAT, "openai-chat", OpenAIChatLM, AsyncOpenAIChatLM, compat="openai", + console_url="https://portal.azure.com/", + note="Azure OpenAI v1 Chat Completions wire (same resource or AZURE_OPENAI_ENDPOINT; also Foundry-sold models such as DeepSeek, Kimi and Grok)", + ), + _hosted( + _access.AZURE_ANTHROPIC, "anthropic", AnthropicLM, AsyncAnthropicLM, + console_url="https://ai.azure.com/", + note="Claude in Microsoft Foundry ({resource}.services.ai.azure.com/anthropic or ANTHROPIC_FOUNDRY_BASE_URL; x-api-key or Entra token)", + ), + _hosted( + _access.AWS_ANTHROPIC, "anthropic", AnthropicLM, AsyncAnthropicLM, + console_url="https://console.aws.amazon.com/", + note="Claude Platform on AWS (Anthropic-operated; SigV4 or ANTHROPIC_AWS_API_KEY; needs AWS_REGION and ANTHROPIC_AWS_WORKSPACE_ID)", + ), + _hosted( + _access.BEDROCK_ANTHROPIC, "anthropic", AnthropicLM, AsyncAnthropicLM, + console_url="https://console.aws.amazon.com/bedrock/", + note="Claude in Amazon Bedrock (bedrock-mantle, Opus 4.7 and later; SigV4 or AWS_BEARER_TOKEN_BEDROCK; needs AWS_REGION)", + ), + _hosted( + _access.BEDROCK_CHAT, "openai-chat", OpenAIChatLM, AsyncOpenAIChatLM, compat="bedrock", + console_url="https://console.aws.amazon.com/bedrock/", + note="Amazon Bedrock over the OpenAI Chat Completions wire (bedrock-runtime /openai/v1; SigV4 or AWS_BEARER_TOKEN_BEDROCK)", + ), + _hosted( + _access.BEDROCK_MANTLE_CHAT, "openai-chat", OpenAIChatLM, AsyncOpenAIChatLM, compat="bedrock-mantle", + console_url="https://console.aws.amazon.com/bedrock/", + note="Amazon Bedrock Chat Completions on bedrock-mantle (un-versioned ids, GET /v1/models; SigV4 or AWS_BEARER_TOKEN_BEDROCK)", + ), + _hosted( + _access.VERTEX, "gemini", GeminiLM, AsyncGeminiLM, + console_url="https://console.cloud.google.com/vertex-ai", + note="Gemini on Google Cloud (Agent Platform); ADC chain; needs GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION defaults to global", + ), + _hosted( + _access.VERTEX_EXPRESS, "gemini", GeminiLM, AsyncGeminiLM, + console_url="https://console.cloud.google.com/vertex-ai/studio", + note="Agent Platform express mode: GOOGLE_API_KEY as ?key=, no project or location", + ), + _hosted( + _access.VERTEX_ANTHROPIC, "anthropic", AnthropicLM, AsyncAnthropicLM, + console_url="https://console.cloud.google.com/vertex-ai/model-garden", + note="Claude on Google Cloud (rawPredict; model in the path, anthropic_version in the body)", + ), + _chat_bound(_access.OLLAMA, placeholder_key="ollama", note="local ollama server (keyless)"), + _chat_bound(_access.VLLM, placeholder_key="EMPTY", note="local vLLM server (keyless)"), + _chat_bound(_access.SGLANG, placeholder_key="EMPTY", note="local SGLang server (keyless)"), +) + +PROVIDERS: Mapping[str, ProviderDefinition] = MappingProxyType({d.id: d for d in _DEFINITIONS}) + + +def lookup(name: str) -> ProviderDefinition | None: + """The definition for a provider string in either spelling, or None.""" + return PROVIDERS.get(canonical_provider(name)) diff --git a/result.py b/result.py new file mode 100644 index 0000000000..ebab452aaf --- /dev/null +++ b/result.py @@ -0,0 +1,1211 @@ +""" +lm15.result — Stream materialization. + +One engine and two skins, all speaking the canonical StreamEvent +vocabulary: + +- ``StreamAccumulator`` — the push-based engine. Feed it events, ask it + for the ``Response``. It is the shape every port shares (Rust/Go/TS + have no generators) and the only place accumulation logic lives. +- ``ResponseStream`` / ``AsyncResponseStream`` — lazy iteration sugar + over a live stream: iterate for text as it arrives, ``.events()`` for + the canonical typed events, ``.response`` afterwards for the same + Response a non-streaming call returns. +- ``materialize_response`` / ``amaterialize_response`` — the one-shot + functional form. + +Nothing here executes anything: tool calls are surfaced as data, and +any execute-tools-until-done loop belongs to the layer above lm15. +""" + +from __future__ import annotations + +import json +from collections import deque +from dataclasses import dataclass, field, replace +from typing import Any, AsyncIterator, Iterator + +from .errors import LM15Error, StreamAssemblyError, StreamCleanupWarning, error_class_for_code +from .types import ( + AudioDelta, + AudioPart, + CitationDelta, + CitationPart, + ContinuationDelta, + ContinuationState, + ErrorDetail, + ImageDelta, + ImagePart, + JsonObject, + Message, + Part, + Request, + Response, + StreamDeltaEvent, + StreamEndEvent, + StreamEvent, + StreamStartEvent, + TextDelta, + TextPart, + ThinkingDelta, + ThinkingPart, + TokenLogprob, + ToolCallDelta, + ToolCallPart, + Usage, +) + +__all__ = [ + "StreamAccumulator", + "ResponseStream", + "AsyncResponseStream", + "materialize_response", + "amaterialize_response", + "response_to_events", + "coalesce_stream", + "acoalesce_stream", +] + + +@dataclass(slots=True) +class StreamAccumulator: + """Accumulates canonical stream events into a complete Response. + + Push-based so that sync iteration, async iteration, and one-shot + materialization all share the same accumulation logic: + + acc = StreamAccumulator(request) + for event in lm.stream(request): + acc.push(event) + response = acc.response() + + ``push`` ignores error events — deciding whether to raise is the + caller's job (``ResponseStream`` raises; a resumption layer might + not). ``response()`` materializes whatever has been accumulated so + far; callers normally push through the end event first. + """ + + request: Request + started_id: str | None = None + started_model: str | None = None + finish_reason: str | None = None + usage: Usage | None = None + text_parts: dict[int, list[str]] = field(default_factory=dict) + thinking_parts: dict[int, list[str]] = field(default_factory=dict) + audio_chunks: dict[int, list[str]] = field(default_factory=dict) + audio_media_types: dict[int, str | None] = field(default_factory=dict) + image_parts: dict[int, ImagePart] = field(default_factory=dict) + citation_parts: dict[int, list[CitationPart]] = field(default_factory=dict) + tool_call_raw: dict[int, str] = field(default_factory=dict) + tool_call_meta: dict[int, dict[str, Any]] = field(default_factory=dict) + message_continuation: list[ContinuationState] = field(default_factory=list) + part_continuation: dict[int, list[ContinuationState]] = field(default_factory=dict) + logprob_seq: list[TokenLogprob] = field(default_factory=list) + logprobs_complete: bool = True + provider_data: dict[str, Any] | None = None + adaptations: tuple = () + + def push(self, event: StreamEvent) -> None: + """Fold one canonical stream event into the accumulated state.""" + if event.type == "start": + self.started_id = event.id or self.started_id + self.started_model = event.model or self.started_model + if event.adaptations: + self.adaptations = event.adaptations + return + + if event.type == "end": + self.finish_reason = event.finish_reason or self.finish_reason + self.usage = event.usage or self.usage + if event.provider_data is not None: + self.provider_data = event.provider_data + return + + if event.type != "delta" or event.delta is None: + return + + delta = event.delta + + if delta.type == "text": + self.text_parts.setdefault(delta.part_index, []).append(delta.text or "") + if delta.logprobs: + self.logprob_seq.extend(delta.logprobs) + self.logprobs_complete = self.logprobs_complete and delta.logprobs_complete + + elif delta.type == "thinking": + self.thinking_parts.setdefault(delta.part_index, []).append(delta.text or "") + + elif delta.type == "audio": + self.audio_chunks.setdefault(delta.part_index, []).append(delta.data or "") + self.audio_media_types.setdefault(delta.part_index, delta.media_type) + + elif delta.type == "tool_call": + idx = delta.part_index + meta = self.tool_call_meta.setdefault(idx, {}) + if delta.id is not None: + meta["id"] = str(delta.id) + if delta.name is not None: + meta["name"] = str(delta.name) + aggregate = self.tool_call_raw.get(idx, "") + delta.input + self.tool_call_raw[idx] = aggregate + meta["input"] = _parse_json_best_effort(aggregate) + + elif delta.type == "image": + mt = delta.media_type or "image/png" + if delta.data is not None: + part = ImagePart(media_type=mt, data=str(delta.data)) + elif delta.url is not None: + part = ImagePart(media_type=mt, url=str(delta.url)) + elif delta.file_id is not None: + part = ImagePart(media_type=mt, file_id=str(delta.file_id)) + else: + return + self.image_parts[delta.part_index] = part + + elif delta.type == "citation": + self.citation_parts.setdefault(delta.part_index, []).append(CitationPart( + text=delta.text, url=delta.url, title=delta.title, + )) + + elif delta.type == "continuation": + state = delta.to_state() + if delta.part_index is None: + self.message_continuation.append(state) + else: + self.part_continuation.setdefault(delta.part_index, []).append(state) + + def response(self) -> Response: + """Build a complete Response from the accumulated state. + + Raises :class:`StreamAssemblyError` when a tool call's fragments + never carried a name (MAP-9): the error carries everything else + that did assemble as ``partial``. + """ + unnamed = sorted( + idx for idx, meta in self.tool_call_meta.items() if not meta.get("name") + ) + if unnamed: + partial = self._assemble(skip=frozenset(unnamed)) + from .errors import StreamAssemblyError + + raise StreamAssemblyError( + f"tool call at part {unnamed[0]} arrived without a name; the adapter " + "that produced this stream must set ToolCallDelta.name on the call's " + "first fragment (MAP-9: lm15 does not guess which tool the model meant)", + partial=partial, + part_index=unnamed[0], + ) + return self._assemble(skip=frozenset()) + + def _assemble(self, *, skip: frozenset[int]) -> Response: + parts: list[Part] = [] + + part_indexes = sorted( + set(self.thinking_parts) + | set(self.text_parts) + | set(self.image_parts) + | set(self.audio_chunks) + | set(self.citation_parts) + | set(self.tool_call_meta) + | set(self.part_continuation) + ) + + for idx in part_indexes: + continuation = tuple(self.part_continuation.get(idx, ())) + has_tool = idx in self.tool_call_meta and idx not in skip + if idx in self.thinking_parts: + parts.append(ThinkingPart(text="".join(self.thinking_parts[idx]), continuation=continuation)) + if idx in self.text_parts: + parts.append(TextPart(text="".join(self.text_parts[idx]), continuation=continuation)) + if idx in self.image_parts: + parts.append(replace(self.image_parts[idx], continuation=continuation)) + if idx in self.audio_chunks: + raw_data = _concat_b64_chunks(self.audio_chunks[idx]) + media_type = self.audio_media_types.get(idx) + from .types import audio as make_audio + if media_type in (None, "audio/pcm", "audio/pcm16"): + parts.append(make_audio(data=_pcm_to_wav(raw_data), media_type="audio/wav", continuation=continuation)) + else: + parts.append(make_audio(data=raw_data, media_type=media_type, continuation=continuation)) + if idx in self.citation_parts: + parts.extend(replace(part, continuation=continuation) for part in self.citation_parts[idx]) + if has_tool: + meta = self.tool_call_meta[idx] + payload = meta.get("input") + if not isinstance(payload, dict): + payload = _parse_json_best_effort(self.tool_call_raw.get(idx, "")) + # A missing id gets an lm15-minted correlator (Gemini sends + # none); a missing name is never minted — see response(). + tc_id = str(meta.get("id") or f"tool_call_{idx}") + parts.append(ToolCallPart(id=tc_id, name=str(meta["name"]), input=payload, continuation=continuation)) + elif ( + idx not in skip + and idx not in self.thinking_parts + and idx not in self.text_parts + and idx not in self.image_parts + and idx not in self.audio_chunks + and idx not in self.citation_parts + ): + parts.append(TextPart(text="", continuation=continuation)) + + if not parts: + parts = [TextPart(text="")] + + finish = self.finish_reason + has_tool_calls = any(isinstance(p, ToolCallPart) for p in parts) + if finish is None: + finish = "tool_call" if has_tool_calls else "stop" + elif finish == "stop" and has_tool_calls: + finish = "tool_call" + + from .judgments import replace_text_with_data, request_judgments + answer_parts = replace_text_with_data(parts, request_judgments(self.request)) + return Response( + id=self.started_id, + model=self.started_model or self.request.model, + message=Message(role="assistant", parts=answer_parts, continuation=tuple(self.message_continuation)), + finish_reason=finish, + usage=self.usage or Usage(), + logprobs=tuple(self.logprob_seq) if self.logprob_seq else None, + logprobs_complete=self.logprobs_complete, + provider_data=self.provider_data, + adaptations=self.adaptations, + ) + + +class ResponseStream: + """Lazy stream-backed response assembler. + + rs = ResponseStream(lm.stream(request), request) + for text in rs: # text as it arrives + print(text, end="") + rs.response # the same Response complete() returns + + ``rs.events()`` yields the canonical StreamEvents instead — one + vocabulary for raw and assembled streaming. Accessors mirror + Response's own minimal set; everything richer is + ``rs.response.message.first(...)`` / ``.parts_of(...)``. + + Tool calls are surfaced as data only; ResponseStream never executes + anything. + """ + + def __init__(self, events: Iterator[StreamEvent], request: Request) -> None: + self._accumulator = StreamAccumulator(request) + self._source = events + self._response: Response | None = None + self._failure: BaseException | None = None + self._done = False + self._source_closed = False + #: Failures that followed the end event (a read error while draining, + #: a close() that raised). The Response is complete regardless; each + #: was also emitted as a StreamCleanupWarning. + self.cleanup_errors: tuple[BaseException, ...] = () + self._event_iter = self._pump() + + def _close_source(self, primary: BaseException | None = None) -> None: + if not self._source_closed: + self._source_closed = True + _close_events(self._source, primary, self._response, self._record_cleanup) + + def _record_cleanup(self, exc: BaseException) -> None: + self.cleanup_errors = (*self.cleanup_errors, exc) + + def close(self) -> None: + """Stop reading and release the source, without draining it. + + An unfinished stream has no complete response. Closing does not + promise that the provider stops generating or billing immediately. + """ + if not self._done and self._response is None and self._failure is None: + self._failure = _closed_early(self._accumulator) + try: + self._event_iter.close() + finally: + self._done = True + self._close_source() + + def __enter__(self) -> "ResponseStream": + return self + + def __exit__(self, *_exc: Any) -> None: + self.close() + + def __iter__(self) -> Iterator[str]: + for event in self.events(): + if event.type == "delta" and event.delta is not None: + if event.delta.type == "text" and event.delta.text is not None: + yield event.delta.text + + def events(self) -> Iterator[StreamEvent]: + """Canonical stream events, teed through the accumulator.""" + while True: + try: + yield next(self._event_iter) + except StopIteration: + return + + @property + def text(self) -> str | None: + return self.response.text + + @property + def tool_calls(self) -> list[ToolCallPart]: + return self.response.tool_calls + + @property + def citations(self) -> list[CitationPart]: + return self.response.citations + + @property + def usage(self) -> Usage: + return self.response.usage + + @property + def finish_reason(self) -> str: + return self.response.finish_reason + + @property + def model(self) -> str: + return self.response.model + + @property + def json(self) -> Any: + return self.response.json + + @property + def response(self) -> Response: + if self._failure is not None: + raise self._failure + if not self._done: + for _ in self.events(): + pass + if self._failure is not None: # pragma: no cover - pump raises first + raise self._failure + assert self._response is not None + return self._response + + def _pump(self) -> Iterator[StreamEvent]: + primary = None + try: + try: + for event in self._source: + _check_terminal(event, self._response) + self._accumulator.push(event) + if event.type == "end": + self._response = self._accumulator.response() + yield event + except Exception as exc: + if not _after_completion(exc, self._response): + raise + _warn_cleanup(exc, self._record_cleanup) + if self._response is None: + raise _incomplete(self._accumulator) + except BaseException as exc: + primary = exc + if self._failure is None and not (isinstance(exc, GeneratorExit) and self._response is not None): + self._failure = exc + raise + finally: + self._done = True + try: + self._close_source(primary) + except BaseException as exc: + self._failure = exc + raise + + +class AsyncResponseStream: + """Async mirror of :class:`ResponseStream`, same accumulator engine. + + rs = AsyncResponseStream(lm.stream(request), request) + async for text in rs: + print(text, end="") + response = await rs.response() + + ``response()`` is a method (it may need to consume the stream, which + is an awaitable operation in async code). + """ + + def __init__(self, events: AsyncIterator[StreamEvent], request: Request) -> None: + self._accumulator = StreamAccumulator(request) + self._source = events + self._response: Response | None = None + self._failure: BaseException | None = None + self._done = False + self._source_closed = False + #: See :attr:`ResponseStream.cleanup_errors`. + self.cleanup_errors: tuple[BaseException, ...] = () + self._event_gen = self._pump() + + async def _close_source(self, primary: BaseException | None = None) -> None: + if not self._source_closed: + self._source_closed = True + await _aclose_events(self._source, primary, self._response, self._record_cleanup) + + def _record_cleanup(self, exc: BaseException) -> None: + self.cleanup_errors = (*self.cleanup_errors, exc) + + async def aclose(self) -> None: + """Stop reading and release the source, without draining it.""" + if not self._done and self._response is None and self._failure is None: + self._failure = _closed_early(self._accumulator) + try: + await self._event_gen.aclose() + finally: + self._done = True + await self._close_source() + + async def __aenter__(self) -> "AsyncResponseStream": + return self + + async def __aexit__(self, *_exc: Any) -> None: + await self.aclose() + + def __aiter__(self) -> AsyncIterator[str]: + return self._text_iter() + + async def _text_iter(self) -> AsyncIterator[str]: + async for event in self.events(): + if event.type == "delta" and event.delta is not None: + if event.delta.type == "text" and event.delta.text is not None: + yield event.delta.text + + async def events(self) -> AsyncIterator[StreamEvent]: + """Canonical stream events, teed through the accumulator.""" + async for event in self._event_gen: + yield event + + async def response(self) -> Response: + if self._failure is not None: + raise self._failure + if not self._done: + async for _ in self.events(): + pass + if self._failure is not None: # pragma: no cover - pump raises first + raise self._failure + assert self._response is not None + return self._response + + async def _pump(self) -> AsyncIterator[StreamEvent]: + primary = None + try: + try: + async for event in self._source: + _check_terminal(event, self._response) + self._accumulator.push(event) + if event.type == "end": + self._response = self._accumulator.response() + yield event + except Exception as exc: + if not _after_completion(exc, self._response): + raise + _warn_cleanup(exc, self._record_cleanup) + if self._response is None: + raise _incomplete(self._accumulator) + except BaseException as exc: + primary = exc + if self._failure is None and not (isinstance(exc, GeneratorExit) and self._response is not None): + self._failure = exc + raise + finally: + self._done = True + try: + await self._close_source(primary) + except BaseException as exc: + self._failure = exc + raise + + +# ─── One-shot materialization ──────────────────────────────────────── + +def materialize_response(events: Iterator[StreamEvent], request: Request) -> Response: + """Consume a complete stream, requiring a final end event and closing its source.""" + accumulator = StreamAccumulator(request) + response = None + primary = None + try: + try: + for event in events: + _check_terminal(event, response) + accumulator.push(event) + if event.type == "end": + response = accumulator.response() + except Exception as exc: + if not _after_completion(exc, response): + raise + _warn_cleanup(exc) + if response is None: + raise _incomplete(accumulator) + return response + except BaseException as exc: + primary = exc + raise + finally: + _close_events(events, primary, response) + + +async def amaterialize_response(events: AsyncIterator[StreamEvent], request: Request) -> Response: + """Async mirror of :func:`materialize_response`.""" + accumulator = StreamAccumulator(request) + response = None + primary = None + try: + try: + async for event in events: + _check_terminal(event, response) + accumulator.push(event) + if event.type == "end": + response = accumulator.response() + except Exception as exc: + if not _after_completion(exc, response): + raise + _warn_cleanup(exc) + if response is None: + raise _incomplete(accumulator) + return response + except BaseException as exc: + primary = exc + raise + finally: + await _aclose_events(events, primary, response) + + +def _check_terminal(event, response): + if response is not None: + raise StreamAssemblyError( + "Stream emitted an event after its end event (MAP-3: the end event is " + "final); the source that produced this stream is defective", + partial=response, + ) + if event.type == "error": + raise _exception_from_error(event) + + +def _incomplete(accumulator): + try: + partial = accumulator.response() + except StreamAssemblyError as exc: + partial = exc.partial + except Exception: + partial = None + return StreamAssemblyError( + "Stream ended without an end event: its finish reason and usage never " + "arrived, so the text is not a finished turn (MAP-3)", + partial=partial, + ) + + +def _closed_early(accumulator): + """The caller closed the stream before its end event: the same fact as + a stream that ended without one (MAP-3) — no finish reason, no usage, + the text not a finished turn — so the same error, inside the family + (every failure lm15 raises is an LM15Error). ``partial`` is what had + arrived.""" + try: + partial = accumulator.response() + except StreamAssemblyError as exc: + partial = exc.partial + except Exception: + partial = None + return StreamAssemblyError( + "Stream closed before its end event: the response was not completed " + "(close() was called while the stream was still open; MAP-3)", + partial=partial, + ) + + +def _after_completion(exc, response): + """Is ``exc`` a source failure that followed a complete Response? + + True only when the end event was seen and the failure is not itself an + assembly defect: the Response is complete, the provider billed it, and + the failure concerns the connection's afterlife. Such a failure is + reported (StreamCleanupWarning), never raised in place of the Response. + """ + return response is not None and not isinstance(exc, StreamAssemblyError) + + +def _warn_cleanup(exc, record=None): + import warnings + + if record is not None: + record(exc) + warnings.warn( + f"stream source failed after the response was complete " + f"({type(exc).__name__}: {exc}); the Response is returned unchanged", + StreamCleanupWarning, + stacklevel=3, + ) + + +def _cleanup_failure(primary, cleanup, response, record): + if primary is None or isinstance(primary, GeneratorExit): + if response is not None: + _warn_cleanup(cleanup, record) + return + raise cleanup + try: + primary.cleanup_errors = (*getattr(primary, "cleanup_errors", ()), cleanup) + if hasattr(primary, "add_note"): + primary.add_note(f"Stream cleanup also failed ({type(cleanup).__name__}); see cleanup_errors.") + except Exception: + pass # Optional diagnostics must not replace the primary failure. + + +def _close_events(source, primary=None, response=None, record=None): + try: + close = getattr(source, "close", None) + if close is not None: + close() + except Exception as cleanup: + _cleanup_failure(primary, cleanup, response, record) + + +async def _aclose_events(source, primary=None, response=None, record=None): + try: + close = getattr(source, "aclose", None) + if close is not None: + await close() + except Exception as cleanup: + _cleanup_failure(primary, cleanup, response, record) + + +# ─── Conversion utilities ──────────────────────────────────────────── + +def response_to_events(response: Response) -> Iterator[StreamEvent]: + """Convert a complete Response to stream events. + + The conversion is intentionally lossless for the Delta vocabulary. If a + response contains a valid Part that has no Delta representation, this + function raises instead of silently dropping content. + """ + yield StreamStartEvent(id=response.id, model=response.model, adaptations=response.adaptations) + # Response.logprobs is message-level; the delta vocabulary carries + # logprobs on text deltas. Emitting the whole sequence on the first + # text delta makes Response -> events -> Response lossless. + pending_logprobs = response.logprobs or () + pending_complete = response.logprobs_complete + if not pending_complete and not any(isinstance(p, TextPart) for p in response.message.parts): + raise TypeError("Cannot stream incomplete logprobs without a TextPart to carry their coverage") + for idx, part in enumerate(response.message.parts): + if isinstance(part, TextPart): + yield StreamDeltaEvent(delta=TextDelta(text=part.text, part_index=idx, logprobs=pending_logprobs, + logprobs_complete=pending_complete)) + pending_logprobs = () + pending_complete = True + elif isinstance(part, ThinkingPart): + yield StreamDeltaEvent(delta=ThinkingDelta(text=part.text, part_index=idx)) + elif isinstance(part, ToolCallPart): + yield StreamDeltaEvent( + delta=ToolCallDelta( + input=json.dumps(part.input), + part_index=idx, + id=part.id, + name=part.name, + ) + ) + elif isinstance(part, ImagePart): + yield StreamDeltaEvent( + delta=ImageDelta( + part_index=idx, + data=part.data, + url=part.url, + file_id=part.file_id, + media_type=part.media_type, + ) + ) + elif isinstance(part, AudioPart): + if part.data is None: + _raise_non_streamable_part( + part, + reason="AudioDelta only supports inline data", + ) + yield StreamDeltaEvent( + delta=AudioDelta( + data=part.data, + part_index=idx, + media_type=part.media_type, + ) + ) + elif isinstance(part, CitationPart): + yield StreamDeltaEvent( + delta=CitationDelta( + text=part.text, + url=part.url, + title=part.title, + part_index=idx, + ) + ) + else: + _raise_non_streamable_part(part) + for state in part.continuation: + yield StreamDeltaEvent( + delta=ContinuationDelta( + provider=state.provider, + kind=state.kind, + data=state.data, + part_index=idx, + ) + ) + for state in response.message.continuation: + yield StreamDeltaEvent( + delta=ContinuationDelta( + provider=state.provider, + kind=state.kind, + data=state.data, + part_index=None, + ) + ) + yield StreamEndEvent( + finish_reason=response.finish_reason, + usage=response.usage, + provider_data=response.provider_data, + ) + + +def _stamp_start(event: StreamStartEvent, adaptations: tuple) -> StreamStartEvent: + if not adaptations or event.adaptations: + return event + from dataclasses import replace + + return replace(event, adaptations=tuple(adaptations)) + + +class _EndProviderData: + """MAP-3 / D9: which adapter end event's ``provider_data`` the merged + end carries. Rank 2: a frame that supplied usage. Rank 1: a frame that + supplied finish_reason. Rank 0: anything else that carried data. A + later frame replaces an earlier one only at the same or a higher rank, + so the usage frame wins over a finish-only frame in either order.""" + + __slots__ = ("value", "rank") + + def __init__(self) -> None: + self.value: dict[str, Any] | None = None + self.rank = -1 + + def absorb(self, event: StreamEndEvent) -> None: + if event.provider_data is None: + return + rank = 2 if event.usage is not None else 1 if event.finish_reason is not None else 0 + if rank >= self.rank: + self.value = event.provider_data + self.rank = rank + + +def coalesce_stream( + events: Iterator[StreamEvent], *, model: str | None = None, adaptations: tuple = () +) -> Iterator[StreamEvent]: + """Enforce MAP-3 and MAP-4: one final StreamEndEvent, one leading StreamStartEvent. + + Adapters are stateless and may emit one end event per provider terminal + frame (finish_reason chunk, usage-only chunk, ``[DONE]``, + ``message_delta`` + ``message_stop``). This wrapper passes delta and + error events through unchanged, absorbs every end event's fields — + a later non-None field replaces the accumulated value, a None field never + erases one — and emits the single merged end event once the underlying + iterator is exhausted. If no end event was seen (e.g. the stream errored + or was truncated), no end event is fabricated. ``provider_data`` follows + D9 (2026-09-06): the merged end carries the frame that supplied usage, + else the frame that supplied finish_reason; a later usage-bearing frame + replaces an earlier one, a finish-only frame never displaces a usage + frame. + + Dialects without a start frame (chat completions, gemini SSE) get a + synthesized ``StreamStartEvent`` before the first delta or end event, so + every successful stream reads start → deltas → end (MAP-4). A provider + start passes through; duplicates after the first are dropped. Error + events never force a start: a stream that fails to open has no start. + + ``adaptations`` (MAP-13) are stamped on the start event, provider-sent + or synthesized: they are known before the first byte. + + See docs/mapping-rules.md MAP-3 and MAP-4. + """ + primary = None + ended = None + try: + for event in _coalesce_stream(events, model=model, adaptations=adaptations): + if event.type == "end": + ended = event + yield event + except BaseException as exc: + primary = exc + raise + finally: + # After the merged end event the stream is complete: a close() that + # raises is reported, not raised (contract 2026-09-11-stream-completion). + _close_events(events, primary, ended) + + +def _coalesce_stream(events: Iterator[StreamEvent], *, model: str | None, adaptations: tuple = ()) -> Iterator[StreamEvent]: + started = False + saw_end = False + finish_reason = None + usage: Usage | None = None + end_data = _EndProviderData() + for event in events: + if event.type == "start": + if started: + continue + started = True + yield _stamp_start(event, adaptations) + continue + if event.type == "end": + saw_end = True + if event.finish_reason is not None: + finish_reason = event.finish_reason + if event.usage is not None: + usage = event.usage + end_data.absorb(event) + continue + if not started and event.type == "delta": + started = True + yield StreamStartEvent(model=model, adaptations=adaptations) + yield event + if saw_end: + if not started: + yield StreamStartEvent(model=model, adaptations=adaptations) + yield StreamEndEvent( + finish_reason=finish_reason, + usage=usage, + provider_data=end_data.value, + ) + + +async def acoalesce_stream( + events: "AsyncIterator[StreamEvent]", *, model: str | None = None, adaptations: tuple = () +) -> "AsyncIterator[StreamEvent]": + """Async mirror of :func:`coalesce_stream` — same MAP-3/MAP-4 semantics. + + Passes delta/error events through unchanged, absorbs every end + event's fields (later non-None replaces, None never erases), and emits + exactly one merged final StreamEndEvent once the source is exhausted. + No end event is fabricated if none was seen. Synthesizes one leading + StreamStartEvent for dialects without a start frame; duplicate starts + are dropped; error events never force a start. + """ + primary = None + ended = None + try: + async for event in _acoalesce_stream(events, model=model, adaptations=adaptations): + if event.type == "end": + ended = event + yield event + except BaseException as exc: + primary = exc + raise + finally: + await _aclose_events(events, primary, ended) + + +async def _acoalesce_stream(events: AsyncIterator[StreamEvent], *, model: str | None, adaptations: tuple = ()) -> AsyncIterator[StreamEvent]: + started = False + saw_end = False + finish_reason = None + usage: Usage | None = None + end_data = _EndProviderData() + async for event in events: + if event.type == "start": + if started: + continue + started = True + yield _stamp_start(event, adaptations) + continue + if event.type == "end": + saw_end = True + if event.finish_reason is not None: + finish_reason = event.finish_reason + if event.usage is not None: + usage = event.usage + end_data.absorb(event) + continue + if not started and event.type == "delta": + started = True + yield StreamStartEvent(model=model, adaptations=adaptations) + yield event + if saw_end: + if not started: + yield StreamStartEvent(model=model, adaptations=adaptations) + yield StreamEndEvent( + finish_reason=finish_reason, + usage=usage, + provider_data=end_data.value, + ) + + +# ─── MAP-13 client-side stop ───────────────────────────────────────── +# +# A wire with no stop field (OpenAI Responses) gets the sequence applied +# here: the visible text is cut at the first occurrence, the finish reason +# becomes "stop", and the source is closed at the cut. Whether the +# provider then stops generating (and billing) on a closed connection is +# the provider's behaviour, not a promise lm15 can make; what lm15 does +# promise is that the end event carries no usage after a cut (the final +# frame was never read) — "not reported", never estimated. + + +def _first_stop(text: str, stop: tuple[str, ...]) -> tuple[int, str] | None: + best: tuple[int, str] | None = None + for seq in stop: + if not seq: + continue + idx = text.find(seq) + if idx >= 0 and (best is None or idx < best[0]): + best = (idx, seq) + return best + + +def apply_client_side_stop(response: Response, stop: tuple[str, ...]) -> Response: + """Cut the response's visible text at the first stop sequence. + + The text parts are one stream in document order: a sequence that + starts at the end of one part and finishes at the start of the next + is a hit (a provider's own stop works on the token stream and knows + no block boundary). The part holding the start is cut there; every + later part is removed. + """ + from dataclasses import replace + + if not stop: + return response + text_parts = [(i, p) for i, p in enumerate(response.message.parts) if isinstance(p, TextPart)] + joined = "".join(p.text for _, p in text_parts) + hit = _first_stop(joined, stop) + if hit is None: + return response + # Locate the part that holds the hit's start. + offset = 0 + cut_index, cut_at = text_parts[-1][0], 0 + for index, part in text_parts: + if offset + len(part.text) > hit[0]: + cut_index, cut_at = index, hit[0] - offset + break + offset += len(part.text) + parts: list[Part] = [] + for index, part in enumerate(response.message.parts): + if index > cut_index: + break + if index == cut_index: + parts.append(replace(part, text=part.text[:cut_at])) + else: + parts.append(part) + if not parts: + parts = [TextPart(text="")] + message = replace(response.message, parts=tuple(parts)) + scores, incomplete = _scores_before_cut(response.logprobs or (), joined, hit[0]) + return replace(response, message=message, finish_reason="stop", logprobs=scores or None, + logprobs_complete=response.logprobs_complete and not incomplete) + + +def _scores_before_cut( + scores: tuple[TokenLogprob, ...], text: str, cut_at: int, +) -> tuple[tuple[TokenLogprob, ...], bool]: + """Keep original scores for whole retained tokens; never score a token fragment. + + Byte boundaries matter: a provider token can itself contain only part of + a Unicode character. Only use token spellings as a fallback when their + concatenated UTF-8 bytes exactly reproduce the original text. + """ + if not scores or cut_at == 0: + return (), False + if cut_at == len(text): + return scores, False + try: + token_bytes = [bytes(s.bytes) if s.bytes is not None else s.token.encode("utf-8") for s in scores] + original = text.encode("utf-8") + boundary = len(text[:cut_at].encode("utf-8")) + except (ValueError, UnicodeError): + return (), True + if b"".join(token_bytes) != original: + # These scores cannot be placed reliably on the shortened text. + return (), True + end = 0 + for index, data in enumerate(token_bytes): + if end == boundary: + return scores[:index], False + end += len(data) + if end > boundary: + return scores[:index], True + return scores, False + + +class _StopCutter: + """Keep original events until their text is safe, then pass them unchanged. + + Text parts form one text stream. A possible stop suffix holds its + entire event, plus intervening events to preserve order. Only the + event actually cut is reconstructed; unrelated fields survive via + dataclasses.replace. This can delay delivery by an event boundary, + but never splits token scores just to release text earlier. + """ + + def __init__(self, stop: tuple[str, ...]) -> None: + self.stop = tuple(s for s in stop if s) + self.hold = max((len(s) for s in self.stop), default=1) - 1 + self.segments: deque[StreamEvent] = deque() + self.cut = False + + def _take(self, count: int, *, cutting: bool = False) -> list[StreamEvent]: + out: list[StreamEvent] = [] + while self.segments: + event = self.segments[0] + if event.type != "delta" or not isinstance(event.delta, TextDelta): + out.append(self.segments.popleft()) + continue + delta = event.delta + if cutting and count == 0: + break + if len(delta.text) <= count: + out.append(self.segments.popleft()) + count -= len(delta.text) + elif cutting: + scores, incomplete = _scores_before_cut(delta.logprobs, delta.text, count) + out.append(replace(event, delta=replace(delta, text=delta.text[:count], logprobs=scores, + logprobs_complete=delta.logprobs_complete and not incomplete))) + break + else: + break + return out + + def feed(self, event: StreamEvent) -> list[StreamEvent]: + if event.type != "delta" or not isinstance(event.delta, TextDelta): + if not self.segments: + return [event] + self.segments.append(event) + return [] + self.segments.append(event) + buf = "".join(e.delta.text for e in self.segments + if e.type == "delta" and isinstance(e.delta, TextDelta)) + hit = _first_stop(buf, self.stop) + if hit is not None: + out = self._take(hit[0], cutting=True) + self.segments.clear() + self.cut = True + return out + return self._take(max(0, len(buf) - self.hold)) + + def flush(self) -> list[StreamEvent]: + out = list(self.segments) + self.segments.clear() + return out + + +def _cut_events(cutter: _StopCutter, event: StreamEvent): + if event.type in ("end", "error"): + yield from cutter.flush() + yield event + else: + yield from cutter.feed(event) + + +def truncate_stream_at_stop(events: Iterator[StreamEvent], stop: tuple[str, ...]) -> Iterator[StreamEvent]: + cutter = _StopCutter(stop) + if not cutter.stop: + yield from events + return + try: + for event in events: + for out in _cut_events(cutter, event): + yield out + if cutter.cut: + yield StreamEndEvent(finish_reason="stop") + return + finally: + close = getattr(events, "close", None) + if close is not None: + close() + + +async def atruncate_stream_at_stop(events: AsyncIterator[StreamEvent], stop: tuple[str, ...]) -> AsyncIterator[StreamEvent]: + cutter = _StopCutter(stop) + if not cutter.stop: + async for event in events: + yield event + return + try: + async for event in events: + for out in _cut_events(cutter, event): + yield out + if cutter.cut: + yield StreamEndEvent(finish_reason="stop") + return + finally: + aclose = getattr(events, "aclose", None) + if aclose is not None: + await aclose() + + +# ─── Internal helpers ──────────────────────────────────────────────── + +def _raise_non_streamable_part(part: Part, *, reason: str | None = None) -> None: + detail = reason or f"no {part.type!r} Delta variant exists" + raise TypeError(f"Cannot convert {type(part).__name__} to StreamEvent: {detail}") + + +def _exception_from_error(event: StreamEvent) -> Exception: + err = event.error or ErrorDetail(code="provider", message="stream error") + code = err.code + message = err.message + exc_cls = error_class_for_code(code) + if issubclass(exc_cls, LM15Error): + import math + + http = err.http_response + http = http if isinstance(http, dict) else {} + wait = http.get("retry_after") + if not isinstance(wait, (int, float)) or isinstance(wait, bool) or not math.isfinite(wait) or wait < 0: + wait = None + request_id = http.get("request_id") + return exc_cls(message, provider_code=err.provider_code, + request_id=request_id if isinstance(request_id, str) else None, + retry_after=wait, rate_limit_headers=http.get("rate_limit_headers")) + return exc_cls(message) + + +def _concat_b64_chunks(chunks: list[str]) -> bytes: + """Decode each base64 chunk and concatenate raw bytes.""" + import base64 + raw = bytearray() + for chunk in chunks: + if not chunk: + continue + try: + raw.extend(base64.b64decode(chunk)) + except Exception: + padded = chunk + "=" * (-len(chunk) % 4) + try: + raw.extend(base64.b64decode(padded)) + except Exception: + pass + return bytes(raw) + + +def _pcm_to_wav(pcm: bytes, sample_rate: int = 24000, channels: int = 1, bits: int = 16) -> bytes: + """Wrap raw PCM bytes in a WAV header.""" + import struct + byte_rate = sample_rate * channels * bits // 8 + block_align = channels * bits // 8 + data_size = len(pcm) + header = struct.pack( + "<4sI4s4sIHHIIHH4sI", + b"RIFF", 36 + data_size, b"WAVE", b"fmt ", 16, 1, + channels, sample_rate, byte_rate, block_align, bits, + b"data", data_size, + ) + return header + pcm + + +def _parse_json_best_effort(raw: str | None) -> JsonObject: + if not raw: + return {} + try: + value = json.loads(raw, parse_constant=_reject_json_constant) + return value if isinstance(value, dict) else {"value": value} + except Exception: + return {"partial_json": raw} + + +def _reject_json_constant(value: str) -> None: + raise ValueError(f"non-standard JSON constant: {value}") diff --git a/router.py b/router.py new file mode 100644 index 0000000000..28775a7896 --- /dev/null +++ b/router.py @@ -0,0 +1,1423 @@ +""" +lm15.router — Minimalist model-string router. + +The router is a lookup table you can read, not a framework. Four +resolution rungs, in fixed order, with no configuration of the order +itself: + + 0. object attribute a ``provider`` attribute carried by the model + value itself -> source="object" + (catalog packages ship model ids as str + subclasses that know their provider; duck-typed — + lm15 names no package. An attribute that names + nothing routable falls through.) + 1. explicit prefix ``"openai:gpt-4.1-mini"`` -> source="prefix" + 2. catalog match against ``registry.list()`` -> source="catalog" + entries by id or alias; exact-id matches beat + alias matches; multiple providers (or multiple + same-provider entries) raise AmbiguousModelError + (only if you passed a registry; catalogs are + opt-in via ``ModelRegistry.discover()``) + 3. built-in rules ``DEFAULT_RULES`` prefix match -> source="rule" + +Nothing else. No plugins, no callbacks, no fallback chains. + +A provider is routable when the provider registry (``lm15.registry``) +declares it: either adapter-owned (a key of :data:`ADAPTERS`) or +chat-bound (a key of :data:`CHAT_PRESET_ROUTES`: groq, openrouter, +deepseek, ollama, vllm, sglang). Chat-bound providers route to +``OpenAIChatLM(compat=, access=)``: the preset names the +server's quirks and default base_url, the access policy its credential. + +Model-string grammar +-------------------- +A model string is split on the FIRST ``:``. If the head is a routable +provider string, the remainder is the model id sent on the wire. +Otherwise the whole string (colons and all) is treated as a bare model +id and falls through to the catalog and rule rungs. Consequence: a +fine-tune id like ``ft:gpt-4.1:org`` needs the explicit form +``openai:ft:gpt-4.1:org``. + +Credentials +----------- +``resolve()`` touches no network and invokes no credential provider. +It checks configured keys and environment presence, but its result records +WHICH env var would be used, never the value. ``lm()`` +reads the key — first from ``RouterConfig.api_keys`` (explicit, +repr-suppressed; values may be static strings or zero-argument +credential-provider callables, resolved per request by the adapter), +then from the env mapping via the provider's ``ProviderManifest.env_keys`` +or the preset's own convention (first hit wins), then a keyless local +server's placeholder key. OAuth providers (``claude-code``, +``openai-codex``) declare no env keys and pass through to their +self-resolving constructors. + +The direct LM classes remain first-class; the router is just the +recommended front door. Needing a custom ``base_url``/transport/compat +(azure, a self-hosted gateway) is the documented escape hatch: ``lm()`` +returns the ordinary provider LM — keep it and configure it yourself +next time. + +Connections +----------- +A router owns ONE transport (connection pool) shared by every LM it +builds, so ``RouterConfig(timeouts=..., max_connections=...)`` is the +whole connection budget of that router: ``Timeouts(read=1800)`` for a +slow local model, ``max_connections=200`` for a wide evaluation. Pass +``transport=`` instead to bring your own (then the two knobs are refused: +configure the transport you pass). ``close()`` / ``aclose()`` (or ``with``) +releases every socket; a router that is simply dropped closes its idle +sockets when collected. +""" + +from __future__ import annotations + +import os +import threading +from dataclasses import dataclass, field, replace +from types import MappingProxyType +from typing import AsyncIterator, Iterator, Literal, Mapping, overload + +from .result import AsyncResponseStream, ResponseStream + +from .errors import AmbiguousModelError, NotConfiguredError, UnknownModelError +from .models import ModelInfo, ModelRegistry +from .providers import Credential +from .registry import PROVIDERS, Compat, ProviderDefinition, canonical_provider as _canonical_provider +from .adaptation import AdaptationPolicy, check_policy +from .transports import Timeouts +from .types import Request, Response, StreamEvent + +__all__ = [ + "RouteRule", + "DEFAULT_RULES", + "ADAPTERS", + "ASYNC_ADAPTERS", + "PresetRoute", + "CHAT_PRESET_ROUTES", + "Resolution", + "RouterConfig", + "RouterError", + "UnknownModelError", + "AmbiguousModelError", + "MissingCredentialError", + "LMRouter", + "LITELLM_PROVIDER_PREFIXES", + "openai_chat_model_string", + "AsyncLMRouter", +] + + +# ---------------------------------------------------------------- rules ---- + + +@dataclass(frozen=True, slots=True) +class RouteRule: + """Maps a model-id prefix to a provider. That's all a rule is. + + ``note`` is a short human rationale surfaced in docs and + :meth:`Resolution.describe` output. + """ + + prefix: str + provider: str + note: str = "" + + +# The complete built-in knowledge of the router. Inspectable, printable, +# overridable by passing rules=... in RouterConfig. First match wins. +# This table is a convenience, not a registry of truth: new model +# families need a release (or a catalog, or the provider: prefix). +DEFAULT_RULES: tuple[RouteRule, ...] = ( + RouteRule("claude-", "anthropic", note="Anthropic Claude family"), + RouteRule("gpt-", "openai", note="OpenAI GPT family (Responses API; use openai-chat: for Chat Completions)"), + RouteRule("o1", "openai", note="OpenAI o1 reasoning family"), + RouteRule("o3", "openai", note="OpenAI o3 reasoning family"), + RouteRule("o4", "openai", note="OpenAI o4 reasoning family"), + RouteRule("gemini-", "gemini", note="Google Gemini family"), + RouteRule("gemma-", "gemini", note="Google Gemma open models, served by the Gemini API (live /models listing 2026-09-01)"), + RouteRule("nano-banana", "gemini", note="Google image models on the Gemini API (live /models listing 2026-09-01)"), + RouteRule("grok-", "xai", note="xAI Grok family (XAI_API_KEY or subscription OAuth)"), + RouteRule("sora-", "openai", note="OpenAI Sora video generation"), + RouteRule("veo-", "gemini", note="Google Veo video generation"), + RouteRule("chat-latest", "openai", note="OpenAI rolling chat alias (live /models listing 2026-09-01)"), + RouteRule("jev-", "typesafe", note="TypeSafe Jev (live /v1/models listing 2026-09-17: jev-latest, jev-preview; versioned ids jev-1.13.0 accepted)"), +) + + +# provider string -> LM class. Read-only views of the provider registry +# (lm15.registry.PROVIDERS): adapter-owned entries here, chat-bound +# entries in CHAT_PRESET_ROUTES. Exported and inspectable, as before; +# the registry is the one place a provider is declared. +# Values are the *sync* classes; AsyncLMRouter uses ASYNC_ADAPTERS. +ADAPTERS: Mapping[str, type] = MappingProxyType( + {d.id: d.adapter for d in PROVIDERS.values() if not d.bound} +) + +ASYNC_ADAPTERS: Mapping[str, type] = MappingProxyType( + {d.id: d.async_adapter for d in PROVIDERS.values() if not d.bound} +) + + +class _Table(Mapping[str, type]): + """What one router routes with: the adapter-owned classes of one + direction (sync or async) plus every bound definition it knows — the + registry's receipted entries and the config's declared ones + (``RouterConfig(providers=...)``). A plain mapping of classes (the + module views, a test's fakes) means the registry alone.""" + + __slots__ = ("_adapters", "_aliases", "definitions", "is_async") + + def __init__(self, adapters: Mapping[str, type], declared: tuple[ProviderDefinition, ...], *, is_async: bool) -> None: + self._adapters = adapters + self.definitions: Mapping[str, ProviderDefinition] = MappingProxyType( + {**PROVIDERS, **{d.id: d for d in declared}} + ) + self.is_async = is_async + self._aliases: Mapping[str, str] = MappingProxyType( + {alias: d.id for d in declared for alias in d.aliases} + ) + + def __getitem__(self, key: str) -> type: + return self._adapters[key] + + def __iter__(self): + return iter(self._adapters) + + def __len__(self) -> int: + return len(self._adapters) + + def alias(self, name: str) -> str | None: + """The provider id a declared alias names, or None.""" + return self._aliases.get(name) + + +def _table(adapters: Mapping[str, type], config: RouterConfig) -> Mapping[str, type]: + """``adapters`` as a router uses it: unchanged when nothing is declared + (the module views keep their identity), else wrapped with the config's + declared providers.""" + if not config.providers: + return adapters + return _Table(adapters, config.providers, is_async=adapters is ASYNC_ADAPTERS) + + +def _definitions(adapters: Mapping[str, type]) -> Mapping[str, ProviderDefinition]: + return adapters.definitions if isinstance(adapters, _Table) else PROVIDERS + + +def _definition(provider: str, adapters: Mapping[str, type] | None = None) -> ProviderDefinition | None: + return _definitions(ADAPTERS if adapters is None else adapters).get(provider) + + +def _declared(provider: str, adapters: Mapping[str, type]) -> bool: + """True when ``provider`` comes from ``RouterConfig(providers=...)``, + not the receipted registry.""" + return provider not in PROVIDERS and provider in _definitions(adapters) + + +def _credential_policy(provider: str, adapters: Mapping[str, type] | None = None) -> str: + """The provider's declared ``AccessPolicy.credential_policy``. + + Read from the registry entry's access policy — for an adapter-owned + entry that is the class manifest; for a chat-bound entry the bound + policy (always ``"key"`` today). This is the single source the router + and doctor consult — there is deliberately no hardcoded provider-name + list that could drift from the declarations. ``adapters`` lets tests + substitute fake classes; a provider found there but not in the + registry answers with its class manifest. + """ + definition = _definition(provider, adapters) + if definition is not None: + return definition.credential_policy + lookup = ADAPTERS if adapters is None else adapters + cls = lookup.get(provider) + if cls is None: + return "key" + return cls.manifest.credential_policy + + +@dataclass(frozen=True, slots=True) +class PresetRoute: + """A provider routable through ``OpenAIChatLM`` with a named compat + preset — the registry's chat-bound entry, projected to the fields this + table always carried. Pure data: the provider string doubles as the + preset name (which also supplies the server's default base_url); + ``env_keys`` is that server's own key convention; ``default_key`` is + the placeholder local servers accept when no key is configured.""" + + provider: str + env_keys: tuple[str, ...] = () + default_key: str | None = None + note: str = "" + + +# Chat Completions preset providers, keyed by provider string: the +# chat-bound entries of the registry. Same spirit as DEFAULT_RULES — the +# complete built-in knowledge, inspectable and printable. A new entry is +# an AccessPolicy in lm15.access plus a registry declaration, landed with +# live receipts first, like every provider behavior. Bound entries of +# other dialects (deepseek-anthropic) are routable too; they are read from +# the registry directly, not from this table. +CHAT_PRESET_ROUTES: Mapping[str, PresetRoute] = MappingProxyType( + { + d.id: PresetRoute(d.id, env_keys=d.env_keys, default_key=d.placeholder_key, note=d.note) + for d in PROVIDERS.values() + if d.bound and d.dialect == "openai-chat" + } +) + + +def _bound(provider: str, adapters: Mapping[str, type]) -> ProviderDefinition | None: + """The registry's bound entry for ``provider``, or None when the provider + is adapter-owned (a key of ``adapters``) or unknown.""" + if provider in adapters: + return None + definition = _definitions(adapters).get(provider) + return definition if definition is not None and definition.bound else None + + +def _routable(provider: str, adapters: Mapping[str, type]) -> bool: + return provider in adapters or _bound(provider, adapters) is not None + + +def _provider_id(name: str, adapters: Mapping[str, type]) -> str: + """A canonical input spelling → the provider it names: a declared + alias resolves to its provider; anything else is itself.""" + if isinstance(adapters, _Table): + return adapters.alias(name) or name + return name + + +def _adapter_for(provider: str, adapters: Mapping[str, type]) -> type: + if provider in adapters: + return adapters[provider] + definition = _definitions(adapters)[provider] # bound entry: its dialect class + is_async = adapters.is_async if isinstance(adapters, _Table) else adapters is ASYNC_ADAPTERS + return definition.async_adapter if is_async else definition.adapter + + +def _bound_ids(adapters: Mapping[str, type]) -> set[str]: + return {d.id for d in _definitions(adapters).values() if d.bound and d.id not in adapters} + + +def _check_provider_keyed(config: RouterConfig, adapters: Mapping[str, type]) -> None: + """Every provider string a RouterConfig is keyed by (api_keys, base_urls, + settings) must name a routable provider. A near miss is named: an + entry that matches nothing is otherwise silently ignored, and the + request goes out on whatever the environment holds — the wrong + account, with nothing said (AUTH-1).""" + import difflib + + known = sorted(set(adapters) | _bound_ids(adapters)) + for field_name in ("api_keys", "base_urls", "settings", "credentials"): + mapping = getattr(config, field_name) + if not mapping: + continue + seen: set[str] = set() + for key in mapping: + provider = _canonical_provider(key) + if field_name in ("api_keys", "credentials") and provider in seen: + raise NotConfiguredError(f"RouterConfig({field_name}=...): duplicate spellings for {provider!r}; use one entry") + seen.add(provider) + if provider in known: + if field_name == "credentials": + _check_named_credential(config, provider, mapping[key], adapters) + continue + close = difflib.get_close_matches(provider, known, n=1, cutoff=0.6) + hint = f" Did you mean {close[0]!r}?" if close else "" + raise NotConfiguredError( + f"RouterConfig({field_name}=...): {key!r} is not a provider lm15 routes to.{hint} " + f"router.resolve(model).provider (or resolve_openai_chat) names the one a model " + f"string uses; known: {', '.join(known)}", + ) + + +# --------------------------------------------------------------- errors ---- +# +# The router's failures are ErrorCode vocabulary entries (spec/vocabularies.md, +# 2026-09-08): UnknownModelError / AmbiguousModelError live in lm15.errors +# under ConfigurationError and are re-exported here. There is no router-wide +# base class or code: a code names a failure the caller can act on, not the +# component that raised it. + + +class MissingCredentialError(NotConfiguredError): + """Provider resolved but no API key was found. + + A :class:`lm15.errors.NotConfiguredError` (code ``not_configured``) — + semantically the credential case IS not-configured — so + ``except NotConfiguredError`` handlers keep working. Carries + ``provider`` and ``env_keys`` straight from the ProviderManifest. + """ + + default_code = "not_configured" + + +# ----------------------------------------------------------- resolution ---- + + +@dataclass(frozen=True, slots=True) +class Resolution: + """The complete answer to "how did you route this string". + + ``resolve()`` returning this IS the explain() method — there is no + separate one. + """ + + requested: str # verbatim input string + model: str # id sent on the wire (prefix stripped) + provider: str # canonical provider string + adapter: str # LM class name, e.g. "AnthropicLM" + source: str # "object" | "prefix" | "catalog" | "rule" + rule: RouteRule | None = None # the matching rule when source == "rule" + env_key: str | None = None # env var the key would be read from; + # None for OAuth providers or when an + # explicit api_keys entry overrides env + model_info: ModelInfo | None = None # catalog metadata when source == "catalog" + compat: str | Compat | None = None # compat preset name when routed through + # a bound registry entry (CHAT_PRESET_ROUTES + # or an Anthropic-dialect binding); the + # compat object for a declared provider + declared: bool = False # provider from RouterConfig(providers=), + # not the receipted registry + credential_policy: str = "key" # the provider's AccessPolicy.credential_policy + placeholder_key: str | None = None # a keyless local server's default key + + def describe(self) -> str: + """One-paragraph human-readable explanation of this resolution.""" + parts = [f"{self.requested!r} -> provider {self.provider!r} ({self.adapter})"] + if self.source == "object": + parts.append("via provider attribute on the model value") + elif self.source == "prefix": + parts.append("via explicit provider prefix") + elif self.source == "catalog": + parts.append("via catalog match") + elif self.source == "rule" and self.rule is not None: + note = f" — {self.rule.note}" if self.rule.note else "" + parts.append(f"via built-in rule prefix={self.rule.prefix!r}{note}") + if isinstance(self.compat, str): + parts.append(f"compat preset {self.compat!r}") + elif self.compat is not None: + parts.append(f"compat {type(self.compat).__name__} object") + if self.declared: + parts.append("declared by RouterConfig(providers=...) — no lm15 receipts") + parts.append(f"wire model {self.model!r}") + # Pure data: the resolution carries its provider's credential facts + # (a declared provider is not in the module tables — greptile on + # dspy#10440, 2026-09-17). + policy = self.credential_policy + if policy == "oauth-unless-explicit": + # resolve() is pure (no file reads), so it describes the chain + # rather than asserting a winner. + chain = "key from explicit api_keys, else the stored subscription OAuth credential" + if self.env_key is not None: + chain += f", else ${self.env_key}" + parts.append(chain) + elif self.env_key is not None: + parts.append(f"key from ${self.env_key}") + elif policy == "oauth": + parts.append("local OAuth credential (no env key)") + elif self.placeholder_key is not None: + parts.append("key from explicit api_keys or the preset's local-server default") + else: + parts.append("key from explicit api_keys") + return "; ".join(parts) + "." + + def __str__(self) -> str: + return self.describe() + + +# ---------------------------------------------------------------- config ---- + + +@dataclass(frozen=True, slots=True) +class RouterConfig: + """Everything the router consults. All explicit, nothing discovered + behind your back. Catalog use is opt-in: pass + ``registry=ModelRegistry.discover()``. + + ``api_keys`` maps provider string -> credential and beats env (repr- + suppressed; lets hermetic tests pass ``env={}``). A credential is a + static key string or a zero-argument provider callable, resolved per + request by the adapter. ``env`` defaults to ``os.environ`` at + lookup time. An exact provider entry wins; otherwise one entry for a + provider with the identical non-empty declared env-key tuple supplies + the credential (e.g. ``openai`` also covers ``openai-chat``). Multiple + shared candidates raise rather than guess an account. URLs and host + settings are never shared this way. + + ``base_urls`` maps provider string -> the URL the provider's LM is + built with, replacing the adapter's (or the preset's) default: a + proxy in front of OpenAI, a vLLM server on another port. On a cloud + door (azure, bedrock, vertex) the entry is the endpoint root — what + the console shows (``https://acct.services.ai.azure.com``), a private + endpoint, a gateway, a sovereign cloud — and the door appends its own + path (``/openai/v1``, ``/anthropic/v1``) unless the URL already ends + with it; the door's auth scheme, error mapping and doctor stay + attached. Without an entry the vendor's own variable is read + (``AZURE_OPENAI_ENDPOINT``, ``ANTHROPIC_FOUNDRY_BASE_URL``, + ``AWS_ENDPOINT_URL_BEDROCK_RUNTIME`` / ``AWS_ENDPOINT_URL``), then the + URL is built from ``settings`` (resource, region). With an endpoint, + ``resource`` is not needed; ``region`` still is on AWS (it signs). + + ``credentials`` maps a cloud provider string -> one named identity: + ``"platform"`` (the machine's own: managed identity, instance or task + role, attached service account), ``"workload"`` (the federated + Kubernetes kind), ``"environment"`` (a service principal or static + keys from env variables), ``"cli"`` (``az`` / ``aws`` / ``gcloud`` + sign-in). That rung only is used and the cloud's chain is not walked: + an absent named identity is a ``NotConfiguredError`` at construction, + never a fall-through to a developer login (spec/auth.md AUTH-1, amended + 2026-09-19). An ``api_keys`` entry and a ``credentials`` entry for + the same provider is refused: two answers to "who am I". + + ``providers`` declares providers the registry does not list — a + gateway, a service lm15 has not receipted — as + :class:`~lm15.registry.ProviderDefinition` values + (``ProviderDefinition.chat(access, compat=OpenAIChatCompat(...))``). + They route like registry entries in every router built with this + config (``id:model``, ``id/model``, each alias likewise), take + ``api_keys``/``base_urls`` entries, and answer + ``Resolution.declared``: no live receipt backs them, and lm15 says so. + An id or alias that a registry entry or litellm prefix already spells + is refused: one string, one door. + + ``timeouts`` (:class:`lm15.Timeouts`) and ``max_connections`` shape the + one transport the router builds and shares across its LMs; defaults + are the provider SDKs' (connect 10 s, read/write/pool 600 s, 100 + connections). ``transport`` replaces that transport with one you + built; it cannot be combined with the two knobs, which would then + silently not apply. + """ + + registry: ModelRegistry | None = None + rules: tuple[RouteRule, ...] = DEFAULT_RULES + env: Mapping[str, str] | None = None + api_keys: Mapping[str, Credential] | None = field(default=None, repr=False) + base_urls: Mapping[str, str] | None = field(default=None, kw_only=True) + # Cloud-host settings per provider (AUTH-10): {"bedrock-anthropic": + # {"region": "us-east-1"}}. A setting not given here is read from its + # env variables in order, then its default; region and resource have + # none and raise. + settings: Mapping[str, Mapping[str, str]] | None = field(default=None, kw_only=True) + # Named cloud identities per provider (AUTH-1, amended 2026-09-19): + # {"azure": "platform"}. See the class docstring. + credentials: Mapping[str, str] | None = field(default=None, kw_only=True) + transport: object | None = None # SyncTransport for LMRouter, AsyncTransport + # for AsyncLMRouter; passed to every LM the + # router constructs (tests, custom pooling) + timeouts: Timeouts | None = field(default=None, kw_only=True) + max_connections: int | None = field(default=None, kw_only=True) + # MAP-13: "note" (adapt and record on the response), "silent" (adapt, + # record nothing), "refuse" (every adaptation is an error before the + # wire). Applied to every LM the router builds. + adaptations: AdaptationPolicy = field(default="note", kw_only=True) + providers: tuple[ProviderDefinition, ...] = field(default=(), kw_only=True) + + def __post_init__(self) -> None: + check_policy(self.adaptations) + _check_declared(self.providers) + if self.credentials is not None: + from .features import NAMED_CREDENTIALS + + for key, name in self.credentials.items(): + if name not in NAMED_CREDENTIALS: + raise NotConfiguredError( + f"RouterConfig(credentials={{{key!r}: {name!r}}}): not a named credential; one of " + + ", ".join(repr(n) for n in NAMED_CREDENTIALS) + + ". A credential VALUE (a key, a token, a callable) goes in api_keys=." + ) + if self.timeouts is not None and not isinstance(self.timeouts, Timeouts): + raise TypeError( + f"RouterConfig(timeouts=...) takes lm15.Timeouts, got {type(self.timeouts).__name__}; " + "e.g. Timeouts(read=600)" + ) + if self.max_connections is not None: + from .transports._limits import check_max_connections + + check_max_connections(self.max_connections) + if self.transport is not None and (self.timeouts is not None or self.max_connections is not None): + raise NotConfiguredError( + "RouterConfig(transport=...) cannot be combined with timeouts= or max_connections=: " + "they configure the transport lm15 would build, and would silently not apply to " + "the one you passed. Configure that transport directly (StdlibTransport(read_timeout=...))." + ) + + def transport_kwargs(self) -> dict: + """Constructor keywords for the default transport this config asks for.""" + kwargs: dict = {} + if self.timeouts is not None: + kwargs.update( + connect_timeout=self.timeouts.connect, + read_timeout=self.timeouts.read, + write_timeout=self.timeouts.write, + pool_timeout=self.timeouts.pool, + ) + if self.max_connections is not None: + kwargs["max_connections"] = self.max_connections + return kwargs + + +# ------------------------------------------------------------- internals ---- + + +def _check_declared(providers: object) -> None: + """``RouterConfig(providers=...)``: definitions only, each spelling + (id and aliases) naming one door that nothing built in already names.""" + if not isinstance(providers, tuple): + raise TypeError( + f"RouterConfig(providers=...) takes a tuple of ProviderDefinition, got {type(providers).__name__}" + ) + taken: dict[str, str] = {} + for definition in providers: + if not isinstance(definition, ProviderDefinition): + raise TypeError( + f"RouterConfig(providers=...): {definition!r} is not a ProviderDefinition; " + "declare one with ProviderDefinition.chat(access, compat=...)" + ) + for spelling in definition.spellings: + built_in = PROVIDERS[spelling].id if spelling in PROVIDERS else _LITELLM_CANONICAL.get(spelling) + if built_in is not None: + raise NotConfiguredError( + f"RouterConfig(providers=...): {spelling!r} already names lm15's {built_in!r} door; " + "a declared provider takes a new id and aliases" + ) + if spelling in taken: + raise NotConfiguredError( + f"RouterConfig(providers=...): {spelling!r} is spelled by both {taken[spelling]!r} and {definition.id!r}" + ) + taken[spelling] = definition.id + + +def _resolution( + *, + requested: str, + wire_model: str, + provider: str, + source: str, + config: RouterConfig, + adapters: Mapping[str, type], + rule: RouteRule | None = None, + model_info: ModelInfo | None = None, +) -> Resolution: + definition = _bound(provider, adapters) + return Resolution( + requested=requested, + model=wire_model, + provider=provider, + adapter=_adapter_for(provider, adapters).__name__, + source=source, + rule=rule, + env_key=_env_key_for(provider, config, adapters), + model_info=model_info, + compat=definition.compat if definition is not None else None, + declared=_declared(provider, adapters), + credential_policy=_credential_policy(provider, adapters), + placeholder_key=definition.placeholder_key if definition is not None else None, + ) + + +def _resolve(model: str, config: RouterConfig, adapters: Mapping[str, type]) -> Resolution: + if not isinstance(model, str) or not model: + raise UnknownModelError( + "model must be a non-empty string", model=str(model), + rules_tried=config.rules, catalog_searched=config.registry is not None, + ) + + requested = model + + # Rung 0: a provider attribute carried by the model value itself. + # Catalog packages ship model ids as str subclasses that know their + # provider; duck-typed, so lm15 names no package. An attribute that + # names nothing routable falls through (and is mentioned if nothing + # else matches either). + object_provider = getattr(model, "provider", None) + if isinstance(object_provider, str) and object_provider: + object_provider = _provider_id(_canonical_provider(object_provider), adapters) + else: + object_provider = None + if object_provider is not None and _routable(object_provider, adapters): + return _resolution( + requested=requested, + wire_model=str(model), + provider=object_provider, + source="object", + config=config, + adapters=adapters, + ) + + # Rung 1: explicit provider prefix (split on FIRST colon). + if ":" in model: + raw_head, rest = model.split(":", 1) + head = _provider_id(_canonical_provider(raw_head), adapters) + if _routable(head, adapters) and rest: + return _resolution( + requested=requested, + wire_model=rest, + provider=head, + source="prefix", + config=config, + adapters=adapters, + ) + + # Rung 2: catalog (only if a registry was explicitly supplied). + if config.registry is not None: + matches = tuple( + info + for info in config.registry.list() + if info.id == model or model in info.aliases + ) + providers = tuple(dict.fromkeys(info.provider for info in matches)) + if len(providers) > 1: + options = " or ".join(f'"{p}:{model}"' for p in providers) + raise AmbiguousModelError( + f"model {model!r} is offered by multiple providers: " + f"{', '.join(providers)}. Fix: use the explicit form, e.g. " + f"Request(model={providers[0] + ':' + model!r}) — options: {options}.", + model=model, + providers=providers, + ) + if matches: + # Exact-id matches beat alias matches; an alias must never + # shadow an entry whose canonical id IS the requested string. + exact = tuple(info for info in matches if info.id == model) + narrowed = exact if exact else matches + if len(narrowed) > 1: + # Same provider (multi-provider was caught above), multiple + # entries: never pick one by insertion order. + ids = ", ".join(info.id for info in narrowed) + raise AmbiguousModelError( + f"model {model!r} matches multiple catalog entries " + f"({ids}) under provider {narrowed[0].provider!r}. " + "Fix: request a canonical id directly.", + model=model, + providers=providers, + ) + info = narrowed[0] + catalog_provider = _provider_id(_canonical_provider(info.provider), adapters) + if not _routable(catalog_provider, adapters): + raise UnknownModelError( + f"model {model!r} resolved in the catalog to provider " + f"{info.provider!r}, but lm15 has no adapter or compat " + f"preset for it. Known providers: {_known_providers(adapters)}. " + "Construct a provider LM directly (e.g. OpenAIChatLM with " + "a custom base_url) for OpenAI-compatible servers.", + model=model, + rules_tried=config.rules, + catalog_searched=True, + ) + return _resolution( + requested=requested, + wire_model=info.id if model in info.aliases else str(model), + provider=catalog_provider, + source="catalog", + config=config, + adapters=adapters, + model_info=info, + ) + + # Rung 3: built-in prefix rules, first match wins. + for rule in config.rules: + if model.startswith(rule.prefix): + rule_provider = _canonical_provider(rule.provider) + if not _routable(rule_provider, adapters): + raise UnknownModelError( + f"rule {rule!r} names provider {rule.provider!r}, which has " + f"no adapter. Known providers: {_known_providers(adapters)}.", + model=model, + rules_tried=config.rules, + catalog_searched=config.registry is not None, + ) + return _resolution( + requested=requested, + wire_model=str(model), + provider=rule_provider, + source="rule", + config=config, + adapters=adapters, + rule=rule, + ) + + hints = [] + if ":" in model: + import difflib + + head = _canonical_provider(model.split(":", 1)[0]) + close = difflib.get_close_matches(head, sorted({*adapters, *_bound_ids(adapters)}), n=1, cutoff=0.75) + if close: + hints.append(f'Did you mean "{close[0]}:{model.split(":", 1)[1]}"?') + if object_provider is not None: + hints.append( + f"The model value carries provider {object_provider!r}, which has " + f"no adapter or compat preset (known providers: {_known_providers(adapters)})." + ) + hints.append( + f'Use an explicit provider prefix — "provider:{model}" with provider ' + f"one of: {_known_providers(adapters)}." + ) + if config.registry is None: + hints.append( + "Or pass a model catalog: " + "LMRouter(config=RouterConfig(registry=ModelRegistry.discover())) " + "— install a catalog package such as 'aimo' first." + ) + raise UnknownModelError( + f"could not route model {model!r}: no provider prefix, " + f"{'no catalog match' if config.registry is not None else 'no catalog supplied'}, " + f"and none of the {len(config.rules)} built-in rules matched. " + + " ".join(hints), + model=model, + rules_tried=config.rules, + catalog_searched=config.registry is not None, + ) + + +def _known_providers(adapters: Mapping[str, type]) -> str: + return ", ".join(sorted({*adapters, *_bound_ids(adapters)})) + + +def _declared_env_keys(provider: str, adapters: Mapping[str, type]) -> tuple[str, ...]: + definition = _bound(provider, adapters) + if definition is not None: + return definition.env_keys + return adapters[provider].manifest.env_keys + + +def _api_keys_source( + config: RouterConfig, provider: str, adapters: Mapping[str, type] = ADAPTERS, +) -> str | None: + """Select a config key, not its value; never invoke credential providers. + + Exact provider first, else identical non-empty env-key tuples. Empty + tuples do not join unrelated local servers, OAuth stores or ADC chains. + Multiple candidates are ambiguous even if their values look equal: + credential callables can change independently at request time. + """ + keys = config.api_keys + if not keys: + return None + exact = [key for key in keys if _canonical_provider(key) == provider] + candidates = exact + if not exact and _routable(provider, adapters): + env_keys = _declared_env_keys(provider, adapters) + if env_keys: + candidates = [key for key in keys + if _routable(_canonical_provider(key), adapters) + and _declared_env_keys(_canonical_provider(key), adapters) == env_keys] + if len(candidates) > 1: + raise NotConfiguredError( + f"RouterConfig(api_keys=...): ambiguous credentials for {provider!r} from " + f"{', '.join(repr(key) for key in sorted(candidates))}; " + f"supply one entry under {provider!r} or keep only one shared entry", + ) + if not candidates: + return None + key = candidates[0] + value = keys[key] + if value is None or (isinstance(value, str) and not value): + raise NotConfiguredError(f"RouterConfig(api_keys=...): empty credential under {key!r}; no environment fallback") + return key + + +def _api_keys_entry( + config: RouterConfig, provider: str, adapters: Mapping[str, type] = ADAPTERS, +) -> tuple[Credential | None, bool]: + key = _api_keys_source(config, provider, adapters) + return (config.api_keys[key], True) if key is not None else (None, False) + + +def _base_url_entry(config: RouterConfig, provider: str) -> str | None: + """Explicit base_urls entry for a provider, matching either spelling.""" + if config.base_urls is None: + return None + for key, value in config.base_urls.items(): + if _canonical_provider(key) == provider: + return value + return None + + +def _credentials_entry(config: RouterConfig, provider: str) -> str | None: + """The named credential for a provider (AUTH-1), matching either spelling.""" + if config.credentials is None: + return None + for key, value in config.credentials.items(): + if _canonical_provider(key) == provider: + return value + return None + + +def _check_named_credential(config: RouterConfig, provider: str, name: str, adapters: Mapping[str, type]) -> None: + """A ``credentials`` entry names an identity on a cloud door only, and + never alongside an ``api_keys`` entry for the same provider.""" + definition = _bound(provider, adapters) + if definition is None or not definition.access.cloud_chain: + raise NotConfiguredError( + f"RouterConfig(credentials={{{provider!r}: {name!r}}}): {provider!r} is not a cloud door; named " + "credentials (platform, workload, environment, cli) exist on the azure, bedrock and vertex doors. " + "Pass this provider's credential in api_keys=.", + ) + if _api_keys_source(config, provider, adapters) is not None: + raise NotConfiguredError( + f"RouterConfig: both api_keys and credentials name {provider!r}; a door has one identity — pass the " + "credential value (api_keys) or name the identity (credentials), not both.", + ) + + +def _hosted_endpoint(config: RouterConfig, provider: str, definition: "ProviderDefinition") -> str | None: + """The endpoint root for a cloud door: the explicit ``base_urls`` entry, + else the vendor's own variable (AUTH-10, amended 2026-09-19).""" + from .cloud.hosts import endpoint_from_env + + explicit = _base_url_entry(config, provider) + if explicit is not None: + return explicit + env = config.env if config.env is not None else os.environ + return endpoint_from_env(definition.access.host, env) + + +def _set_origin(lm: object, label: str) -> None: + """Stamp the provenance label on a built LM (and its inner sync adapter + for the async mirrors).""" + for target in (lm, getattr(lm, "_inner", None)): + if target is not None and hasattr(target, "_credential_origin"): + target._credential_origin = label + + +def _env_key_for(provider: str, config: RouterConfig, adapters: Mapping[str, type]) -> str | None: + """WHICH env var lm() would read for this provider (never the value). + + None when the provider is OAuth-based or a keyless local preset + (declares no env keys) or when an explicit ``api_keys`` entry + overrides env lookup entirely. + """ + if _api_keys_entry(config, provider, adapters)[1]: + return None + env_keys = _declared_env_keys(provider, adapters) + if not env_keys: + return None + env = config.env if config.env is not None else os.environ + for key in env_keys: + if env.get(key): + return key + return env_keys[0] + + +def _build_lm(resolution: Resolution, config: RouterConfig, adapters: Mapping[str, type], transport: object | None = None): + cls = _adapter_for(resolution.provider, adapters) + definition = _bound(resolution.provider, adapters) + extra: dict = {} + if transport is not None: + extra["transport"] = transport + elif config.transport is not None: + extra["transport"] = config.transport + if config.adaptations != "note": + extra["adaptations"] = config.adaptations + hosted = definition is not None and definition.hosted + base_url = _hosted_endpoint(config, resolution.provider, definition) if hosted else _base_url_entry(config, resolution.provider) + if base_url is not None: + extra["base_url"] = base_url + policy = _credential_policy(resolution.provider, adapters) + if policy == "oauth": + return cls(**extra) # self-resolving local OAuth constructor + api_key, _ = _api_keys_entry(config, resolution.provider, adapters) + if hosted: + # A cloud door (AUTH-10): the endpoint from config or the vendor's + # variable, host settings from config, then env, then defaults; the + # credential from the explicit entry, the named identity, or the + # chain's caching provider (AUTH-1/AUTH-2). + from .cloud.chains import ChainContext, credential_provider + from .cloud.hosts import resolve_settings + + from .cloud.chains import profile_settings + + env = config.env if config.env is not None else os.environ + given = (config.settings or {}).get(resolution.provider) + ctx = ChainContext.online(env) + settings = resolve_settings(definition.access.host, given, env, provider=resolution.provider, + profile=profile_settings(definition.access, ctx), endpoint=base_url) + ctx.settings = settings + named = _credentials_entry(config, resolution.provider) + origin: str | None = None + if api_key is None and definition.access.cloud_chain: + api_key = credential_provider(definition.access, ctx, named=named) + elif api_key is None: + for key in definition.access.env_keys: + value = env.get(key) + if value: + api_key = value + origin = f"env ${key} (value never shown)" + break + if api_key is None: + env_keys = definition.access.env_keys + raise MissingCredentialError( + f"no credential found for provider {resolution.provider!r}. " + f"Set {' or '.join(env_keys)} in the environment, or pass " + f"RouterConfig(api_keys={{{resolution.provider!r}: \"...\"}}).", + provider=resolution.provider, + env_keys=env_keys, + ) + if definition.compat is not None: + extra["compat"] = definition.compat + lm = cls(api_key=api_key, access=definition.access, settings=settings, **extra) + if origin is not None: + _set_origin(lm, origin) + return lm + if api_key is None and policy == "oauth-unless-explicit" and cls.has_stored_credential(): + # A usable stored subscription login outranks ambient env keys: + # it spends no money per token (AUTH-1, oauth-unless-explicit). + return cls(**extra) # self-resolving local OAuth constructor + origin: str | None = None + if api_key is None: + env = config.env if config.env is not None else os.environ + for key in _declared_env_keys(resolution.provider, adapters): + value = env.get(key) + if value: + api_key = value + origin = f"env ${key} (value never shown)" + break + if api_key is None and definition is not None and definition.placeholder_key is not None: + api_key = definition.placeholder_key + origin = "the local server's placeholder key" + if not api_key and policy == "oauth-unless-explicit": + # Nothing anywhere: the constructor raises the typed login-hint error. + return cls(**extra) + if not api_key: + env_keys = _declared_env_keys(resolution.provider, adapters) + raise MissingCredentialError( + f"no API key found for provider {resolution.provider!r}. " + f"Set {' or '.join(env_keys)} in the environment, or pass " + f"RouterConfig(api_keys={{{resolution.provider!r}: \"...\"}}).", + provider=resolution.provider, + env_keys=env_keys, + ) + if definition is not None: + # Bound entry: the dialect class with this provider's access policy + # (credential header, surfaces, base_url) and compat preset bound at + # construction. The LM then names itself by the provider (errors, + # ModelInfo.provider), not by the dialect. + lm = cls(api_key=api_key, compat=definition.compat, access=definition.access, **extra) + else: + lm = cls(api_key=api_key, **extra) + if origin is not None: + _set_origin(lm, origin) + return lm + + +PLANNING_KEY = "lm15-planning" + + +def _build_planning_lm(resolution: Resolution, config: RouterConfig, adapters: Mapping[str, type], transport: object | None): + """A throwaway LM for ``plan()``: the build's bytes are discarded, so it + carries a placeholder credential under every policy — no stored login + is read or refreshed, no cloud chain is walked, no environment key is + needed — and placeholder host settings where a cloud door would + otherwise refuse to render its URL. Never cached. Everything else + (compat preset, access policy, base_url, adaptations policy) is the + real route's, so the plan is the call's.""" + cls = _adapter_for(resolution.provider, adapters) + definition = _bound(resolution.provider, adapters) + extra: dict = {"api_key": PLANNING_KEY, "adaptations": config.adaptations} + if transport is not None: + extra["transport"] = transport + elif config.transport is not None: + extra["transport"] = config.transport + hosted = definition is not None and definition.hosted + base_url = _hosted_endpoint(config, resolution.provider, definition) if hosted else _base_url_entry(config, resolution.provider) + if base_url is not None: + extra["base_url"] = base_url + account_field = getattr(cls, "__dataclass_fields__", {}).get("account_id") + if account_field is not None and account_field.init: + # The Codex backend derives an account id from its token at + # construction; a placeholder token has none, so name one. + extra["account_id"] = PLANNING_KEY + if definition is None: + return cls(**extra) + if definition.hosted: + from .cloud.hosts import resolve_settings + + env = config.env if config.env is not None else os.environ + given = dict((config.settings or {}).get(resolution.provider) or {}) + try: + settings = resolve_settings(definition.access.host, given, env, provider=resolution.provider, endpoint=base_url) + except NotConfiguredError: + placeholders = {setting.name: given.get(setting.name) or setting.default or "planning" + for setting in definition.access.host.settings} + settings = resolve_settings(definition.access.host, placeholders, None, provider=resolution.provider) + if definition.compat is not None: + extra["compat"] = definition.compat + return cls(access=definition.access, settings=settings, **extra) + return cls(compat=definition.compat, access=definition.access, **extra) + + +def _routed_request(request: Request, resolution: Resolution) -> Request: + if request.model == resolution.model: + return request + return replace(request, model=resolution.model) + + +# ------------------------------------------------- the OpenAI-shaped door ---- + +# litellm's routing prefixes (`/`) that name a door lm15 +# has, copied as data. A prefix absent here is refused by name — never +# routed by rule — because litellm's own rule is "a leading known provider +# name is the provider", and an unknown one is an error there too. Where +# litellm's name covers two lm15 doors (bedrock, vertex_ai: Anthropic or +# not, by model) it is left out: choosing would be a guess. +LITELLM_PROVIDER_PREFIXES: Mapping[str, str] = MappingProxyType({ + "openai": "openai-chat", + "anthropic": "anthropic", + "gemini": "gemini", + "groq": "groq", + "openrouter": "openrouter", + "deepseek": "deepseek", + "xai": "xai", + "ollama": "ollama", + "ollama_chat": "ollama", + "hosted_vllm": "vllm", + "moonshot": "moonshotai", + "azure": "azure-chat", +}) + +# The same prefixes in canonical spelling, for the collision check a +# declared provider's spellings go through (`_check_declared`). +_LITELLM_CANONICAL: Mapping[str, str] = MappingProxyType( + {_canonical_provider(prefix): provider for prefix, provider in LITELLM_PROVIDER_PREFIXES.items()} +) + +# Keyword arguments of `create()` / `completion()` that configure the +# CLIENT, not the request: refused with the lm15 place they belong. +_CLIENT_KEYWORDS: Mapping[str, str] = MappingProxyType({ + "api_key": "LMRouter(RouterConfig(api_keys={provider: key})) or the environment", + "api_base": "LMRouter(RouterConfig(base_urls={provider: url}))", + "base_url": "LMRouter(RouterConfig(base_urls={provider: url}))", + "timeout": "RouterConfig(timeouts=Timeouts(read=...))", + "num_retries": "your own retry loop over lm15.RETRYABLE_ERRORS (lm15 never retries)", + "max_retries": "your own retry loop over lm15.RETRYABLE_ERRORS (lm15 never retries)", + "headers": "RouterConfig(transport=...)", + "extra_headers": "RouterConfig(transport=...)", + "extra_body": "config.extensions on the Request (build it with request_from_openai_chat and edit)", + "extra_query": "RouterConfig(transport=...)", + "cache": "your own cache keyed on the Request (lm15 has no response cache)", + "caching": "your own cache keyed on the Request (lm15 has no response cache)", + "mock_response": "lm15.testing.FakeLM", + "drop_params": "RouterConfig(adaptations='silent'): lm15 adapts what a wire cannot carry and records it on the response (MAP-13); 'silent' keeps no record, 'refuse' raises instead", + "custom_llm_provider": "the model string's prefix", +}) + + +def openai_chat_model_string(model: str, *, providers: tuple[ProviderDefinition, ...] = ()) -> str: + """The lm15 model string for a model string written for the OpenAI SDK + or litellm (`playbooks/api-family.md` § Ingest): + + - an lm15 string (``provider:model``) is left alone; + - litellm's ``provider/model`` maps its prefix through + :data:`LITELLM_PROVIDER_PREFIXES` (only the first segment; a model + id may contain slashes itself: ``groq/openai/gpt-oss-20b``), or + through a declared provider's id and aliases (``providers``, the + config's ``RouterConfig(providers=...)``; either spelling); + - a bare name routes by lm15's rules, except that OpenAI's models go + to the Chat Completions door (``openai-chat:``) — the endpoint both + libraries were using — not the Responses API. + """ + if ":" in model: + return model + head, sep, rest = model.partition("/") + if sep and rest: + provider = LITELLM_PROVIDER_PREFIXES.get(head) + if provider is None: + canonical = _canonical_provider(head) + for definition in providers: + if canonical in definition.spellings: + provider = definition.id + break + if provider is None: + known = sorted({*LITELLM_PROVIDER_PREFIXES, *(s for d in providers for s in d.spellings)}) + raise UnknownModelError( + f"could not read {model!r} as a litellm model string: {head!r} is not a provider prefix lm15 " + f"has a door for (known: {', '.join(known)}); write it as lm15's " + "provider:model instead", + model=model, + ) + return f"{provider}:{rest}" + return model + + +def _split_openai_chat_call(model: str, messages: object, kwargs: dict) -> dict: + """``(model, messages, **kwargs)`` → the Chat Completions body, after + refusing the client keywords by name.""" + for key, where in _CLIENT_KEYWORDS.items(): + if key in kwargs: + raise NotConfiguredError( + f"{key!r} configures the client, not the request; in lm15 it lives in {where}", + ) + return {"model": model, "messages": messages, **kwargs} + + +# ---------------------------------------------------------------- router ---- + + +class LMRouter: + """Routes model strings to provider LMs. + + Four methods, no state you can't see: config is frozen; the only + mutation is an LM cache keyed by provider (one LM per provider, + built lazily, reused) and the one transport those LMs share. + """ + + def __init__(self, config: RouterConfig = RouterConfig()) -> None: + self._adapters = _table(type(self)._adapters, config) + _check_provider_keyed(config, self._adapters) + self.config = config + self._lms: dict[str, object] = {} + self._transport: object | None = config.transport + # One lock for the LM cache and the shared transport: routers are + # used from many threads (an evaluation's workers), and two first + # calls at once must not each build a pool that close() cannot + # find (found in review of dspy#10409). + self._lock = threading.RLock() + + _adapters: Mapping[str, type] = ADAPTERS + + def _shared_transport(self): + """The transport every LM of this router uses: the configured one, + else one StdlibTransport built from ``timeouts``/``max_connections`` + on first use and shared, so the router's pool is one pool.""" + with self._lock: + if self._transport is None: + from .transports import StdlibTransport + + self._transport = StdlibTransport(**self.config.transport_kwargs()) + return self._transport + + def close(self) -> None: + """Close every connection this router holds. Idempotent; the router + may be used again afterwards (a fresh transport is built).""" + with self._lock: + transport, self._transport = self._transport, None + self._lms.clear() + close = getattr(transport, "close", None) + if callable(close): + close() + + def __enter__(self) -> "LMRouter": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + def resolve(self, model: str) -> Resolution: + """Offline lookup; invokes no credential providers and returns no secrets. + + Raises UnknownModelError / AmbiguousModelError. This IS the + explain() method — there is no separate one. + """ + return _resolve(model, self.config, self._adapters) + + def lm(self, model: str): + """resolve(), then construct-or-reuse the provider LM. + + Raises MissingCredentialError when no key is found. The + returned LM is an ordinary OpenAILM/AnthropicLM/... — the escape + hatch is built in: keep it, configure transports yourself. + """ + resolution = self.resolve(model) + with self._lock: + lm = self._lms.get(resolution.provider) + if lm is None: + lm = _build_lm(resolution, self.config, self._adapters, self._shared_transport()) + self._lms[resolution.provider] = lm + return lm + + def plan(self, request: Request): + """MAP-13 pre-flight: what this request WOULD adapt on its route, no + network and no credential (like ``resolve()``); raises what the call + would raise. A route with no key gets a throwaway planning LM (not + cached): the build's bytes are discarded, so no key is needed.""" + resolution = self.resolve(request.model) + with self._lock: + lm = self._lms.get(resolution.provider) + if lm is None: + # Not lm(): constructing the real LM reads or refreshes a stored + # login and walks cloud chains — I/O a pre-flight must not do. + lm = _build_planning_lm(resolution, self.config, self._adapters, self._shared_transport()) + return lm.plan(_routed_request(request, resolution)) + + def complete(self, request: Request) -> Response: + resolution = self.resolve(request.model) + return self.lm(request.model).complete(_routed_request(request, resolution)) + + def stream(self, request: Request) -> Iterator[StreamEvent]: + resolution = self.resolve(request.model) + return self.lm(request.model).stream(_routed_request(request, resolution)) + + def cache(self, prefix: Request, *, ttl_seconds: int | None = None, label: str | None = None): + """``router.cache(prefix)``: the MAP-6 door, routed by the prefix's model.""" + resolution = self.resolve(prefix.model) + cached = self.lm(prefix.model).cache(_routed_request(prefix, resolution), ttl_seconds=ttl_seconds, label=label) + return replace(cached, provider=resolution.provider) + + # ─── the OpenAI-shaped door (api-family § Ingest) ────────────────── + + def resolve_openai_chat(self, model: str) -> Resolution: + """:meth:`resolve` for the OpenAI-shaped door: ``model`` is read by + :func:`openai_chat_model_string`, and a bare OpenAI name goes to + Chat Completions (``openai-chat``), the endpoint the OpenAI SDK and + litellm were using. Like ``resolve()``: no network, no credential + invocation or secret values in the result — "which provider, which env var" + before any key exists.""" + resolution = self.resolve(openai_chat_model_string(model, providers=self.config.providers)) + if resolution.source == "rule" and resolution.provider == "openai": + resolution = self.resolve(f"openai-chat:{resolution.model}") + return resolution + + def request_from_openai_chat(self, model: str, messages: object, /, **kwargs) -> tuple[Request, object]: + """The Request behind :meth:`complete_from_openai_chat`, and the LM + it routes to. ``model`` may be written for the OpenAI SDK, for + litellm, or for lm15 (:meth:`resolve_openai_chat`); the body is + read with the destination door's own spellings when it speaks + the Chat Completions wire, else with OpenAI's.""" + resolution = self.resolve_openai_chat(model) + body = _split_openai_chat_call(resolution.requested, messages, kwargs) + lm = self.lm(resolution.requested) + reader = getattr(lm, "request_from_openai_chat", None) + if reader is None: + from .providers.openai_chat import request_from_openai_chat as read_openai + + request = read_openai(body) + else: + request = reader(body) + return _routed_request(request, resolution), lm + + @overload + def complete_from_openai_chat(self, model: str, messages: object, /, *, stream: Literal[False] = False, **kwargs) -> Response: ... + + @overload + def complete_from_openai_chat(self, model: str, messages: object, /, *, stream: Literal[True], **kwargs) -> ResponseStream: ... + + @overload + def complete_from_openai_chat(self, model: str, messages: object, /, *, stream: bool, **kwargs) -> Response | ResponseStream: ... + + def complete_from_openai_chat(self, model: str, messages: object, /, *, stream: bool = False, **kwargs) -> Response | ResponseStream: + """``client.chat.completions.create(model=, messages=, ...)`` or + ``litellm.completion(model=, messages=, ...)`` — the same call, + answered by lm15. The messages and keywords are read by + :func:`request_from_openai_chat` (MAP-12: every key maps, passes + through, or is refused by name); the model string by + :func:`openai_chat_model_string`. Returns a canonical + :class:`Response`, or with ``stream=True`` a lazy + :class:`ResponseStream`: iterate text, inspect ``.events()`` for + typed events and ``.response`` for the assembled answer. Close the + result (or use ``with``) when leaving a stream early. The native + ``complete(Request)`` method retains its single return type.""" + if not isinstance(stream, bool): + raise TypeError("stream must be a boolean") + request, lm = self.request_from_openai_chat(model, messages, **kwargs) + if stream: + return ResponseStream(lm.stream(request), request) + return lm.complete(request) + + def stream_from_openai_chat(self, model: str, messages: object, /, **kwargs) -> Iterator[StreamEvent]: + """The streaming twin of :meth:`complete_from_openai_chat`: typed + lm15 stream events, not OpenAI-shaped chunks.""" + request, lm = self.request_from_openai_chat(model, messages, **kwargs) + return lm.stream(request) + + +class AsyncLMRouter: + """Async mirror of :class:`LMRouter`. + + Same four methods; ``lm()`` returns Async* mirrors; ``complete`` is + a coroutine and ``stream`` returns an async iterator. ``resolve()`` + stays sync (it is pure). + """ + + def __init__(self, config: RouterConfig = RouterConfig()) -> None: + self._adapters = _table(type(self)._adapters, config) + _check_provider_keyed(config, self._adapters) + self.config = config + self._lms: dict[str, object] = {} + self._transport: object | None = config.transport + self._lock = threading.RLock() # construction is sync; see LMRouter + + _adapters: Mapping[str, type] = ASYNC_ADAPTERS + + def _shared_transport(self): + with self._lock: + if self._transport is None: + from .transports import StdlibAsyncTransport + + self._transport = StdlibAsyncTransport(**self.config.transport_kwargs()) + return self._transport + + async def aclose(self) -> None: + """Close every connection this router holds. An async transport's + pool belongs to the loop that used it; call this on that loop + before it ends (one router per loop). Idempotent.""" + with self._lock: + transport, self._transport = self._transport, None + self._lms.clear() + aclose = getattr(transport, "aclose", None) + if callable(aclose): + await aclose() + + async def __aenter__(self) -> "AsyncLMRouter": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.aclose() + + def resolve(self, model: str) -> Resolution: + return _resolve(model, self.config, self._adapters) + + def lm(self, model: str): + resolution = self.resolve(model) + with self._lock: + lm = self._lms.get(resolution.provider) + if lm is None: + lm = _build_lm(resolution, self.config, self._adapters, self._shared_transport()) + self._lms[resolution.provider] = lm + return lm + + plan = LMRouter.plan + + async def complete(self, request: Request) -> Response: + resolution = self.resolve(request.model) + return await self.lm(request.model).complete(_routed_request(request, resolution)) + + def stream(self, request: Request) -> AsyncIterator[StreamEvent]: + resolution = self.resolve(request.model) + return self.lm(request.model).stream(_routed_request(request, resolution)) + + async def cache(self, prefix: Request, *, ttl_seconds: int | None = None, label: str | None = None): + resolution = self.resolve(prefix.model) + cached = await self.lm(prefix.model).cache(_routed_request(prefix, resolution), ttl_seconds=ttl_seconds, label=label) + return replace(cached, provider=resolution.provider) + + resolve_openai_chat = LMRouter.resolve_openai_chat + request_from_openai_chat = LMRouter.request_from_openai_chat + + @overload + async def complete_from_openai_chat(self, model: str, messages: object, /, *, stream: Literal[False] = False, **kwargs) -> Response: ... + + @overload + async def complete_from_openai_chat(self, model: str, messages: object, /, *, stream: Literal[True], **kwargs) -> AsyncResponseStream: ... + + @overload + async def complete_from_openai_chat(self, model: str, messages: object, /, *, stream: bool, **kwargs) -> Response | AsyncResponseStream: ... + + async def complete_from_openai_chat(self, model: str, messages: object, /, *, stream: bool = False, **kwargs) -> Response | AsyncResponseStream: + """Await for a Response, or a lazy AsyncResponseStream when stream=True. + + Iterate the latter with ``async for``; ``await result.response()`` + assembles the answer. ``async with result`` closes on early exit. + """ + if not isinstance(stream, bool): + raise TypeError("stream must be a boolean") + request, lm = self.request_from_openai_chat(model, messages, **kwargs) + if stream: + return AsyncResponseStream(lm.stream(request), request) + return await lm.complete(request) + + def stream_from_openai_chat(self, model: str, messages: object, /, **kwargs) -> AsyncIterator[StreamEvent]: + request, lm = self.request_from_openai_chat(model, messages, **kwargs) + return lm.stream(request) diff --git a/serde.py b/serde.py new file mode 100644 index 0000000000..b0dd33473c --- /dev/null +++ b/serde.py @@ -0,0 +1,1363 @@ +""" +lm15.serde — Serialization and deserialization for the lm15 type system. + +One function pair per type: xxx_to_dict / xxx_from_dict. +All Part field names are consistent: "input" on the wire matches .input +in memory. No name translation (no "arguments" vs "input" split). +""" + +from __future__ import annotations + +from typing import Any + +from .adaptation import adaptation_from_dict, adaptation_to_dict +from .models import ( + InferenceModelInfo, + InferencePricing, + ModelInfo, + ModelOrigin, +) +from .types import ( + AudioDelta, + DataPart, + AudioFormat, + AudioPart, + BatchEntry, + BatchJobInfo, + BatchRequest, + BinaryPart, + BuiltinTool, + CacheConfig, + CachedPrefix, + CacheInfo, + CachePage, + CitationDelta, + CitationPart, + Config, + ContinuationDelta, + ContinuationState, + Delta, + DocumentPart, + ErrorDetail, + FileInfo, + FilePage, + FileUploadRequest, + ImageGenerationRequest, + ImageGenerationResponse, + SpeechGenerationRequest, + SpeechGenerationResponse, + VideoGenerationRequest, + VideoJobInfo, + FunctionTool, + ImageDelta, + ImagePart, + LiveClientAudioEvent, + LiveClientEndAudioEvent, + LiveClientEvent, + LiveClientImageEvent, + LiveClientInterruptEvent, + LiveClientTextEvent, + LiveClientToolResultEvent, + LiveClientTurnEvent, + LiveConfig, + LiveServerAudioEvent, + LiveServerErrorEvent, + LiveServerEvent, + LiveServerInterruptedEvent, + LiveServerTextEvent, + LiveServerToolCallDeltaEvent, + LiveServerToolCallEvent, + LiveServerTurnEndEvent, + LiveServerUsageEvent, + Message, + Part, + PART_TYPES, + Reasoning, + Request, + Response, + StreamDeltaEvent, + StreamEndEvent, + StreamErrorEvent, + StreamEvent, + StreamStartEvent, + TextDelta, + TokenLogprob, + TopLogprob, + TextPart, + ThinkingDelta, + ThinkingPart, + RefusalPart, + Tool, + ToolCallDelta, + ToolCallPart, + ToolChoice, + ToolResultPart, + Usage, + VideoPart, +) + + +# ─── Helpers ───────────────────────────────────────────────────────── + +def _is_empty(value: Any) -> bool: + return value is None or value == "" or value == () or value == [] or value == {} + + +def _clean_mapping(values: dict[str, Any]) -> dict[str, Any]: + """Drop empty optional fields — at the top level of this object ONLY. + + The omission rule (docs/serde-rules.md): each typed serializer omits its + own empty optional fields; nested values are either already-serialized + typed objects or opaque user/provider payloads, and both are embedded + verbatim. Recursing here would give the same value different wire forms + depending on the serialization entry point. + """ + out: dict[str, Any] = {} + for key, value in values.items(): + if isinstance(value, tuple): + value = list(value) + if _is_empty(value): + continue + out[key] = value + return out + + + +# ─── Continuation state ────────────────────────────────────────────── + +def continuation_to_dict(state: ContinuationState) -> dict[str, Any]: + return { + "provider": state.provider, + "kind": state.kind, + "data": state.data, + } + + +def continuation_from_dict(d: dict[str, Any]) -> ContinuationState: + return ContinuationState( + provider=d["provider"], + kind=d["kind"], + data=d.get("data", {}), + ) + + +def _continuation_to_json(values: tuple[ContinuationState, ...]) -> list[dict[str, Any]] | None: + if not values: + return None + return [continuation_to_dict(state) for state in values] + + +def _continuation_from_json(value: Any) -> tuple[ContinuationState, ...]: + if value is None: + return () + if not isinstance(value, list): + raise TypeError("continuation must be a list") + states: list[ContinuationState] = [] + for item in value: + if not isinstance(item, dict): + raise TypeError("continuation entries must be objects") + states.append(continuation_from_dict(item)) + return tuple(states) + + +# ─── Parts ─────────────────────────────────────────────────────────── + +def part_to_dict(part: Part) -> dict[str, Any]: + """Serialize a Part to the canonical lm15 JSON format.""" + d: dict[str, Any] = {"type": part.type} + + if isinstance(part, TextPart): + d["text"] = part.text + + elif isinstance(part, ThinkingPart): + d["text"] = part.text + + elif isinstance(part, RefusalPart): + d["text"] = part.text + + elif isinstance(part, CitationPart): + if part.text is not None: + d["text"] = part.text + if part.url is not None: + d["url"] = part.url + if part.title is not None: + d["title"] = part.title + + elif isinstance(part, (ImagePart, AudioPart, VideoPart, DocumentPart, BinaryPart)): + d["media_type"] = part.media_type + if part.data is not None: + d["data"] = part.data + if part.url is not None: + d["url"] = part.url + if part.file_id is not None: + d["file_id"] = part.file_id + if part.path is not None: + d["path"] = str(part.path) + if hasattr(part, "detail") and part.detail is not None: + d["detail"] = part.detail + + elif isinstance(part, ToolCallPart): + d["id"] = part.id + d["name"] = part.name + d["input"] = part.input # consistent: "input" everywhere + + elif isinstance(part, ToolResultPart): + d["id"] = part.id + if part.name is not None: + d["name"] = part.name + d["content"] = [part_to_dict(p) for p in part.content] if part.content else [] + if part.is_error: + d["is_error"] = part.is_error + + elif isinstance(part, DataPart): + d["value"] = part.value # opaque, always emitted (null is a value) + if part.probabilities is not None: + # canonical data, not an opaque payload: floats stay JSON floats + d["probabilities"] = { + name: {key: float(prob) for key, prob in dist.items()} + for name, dist in part.probabilities.items() + } + if part.method is not None: + d["method"] = part.method + + continuation = _continuation_to_json(part.continuation) + if continuation is not None: + d["continuation"] = continuation + + return d + + +def part_from_dict(d: dict[str, Any]) -> Part: + """Deserialize a Part from the canonical lm15 JSON format.""" + t = d["type"] + + continuation = _continuation_from_json(d.get("continuation")) + + if t == "text": + return TextPart(text=d.get("text", ""), continuation=continuation) + + if t == "thinking": + return ThinkingPart(text=d.get("text", ""), continuation=continuation) + + if t == "refusal": + return RefusalPart(text=d.get("text", ""), continuation=continuation) + + if t == "citation": + return CitationPart(text=d.get("text"), url=d.get("url"), title=d.get("title"), continuation=continuation) + + if t == "data": + if "value" not in d: + raise ValueError("data part requires 'value' (null is a value; absence is not)") + return DataPart(value=d["value"], probabilities=d.get("probabilities"), method=d.get("method"), + continuation=continuation) + + if t in ("image", "audio", "video", "document", "binary"): + cls = PART_TYPES[t] + kwargs: dict[str, Any] = { + "media_type": d.get("media_type", ""), + "data": d.get("data"), + "url": d.get("url"), + "file_id": d.get("file_id"), + "path": d.get("path"), + "continuation": continuation, + } + if t == "image": + kwargs["detail"] = d.get("detail") + return cls(**kwargs) + + if t == "tool_call": + return ToolCallPart( + id=d["id"], + name=d["name"], + input=d.get("input", {}), + continuation=continuation, + ) + + if t == "tool_result": + raw_content = d.get("content", []) + if isinstance(raw_content, str): + content = (TextPart(text=raw_content),) if raw_content else () + elif isinstance(raw_content, list): + content = tuple( + part_from_dict(c) if isinstance(c, dict) else TextPart(text=str(c)) + for c in raw_content + ) + else: + content = () + return ToolResultPart( + id=d["id"], + content=content, + name=d.get("name"), + is_error=d.get("is_error", False), + continuation=continuation, + ) + + raise ValueError(f"unsupported part type: {t}") + + +# ─── Messages ──────────────────────────────────────────────────────── + +def message_to_dict(msg: Message) -> dict[str, Any]: + out: dict[str, Any] = {"role": msg.role, "parts": [part_to_dict(p) for p in msg.parts]} + continuation = _continuation_to_json(msg.continuation) + if continuation is not None: + out["continuation"] = continuation + return out + + +def message_from_dict(d: dict[str, Any]) -> Message: + role = d["role"] + parts_raw = d.get("parts", []) + parts = tuple( + part_from_dict(p) if isinstance(p, dict) else TextPart(text=str(p)) + for p in parts_raw + ) + if not parts: + raise ValueError(f"message for role '{role}' has no parts") + return Message(role=role, parts=parts, continuation=_continuation_from_json(d.get("continuation"))) + + +def messages_to_json(messages: list[Message] | tuple[Message, ...]) -> list[dict[str, Any]]: + return [message_to_dict(m) for m in messages] + + +def messages_from_json(data: list[dict[str, Any]]) -> list[Message]: + return [message_from_dict(d) for d in data] + + +# ─── Tools ─────────────────────────────────────────────────────────── + +def tool_to_dict(t: Tool) -> dict[str, Any]: + if isinstance(t, FunctionTool): + out = _clean_mapping({ + "type": "function", + "name": t.name, + "description": t.description, + }) + # parameters is required-with-shape (INV-033): always emitted, even + # when it is the opaque literal {} — opaque payloads round-trip + # verbatim and are never subject to the omission rule. + out["parameters"] = t.parameters + return out + if isinstance(t, BuiltinTool): + return _clean_mapping({ + "type": "builtin", + "name": t.name, + "config": t.config, + }) + raise TypeError(f"unsupported tool type: {type(t)}") + + +def tool_from_dict(d: dict[str, Any]) -> Tool: + if d.get("type") == "builtin": + return BuiltinTool(name=d["name"], config=d.get("config")) + return FunctionTool( + name=d["name"], + description=d.get("description"), + parameters=d.get("parameters", {"type": "object", "properties": {}}), + ) + + +# ─── Config ────────────────────────────────────────────────────────── + +def tool_choice_to_dict(tc: ToolChoice) -> dict[str, Any]: + return _clean_mapping({ + "mode": tc.mode, + "allowed": list(tc.allowed), + "parallel": tc.parallel, + }) + + + +def _bare_or_list(value: Any) -> tuple: + """INV-020: a bare string coerces to a 1-tuple; a list becomes a tuple. + (A bare string used to be iterated into characters — 2026-09-07.)""" + if isinstance(value, str): + return (value,) + return tuple(value) + + +def tool_choice_from_dict(d: dict[str, Any]) -> ToolChoice: + return ToolChoice( + mode=d.get("mode", "auto"), + allowed=_bare_or_list(d.get("allowed", [])), # INV-020: a bare str is one element + parallel=d.get("parallel"), + ) + + +def reasoning_to_dict(r: Reasoning) -> dict[str, Any]: + return _clean_mapping({ + "effort": r.effort, + "thinking_budget": r.thinking_budget, + "summary": r.summary, + }) + + +def reasoning_from_dict(d: dict[str, Any]) -> Reasoning: + default_effort = "off" if d.get("enabled") is False else "medium" + effort = d.get("effort", default_effort) + # Legacy payloads could combine enabled=false with a budget. In the + # current type system, off reasoning has no budgets, so discard them. + # `total_budget` (removed 2026-09-02, MAP-7) and `adaptive` (now + # "absent") are legacy spellings: the first is dropped on read, the + # second reads as medium — INV-043 leniency for pre-MAP-7 payloads. + if effort == "adaptive": + effort = "medium" + if effort == "off": + return Reasoning(effort="off") + return Reasoning( + effort=effort, + thinking_budget=d.get("thinking_budget", d.get("budget")), + summary=d.get("summary"), + ) + + +_CACHE_MODES = ("auto", "off") +_CACHE_RETENTIONS = ("short", "long") + + +def cache_config_to_dict(c: CacheConfig) -> dict[str, Any]: + return _clean_mapping({ + "mode": c.mode, + "retention": c.retention, + "key": c.key, + "prefix_until_index": c.prefix_until_index, + "prefix": c.prefix, + "resource": c.resource, + }) + + +def cache_config_from_dict(d: dict[str, Any]) -> CacheConfig: + mode = d.get("mode", "auto") + if mode not in _CACHE_MODES: + raise ValueError(f"unsupported cache mode: {mode}") + retention = d.get("retention") + if retention is not None and retention not in _CACHE_RETENTIONS: + raise ValueError(f"unsupported cache retention: {retention}") + return CacheConfig( + mode=mode, + retention=retention, + key=d.get("key"), + prefix_until_index=d.get("prefix_until_index"), + prefix=d.get("prefix"), + resource=d.get("resource"), + ) + + +# ─── Cache resources ───────────────────────────────────────────────── + +def cache_info_to_dict(c: CacheInfo) -> dict[str, Any]: + return _clean_mapping({ + "id": c.id, + "model": c.model, + "tokens": c.tokens, + "created_at": c.created_at, + "expires_at": c.expires_at, + "label": c.label, + "provider_data": c.provider_data, + }) + + +def cache_info_from_dict(d: dict[str, Any]) -> CacheInfo: + return CacheInfo( + id=d["id"], + model=d["model"], + tokens=d.get("tokens"), + created_at=d.get("created_at"), + expires_at=d.get("expires_at"), + label=d.get("label"), + provider_data=d.get("provider_data"), + ) + + +def cache_page_to_dict(p: CachePage) -> dict[str, Any]: + return _clean_mapping({ + "items": [cache_info_to_dict(c) for c in p.items], + "next_cursor": p.next_cursor, + }) + + +def cache_page_from_dict(d: dict[str, Any]) -> CachePage: + return CachePage( + items=tuple(cache_info_from_dict(c) for c in d.get("items", [])), + next_cursor=d.get("next_cursor"), + ) + + +def cached_prefix_to_dict(c: CachedPrefix) -> dict[str, Any]: + return _clean_mapping({ + "prefix": request_to_dict(c.prefix), + "resource": cache_info_to_dict(c.resource) if c.resource is not None else None, + "provider": c.provider, + }) + + +def cached_prefix_from_dict(d: dict[str, Any]) -> CachedPrefix: + return CachedPrefix( + prefix=request_from_dict(d["prefix"]), + resource=cache_info_from_dict(d["resource"]) if isinstance(d.get("resource"), dict) else None, + provider=d.get("provider"), + ) + + +def config_to_dict(c: Config) -> dict[str, Any]: + return _clean_mapping({ + "max_tokens": c.max_tokens, + "temperature": c.temperature, + "top_p": c.top_p, + "top_k": c.top_k, + "stop": list(c.stop), + "response_format": c.response_format, + "tool_choice": tool_choice_to_dict(c.tool_choice) if c.tool_choice else None, + "reasoning": reasoning_to_dict(c.reasoning) if c.reasoning else None, + "cache": cache_config_to_dict(c.cache) if c.cache else None, + "seed": c.seed, # 0 is data (a seed), not emptiness — emitted + "frequency_penalty": c.frequency_penalty, # 0.0 is data (explicitly none) — emitted + "presence_penalty": c.presence_penalty, + "service_tier": c.service_tier, + "user_id": c.user_id, + "store": c.store, # False is data (opt-out), not emptiness — emitted + "logprobs": c.logprobs, # 0 is data (chosen-only), not emptiness — emitted + "probabilities": c.probabilities, + "extensions": c.extensions, + }) + + +def _config_nest(d: dict[str, Any], key: str) -> dict[str, Any] | None: + """Return config. as a dict, None when absent/null. + + A present non-dict value is malformed canonical JSON and raises + TypeError (INV-042): an error, never silent data loss. + """ + value = d.get(key) + if value is None: + return None + if not isinstance(value, dict): + raise TypeError(f"config.{key} must be a JSON object, got {type(value).__name__}") + return value + + +def config_from_dict(d: dict[str, Any]) -> Config: + tool_choice = _config_nest(d, "tool_choice") + reasoning = _config_nest(d, "reasoning") + cache = _config_nest(d, "cache") + return Config( + max_tokens=d.get("max_tokens"), + temperature=d.get("temperature"), + top_p=d.get("top_p"), + top_k=d.get("top_k"), + stop=_bare_or_list(d.get("stop", [])), # INV-020: a bare str is one element + response_format=d.get("response_format"), + tool_choice=tool_choice_from_dict(tool_choice) if tool_choice is not None else None, + reasoning=reasoning_from_dict(reasoning) if reasoning is not None else None, + cache=cache_config_from_dict(cache) if cache is not None else None, + seed=d.get("seed"), + frequency_penalty=d.get("frequency_penalty"), + presence_penalty=d.get("presence_penalty"), + service_tier=d.get("service_tier"), + user_id=d.get("user_id"), + store=d.get("store"), + logprobs=d.get("logprobs"), + probabilities=d.get("probabilities"), + extensions=d.get("extensions"), + ) + + +# ─── Logprobs ──────────────────────────────────────────────────────── + +def _top_logprob_to_dict(t: TopLogprob) -> dict[str, Any]: + # token and logprob are required — "" and 0.0 are data, not emptiness, + # so they bypass the omission rule. + out: dict[str, Any] = {"token": t.token, "logprob": t.logprob} + if t.bytes is not None: + out["bytes"] = list(t.bytes) + if t.token_id is not None: + out["token_id"] = t.token_id + return out + + +def token_logprob_to_dict(t: TokenLogprob) -> dict[str, Any]: + out = _top_logprob_to_dict(TopLogprob(token=t.token, logprob=t.logprob, bytes=t.bytes, token_id=t.token_id)) + if t.top: + out["top"] = [_top_logprob_to_dict(x) for x in t.top] + return out + + +def token_logprob_from_dict(d: dict[str, Any]) -> TokenLogprob: + return TokenLogprob( + token=d["token"], + logprob=d["logprob"], + bytes=tuple(d["bytes"]) if d.get("bytes") is not None else None, + token_id=d.get("token_id"), + top=tuple( + TopLogprob( + token=x["token"], + logprob=x["logprob"], + bytes=tuple(x["bytes"]) if x.get("bytes") is not None else None, + token_id=x.get("token_id"), + ) + for x in d.get("top", []) + ), + ) + + +def _logprobs_to_json(logprobs: tuple[TokenLogprob, ...] | None) -> list[dict[str, Any]] | None: + if not logprobs: + return None + return [token_logprob_to_dict(t) for t in logprobs] + + +def _logprobs_from_json(value: Any) -> tuple[TokenLogprob, ...] | None: + if value is None: + return None + return tuple(token_logprob_from_dict(x) for x in value) + + +# ─── ErrorDetail ───────────────────────────────────────────────────── + +def error_detail_to_dict(e: ErrorDetail) -> dict[str, Any]: + return _clean_mapping({ + "code": e.code, + "message": e.message, + "provider_code": e.provider_code, + "http_response": e.http_response, + }) + + +def error_detail_from_dict(d: dict[str, Any]) -> ErrorDetail: + return ErrorDetail( + code=d["code"], + message=d.get("message", ""), + provider_code=d.get("provider_code"), + http_response=d.get("http_response", {}), + ) + + +# ─── Delta ─────────────────────────────────────────────────────────── + +def delta_to_dict(d: Delta) -> dict[str, Any]: + out: dict[str, Any] = { + "type": d.type, + "part_index": d.part_index, + } + + if isinstance(d, TextDelta): + out["text"] = d.text + if d.logprobs: + out["logprobs"] = _logprobs_to_json(d.logprobs) + if not d.logprobs_complete: + out["logprobs_complete"] = False + elif isinstance(d, ThinkingDelta): + out["text"] = d.text + elif isinstance(d, AudioDelta): + out["data"] = d.data + out["url"] = d.url + out["file_id"] = d.file_id + out["media_type"] = d.media_type + elif isinstance(d, ImageDelta): + out["data"] = d.data + out["url"] = d.url + out["file_id"] = d.file_id + out["media_type"] = d.media_type + elif isinstance(d, ToolCallDelta): + out["input"] = d.input + out["id"] = d.id + out["name"] = d.name + elif isinstance(d, CitationDelta): + out["text"] = d.text + out["url"] = d.url + out["title"] = d.title + elif isinstance(d, ContinuationDelta): + out["provider"] = d.provider + out["kind"] = d.kind + out["data"] = d.data + out["part_index"] = d.part_index + else: + raise TypeError(f"unsupported delta type: {type(d)}") + + return {key: value for key, value in out.items() if value is not None} + + +def delta_from_dict(d: dict[str, Any]) -> Delta: + t = d["type"] + part_index = d.get("part_index", 0) + + if t == "text": + return TextDelta( + text=d.get("text", ""), + part_index=part_index, + logprobs=_logprobs_from_json(d.get("logprobs")) or (), + logprobs_complete=d.get("logprobs_complete", True), + ) + if t == "thinking": + return ThinkingDelta(text=d.get("text", ""), part_index=part_index) + if t == "audio": + return AudioDelta( + data=d.get("data"), + url=d.get("url"), + file_id=d.get("file_id"), + part_index=part_index, + media_type=d.get("media_type"), + ) + if t == "image": + return ImageDelta( + data=d.get("data"), + url=d.get("url"), + file_id=d.get("file_id"), + part_index=part_index, + media_type=d.get("media_type"), + ) + if t == "tool_call": + return ToolCallDelta( + input=d.get("input", ""), + part_index=part_index, + id=d.get("id"), + name=d.get("name"), + ) + if t == "citation": + return CitationDelta( + text=d.get("text"), + url=d.get("url"), + title=d.get("title"), + part_index=part_index, + ) + if t == "continuation": + return ContinuationDelta( + provider=d["provider"], + kind=d["kind"], + data=d.get("data", {}), + part_index=d.get("part_index"), + ) + + raise ValueError(f"unsupported delta type: {t}") + + +# ─── Usage ─────────────────────────────────────────────────────────── + +def usage_to_dict(u: Usage) -> dict[str, Any]: + return _clean_mapping({ + "input_tokens": u.input_tokens, + "output_tokens": u.output_tokens, + "total_tokens": u.total_tokens, + "cache_read_tokens": u.cache_read_tokens, + "cache_write_tokens": u.cache_write_tokens, + "reasoning_tokens": u.reasoning_tokens, + "input_audio_tokens": u.input_audio_tokens, + "output_audio_tokens": u.output_audio_tokens, + }) + + +def usage_from_dict(d: dict[str, Any]) -> Usage: + return Usage( + input_tokens=d.get("input_tokens"), + output_tokens=d.get("output_tokens"), + total_tokens=d.get("total_tokens"), + cache_read_tokens=d.get("cache_read_tokens"), + cache_write_tokens=d.get("cache_write_tokens"), + reasoning_tokens=d.get("reasoning_tokens"), + input_audio_tokens=d.get("input_audio_tokens"), + output_audio_tokens=d.get("output_audio_tokens"), + ) + + +# ─── StreamEvent ───────────────────────────────────────────────────── + +def stream_event_to_dict(e: StreamEvent) -> dict[str, Any]: + if isinstance(e, StreamStartEvent): + return _clean_mapping({ + "type": e.type, "id": e.id, "model": e.model, + "adaptations": [adaptation_to_dict(a) for a in e.adaptations] or None, + }) + if isinstance(e, StreamDeltaEvent): + return {"type": e.type, "delta": delta_to_dict(e.delta)} + if isinstance(e, StreamEndEvent): + return _clean_mapping({ + "type": e.type, + "finish_reason": e.finish_reason, + "usage": usage_to_dict(e.usage) if e.usage else None, + "provider_data": e.provider_data, + }) + if isinstance(e, StreamErrorEvent): + return {"type": e.type, "error": error_detail_to_dict(e.error)} + raise TypeError(f"unsupported stream event type: {type(e)}") + + +def stream_event_from_dict(d: dict[str, Any]) -> StreamEvent: + t = d["type"] + if t == "start": + return StreamStartEvent( + id=d.get("id"), model=d.get("model"), + adaptations=tuple(adaptation_from_dict(a) for a in d.get("adaptations") or ()), + ) + if t == "delta": + return StreamDeltaEvent(delta=delta_from_dict(d["delta"])) + if t == "end": + return StreamEndEvent( + finish_reason=d.get("finish_reason"), + usage=usage_from_dict(d["usage"]) if isinstance(d.get("usage"), dict) else None, + provider_data=d.get("provider_data"), + ) + if t == "error": + return StreamErrorEvent(error=error_detail_from_dict(d["error"])) + raise ValueError(f"unsupported stream event type: {t}") + + +# ─── Request / Response ───────────────────────────────────────────── + +def request_to_dict(r: Request) -> dict[str, Any]: + system: Any + if isinstance(r.system, tuple): + system = [part_to_dict(p) for p in r.system] + else: + system = r.system + return _clean_mapping({ + "model": r.model, + "messages": [message_to_dict(m) for m in r.messages], + "system": system, + "tools": [tool_to_dict(t) for t in r.tools], + "config": config_to_dict(r.config), + }) + + +def request_from_dict(d: dict[str, Any]) -> Request: + raw_system = d.get("system") + system: str | tuple[Part, ...] | None + if isinstance(raw_system, list): + system = tuple(part_from_dict(x) for x in raw_system) + else: + system = raw_system + return Request( + model=d["model"], + messages=tuple(message_from_dict(m) for m in d["messages"]), + system=system, + tools=tuple(tool_from_dict(t) for t in d.get("tools", [])), + config=config_from_dict(d.get("config", {})), + ) + + +def response_to_dict(r: Response, *, include_provider_data: bool = False) -> dict[str, Any]: + out: dict[str, Any] = { + "id": r.id, + "model": r.model, + "message": message_to_dict(r.message), + "finish_reason": r.finish_reason, + "usage": usage_to_dict(r.usage), + "logprobs": _logprobs_to_json(r.logprobs), + "logprobs_complete": None if r.logprobs_complete else False, + "adaptations": [adaptation_to_dict(a) for a in r.adaptations] or None, + } + if include_provider_data and r.provider_data is not None: + out["provider_data"] = r.provider_data + return _clean_mapping(out) + + +def response_from_dict(d: dict[str, Any]) -> Response: + return Response( + id=d.get("id"), + model=d["model"], + message=message_from_dict(d["message"]), + finish_reason=d["finish_reason"], + usage=usage_from_dict(d["usage"]) if isinstance(d.get("usage"), dict) else Usage(), + logprobs=_logprobs_from_json(d.get("logprobs")), + logprobs_complete=d.get("logprobs_complete", True), + provider_data=d.get("provider_data"), + adaptations=tuple(adaptation_from_dict(a) for a in d.get("adaptations") or ()), + ) + + +# ─── Batch ───────────────────────────────────────────────────────────── + +def batch_request_to_dict(b: BatchRequest) -> dict[str, Any]: + return _clean_mapping({ + "model": b.model, + "requests": [request_to_dict(r) for r in b.requests], + "label": b.label, + "extensions": b.extensions, + }) + + +def batch_request_from_dict(d: dict[str, Any]) -> BatchRequest: + return BatchRequest( + model=d.get("model"), + requests=tuple(request_from_dict(r) for r in d.get("requests", [])), + label=d.get("label"), + extensions=d.get("extensions"), + ) + + +def batch_job_to_dict(j: BatchJobInfo) -> dict[str, Any]: + return _clean_mapping({ + "id": j.id, + "status": j.status, + "label": j.label, + "created_at": j.created_at, + "provider_data": j.provider_data, + }) + + +def batch_job_from_dict(d: dict[str, Any]) -> BatchJobInfo: + return BatchJobInfo( + id=d["id"], + status=d["status"], + label=d.get("label"), + created_at=d.get("created_at"), + provider_data=d.get("provider_data"), + ) + + +def batch_entry_to_dict(e: BatchEntry) -> dict[str, Any]: + return _clean_mapping({ + "index": e.index, + "outcome": e.outcome, + "response": response_to_dict(e.response, include_provider_data=True) if e.response else None, + "error": error_detail_to_dict(e.error) if e.error else None, + }) + + +def batch_entry_from_dict(d: dict[str, Any]) -> BatchEntry: + return BatchEntry( + index=d["index"], + outcome=d["outcome"], + response=response_from_dict(d["response"]) if isinstance(d.get("response"), dict) else None, + error=error_detail_from_dict(d["error"]) if isinstance(d.get("error"), dict) else None, + ) + + +# ─── Files ───────────────────────────────────────────────────────── + +def file_upload_request_to_dict(r: FileUploadRequest) -> dict[str, Any]: + """Bytes travel as base64 (the media-part precedent); a path-backed + request serializes its path as a plain string, exactly like + path-addressed media Parts — meaningful only where the filesystem is + shared, which is the same caveat Parts already carry.""" + import base64 + + return _clean_mapping({ + "filename": r.filename, + "bytes_data": base64.b64encode(r.bytes_data).decode("ascii") if r.bytes_data is not None else None, + "media_type": r.media_type, + "extensions": r.extensions, + "path": str(r.path) if r.path is not None else None, + }) + + +def file_upload_request_from_dict(d: dict[str, Any]) -> FileUploadRequest: + import base64 + + raw = d.get("bytes_data") + return FileUploadRequest( + filename=d["filename"], + bytes_data=base64.b64decode(raw) if isinstance(raw, str) else None, + media_type=d.get("media_type", "application/octet-stream"), + extensions=d.get("extensions"), + path=d.get("path"), + ) + + +def file_info_to_dict(f: FileInfo) -> dict[str, Any]: + return _clean_mapping({ + "id": f.id, + "filename": f.filename, + "media_type": f.media_type, + "size_bytes": f.size_bytes, + "created_at": f.created_at, + "expires_at": f.expires_at, + "readiness": f.readiness, + "downloadable": f.downloadable, + "provider_data": f.provider_data, + }) + + +def file_info_from_dict(d: dict[str, Any]) -> FileInfo: + return FileInfo( + id=d["id"], + filename=d.get("filename"), + media_type=d.get("media_type"), + size_bytes=d.get("size_bytes"), + created_at=d.get("created_at"), + expires_at=d.get("expires_at"), + readiness=d.get("readiness", "ready"), + downloadable=d.get("downloadable"), + provider_data=d.get("provider_data"), + ) + + +def file_page_to_dict(p: FilePage) -> dict[str, Any]: + return _clean_mapping({ + "items": [file_info_to_dict(f) for f in p.items], + "next_cursor": p.next_cursor, + }) + + +def file_page_from_dict(d: dict[str, Any]) -> FilePage: + return FilePage( + items=tuple(file_info_from_dict(f) for f in d.get("items", [])), + next_cursor=d.get("next_cursor"), + ) + + +# ─── Media generation ──────────────────────────────────────────────── + +def image_generation_request_to_dict(r: ImageGenerationRequest) -> dict[str, Any]: + return _clean_mapping({ + "model": r.model, + "prompt": r.prompt, + "size": r.size, + "images": [part_to_dict(img) for img in r.images] or None, + "extensions": r.extensions, + }) + + +def image_generation_request_from_dict(d: dict[str, Any]) -> ImageGenerationRequest: + return ImageGenerationRequest( + model=d["model"], + prompt=d["prompt"], + size=d.get("size"), + images=tuple(part_from_dict(img) for img in d.get("images", [])), + extensions=d.get("extensions"), + ) + + +def image_generation_response_to_dict(r: ImageGenerationResponse) -> dict[str, Any]: + return _clean_mapping({ + "images": [part_to_dict(img) for img in r.images], + "text": r.text, + "id": r.id, + "model": r.model, + "usage": usage_to_dict(r.usage) or None, + "provider_data": r.provider_data, + }) + + +def image_generation_response_from_dict(d: dict[str, Any]) -> ImageGenerationResponse: + return ImageGenerationResponse( + images=tuple(part_from_dict(img) for img in d.get("images", [])), + text=d.get("text"), + id=d.get("id"), + model=d.get("model"), + usage=usage_from_dict(d.get("usage") or {}), + provider_data=d.get("provider_data"), + ) + + +def speech_generation_request_to_dict(r: SpeechGenerationRequest) -> dict[str, Any]: + return _clean_mapping({ + "model": r.model, + "prompt": r.prompt, + "voice": r.voice, + "format": r.format, + "extensions": r.extensions, + }) + + +def speech_generation_request_from_dict(d: dict[str, Any]) -> SpeechGenerationRequest: + return SpeechGenerationRequest( + model=d["model"], + prompt=d["prompt"], + voice=d.get("voice"), + format=d.get("format"), + extensions=d.get("extensions"), + ) + + +def speech_generation_response_to_dict(r: SpeechGenerationResponse) -> dict[str, Any]: + return _clean_mapping({ + "audio": part_to_dict(r.audio), + "id": r.id, + "model": r.model, + "usage": usage_to_dict(r.usage) or None, + "provider_data": r.provider_data, + }) + + +def speech_generation_response_from_dict(d: dict[str, Any]) -> SpeechGenerationResponse: + return SpeechGenerationResponse( + audio=part_from_dict(d["audio"]), + id=d.get("id"), + model=d.get("model"), + usage=usage_from_dict(d.get("usage") or {}), + provider_data=d.get("provider_data"), + ) + + +# ─── Video generation ──────────────────────────────────────────────── + +def video_generation_request_to_dict(r: VideoGenerationRequest) -> dict[str, Any]: + return _clean_mapping({ + "model": r.model, + "prompt": r.prompt, + "seconds": r.seconds, + "images": [part_to_dict(img) for img in r.images] or None, + "extensions": r.extensions, + }) + + +def video_generation_request_from_dict(d: dict[str, Any]) -> VideoGenerationRequest: + return VideoGenerationRequest( + model=d["model"], + prompt=d["prompt"], + seconds=d.get("seconds"), + images=tuple(part_from_dict(img) for img in d.get("images", [])), + extensions=d.get("extensions"), + ) + + +def video_job_to_dict(j: VideoJobInfo) -> dict[str, Any]: + return _clean_mapping({ + "id": j.id, + "status": j.status, + "progress": j.progress, + "created_at": j.created_at, + "model": j.model, + "provider_data": j.provider_data, + }) + + +def video_job_from_dict(d: dict[str, Any]) -> VideoJobInfo: + return VideoJobInfo( + id=d["id"], + status=d["status"], + progress=d.get("progress"), + created_at=d.get("created_at"), + model=d.get("model"), + provider_data=d.get("provider_data"), + ) + + +# ─── ModelInfo ─────────────────────────────────────────────────────── + +def _inference_pricing_to_dict(p: InferencePricing) -> dict[str, Any]: + return _clean_mapping({ + "input_per_million": p.input_per_million, + "output_per_million": p.output_per_million, + "cache_read_per_million": p.cache_read_per_million, + "cache_write_per_million": p.cache_write_per_million, + "currency": p.currency, + "dimensions": p.dimensions, + }) + + +def _inference_pricing_from_dict(d: dict[str, Any]) -> InferencePricing: + return InferencePricing( + input_per_million=d.get("input_per_million"), + output_per_million=d.get("output_per_million"), + cache_read_per_million=d.get("cache_read_per_million"), + cache_write_per_million=d.get("cache_write_per_million"), + currency=d.get("currency", "USD"), + dimensions=d.get("dimensions"), + ) + + +def _inference_model_info_to_dict(i: InferenceModelInfo) -> dict[str, Any]: + return _clean_mapping({ + "input_modalities": list(i.input_modalities), + "output_modalities": list(i.output_modalities), + "context_window": i.context_window, + "max_output_tokens": i.max_output_tokens, + "supports_reasoning": i.supports_reasoning or None, + "reasoning_efforts": list(i.reasoning_efforts), + "pricing": _inference_pricing_to_dict(i.pricing) if i.pricing else None, + "extensions": i.extensions, + }) + + +def _inference_model_info_from_dict(d: dict[str, Any]) -> InferenceModelInfo: + return InferenceModelInfo( + input_modalities=tuple(d.get("input_modalities", ["text"])), + output_modalities=tuple(d.get("output_modalities", ["text"])), + context_window=d.get("context_window"), + max_output_tokens=d.get("max_output_tokens"), + supports_reasoning=d.get("supports_reasoning", False), + reasoning_efforts=tuple(d.get("reasoning_efforts", [])), + pricing=_inference_pricing_from_dict(d["pricing"]) if isinstance(d.get("pricing"), dict) else None, + extensions=d.get("extensions"), + ) + + +def _model_origin_to_dict(o: ModelOrigin) -> dict[str, Any]: + return _clean_mapping({ + "type": o.type, + "id": o.id, + "base_model": o.base_model, + "provider_data": o.provider_data, + }) + + +def _model_origin_from_dict(d: dict[str, Any]) -> ModelOrigin: + return ModelOrigin( + type=d.get("type", "provider"), + id=d.get("id"), + base_model=d.get("base_model"), + provider_data=d.get("provider_data"), + ) + + +def model_info_to_dict(m: ModelInfo) -> dict[str, Any]: + origin = _model_origin_to_dict(m.origin) + if origin == {"type": "provider"}: # the default origin carries no information + origin = {} + return _clean_mapping({ + "id": m.id, + "provider": m.provider, + "api_family": m.api_family, + "aliases": list(m.aliases), + "origin": origin, + "inference": _inference_model_info_to_dict(m.inference) if m.inference else None, + "extensions": m.extensions, + }) + + +def model_info_from_dict(d: dict[str, Any]) -> ModelInfo: + return ModelInfo( + id=d["id"], + provider=d["provider"], + api_family=d["api_family"], + aliases=tuple(d.get("aliases", [])), + origin=_model_origin_from_dict(d["origin"]) if isinstance(d.get("origin"), dict) else ModelOrigin(), + inference=_inference_model_info_from_dict(d["inference"]) if isinstance(d.get("inference"), dict) else None, + extensions=d.get("extensions"), + ) + + +# ─── AudioFormat ───────────────────────────────────────────────────── + +def audio_format_to_dict(af: AudioFormat) -> dict[str, Any]: + return _clean_mapping({ + "encoding": af.encoding, + "sample_rate": af.sample_rate, + "channels": af.channels, + }) + + +def audio_format_from_dict(d: dict[str, Any]) -> AudioFormat: + return AudioFormat( + encoding=d["encoding"], + sample_rate=d["sample_rate"], + channels=d.get("channels", 1), + ) + + +# ─── LiveConfig ────────────────────────────────────────────────────── + +def live_config_to_dict(lc: LiveConfig) -> dict[str, Any]: + system: Any + if isinstance(lc.system, tuple): + system = [part_to_dict(p) for p in lc.system] + else: + system = lc.system + return _clean_mapping({ + "model": lc.model, + "system": system, + "tools": [tool_to_dict(t) for t in lc.tools], + "voice": lc.voice, + "input_format": audio_format_to_dict(lc.input_format) if lc.input_format else None, + "output_format": audio_format_to_dict(lc.output_format) if lc.output_format else None, + "extensions": lc.extensions, + }) + + +def live_config_from_dict(d: dict[str, Any]) -> LiveConfig: + raw_system = d.get("system") + system: str | tuple[Part, ...] | None + if isinstance(raw_system, list): + system = tuple(part_from_dict(x) for x in raw_system) + else: + system = raw_system + return LiveConfig( + model=d["model"], + system=system, + tools=tuple(tool_from_dict(t) for t in d.get("tools", [])), + voice=d.get("voice"), + input_format=audio_format_from_dict(d["input_format"]) if isinstance(d.get("input_format"), dict) else None, + output_format=audio_format_from_dict(d["output_format"]) if isinstance(d.get("output_format"), dict) else None, + extensions=d.get("extensions"), + ) + + +# ─── LiveClientEvent / LiveServerEvent ─────────────────────────────── + +def live_client_event_to_dict(e: LiveClientEvent) -> dict[str, Any]: + if isinstance(e, LiveClientTurnEvent): + return {"type": e.type, "parts": [part_to_dict(p) for p in e.parts], "turn_complete": e.turn_complete} + if isinstance(e, (LiveClientAudioEvent, LiveClientImageEvent)): + return {"type": e.type, "data": e.data, "media_type": e.media_type} + if isinstance(e, LiveClientTextEvent): + return {"type": e.type, "text": e.text} + if isinstance(e, LiveClientToolResultEvent): + return {"type": e.type, "id": e.id, "content": [part_to_dict(p) for p in e.content]} + if isinstance(e, (LiveClientInterruptEvent, LiveClientEndAudioEvent)): + return {"type": e.type} + raise TypeError(f"unsupported live client event type: {type(e)}") + + +def live_client_event_from_dict(d: dict[str, Any]) -> LiveClientEvent: + t = d["type"] + if t == "turn": + return LiveClientTurnEvent( + parts=tuple(part_from_dict(p) for p in d.get("parts", [])), + turn_complete=d.get("turn_complete", True), + ) + if t == "audio": + return LiveClientAudioEvent(data=d["data"], media_type=d.get("media_type", "audio/pcm;rate=16000")) + if t == "image": + return LiveClientImageEvent(data=d["data"], media_type=d.get("media_type", "image/jpeg")) + if t == "text": + return LiveClientTextEvent(text=d.get("text", "")) + if t == "tool_result": + return LiveClientToolResultEvent( + id=d["id"], + content=tuple(part_from_dict(p) for p in d.get("content", [])), + ) + if t == "interrupt": + return LiveClientInterruptEvent() + if t == "end_audio": + return LiveClientEndAudioEvent() + raise ValueError(f"unsupported live client event type: {t}") + + +def live_server_event_to_dict(e: LiveServerEvent) -> dict[str, Any]: + if isinstance(e, LiveServerAudioEvent): + return _clean_mapping({"type": e.type, "data": e.data, "media_type": e.media_type}) + if isinstance(e, LiveServerTextEvent): + return {"type": e.type, "text": e.text} + if isinstance(e, LiveServerToolCallEvent): + return {"type": e.type, "id": e.id, "name": e.name, "input": e.input} + if isinstance(e, LiveServerToolCallDeltaEvent): + return _clean_mapping({ + "type": e.type, + "id": e.id, + "name": e.name, + "input_delta": e.input_delta, + }) + if isinstance(e, LiveServerInterruptedEvent): + return {"type": e.type} + if isinstance(e, LiveServerTurnEndEvent): + return _clean_mapping({"type": e.type, "usage": usage_to_dict(e.usage)}) + if isinstance(e, LiveServerUsageEvent): + return _clean_mapping({"type": e.type, "usage": usage_to_dict(e.usage)}) + if isinstance(e, LiveServerErrorEvent): + return {"type": e.type, "error": error_detail_to_dict(e.error)} + raise TypeError(f"unsupported live server event type: {type(e)}") + + +def live_server_event_from_dict(d: dict[str, Any]) -> LiveServerEvent: + t = d["type"] + if t == "audio": + return LiveServerAudioEvent(data=d["data"], media_type=d.get("media_type")) + if t == "text": + return LiveServerTextEvent(text=d.get("text", "")) + if t == "tool_call": + return LiveServerToolCallEvent(id=d["id"], name=d["name"], input=d.get("input", {})) + if t == "tool_call_delta": + return LiveServerToolCallDeltaEvent( + input_delta=d.get("input_delta", ""), id=d.get("id"), name=d.get("name") + ) + if t == "interrupted": + return LiveServerInterruptedEvent() + if t == "turn_end": + return LiveServerTurnEndEvent(usage=usage_from_dict(d.get("usage", {}))) + if t == "usage": + return LiveServerUsageEvent(usage=usage_from_dict(d.get("usage", {}))) + if t == "error": + return LiveServerErrorEvent(error=error_detail_from_dict(d["error"])) + raise ValueError(f"unsupported live server event type: {t}") diff --git a/sse.py b/sse.py new file mode 100644 index 0000000000..93f99e5923 --- /dev/null +++ b/sse.py @@ -0,0 +1,105 @@ +""" +lm15.sse — Server-Sent Events parser. + +Parses a byte-line iterator (from a streaming HTTP response) into +typed SSEEvent objects. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import AsyncIterator, Iterator + +from .errors import TransportError + + +@dataclass(frozen=True, slots=True) +class SSEEvent: + event: str | None + data: str + + +def parse_sse( + lines: Iterator[bytes], + *, + max_line_bytes: int = 64 * 1024, + max_event_bytes: int = 1024 * 1024, +) -> Iterator[SSEEvent]: + """Parse SSE byte lines into events.""" + event_name: str | None = None + data_lines: list[str] = [] + event_bytes = 0 + + for raw in lines: + if len(raw) > max_line_bytes: + raise TransportError(f"SSE line exceeds limit ({len(raw)} > {max_line_bytes})") + + line = raw.decode("utf-8", errors="replace").rstrip("\r\n") + event_bytes += len(raw) + if event_bytes > max_event_bytes: + raise TransportError(f"SSE event exceeds limit ({event_bytes} > {max_event_bytes})") + + if line == "": + if data_lines: + yield SSEEvent(event=event_name, data="\n".join(data_lines)) + event_name = None + data_lines = [] + event_bytes = 0 + continue + + if line.startswith(":"): + continue + if line.startswith("event:"): + event_name = line[len("event:"):].strip() + continue + if line.startswith("data:"): + data_lines.append(line[len("data:"):].lstrip()) + continue + + if data_lines: + yield SSEEvent(event=event_name, data="\n".join(data_lines)) + + +async def aparse_sse( + lines: AsyncIterator[bytes], + *, + max_line_bytes: int = 64 * 1024, + max_event_bytes: int = 1024 * 1024, +) -> AsyncIterator[SSEEvent]: + """Async mirror of :func:`parse_sse` over an async byte-line iterator. + + Same field grammar and limits; the only difference is ``async for`` over + the line source. + """ + event_name: str | None = None + data_lines: list[str] = [] + event_bytes = 0 + + async for raw in lines: + if len(raw) > max_line_bytes: + raise TransportError(f"SSE line exceeds limit ({len(raw)} > {max_line_bytes})") + + line = raw.decode("utf-8", errors="replace").rstrip("\r\n") + event_bytes += len(raw) + if event_bytes > max_event_bytes: + raise TransportError(f"SSE event exceeds limit ({event_bytes} > {max_event_bytes})") + + if line == "": + if data_lines: + yield SSEEvent(event=event_name, data="\n".join(data_lines)) + event_name = None + data_lines = [] + event_bytes = 0 + continue + + if line.startswith(":"): + continue + if line.startswith("event:"): + event_name = line[len("event:"):].strip() + continue + if line.startswith("data:"): + data_lines.append(line[len("data:"):].lstrip()) + continue + + if data_lines: + yield SSEEvent(event=event_name, data="\n".join(data_lines)) diff --git a/testing.py b/testing.py new file mode 100644 index 0000000000..dd67a1fb4a --- /dev/null +++ b/testing.py @@ -0,0 +1,168 @@ +""" +lm15.testing — shipped test doubles for code built on lm15. + +Two seams, pick by what you are testing: + +- :class:`FakeLM` — a canonical-level provider double. Script it with + ``Response`` objects (or plain strings, or exceptions) and it answers + ``complete()`` / ``stream()`` with them, streaming via + ``response_to_events``. No wire format involved, works wherever a + provider LM does — the right seam for testing tool loops, retries, + and everything above the wire. +- :class:`FakeTransport` / :class:`FakeResponse` — a wire-level double. + Inject it as any adapter's ``transport`` and the REAL adapter serde + runs both directions against your scripted HTTP bytes; the fake + records every ``TransportRequest`` so one fixture asserts behavior + AND wire format. The right seam for testing provider dialects. + +Both record the requests they serve. Neither performs any I/O. + + from lm15.testing import FakeLM + lm = FakeLM(responses=["hello"]) + assert lm.complete(request).text == "hello" + +The router accepts a transport too — ``RouterConfig(transport=...)`` — +so routed code paths are testable without touching the LM cache. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, ClassVar, Iterator, Sequence + +from .features import EndpointSupport, ProviderManifest +from .result import response_to_events +from .types import ( + Message, + Request, + Response, + StreamEvent, + TextPart, + Usage, +) + +__all__ = ["FakeLM", "FakeTransport", "FakeResponse"] + + +@dataclass +class FakeResponse: + """A scripted wire response served by :class:`FakeTransport`. + + ``body`` is the exact bytes the adapter will parse; ``chunks`` (when + given) is what streaming iteration yields instead — SSE tests script + the frames there. + """ + + status: int + body: bytes + headers: list[tuple[str, str]] | None = None + reason: str = "OK" + http_version: str = "HTTP/1.1" + chunks: list[bytes] | None = None + + def __post_init__(self) -> None: + if self.headers is None: + self.headers = [("content-type", "application/json")] + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def __iter__(self) -> Iterator[bytes]: + yield from (self.chunks if self.chunks is not None else [self.body]) + + def read(self) -> bytes: + return b"".join(iter(self)) + + def header(self, name: str) -> str | None: + lname = name.lower() + for key, value in self.headers or []: + if key.lower() == lname: + return value + return None + + +class FakeTransport: + """Wire-level double: serves scripted :class:`FakeResponse` objects + in order and records every request in ``.requests``. + + Script an ``Exception`` instance instead of a response and it is + raised at request time (dropped-socket tests: script + ``lm15.transports.TransportError``). + """ + + def __init__(self, responses: Sequence[FakeResponse | Exception] | None = None) -> None: + self.responses: list[FakeResponse | Exception] = list(responses or []) + self.requests: list[Any] = [] + + def stream(self, request: Any) -> FakeResponse: + self.requests.append(request) + assert self.responses, ( + "FakeTransport ran out of scripted responses " + f"(served {len(self.requests) - 1}, then got another request)" + ) + item = self.responses.pop(0) + if isinstance(item, Exception): + raise item + return item + + +def _as_response(item: "Response | str") -> Response: + if isinstance(item, Response): + return item + return Response( + id="fake", + model="fake", + message=Message.assistant(TextPart(str(item))), + finish_reason="stop", + usage=Usage(), + ) + + +class FakeLM: + """Canonical-level provider double. + + Scripted with ``Response`` objects, plain strings (shorthand for a + text response), or ``Exception`` instances (raised in order). + ``stream()`` replays the same scripted response through + ``response_to_events``, so streaming code paths see exactly the + events a real adapter would emit for that response. + """ + + provider: str = "fake" + supports: ClassVar[EndpointSupport] = EndpointSupport(complete=True, stream=True) + manifest: ClassVar[ProviderManifest] = ProviderManifest( + provider="fake", supports=supports, auth_modes=(), env_keys=(), + ) + + def __init__( + self, + responses: Sequence["Response | str | Exception"] = (), + *, + provider: str = "fake", + ) -> None: + self.provider = provider + self._script: list[Any] = list(responses) + self.requests: list[Request] = [] + + def _next(self, request: Request) -> Response: + self.requests.append(request) + assert self._script, ( + "FakeLM ran out of scripted responses " + f"(served {len(self.requests) - 1}, then got another request)" + ) + item = self._script.pop(0) + if isinstance(item, Exception): + raise item + return _as_response(item) + + def complete(self, request: Request) -> Response: + return self._next(request) + + def stream(self, request: Request) -> Iterator[StreamEvent]: + return response_to_events(self._next(request)) + + def close(self) -> None: # symmetric with real adapters + return None diff --git a/tools.py b/tools.py new file mode 100644 index 0000000000..8fee390f28 --- /dev/null +++ b/tools.py @@ -0,0 +1,725 @@ +""" +lm15.tools — derive a FunctionTool from a Python callable. + +Pure functions only. ``tool(fn)`` reads *fn*'s signature, type hints, and +docstring, and returns an ordinary, frozen :class:`lm15.FunctionTool`. +Nothing is registered, cached, wrapped, or executed. The JSON-Schema path +remains primary and canonical: this module is sugar that *produces* that +JSON. The wire format of FunctionTool is untouched (INV-033 intact). + +Design notes: + +* Derivation is eager: errors surface at definition time, never at request + time — matching the ``__post_init__`` validation culture of lm15.types. +* Soft where the input is prose, hard where the input is types: docstring + parsing is best-effort (missing descriptions are never errors), while + type-hint mapping raises :class:`ToolDerivationError` rather than guess. +* ``Optional[X]`` / ``X | None`` is about VALUE nullability and maps to + ``anyOf`` with ``{"type": "null"}``. Required-ness comes solely from the + presence of a default. The two axes are orthogonal. +* No ``$ref`` / recursive schemas in v1: nested dataclasses and TypedDicts + are inlined, and cycles raise ToolDerivationError. Adding ``$ref`` later + is therefore an extension, not a behavior change. +* Dispatch stays in user hands — lm15 never executes tools: + + handlers = {fn.__name__: fn for fn in (get_weather,)} + result = handlers[call.name](**call.input) + +Stdlib only: inspect, typing, dataclasses, enum, types. +""" + +from __future__ import annotations + +import collections.abc as _collections_abc +import dataclasses +import enum +import inspect +import math +import types as _types +import typing +from dataclasses import dataclass, field +from typing import Any, Callable, Literal, Union, get_args, get_origin, get_type_hints + +from .errors import LM15Error +from .types import FunctionTool, JsonObject, JsonValue + +__all__ = [ + "ToolDerivationError", + "ToolConfig", + "DerivedParam", + "ToolDerivation", + "tool", + "derive", +] + +_ESCAPE_HATCH = ( + "; override this parameter via ToolConfig(overrides=...) or pass an " + "explicit FunctionTool with hand-written parameters" +) + + +class ToolDerivationError(LM15Error): + """Raised when a callable cannot be derived into a FunctionTool. + + Always names the function and, when applicable, the offending parameter + and the unsupported annotation, plus both escape hatches: a per-parameter + ``ToolConfig(overrides=...)`` entry, or a hand-written FunctionTool. + """ + + default_code = "tool_derivation" + + +@dataclass(frozen=True, slots=True) +class ToolConfig: + """Options for :func:`tool` / :func:`derive`. + + Explicit over magic: every knob is visible. + + docstring_style: ``"auto"`` tries Google, NumPy, then Sphinx + (``:param x:``) markers pragmatically; or pin one style; ``"none"`` + skips parameter descriptions entirely. + include_defaults: emit JSON-compatible default values as ``"default"`` + in the property schema. Informational only; never affects + required-ness. Non-JSON defaults are silently skipped (documented). + additional_properties_false: emit ``"additionalProperties": false`` on + the top-level object. Off by default (provider-neutral); some + providers' strict tool modes want this — see the cookbook caveat + about strict mode also requiring all properties to be required. + overrides: per-parameter raw JSON-Schema fragments used *in place of* + derivation for the named parameter, e.g. + ``(("when", {"type": "string", "format": "date-time"}),)``. + A surgical escape hatch: one un-derivable parameter no longer forces + a fully hand-written FunctionTool. Stored as a tuple of + ``(param_name, schema)`` pairs so ToolConfig stays a plain frozen + dataclass; a docstring description is still merged in when the + override fragment has no ``"description"`` of its own. + """ + + docstring_style: Literal["auto", "google", "numpy", "sphinx", "none"] = "auto" + include_defaults: bool = True + additional_properties_false: bool = False + overrides: tuple[tuple[str, JsonObject], ...] = () + + def __post_init__(self) -> None: + if self.docstring_style not in ("auto", "google", "numpy", "sphinx", "none"): + raise ValueError( + "ToolConfig.docstring_style must be one of " + "'auto', 'google', 'numpy', 'sphinx', 'none'" + ) + if not isinstance(self.overrides, tuple): + raise TypeError( + "ToolConfig.overrides must be a tuple of (name, schema) pairs" + ) + for entry in self.overrides: + if ( + not isinstance(entry, tuple) + or len(entry) != 2 + or not isinstance(entry[0], str) + or not isinstance(entry[1], dict) + ): + raise TypeError( + "ToolConfig.overrides entries must be (str, dict) pairs" + ) + + +@dataclass(frozen=True, slots=True) +class DerivedParam: + """Explainable record of how one parameter was derived.""" + + name: str + annotation: str # repr of the hint, e.g. "list[int] | None" + schema: JsonObject # the JSON-Schema fragment produced + required: bool + description: str | None # from docstring, if found + source: Literal["hint", "hint+docstring", "override", "override+docstring"] + + +@dataclass(frozen=True, slots=True) +class ToolDerivation: + """Full, typed account of a derivation — :func:`tool`'s explain() analogue. + + Python-only diagnostic surface (other lm15 languages derive from + idiomatic sources — struct tags, macros, schema literals — and do not + mirror this type). + """ + + tool: FunctionTool + fn_qualname: str + params: tuple[DerivedParam, ...] + docstring_style_detected: Literal["google", "numpy", "sphinx", "plain", "none"] + + +# ─── JSON-value check (for defaults and enum/Literal members) ──────── + +def _is_json_value(value: object) -> bool: + if isinstance(value, float): + return math.isfinite(value) # NaN/Infinity are not JSON values + if value is None or isinstance(value, (str, bool, int)): + return True + if isinstance(value, (list, tuple)): + return all(_is_json_value(v) for v in value) + if isinstance(value, dict): + return all(isinstance(k, str) and _is_json_value(v) for k, v in value.items()) + return False + + +def _json_default(value: object) -> JsonValue: + if isinstance(value, tuple): + return [_json_default(v) for v in value] + if isinstance(value, list): + return [_json_default(v) for v in value] + if isinstance(value, dict): + return {k: _json_default(v) for k, v in value.items()} + return value # type: ignore[return-value] + + +# ─── Hint → schema mapping ─────────────────────────────────────────── + +_PRIMITIVES: dict[type, str] = { + str: "string", + int: "integer", + float: "number", + bool: "boolean", +} + +_NONE_TYPE = type(None) + + +def _err(fn_name: str, message: str) -> ToolDerivationError: + return ToolDerivationError( + f"cannot derive '{fn_name}': {message}{_ESCAPE_HATCH}" + ) + + +def _is_typed_dict(hint: object) -> bool: + return isinstance(hint, type) and hasattr(hint, "__required_keys__") and hasattr( + hint, "__optional_keys__" + ) + + +def _schema_for( + hint: object, + *, + fn_name: str, + param: str, + seen: frozenset[object], +) -> JsonObject: + """Map one type hint to a JSON-Schema fragment. Conservative: anything + not obviously JSON-Schema-able raises ToolDerivationError.""" + + def fail(detail: str) -> ToolDerivationError: + return _err(fn_name, f"parameter '{param}' {detail}") + + origin = get_origin(hint) + + # Annotated[X, "text"]: first str metadata wins as description. + if origin is typing.Annotated: + base, *metadata = get_args(hint) + schema = _schema_for(base, fn_name=fn_name, param=param, seen=seen) + for meta in metadata: + if isinstance(meta, str): + schema = dict(schema) + schema["description"] = meta + break + return schema + + # Any: explicitly-anything. + if hint is Any: + return {} + + # None / NoneType. + if hint is None or hint is _NONE_TYPE: + return {"type": "null"} + + # Unions (typing.Union and X | Y). + if origin is Union or origin is _types.UnionType: + members = [ + _schema_for(arg, fn_name=fn_name, param=param, seen=seen) + for arg in get_args(hint) + ] + return {"anyOf": members} + + # Literal["a", "b", 3] -> enum (+ type when homogeneous). + if origin is Literal: + values = get_args(hint) + for v in values: + if not (v is None or isinstance(v, (str, bool, int, float))): + raise fail( + f"has Literal value {v!r} that is not JSON-compatible" + ) + schema = {"enum": list(values)} + kinds = {type(v) for v in values} + if len(kinds) == 1: + sole = next(iter(kinds)) + if sole in _PRIMITIVES: + schema["type"] = _PRIMITIVES[sole] + elif sole is _NONE_TYPE: + schema["type"] = "null" + return schema + + # Containers. + if origin in (list, typing.Sequence, _abc_sequence()): + args = get_args(hint) + if not args: # bare typing.List / typing.Sequence + return {"type": "array"} + items = _schema_for(args[0], fn_name=fn_name, param=param, seen=seen) + return {"type": "array", "items": items} + if origin in (set, frozenset): + args = get_args(hint) + if not args: # bare typing.Set / typing.FrozenSet + return {"type": "array", "uniqueItems": True} + items = _schema_for(args[0], fn_name=fn_name, param=param, seen=seen) + return {"type": "array", "items": items, "uniqueItems": True} + if origin is tuple: + args = get_args(hint) + if not args: # bare typing.Tuple + return {"type": "array"} + if len(args) == 2 and args[1] is Ellipsis: + items = _schema_for(args[0], fn_name=fn_name, param=param, seen=seen) + return {"type": "array", "items": items} + raise fail( + f"has fixed-length tuple annotation {_annotation_repr(hint)}; " + "only homogeneous tuple[X, ...] is supported" + ) + if origin in (dict, typing.Mapping, _abc_mapping()): + dict_args = get_args(hint) + if not dict_args: # bare typing.Dict / typing.Mapping + return {"type": "object"} + key_t, value_t = dict_args + if key_t is not str: + raise fail( + f"has dict annotation with non-str keys ({_annotation_repr(hint)}); " + "JSON object keys must be strings" + ) + value_schema = _schema_for(value_t, fn_name=fn_name, param=param, seen=seen) + return {"type": "object", "additionalProperties": value_schema} + + if origin is not None: + raise fail(f"has unsupported annotation {_annotation_repr(hint)}") + + # Bare (unparameterized) types. + if isinstance(hint, type): + if hint in _PRIMITIVES: + # NB: bool before int matters in isinstance checks, but here the + # dict lookup is exact, so bool maps to "boolean" correctly. + return {"type": _PRIMITIVES[hint]} + if hint in (list, tuple): + return {"type": "array"} + if hint in (set, frozenset): + return {"type": "array", "uniqueItems": True} + if hint is dict: + return {"type": "object"} + + # Enum subclass -> enum of member values (values must be JSON). + if issubclass(hint, enum.Enum): + values = [member.value for member in hint] + for v in values: + if not (v is None or isinstance(v, (str, bool, int, float))): + raise fail( + f"uses Enum {hint.__name__!s} whose member value {v!r} " + "is not JSON-compatible" + ) + return {"enum": values} + + # TypedDict -> nested object (no $ref/recursion in v1). + if _is_typed_dict(hint): + if hint in seen: + raise fail( + f"has recursive TypedDict annotation {hint.__name__!s}; " + "recursive ($ref) schemas are not supported in v1" + ) + try: + nested_hints = get_type_hints(hint, include_extras=True) + except Exception as exc: + raise fail( + f"has TypedDict annotation {hint.__name__!s} whose hints " + f"could not be resolved ({exc})" + ) from exc + nested_seen = seen | {hint} + properties: JsonObject = {} + for key, key_hint in nested_hints.items(): + properties[key] = _schema_for( + key_hint, fn_name=fn_name, param=f"{param}.{key}", seen=nested_seen + ) + required = [k for k in nested_hints if k in hint.__required_keys__] + schema = {"type": "object", "properties": properties} + if required: + schema["required"] = required + return schema + + # @dataclass class -> nested object from its fields (same rules). + if dataclasses.is_dataclass(hint): + if hint in seen: + raise fail( + f"has recursive dataclass annotation {hint.__name__!s}; " + "recursive ($ref) schemas are not supported in v1" + ) + try: + nested_hints = get_type_hints(hint, include_extras=True) + except Exception as exc: + raise fail( + f"has dataclass annotation {hint.__name__!s} whose hints " + f"could not be resolved ({exc})" + ) from exc + nested_seen = seen | {hint} + properties = {} + required = [] + for f in dataclasses.fields(hint): + if not f.init: + continue + properties[f.name] = _schema_for( + nested_hints.get(f.name, f.type), + fn_name=fn_name, + param=f"{param}.{f.name}", + seen=nested_seen, + ) + if ( + f.default is dataclasses.MISSING + and f.default_factory is dataclasses.MISSING + ): + required.append(f.name) + schema = {"type": "object", "properties": properties} + if required: + schema["required"] = required + return schema + + raise fail( + f"has unsupported annotation {_annotation_repr(hint)}; annotate with " + "a JSON-compatible type" + ) + + +def _abc_sequence() -> object: + return _collections_abc.Sequence + + +def _abc_mapping() -> object: + return _collections_abc.Mapping + + +def _annotation_repr(hint: object) -> str: + if isinstance(hint, type): + module = getattr(hint, "__module__", "") + if module and module != "builtins": + return f"{module}.{hint.__qualname__}" + return hint.__qualname__ + return repr(hint) + + +# ─── Docstring parsing (pragmatic line-marker detection, not a parser) ─── + +def _docstring_summary(doc: str) -> str | None: + lines = doc.strip().splitlines() + summary: list[str] = [] + for line in lines: + if not line.strip(): + break + summary.append(line.strip()) + return " ".join(summary) if summary else None + + +def _parse_google(doc: str) -> dict[str, str] | None: + lines = doc.splitlines() + out: dict[str, str] = {} + in_args = False + current: str | None = None + section_indent = 0 + for line in lines: + stripped = line.strip() + if stripped in ("Args:", "Arguments:", "Parameters:"): + in_args = True + current = None + continue + if not in_args: + continue + if not stripped: + current = None + continue + indent = len(line) - len(line.lstrip()) + if stripped.endswith(":") and " " not in stripped and indent == 0: + break # next top-level section + if ":" in stripped: + name_part, _, desc = stripped.partition(":") + name = name_part.split("(")[0].strip() + if name.isidentifier(): + out[name] = desc.strip() + current = name + section_indent = indent + continue + if current is not None and indent > section_indent: + out[current] = (out[current] + " " + stripped).strip() + return out or None + + +def _parse_numpy(doc: str) -> dict[str, str] | None: + lines = doc.splitlines() + out: dict[str, str] = {} + in_params = False + current: str | None = None + i = 0 + while i < len(lines): + stripped = lines[i].strip() + if stripped in ("Parameters", "Other Parameters") and i + 1 < len(lines) and set( + lines[i + 1].strip() + ) == {"-"}: + in_params = True + current = None + i += 2 + continue + if in_params: + if stripped and i + 1 < len(lines) and set(lines[i + 1].strip() or ".") == {"-"}: + break # next underlined section + if not stripped: + current = None + else: + indent = len(lines[i]) - len(lines[i].lstrip()) + name = stripped.split(":")[0].strip() + if indent == 0 and name.isidentifier(): + out[name] = "" + current = name + elif current is not None: + out[current] = (out[current] + " " + stripped).strip() + i += 1 + return {k: v for k, v in out.items()} or None + + +def _parse_sphinx(doc: str) -> dict[str, str] | None: + out: dict[str, str] = {} + current: str | None = None + for line in doc.splitlines(): + stripped = line.strip() + if stripped.startswith(":param "): + rest = stripped[len(":param "):] + name_part, _, desc = rest.partition(":") + name = name_part.split()[-1] if name_part.split() else "" + if name.isidentifier(): + out[name] = desc.strip() + current = name + continue + if stripped.startswith(":"): + current = None + continue + if current is not None and stripped: + out[current] = (out[current] + " " + stripped).strip() + elif not stripped: + current = None + return out or None + + +def _parse_docstring( + doc: str | None, style: str +) -> tuple[dict[str, str], Literal["google", "numpy", "sphinx", "plain", "none"]]: + if not doc: + return {}, "none" + if style == "none": + return {}, "plain" + attempts: tuple[tuple[str, Callable[[str], dict[str, str] | None]], ...] + if style == "auto": + attempts = ( + ("google", _parse_google), + ("numpy", _parse_numpy), + ("sphinx", _parse_sphinx), + ) + else: + parser = {"google": _parse_google, "numpy": _parse_numpy, "sphinx": _parse_sphinx}[style] + attempts = ((style, parser),) # type: ignore[assignment] + for detected, parser in attempts: + result = parser(doc) + if result: + return result, detected # type: ignore[return-value] + return {}, "plain" + + +# ─── Public API ────────────────────────────────────────────────────── + +def derive( + fn: Callable[..., Any], + *, + name: str | None = None, + description: str | None = None, + config: ToolConfig = ToolConfig(), +) -> ToolDerivation: + """Derive a FunctionTool from *fn* with a full, typed account. + + Like :func:`tool`, but returns the :class:`ToolDerivation` so users can + inspect exactly how each parameter was mapped (explainability parity + with the explain() ethos). ``tool(fn)`` is sugar for ``derive(fn).tool``. + """ + if not callable(fn): + raise ToolDerivationError( + f"cannot derive tool from non-callable {fn!r}{_ESCAPE_HATCH}" + ) + + fn_name = name + if fn_name is None: + fn_name = getattr(fn, "__name__", None) + if fn_name is None: + raise ToolDerivationError( + f"cannot derive tool from {fn!r}: it has no __name__; " + f"pass name= explicitly{_ESCAPE_HATCH}" + ) + if fn_name == "": + raise ToolDerivationError( + "cannot derive tool from a lambda: '' is not a usable " + f"tool name; pass name= explicitly{_ESCAPE_HATCH}" + ) + + try: + signature = inspect.signature(fn) + except (ValueError, TypeError) as exc: + raise _err( + fn_name, + f"its signature is not introspectable ({exc})", + ) from exc + + try: + hints = get_type_hints(fn, include_extras=True) + except NameError as exc: + raise _err( + fn_name, + f"a type annotation could not be resolved ({exc}); forward " + "references must be importable at derivation time — define the " + "referenced type before calling derive(), or annotate with a " + "resolvable JSON-compatible type", + ) from exc + except Exception as exc: + raise _err( + fn_name, f"type hints could not be resolved ({exc})" + ) from exc + + overrides = dict(config.overrides) + doc = inspect.getdoc(fn) + param_docs, detected = _parse_docstring(doc, config.docstring_style) + + derived: list[DerivedParam] = [] + properties: JsonObject = {} + required: list[str] = [] + + parameters = list(signature.parameters.values()) + # Bound methods and classmethods already have self/cls stripped by + # inspect.signature; an explicit leading self/cls only remains on plain + # unbound functions, where it is treated as an ordinary parameter. + + for p in parameters: + if p.kind is inspect.Parameter.VAR_POSITIONAL: + raise _err( + fn_name, + f"parameter '*{p.name}' is variadic; tool signatures must be " + "fully nameable — wrap the function", + ) + if p.kind is inspect.Parameter.VAR_KEYWORD: + raise _err( + fn_name, + f"parameter '**{p.name}' is variadic; tool signatures must " + "be fully nameable — wrap the function", + ) + if p.kind is inspect.Parameter.POSITIONAL_ONLY: + raise _err( + fn_name, + f"parameter '{p.name}' is positional-only; tool calls " + "dispatch by name — make it keyword-able", + ) + + doc_desc = param_docs.get(p.name) + + if p.name in overrides: + schema = dict(overrides[p.name]) + if doc_desc and "description" not in schema: + schema["description"] = doc_desc + source: Literal[ + "hint", "hint+docstring", "override", "override+docstring" + ] = "override+docstring" if doc_desc else "override" + annotation_repr = ( + _annotation_repr(hints[p.name]) if p.name in hints else "" + ) + else: + if p.name not in hints: + raise _err( + fn_name, + f"parameter '{p.name}' has no type annotation", + ) + hint = hints[p.name] + schema = _schema_for( + hint, fn_name=fn_name, param=p.name, seen=frozenset() + ) + schema = dict(schema) + if doc_desc and "description" not in schema: + schema["description"] = doc_desc + source = "hint+docstring" if doc_desc else "hint" + annotation_repr = _annotation_repr(hint) + + is_required = p.default is inspect.Parameter.empty + if is_required: + required.append(p.name) + elif ( + config.include_defaults + and "default" not in schema + and _is_json_value(p.default) + ): + schema["default"] = _json_default(p.default) + + properties[p.name] = schema + derived.append( + DerivedParam( + name=p.name, + annotation=annotation_repr, + schema=schema, + required=is_required, + description=schema.get("description"), # type: ignore[arg-type] + source=source, + ) + ) + + unknown_overrides = set(overrides) - set(signature.parameters) + if unknown_overrides: + raise _err( + fn_name, + "ToolConfig.overrides names parameters not in the signature: " + + ", ".join(sorted(unknown_overrides)), + ) + + schema_top: JsonObject = {"type": "object", "properties": properties} + if required: + schema_top["required"] = required + if config.additional_properties_false: + schema_top["additionalProperties"] = False + + tool_description = description + if tool_description is None and doc: + tool_description = _docstring_summary(doc) + + derived_tool = FunctionTool( + name=fn_name, + description=tool_description, + parameters=schema_top, + ) + + return ToolDerivation( + tool=derived_tool, + fn_qualname=getattr(fn, "__qualname__", fn_name), + params=tuple(derived), + docstring_style_detected=detected, + ) + + +def tool( + fn: Callable[..., Any], + *, + name: str | None = None, + description: str | None = None, + config: ToolConfig = ToolConfig(), +) -> FunctionTool: + """Derive a FunctionTool from *fn*. Pure; returns a plain FunctionTool. + + ``name``/``description`` override derivation (name falls back to + ``fn.__name__``, description to the docstring summary paragraph, else + None). A parameter is required iff it has no default; ``Optional[X]`` + affects only value nullability (``anyOf`` with null), never + required-ness. See the module docstring for the full mapping table and + :class:`ToolConfig` for the knobs. + + Deliberately not a decorator: a decorator would replace or wrap the + function — magic. Call ``tool(fn)`` and keep *fn* yourself; dispatch with + ``{f.__name__: f for f in (...)}``. + """ + return derive(fn, name=name, description=description, config=config).tool diff --git a/transports/__init__.py b/transports/__init__.py new file mode 100644 index 0000000000..90f66406c3 --- /dev/null +++ b/transports/__init__.py @@ -0,0 +1,46 @@ +""" +lm15.transports — Minimal stdlib-only HTTP/1.1 transports. + +Public API: + TransportRequest, TransportResponse — transport-level request/response models + StdlibTransport — sync transport (blocking, socket-based) + StdlibAsyncTransport — async transport (asyncio-native) + FetchTransport — async transport over the host's fetch (Pyodide: a page, a worker) + Timeouts — per-operation timeouts (connect/read/write/pool) + TransportError + subclasses +""" + +from ._exceptions import ( + ConnectError, + ConnectTimeout, + ProtocolError, + ReadError, + ReadTimeout, + TransportError, + WriteError, + WriteTimeout, +) +from ._limits import DEFAULT_MAX_CONNECTIONS, Timeouts +from ._types import TransportRequest, TransportResponse, AsyncTransportResponse +from ._sync import StdlibTransport +from ._async import StdlibAsyncTransport +from ._fetch import FetchTransport + +__all__ = [ + "Timeouts", + "DEFAULT_MAX_CONNECTIONS", + "TransportRequest", + "TransportResponse", + "AsyncTransportResponse", + "StdlibTransport", + "StdlibAsyncTransport", + "FetchTransport", + "TransportError", + "ConnectError", + "ConnectTimeout", + "ReadError", + "ReadTimeout", + "WriteError", + "WriteTimeout", + "ProtocolError", +] diff --git a/transports/_async.py b/transports/_async.py new file mode 100644 index 0000000000..2908c1435f --- /dev/null +++ b/transports/_async.py @@ -0,0 +1,645 @@ +""" +Async transport built on `asyncio.open_connection`. + +Mirror of _sync.py but async end-to-end. Key differences: + +- `asyncio.open_connection` gives us a (reader, writer) pair, not a raw socket. +- Stale-connection detection: we peek at the reader's transport socket with + select (fd-level), same trick as the sync side. +- Cancellation correctness: if the caller's task is cancelled mid-stream, + we must close the writer (not return it to the pool), then re-raise + CancelledError without awaiting anything that could itself be cancelled. +- Timeouts use a cancellation-safe wait helper, including on Python 3.10/3.11 + where asyncio.wait_for can swallow simultaneous caller cancellation. +- Lifetime: `aclose()` (or `async with`) closes every connection. A + transport collected without it closes its idle connections from a + finalizer; when their event loop is already gone the socket underneath + is closed directly, since the asyncio transport can no longer do it. +- TLS lives in `_ssl.py`, which this module imports on the first https request + and not before. That module needs the stdlib `ssl`; this one does not, so a + CPython build without `ssl` still carries plain HTTP through here. +""" +from __future__ import annotations + +import asyncio +import select +import socket +import weakref +from typing import TYPE_CHECKING, AsyncIterator + +from ._exceptions import ( + ConnectError, + ConnectTimeout, + ProtocolError, + ReadError, + ReadTimeout, + TransportError, + WriteError, + WriteTimeout, +) +from ._http11 import ( + ResponseHeadParser, + build_request_head, +) +from ._limits import ( + DEFAULT_CONNECT_TIMEOUT, + DEFAULT_MAX_CONNECTIONS, + DEFAULT_POOL_TIMEOUT, + DEFAULT_READ_TIMEOUT, + DEFAULT_WRITE_TIMEOUT, + check_max_connections, + pool_timeout_hint, + read_timeout_hint, +) +from ._proxy import ProxyRoute, connect_payload, proxy_route_for, route_origin +from ._timeouts import wait_for +from ._types import AsyncTransportResponse, TransportRequest +from ._url import ParsedURL, parse_url + +if TYPE_CHECKING: + from ._ssl import TLS + + +_READ_CHUNK = 64 * 1024 + + +# ─── Async connection wrapper ──────────────────────────────────────── + + +class _AsyncConnection: + __slots__ = ("origin", "reader", "writer", "closed") + + def __init__( + self, + origin: tuple[str, str, int], + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + self.origin = origin + self.reader = reader + self.writer = writer + self.closed = False + + def is_stale(self) -> bool: + if self.closed: + return True + # Look at the underlying socket's FD for readability + try: + sock = self.writer.get_extra_info("socket") + except Exception: + return True + if sock is None: + return True + try: + r, _, _ = select.select([sock], [], [], 0) + if not r: + return False + # Readable on an idle keepalive means EOF/close_notify or + # unexpected unread bytes, so the connection is not safe to reuse. + # Avoid MSG_PEEK: TLS sockets/proxies may reject non-zero recv() + # flags, and peeking is unnecessary once select() reports readable. + return True + except (OSError, ValueError): + return True + + def close(self) -> None: + if self.closed: + return + self.closed = True + _close_writer(self.writer) + + +def _close_writer(writer: asyncio.StreamWriter) -> None: + """Close a StreamWriter; when its loop is already closed (the owner was + collected after ``asyncio.run`` returned) asyncio cannot schedule the + close, so the socket underneath is closed directly. + + The fallback reaches asyncio's private ``_sock`` (through the TLS + protocol's inner transport when there is one) and does what the + loop's own ``_call_connection_lost`` would have done: close it and + forget it, so the transport's finalizer neither warns nor double- + closes. Best effort by construction; a future asyncio that renames + the field degrades to the warning, never to an exception.""" + try: + writer.close() + return + except Exception: + pass + transport = getattr(writer, "transport", None) + ssl_protocol = getattr(transport, "_ssl_protocol", None) + if ssl_protocol is not None: + transport = getattr(ssl_protocol, "_transport", None) + sock = getattr(transport, "_sock", None) + if sock is None: + return + try: + sock.close() + except Exception: + pass + try: + transport._sock = None + except Exception: + pass + + +# ─── Async pool ────────────────────────────────────────────────────── + + +class _AsyncConnectionPool: + def __init__(self, max_connections: int) -> None: + self._max = max_connections + self._idle: dict[tuple[str, str, int], list[_AsyncConnection]] = {} + self._in_use: set[_AsyncConnection] = set() + self._lock = asyncio.Lock() + self._slot = asyncio.Semaphore(max_connections) + self._total_opened = 0 + self._closed = False + weakref.finalize(self, _close_idle, self._idle) + + async def acquire_slot(self, timeout: float | None = None) -> None: + if self._closed: + raise TransportError("transport is closed") + if timeout is None: + await self._slot.acquire() + return + try: + await wait_for(self._slot.acquire(), timeout=timeout, cancel_result=lambda _: self._slot.release()) + except asyncio.TimeoutError: + raise TransportError(pool_timeout_hint(timeout, self._max)) + + def release_slot(self) -> None: + self._slot.release() + + async def checkout(self, origin: tuple[str, str, int]) -> _AsyncConnection | None: + async with self._lock: + q = self._idle.get(origin) + while q: + conn = q.pop() + if conn.is_stale(): + conn.close() + continue + self._in_use.add(conn) + return conn + return None + + async def checkin(self, conn: _AsyncConnection) -> None: + async with self._lock: + self._in_use.discard(conn) + if self._closed or conn.closed: + conn.close() + return + self._idle.setdefault(conn.origin, []).append(conn) + + async def discard(self, conn: _AsyncConnection) -> None: + async with self._lock: + self._in_use.discard(conn) + conn.close() + + async def register_new(self, conn: _AsyncConnection) -> None: + async with self._lock: + self._in_use.add(conn) + self._total_opened += 1 + + def stats(self) -> dict: + return { + "idle": sum(len(q) for q in self._idle.values()), + "in_use": len(self._in_use), + "total_opened": self._total_opened, + } + + async def close_all(self) -> None: + async with self._lock: + self._closed = True + for q in self._idle.values(): + for conn in q: + conn.close() + self._idle.clear() + for conn in list(self._in_use): + conn.close() + self._in_use.clear() + + +def _close_idle(idle: dict) -> None: + for q in list(idle.values()): + for conn in q: + conn.close() + idle.clear() + + +# ─── Transport ─────────────────────────────────────────────────────── + + +class StdlibAsyncTransport: + """Async HTTP/1.1 transport on asyncio streams. Same knobs and per- + operation timeout semantics as :class:`StdlibTransport`. Its pool + belongs to the event loop that first used it; one transport per loop. + """ + + def __init__( + self, + *, + connect_timeout: float = DEFAULT_CONNECT_TIMEOUT, + read_timeout: float = DEFAULT_READ_TIMEOUT, + write_timeout: float = DEFAULT_WRITE_TIMEOUT, + pool_timeout: float | None = DEFAULT_POOL_TIMEOUT, + max_connections: int = DEFAULT_MAX_CONNECTIONS, + verify: bool = True, + ca_bundle: str | None = None, + user_agent: str = "lm15/stdlib", + proxy: str | None = None, + trust_env: bool = True, + ) -> None: + self._connect_timeout = connect_timeout + self._read_timeout = read_timeout + self._write_timeout = write_timeout + self._pool_timeout = pool_timeout + self._max_connections = check_max_connections(max_connections) + self._user_agent = user_agent + self._verify = verify + self._ca_bundle = ca_bundle + self._proxy = proxy + self._trust_env = trust_env + self._tls: TLS | None = None + self._pool = _AsyncConnectionPool(self._max_connections) + self._closed = False + + @property + def max_connections(self) -> int: + return self._max_connections + + def copy(self) -> "StdlibAsyncTransport": + """A fresh, open transport with this one's configuration and none of + its connections (an interactive runner closed the original).""" + return type(self)( + connect_timeout=self._connect_timeout, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + pool_timeout=self._pool_timeout, + max_connections=self._max_connections, + verify=self._verify, + ca_bundle=self._ca_bundle, + user_agent=self._user_agent, + proxy=self._proxy, + trust_env=self._trust_env, + ) + + def _tls_half(self) -> "TLS": + """Build the TLS half on first https use. `._ssl` needs the stdlib `ssl` module, + which a reduced build may not have — and a plain-HTTP caller never asks for it.""" + from ._ssl import TLS + + if self._tls is None: + self._tls = TLS(verify=self._verify, ca_bundle=self._ca_bundle) + return self._tls + + def pool_stats(self) -> dict: + return self._pool.stats() + + async def aclose(self) -> None: + if self._closed: + return + self._closed = True + await self._pool.close_all() + + async def __aenter__(self) -> "StdlibAsyncTransport": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.aclose() + + # ─── Main entry point ─── + + def stream(self, request: TransportRequest) -> "_AsyncStreamCM": + """Return an async context manager that produces an AsyncTransportResponse.""" + return _AsyncStreamCM(self, request) + + async def _do_stream(self, request: TransportRequest) -> AsyncTransportResponse: + if self._closed: + raise TransportError("transport is closed") + + parsed = parse_url(request.url) + proxy = proxy_route_for(parsed, proxy=self._proxy, trust_env=self._trust_env) + origin = route_origin(parsed, proxy) + connect_timeout = request.connect_timeout or self._connect_timeout + read_timeout = request.read_timeout or self._read_timeout + write_timeout = request.write_timeout or self._write_timeout + + await self._pool.acquire_slot(timeout=self._pool_timeout) + slot_released = {"done": False} + + def release_slot_once() -> None: + if not slot_released["done"]: + slot_released["done"] = True + self._pool.release_slot() + + conn: _AsyncConnection | None = None + try: + attempt = 0 + while True: + conn = ( + await self._pool.checkout(origin) if attempt == 0 else None + ) + reused = conn is not None + if conn is None: + conn = await self._open( + parsed, proxy=proxy, origin=origin, connect_timeout=connect_timeout + ) + await self._pool.register_new(conn) + + try: + await self._send_request( + conn, request, parsed, proxy=proxy, write_timeout=write_timeout + ) + break + except (WriteError, ConnectionResetError, BrokenPipeError, OSError) as exc: + if not reused or attempt > 0: + await self._pool.discard(conn) + if isinstance(exc, WriteError): + raise + raise WriteError(str(exc)) from exc + await self._pool.discard(conn) + conn = None + attempt += 1 + continue + + head = await self._read_head(conn, read_timeout=read_timeout) + decoder = head.body_decoder(request.method) + keep_alive = head.keep_alive() + + release_conn = self._make_release(conn, keep_alive) + + async def chunks_gen() -> AsyncIterator[bytes]: + aborted = False + try: + if head.leftover: + for out in decoder.feed(head.leftover): + if out: + yield out + if decoder.complete: + return + while not decoder.complete: + try: + data = await wait_for( + conn.reader.read(_READ_CHUNK), + timeout=read_timeout, + ) + except asyncio.TimeoutError as exc: + aborted = True + raise ReadTimeout( + f"read timed out mid-body: {read_timeout_hint(read_timeout)}" + ) from exc + except asyncio.CancelledError: + aborted = True + raise + except OSError as exc: + aborted = True + raise ReadError(f"read failed: {exc}") from exc + if not data: + try: + decoder.eof() + except ProtocolError: + aborted = True + raise + tail = decoder.drain() + if tail: + yield tail + break + try: + for out in decoder.feed(data): + if out: + yield out + except ProtocolError: + aborted = True + raise + finally: + if aborted: + release_slot_once() + + async def release_with_slot(body_consumed: bool) -> None: + try: + await release_conn(body_consumed) + finally: + release_slot_once() + + return AsyncTransportResponse( + status=head.status, + reason=head.reason, + headers=head.headers, + http_version=head.http_version, + chunks=chunks_gen(), + release=release_with_slot, + ) + except BaseException: + if conn is not None: + await self._pool.discard(conn) + release_slot_once() + raise + + # ─── Connection I/O ─── + + async def _open( + self, + parsed: ParsedURL, + *, + proxy: ProxyRoute | None, + origin: tuple[str, str, int], + connect_timeout: float, + ) -> _AsyncConnection: + tls = self._tls_half() if parsed.is_tls else None + + if proxy is not None and tls is not None: + # CONNECT over a raw socket, then hand it to open_connection + # for the end-to-end TLS handshake (works on 3.10; StreamWriter + # gained start_tls only in 3.11). + tunnel = await self._connect_tunnel(parsed, proxy, timeout=connect_timeout) + reader, writer = await tls.connect_over( + tunnel, server_hostname=parsed.host, timeout=connect_timeout + ) + return _AsyncConnection(origin, reader, writer) + + connect_host = proxy.host if proxy is not None else parsed.host + connect_port = proxy.port if proxy is not None else parsed.port + if tls is not None: + reader, writer = await tls.connect( + host=connect_host, + port=connect_port, + server_hostname=parsed.host, + timeout=connect_timeout, + ) + else: + try: + reader, writer = await wait_for( + asyncio.open_connection(host=connect_host, port=connect_port), + timeout=connect_timeout, + cancel_result=lambda pair: pair[1].close(), + ) + except asyncio.TimeoutError as exc: + raise ConnectTimeout( + f"timed out connecting to {connect_host}:{connect_port}" + ) from exc + except OSError as exc: + raise ConnectError( + f"failed to connect to {connect_host}:{connect_port}: {exc}" + ) from exc + + # TCP_NODELAY on the underlying socket + sock = writer.get_extra_info("socket") + if sock is not None: + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + + return _AsyncConnection(origin, reader, writer) + + async def _connect_tunnel( + self, parsed: ParsedURL, proxy: ProxyRoute, *, timeout: float + ) -> socket.socket: + """Open a raw socket to the proxy and CONNECT it to the TLS target.""" + loop = asyncio.get_running_loop() + try: + sock = await wait_for( + asyncio.to_thread( + socket.create_connection, (proxy.host, proxy.port), timeout + ), + timeout=timeout, + cancel_result=lambda sock: sock.close(), + ) + except asyncio.TimeoutError as exc: + raise ConnectTimeout( + f"timed out connecting to proxy {proxy.host}:{proxy.port}" + ) from exc + except OSError as exc: + raise ConnectError( + f"failed to connect to proxy {proxy.host}:{proxy.port}: {exc}" + ) from exc + sock.setblocking(False) + try: + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + await wait_for( + loop.sock_sendall(sock, connect_payload(parsed, proxy)), timeout=timeout + ) + parser = ResponseHeadParser() + while not parser.complete: + data = await wait_for( + loop.sock_recv(sock, _READ_CHUNK), timeout=timeout + ) + if not data: + raise ConnectError("proxy closed connection during CONNECT") + parser.feed(data) + if not (200 <= parser.status < 300): + raise ConnectError( + f"proxy refused CONNECT to {proxy.authority(parsed)}: " + f"{parser.status} {parser.reason}" + ) + if parser.leftover: + raise ProtocolError("proxy sent unexpected bytes after the CONNECT response") + except asyncio.TimeoutError as exc: + sock.close() + raise ConnectTimeout( + f"CONNECT via {proxy.host}:{proxy.port} timed out" + ) from exc + except (ConnectError, ProtocolError): + sock.close() + raise + except OSError as exc: + sock.close() + raise ConnectError(f"CONNECT via {proxy.host}:{proxy.port} failed: {exc}") from exc + return sock + + async def _send_request( + self, + conn: _AsyncConnection, + request: TransportRequest, + parsed: ParsedURL, + *, + proxy: ProxyRoute | None, + write_timeout: float, + ) -> None: + body = request.body or b"" + has_body = request.method.upper() not in ("GET", "HEAD", "DELETE") or bool(body) + body_length = len(body) if has_body else None + target = parsed.target + headers = request.headers + if proxy is not None and not parsed.is_tls: + # Forward-proxy plain HTTP: absolute-URI request line; the + # Host header still names the target (build_request_head). + target = f"http://{parsed.host_header()}{parsed.target}" + if proxy.basic_auth is not None: + headers = [*headers, ("Proxy-Authorization", proxy.basic_auth)] + head = build_request_head( + method=request.method, + target=target, + host=parsed.host, + port=parsed.port, + is_tls=parsed.is_tls, + headers=headers, + body_length=body_length, + user_agent=self._user_agent, + ) + try: + conn.writer.write(head) + if body_length is not None and body: + conn.writer.write(body) + await wait_for(conn.writer.drain(), timeout=write_timeout) + except asyncio.TimeoutError as exc: + raise WriteTimeout(f"write timed out: {exc}") from exc + except (BrokenPipeError, ConnectionResetError): + raise + except OSError as exc: + raise WriteError(f"write failed: {exc}") from exc + + async def _read_head( + self, conn: _AsyncConnection, *, read_timeout: float + ) -> ResponseHeadParser: + parser = ResponseHeadParser() + while not parser.complete: + try: + data = await wait_for( + conn.reader.read(_READ_CHUNK), timeout=read_timeout + ) + except asyncio.TimeoutError as exc: + raise ReadTimeout( + f"read timed out waiting for headers: {read_timeout_hint(read_timeout)}" + ) from exc + except OSError as exc: + raise ReadError(f"read failed: {exc}") from exc + if not data: + raise ReadError("server closed connection before sending response") + parser.feed(data) + return parser + + def _make_release(self, conn: _AsyncConnection, keep_alive: bool): + async def release(body_consumed: bool) -> None: + if body_consumed and keep_alive and not conn.closed: + await self._pool.checkin(conn) + else: + await self._pool.discard(conn) + return release + + +class _AsyncStreamCM: + """Async context manager wrapper so callers can write: + + async with transport.stream(req) as resp: + async for chunk in resp: ... + """ + + def __init__(self, transport: StdlibAsyncTransport, request: TransportRequest) -> None: + self._transport = transport + self._request = request + self._response: AsyncTransportResponse | None = None + + def __await__(self): + return self._transport._do_stream(self._request).__await__() + + async def __aenter__(self) -> AsyncTransportResponse: + self._response = await self._transport._do_stream(self._request) + return self._response + + async def __aexit__(self, exc_type, exc, tb) -> None: + if self._response is not None: + await self._response.aclose() diff --git a/transports/_exceptions.py b/transports/_exceptions.py new file mode 100644 index 0000000000..1dd13d750a --- /dev/null +++ b/transports/_exceptions.py @@ -0,0 +1,40 @@ +""" +Transport-level exceptions. + +These are distinct from lm15.errors (which are provider-level error taxonomy). +Provider LMs wrap these lower-level errors into lm15.errors.TransportError. +They are not provider HTTP/API errors. +""" +from __future__ import annotations + + +class TransportError(Exception): + """Base transport error — network or protocol failure below the LM layer.""" + + +class ConnectError(TransportError): + """Failed to establish a TCP (or TLS) connection.""" + + +class ConnectTimeout(ConnectError): + """Connection establishment timed out.""" + + +class ReadError(TransportError): + """Failed to read from an open socket (EOF, reset, etc.).""" + + +class ReadTimeout(ReadError): + """Reading from the socket timed out.""" + + +class WriteError(TransportError): + """Failed to write to an open socket.""" + + +class WriteTimeout(WriteError): + """Writing to the socket timed out.""" + + +class ProtocolError(TransportError): + """The server sent something that isn't valid HTTP/1.1.""" diff --git a/transports/_fetch.py b/transports/_fetch.py new file mode 100644 index 0000000000..2d400e1f56 --- /dev/null +++ b/transports/_fetch.py @@ -0,0 +1,208 @@ +"""The async transport over the host's ``fetch``: lm15 in a page. + +Under Pyodide — CPython compiled to WebAssembly, in a browser page, a +worker, or Node — there is no socket, so :class:`StdlibAsyncTransport` +cannot open one. The host has ``fetch``. :class:`FetchTransport` is the +same :class:`AsyncTransport` protocol over it: the same +``AsyncTransportResponse``, chunk by chunk as the body arrives, cancelled +by dropping the response before its end. Every adapter's async mirror +(``AsyncOpenAIChatLM``, ``AsyncAnthropicLM``, …) takes it as +``transport=`` and is otherwise unchanged: the request bytes are the +ones the stdlib transport would send. + +What a page cannot do is not hidden here: a cross-origin request goes +only where the server's CORS headers allow it (a refusal can happen +AFTER the server served the request; ``fetch`` reports a bare +``TypeError`` with no status — surfaced as :class:`TransportError` +naming that possibility). Separate socket connect/write deadlines, +pool limits, and proxy configuration are controlled by the host, not us. +The TypeScript port's ``docs/browser.md`` describes these host limitations. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, AsyncIterator + +from ._exceptions import ReadTimeout, TransportError +from ._http11 import content_codings +from ._timeouts import wait_for +from ._types import AsyncTransportResponse, TransportRequest + +try: # the host bridge exists only under Pyodide + import js as _js # type: ignore[import-not-found] + from pyodide.ffi import JsProxy, to_js # type: ignore[import-not-found] +except ImportError: # pragma: no cover - exercised under CPython only by the import guard test + _js = None + JsProxy = None # type: ignore[assignment] + to_js = None # type: ignore[assignment] + +from ._limits import DEFAULT_READ_TIMEOUT, read_timeout_hint + + +def _require_pyodide() -> None: + if _js is None: + raise TransportError( + "FetchTransport runs under Pyodide (a page, a worker, or Node hosting Pyodide); " + "on CPython use StdlibAsyncTransport" + ) + + +class FetchTransport: + """The :class:`AsyncTransport` over the host's ``fetch``. + + ``fetch`` may be given (a test double, a wrapped ``fetch``); by default + it is the host's global. ``read_timeout`` bounds the initial fetch + (through response headers) and each body chunk, not the whole response. + A request's explicit ``read_timeout`` overrides it. The host decodes + compression; visible Content-Encoding is checked against INV-053, never + inflated again. CORS-hidden headers cannot be checked. + """ + + def __init__(self, *, fetch: Any = None, read_timeout: float = DEFAULT_READ_TIMEOUT) -> None: + _require_pyodide() + self._fetch = fetch if fetch is not None else _js.fetch + self._read_timeout = read_timeout + self._closed = False + self._controllers: dict[int, Any] = {} + self._responses: dict[int, AsyncTransportResponse] = {} + + def stream(self, request: TransportRequest) -> "_FetchStreamCM": + return _FetchStreamCM(self, request) + + async def aclose(self) -> None: + self._closed = True + for controller in tuple(self._controllers.values()): + controller.abort() + self._controllers.clear() + for response in tuple(self._responses.values()): + await response.aclose() + + async def _do_stream(self, request: TransportRequest) -> AsyncTransportResponse: + if self._closed: + raise TransportError("transport is closed") + for name in ("connect_timeout", "write_timeout"): + if getattr(request, name) is not None: + raise TransportError(f"fetch has no separate {name}; leave {name} unset") + read_timeout = self._read_timeout if request.read_timeout is None else request.read_timeout + controller = _js.AbortController.new() + self._controllers[id(controller)] = controller + reader = None + + async def host_wait(awaitable): + # Abort BEFORE draining cancellation. asyncio.wait distinguishes + # caller cancellation even when the host promise resolves at once. + task = asyncio.ensure_future(awaitable) + try: + done, _ = await asyncio.wait((task,), timeout=read_timeout) + if not done: + raise asyncio.TimeoutError() + return task.result() + except BaseException: + controller.abort() + task.cancel() + await asyncio.gather(task, return_exceptions=True) + raise + + try: + headers = _js.Headers.new() + for name, value in request.headers: + headers.append(name, value) + if not any(name.lower() == "accept-encoding" for name, _ in request.headers): + # Browsers may silently strip this forbidden request header. + headers.append("Accept-Encoding", "identity") + options: dict[str, Any] = {"method": request.method, "headers": headers, "signal": controller.signal} + if request.body: + options["body"] = to_js(request.body) + try: + response = await host_wait(self._fetch(request.url, to_js(options, dict_converter=_js.Object.fromEntries))) + except asyncio.TimeoutError as exc: + raise ReadTimeout(f"response headers timed out: {read_timeout_hint(read_timeout)}") from exc + except Exception as exc: + raise TransportError( + f"{request.method} {request.url.split('?', 1)[0]}: fetch failed ({_message(exc)}); " + "a browser reports a CORS refusal and a network failure the same way" + ) from exc + + header_pairs = [(str(pair[0]), str(pair[1])) for pair in response.headers.entries()] + # Fetch already decoded the body, even when it retains the coding + # header. Enforce the allowed vocabulary only; do not double inflate. + content_codings([v for k, v in header_pairs if k.lower() == "content-encoding"]) + status = int(response.status) + reason = str(response.statusText or "") + reader = response.body.getReader() if response.body is not None else None + except BaseException: + controller.abort() + self._controllers.pop(id(controller), None) + raise + state = {"consumed": reader is None} + + async def chunks() -> AsyncIterator[bytes]: + if reader is None: + state["consumed"] = True + return + while True: + try: + result = await host_wait(reader.read()) + except asyncio.TimeoutError as exc: + raise ReadTimeout(f"response body read timed out: {read_timeout_hint(read_timeout)}") from exc + except Exception as exc: + raise TransportError(f"response body read failed ({_message(exc)})") from exc + if result.done: + state["consumed"] = True + return + yield bytes(result.value.to_py()) + + async def release(body_consumed: bool) -> None: + # Abort cancels host work, not a guarantee the server stops billing. + # Release the reader lock on success too, and forget ownership once. + try: + if not (body_consumed or state["consumed"]): + controller.abort() + if reader is not None: + try: + await wait_for(reader.cancel(), read_timeout) + except Exception: + pass + finally: + self._controllers.pop(id(controller), None) + self._responses.pop(id(controller), None) + if reader is not None: + try: + reader.releaseLock() + except Exception: + pass + + result = AsyncTransportResponse( + status=status, + reason=reason, + headers=header_pairs, + http_version="HTTP/1.1", # fetch does not expose the negotiated version + chunks=chunks(), + release=release, + ) + self._responses[id(controller)] = result + return result + + +class _FetchStreamCM: + def __init__(self, transport: FetchTransport, request: TransportRequest) -> None: + self._transport = transport + self._request = request + self._response: AsyncTransportResponse | None = None + + def __await__(self): + return self._transport._do_stream(self._request).__await__() + + async def __aenter__(self) -> AsyncTransportResponse: + self._response = await self._transport._do_stream(self._request) + return self._response + + async def __aexit__(self, exc_type, exc, tb) -> None: + if self._response is not None: + await self._response.aclose() + + +def _message(exc: BaseException) -> str: + text = str(exc).strip() + return text.splitlines()[0] if text else type(exc).__name__ diff --git a/transports/_http11.py b/transports/_http11.py new file mode 100644 index 0000000000..ada132ed29 --- /dev/null +++ b/transports/_http11.py @@ -0,0 +1,604 @@ +""" +HTTP/1.1 codec — pure functions over bytes. + +Used by both the sync and async transports. All state is in small helper +objects (ResponseHeadParser, ContentLengthDecoder, ChunkedDecoder, EOFDecoder); +none of them do any I/O. The transport feeds them bytes and reads back +decoded body chunks + a completion flag. + +This is deliberately minimal: + - HTTP/1.1 only (no HTTP/2, no HTTP/1.0 write side) + - Requests advertise ``Accept-Encoding: identity`` (a compressed SSE + stream sits in a proxy's buffer until the window fills, which defeats + streaming). Responses that arrive compressed anyway — gateways and + CDNs ignore the header routinely — are decoded: ``gzip``/``x-gzip`` + and ``deflate`` through the stdlib ``zlib``, incrementally, so a + compressed stream still streams. ``br`` and ``zstd`` have no stdlib + codec and raise a ProtocolError that names the coding. + - No trailers, no pipelining, no 100-continue. + - Chunk extensions (RFC 7230 §4.1.1) are parsed and discarded. +""" +from __future__ import annotations + +from typing import Iterator + +from ._exceptions import ProtocolError + + +# ─── Request serialization ─────────────────────────────────────────── + + +_FORBIDDEN_BYTES = {ord("\r"), ord("\n"), 0} + + +def _validate_field(kind: str, value: str) -> None: + """Reject header values / targets containing CR/LF/NUL (CRLF injection).""" + for ch in value.encode("iso-8859-1", errors="replace"): + if ch in _FORBIDDEN_BYTES: + raise ProtocolError(f"forbidden byte in HTTP {kind}: {value!r}") + + +def build_request_head( + *, + method: str, + target: str, + host: str, + port: int, + is_tls: bool, + headers: list[tuple[str, str]], + body_length: int | None, + user_agent: str = "lm15/stdlib", +) -> bytes: + """Serialize the request line + headers (NOT the body). + + `body_length=None` means no body at all (e.g. GET). `body_length=0` means + the request has an empty body (POST with no payload). Any other value + becomes the Content-Length header. + """ + _validate_field("method", method) + _validate_field("target", target) + _validate_field("host", host) + for k, v in headers: + _validate_field(f"header name ({k})", k) + _validate_field(f"header value ({k})", v) + + lines = [f"{method} {target} HTTP/1.1"] + + # Track which headers the caller provided so we don't duplicate them + lower_names = {k.lower() for k, _ in headers} + + if "host" not in lower_names: + is_default = (is_tls and port == 443) or (not is_tls and port == 80) + host_hdr = _bracketed_if_v6(host) if is_default else f"{_bracketed_if_v6(host)}:{port}" + lines.append(f"Host: {host_hdr}") + + if "user-agent" not in lower_names: + lines.append(f"User-Agent: {user_agent}") + + if "accept" not in lower_names: + lines.append("Accept: */*") + + # Be explicit about no compression — we don't implement Content-Encoding. + if "accept-encoding" not in lower_names: + lines.append("Accept-Encoding: identity") + + if body_length is not None and "content-length" not in lower_names: + lines.append(f"Content-Length: {body_length}") + + for k, v in headers: + lines.append(f"{k}: {v}") + + lines.append("") # end of headers + lines.append("") # blank line terminator + + return "\r\n".join(lines).encode("iso-8859-1") + + +def _bracketed_if_v6(host: str) -> str: + return f"[{host}]" if ":" in host else host + + +# ─── Response head parser ──────────────────────────────────────────── + + +class ResponseHeadParser: + """Incrementally parses status line + headers up to the blank line. + + Feed bytes via `.feed(data)`. Once `.complete` is True the status, + reason, http_version, and headers are populated, and `.leftover` holds + any bytes that arrived past the \\r\\n\\r\\n terminator (start of body). + """ + + def __init__(self, max_head_bytes: int = 64 * 1024) -> None: + self._buf = bytearray() + self._max = max_head_bytes + self.complete = False + self.http_version: str = "" + self.status: int = 0 + self.reason: str = "" + # Preserve order + duplicates + self.headers: list[tuple[str, str]] = [] + self.leftover: bytes = b"" + + def feed(self, data: bytes) -> None: + if self.complete: + # Anything after completion belongs to the body; we shouldn't be called. + self.leftover += bytes(data) + return + self._buf.extend(data) + if len(self._buf) > self._max: + raise ProtocolError( + f"response head exceeded limit ({len(self._buf)} > {self._max} bytes)" + ) + end = self._buf.find(b"\r\n\r\n") + if end == -1: + # Also accept bare \n\n (spec requires \r\n\r\n but be lenient) + return + head = bytes(self._buf[:end]) + self.leftover = bytes(self._buf[end + 4:]) + self._buf.clear() + self._parse_head(head) + self.complete = True + + def _parse_head(self, head: bytes) -> None: + # Accept \r\n or \n line terminators inside head + lines = head.replace(b"\r\n", b"\n").split(b"\n") + if not lines: + raise ProtocolError("empty response head") + status_line = lines[0].decode("iso-8859-1") + parts = status_line.split(" ", 2) + if len(parts) < 2: + raise ProtocolError(f"malformed status line: {status_line!r}") + version = parts[0] + if not version.startswith("HTTP/"): + raise ProtocolError(f"malformed status line: {status_line!r}") + self.http_version = version + try: + self.status = int(parts[1]) + except ValueError: + raise ProtocolError(f"malformed status code: {status_line!r}") + self.reason = parts[2] if len(parts) == 3 else "" + + for line in lines[1:]: + if not line: + continue + s = line.decode("iso-8859-1") + if ":" not in s: + raise ProtocolError(f"malformed header line: {s!r}") + name, _, value = s.partition(":") + self.headers.append((name.strip(), value.strip())) + + # ─── Convenience accessors ─── + + def header(self, name: str) -> str | None: + lname = name.lower() + for k, v in self.headers: + if k.lower() == lname: + return v + return None + + def headers_all(self, name: str) -> list[str]: + lname = name.lower() + return [v for k, v in self.headers if k.lower() == lname] + + # ─── Framing decision ─── + + def body_decoder(self, request_method: str) -> "_BodyDecoder": + """Pick the right decoder based on headers (RFC 7230 §3.3.3), wrapped + in a content decoder when the reply is compressed.""" + framing = self._framing_decoder(request_method) + if framing.complete: + return framing + codings = content_codings(self.headers_all("content-encoding")) + if not codings: + return framing + return ContentDecoder(framing, codings) + + def _framing_decoder(self, request_method: str) -> "_BodyDecoder": + # No body on 1xx, 204, 304, or HEAD + if ( + self.status // 100 == 1 + or self.status in (204, 304) + or request_method.upper() == "HEAD" + ): + return NoBodyDecoder() + + te = self.header("transfer-encoding") + if te: + # "chunked" may be the last (or only) coding + codings = [c.strip().lower() for c in te.split(",")] + if codings and codings[-1] == "chunked": + # We don't support inner codings (gzip,chunked etc.) + if len(codings) > 1: + raise ProtocolError( + f"unsupported Transfer-Encoding stacking: {te!r}" + ) + return ChunkedDecoder() + # Non-chunked TE (deprecated) — treat as identity-to-EOF + return EOFDecoder() + + cl = self.header("content-length") + if cl is not None: + try: + n = int(cl) + except ValueError: + raise ProtocolError(f"malformed Content-Length: {cl!r}") + if n < 0: + raise ProtocolError(f"negative Content-Length: {cl!r}") + return ContentLengthDecoder(n) + + # No framing headers — HTTP/1.1 should have used Content-Length, + # but in practice responses without either exist; read until close. + return EOFDecoder() + + def keep_alive(self) -> bool: + """Whether the connection may be reused after this response.""" + conn = (self.header("connection") or "").lower() + # HTTP/1.1 default is keep-alive; HTTP/1.0 default is close. + if self.http_version == "HTTP/1.0": + return "keep-alive" in conn + return "close" not in conn + + +# ─── Body decoders ─────────────────────────────────────────────────── + + +class _BodyDecoder: + """Interface: feed(data) -> iter of decoded chunks, eof() to signal close, + drain() for any bytes an EOF-delimited body releases at close.""" + complete: bool + leftover: bytes + + def feed(self, data: bytes) -> Iterator[bytes]: + raise NotImplementedError + + def eof(self) -> None: + raise NotImplementedError + + def drain(self) -> bytes: + """Bytes released by eof() (a content decoder's final flush).""" + return b"" + + +class NoBodyDecoder(_BodyDecoder): + def __init__(self) -> None: + self.complete = True + self.leftover = b"" + + def feed(self, data: bytes) -> Iterator[bytes]: + if data: + self.leftover += bytes(data) + return iter(()) + + def eof(self) -> None: + self.complete = True + + +class ContentLengthDecoder(_BodyDecoder): + def __init__(self, length: int) -> None: + self._remaining = length + self.complete = length == 0 + self.leftover = b"" + + def feed(self, data: bytes) -> Iterator[bytes]: + if self.complete: + if data: + raise ProtocolError("received data after Content-Length reached") + return + if not data: + return + take = min(len(data), self._remaining) + out = bytes(data[:take]) + self._remaining -= take + if take < len(data): + self.leftover = bytes(data[take:]) + if self._remaining == 0: + self.complete = True + yield out + + def eof(self) -> None: + if not self.complete: + raise ProtocolError( + f"connection closed with {self._remaining} body bytes outstanding" + ) + + +class ChunkedDecoder(_BodyDecoder): + """Decodes Transfer-Encoding: chunked. + + States: + SIZE — reading the chunk size line (hex + optional extensions) + DATA — reading chunk data + DATA_CRLF — reading the CRLF after chunk data + TRAILER — reading trailer headers + terminating blank line + """ + + SIZE = 0 + DATA = 1 + DATA_CRLF = 2 + TRAILER = 3 + + def __init__(self, max_line_bytes: int = 16 * 1024) -> None: + self._buf = bytearray() + self._state = self.SIZE + self._remaining = 0 + self._max_line = max_line_bytes + self.complete = False + self.leftover = b"" + + def feed(self, data: bytes) -> Iterator[bytes]: + if data: + self._buf.extend(data) + while True: + if self._state == self.SIZE: + line, rest = self._extract_line() + if line is None: + return + assert rest is not None + self._buf = rest + size = self._parse_chunk_size(line) + if size == 0: + self._state = self.TRAILER + continue + self._remaining = size + self._state = self.DATA + continue + + if self._state == self.DATA: + if not self._buf: + return + take = min(len(self._buf), self._remaining) + out = bytes(self._buf[:take]) + self._buf = self._buf[take:] + self._remaining -= take + if out: + yield out + if self._remaining == 0: + self._state = self.DATA_CRLF + continue + return + + if self._state == self.DATA_CRLF: + if len(self._buf) < 2: + return + if bytes(self._buf[:2]) != b"\r\n": + # Be lenient: accept bare \n as terminator + if self._buf[:1] == b"\n": + self._buf = self._buf[1:] + else: + raise ProtocolError("missing CRLF after chunk data") + else: + self._buf = self._buf[2:] + self._state = self.SIZE + continue + + if self._state == self.TRAILER: + # Lines until blank line + line, rest = self._extract_line() + if line is None: + return + assert rest is not None + self._buf = rest + if line == b"": + self.complete = True + self.leftover = bytes(self._buf) + self._buf.clear() + return + # Trailer header — ignored (we don't surface them) + continue + return # unreachable + + def _extract_line(self) -> tuple[bytes, bytearray] | tuple[None, None]: + # Find \r\n or bare \n + idx = self._buf.find(b"\r\n") + if idx == -1: + bare = self._buf.find(b"\n") + if bare == -1: + if len(self._buf) > self._max_line: + raise ProtocolError("chunk line exceeds limit") + return None, None + line = bytes(self._buf[:bare]) + return line, self._buf[bare + 1:] + line = bytes(self._buf[:idx]) + if idx > self._max_line: + raise ProtocolError("chunk line exceeds limit") + return line, self._buf[idx + 2:] + + @staticmethod + def _parse_chunk_size(line: bytes) -> int: + # Discard chunk extensions after ';' + size_part = line.split(b";", 1)[0].strip() + if not size_part: + raise ProtocolError(f"empty chunk size line: {line!r}") + try: + return int(size_part, 16) + except ValueError: + raise ProtocolError(f"malformed chunk size: {line!r}") + + def eof(self) -> None: + if not self.complete: + raise ProtocolError("connection closed mid-chunk") + + +class EOFDecoder(_BodyDecoder): + """Body ends when the connection closes (HTTP/1.0 style).""" + + def __init__(self) -> None: + self.complete = False + self.leftover = b"" + + def feed(self, data: bytes) -> Iterator[bytes]: + if data: + yield bytes(data) + + def eof(self) -> None: + self.complete = True + + +# ─── Content decoding (Content-Encoding) ───────────────────────────── + + +_SUPPORTED_CODINGS = frozenset({"gzip", "x-gzip", "deflate"}) + + +def content_codings(values: list[str]) -> list[str]: + """The codings applied to a body, in the order they must be UNDONE + (RFC 9110 §8.4: the header lists them in the order applied, so the + last one listed is the outermost). ``identity`` is a no-op and is + dropped. Anything lm15 cannot undo raises a ProtocolError naming it — + the alternative is handing compressed bytes to a JSON parser, which + surfaces as a baffling decode error far from the cause.""" + codings: list[str] = [] + for value in values: + for token in value.split(","): + coding = token.strip().lower() + if not coding or coding == "identity": + continue + if coding not in _SUPPORTED_CODINGS: + raise ProtocolError( + f"response is Content-Encoding: {coding!r}, which lm15 cannot decode " + "(it asked for identity; gzip and deflate are decoded, br and zstd are not)" + ) + codings.append(coding) + codings.reverse() + return codings + + +class _Inflater: + """One content coding, possibly containing several gzip members. + + Deflate normally has a two-byte zlib header (RFC 1950). Wait for + both bytes before selecting wrapped or legacy raw deflate, regardless + of how the transport split them. Never reinterpret a checksum failure + as another format after output has already been delivered. + """ + + __slots__ = ("_coding", "_obj", "_started", "_prefix") + + def __init__(self, coding: str) -> None: + import zlib + + self._coding = coding + self._started = False + self._prefix = b"" + self._obj = None if coding == "deflate" else zlib.decompressobj(16 + zlib.MAX_WBITS) + + def decompress(self, data: bytes) -> bytes: + import zlib + + if not data: + return b"" + self._started = True + if self._obj is None: + needed = 2 - len(self._prefix) + self._prefix += data[:needed] + data = data[needed:] + if len(self._prefix) < 2: + return b"" + cmf, flg = self._prefix + wrapped = (cmf & 0x0F) == 8 and (cmf >> 4) <= 7 and ((cmf << 8) | flg) % 31 == 0 + self._obj = zlib.decompressobj(zlib.MAX_WBITS if wrapped else -zlib.MAX_WBITS) + data = self._prefix + data + self._prefix = b"" + + output: list[bytes] = [] + while data: + if self._obj.eof: + if self._coding == "deflate": + raise ProtocolError("malformed deflate body: data after the compressed stream") + # Match stdlib gzip's allowance for zero padding between + # members or after the last member. Other trailing bytes + # must form a valid member; they are never silently lost. + data = data.lstrip(b"\x00") + if not data: + break + self._obj = zlib.decompressobj(16 + zlib.MAX_WBITS) + try: + output.append(self._obj.decompress(data)) + except zlib.error as exc: + raise ProtocolError(f"malformed {self._coding} body: {exc}") from exc + # zlib stops at ONE gzip member. Carry the remaining bytes + # into a fresh member decoder, including a split next header. + data = self._obj.unused_data + return b"".join(output) + + def finish(self) -> bytes: + import zlib + + if not self._started: + return b"" + if self._obj is None or not self._obj.eof: + raise ProtocolError( + f"{self._coding} body ended before the compressed stream did (truncated)" + ) + try: + return self._obj.flush() + except zlib.error as exc: + raise ProtocolError(f"malformed {self._coding} body: {exc}") from exc + + +class ContentDecoder(_BodyDecoder): + """Undo Content-Encoding on top of a framing decoder. + + The framing decoder (chunked / content-length / to-EOF) decides where + the body ends; this decoder turns each framed chunk back into the + bytes the origin produced. Incremental, so a compressed SSE stream + still yields events as they arrive. ``complete`` and ``leftover`` + mirror the framing decoder's — the framing decides connection reuse, + not the compression. + """ + + def __init__(self, framing: _BodyDecoder, codings: list[str]) -> None: + self._framing = framing + self._inflaters = [_Inflater(c) for c in codings] + self._finished = False + self._drain = b"" + + @property + def complete(self) -> bool: # type: ignore[override] + return self._framing.complete + + @property + def leftover(self) -> bytes: # type: ignore[override] + return self._framing.leftover + + def _inflate(self, data: bytes) -> bytes: + for inflater in self._inflaters: + data = inflater.decompress(data) + if not data: + return b"" + return data + + def _finish(self) -> bytes: + if self._finished: + return b"" + self._finished = True + # Flush each layer and push the tail through the layers above it. + out = b"" + for index, inflater in enumerate(self._inflaters): + tail = inflater.finish() + for outer in self._inflaters[index + 1:]: + tail = outer.decompress(tail) + out += tail + return out + + def feed(self, data: bytes) -> Iterator[bytes]: + for framed in self._framing.feed(data): + out = self._inflate(framed) + if out: + yield out + if self._framing.complete: + tail = self._finish() + if tail: + yield tail + + def eof(self) -> None: + self._framing.eof() + # An EOF-delimited body finishes here (a framed one already did in + # feed). The flush may release bytes; the transport collects them + # through drain() since there is no further feed call to carry them. + self._drain += self._finish() + + def drain(self) -> bytes: + out, self._drain = self._drain, b"" + return out diff --git a/transports/_limits.py b/transports/_limits.py new file mode 100644 index 0000000000..f6ff85b36a --- /dev/null +++ b/transports/_limits.py @@ -0,0 +1,83 @@ +""" +Timeouts and connection limits — the two knobs every transport takes. + +Defaults follow the provider SDKs, not general-purpose HTTP clients: a +model call that thinks for three minutes before its first byte is normal, +and a client that gives up at 60 seconds turns that into a "network +failure" and, under a retry loop, restarts the generation each time. +OpenAI, Anthropic and litellm all wait 600 seconds by default; lm15 does +the same. Connect stays short (10 s): a host that does not answer a SYN +in ten seconds is down or unreachable, and a long wait there only delays +the error. + +Every timeout is per operation, not per request: ``read`` bounds the wait +for the next byte, so a slow stream that keeps trickling never times out, +and a stalled one fails at ``read`` seconds after its last byte. +""" +from __future__ import annotations + +from dataclasses import dataclass + +DEFAULT_CONNECT_TIMEOUT = 10.0 +DEFAULT_READ_TIMEOUT = 600.0 +DEFAULT_WRITE_TIMEOUT = 600.0 +DEFAULT_POOL_TIMEOUT = 600.0 +DEFAULT_MAX_CONNECTIONS = 100 + + +@dataclass(frozen=True, slots=True) +class Timeouts: + """How long to wait, in seconds, at each step of a request. + + - ``connect``: TCP + TLS (and a proxy CONNECT) to the host. + - ``read``: the next byte of the reply — headers first, then each body + chunk. A model that is still thinking sends nothing, so this is the + one to raise for slow local models and long non-streaming answers. + - ``write``: sending the request bytes. + - ``pool``: a free connection when ``max_connections`` are all busy. + ``None`` waits as long as it takes. + + Every value must be positive (or ``None`` for ``pool``); a zero or + negative timeout is a configuration mistake, not "no timeout". + """ + + connect: float = DEFAULT_CONNECT_TIMEOUT + read: float = DEFAULT_READ_TIMEOUT + write: float = DEFAULT_WRITE_TIMEOUT + pool: float | None = DEFAULT_POOL_TIMEOUT + + def __post_init__(self) -> None: + for name in ("connect", "read", "write"): + _check_positive(name, getattr(self, name)) + if self.pool is not None: + _check_positive("pool", self.pool) + + +def _check_positive(name: str, value: object) -> None: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not value > 0: + raise ValueError( + f"Timeouts.{name} must be a positive number of seconds, got {value!r}" + ) + + +def check_max_connections(value: object) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError(f"max_connections must be a positive integer, got {value!r}") + return value + + +def read_timeout_hint(seconds: float) -> str: + """The tail of a read-timeout message: says whose limit fired and how + to move it. A user who cannot tell a client timeout from a dead + server retries the wrong thing.""" + return ( + f"no bytes for {seconds:g}s; this is lm15's read timeout, not a server " + f"failure — raise it with Timeouts(read=...) if the model needs longer" + ) + + +def pool_timeout_hint(seconds: float, max_connections: int) -> str: + return ( + f"all {max_connections} connections were busy for {seconds:g}s; raise " + f"max_connections, or Timeouts(pool=...) to wait longer" + ) diff --git a/transports/_proxy.py b/transports/_proxy.py new file mode 100644 index 0000000000..fff82027b7 --- /dev/null +++ b/transports/_proxy.py @@ -0,0 +1,98 @@ +""" +Proxy route resolution. + +An explicit ``proxy=`` on the transport always wins; otherwise, with +``trust_env=True`` (the default), the standard environment variables +(``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY``, minus ``NO_PROXY``) +are consulted per request — the platform contract every HTTP client +honors, and the mechanism egress brokers and corporate proxies rely on. +``trust_env=False`` makes the transport hermetic. + +Only plain-HTTP proxies are supported (``http://`` proxy URLs). Plain +targets are forwarded with an absolute-URI request line; TLS targets are +tunneled with CONNECT and the TLS handshake runs end-to-end to the +origin — the proxy never sees inside the tunnel. +""" +from __future__ import annotations + +import base64 +from dataclasses import dataclass +from urllib.parse import unquote +from urllib.request import getproxies, proxy_bypass + +from ._url import ParsedURL, parse_url + + +@dataclass(frozen=True, slots=True) +class ProxyRoute: + """Where to connect, and what Proxy-Authorization to present (if any).""" + + host: str + port: int + basic_auth: str | None = None # full header value, e.g. "Basic dXNlcjpwdw==" + + def authority(self, parsed: ParsedURL) -> str: + """The target's host:port authority-form, as CONNECT wants it.""" + host = f"[{parsed.host}]" if ":" in parsed.host else parsed.host + return f"{host}:{parsed.port}" + + +def parse_proxy_url(url: str) -> ProxyRoute: + """Parse a proxy URL into a ProxyRoute. Raises ValueError on anything unsupported.""" + if "://" not in url: + url = "http://" + url + scheme, _, rest = url.partition("://") + if scheme.lower() != "http": + raise ValueError( + f"unsupported proxy scheme {scheme!r} in {url!r}: lm15 speaks to plain-HTTP proxies only" + ) + auth: str | None = None + authority = rest.split("/", 1)[0] + if "@" in authority: + userinfo, _, hostpart = authority.rpartition("@") + rest = hostpart + rest[len(authority):] + auth = "Basic " + base64.b64encode(unquote(userinfo).encode("utf-8")).decode("ascii") + parsed = parse_url("http://" + rest) + return ProxyRoute(host=parsed.host, port=parsed.port, basic_auth=auth) + + +def proxy_route_for(parsed: ParsedURL, *, proxy: str | None, trust_env: bool) -> ProxyRoute | None: + """Resolve the proxy for one target URL, or None for a direct connection.""" + if proxy is not None: + return parse_proxy_url(proxy) + if not trust_env: + return None + proxies = getproxies() + url = proxies.get(parsed.scheme) or proxies.get("all") + if not url: + return None + try: + if proxy_bypass(parsed.host): # honors NO_PROXY, including "*" + return None + except Exception: # pragma: no cover — platform lookup quirks bypass nothing + pass + return parse_proxy_url(url) + + +def route_origin(parsed: ParsedURL, proxy: ProxyRoute | None) -> tuple[str, str, int]: + """The connection-pool key for this (target, proxy) pair. + + Direct: the target origin, as before. Plain HTTP via proxy: one pool + per proxy — the proxy multiplexes targets on a single connection. + TLS via proxy: one pool per (proxy, target) — a CONNECT tunnel is + welded to its target and cannot be reused for another host. + """ + if proxy is None: + return parsed.origin() + if parsed.is_tls: + return (f"https@{proxy.host}:{proxy.port}", parsed.host, parsed.port) + return ("http-proxy", proxy.host, proxy.port) + + +def connect_payload(parsed: ParsedURL, proxy: ProxyRoute) -> bytes: + """Serialize the CONNECT request that opens a tunnel to the target.""" + authority = proxy.authority(parsed) + lines = [f"CONNECT {authority} HTTP/1.1", f"Host: {authority}"] + if proxy.basic_auth is not None: + lines.append(f"Proxy-Authorization: {proxy.basic_auth}") + return ("\r\n".join(lines) + "\r\n\r\n").encode("ascii") diff --git a/transports/_ssl.py b/transports/_ssl.py new file mode 100644 index 0000000000..b9ed106258 --- /dev/null +++ b/transports/_ssl.py @@ -0,0 +1,110 @@ +"""TLS for the stdlib transports: one context, and the ways to put it on a socket. + +The socket transports load this module only when a request names an HTTPS URL. +This keeps plain HTTP usable on Python builds without ``ssl``. If such a build +does request HTTPS, :class:`TLS` raises a transport error that names the host's +fetch transport as the Pyodide alternative. + +We rely on the stdlib ``create_default_context``, which on Python 3.10+ loads +the system trust store correctly on Linux, macOS, and Windows. No certifi bundle +is shipped: set ``SSL_CERT_FILE`` if the system store is broken, or pass an +explicit ``ca_bundle=`` to the transport. +""" +from __future__ import annotations + +import asyncio +import socket + +try: + import ssl +except ImportError: # pragma: no cover - Pyodide and reduced Python builds + ssl = None # type: ignore[assignment] + +from ._exceptions import ConnectError, ConnectTimeout +from ._timeouts import wait_for + + +def _close(sock: socket.socket) -> None: + try: + sock.close() + except Exception: + pass + + +def _is_ssl_error(exc: OSError) -> bool: + return ssl is not None and isinstance(exc, ssl.SSLError) + + +class TLS: + """The TLS half of a transport, holding the context its connections share.""" + + __slots__ = ("_ctx",) + + def __init__(self, *, verify: bool = True, ca_bundle: str | None = None) -> None: + if ssl is None: # pragma: no cover - Pyodide + raise ConnectError( + "this Python has no ssl module (Pyodide?), so the socket transports " + "cannot open TLS; use lm15.transports.FetchTransport, the host's fetch" + ) + if not verify: + self._ctx = ssl._create_unverified_context() + return + self._ctx = ssl.create_default_context() + if ca_bundle: + self._ctx.load_verify_locations(cafile=ca_bundle) + + def wrap( + self, sock: socket.socket, *, server_hostname: str, timeout: float + ) -> socket.socket: + """Return ``sock`` with TLS, or close it and describe the handshake failure.""" + try: + sock.settimeout(timeout) + return self._ctx.wrap_socket(sock, server_hostname=server_hostname) + except OSError as exc: + _close(sock) + raise ConnectError(f"TLS handshake failed: {exc}") from exc + + async def connect( + self, *, host: str, port: int, server_hostname: str, timeout: float + ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """Open a TCP connection and run its TLS handshake in one asyncio call.""" + try: + return await wait_for( + asyncio.open_connection( + host=host, + port=port, + ssl=self._ctx, + server_hostname=server_hostname, + ), + timeout=timeout, + cancel_result=lambda pair: pair[1].close(), + ) + except asyncio.TimeoutError as exc: + raise ConnectTimeout(f"timed out connecting to {host}:{port}") from exc + except OSError as exc: + if _is_ssl_error(exc): + raise ConnectError(f"TLS handshake failed: {exc}") from exc + raise ConnectError(f"failed to connect to {host}:{port}: {exc}") from exc + + async def connect_over( + self, sock: socket.socket, *, server_hostname: str, timeout: float + ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """Run TLS over a socket that a proxy has already tunneled to the origin.""" + try: + return await wait_for( + asyncio.open_connection( + sock=sock, + ssl=self._ctx, + server_hostname=server_hostname, + ), + timeout=timeout, + cancel_result=lambda pair: pair[1].close(), + ) + except asyncio.TimeoutError as exc: + _close(sock) + raise ConnectTimeout("TLS handshake through proxy timed out") from exc + except OSError as exc: + _close(sock) + if _is_ssl_error(exc): + raise ConnectError(f"TLS handshake failed: {exc}") from exc + raise ConnectError(f"TLS handshake through proxy failed: {exc}") from exc diff --git a/transports/_sync.py b/transports/_sync.py new file mode 100644 index 0000000000..431c593bba --- /dev/null +++ b/transports/_sync.py @@ -0,0 +1,594 @@ +""" +Sync transport built on the stdlib `socket` module. + +Design: + +- A single transport owns a connection pool keyed by origin (scheme, host, + port). Up to `max_connections` concurrent connections per pool total (not + per origin — keeping it simple for lm15's workload). +- `stream(request)` takes a connection (new or reused), writes the request, + reads the response head, and returns a `TransportResponse` whose iteration yields + body bytes. The response's `__exit__`/generator teardown releases the + connection (to the pool if reusable, otherwise closed). +- Staleness detection: before reusing an idle connection, we peek with + `select` to see if the socket has readable bytes. For an idle HTTP keepalive + socket, readable means the server sent EOF — so we drop it and open fresh. +- If the server sent `Connection: close`, or the body is still outstanding + when the response closes, the connection is closed rather than reused. +- TLS lives in `_ssl.py`, which this module imports on the first https request + and not before. That module needs the stdlib `ssl`; this one does not, so a + CPython build without `ssl` still carries plain HTTP through here. +- Proxies: `proxy=` pins one explicitly; otherwise `trust_env=True` (default) + honors HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY. Plain-HTTP targets + are forwarded absolute-URI; TLS targets are tunneled with CONNECT and the + handshake runs end-to-end to the origin (see `_proxy.py`). +- Lifetime: `close()` (or `with`) closes every socket. A transport that is + garbage-collected without it closes its idle sockets from a finalizer so + a forgotten short-lived client does not leak file descriptors or warn + under `-W error`; sockets still in use belong to their open response. +""" +from __future__ import annotations + +import select +import socket +import threading +import weakref +from typing import TYPE_CHECKING, Iterator + +from ._exceptions import ( + ConnectError, + ConnectTimeout, + ProtocolError, + ReadError, + ReadTimeout, + TransportError, + WriteError, + WriteTimeout, +) +from ._http11 import ( + ChunkedDecoder, + ContentLengthDecoder, + EOFDecoder, + ResponseHeadParser, + build_request_head, +) +from ._limits import ( + DEFAULT_CONNECT_TIMEOUT, + DEFAULT_MAX_CONNECTIONS, + DEFAULT_POOL_TIMEOUT, + DEFAULT_READ_TIMEOUT, + DEFAULT_WRITE_TIMEOUT, + check_max_connections, + pool_timeout_hint, + read_timeout_hint, +) +from ._proxy import ProxyRoute, connect_payload, proxy_route_for, route_origin +from ._types import TransportRequest, TransportResponse +from ._url import ParsedURL, parse_url + +if TYPE_CHECKING: + from ._ssl import TLS + + +_READ_CHUNK = 64 * 1024 + + +# ─── Connection wrapper ────────────────────────────────────────────── + + +class _SyncConnection: + """Owns a single TCP/TLS socket keyed by an origin.""" + + __slots__ = ("origin", "sock", "in_use", "closed") + + def __init__(self, origin: tuple[str, str, int], sock: socket.socket) -> None: + self.origin = origin + self.sock = sock + self.in_use = False + self.closed = False + + def is_stale(self) -> bool: + """Return True if the peer has closed the connection while it was idle.""" + if self.closed: + return True + try: + r, _, _ = select.select([self.sock], [], [], 0) + if not r: + return False + # Readable on an idle socket means the peer sent EOF/close_notify, + # or that unexpected bytes were left unread. Either way this + # connection is not safe to reuse. Do not probe with MSG_PEEK here: + # ssl.SSLSocket.recv(..., flags) raises ValueError for non-zero + # flags, and peeking is unnecessary once select() says an idle + # socket is readable. + return True + except (OSError, ValueError): + return True + + def close(self) -> None: + if self.closed: + return + self.closed = True + try: + self.sock.close() + except Exception: + pass + + +# ─── Connection pool ───────────────────────────────────────────────── + + +class _ConnectionPool: + """Small LIFO pool. LIFO (not FIFO) minimizes chance of reusing stale + keepalive connections, since the most recently used is the most likely + to still be alive.""" + + def __init__(self, max_connections: int) -> None: + self._max = max_connections + self._idle: dict[tuple[str, str, int], list[_SyncConnection]] = {} + self._in_use: set[_SyncConnection] = set() + self._lock = threading.Lock() + self._slot = threading.Semaphore(max_connections) + self._total_opened = 0 + self._closed = False + # Close idle sockets when the pool itself is collected. A finalizer + # (not __del__) so nothing here runs during interpreter teardown + # with half-dead globals; it captures the idle dict, never the pool. + weakref.finalize(self, _close_idle, self._idle) + + def acquire_slot(self, timeout: float | None = None) -> None: + if self._closed: + raise TransportError("transport is closed") + ok = self._slot.acquire(timeout=timeout) + if not ok: + raise TransportError(pool_timeout_hint(timeout or 0.0, self._max)) + + def release_slot(self) -> None: + self._slot.release() + + def checkout(self, origin: tuple[str, str, int]) -> _SyncConnection | None: + """Return an idle connection for this origin, or None if none usable.""" + with self._lock: + q = self._idle.get(origin) + while q: + conn = q.pop() + if conn.is_stale(): + conn.close() + continue + conn.in_use = True + self._in_use.add(conn) + return conn + return None + + def checkin(self, conn: _SyncConnection) -> None: + """Return a connection to the idle pool.""" + with self._lock: + self._in_use.discard(conn) + if self._closed or conn.closed: + conn.close() + return + conn.in_use = False + self._idle.setdefault(conn.origin, []).append(conn) + + def discard(self, conn: _SyncConnection) -> None: + """Close a connection and remove it from all tracking.""" + with self._lock: + self._in_use.discard(conn) + conn.close() + + def register_new(self, conn: _SyncConnection) -> None: + with self._lock: + self._in_use.add(conn) + self._total_opened += 1 + + def stats(self) -> dict: + with self._lock: + return { + "idle": sum(len(q) for q in self._idle.values()), + "in_use": len(self._in_use), + "total_opened": self._total_opened, + } + + def close_all(self) -> None: + with self._lock: + self._closed = True + for q in self._idle.values(): + for conn in q: + conn.close() + self._idle.clear() + for conn in list(self._in_use): + conn.close() + self._in_use.clear() + + +def _close_idle(idle: dict) -> None: + for q in list(idle.values()): + for conn in q: + conn.close() + idle.clear() + + +# ─── Transport ─────────────────────────────────────────────────────── + + +class StdlibTransport: + """Sync HTTP/1.1 transport using only the Python standard library. + + Timeouts are per operation (``_limits.py``): ``read_timeout`` bounds the + wait for the NEXT byte, so a streaming reply that keeps arriving never + trips it. ``pool_timeout`` bounds the wait for a free connection when + ``max_connections`` are busy; ``None`` waits indefinitely. + """ + + def __init__( + self, + *, + connect_timeout: float = DEFAULT_CONNECT_TIMEOUT, + read_timeout: float = DEFAULT_READ_TIMEOUT, + write_timeout: float = DEFAULT_WRITE_TIMEOUT, + pool_timeout: float | None = DEFAULT_POOL_TIMEOUT, + max_connections: int = DEFAULT_MAX_CONNECTIONS, + verify: bool = True, + ca_bundle: str | None = None, + user_agent: str = "lm15/stdlib", + proxy: str | None = None, + trust_env: bool = True, + ) -> None: + self._connect_timeout = connect_timeout + self._read_timeout = read_timeout + self._write_timeout = write_timeout + self._pool_timeout = pool_timeout + self._max_connections = check_max_connections(max_connections) + self._user_agent = user_agent + self._verify = verify + self._ca_bundle = ca_bundle + self._proxy = proxy + self._trust_env = trust_env + self._tls: TLS | None = None + self._tls_lock = threading.Lock() + self._pool = _ConnectionPool(self._max_connections) + self._closed = False + + @property + def max_connections(self) -> int: + return self._max_connections + + def copy(self) -> "StdlibTransport": + """A fresh, open transport with this one's configuration and none of + its connections (an interactive runner closed the original).""" + return type(self)( + connect_timeout=self._connect_timeout, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + pool_timeout=self._pool_timeout, + max_connections=self._max_connections, + verify=self._verify, + ca_bundle=self._ca_bundle, + user_agent=self._user_agent, + proxy=self._proxy, + trust_env=self._trust_env, + ) + + def _tls_half(self) -> "TLS": + """Build the TLS half on first https use. `._ssl` needs the stdlib `ssl` module, + which a reduced build may not have — and a plain-HTTP caller never asks for it.""" + from ._ssl import TLS + + with self._tls_lock: + if self._tls is None: + self._tls = TLS(verify=self._verify, ca_bundle=self._ca_bundle) + return self._tls + + def pool_stats(self) -> dict: + return self._pool.stats() + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._pool.close_all() + + def __enter__(self) -> "StdlibTransport": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + # ─── Main entry point ─── + + def stream(self, request: TransportRequest) -> TransportResponse: + """Open a stream to the URL, returning a TransportResponse whose iteration + yields body bytes. + + The response MUST be used as a context manager (or have `.close()` + called) — otherwise the underlying connection leaks. + """ + if self._closed: + raise TransportError("transport is closed") + + parsed = parse_url(request.url) + proxy = proxy_route_for(parsed, proxy=self._proxy, trust_env=self._trust_env) + origin = route_origin(parsed, proxy) + connect_timeout = request.connect_timeout or self._connect_timeout + read_timeout = request.read_timeout or self._read_timeout + write_timeout = request.write_timeout or self._write_timeout + + # Acquire a pool slot FIRST (may block if we're at max_connections) + self._pool.acquire_slot(timeout=self._pool_timeout) + slot_released = False + + def release_slot_once() -> None: + nonlocal slot_released + if not slot_released: + slot_released = True + self._pool.release_slot() + + conn: _SyncConnection | None = None + try: + # Try to reuse an idle connection. If the write fails because the + # connection went stale between stale-check and write, open fresh + # and retry once. + attempt = 0 + while True: + conn = self._pool.checkout(origin) if attempt == 0 else None + reused = conn is not None + if conn is None: + conn = self._open( + parsed, proxy=proxy, origin=origin, connect_timeout=connect_timeout + ) + self._pool.register_new(conn) + + try: + self._send_request( + conn, request, parsed, proxy=proxy, write_timeout=write_timeout + ) + break + except (WriteError, ConnectionResetError, BrokenPipeError, OSError) as exc: + if not reused or attempt > 0: + self._pool.discard(conn) + if isinstance(exc, WriteError): + raise + raise WriteError(str(exc)) from exc + # Reused connection was stale — close it and retry fresh + self._pool.discard(conn) + conn = None + attempt += 1 + continue + + # Receive response head + head = self._read_head(conn, read_timeout=read_timeout) + decoder = head.body_decoder(request.method) + keep_alive = head.keep_alive() + + release_conn = self._make_release(conn, keep_alive) + # Transfer ownership of the pool slot to the TransportResponse + # (TransportResponse.close will trigger release_slot via _release) + chunks = self._iter_body( + conn, decoder, initial=head.leftover, + read_timeout=read_timeout, on_finish=release_slot_once, + on_abort=release_slot_once, + ) + return TransportResponse( + status=head.status, + reason=head.reason, + headers=head.headers, + http_version=head.http_version, + chunks=chunks, + release=release_conn, + ) + except BaseException: + release_slot_once() + if conn is not None and conn not in self._pool._in_use: + # never happens — register_new or checkout put it in_use + pass + if conn is not None: + self._pool.discard(conn) + raise + + # ─── Connection I/O ─── + + def _open( + self, + parsed: ParsedURL, + *, + proxy: ProxyRoute | None, + origin: tuple[str, str, int], + connect_timeout: float, + ) -> _SyncConnection: + # Built before the socket, as on the async side: a missing `ssl` or an unreadable + # ca_bundle then fails with nothing yet open. + tls = self._tls_half() if parsed.is_tls else None + + connect_host = proxy.host if proxy is not None else parsed.host + connect_port = proxy.port if proxy is not None else parsed.port + try: + sock = socket.create_connection( + (connect_host, connect_port), timeout=connect_timeout, + ) + except socket.timeout as exc: + raise ConnectTimeout( + f"timed out connecting to {connect_host}:{connect_port}" + ) from exc + except OSError as exc: + raise ConnectError( + f"failed to connect to {connect_host}:{connect_port}: {exc}" + ) from exc + + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + + if proxy is not None and parsed.is_tls: + try: + self._connect_tunnel(sock, parsed, proxy, timeout=connect_timeout) + except BaseException: + try: + sock.close() + except Exception: + pass + raise + + if tls is not None: + sock = tls.wrap(sock, server_hostname=parsed.host, timeout=connect_timeout) + + return _SyncConnection(origin, sock) + + def _connect_tunnel( + self, sock: socket.socket, parsed: ParsedURL, proxy: ProxyRoute, *, timeout: float + ) -> None: + """Ask the proxy to open a CONNECT tunnel to the TLS target.""" + sock.settimeout(timeout) + try: + sock.sendall(connect_payload(parsed, proxy)) + except socket.timeout as exc: + raise ConnectTimeout(f"CONNECT to proxy timed out: {exc}") from exc + except OSError as exc: + raise ConnectError(f"CONNECT write to proxy failed: {exc}") from exc + + parser = ResponseHeadParser() + while not parser.complete: + try: + data = sock.recv(_READ_CHUNK) + except socket.timeout as exc: + raise ConnectTimeout(f"timed out waiting for CONNECT response: {exc}") from exc + except OSError as exc: + raise ConnectError(f"read of CONNECT response failed: {exc}") from exc + if not data: + raise ConnectError("proxy closed connection during CONNECT") + parser.feed(data) + if not (200 <= parser.status < 300): + raise ConnectError( + f"proxy refused CONNECT to {proxy.authority(parsed)}: " + f"{parser.status} {parser.reason}" + ) + if parser.leftover: + raise ProtocolError("proxy sent unexpected bytes after the CONNECT response") + + def _send_request( + self, + conn: _SyncConnection, + request: TransportRequest, + parsed: ParsedURL, + *, + proxy: ProxyRoute | None, + write_timeout: float, + ) -> None: + body = request.body or b"" + has_body = request.method.upper() not in ("GET", "HEAD", "DELETE") or bool(body) + body_length = len(body) if has_body else None + target = parsed.target + headers = request.headers + if proxy is not None and not parsed.is_tls: + # Forward-proxy plain HTTP: absolute-URI request line; the + # Host header still names the target (build_request_head). + target = f"http://{parsed.host_header()}{parsed.target}" + if proxy.basic_auth is not None: + headers = [*headers, ("Proxy-Authorization", proxy.basic_auth)] + head = build_request_head( + method=request.method, + target=target, + host=parsed.host, + port=parsed.port, + is_tls=parsed.is_tls, + headers=headers, + body_length=body_length, + user_agent=self._user_agent, + ) + conn.sock.settimeout(write_timeout) + try: + conn.sock.sendall(head) + if body_length is not None and body: + conn.sock.sendall(body) + except socket.timeout as exc: + raise WriteTimeout(f"write timed out: {exc}") from exc + except (BrokenPipeError, ConnectionResetError): + raise # caller retries reused-but-stale connections + except OSError as exc: + raise WriteError(f"write failed: {exc}") from exc + + def _read_head( + self, conn: _SyncConnection, *, read_timeout: float + ) -> ResponseHeadParser: + parser = ResponseHeadParser() + conn.sock.settimeout(read_timeout) + while not parser.complete: + try: + data = conn.sock.recv(_READ_CHUNK) + except socket.timeout as exc: + raise ReadTimeout( + f"read timed out waiting for headers: {read_timeout_hint(read_timeout)}" + ) from exc + except OSError as exc: + raise ReadError(f"read failed: {exc}") from exc + if not data: + raise ReadError("server closed connection before sending response") + parser.feed(data) + return parser + + def _iter_body( + self, + conn: _SyncConnection, + decoder, + *, + initial: bytes, + read_timeout: float, + on_finish, + on_abort, + ) -> Iterator[bytes]: + """Generator that yields decoded body chunks. + + Calls on_finish() on clean completion, on_abort() on exception. + """ + aborted = False + try: + if initial: + for out in decoder.feed(initial): + if out: + yield out + if decoder.complete: + return + conn.sock.settimeout(read_timeout) + while not decoder.complete: + try: + data = conn.sock.recv(_READ_CHUNK) + except socket.timeout as exc: + aborted = True + raise ReadTimeout( + f"read timed out mid-body: {read_timeout_hint(read_timeout)}" + ) from exc + except OSError as exc: + aborted = True + raise ReadError(f"read failed: {exc}") from exc + if not data: + # EOF + try: + decoder.eof() + except ProtocolError: + aborted = True + raise + tail = decoder.drain() + if tail: + yield tail + break + try: + for out in decoder.feed(data): + if out: + yield out + except ProtocolError: + aborted = True + raise + finally: + if aborted: + on_abort() + else: + on_finish() + + def _make_release(self, conn: _SyncConnection, keep_alive: bool): + """Create the release callback the TransportResponse invokes on close.""" + def release(body_consumed: bool) -> None: + if body_consumed and keep_alive and not conn.closed: + self._pool.checkin(conn) + else: + self._pool.discard(conn) + return release diff --git a/transports/_timeouts.py b/transports/_timeouts.py new file mode 100644 index 0000000000..6e0b0645b7 --- /dev/null +++ b/transports/_timeouts.py @@ -0,0 +1,53 @@ +"""Cancellation-safe timeouts on every supported Python version. + +Python 3.10/3.11 asyncio.wait_for can swallow caller cancellation when its +inner future completes concurrently (CPython #86296). Socket reads/writes +must not continue after that cancellation. Use asyncio.wait to distinguish +our timeout from caller cancellation, then explicitly cancel and drain the +owned operation. No global event-loop patches or third-party dependency. +""" + +from __future__ import annotations + +import asyncio +from typing import Awaitable, Callable, TypeVar + +_T = TypeVar("_T") + + +async def wait_for( + awaitable: Awaitable[_T], timeout: float | None, + *, cancel_result: Callable[[_T], None] | None = None, +) -> _T: + """Wait with a timeout, never replacing caller cancellation with success. + + Like asyncio.wait_for, timeout cancellation waits for the operation's + cleanup. An operation that suppresses cancellation can return a value; + an operation that suppresses it indefinitely can exceed the timeout. + cancel_result releases a resource acquired concurrently with cancellation + (a socket, or a semaphore permit) before its caller took ownership. + """ + if timeout is None: + return await awaitable + task = asyncio.ensure_future(awaitable) + try: + if timeout > 0: + done, _ = await asyncio.wait((task,), timeout=timeout) + if done: + return task.result() + elif task.done(): + return task.result() + task.cancel() + # return_exceptions consumes only the child's cancellation. A caller + # cancellation still raises here and reaches the outer handler. + await asyncio.gather(task, return_exceptions=True) + try: + return task.result() + except asyncio.CancelledError as exc: + raise asyncio.TimeoutError() from exc + except BaseException: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + if cancel_result is not None and not task.cancelled() and task.exception() is None: + cancel_result(task.result()) + raise diff --git a/transports/_types.py b/transports/_types.py new file mode 100644 index 0000000000..c5ee42428d --- /dev/null +++ b/transports/_types.py @@ -0,0 +1,206 @@ +""" +Transport-level request/response models. + +These are intentionally minimal — they're the bytes-in/bytes-out interface +between the LM layer (which speaks the lm15 type system) and the +HTTP transport. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import AsyncIterator, Iterator + + +@dataclass(slots=True) +class TransportRequest: + method: str + url: str = field(repr=False) + headers: list[tuple[str, str]] = field(default_factory=list, repr=False) + body: bytes = field(default=b"", repr=False) + # Per-request timeout overrides (None = use transport default) + connect_timeout: float | None = None + read_timeout: float | None = None + write_timeout: float | None = None + + +class TransportResponse: + """Sync streaming response. + + Iterating yields body chunks as bytes. Must be used as a context manager + so the connection is properly returned to the pool (or closed) even on + early exit. + """ + + status: int + reason: str + headers: list[tuple[str, str]] + http_version: str + + def __init__( + self, + *, + status: int, + reason: str, + headers: list[tuple[str, str]], + http_version: str, + chunks: Iterator[bytes], + release: "callable", + ) -> None: + self.status = status + self.reason = reason + self.headers = headers + self.http_version = http_version + self._chunks = chunks + self._release = release + self._released = False + self._complete = False + + def header(self, name: str) -> str | None: + lname = name.lower() + for k, v in self.headers: + if k.lower() == lname: + return v + return None + + def headers_all(self, name: str) -> list[str]: + lname = name.lower() + return [v for k, v in self.headers if k.lower() == lname] + + def __iter__(self) -> Iterator[bytes]: + try: + for chunk in self._chunks: + if chunk: + yield chunk + self._complete = True + finally: + self._release_once(body_consumed=self._complete) + + def read(self) -> bytes: + return b"".join(self) + + def iter_lines(self) -> Iterator[bytes]: + """Yield newline-terminated byte lines from arbitrary body chunks.""" + buf = bytearray() + for chunk in self: + if not chunk: + continue + buf.extend(chunk) + while True: + idx = buf.find(b"\n") + if idx < 0: + break + line = bytes(buf[: idx + 1]) + del buf[: idx + 1] + yield line + if buf: + yield bytes(buf) + + def close(self) -> None: + self._release_once(body_consumed=self._complete) + + def _release_once(self, *, body_consumed: bool) -> None: + if self._released: + return + self._released = True + try: + self._release(body_consumed) + except Exception: + pass + + def __enter__(self) -> "TransportResponse": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + +class AsyncTransportResponse: + """Async streaming response. Async-iterate to get body chunks.""" + + status: int + reason: str + headers: list[tuple[str, str]] + http_version: str + + def __init__( + self, + *, + status: int, + reason: str, + headers: list[tuple[str, str]], + http_version: str, + chunks: AsyncIterator[bytes], + release: "callable", + ) -> None: + self.status = status + self.reason = reason + self.headers = headers + self.http_version = http_version + self._chunks = chunks + self._release = release # async callable(body_consumed: bool) + self._released = False + self._complete = False + + def header(self, name: str) -> str | None: + lname = name.lower() + for k, v in self.headers: + if k.lower() == lname: + return v + return None + + def headers_all(self, name: str) -> list[str]: + lname = name.lower() + return [v for k, v in self.headers if k.lower() == lname] + + def __aiter__(self) -> AsyncIterator[bytes]: + return self._iter() + + async def _iter(self) -> AsyncIterator[bytes]: + try: + async for chunk in self._chunks: + if chunk: + yield chunk + self._complete = True + finally: + await self._release_once(body_consumed=self._complete) + + async def read(self) -> bytes: + buf = bytearray() + async for c in self: + buf.extend(c) + return bytes(buf) + + async def aiter_lines(self) -> AsyncIterator[bytes]: + """Yield newline-terminated byte lines from arbitrary body chunks.""" + buf = bytearray() + async for chunk in self: + if not chunk: + continue + buf.extend(chunk) + while True: + idx = buf.find(b"\n") + if idx < 0: + break + line = bytes(buf[: idx + 1]) + del buf[: idx + 1] + yield line + if buf: + yield bytes(buf) + + async def aclose(self) -> None: + await self._release_once(body_consumed=self._complete) + + async def _release_once(self, *, body_consumed: bool) -> None: + if self._released: + return + self._released = True + try: + await self._release(body_consumed) + except Exception: + pass + + async def __aenter__(self) -> "AsyncTransportResponse": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.aclose() diff --git a/transports/_url.py b/transports/_url.py new file mode 100644 index 0000000000..b5b28ed5b0 --- /dev/null +++ b/transports/_url.py @@ -0,0 +1,103 @@ +""" +Minimal URL parser. + +We only need to parse `http://` and `https://` URLs into the four bits a +transport actually uses: scheme, host, port, and request-target. Full RFC 3986 +parsing is unnecessary — provider URLs are well-formed. +""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ParsedURL: + scheme: str # "http" or "https" + host: str # hostname or IP literal (no brackets for IPv6) + port: int # always set (default 80 or 443) + target: str # request-target: "/path?query" + is_tls: bool + + def origin(self) -> tuple[str, str, int]: + """Pool key — two URLs with the same origin share connections.""" + return (self.scheme, self.host, self.port) + + def host_header(self) -> str: + """The value to put in the Host: header.""" + is_default = (self.is_tls and self.port == 443) or (not self.is_tls and self.port == 80) + if is_default: + return _bracketed_if_v6(self.host) + return f"{_bracketed_if_v6(self.host)}:{self.port}" + + +def _bracketed_if_v6(host: str) -> str: + return f"[{host}]" if ":" in host else host + + +def parse_url(url: str) -> ParsedURL: + """Parse an http(s) URL. Raises ValueError on anything unsupported.""" + # Scheme + if "://" not in url: + raise ValueError(f"URL missing scheme: {url!r}") + scheme, _, rest = url.partition("://") + scheme = scheme.lower() + if scheme not in ("http", "https"): + raise ValueError(f"unsupported scheme {scheme!r} in {url!r}") + is_tls = scheme == "https" + + # Strip fragment (never sent to server) + rest, _, _fragment = rest.partition("#") + + # Split authority from path + slash = rest.find("/") + qmark = rest.find("?") + # First separator, if any + cut = min((i for i in (slash, qmark) if i != -1), default=-1) + if cut == -1: + authority = rest + target = "/" + else: + authority = rest[:cut] + target = rest[cut:] + # If the first char of target is '?', prepend '/' + if target.startswith("?"): + target = "/" + target + + if not authority: + raise ValueError(f"URL missing host: {url!r}") + + host, port = _split_authority(authority, is_tls) + + return ParsedURL( + scheme=scheme, + host=host, + port=port, + target=target, + is_tls=is_tls, + ) + + +def _split_authority(authority: str, is_tls: bool) -> tuple[str, int]: + # Handle IPv6 literal: [::1]:8080 or [::1] + if authority.startswith("["): + end = authority.find("]") + if end == -1: + raise ValueError(f"malformed IPv6 literal in authority: {authority!r}") + host = authority[1:end] + rest = authority[end + 1:] + if rest.startswith(":"): + port_s = rest[1:] + if not port_s.isdigit(): + raise ValueError(f"bad port in authority: {authority!r}") + return host, int(port_s) + if rest == "": + return host, 443 if is_tls else 80 + raise ValueError(f"unexpected trailing chars in authority: {authority!r}") + + # Hostname or IPv4 + if ":" in authority: + host, _, port_s = authority.rpartition(":") + if not port_s.isdigit(): + raise ValueError(f"bad port in authority: {authority!r}") + return host, int(port_s) + return authority, 443 if is_tls else 80 diff --git a/types.py b/types.py new file mode 100644 index 0000000000..3163d4a3cf --- /dev/null +++ b/types.py @@ -0,0 +1,3274 @@ +""" +lm15.types — Core vocabulary for foundation model interaction. + +The fundamental unit is the Part: an atomic, typed block of content. +Parts compose into Messages (attributed to a speaker). +Messages compose into Requests (sent to a model). +Models produce Responses (containing a Message). + +Streams reveal Responses incrementally through Deltas — typed fragments +of streamable response parts. Not every Part is streamable; use +StreamablePart / NonStreamablePart to make that boundary explicit. + +Design principles: + +1. Parts, Deltas, and Events are proper discriminated unions. Each + variant is an independent frozen dataclass. Fields that don't belong + to a variant don't exist on it — accessing them raises AttributeError. + Check .type or use isinstance() before accessing variant-specific fields. + +2. One representation per concept. A Delta is always a typed Delta + object, never a dict. Tool call arguments are called "input" + everywhere — in memory, in deltas, in serialization. + +3. Frozen + slotted dataclasses throughout. Dataclass attributes are + shallowly immutable after construction: fields cannot be rebound, while + caller-provided JSON containers remain ordinary mutable Python containers. + +4. Universal structure, provider-specific values. The shape of a + Request is universal. Provider-specific configuration flows + through Config.extensions — clearly separated from universal knobs. + Provider-specific response metadata lives in Response.provider_data. + +5. Runtime validation is deliberately narrow. Constructors enforce the + invariants that make objects meaningful (required identities, one media + source, non-negative token counts, JSON-serializable extension fields) + while LMs remain responsible for normalizing provider quirks before + constructing these types. +""" + +from __future__ import annotations + +import base64 as _base64 +import binascii as _binascii +import json as _json +import math as _math +import mimetypes as _mimetypes +import re as _re +from collections.abc import Sequence +from dataclasses import dataclass, field +from os import PathLike +from pathlib import Path +from typing import Any, Literal, TypeAlias, TypeVar, get_args + +from .adaptation import Adaptation + + +# ─── Literal vocabularies ──────────────────────────────────────────── + +Role = Literal["user", "assistant", "tool", "developer"] + +PartType = Literal[ + "text", + "image", + "audio", + "video", + "document", + "binary", + "tool_call", + "tool_result", + "thinking", + "refusal", + "citation", + "data", +] + +ContinuationKind: TypeAlias = str + +# Judgments (changes/2026-09-17-judgments.md). ``Config.probabilities`` +# asks for a distribution over the keys a json_schema declares (MAP-14); +# ``DataPart.method`` says how a delivered distribution was measured. +ProbabilityPolicy = Literal["off", "if_available", "required"] +PROBABILITY_POLICIES: tuple[str, ...] = get_args(ProbabilityPolicy) +JudgmentMethod = Literal["provider_classification", "candidate_sequence_likelihood"] +JUDGMENT_METHODS: tuple[str, ...] = get_args(JudgmentMethod) + +# FinishReason values are a separate namespace from PartType values even when +# a token such as "tool_call" appears in both. +FinishReason = Literal["stop", "length", "tool_call", "content_filter", "error"] + +ReasoningEffort = Literal[ + "off", "minimal", "low", "medium", "high", "xhigh", "max" +] +ReasoningSummary = Literal["auto", "concise", "detailed"] + +ErrorCode = Literal[ + "auth", + "billing", + "rate_limit", + "invalid_request", + "context_length", + "timeout", + "server", + "unsupported_model", + "unsupported_feature", + "not_configured", + "unknown_model", + "ambiguous_model", + "transport", + "lock_timeout", + "stream_assembly", + "collection_limit", + "provider", +] +ERROR_CODES = frozenset(get_args(ErrorCode)) + +StreamEventType = Literal["start", "delta", "end", "error"] +BatchStatus = Literal["queued", "running", "cancelling", "completed", "failed", "cancelled", "expired"] +VideoStatus = Literal["queued", "running", "completed", "failed", "cancelled"] +BatchOutcome = Literal["succeeded", "errored", "cancelled", "expired"] +FileReadiness = Literal["pending", "ready", "failed"] +AudioEncoding = Literal["pcm16", "opus", "mp3", "aac"] +ToolChoiceMode = Literal["auto", "required", "none"] +LiveClientEventType = Literal["turn", "audio", "image", "text", "tool_result", "interrupt", "end_audio"] +LiveServerEventType = Literal["audio", "text", "tool_call", "tool_call_delta", "interrupted", "turn_end", "usage", "error"] + +ROLE_VALUES = frozenset(get_args(Role)) +FINISH_REASONS = frozenset(get_args(FinishReason)) +REASONING_EFFORTS = frozenset(get_args(ReasoningEffort)) +REASONING_SUMMARIES = frozenset(get_args(ReasoningSummary)) +BATCH_STATUSES = frozenset(get_args(BatchStatus)) +BATCH_OUTCOMES = frozenset(get_args(BatchOutcome)) +# Job statuses in which a batch will make no further progress. +BATCH_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "expired"}) +VIDEO_STATUSES = frozenset(get_args(VideoStatus)) +# Job statuses in which a video job will make no further progress. +VIDEO_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"}) +FILE_READINESS_VALUES = frozenset(get_args(FileReadiness)) +AUDIO_ENCODINGS = frozenset(get_args(AudioEncoding)) +TOOL_CHOICE_MODES = frozenset(get_args(ToolChoiceMode)) + +_P = TypeVar("_P") +_MISSING: Any = object() + +# JSON-compatible values used for model inputs, tool schemas, provider +# extensions, and provider metadata. Keep these small and boring: the +# LM layer can map richer Python objects into this vocabulary before +# they enter the core model. +JsonPrimitive: TypeAlias = None | bool | int | float | str +JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"] +JsonArray: TypeAlias = list[JsonValue] +JsonObject: TypeAlias = dict[str, JsonValue] + + +def _is_json_value(value: Any) -> bool: + """Return True if value is made only of strict JSON containers. + + Complete JSON validation is necessarily proportional to payload size, so + keep it iterative and simple: no recursion, no copying, and no coercion of + tuples or non-string dict keys into something different on the wire. + """ + stack = [value] + while stack: + item = stack.pop() + kind = type(item) + if item is None or kind is bool or kind is int or kind is str: + continue + if kind is float: + if not _math.isfinite(item): + return False + continue + if kind is list: + stack.extend(item) + continue + if kind is dict: + for key, child in item.items(): + if not isinstance(key, str): + return False + stack.append(child) + continue + # Keep compatibility with scalar subclasses without paying + # ``isinstance`` costs for the common exact built-in types above. + if isinstance(item, (int, str)): + continue + if isinstance(item, float): + if not _math.isfinite(item): + return False + continue + return False + return True + + +def _check_json_object(value: Any, *, field_name: str, required: bool = False) -> None: + """Validate a JSON object field without copying or wrapping it.""" + if value is None: + if required: + raise TypeError(f"{field_name} must be a JSON object") + return + if not isinstance(value, dict): + raise TypeError(f"{field_name} must be a JSON object") + if not _is_json_value(value): + raise TypeError(f"{field_name} must contain only JSON-compatible values") + + +def _validate_json_field(obj: object, field_name: str, *, required: bool = False) -> None: + """Validate a JSON object field on a dataclass.""" + _check_json_object(getattr(obj, field_name), field_name=field_name, required=required) + + +def _validate_adaptations(obj: object, field_name: str) -> None: + value = getattr(obj, "adaptations") + if isinstance(value, list): + value = tuple(value) + object.__setattr__(obj, "adaptations", value) + if not isinstance(value, tuple) or not all(isinstance(a, Adaptation) for a in value): + raise TypeError(f"{field_name} must be a tuple of Adaptation") + + +def _validate_extensions_field(obj: object) -> None: + """Validate extensions, normalizing an empty mapping to None.""" + if getattr(obj, "extensions") == {}: + object.__setattr__(obj, "extensions", None) + return + _validate_json_field(obj, "extensions") + + +@dataclass(frozen=True, slots=True, repr=False) +class ContinuationState: + """Opaque provider-owned state needed to continue/replay a transcript. + + Continuation state is not visible model content. It travels with the + Message or Part it describes so provider adapters can reconstruct future + provider requests without relying on detached response metadata. + """ + + provider: str + kind: ContinuationKind + data: JsonObject = field(default_factory=dict) + + def __post_init__(self) -> None: + _validate_text(self.provider, field_name="ContinuationState.provider", allow_empty=False) + _validate_text(self.kind, field_name="ContinuationState.kind", allow_empty=False) + _validate_json_field(self, "data", required=True) + + def __repr__(self) -> str: + return ( + "ContinuationState(" + f"provider={self.provider!r}, " + f"kind={self.kind!r}, " + f"data=)" + ) + + +def _normalize_continuation(value: Any, *, field_name: str = "continuation") -> tuple[ContinuationState, ...]: + if value is None: + return () + if isinstance(value, ContinuationState): + return (value,) + if isinstance(value, str): + raise TypeError(f"{field_name} must contain ContinuationState objects") + try: + states = tuple(value) + except TypeError as exc: + raise TypeError(f"{field_name} must be a sequence of ContinuationState objects") from exc + if not all(isinstance(item, ContinuationState) for item in states): + raise TypeError(f"{field_name} must contain ContinuationState objects") + return states + + +def _validate_continuation_field(obj: object, field_name: str = "continuation") -> None: + object.__setattr__( + obj, + field_name, + _normalize_continuation(getattr(obj, field_name), field_name=field_name), + ) + + +def _continuation_repr(value: tuple[ContinuationState, ...]) -> str | None: + if not value: + return None + return f"<{len(value)} continuation state{'s' if len(value) != 1 else ''}>" + + +def _continuation_by_kind( + value: tuple[ContinuationState, ...], provider: str, kind: str +) -> JsonObject | None: + for state in value: + if state.provider == provider and state.kind == kind: + return state.data + return None + + +def continuation_data( + value: Message | Part | tuple[ContinuationState, ...], provider: str, kind: str +) -> JsonObject | None: + """Return provider-owned continuation data from a Message or Part. + + This is a convenience helper for provider adapters and advanced callers. + It returns the opaque JSON object attached to the first matching + ContinuationState, or None when no match exists. + """ + if isinstance(value, tuple): + states = value + else: + states = getattr(value, "continuation", ()) + return _continuation_by_kind(states, provider, kind) + + +def _coerce_int_field(obj: object, field_name: str) -> None: + """Number rule (docs/serde-rules.md): int fields have ONE wire form. + + Same-valued float input is coerced (2.0 -> 2); non-integral floats are + rejected; bool never coerces (``type is float`` excludes bool, and the + int validators reject bool downstream). + """ + value = getattr(obj, field_name) + if type(value) is float: + if not value.is_integer(): + raise TypeError(f"{field_name} must be an int") + object.__setattr__(obj, field_name, int(value)) + + +def _coerce_float_field(obj: object, field_name: str) -> None: + """Number rule (docs/serde-rules.md): float fields have ONE wire form. + + Same-valued int input is coerced (1 -> 1.0); bool never coerces + (``type is int`` excludes bool, leaving it for downstream rejection). + """ + value = getattr(obj, field_name) + if type(value) is int: + object.__setattr__(obj, field_name, float(value)) + + +def _validate_int(value: Any, *, field_name: str) -> None: + """Reject non-int values, including bool (which subclasses int).""" + if value is None: + return + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{field_name} must be an int") + + +def _validate_positive(value: int | None, *, field_name: str) -> None: + _validate_int(value, field_name=field_name) + if value is not None and value <= 0: + raise ValueError(f"{field_name} must be > 0") + + +def _validate_non_negative(value: int | None, *, field_name: str) -> None: + _validate_int(value, field_name=field_name) + if value is not None and value < 0: + raise ValueError(f"{field_name} must be >= 0") + + +def _validate_part_index(part_index: int) -> None: + _validate_int(part_index, field_name="part_index") + if part_index < 0: + raise ValueError("part_index must be >= 0") + + +def _validate_text(value: Any, *, field_name: str, allow_empty: bool = True) -> None: + """Validate that value is a string (and optionally non-empty).""" + if not isinstance(value, str): + raise TypeError(f"{field_name} must be a string") + if not allow_empty and value == "": + raise ValueError(f"{field_name} cannot be empty") + + +def _validate_optional_text( + value: Any, *, field_name: str, allow_empty: bool = True +) -> None: + """Validate that an optional value is either None or a string.""" + if value is not None: + _validate_text(value, field_name=field_name, allow_empty=allow_empty) + + +def _validate_bool(value: Any, *, field_name: str) -> None: + """Validate that value is exactly a bool.""" + if not isinstance(value, bool): + raise TypeError(f"{field_name} must be a bool") + + +def _validate_optional_bool(value: Any, *, field_name: str) -> None: + """Validate that an optional value is either None or exactly a bool.""" + if value is not None: + _validate_bool(value, field_name=field_name) + + +# ─── Media helpers ──────────────────────────────────────────────────── + + +def _validate_media( + part_type: str, + data: str | None, + url: str | None, + file_id: str | None, + path: Path | None, +) -> None: + """Validate that exactly one non-empty media source is set.""" + count = ( + (data is not None) + + (url is not None) + + (file_id is not None) + + (path is not None) + ) + if count != 1: + raise ValueError(f"{part_type} requires exactly one of data, url, file_id, or path") + if path is not None: + if not isinstance(path, Path): + raise TypeError(f"{part_type} path must be a pathlib.Path") + return + name, value = ( + ("data", data) + if data is not None + else ("url", url) + if url is not None + else ("file_id", file_id) + ) + if not isinstance(value, str): + raise TypeError(f"{part_type} {name} must be a string") + if value == "": + raise ValueError(f"{part_type} {name} cannot be empty") + + +def _validate_media_delta_addresses( + part_type: str, data: str | None, url: str | None, file_id: str | None +) -> None: + """Validate media-delta address shape without requiring a final address.""" + if (data is not None) + (url is not None) + (file_id is not None) > 1: + raise ValueError(f"{part_type} can include at most one of data, url, or file_id") + + +_BASE64_RE = _re.compile(r"^[A-Za-z0-9+/]*={0,2}$") + + +def _base64_payload(part_type: str, data: str | None) -> str: + """Return the base64 payload from a raw base64 string or data URI.""" + if data is None: + raise ValueError( + f"{part_type} has no inline data; fetch url/file_id-addressed media before decoding" + ) + if not isinstance(data, str): + raise TypeError(f"{part_type}.data must be a base64 string") + if data == "": + raise ValueError(f"{part_type}.data cannot be empty") + if data.startswith("data:") and ";base64," in data: + data = data.split(";base64,", 1)[1] + stripped = data.strip() + if ( + stripped != data + or " " in stripped + or "\n" in stripped + or "\r" in stripped + or "\t" in stripped + or "\v" in stripped + or "\f" in stripped + ): + return "".join(stripped.split()) + return data + + +def _validate_base64_data(part_type: str, data: str | None) -> None: + """Validate base64 shape without eagerly decoding large payloads.""" + payload = _base64_payload(part_type, data) + if len(payload) % 4 != 0 or not _BASE64_RE.fullmatch(payload): + raise ValueError(f"{part_type}.data must be a valid base64 string") + + +def _decode_data(part_type: str, data: str | None) -> bytes: + """Decode base64 data to bytes.""" + payload = _base64_payload(part_type, data) + try: + return _base64.b64decode(payload, validate=True) + except (_binascii.Error, ValueError) as e: + raise ValueError(f"{part_type}.data must be a valid base64 string") from e + + +def _base64_summary(data: str | None) -> str | None: + """Return a short repr-safe summary for base64 data.""" + if data is None: + return None + payload = _base64_payload("media", data) + return f"" + + +def _base64_chunk_summary(data: str | None) -> str | None: + """Return a repr-safe summary for a possibly partial base64 chunk.""" + if data is None: + return None + if data.startswith("data:") and ";base64," in data: + data = data.split(";base64,", 1)[1] + stripped = data.strip() + if ( + stripped != data + or " " in stripped + or "\n" in stripped + or "\r" in stripped + or "\t" in stripped + or "\v" in stripped + or "\f" in stripped + ): + stripped = "".join(stripped.split()) + return f"" + + +def _bytes_summary(data: bytes | bytearray | None) -> str | None: + """Return a short repr-safe summary for raw bytes.""" + if data is None: + return None + return f"" + + +@dataclass(frozen=True, slots=True, repr=False) +class _MediaMixin: + """Shared fields, validation, repr, and byte access for media parts.""" + + media_type: str + data: str | None = None + url: str | None = None + file_id: str | None = None + path: Path | None = None + continuation: tuple[ContinuationState, ...] = () + + def __post_init__(self) -> None: + if self.path is not None and not isinstance(self.path, Path): + if str(self.path) == "": + raise ValueError(f"{self.__class__.__name__} path cannot be empty") + object.__setattr__(self, "path", Path(self.path)) + if not isinstance(self.media_type, str) or self.media_type == "": + raise ValueError(f"{self.__class__.__name__} requires media_type") + _validate_media(self.__class__.__name__, self.data, self.url, self.file_id, self.path) + if self.data is not None: + _validate_base64_data(self.__class__.__name__, self.data) + _validate_continuation_field(self) + + def __repr__(self) -> str: + fields = [ + ("media_type", self.media_type), + ("data", _base64_summary(self.data)), + ("url", self.url), + ("file_id", self.file_id), + ("path", self.path), + ] + detail = getattr(self, "detail", None) + if detail is not None: + fields.append(("detail", detail)) + continuation = _continuation_repr(self.continuation) + if continuation is not None: + fields.append(("continuation", continuation)) + args = ", ".join(f"{name}={value!r}" for name, value in fields) + return f"{self.__class__.__name__}({args})" + + @property + def bytes(self) -> bytes: + if self.data is not None: + return _decode_data(self.__class__.__name__, self.data) + if self.path is not None: + return self.path.read_bytes() + raise ValueError( + f"{self.__class__.__name__} has no inline data or path; " + "fetch url/file_id-addressed media before decoding" + ) + + +# ─── Parts ─────────────────────────────────────────────────────────── +# +# The atoms of content. A discriminated union — check .type or use +# isinstance(), then access the fields that belong to that variant. + + +@dataclass(frozen=True, slots=True) +class TextPart: + """A block of text content.""" + + text: str + continuation: tuple[ContinuationState, ...] = () + type: Literal["text"] = field(default="text", init=False) + + def __post_init__(self) -> None: + _validate_text(self.text, field_name="TextPart.text") + _validate_continuation_field(self) + + +@dataclass(frozen=True, slots=True, repr=False) +class ImagePart(_MediaMixin): + """An image, addressed by exactly one of data/url/file_id/path.""" + + media_type: str = "image/png" + detail: Literal["low", "high", "auto"] | None = None + type: Literal["image"] = field(default="image", init=False) + + def __post_init__(self) -> None: + _MediaMixin.__post_init__(self) + _validate_optional_text(self.detail, field_name="ImagePart.detail", allow_empty=False) + if self.detail is not None and self.detail not in {"low", "high", "auto"}: + raise ValueError(f"unsupported ImagePart.detail: {self.detail}") + + +@dataclass(frozen=True, slots=True, repr=False) +class AudioPart(_MediaMixin): + """Audio content, addressed by exactly one of data/url/file_id/path.""" + + media_type: str = "audio/wav" + type: Literal["audio"] = field(default="audio", init=False) + + +@dataclass(frozen=True, slots=True, repr=False) +class VideoPart(_MediaMixin): + """Video content, addressed by exactly one of data/url/file_id/path.""" + + media_type: str = "video/mp4" + type: Literal["video"] = field(default="video", init=False) + + +@dataclass(frozen=True, slots=True, repr=False) +class DocumentPart(_MediaMixin): + """A document (PDF, etc.), addressed by exactly one of data/url/file_id/path.""" + + media_type: str = "application/pdf" + type: Literal["document"] = field(default="document", init=False) + + +@dataclass(frozen=True, slots=True, repr=False) +class BinaryPart(_MediaMixin): + """Arbitrary binary content, addressed by exactly one media source.""" + + media_type: str = "application/octet-stream" + type: Literal["binary"] = field(default="binary", init=False) + + +@dataclass(frozen=True, slots=True) +class ToolCallPart: + """The model requests an external computation.""" + + id: str + name: str + input: JsonObject + continuation: tuple[ContinuationState, ...] = () + type: Literal["tool_call"] = field(default="tool_call", init=False) + + def __post_init__(self) -> None: + _validate_text(self.id, field_name="ToolCallPart.id", allow_empty=False) + _validate_text(self.name, field_name="ToolCallPart.name", allow_empty=False) + _validate_json_field(self, "input", required=True) + _validate_continuation_field(self) + + +@dataclass(frozen=True, slots=True) +class ToolResultPart: + """The result of an external computation, sent back to the model.""" + + id: str + content: tuple[ToolResultContentPart, ...] + name: str | None = None + is_error: bool = False + continuation: tuple[ContinuationState, ...] = () + type: Literal["tool_result"] = field(default="tool_result", init=False) + + def __post_init__(self) -> None: + _validate_text(self.id, field_name="ToolResultPart.id", allow_empty=False) + _validate_optional_text(self.name, field_name="ToolResultPart.name", allow_empty=False) + object.__setattr__(self, "content", tuple(self.content)) + if not self.content: + raise ValueError("ToolResultPart requires content") + if not all(_is_part(p) for p in self.content): + raise TypeError("ToolResultPart.content must contain Part objects") + _validate_bool(self.is_error, field_name="ToolResultPart.is_error") + # Tool results may not contain protocol parts: tool calls, nested + # tool results, model reasoning traces, or refusals. Only the + # presentational variants from ToolResultContentPart are allowed. + if any(isinstance(p, _TOOL_RESULT_FORBIDDEN_PARTS) for p in self.content): + raise TypeError( + "ToolResultPart.content cannot contain tool calls, nested tool " + "results, thinking parts, or refusals" + ) + _validate_input_data_parts("tool results", self.content) + _validate_continuation_field(self) + + +@dataclass(frozen=True, slots=True) +class ThinkingPart: + """Model reasoning trace. + + Hidden thinking (Anthropic ``redacted_thinking``, an OpenAI reasoning + item with no summary) is a ThinkingPart with empty ``text`` and its + replay state in ``continuation``. There is no flag and no placeholder + text (MAP-7 rule 11, ratified 2026-09-06). + """ + + text: str + continuation: tuple[ContinuationState, ...] = () + type: Literal["thinking"] = field(default="thinking", init=False) + + def __post_init__(self) -> None: + _validate_text(self.text, field_name="ThinkingPart.text") + _validate_continuation_field(self) + + +@dataclass(frozen=True, slots=True) +class RefusalPart: + """Model explicitly refused to respond. + + Refusals require non-empty text because they are final semantic content; + empty ``TextPart``/``ThinkingPart`` values remain allowed for streaming + reassembly and provider redaction edge cases. + """ + + text: str + continuation: tuple[ContinuationState, ...] = () + type: Literal["refusal"] = field(default="refusal", init=False) + + def __post_init__(self) -> None: + _validate_text(self.text, field_name="RefusalPart.text", allow_empty=False) + _validate_continuation_field(self) + + +@dataclass(frozen=True, slots=True) +class CitationPart: + """A reference to source material.""" + + url: str | None = None + title: str | None = None + text: str | None = None + continuation: tuple[ContinuationState, ...] = () + type: Literal["citation"] = field(default="citation", init=False) + + def __post_init__(self) -> None: + _validate_optional_text(self.url, field_name="CitationPart.url", allow_empty=False) + _validate_optional_text(self.title, field_name="CitationPart.title", allow_empty=False) + _validate_optional_text(self.text, field_name="CitationPart.text", allow_empty=False) + if self.url is None and self.title is None and self.text is None: + raise ValueError("CitationPart requires at least one of url, title, or text") + _validate_continuation_field(self) + + +def _validate_probabilities(value: Any, *, field_name: str) -> None: + """INV-052: ``{field: {key: float}}``, inner maps non-empty, floats in [0, 1].""" + if not isinstance(value, dict) or not value: + raise TypeError(f"{field_name} must be a non-empty mapping of field -> {{key: probability}}") + for name, dist in value.items(): + if not isinstance(name, str) or not name: + raise TypeError(f"{field_name} keys must be non-empty strings") + if not isinstance(dist, dict) or not dist: + raise TypeError(f"{field_name}[{name!r}] must be a non-empty mapping of key -> probability") + for key, prob in dist.items(): + if not isinstance(key, str) or not key: + raise TypeError(f"{field_name}[{name!r}] keys must be non-empty strings") + if isinstance(prob, bool) or not isinstance(prob, (int, float)): + raise TypeError(f"{field_name}[{name!r}][{key!r}] must be a number") + if not (0.0 <= float(prob) <= 1.0) or not _math.isfinite(float(prob)): + raise ValueError(f"{field_name}[{name!r}][{key!r}] must be in [0, 1]") + + +@dataclass(frozen=True, slots=True) +class DataPart: + """Structured data as content (changes/2026-09-17-judgments.md, D2). + + In a user/system message: structured input (a provider that reads + JSON state takes ``value`` as such; a text-only wire gets it as JSON + text). In an assistant message: the answer to a ``json_schema`` + request that declares judgments (MAP-14) — ``value`` is the model's + JSON object, ``probabilities`` a distribution per judgment over its + declared keys when one was measured, ``method`` how (JudgmentMethod). + ``value`` is an opaque payload: any JSON value, verbatim (INV-002). + """ + + value: JsonValue + probabilities: dict[str, dict[str, float]] | None = None + method: JudgmentMethod | None = None + continuation: tuple[ContinuationState, ...] = () + type: Literal["data"] = field(default="data", init=False) + + def __post_init__(self) -> None: + if not _is_json_value(self.value): + raise TypeError("DataPart.value must be a JSON-compatible value") + if self.probabilities is not None: + _validate_probabilities(self.probabilities, field_name="DataPart.probabilities") + normalized = { + name: {key: float(prob) for key, prob in dist.items()} + for name, dist in self.probabilities.items() + } + object.__setattr__(self, "probabilities", normalized) + if self.method is not None and self.method not in JUDGMENT_METHODS: + raise ValueError(f"DataPart.method must be one of {JUDGMENT_METHODS}, got {self.method!r}") + if (self.method is None) != (self.probabilities is None): + raise ValueError("DataPart.method is present iff DataPart.probabilities is (INV-052)") + _validate_continuation_field(self) + + +_TOOL_RESULT_FORBIDDEN_PARTS: tuple[type, ...] = ( + ToolCallPart, + ToolResultPart, + ThinkingPart, + RefusalPart, +) + + +# The union type. This IS the vocabulary of content. +Part: TypeAlias = ( + TextPart + | ImagePart + | AudioPart + | VideoPart + | DocumentPart + | BinaryPart + | ToolCallPart + | ToolResultPart + | ThinkingPart + | RefusalPart + | CitationPart + | DataPart +) + +# Runtime dispatch table, derived from the union so adding a Part variant has +# one source of truth: the variant class plus the Part union. +def _variant_type(cls: type) -> str: + return cls.__dataclass_fields__["type"].default # type: ignore[attr-defined] + + +PART_CLASSES: tuple[type, ...] = get_args(Part) +PART_TYPES: dict[str, type] = {_variant_type(cls): cls for cls in PART_CLASSES} + + +def _is_part(value: object) -> bool: + """Return True if value is one of lm15's concrete Part variants.""" + return isinstance(value, PART_CLASSES) + + +MediaPart: TypeAlias = ImagePart | AudioPart | VideoPart | DocumentPart | BinaryPart +MEDIA_TYPES: tuple[type, ...] = get_args(MediaPart) + +# Shared endpoint metadata/content aliases +Extensions: TypeAlias = JsonObject +ProviderData: TypeAlias = JsonObject + +# Parts allowed in tool result content: presentational variants only. +# (ToolCallPart, ToolResultPart, ThinkingPart, and RefusalPart are excluded +# both at the type level and in ``ToolResultPart.__post_init__``.) +ToolResultContentPart: TypeAlias = ( + TextPart | ImagePart | AudioPart | VideoPart | DocumentPart | BinaryPart | CitationPart | DataPart +) +ToolResultContent: TypeAlias = str | ToolResultContentPart | Sequence[str | ToolResultContentPart] + +# Parts allowed in prompts (user/developer messages and system content). +# Excludes model/tool protocol parts which are produced by the model or +# tool runtime, never authored by the caller. +PromptPart: TypeAlias = ( + TextPart | ImagePart | AudioPart | VideoPart | DocumentPart | BinaryPart | DataPart +) +PromptContent: TypeAlias = str | PromptPart | Sequence[str | PromptPart] +SystemContent: TypeAlias = PromptContent + +# Parts allowed in assistant messages. This intentionally includes +# model-emitted protocol/artifact parts while still excluding ToolResultPart, +# which belongs only in tool messages. +AssistantPart: TypeAlias = ( + TextPart + | ImagePart + | AudioPart + | VideoPart + | DocumentPart + | BinaryPart + | ToolCallPart + | ThinkingPart + | RefusalPart + | CitationPart + | DataPart +) +AssistantContent: TypeAlias = str | AssistantPart | Sequence[str | AssistantPart] + +# Broad content alias used internally by the normalizer; role-specific +# constructors expose narrower aliases above. +PartInput: TypeAlias = str | Part | Sequence[str | Part] + +# Parts forbidden in prompts (user/developer messages and system content). +# Defined once and reused by every prompt-side validator. +_PROMPT_FORBIDDEN_PARTS: tuple[type, ...] = ( + ToolCallPart, + ToolResultPart, + ThinkingPart, + RefusalPart, + CitationPart, +) + + +# ─── Part constructors ─────────────────────────────────────────────── +# +# Factory functions for the common construction patterns. These live +# at module level — there's no base class to hang them on. + + +def text(content: str, *, continuation: Sequence[ContinuationState] | ContinuationState | None = None) -> TextPart: + """Create a text part.""" + return TextPart(text=content, continuation=_normalize_continuation(continuation)) + + +def thinking( + content: str, + *, + continuation: Sequence[ContinuationState] | ContinuationState | None = None, +) -> ThinkingPart: + return ThinkingPart(text=content, continuation=_normalize_continuation(continuation)) + + +def refusal(content: str, *, continuation: Sequence[ContinuationState] | ContinuationState | None = None) -> RefusalPart: + return RefusalPart(text=content, continuation=_normalize_continuation(continuation)) + + +def citation( + *, + url: str | None = None, + title: str | None = None, + text: str | None = None, + continuation: Sequence[ContinuationState] | ContinuationState | None = None, +) -> CitationPart: + return CitationPart(url=url, title=title, text=text, continuation=_normalize_continuation(continuation)) + + +def data( + value: JsonValue, + *, + probabilities: dict[str, dict[str, float]] | None = None, + method: JudgmentMethod | None = None, + continuation: Sequence[ContinuationState] | ContinuationState | None = None, +) -> DataPart: + """Create a data part (structured input, or a judged answer).""" + return DataPart(value=value, probabilities=probabilities, method=method, + continuation=_normalize_continuation(continuation)) + + +def _encode_data(data: bytes | str) -> str: + """Ensure data is a base64 string.""" + if isinstance(data, bytes): + return _base64.b64encode(data).decode("ascii") + return data + + +def _prepare_media_factory_input( + part_type: str, + *, + url: str | None, + data: bytes | str | None, + file_id: str | None, + path: str | PathLike[str] | None, + media_type: str | None, + default_media_type: str, +) -> tuple[str | None, Path | None, str]: + count = ( + (data is not None) + + (url is not None) + + (file_id is not None) + + (path is not None) + ) + if count != 1: + raise ValueError( + f"{part_type} requires exactly one of data, url, file_id, or path" + ) + media_path: Path | None = None + if path is not None: + if str(path) == "": + raise ValueError(f"{part_type} path cannot be empty") + media_path = Path(path) + media_type = media_type or _mimetypes.guess_type(str(media_path))[0] + encoded = _encode_data(data) if data is not None else None + return encoded, media_path, media_type or default_media_type + + +def image( + *, + url: str | None = None, + data: bytes | str | None = None, + path: str | PathLike[str] | None = None, + file_id: str | None = None, + media_type: str | None = None, + detail: Literal["low", "high", "auto"] | None = None, + continuation: Sequence[ContinuationState] | ContinuationState | None = None, +) -> ImagePart: + encoded_data, media_path, resolved_media_type = _prepare_media_factory_input( + "ImagePart", + url=url, + data=data, + file_id=file_id, + path=path, + media_type=media_type, + default_media_type="image/png", + ) + return ImagePart( + media_type=resolved_media_type, + data=encoded_data, + url=url, + file_id=file_id, + path=media_path, + detail=detail, + continuation=_normalize_continuation(continuation), + ) + + +def audio( + *, + url: str | None = None, + data: bytes | str | None = None, + path: str | PathLike[str] | None = None, + file_id: str | None = None, + media_type: str | None = None, + continuation: Sequence[ContinuationState] | ContinuationState | None = None, +) -> AudioPart: + encoded_data, media_path, resolved_media_type = _prepare_media_factory_input( + "AudioPart", + url=url, + data=data, + file_id=file_id, + path=path, + media_type=media_type, + default_media_type="audio/wav", + ) + return AudioPart( + media_type=resolved_media_type, + data=encoded_data, + url=url, + file_id=file_id, + path=media_path, + continuation=_normalize_continuation(continuation), + ) + + +def video( + *, + url: str | None = None, + data: bytes | str | None = None, + path: str | PathLike[str] | None = None, + file_id: str | None = None, + media_type: str | None = None, + continuation: Sequence[ContinuationState] | ContinuationState | None = None, +) -> VideoPart: + encoded_data, media_path, resolved_media_type = _prepare_media_factory_input( + "VideoPart", + url=url, + data=data, + file_id=file_id, + path=path, + media_type=media_type, + default_media_type="video/mp4", + ) + return VideoPart( + media_type=resolved_media_type, + data=encoded_data, + url=url, + file_id=file_id, + path=media_path, + continuation=_normalize_continuation(continuation), + ) + + +def document( + *, + url: str | None = None, + data: bytes | str | None = None, + path: str | PathLike[str] | None = None, + file_id: str | None = None, + media_type: str | None = None, + continuation: Sequence[ContinuationState] | ContinuationState | None = None, +) -> DocumentPart: + encoded_data, media_path, resolved_media_type = _prepare_media_factory_input( + "DocumentPart", + url=url, + data=data, + file_id=file_id, + path=path, + media_type=media_type, + default_media_type="application/pdf", + ) + return DocumentPart( + media_type=resolved_media_type, + data=encoded_data, + url=url, + file_id=file_id, + path=media_path, + continuation=_normalize_continuation(continuation), + ) + + +def binary( + *, + url: str | None = None, + data: bytes | str | None = None, + path: str | PathLike[str] | None = None, + file_id: str | None = None, + media_type: str | None = None, + continuation: Sequence[ContinuationState] | ContinuationState | None = None, +) -> BinaryPart: + encoded_data, media_path, resolved_media_type = _prepare_media_factory_input( + "BinaryPart", + url=url, + data=data, + file_id=file_id, + path=path, + media_type=media_type, + default_media_type="application/octet-stream", + ) + return BinaryPart( + media_type=resolved_media_type, + data=encoded_data, + url=url, + file_id=file_id, + path=media_path, + continuation=_normalize_continuation(continuation), + ) + + +def tool_call( + id: str, + name: str, + input: JsonObject, + *, + continuation: Sequence[ContinuationState] | ContinuationState | None = None, +) -> ToolCallPart: + return ToolCallPart(id=id, name=name, input=input, continuation=_normalize_continuation(continuation)) + + +def tool_result( + id: str, + content: ToolResultContent, + *, + name: str | None = None, + is_error: bool = False, + continuation: Sequence[ContinuationState] | ContinuationState | None = None, +) -> ToolResultPart: + """Create a tool result part. + + content can be a sequence of parts, a single part, or a string + (which becomes a TextPart). Raw bytes should be wrapped in a media part; + use BinaryPart for arbitrary bytes that are not image/audio/video/document. + """ + parts = _normalize_parts(content) # type: ignore[arg-type] + return ToolResultPart( + id=id, + content=parts, + name=name, + is_error=is_error, + continuation=_normalize_continuation(continuation), + ) + + +# ─── Messages ──────────────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class Message: + """A contribution to a conversation, attributed to a speaker. + + A message is a sequence of typed Parts. Roles: + + - ``user`` — end-user input + - ``assistant`` — model output + - ``tool`` — tool execution results + - ``developer`` — high-authority instructions from the application + developer. Can appear mid-conversation to inject + new instructions without invalidating the KV-cache + prefix. On OpenAI this maps to the native + ``developer`` role; on other providers the LM + converts it to a user message with a clear prefix. + """ + + role: Role + parts: tuple[Part, ...] + continuation: tuple[ContinuationState, ...] = () + + def __post_init__(self) -> None: + if self.role not in ROLE_VALUES: + raise ValueError(f"unsupported role: {self.role}") + if isinstance(self.parts, str): + raise TypeError("Message.parts must be Part objects; use Message.user('text') for strings") + parts = (self.parts,) if _is_part(self.parts) else tuple(self.parts) + object.__setattr__(self, "parts", parts) + if not self.parts: + raise ValueError("Message requires at least one part") + if not all(_is_part(p) for p in self.parts): + raise TypeError("Message.parts must contain Part objects") + _validate_continuation_field(self) + _validate_message_parts(self.role, self.parts) + + @staticmethod + def user(content: PromptContent) -> "Message": + return Message(role="user", parts=_normalize_parts(content)) + + @staticmethod + def assistant(content: AssistantContent) -> "Message": + return Message(role="assistant", parts=_normalize_parts(content)) + + @staticmethod + def developer(content: PromptContent) -> "Message": + """Create a developer message. + + Developer messages carry instructions with higher authority than + user messages (equivalent to OpenAI's ``developer`` role). They + can appear anywhere in the conversation — including mid-conversation + — which is useful for injecting new instructions without + invalidating the KV-cache prefix built from earlier turns. + + On providers that don't natively support a developer role + (Anthropic, Gemini), the LM converts these to user messages + with a clear ``[developer]`` prefix so the model still sees the + instruction boundary. + """ + return Message(role="developer", parts=_normalize_parts(content)) + + @staticmethod + def tool( + results: "str | ToolResultPart | Sequence[ToolResultPart] | dict[str, ToolResultContent]", + output: "ToolResultContent | None" = None, + *, + is_error: bool = False, + ) -> "Message": + """Create a tool message. Three spellings: + + - ``Message.tool(call_id, output, is_error=False)`` — one result. + - ``Message.tool({call_id: output, ...})`` — answer several calls + at once (cannot express is_error; use the other forms). + - ``Message.tool(part_or_parts)`` — explicit ToolResultPart(s), + e.g. from :func:`lm15.tool_result`. + """ + if isinstance(results, str): + if output is None: + raise TypeError( + "Message.tool(call_id) is missing the output. Accepted " + "spellings: Message.tool(call_id, output, is_error=...), " + "Message.tool({call_id: output, ...}), or " + "Message.tool(ToolResultPart(...))." + ) + return Message( + role="tool", + parts=(tool_result(results, output, is_error=is_error),), + ) + if output is not None: + raise TypeError( + "Message.tool() takes an output only with a call-id string: " + "Message.tool(call_id, output, is_error=...)." + ) + if is_error: + raise TypeError( + "Message.tool({...}, is_error=True) is ambiguous — the dict " + "form cannot say which result errored. Use " + "Message.tool(call_id, output, is_error=True) or " + "lm15.tool_result(call_id, output, is_error=True)." + ) + if isinstance(results, dict): + parts: list[Part] = [] + for call_id, value in results.items(): + parts.append(tool_result(call_id, value)) + return Message(role="tool", parts=tuple(parts)) + parts = (results,) if isinstance(results, ToolResultPart) else tuple(results) + if not all(isinstance(p, ToolResultPart) for p in parts): + raise TypeError( + "Message.tool() requires ToolResultPart objects. Accepted " + "spellings: Message.tool(call_id, output, is_error=...), " + "Message.tool({call_id: output, ...}), or " + "Message.tool(ToolResultPart(...))." + ) + return Message(role="tool", parts=parts) + + def parts_of(self, cls: type[_P]) -> list[_P]: + """Return all parts that are instances of ``cls``.""" + return [p for p in self.parts if isinstance(p, cls)] + + def first(self, cls: type[_P]) -> _P | None: + """Return the first part that is an instance of ``cls``, if any.""" + return next((p for p in self.parts if isinstance(p, cls)), None) + + @property + def text(self) -> str | None: + """Text only when the message contains text and nothing else.""" + if not all(isinstance(p, TextPart) for p in self.parts): + return None + return "\n".join(p.text for p in self.parts) + + +def _normalize_parts(content: PartInput) -> tuple[Part, ...]: + if isinstance(content, str): + return (TextPart(text=content),) + if _is_part(content): + return (content,) + if isinstance(content, Sequence): + raw_parts = tuple(content) + if not raw_parts: + raise ValueError("content sequence cannot be empty") + parts: list[Part] = [] + for part in raw_parts: + if isinstance(part, str): + parts.append(TextPart(text=part)) + elif _is_part(part): + parts.append(part) + else: + raise TypeError("content sequence must contain strings or Part objects") + return tuple(parts) + raise TypeError("content must be a string, Part, or sequence of Parts") + + +def _validate_message_parts(role: Role, parts: tuple[Part, ...]) -> None: + if role == "tool": + if not all(isinstance(p, ToolResultPart) for p in parts): + raise TypeError("tool messages may only contain ToolResultPart objects") + return + if role == "assistant": + if any(isinstance(p, ToolResultPart) for p in parts): + raise TypeError( + "assistant messages cannot contain ToolResultPart objects" + ) + return + # User and developer messages carry prompts/instructions, not model/tool + # protocol parts and not model-emitted artifacts like citations. + if any(isinstance(p, _PROMPT_FORBIDDEN_PARTS) for p in parts): + raise TypeError(f"{role} messages cannot contain model/tool protocol parts") + _validate_input_data_parts(role, parts) + + +def _validate_input_data_parts(where: str, parts: tuple[Part, ...]) -> None: + """INV-052: a distribution is a claim about an answer; input carries value alone.""" + for p in parts: + if isinstance(p, DataPart) and p.probabilities is not None: + raise TypeError(f"{where} data parts carry value only; probabilities belong to assistant messages (INV-052)") + + +def _normalize_system(system: SystemContent | None) -> str | tuple[PromptPart, ...] | None: + if system is None: + return None + if isinstance(system, str): + if system == "": + raise ValueError("system cannot be empty") + return system + parts = _normalize_parts(system) + if any(isinstance(p, _PROMPT_FORBIDDEN_PARTS) for p in parts): + raise TypeError("system parts cannot contain model/tool protocol parts") + _validate_input_data_parts("system", parts) + return parts + + +# ─── Deltas ────────────────────────────────────────────────────────── +# +# A Delta is a typed fragment of a Part being assembled during +# streaming. Like Part, Delta is a discriminated union: fields that +# don't belong to a variant don't exist on it. + +DeltaType = Literal["text", "thinking", "audio", "image", "tool_call", "citation", "continuation"] + + +@dataclass(frozen=True, slots=True) +class TextDelta: + """A text fragment arriving during streaming. + + ``logprobs`` carries the token logprobs for exactly the tokens in this + fragment, when the request asked for them (``Config.logprobs``) and the + provider streams them per chunk (verified live for OpenAI Responses, + 2026-09-01). Materialization concatenates fragment logprobs in arrival + order into ``Response.logprobs``. ``logprobs_complete=False`` means + local text editing left some retained text without its original score + (for example, a stop inside a token). Scores always describe original, + whole provider tokens. True does not promise the provider supplied any. + """ + + text: str + part_index: int = 0 + logprobs: tuple[TokenLogprob, ...] = () + # False when local text editing removed scores that cannot describe + # the retained text. True does not promise the provider supplied scores. + logprobs_complete: bool = field(default=True, kw_only=True) + type: Literal["text"] = field(default="text", init=False) + + def __post_init__(self) -> None: + _coerce_int_field(self, "part_index") + _validate_part_index(self.part_index) + _validate_text(self.text, field_name="TextDelta.text") + _validate_logprobs_field(self, "logprobs", allow_none=False) + _validate_bool(self.logprobs_complete, field_name="TextDelta.logprobs_complete") + + +@dataclass(frozen=True, slots=True) +class ThinkingDelta: + """A reasoning/thinking fragment arriving during streaming.""" + + text: str + part_index: int = 0 + type: Literal["thinking"] = field(default="thinking", init=False) + + def __post_init__(self) -> None: + _coerce_int_field(self, "part_index") + _validate_part_index(self.part_index) + _validate_text(self.text, field_name="ThinkingDelta.text") + + +@dataclass(frozen=True, slots=True, repr=False) +class AudioDelta: + """An audio fragment arriving during streaming. + + Media deltas are partial stream chunks, not final media parts. They may + carry unaligned base64 data, a URL/file id update, or metadata only; final + media validation happens when the stream is assembled into an AudioPart. + """ + + data: str | None = None + url: str | None = None + file_id: str | None = None + part_index: int = 0 + media_type: str | None = None + type: Literal["audio"] = field(default="audio", init=False) + + def __post_init__(self) -> None: + _coerce_int_field(self, "part_index") + _validate_part_index(self.part_index) + _validate_optional_text(self.data, field_name="AudioDelta.data") + _validate_optional_text(self.url, field_name="AudioDelta.url") + _validate_optional_text(self.file_id, field_name="AudioDelta.file_id") + _validate_media_delta_addresses("AudioDelta", self.data, self.url, self.file_id) + _validate_optional_text(self.media_type, field_name="AudioDelta.media_type", allow_empty=False) + + def __repr__(self) -> str: + return ( + "AudioDelta(" + f"data={_base64_chunk_summary(self.data)!r}, " + f"url={self.url!r}, " + f"file_id={self.file_id!r}, " + f"part_index={self.part_index!r}, " + f"media_type={self.media_type!r})" + ) + + +@dataclass(frozen=True, slots=True, repr=False) +class ImageDelta: + """An image fragment, addressed by exactly one of data/url/file_id.""" + + data: str | None = None + url: str | None = None + file_id: str | None = None + part_index: int = 0 + media_type: str | None = None + type: Literal["image"] = field(default="image", init=False) + + def __post_init__(self) -> None: + _coerce_int_field(self, "part_index") + _validate_part_index(self.part_index) + _validate_optional_text(self.data, field_name="ImageDelta.data") + _validate_optional_text(self.url, field_name="ImageDelta.url") + _validate_optional_text(self.file_id, field_name="ImageDelta.file_id") + _validate_media_delta_addresses("ImageDelta", self.data, self.url, self.file_id) + _validate_optional_text(self.media_type, field_name="ImageDelta.media_type", allow_empty=False) + + def __repr__(self) -> str: + return ( + "ImageDelta(" + f"data={_base64_chunk_summary(self.data)!r}, " + f"url={self.url!r}, " + f"file_id={self.file_id!r}, " + f"part_index={self.part_index!r}, " + f"media_type={self.media_type!r})" + ) + + +@dataclass(frozen=True, slots=True) +class ToolCallDelta: + """A tool-call input fragment, optionally carrying call identity.""" + + input: str + part_index: int = 0 + id: str | None = None + name: str | None = None + type: Literal["tool_call"] = field(default="tool_call", init=False) + + def __post_init__(self) -> None: + _coerce_int_field(self, "part_index") + _validate_part_index(self.part_index) + _validate_text(self.input, field_name="ToolCallDelta.input") + _validate_optional_text(self.id, field_name="ToolCallDelta.id", allow_empty=False) + _validate_optional_text(self.name, field_name="ToolCallDelta.name", allow_empty=False) + + +@dataclass(frozen=True, slots=True) +class CitationDelta: + """A citation fragment arriving during streaming.""" + + text: str | None = None + url: str | None = None + title: str | None = None + part_index: int = 0 + type: Literal["citation"] = field(default="citation", init=False) + + def __post_init__(self) -> None: + _coerce_int_field(self, "part_index") + _validate_part_index(self.part_index) + _validate_optional_text(self.text, field_name="CitationDelta.text") + _validate_optional_text(self.url, field_name="CitationDelta.url") + _validate_optional_text(self.title, field_name="CitationDelta.title") + if self.text is None and self.url is None and self.title is None: + raise ValueError("CitationDelta requires at least one of text, url, or title") + + +@dataclass(frozen=True, slots=True) +class ContinuationDelta: + """Opaque provider continuation state arriving during streaming. + + part_index=None attaches the state to the assistant message; otherwise it + attaches to the completed part with that index. + """ + + provider: str + kind: ContinuationKind + data: JsonObject = field(default_factory=dict) + part_index: int | None = None + type: Literal["continuation"] = field(default="continuation", init=False) + + def __post_init__(self) -> None: + _validate_text(self.provider, field_name="ContinuationDelta.provider", allow_empty=False) + _validate_text(self.kind, field_name="ContinuationDelta.kind", allow_empty=False) + _validate_json_field(self, "data", required=True) + if self.part_index is not None: + _coerce_int_field(self, "part_index") + _validate_part_index(self.part_index) + + def to_state(self) -> ContinuationState: + return ContinuationState(provider=self.provider, kind=self.kind, data=self.data) + + +Delta: TypeAlias = ( + TextDelta + | ThinkingDelta + | AudioDelta + | ImageDelta + | ToolCallDelta + | CitationDelta + | ContinuationDelta +) + +DELTA_CLASSES: tuple[type, ...] = get_args(Delta) +DELTA_TYPES: dict[str, type] = {_variant_type(cls): cls for cls in DELTA_CLASSES} + +# Streaming boundary: content Delta variants assemble into Parts. Some Delta +# variants (currently ContinuationDelta) carry stream metadata that attaches to +# a Message or Part rather than materializing as content. +StreamablePart: TypeAlias = TextPart | ThinkingPart | ImagePart | AudioPart | ToolCallPart | CitationPart +NonStreamablePart: TypeAlias = VideoPart | DocumentPart | BinaryPart | ToolResultPart | RefusalPart | DataPart + +_STREAMABLE_PART_CLASSES: tuple[type, ...] = get_args(StreamablePart) +_NON_STREAMABLE_PART_CLASSES: tuple[type, ...] = get_args(NonStreamablePart) +_STATE_DELTA_TYPES = frozenset({"continuation"}) + + +def _check_streamable_partition() -> None: + streamable = {_variant_type(cls) for cls in _STREAMABLE_PART_CLASSES} + non_streamable = {_variant_type(cls) for cls in _NON_STREAMABLE_PART_CLASSES} + delta_types = set(DELTA_TYPES) - set(_STATE_DELTA_TYPES) + part_types = set(PART_TYPES) + + overlap = streamable & non_streamable + if overlap: + raise RuntimeError( + f"StreamablePart and NonStreamablePart overlap on: {sorted(overlap)}" + ) + union = streamable | non_streamable + if union != part_types: + missing = part_types - union + extra = union - part_types + raise RuntimeError( + "StreamablePart ∪ NonStreamablePart must equal Part. " + f"missing={sorted(missing)} extra={sorted(extra)}" + ) + if delta_types != streamable: + raise RuntimeError( + "Delta variants must match StreamablePart. " + f"delta_types={sorted(delta_types)} streamable={sorted(streamable)}" + ) + + +_check_streamable_partition() + + +# ─── Stream Events ─────────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class ErrorDetail: + """Structured error information. A dataclass, not a dict.""" + + code: ErrorCode + message: str + provider_code: str | None = None + # Bounded handshake diagnostics; absent means no HTTP evidence, not success. + http_response: JsonObject = field(default_factory=dict) + + def __post_init__(self) -> None: + from .rate_limits import normalize_http_response + + object.__setattr__(self, "http_response", normalize_http_response(self.http_response)) + if self.code not in ERROR_CODES: + raise ValueError(f"unsupported error code: {self.code}") + _validate_text(self.message, field_name="ErrorDetail.message") + _validate_optional_text(self.provider_code, field_name="ErrorDetail.provider_code", allow_empty=False) + + +@dataclass(frozen=True, slots=True) +class StreamStartEvent: + """The response stream has started.""" + + id: str | None = None + model: str | None = None + # MAP-13: what the wire got that differs from what was asked, known + # before the first byte and so carried by the first event. + adaptations: tuple[Adaptation, ...] = () + type: Literal["start"] = field(default="start", init=False) + + def __post_init__(self) -> None: + _validate_optional_text(self.id, field_name="StreamStartEvent.id", allow_empty=False) + _validate_optional_text(self.model, field_name="StreamStartEvent.model", allow_empty=False) + _validate_adaptations(self, "StreamStartEvent.adaptations") + + +@dataclass(frozen=True, slots=True) +class StreamDeltaEvent: + """A typed content delta arrived.""" + + delta: Delta + type: Literal["delta"] = field(default="delta", init=False) + + def __post_init__(self) -> None: + if not isinstance(self.delta, DELTA_CLASSES): + raise TypeError("StreamDeltaEvent.delta must be a Delta") + + +@dataclass(frozen=True, slots=True, repr=False) +class StreamEndEvent: + """The response stream completed.""" + + finish_reason: FinishReason | None = None + usage: "Usage | None" = None + provider_data: ProviderData | None = None + type: Literal["end"] = field(default="end", init=False) + + def __post_init__(self) -> None: + if self.finish_reason is not None and self.finish_reason not in FINISH_REASONS: + raise ValueError(f"unsupported finish reason: {self.finish_reason}") + if self.usage is not None and not isinstance(self.usage, Usage): + raise TypeError("StreamEndEvent.usage must be a Usage") + _validate_json_field(self, "provider_data") + + def __repr__(self) -> str: + fields = [] + if self.finish_reason is not None: + fields.append(("finish_reason", repr(self.finish_reason))) + if self.usage is not None: + fields.append(("usage", repr(self.usage))) + if self.provider_data is not None: + if isinstance(self.provider_data, dict): + fields.append(("provider_data", f"")) + else: + fields.append(("provider_data", "")) + fields.append(("type", repr(self.type))) + + body = ",\n".join(f" {name}={value}" for name, value in fields) + return f"StreamEndEvent(\n{body},\n)" + + +@dataclass(frozen=True, slots=True) +class StreamErrorEvent: + """The stream failed.""" + + error: ErrorDetail + type: Literal["error"] = field(default="error", init=False) + + def __post_init__(self) -> None: + if not isinstance(self.error, ErrorDetail): + raise TypeError("StreamErrorEvent.error must be an ErrorDetail") + + +StreamEvent: TypeAlias = StreamStartEvent | StreamDeltaEvent | StreamEndEvent | StreamErrorEvent +STREAM_EVENT_CLASSES: tuple[type, ...] = get_args(StreamEvent) + + +# ─── Tools ─────────────────────────────────────────────────────────── + +@dataclass(frozen=True, slots=True) +class FunctionTool: + """Serializable function tool specification sent to the model.""" + + name: str + description: str | None = None + parameters: JsonObject = field( + default_factory=lambda: {"type": "object", "properties": {}} + ) + type: Literal["function"] = field(default="function", init=False) + + def __post_init__(self) -> None: + _validate_text(self.name, field_name="FunctionTool.name", allow_empty=False) + _validate_optional_text(self.description, field_name="FunctionTool.description") + _validate_json_field(self, "parameters", required=True) + +@dataclass(frozen=True, slots=True) +class BuiltinTool: + """A provider-native tool (web search, code execution, etc.).""" + + name: str + config: JsonObject | None = None + type: Literal["builtin"] = field(default="builtin", init=False) + + def __post_init__(self) -> None: + _validate_text(self.name, field_name="BuiltinTool.name", allow_empty=False) + _validate_json_field(self, "config") + + +Tool: TypeAlias = FunctionTool | BuiltinTool + + +# ─── Configuration ─────────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class Reasoning: + """How much hidden thinking the model does before it answers (MAP-7). + + ``effort`` is the one dial and is required. Vocabulary: ``off``, + ``minimal``, ``low``, ``medium``, ``high``, ``xhigh``, ``max``. Every + provider has this dial with these words; each model accepts a subset + and rejects the rest loudly. Adapters send the word verbatim where + the provider has levels; budget-only model classes (Anthropic 4.5 and + earlier, Gemini 2.5) express it through one documented grading table. + Words with no native level on a provider RAISE rather than downgrade. + + There is no "adaptive" value: leaving ``config.reasoning`` unset is + how you let the model decide, on every provider. ``effort="off"`` + reaches the wire as the provider's disable or fails loudly where the + provider cannot disable (MAP-5). + + ``thinking_budget`` is a token cap on providers that count thinking + separately (Anthropic manual class, Gemini); it RAISES where the wire + has no budget. On budget-only classes the budget is the spelling on + the wire and ``effort`` is the intent. ``total_budget`` was removed + 2026-09-02: ``Config.max_tokens`` is the ceiling. + + ``summary`` is visibility: ``None`` = provider default, ``"auto"`` = + show the thinking where a knob exists (satisfied silently where the + provider always shows it), ``"concise"``/``"detailed"`` = OpenAI's + detail levels (RAISE elsewhere). + """ + + effort: ReasoningEffort + thinking_budget: int | None = None + summary: ReasoningSummary | None = None + + def __post_init__(self) -> None: + if self.effort not in REASONING_EFFORTS: + raise ValueError(f"unsupported reasoning effort: {self.effort}") + if self.summary is not None and self.summary not in REASONING_SUMMARIES: + raise ValueError(f"unsupported reasoning summary: {self.summary}") + _coerce_int_field(self, "thinking_budget") + _validate_positive(self.thinking_budget, field_name="thinking_budget") + if self.effort == "off" and (self.thinking_budget is not None or self.summary is not None): + raise ValueError( + "Reasoning(effort='off') cannot specify thinking_budget or summary" + ) + + @property + def is_off(self) -> bool: + return self.effort == "off" + + +# ─── Cache / Prompt Caching ────────────────────────────────── + +CacheRetention = Literal["short", "long"] +CacheMode = Literal["auto", "off"] +CachePrefix = Literal["stable", "history"] +CACHE_MODES = frozenset(get_args(CacheMode)) +CACHE_RETENTIONS = frozenset(get_args(CacheRetention)) +CACHE_PREFIXES = frozenset(get_args(CachePrefix)) + + +@dataclass(frozen=True, slots=True) +class CacheConfig: + """ + Universal prompt cache configuration (MAP-6, docs/mapping-rules.md). + + Every provider caches in up to three tiers: automatic (nothing to + send, best-effort), breakpoint (a mark on a block, guaranteed above a + per-model minimum), resource (a stored, named, billed object). The + fields name INTENTS; each adapter maps an intent to the best tier it + has, and the result is always visible in ``Usage.cache_read_tokens``. + + - mode="auto": the cheapest safe instruction (a mark on the stable + beginning where marks exist; nothing elsewhere). + - mode="off": send nothing; where a provider has a real off switch + for cache WRITES, send it. + - prefix="stable": mark the end of system + tools. + prefix="history": mark the end of the last message (a growing + chat). Providers without marks fall back to the automatic tier: + that fallback spends nothing and its outcome is observable, the + two conditions that permit it. + - prefix_until_index: the same intent with a precise position (the + last block of that message). Mutually exclusive with ``prefix``. + - retention: "long" asks for the longer lifetime at extra cost; + providers without one RAISE. + - key: a best-effort routing/affinity hint; providers without one RAISE. + - resource: the id of a stored cache object (``CacheInfo.id``) to + reference; providers without the resource tier RAISE. + """ + + mode: CacheMode = "auto" + retention: CacheRetention | None = None + key: str | None = None + prefix_until_index: int | None = None + prefix: CachePrefix | None = None + resource: str | None = None + + def __post_init__(self) -> None: + if self.mode not in CACHE_MODES: + raise ValueError(f"unsupported cache mode: {self.mode}") + if self.retention is not None and self.retention not in CACHE_RETENTIONS: + raise ValueError(f"unsupported cache retention: {self.retention}") + if self.prefix is not None and self.prefix not in CACHE_PREFIXES: + raise ValueError(f"unsupported cache prefix: {self.prefix}") + _validate_optional_text(self.key, field_name="CacheConfig.key", allow_empty=False) + _validate_optional_text(self.resource, field_name="CacheConfig.resource", allow_empty=False) + if self.mode == "off" and ( + self.retention is not None or self.key is not None + or self.prefix is not None or self.prefix_until_index is not None or self.resource is not None + ): + raise ValueError("CacheConfig(mode='off') cannot specify retention, key, prefix, prefix_until_index, or resource") + if self.prefix is not None and self.prefix_until_index is not None: + raise ValueError("CacheConfig cannot specify both prefix and prefix_until_index") + if self.prefix_until_index is not None: + _coerce_int_field(self, "prefix_until_index") + _validate_non_negative(self.prefix_until_index, field_name="prefix_until_index") + + +# ─── Tool Choice ─────────────────────────────────────────────────────── + +@dataclass(frozen=True, slots=True) +class ToolChoice: + """How the model should use tools.""" + + mode: ToolChoiceMode = "auto" + allowed: tuple[str, ...] = () + parallel: bool | None = None + + def __post_init__(self) -> None: + if self.mode not in TOOL_CHOICE_MODES: + raise ValueError(f"unsupported tool choice mode: {self.mode}") + allowed = (self.allowed,) if isinstance(self.allowed, str) else tuple(self.allowed) + object.__setattr__(self, "allowed", allowed) + if any(not isinstance(name, str) or not name for name in self.allowed): + raise ValueError("ToolChoice.allowed must contain non-empty tool names") + _validate_optional_bool(self.parallel, field_name="ToolChoice.parallel") + if self.mode == "none" and (self.allowed or self.parallel is not None): + raise ValueError("ToolChoice(mode='none') cannot specify allowed or parallel") + + @classmethod + def from_tools( + cls, + allowed: Tool | Sequence[Tool | str], + *, + mode: ToolChoiceMode = "auto", + parallel: bool | None = None, + ) -> "ToolChoice": + """Create a choice by explicitly converting Tool objects to names.""" + if isinstance(allowed, str): + names = (allowed,) + elif isinstance(allowed, (FunctionTool, BuiltinTool)): + names = (allowed.name,) + else: + names = tuple(item.name if isinstance(item, (FunctionTool, BuiltinTool)) else item for item in allowed) + return cls(mode=mode, allowed=names, parallel=parallel) + + +@dataclass(frozen=True, slots=True) +class Config: + """Generation parameters. + + Universal fields are typed. Provider-specific settings go in + `extensions` — a clearly-separated namespace that never pretends + to be part of the universal schema. + """ + + max_tokens: int | None = None + # temperature's canonical range is 0–2 (OpenAI's and Gemini's); a wire + # whose ceiling is 1 (Anthropic) clamps and records it (MAP-13). Never + # rescaled: both scales default to 1.0 and 0–1 is the same cool half. + temperature: float | None = None + top_p: float | None = None + top_k: int | None = None + stop: tuple[str, ...] = () + response_format: JsonObject | None = None + tool_choice: ToolChoice | None = None + reasoning: Reasoning | None = None + cache: CacheConfig | None = None + # Sampling knobs promoted from extensions 2026-09-14 (MAP-13 audit): + # OpenAI (both dialects), Gemini (generationConfig.seed / + # frequencyPenalty / presencePenalty) and every OpenAI-compatible + # server carry them; as extensions they reached Gemini at the wrong + # path. seed is best-effort determinism everywhere it exists; the + # penalties are in [-2, 2] on every wire that has them. A wire + # without them (Anthropic) drops and records. + seed: int | None = None + frequency_penalty: float | None = None + presence_penalty: float | None = None + # Cross-provider knobs promoted from extensions (2026-09-01 burn-down): + # service_tier is an OPEN string namespace — the tier concept is shared + # (OpenAI, Anthropic), the value vocabulary is provider-owned, like + # `voice`. user_id is an opaque end-user identifier for abuse + # attribution (OpenAI safety_identifier, Anthropic metadata.user_id). + # store opts a request out of (or into) provider-side response storage + # (OpenAI and Gemini, same wire key). Providers whose wire cannot carry + # a set knob RAISE — never silently drop. + service_tier: str | None = None + user_id: str | None = None + store: bool | None = None + # logprobs: request token log probabilities. None = do not request; + # 0 = chosen tokens only; n > 0 = also the top-n alternatives per + # position. Provider caps (currently 0–20) are provider-owned — lm15 + # does not hard-code them; an over-cap value maps to the provider's + # own InvalidRequestError. Providers without logprobs (Anthropic) + # RAISE — never silently drop. + logprobs: int | None = None + # probabilities: ask for a distribution over the keys the json_schema + # declares (MAP-14). None = off. "if_available": a wire that cannot + # measure one records `dropped`; "required": it refuses before the + # wire (changes/2026-09-17-judgments.md, D3). + probabilities: ProbabilityPolicy | None = None + extensions: Extensions | None = None + + def __post_init__(self) -> None: + stop = (self.stop,) if isinstance(self.stop, str) else tuple(self.stop or ()) + object.__setattr__(self, "stop", stop) + _coerce_int_field(self, "max_tokens") + _coerce_int_field(self, "top_k") + _validate_positive(self.max_tokens, field_name="max_tokens") + _validate_positive(self.top_k, field_name="top_k") + for field_name in ("temperature", "top_p"): + value = getattr(self, field_name) + if value is not None and ( + isinstance(value, bool) or not isinstance(value, (int, float)) + ): + raise TypeError(f"{field_name} must be numeric") + _coerce_float_field(self, field_name) + if self.temperature is not None and not (0 <= self.temperature <= 2): + raise ValueError("temperature must be in [0, 2]") + if self.top_p is not None and not (0 <= self.top_p <= 1): + raise ValueError("top_p must be in [0, 1]") + _coerce_int_field(self, "seed") + if self.seed is not None and (isinstance(self.seed, bool) or not isinstance(self.seed, int)): + raise TypeError("seed must be an int") + for field_name in ("frequency_penalty", "presence_penalty"): + value = getattr(self, field_name) + if value is not None and ( + isinstance(value, bool) or not isinstance(value, (int, float)) + ): + raise TypeError(f"{field_name} must be numeric") + _coerce_float_field(self, field_name) + if value is not None and not (-2 <= value <= 2): + raise ValueError(f"{field_name} must be in [-2, 2]") + if any(not isinstance(s, str) or not s for s in self.stop): + raise ValueError("stop must contain non-empty strings") + if self.tool_choice is not None and not isinstance(self.tool_choice, ToolChoice): + raise TypeError("tool_choice must be a ToolChoice") + if self.reasoning is not None and not isinstance(self.reasoning, Reasoning): + raise TypeError("reasoning must be a Reasoning") + if self.cache is not None and not isinstance(self.cache, CacheConfig): + raise TypeError("cache must be a CacheConfig") + _validate_optional_text(self.service_tier, field_name="Config.service_tier", allow_empty=False) + _validate_optional_text(self.user_id, field_name="Config.user_id", allow_empty=False) + if self.store is not None and not isinstance(self.store, bool): + raise TypeError("Config.store must be a bool or None") + _coerce_int_field(self, "logprobs") + _validate_non_negative(self.logprobs, field_name="logprobs") + _validate_json_field(self, "response_format") + _validate_response_format_shape(self.response_format) + if self.probabilities is not None and self.probabilities not in PROBABILITY_POLICIES: + raise ValueError(f"Config.probabilities must be one of {PROBABILITY_POLICIES}, got {self.probabilities!r}") + _validate_extensions_field(self) + + +_RESPONSE_FORMAT_TYPES = ("json_object", "json_schema") + + +def _validate_response_format_shape(value: JsonObject | None) -> None: + """INV-050 (MAP-8, 2026-09-02): response_format has exactly two shapes. + + ``{"type": "json_object"}`` — any valid JSON; + ``{"type": "json_schema", "schema": , "name"?: str, + "strict"?: bool}`` — this schema. Provider-native spellings + (``{"format": ...}``, ``{"response_mime_type": ...}``, + ``{"json_schema": {...}}``, a bare schema) belong in ``extensions``. + ``schema`` is opaque and verbatim: lm15 never rewrites a keyword to + make a request pass; the provider's 400 is the contract. + """ + if value is None: + return + fmt = value.get("type") + if fmt not in _RESPONSE_FORMAT_TYPES: + raise ValueError( + "response_format must be {'type': 'json_object'} or {'type': 'json_schema', " + "'schema': {...}, 'name'?: str, 'strict'?: bool}; provider-native shapes go in " + f"Config.extensions (got keys {sorted(value)})" + ) + allowed = {"type"} if fmt == "json_object" else {"type", "schema", "name", "strict"} + extra = set(value) - allowed + if extra: + raise ValueError(f"response_format {fmt!r} does not take keys {sorted(extra)}; provider-native shapes go in Config.extensions") + if fmt == "json_schema": + if not isinstance(value.get("schema"), dict): + raise ValueError("response_format json_schema requires a 'schema' object") + if "name" in value and (not isinstance(value["name"], str) or not value["name"]): + raise ValueError("response_format name must be a non-empty string") + if "strict" in value and not isinstance(value["strict"], bool): + raise TypeError("response_format strict must be a bool") + + +# ─── Request ───────────────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class _ModelRequest: + """Base for endpoint requests/configs that require a model.""" + + model: str + + def __post_init__(self) -> None: + if not isinstance(self.model, str) or not self.model: + raise ValueError("model is required") + + +@dataclass(frozen=True, slots=True) +class Request(_ModelRequest): + """A complete request to a foundation model. + + The composed artifact sent to the model — conversation history, + system instructions, available tools, and generation config. + """ + + messages: tuple[Message, ...] + system: str | tuple[PromptPart, ...] | None = None + tools: tuple[Tool, ...] = () + config: Config = field(default_factory=Config) + + def __post_init__(self) -> None: + _ModelRequest.__post_init__(self) + messages = (self.messages,) if isinstance(self.messages, Message) else tuple(self.messages) + object.__setattr__(self, "messages", messages) + tools = (self.tools,) if isinstance(self.tools, (FunctionTool, BuiltinTool)) else tuple(self.tools) + object.__setattr__(self, "tools", tools) + object.__setattr__(self, "system", _normalize_system(self.system)) + if not self.messages: + raise ValueError("at least one message is required") + if not all(isinstance(m, Message) for m in self.messages): + raise TypeError( + 'Request.messages must contain Message objects — wrap plain ' + 'text with Message.user("...").' + ) + if not all(isinstance(t, (FunctionTool, BuiltinTool)) for t in self.tools): + raise TypeError("Request.tools must contain Tool objects") + tool_names = [t.name for t in self.tools] + if len(set(tool_names)) != len(tool_names): + raise ValueError("Request.tools cannot contain duplicate tool names") + if not isinstance(self.config, Config): + raise TypeError("Request.config must be a Config") + if self.config.tool_choice is not None and self.config.tool_choice.allowed: + missing = set(self.config.tool_choice.allowed) - set(tool_names) + if missing: + raise ValueError( + f"ToolChoice.allowed contains tools not present in Request.tools: {sorted(missing)}" + ) + + +# ─── Logprobs ──────────────────────────────────────────────────────── +# +# Decoding telemetry: for each generated token, the chosen token's log +# probability plus a ranked list of top alternative candidates. Two flat +# types instead of one recursive type — the wire never nests alternatives +# inside alternatives, and the type system should say so. + + +@dataclass(frozen=True, slots=True) +class TopLogprob: + """One scored alternative token at a decoding step. + + ``bytes`` is the token's UTF-8 byte sequence when the provider reports + it (OpenAI); ``token_id`` is the vocabulary id when the provider + reports it (Gemini). Absence means "not reported", never zero. + """ + + token: str + logprob: float + bytes: tuple[int, ...] | None = None + token_id: int | None = None + + def __post_init__(self) -> None: + _validate_text(self.token, field_name="token") + _coerce_float_field(self, "logprob") + if not isinstance(self.logprob, float): + raise TypeError("logprob must be a float") + _validate_logprob_bytes(self) + _coerce_int_field(self, "token_id") + _validate_int(self.token_id, field_name="token_id") + + +@dataclass(frozen=True, slots=True) +class TokenLogprob: + """The chosen token at one decoding step, with ranked alternatives. + + ``top`` is the provider-reported ranked candidate list (descending + logprob). Providers differ on whether the chosen token appears in + ``top`` — no guarantee either way, and providers also differ on + whether the requested alternative count includes the chosen token + (Gemini documents that it does; OpenAI counts alternatives only). + lm15 preserves the provider-reported list as-is. + """ + + token: str + logprob: float + bytes: tuple[int, ...] | None = None + token_id: int | None = None + top: tuple[TopLogprob, ...] = () + + def __post_init__(self) -> None: + _validate_text(self.token, field_name="token") + _coerce_float_field(self, "logprob") + if not isinstance(self.logprob, float): + raise TypeError("logprob must be a float") + _validate_logprob_bytes(self) + _coerce_int_field(self, "token_id") + _validate_int(self.token_id, field_name="token_id") + top = (self.top,) if isinstance(self.top, TopLogprob) else tuple(self.top) + object.__setattr__(self, "top", top) + if not all(isinstance(t, TopLogprob) for t in self.top): + raise TypeError("TokenLogprob.top must contain TopLogprob objects") + + +def _validate_logprob_bytes(obj: object) -> None: + value = getattr(obj, "bytes") + if value is None: + return + coerced = tuple(value) + for b in coerced: + if isinstance(b, bool) or not isinstance(b, int) or b < 0: + raise TypeError("bytes must contain non-negative ints") + object.__setattr__(obj, "bytes", coerced) + + +def _validate_logprobs_field(obj: object, field_name: str, *, allow_none: bool) -> None: + value = getattr(obj, field_name) + if value is None: + if allow_none: + return + raise TypeError(f"{type(obj).__name__}.{field_name} must be a tuple, not None") + coerced = (value,) if isinstance(value, TokenLogprob) else tuple(value) + object.__setattr__(obj, field_name, coerced) + if not all(isinstance(t, TokenLogprob) for t in coerced): + raise TypeError(f"{type(obj).__name__}.{field_name} must contain TokenLogprob objects") + + +# ─── Usage ─────────────────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class Usage: + """Token usage. + + ``input_tokens`` and ``output_tokens`` are common dimensions. + ``total_tokens`` is the provider-reported or billed total when present; + providers may include reasoning, audio, cache, or future token classes in + totals differently, so it is not forced to equal input + output. + + ``reasoning_tokens`` is populated only when the provider reports an exact + separate reasoning/thinking token count. Some providers, notably Anthropic, + can return ``ThinkingPart`` content while reporting only combined + ``output_tokens``; in that case ``reasoning_tokens`` remains ``None``. + + Every counter is ``int | None``: ``None`` means "the provider did not + report this dimension", which is distinct from a reported ``0``. + ``Usage()`` with no arguments therefore means "nothing reported" and + serializes to ``{}`` (omitted entirely by enclosing serializers, per + docs/serde-rules.md). + + ``total_tokens``: an explicit value is preserved as provider telemetry. + When omitted, it auto-computes as ``input_tokens + output_tokens`` only + when BOTH are present; if either is ``None`` the total stays ``None``. + + Arithmetic over usage (e.g. ``InferencePricing.estimate``) must treat + ``None`` as "unknown", never as zero. + + Counters are provider-verbatim (spec/types.md, Usage, 2026-09-02): the + same field means different things across providers. ``input_tokens`` + includes cached tokens on OpenAI, Gemini, and xAI but not on Anthropic + (whose cache counters are disjoint); ``output_tokens`` includes + reasoning on OpenAI and Anthropic but not on Gemini and xAI. lm15 does + not normalise: that is what a bill reconciles against. A consumer + comparing across providers must apply the provider's rule from the + table in the spec; ``Response.provider_data`` and the router's + resolution say which provider answered. + """ + + input_tokens: int | None = None + output_tokens: int | None = None + total_tokens: int | None = None + cache_read_tokens: int | None = None + cache_write_tokens: int | None = None + reasoning_tokens: int | None = None + input_audio_tokens: int | None = None + output_audio_tokens: int | None = None + + def __post_init__(self) -> None: + for field_name in ( + "input_tokens", + "output_tokens", + "cache_read_tokens", + "cache_write_tokens", + "reasoning_tokens", + "input_audio_tokens", + "output_audio_tokens", + ): + _coerce_int_field(self, field_name) + _validate_non_negative(getattr(self, field_name), field_name=field_name) + _coerce_int_field(self, "total_tokens") + _validate_non_negative(self.total_tokens, field_name="total_tokens") + if ( + self.total_tokens is None + and self.input_tokens is not None + and self.output_tokens is not None + ): + object.__setattr__(self, "total_tokens", self.input_tokens + self.output_tokens) + + +# ─── Response ──────────────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True, repr=False) +class Response: + """The composed artifact returned by a foundation model. + + ``Response`` keeps only minimal convenience properties. Use + ``response.message.first(...)`` and ``response.message.parts_of(...)`` + for variant-specific content access. + + ``logprobs_complete=False`` means local text editing could not preserve + scores for all retained text. Remaining scores describe whole original + tokens only; never infer a probability for the unscored text. True is + the default and does not promise the provider supplied scores at all. + """ + + id: str | None + model: str + message: Message + finish_reason: FinishReason + usage: Usage + # Decoding telemetry, same category as usage/finish_reason. None = + # provider did not report logprobs (the Usage convention). When a + # response holds several text blocks, per-block lists concatenate in + # document order — the block boundary survives in the stream + # (TextDelta.part_index) and in provider_data, not here. + logprobs: tuple[TokenLogprob, ...] | None = None + provider_data: ProviderData | None = None + # MAP-13: what the wire got that differs from what was asked (a + # dropped hint, a clamped dial, a client-side stop). Empty when the + # request went out exactly as written. Never printed; data. + adaptations: tuple[Adaptation, ...] = () + # Same meaning as TextDelta.logprobs_complete; preserved in canonical JSON. + logprobs_complete: bool = field(default=True, kw_only=True) + + def __post_init__(self) -> None: + _validate_optional_text(self.id, field_name="Response.id", allow_empty=False) + _validate_text(self.model, field_name="Response.model", allow_empty=False) + if not isinstance(self.message, Message): + raise TypeError("Response.message must be a Message") + if self.message.role != "assistant": + raise ValueError("Response.message must have role 'assistant'") + if self.finish_reason not in FINISH_REASONS: + raise ValueError(f"unsupported finish reason: {self.finish_reason}") + if not isinstance(self.usage, Usage): + raise TypeError("Response.usage must be a Usage") + _validate_logprobs_field(self, "logprobs", allow_none=True) + _validate_json_field(self, "provider_data") + _validate_adaptations(self, "Response.adaptations") + _validate_bool(self.logprobs_complete, field_name="Response.logprobs_complete") + + def __repr__(self) -> str: + display_text = self.text + citations = self.citations + + fields = [ + ("text", repr(display_text)) if display_text is not None else ("message", repr(self.message)), + ("model", repr(self.model)), + ("finish_reason", repr(self.finish_reason)), + ("usage", repr(self.usage)), + ] + if citations: + fields.append(("citations", repr(citations))) + if self.logprobs is not None: + fields.append(("logprobs", f"<{len(self.logprobs)} tokens>")) + if not self.logprobs_complete: + fields.append(("logprobs_complete", "False")) + if self.adaptations: + fields.append(("adaptations", repr([f"{a.field}:{a.action}" for a in self.adaptations]))) + if self.id is not None: + fields.append(("id", repr(self.id))) + if self.provider_data is not None: + if isinstance(self.provider_data, dict): + fields.append(("provider_data", f"")) + else: + fields.append(("provider_data", "")) + + body = ",\n".join(f" {name}={value}" for name, value in fields) + return f"Response(\n{body},\n)" + + @property + def text(self) -> str | None: + """Concatenated assistant text, when the response has text. + + ``Message.text`` is intentionally strict and only returns text for + pure-text messages. For model responses, citation and thinking parts + are metadata around the visible answer, so they do not make + ``Response.text`` unavailable. + """ + text = self.message.text + if text is not None: + return text + + if all(isinstance(p, (TextPart, CitationPart, ThinkingPart)) for p in self.message.parts): + text_parts = [p.text for p in self.message.parts if isinstance(p, TextPart)] + if text_parts: + return "\n".join(text_parts) + return None + + @property + def tool_calls(self) -> list[ToolCallPart]: + return self.message.parts_of(ToolCallPart) + + # ─── Judgments (changes/2026-09-17-judgments.md D12) ────────────── + + @property + def data(self) -> Any: + """The answer of a judgment request: the DataPart's value. + + Falls back to the parsed JSON text of a plain structured-output + response (``json``) so ``response.data`` reads the same on a wire + that answered with text; ``None`` when there is neither. + """ + part = self.message.first(DataPart) + if part is not None: + return part.value + return self.json + + @property + def probabilities(self) -> dict[str, dict[str, float]] | None: + """Per-judgment distributions over the declared keys, or None when + none was measured (never a fabricated one).""" + part = self.message.first(DataPart) + return None if part is None else part.probabilities + + @property + def method(self) -> str | None: + part = self.message.first(DataPart) + return None if part is None else part.method + + def expected(self, field_name: str) -> float | None: + """Σ p·i over an ordered judgment's levels (Jev's ``score``), or None.""" + dist = (self.probabilities or {}).get(field_name) + if not dist: + return None + try: + return sum(float(p) * int(k) for k, p in dist.items()) + except ValueError: + return None + + @property + def citations(self) -> list[CitationPart]: + return self.message.parts_of(CitationPart) + + def parse_json(self, *, default: Any = _MISSING) -> Any: + """Parse the response text as exact JSON.""" + t = self.text + if t is None: + if default is not _MISSING: + return default + raise ValueError( + "Cannot parse response as JSON: response is not pure text. " + f"Parts: {[p.type for p in self.message.parts]}" + ) + stripped = t.strip() + try: + return _json.loads(stripped) + except _json.JSONDecodeError as e: + if default is not _MISSING: + return default + preview = stripped[:200] + ("..." if len(stripped) > 200 else "") + raise ValueError( + f"Cannot parse response as JSON: {e}\nRaw text: {preview}" + ) from e + + @property + def json(self) -> Any: + """Parsed exact JSON text, or ``None`` when parsing fails. + + Valid JSON ``null`` also returns ``None``; use ``parse_json()`` when + parse failures should be reported distinctly. + """ + return self.parse_json(default=None) + + +# ─── File Upload ───────────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True, repr=False) +class FileUploadRequest: + """A file upload request. + + Files are an ACCOUNT-scoped resource on every provider, so there is + no ``model`` field. Uploads can be backed by in-memory bytes or by a + local path. Path-backed uploads are lazy: LMs can stream from disk + instead of forcing the whole file into memory at construction time, + and they are runtime-only — the canonical serializer rejects them + because a local path is meaningless off this machine. + """ + + filename: str + bytes_data: bytes | None = None + media_type: str = "application/octet-stream" + extensions: Extensions | None = None + path: Path | None = None + + def __post_init__(self) -> None: + _validate_text(self.filename, field_name="FileUploadRequest.filename", allow_empty=False) + if self.path is not None and not isinstance(self.path, Path): + if str(self.path) == "": + raise ValueError("path cannot be empty") + object.__setattr__(self, "path", Path(self.path)) + if self.bytes_data is None and self.path is None: + raise TypeError("FileUploadRequest requires bytes_data or path") + if self.bytes_data is not None and self.path is not None: + raise ValueError("FileUploadRequest requires exactly one of bytes_data or path") + if self.bytes_data is not None: + if not isinstance(self.bytes_data, (bytes, bytearray)): + raise TypeError("bytes_data must be bytes") + if not self.bytes_data: + raise ValueError("bytes_data is required") + if isinstance(self.bytes_data, bytearray): + object.__setattr__(self, "bytes_data", bytes(self.bytes_data)) + if self.path is not None and str(self.path) == "": + raise ValueError("path cannot be empty") + _validate_text(self.media_type, field_name="FileUploadRequest.media_type", allow_empty=False) + _validate_extensions_field(self) + + def __repr__(self) -> str: + return ( + "FileUploadRequest(" + f"filename={self.filename!r}, " + f"bytes_data={_bytes_summary(self.bytes_data)!r}, " + f"media_type={self.media_type!r}, " + f"extensions={self.extensions!r}, " + f"path={self.path!r})" + ) + + @property + def bytes(self) -> bytes: + if self.bytes_data is not None: + return self.bytes_data + if self.path is not None: + return self.path.read_bytes() + raise ValueError("FileUploadRequest has neither bytes_data nor path") + + +@dataclass(frozen=True, slots=True) +class FileInfo: + """A snapshot of one provider-side stored file. + + ``id`` is the canonical reference for this provider: paste it into a + media Part's ``file_id`` and the chat mapping places it on the wire + (OpenAI/Anthropic file ids verbatim; Gemini the file URI, because + Gemini requests address files by URI, not resource name). + + Optional fields stay ``None`` when the provider does not report the + value (OpenAI reports no MIME type; only Gemini reports expiry). + ``downloadable`` is tri-state: ``True``/``False`` when the provider + states the capability, ``None`` when it does not say. + """ + + id: str + filename: str | None = None + media_type: str | None = None + size_bytes: int | None = None + created_at: str | None = None + expires_at: str | None = None + readiness: FileReadiness = "ready" + downloadable: bool | None = None + provider_data: ProviderData | None = None + + @property + def ready(self) -> bool: + return self.readiness == "ready" + + def __post_init__(self) -> None: + _validate_text(self.id, field_name="FileInfo.id", allow_empty=False) + _validate_optional_text(self.filename, field_name="FileInfo.filename", allow_empty=False) + _validate_optional_text(self.media_type, field_name="FileInfo.media_type", allow_empty=False) + _coerce_int_field(self, "size_bytes") + _validate_non_negative(self.size_bytes, field_name="FileInfo.size_bytes") + _validate_optional_text(self.created_at, field_name="FileInfo.created_at", allow_empty=False) + _validate_optional_text(self.expires_at, field_name="FileInfo.expires_at", allow_empty=False) + if self.readiness not in FILE_READINESS_VALUES: + raise ValueError(f"unsupported file readiness: {self.readiness}") + if self.downloadable is not None and not isinstance(self.downloadable, bool): + raise TypeError("FileInfo.downloadable must be a bool or None") + _validate_json_field(self, "provider_data") + + +@dataclass(frozen=True, slots=True) +class FilePage: + """One page of stored files plus an opaque continuation cursor. + + ``next_cursor`` is provider-issued and opaque: pass it back to + ``file_list`` to fetch the next page; ``None`` means the listing is + complete. + """ + + items: tuple[FileInfo, ...] = () + next_cursor: str | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "items", tuple(self.items)) + if not all(isinstance(item, FileInfo) for item in self.items): + raise TypeError("FilePage.items must contain FileInfo objects") + _validate_optional_text(self.next_cursor, field_name="FilePage.next_cursor", allow_empty=False) + + +# ─── Cache resources (the stored tier of MAP-6) ────────────────────── +# +# A stored cache object belongs to one model on every provider that has +# the tier (a KV state is model-specific by nature). ``CacheInfo`` is the +# snapshot; ``CachedPrefix`` is the ergonomic that pairs a prefix Request +# with its optional object and builds requests against it. Nothing here +# names a provider: id formats, TTL spellings, and what the wire does with +# ``CacheConfig.resource`` are the adapter's business. + + +@dataclass(frozen=True, slots=True) +class CacheInfo: + """A snapshot of one provider-side stored cache object. + + ``id`` is the provider's reference verbatim; place it in + ``CacheConfig.resource``. ``tokens`` is the stored token count when + reported (storage cost = tokens x hours x the provider's rate). + ``expires_at`` is ISO-8601 UTC, normalized from the provider's form. + """ + + id: str + model: str + tokens: int | None = None + created_at: str | None = None + expires_at: str | None = None + label: str | None = None + provider_data: ProviderData | None = None + + def __post_init__(self) -> None: + _validate_text(self.id, field_name="CacheInfo.id", allow_empty=False) + _validate_text(self.model, field_name="CacheInfo.model", allow_empty=False) + _coerce_int_field(self, "tokens") + _validate_non_negative(self.tokens, field_name="CacheInfo.tokens") + _validate_optional_text(self.created_at, field_name="CacheInfo.created_at", allow_empty=False) + _validate_optional_text(self.expires_at, field_name="CacheInfo.expires_at", allow_empty=False) + _validate_optional_text(self.label, field_name="CacheInfo.label", allow_empty=False) + _validate_json_field(self, "provider_data") + + +@dataclass(frozen=True, slots=True) +class CachePage: + """One page of stored cache objects plus an opaque continuation cursor.""" + + items: tuple[CacheInfo, ...] = () + next_cursor: str | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "items", tuple(self.items)) + if not all(isinstance(item, CacheInfo) for item in self.items): + raise TypeError("CachePage.items must contain CacheInfo objects") + _validate_optional_text(self.next_cursor, field_name="CachePage.next_cursor", allow_empty=False) + + +@dataclass(frozen=True, slots=True) +class CachedPrefix: + """A reusable prompt beginning: ``cached = lm.cache(prefix)``. + + ``prefix`` is a Request whose model, system, tools, and messages form + the reusable part (its config must be default: a cached object has no + temperature). ``resource`` is the stored object on providers with + that tier, ``None`` on providers that mark blocks or cache + automatically. Optional ``provider`` preserves a router destination; + prefix/resource models remain wire names, while suffix requests use + ``provider:wiremodel``. Reuse with the same router configuration/account. + ``request(messages)`` builds a Request that appends + ``messages`` and sets the cache boundary at the seam; ``cached + + messages`` is Python sugar for it. The built Request and its wire + bytes are what the contract pins; the sugar is per-language. + """ + + prefix: Request + resource: CacheInfo | None = None + provider: str | None = field(default=None, kw_only=True) + + def __post_init__(self) -> None: + if self.provider is not None: + if not isinstance(self.provider, str): + raise TypeError("CachedPrefix.provider must be a string") + if not self.provider or any(c.isspace() or c in ":/" for c in self.provider): + raise ValueError("CachedPrefix.provider must be a non-empty provider name without routing separators") + object.__setattr__(self, "provider", self.provider.replace("_", "-")) + if not isinstance(self.prefix, Request): + raise TypeError("CachedPrefix.prefix must be a Request") + if self.prefix.config != Config(): + raise ValueError("CachedPrefix.prefix must carry a default Config: a cached object has no generation settings") + if self.resource is not None: + if not isinstance(self.resource, CacheInfo): + raise TypeError("CachedPrefix.resource must be a CacheInfo") + if self.resource.model != self.prefix.model: + raise ValueError("CachedPrefix.resource.model must equal the prefix model (a stored cache belongs to one model)") + + @property + def id(self) -> str | None: + return self.resource.id if self.resource is not None else None + + @property + def expires_at(self) -> str | None: + return self.resource.expires_at if self.resource is not None else None + + def cache_config(self) -> CacheConfig: + """The CacheConfig that marks the seam between prefix and suffix.""" + return CacheConfig( + prefix_until_index=len(self.prefix.messages) - 1, + resource=self.id, + ) + + def request( + self, + messages: "str | Message | Sequence[Message] | Request", + *, + config: Config | None = None, + ) -> Request: + """Append ``messages`` to the prefix and set the cache boundary. + + ``messages`` may be a string (one user message), a Message, a + sequence of Messages, or a Request (same model, no system, no + tools: the prefix owns those). ``config`` supplies generation + settings; its ``cache`` must be unset (the prefix decides). + """ + model = f"{self.provider}:{self.prefix.model}" if self.provider is not None else self.prefix.model + suffix: tuple[Message, ...] + if isinstance(messages, Request): + head, sep, rest = messages.model.partition(":") + same_route = self.provider is not None and sep and head.replace("_", "-") == self.provider and rest == self.prefix.model + if messages.model != self.prefix.model and not same_route: + raise ValueError("suffix Request model must equal the prefix model") + if messages.system is not None or messages.tools: + raise ValueError("suffix Request cannot redefine system or tools: the prefix owns them") + if config is None and messages.config != Config(): + config = messages.config + suffix = messages.messages + elif isinstance(messages, str): + suffix = (Message.user(messages),) + elif isinstance(messages, Message): + suffix = (messages,) + else: + suffix = tuple(messages) + if not suffix or not all(isinstance(m, Message) for m in suffix): + raise TypeError("messages must be a Message or a non-empty sequence of Messages") + base = config if config is not None else Config() + if base.cache is not None: + raise ValueError("config.cache is decided by the CachedPrefix; leave it unset") + from dataclasses import replace as _replace + + return Request( + model=model, + system=self.prefix.system, + tools=self.prefix.tools, + messages=self.prefix.messages + suffix, + config=_replace(base, cache=self.cache_config()), + ) + + def __add__(self, messages: "str | Message | Sequence[Message] | Request") -> Request: + return self.request(messages) + + +# ─── Batch ─────────────────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class BatchRequest: + """A batch of model requests. + + Each nested Request carries its own model. The optional top-level model is + only a routing/default convenience; when omitted, it is inferred from the + first nested request and does not constrain the rest of the batch. + """ + + model: str | None = None + requests: tuple[Request, ...] = () + label: str | None = None + extensions: Extensions | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "requests", tuple(self.requests)) + if not self.requests: + raise ValueError("requests cannot be empty") + if not all(isinstance(r, Request) for r in self.requests): + raise TypeError("BatchRequest.requests must contain Request objects") + _validate_optional_text(self.model, field_name="BatchRequest.model", allow_empty=False) + if self.model is None: + object.__setattr__(self, "model", self.requests[0].model) + _validate_optional_text(self.label, field_name="BatchRequest.label", allow_empty=False) + _validate_extensions_field(self) + + +@dataclass(frozen=True, slots=True) +class BatchJobInfo: + """A snapshot of a provider-side batch job — the ticket. + + ``id`` is the provider's job id, a plain string; store it anywhere. + ``created_at`` is normalized to an ISO-8601 UTC string when the + provider reports a creation time. Raw job state stays verbatim in + ``provider_data``. + """ + + id: str + status: BatchStatus + label: str | None = None + created_at: str | None = None + provider_data: ProviderData | None = None + + @property + def done(self) -> bool: + return self.status in BATCH_TERMINAL_STATUSES + + def __post_init__(self) -> None: + _validate_text(self.id, field_name="BatchJobInfo.id", allow_empty=False) + if self.status not in BATCH_STATUSES: + raise ValueError(f"unsupported batch status: {self.status}") + _validate_optional_text(self.label, field_name="BatchJobInfo.label", allow_empty=False) + _validate_optional_text(self.created_at, field_name="BatchJobInfo.created_at", allow_empty=False) + _validate_json_field(self, "provider_data") + + +@dataclass(frozen=True, slots=True) +class BatchEntry: + """The fate of one request in a batch, in submission order. + + Partial failure is a first-class outcome: a ``completed`` job may mix + ``succeeded`` and ``errored`` entries. ``succeeded`` carries a full + canonical Response; ``errored`` carries a canonical ErrorDetail; + ``cancelled`` and ``expired`` carry neither. + """ + + index: int + outcome: BatchOutcome + response: Response | None = None + error: ErrorDetail | None = None + + @property + def ok(self) -> bool: + return self.outcome == "succeeded" + + def __post_init__(self) -> None: + if not isinstance(self.index, int) or isinstance(self.index, bool) or self.index < 0: + raise ValueError("BatchEntry.index must be a non-negative int") + if self.outcome not in BATCH_OUTCOMES: + raise ValueError(f"unsupported batch outcome: {self.outcome}") + if self.outcome == "succeeded": + if not isinstance(self.response, Response) or self.error is not None: + raise ValueError("succeeded entries carry a Response and no error") + elif self.outcome == "errored": + if not isinstance(self.error, ErrorDetail) or self.response is not None: + raise ValueError("errored entries carry an ErrorDetail and no response") + elif self.response is not None or self.error is not None: + raise ValueError(f"{self.outcome} entries carry neither response nor error") + + +@dataclass(frozen=True, slots=True) +class _PromptRequest(_ModelRequest): + """Base for endpoint requests that require a non-empty text prompt.""" + + prompt: str + + def __post_init__(self) -> None: + _ModelRequest.__post_init__(self) + if not isinstance(self.prompt, str): + raise TypeError("prompt must be a string") + if self.prompt == "": + raise ValueError("prompt is required") + + +# ─── Image Generation ──────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class ImageGenerationRequest(_PromptRequest): + """Text (and optionally input images, for edits) in; images out. + + ``size`` takes the provider's own sizing vocabulary — like ``model`` + and ``voice``, the field is portable but the values are not (OpenAI: + pixels; Gemini: aspect ratios). ``images`` are ordinary ImageParts + (inline data / url / file_id / path); adapters route them to the + provider's real edit door and raise where the wire has none. + """ + + size: str | None = None + images: tuple[ImagePart, ...] = () + extensions: Extensions | None = None + + def __post_init__(self) -> None: + _PromptRequest.__post_init__(self) + _validate_optional_text(self.size, field_name="size", allow_empty=False) + object.__setattr__(self, "images", tuple(self.images)) + if not all(isinstance(img, ImagePart) for img in self.images): + raise TypeError("ImageGenerationRequest.images must contain ImagePart objects") + _validate_extensions_field(self) + + +@dataclass(frozen=True, slots=True) +class ImageGenerationResponse: + """Generated images, plus any text the model said while drawing + (Gemini routinely narrates; dropping it would be silent data loss).""" + + images: tuple[ImagePart, ...] + text: str | None = None + id: str | None = None + model: str | None = None + usage: Usage = field(default_factory=Usage) + provider_data: ProviderData | None = None + + def __post_init__(self) -> None: + _validate_optional_text(self.text, field_name="ImageGenerationResponse.text", allow_empty=False) + _validate_optional_text(self.id, field_name="ImageGenerationResponse.id", allow_empty=False) + _validate_optional_text(self.model, field_name="ImageGenerationResponse.model", allow_empty=False) + if not isinstance(self.usage, Usage): + raise TypeError("ImageGenerationResponse.usage must be a Usage") + object.__setattr__(self, "images", tuple(self.images)) + if not self.images: + raise ValueError("ImageGenerationResponse requires at least one image") + if not all(isinstance(img, ImagePart) for img in self.images): + raise TypeError("images must contain ImagePart objects") + _validate_json_field(self, "provider_data") + + +# ─── Speech Generation ──────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class SpeechGenerationRequest(_PromptRequest): + """Text-to-speech. Named for what every wire actually sells: speech. + + ``voice`` and ``format`` take the provider's own vocabularies. An + omitted field means the SERVER decides (OpenAI's real default format + is MP3); lm15 injects no defaults of its own. ``format`` raises on + providers whose wire has no slot for it (Gemini: always PCM). + """ + + voice: str | None = None + format: str | None = None + extensions: Extensions | None = None + + def __post_init__(self) -> None: + _PromptRequest.__post_init__(self) + _validate_optional_text(self.voice, field_name="voice", allow_empty=False) + _validate_optional_text(self.format, field_name="format", allow_empty=False) + _validate_extensions_field(self) + + +@dataclass(frozen=True, slots=True) +class SpeechGenerationResponse: + audio: AudioPart + id: str | None = None + model: str | None = None + usage: Usage = field(default_factory=Usage) + provider_data: ProviderData | None = None + + def __post_init__(self) -> None: + _validate_optional_text(self.id, field_name="SpeechGenerationResponse.id", allow_empty=False) + _validate_optional_text(self.model, field_name="SpeechGenerationResponse.model", allow_empty=False) + if not isinstance(self.usage, Usage): + raise TypeError("SpeechGenerationResponse.usage must be a Usage") + if not isinstance(self.audio, AudioPart): + raise TypeError("audio must be an AudioPart") + _validate_json_field(self, "provider_data") + + +# ─── Video Generation ──────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class VideoGenerationRequest(_PromptRequest): + """Text (and optionally an input image) in; a video JOB out. + + Video is job-shaped on every wire that sells it (Sora, Veo, + grok-imagine): submission returns a ticket, not bytes. ``seconds`` + maps to the provider's duration knob and raises where the wire has + none; ``images`` are input frames (image-to-video), ordinary + ImageParts. + """ + + seconds: int | None = None + images: tuple[ImagePart, ...] = () + extensions: Extensions | None = None + + def __post_init__(self) -> None: + _PromptRequest.__post_init__(self) + if self.seconds is not None and ( + not isinstance(self.seconds, int) or isinstance(self.seconds, bool) or self.seconds <= 0 + ): + raise ValueError("VideoGenerationRequest.seconds must be a positive int") + object.__setattr__(self, "images", tuple(self.images)) + if not all(isinstance(img, ImagePart) for img in self.images): + raise TypeError("VideoGenerationRequest.images must contain ImagePart objects") + _validate_extensions_field(self) + + +@dataclass(frozen=True, slots=True) +class VideoJobInfo: + """A snapshot of a provider-side video job — the ticket. + + ``id`` is the provider's job id, a plain string; store it anywhere + (on xAI it is the ONLY copy — the wire has no list endpoint). + ``progress`` is a percentage when the provider reports one. Raw job + state stays verbatim in ``provider_data``. + """ + + id: str + status: VideoStatus + progress: int | None = None + created_at: str | None = None + model: str | None = None + provider_data: ProviderData | None = None + + @property + def done(self) -> bool: + return self.status in VIDEO_TERMINAL_STATUSES + + def __post_init__(self) -> None: + _validate_text(self.id, field_name="VideoJobInfo.id", allow_empty=False) + if self.status not in VIDEO_STATUSES: + raise ValueError(f"unsupported video status: {self.status}") + if self.progress is not None and ( + not isinstance(self.progress, int) or isinstance(self.progress, bool) + or not 0 <= self.progress <= 100 + ): + raise ValueError("VideoJobInfo.progress must be an int percentage 0-100") + _validate_optional_text(self.created_at, field_name="VideoJobInfo.created_at", allow_empty=False) + _validate_optional_text(self.model, field_name="VideoJobInfo.model", allow_empty=False) + _validate_json_field(self, "provider_data") + + +EndpointRequest: TypeAlias = ( + Request + | FileUploadRequest + | BatchRequest + | ImageGenerationRequest + | SpeechGenerationRequest + | VideoGenerationRequest +) + +EndpointResponse: TypeAlias = ( + Response + | FileInfo + | BatchJobInfo + | ImageGenerationResponse + | SpeechGenerationResponse + | VideoJobInfo +) + + +# ─── Audio Format ──────────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class AudioFormat: + encoding: AudioEncoding + sample_rate: int + channels: int = 1 + + def __post_init__(self) -> None: + if self.encoding not in AUDIO_ENCODINGS: + raise ValueError(f"unsupported audio encoding: {self.encoding}") + _coerce_int_field(self, "sample_rate") + _coerce_int_field(self, "channels") + _validate_positive(self.sample_rate, field_name="sample_rate") + _validate_positive(self.channels, field_name="channels") + + +# ─── Live (Realtime) ───────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class LiveConfig(_ModelRequest): + system: str | tuple[PromptPart, ...] | None = None + tools: tuple[Tool, ...] = () + voice: str | None = None + input_format: AudioFormat | None = None + output_format: AudioFormat | None = None + extensions: Extensions | None = None + + def __post_init__(self) -> None: + _ModelRequest.__post_init__(self) + object.__setattr__(self, "tools", tuple(self.tools)) + object.__setattr__(self, "system", _normalize_system(self.system)) + if not all(isinstance(t, (FunctionTool, BuiltinTool)) for t in self.tools): + raise TypeError("LiveConfig.tools must contain Tool objects") + tool_names = [t.name for t in self.tools] + if len(set(tool_names)) != len(tool_names): + raise ValueError("LiveConfig.tools cannot contain duplicate tool names") + if self.input_format is not None and not isinstance(self.input_format, AudioFormat): + raise TypeError("input_format must be an AudioFormat") + if self.output_format is not None and not isinstance(self.output_format, AudioFormat): + raise TypeError("output_format must be an AudioFormat") + _validate_optional_text(self.voice, field_name="LiveConfig.voice", allow_empty=False) + _validate_extensions_field(self) + + +@dataclass(frozen=True, slots=True) +class LiveClientTurnEvent: + parts: tuple[PromptPart, ...] + turn_complete: bool = True + type: Literal["turn"] = field(default="turn", init=False) + + def __post_init__(self) -> None: + parts = (self.parts,) if _is_part(self.parts) else tuple(self.parts) + object.__setattr__(self, "parts", parts) + if not self.parts: + raise ValueError("LiveClientTurnEvent requires at least one part") + if not all(_is_part(p) for p in self.parts): + raise TypeError("LiveClientTurnEvent.parts must contain Part objects") + if any(isinstance(p, _PROMPT_FORBIDDEN_PARTS) for p in self.parts): + raise TypeError("LiveClientTurnEvent.parts cannot contain model/tool protocol parts") + _validate_input_data_parts("live input", self.parts) + _validate_bool(self.turn_complete, field_name="LiveClientTurnEvent.turn_complete") + + +@dataclass(frozen=True, slots=True) +class LiveClientAudioEvent: + data: str + media_type: str = "audio/pcm;rate=16000" + type: Literal["audio"] = field(default="audio", init=False) + + def __post_init__(self) -> None: + _validate_text(self.data, field_name="LiveClientAudioEvent.data", allow_empty=False) + _validate_base64_data("LiveClientAudioEvent", self.data) + _validate_text(self.media_type, field_name="LiveClientAudioEvent.media_type", allow_empty=False) + if not self.media_type.startswith("audio/"): + raise ValueError("LiveClientAudioEvent.media_type must start with 'audio/'") + + +@dataclass(frozen=True, slots=True) +class LiveClientImageEvent: + data: str + media_type: str = "image/jpeg" + type: Literal["image"] = field(default="image", init=False) + + def __post_init__(self) -> None: + _validate_text(self.data, field_name="LiveClientImageEvent.data", allow_empty=False) + _validate_base64_data("LiveClientImageEvent", self.data) + _validate_text(self.media_type, field_name="LiveClientImageEvent.media_type", allow_empty=False) + if not self.media_type.startswith("image/"): + raise ValueError("LiveClientImageEvent.media_type must start with 'image/'") + + +@dataclass(frozen=True, slots=True) +class LiveClientTextEvent: + text: str + type: Literal["text"] = field(default="text", init=False) + + def __post_init__(self) -> None: + _validate_text(self.text, field_name="LiveClientTextEvent.text") + + +@dataclass(frozen=True, slots=True) +class LiveClientToolResultEvent: + id: str + content: tuple[ToolResultContentPart, ...] + type: Literal["tool_result"] = field(default="tool_result", init=False) + + def __post_init__(self) -> None: + _validate_text(self.id, field_name="LiveClientToolResultEvent.id", allow_empty=False) + object.__setattr__(self, "content", tuple(self.content)) + if not self.content: + raise ValueError("LiveClientToolResultEvent requires content") + if not all(_is_part(p) for p in self.content): + raise TypeError("LiveClientToolResultEvent.content must contain Part objects") + if any(isinstance(p, _TOOL_RESULT_FORBIDDEN_PARTS) for p in self.content): + raise TypeError("LiveClientToolResultEvent.content cannot contain model or protocol parts") + _validate_input_data_parts("live tool results", self.content) + + +@dataclass(frozen=True, slots=True) +class LiveClientInterruptEvent: + type: Literal["interrupt"] = field(default="interrupt", init=False) + + +@dataclass(frozen=True, slots=True) +class LiveClientEndAudioEvent: + type: Literal["end_audio"] = field(default="end_audio", init=False) + + +LiveClientEvent: TypeAlias = ( + LiveClientTurnEvent + | LiveClientAudioEvent + | LiveClientImageEvent + | LiveClientTextEvent + | LiveClientToolResultEvent + | LiveClientInterruptEvent + | LiveClientEndAudioEvent +) +LIVE_CLIENT_EVENT_CLASSES: tuple[type, ...] = get_args(LiveClientEvent) + + +@dataclass(frozen=True, slots=True) +class LiveServerAudioEvent: + data: str + media_type: str | None = None + type: Literal["audio"] = field(default="audio", init=False) + + def __post_init__(self) -> None: + _validate_text(self.data, field_name="LiveServerAudioEvent.data", allow_empty=False) + _validate_base64_data("LiveServerAudioEvent", self.data) + _validate_optional_text(self.media_type, field_name="LiveServerAudioEvent.media_type", allow_empty=False) + if self.media_type is not None and not self.media_type.startswith("audio/"): + raise ValueError("LiveServerAudioEvent.media_type must start with 'audio/'") + + +@dataclass(frozen=True, slots=True) +class LiveServerTextEvent: + text: str + type: Literal["text"] = field(default="text", init=False) + + def __post_init__(self) -> None: + _validate_text(self.text, field_name="LiveServerTextEvent.text") + + +@dataclass(frozen=True, slots=True) +class LiveServerToolCallEvent: + id: str + name: str + input: JsonObject + type: Literal["tool_call"] = field(default="tool_call", init=False) + + def __post_init__(self) -> None: + _validate_text(self.id, field_name="LiveServerToolCallEvent.id", allow_empty=False) + _validate_text(self.name, field_name="LiveServerToolCallEvent.name", allow_empty=False) + _validate_json_field(self, "input", required=True) + + +@dataclass(frozen=True, slots=True) +class LiveServerToolCallDeltaEvent: + input_delta: str + id: str | None = None + name: str | None = None + type: Literal["tool_call_delta"] = field(default="tool_call_delta", init=False) + + def __post_init__(self) -> None: + _validate_text(self.input_delta, field_name="LiveServerToolCallDeltaEvent.input_delta") + _validate_optional_text(self.id, field_name="LiveServerToolCallDeltaEvent.id", allow_empty=False) + _validate_optional_text(self.name, field_name="LiveServerToolCallDeltaEvent.name", allow_empty=False) + + +@dataclass(frozen=True, slots=True) +class LiveServerInterruptedEvent: + type: Literal["interrupted"] = field(default="interrupted", init=False) + + +@dataclass(frozen=True, slots=True) +class LiveServerTurnEndEvent: + usage: Usage + type: Literal["turn_end"] = field(default="turn_end", init=False) + + def __post_init__(self) -> None: + if not isinstance(self.usage, Usage): + raise TypeError("LiveServerTurnEndEvent.usage must be a Usage") + + +@dataclass(frozen=True, slots=True) +class LiveServerUsageEvent: + """Billed usage for a response that did not end the turn. + + A function-call response (the model waits for results; the turn stays + open) and a cancelled response (barge-in) both consume tokens the + provider reports. ``turn_end`` carries the usage of a completed turn; + this event carries the usage of everything else, so a session's bill + is the sum of every ``usage`` and ``turn_end`` event. Never a turn + boundary; dispatch loops ignore it. + """ + + usage: Usage + type: Literal["usage"] = field(default="usage", init=False) + + def __post_init__(self) -> None: + if not isinstance(self.usage, Usage): + raise TypeError("LiveServerUsageEvent.usage must be a Usage") + + +@dataclass(frozen=True, slots=True) +class LiveServerErrorEvent: + error: ErrorDetail + type: Literal["error"] = field(default="error", init=False) + + def __post_init__(self) -> None: + if not isinstance(self.error, ErrorDetail): + raise TypeError("LiveServerErrorEvent.error must be an ErrorDetail") + + +LiveServerEvent: TypeAlias = ( + LiveServerAudioEvent + | LiveServerTextEvent + | LiveServerToolCallEvent + | LiveServerToolCallDeltaEvent + | LiveServerInterruptedEvent + | LiveServerTurnEndEvent + | LiveServerUsageEvent + | LiveServerErrorEvent +) +LIVE_SERVER_EVENT_CLASSES: tuple[type, ...] = get_args(LiveServerEvent) + + +# ─── ToolCallInfo (for callbacks) ──────────────────────────────────── +# +# Lightweight callback payload: same identity/input shape as ToolCallPart, +# but without the content-part discriminator. + + +@dataclass(frozen=True, slots=True) +class ToolCallInfo: + """Callback view of a tool call without the part discriminator.""" + + id: str + name: str + input: JsonObject + + def __post_init__(self) -> None: + _validate_text(self.id, field_name="ToolCallInfo.id", allow_empty=False) + _validate_text(self.name, field_name="ToolCallInfo.name", allow_empty=False) + _validate_json_field(self, "input", required=True) + + @classmethod + def from_part(cls, part: ToolCallPart) -> "ToolCallInfo": + if not isinstance(part, ToolCallPart): + raise TypeError("ToolCallInfo.from_part() requires a ToolCallPart") + return cls(id=part.id, name=part.name, input=part.input) + + def to_part(self) -> ToolCallPart: + return ToolCallPart(id=self.id, name=self.name, input=self.input) + + +def _check_literal_vocabularies() -> None: + checks = ( + ("PartType", set(get_args(PartType)), set(PART_TYPES)), + ("DeltaType", set(get_args(DeltaType)), set(DELTA_TYPES)), + ("ErrorCode", set(get_args(ErrorCode)), set(ERROR_CODES)), + ("FinishReason", set(get_args(FinishReason)), set(FINISH_REASONS)), + ("Role", set(get_args(Role)), set(ROLE_VALUES)), + ("StreamEventType", set(get_args(StreamEventType)), {_variant_type(cls) for cls in STREAM_EVENT_CLASSES}), + ("BatchStatus", set(get_args(BatchStatus)), set(BATCH_STATUSES)), + ("BatchOutcome", set(get_args(BatchOutcome)), set(BATCH_OUTCOMES)), + ("FileReadiness", set(get_args(FileReadiness)), set(FILE_READINESS_VALUES)), + ("AudioEncoding", set(get_args(AudioEncoding)), set(AUDIO_ENCODINGS)), + ("ToolChoiceMode", set(get_args(ToolChoiceMode)), set(TOOL_CHOICE_MODES)), + ("ReasoningEffort", set(get_args(ReasoningEffort)), set(REASONING_EFFORTS)), + ("ReasoningSummary", set(get_args(ReasoningSummary)), set(REASONING_SUMMARIES)), + ("ProbabilityPolicy", set(get_args(ProbabilityPolicy)), set(PROBABILITY_POLICIES)), + ("JudgmentMethod", set(get_args(JudgmentMethod)), set(JUDGMENT_METHODS)), + ("LiveClientEventType", set(get_args(LiveClientEventType)), {_variant_type(cls) for cls in LIVE_CLIENT_EVENT_CLASSES}), + ("LiveServerEventType", set(get_args(LiveServerEventType)), {_variant_type(cls) for cls in LIVE_SERVER_EVENT_CLASSES}), + ) + for name, literal_values, runtime_values in checks: + if literal_values != runtime_values: + raise RuntimeError( + f"{name} literal values must match runtime vocabulary: " + f"literal={sorted(literal_values)} runtime={sorted(runtime_values)}" + ) + + +_check_literal_vocabularies() diff --git a/vet.py b/vet.py new file mode 100644 index 0000000000..74333320c7 --- /dev/null +++ b/vet.py @@ -0,0 +1,883 @@ +""" +lm15.vet — the lm15 vet shim (reference implementation). + +Speaks the newline-delimited JSON protocol defined in +``lm15-contract/harness/PROTOCOL.md``: one JSON request per stdin line, one +JSON reply per stdout line, same order, one output per input. The shim only +transforms — the harness performs all comparison and runs the shim inside a +no-network sandbox, so this module must never open a connection. + +Run as: ``python -m lm15.vet`` (cwd: lm15-python). +""" + +from __future__ import annotations + +import base64 +import dataclasses +import json +import sys +import urllib.parse +from typing import Any, Callable, Iterator, Literal, get_args, get_origin + +from . import types as lm15_types +from . import serde +from .adaptation import adaptation_to_dict +from .errors import AmbiguousModelError, LM15Error, StreamAssemblyError, UnknownModelError, canonical_error_code +from .providers import HttpResponse +from .providers.base import BaseProviderLM +from .result import coalesce_stream, materialize_response +from .sse import parse_sse +from .types import Request, Response, StreamEvent + +JsonObject = dict[str, Any] + +LANGUAGE = "python" + +from ._version import __version__ as IMPL_VERSION + +# Parse-only ops (parse_response, replay_stream, normalize_error) construct an +# adapter but never build auth headers; the key value is irrelevant and must +# never come from the environment. +_PARSE_ONLY_KEY = "vet-parse-only" + + +# ─── Adapters ──────────────────────────────────────────────────────── + +def _credential_of(msg: JsonObject): + """PROTOCOL.md: ``credential`` (an AUTH-2 value) or the ``api_key`` shorthand.""" + from .credentials import credential_from_dict + + cred = msg.get("credential") + if isinstance(cred, dict): + return credential_from_dict(cred) + return str(msg["api_key"]) + + +def _clock_of(msg: JsonObject): + """The fixed clock the harness pins (``now``), or None for wall time.""" + from .credentials import parse_rfc3339 + + now = msg.get("now") + if now is None: + return None + fixed = parse_rfc3339(str(now)) + return lambda: fixed + + +def _settings_of(msg: JsonObject) -> dict[str, str] | None: + settings = msg.get("settings") + return {str(k): str(v) for k, v in settings.items()} if isinstance(settings, dict) else None + + +def _adapter(msg: JsonObject) -> BaseProviderLM: + return adapter_for_provider(str(msg["provider"]), _credential_of(msg), _base_url(msg), + settings=_settings_of(msg), clock=_clock_of(msg)) + + +def adapter_for_provider(provider: str, api_key: Any, base_url: str | None = None, *, + settings: dict[str, str] | None = None, clock: Any = None) -> BaseProviderLM: + """The LM a contract case's ``provider`` string names, from the registry. + + A chat-bound provider (groq, deepseek, …) is the chat dialect with that + provider's compat preset and access policy bound, exactly as the router + builds it; an adapter-owned provider is its class. ``base_url`` + overrides the provider's default (local vLLM/SGLang cases). + """ + from .registry import lookup + + definition = lookup(provider) + if definition is None: + raise ValueError(f"unknown provider: {provider}") + kwargs: dict[str, Any] = {"api_key": api_key} + if base_url is not None: + kwargs["base_url"] = base_url + if definition.bound: + kwargs["access"] = definition.access + if definition.compat is not None: + # The Gemini dialect takes no compat (the router does the same). + kwargs["compat"] = definition.compat + if definition.hosted: + kwargs["settings"] = settings + if clock is not None: + kwargs["clock"] = clock + if definition.id == "openai-codex": + # PROTOCOL.md pins the harness account id: the ctor cannot derive one + # from a non-JWT injected key, and the wire header must be exact. + kwargs["account_id"] = "test-account" + return definition.adapter(**kwargs) + + +# ─── Serde kind table ──────────────────────────────────────────────── + +JsonToObj = Callable[[JsonObject], Any] +ObjToJson = Callable[[Any], JsonObject] + +def _credential_from_dict(data: JsonObject): + from .credentials import credential_from_dict + + return credential_from_dict(data) + + +def _credential_to_dict(value: Any) -> JsonObject: + from .credentials import credential_to_dict + + return credential_to_dict(value) + + +KIND_SERDE: dict[str, tuple[JsonToObj, ObjToJson]] = { + "part": (serde.part_from_dict, serde.part_to_dict), + "message": (serde.message_from_dict, serde.message_to_dict), + "tool": (serde.tool_from_dict, serde.tool_to_dict), + "tool_choice": (serde.tool_choice_from_dict, serde.tool_choice_to_dict), + "reasoning": (serde.reasoning_from_dict, serde.reasoning_to_dict), + "config": (serde.config_from_dict, serde.config_to_dict), + "cache_config": (serde.cache_config_from_dict, serde.cache_config_to_dict), + "cache_info": (serde.cache_info_from_dict, serde.cache_info_to_dict), + "cache_page": (serde.cache_page_from_dict, serde.cache_page_to_dict), + "cached_prefix": (serde.cached_prefix_from_dict, serde.cached_prefix_to_dict), + "token_logprob": (serde.token_logprob_from_dict, serde.token_logprob_to_dict), + "continuation_state": (serde.continuation_from_dict, serde.continuation_to_dict), + "error_detail": (serde.error_detail_from_dict, serde.error_detail_to_dict), + "delta": (serde.delta_from_dict, serde.delta_to_dict), + "usage": (serde.usage_from_dict, serde.usage_to_dict), + "credential": (_credential_from_dict, _credential_to_dict), + "stream_event": (serde.stream_event_from_dict, serde.stream_event_to_dict), + "request": (serde.request_from_dict, serde.request_to_dict), + "response": (serde.response_from_dict, serde.response_to_dict), + "model_info": (serde.model_info_from_dict, serde.model_info_to_dict), + "batch_request": (serde.batch_request_from_dict, serde.batch_request_to_dict), + "batch_job": (serde.batch_job_from_dict, serde.batch_job_to_dict), + "batch_entry": (serde.batch_entry_from_dict, serde.batch_entry_to_dict), + "file_upload_request": (serde.file_upload_request_from_dict, serde.file_upload_request_to_dict), + "file_info": (serde.file_info_from_dict, serde.file_info_to_dict), + "file_page": (serde.file_page_from_dict, serde.file_page_to_dict), + "image_generation_request": (serde.image_generation_request_from_dict, serde.image_generation_request_to_dict), + "image_generation_response": (serde.image_generation_response_from_dict, serde.image_generation_response_to_dict), + "speech_generation_request": (serde.speech_generation_request_from_dict, serde.speech_generation_request_to_dict), + "speech_generation_response": (serde.speech_generation_response_from_dict, serde.speech_generation_response_to_dict), + "video_generation_request": (serde.video_generation_request_from_dict, serde.video_generation_request_to_dict), + "video_job": (serde.video_job_from_dict, serde.video_job_to_dict), + "audio_format": (serde.audio_format_from_dict, serde.audio_format_to_dict), + "live_config": (serde.live_config_from_dict, serde.live_config_to_dict), + "live_client_event": (serde.live_client_event_from_dict, serde.live_client_event_to_dict), + "live_server_event": (serde.live_server_event_from_dict, serde.live_server_event_to_dict), +} + + +def _serde_for_kind(kind: str) -> tuple[JsonToObj, ObjToJson]: + if kind not in KIND_SERDE: + raise ValueError(f"unknown kind: {kind}") + return KIND_SERDE[kind] + + +# ─── Normalization helpers ─────────────────────────────────────────── + +def normalize_transport_request(transport_req: Any) -> JsonObject: + """Normalize a TransportRequest into the protocol's build_request shape. + + Mirrors conformance/cross_sdk/dump_request.py, except nothing is ever + redacted — the harness asserts exact auth formatting against the api_key + it injected. + """ + parsed = urllib.parse.urlparse(transport_req.url) + params = dict(urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)) + url = urllib.parse.urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", "", "")) + + headers: JsonObject = {key.lower(): value for key, value in transport_req.headers} + + out: JsonObject = { + "method": transport_req.method, + "url": url, + "params": params, + "headers": headers, + "body": None, + } + + body = transport_req.body + if body: + content_type = headers.get("content-type", "") + if "json" in content_type.lower(): + try: + out["body"] = json.loads(body.decode("utf-8")) + return out + except Exception: + pass + out["body_b64"] = base64.b64encode(body).decode("ascii") + return out + + +def _http_response(status: int, body: bytes) -> HttpResponse: + return HttpResponse( + status=status, + reason="OK", + headers=[("content-type", "application/json")], + body=body, + ) + + +def _response_result(response: Response) -> JsonObject: + """Serialize a Response per the protocol, surfacing the unmapped canary.""" + result: JsonObject = {"canonical_response": serde.response_to_dict(response)} + provider_data = response.provider_data if isinstance(response.provider_data, dict) else {} + unmapped = provider_data.get("_lm15_unmapped") + if unmapped is not None: + result["unmapped"] = list(unmapped) + return result + + +def _parse_stream_body(lm: BaseProviderLM, request: Request, body: bytes) -> list[StreamEvent]: + def raw_events() -> Iterator[StreamEvent]: + for raw in parse_sse(iter(body.splitlines(keepends=True))): + for event in lm.parse_stream_events(request, raw): + if event is not None: + yield event + + # MAP-3/MAP-4: the canonical event trace is the POST-coalesce trace — + # exactly one merged StreamEndEvent, final, and exactly one leading + # StreamStartEvent (synthesized with the request's model for dialects + # without a start frame). + return list(coalesce_stream(raw_events(), model=request.model)) + + +# ─── Ops ───────────────────────────────────────────────────────────── + +def op_capabilities(msg: JsonObject) -> JsonObject: + return { + "language": LANGUAGE, + "ops": sorted(HANDLERS), + "impl_version": IMPL_VERSION, + } + + +def _base_url(msg: JsonObject) -> str | None: + base_url = msg.get("base_url") + return str(base_url) if base_url is not None else None + + +def op_build_request(msg: JsonObject) -> JsonObject: + """The wire request, plus ``adaptations`` (MAP-13) when the build + recorded any: what the wire got that differs from what was asked. + ``reason`` is the adapter's own wording and is not part of the reply + (ports word their own, as with error messages).""" + lm = _adapter(msg) + request = serde.request_from_dict(msg["canonical_request"]) + transport_req, adaptations = lm._build(request, stream=bool(msg.get("stream", False))) + out = normalize_transport_request(transport_req) + if adaptations: + out["adaptations"] = [ + {k: v for k, v in adaptation_to_dict(a).items() if k != "reason"} for a in adaptations + ] + return out + + +def op_ingest_openai_chat(msg: JsonObject) -> JsonObject: + """MAP-12: a Chat Completions request body → canonical Request, under the + compat the case's provider binds (the same adapter build_request uses). + Pure; no credential is read (PROTOCOL.md).""" + lm = adapter_for_provider(str(msg["provider"]), _PARSE_ONLY_KEY, _base_url(msg), settings=_settings_of(msg), clock=_clock_of(msg)) + ingest = getattr(lm, "request_from_openai_chat", None) + if ingest is None: + raise ValueError(f"provider {msg['provider']!r} does not speak the Chat Completions wire; nothing to ingest") + return {"canonical_request": serde.request_to_dict(ingest(msg["body"]))} + + +def op_parse_response(msg: JsonObject) -> JsonObject: + lm = adapter_for_provider(str(msg["provider"]), _PARSE_ONLY_KEY, _base_url(msg), settings=_settings_of(msg), clock=_clock_of(msg)) + request = serde.request_from_dict(msg["canonical_request"]) + body = base64.b64decode(msg["body_b64"]) + response = lm.parse_response(request, _http_response(int(msg["status"]), body)) + return _response_result(response) + + +def op_replay_stream(msg: JsonObject) -> JsonObject: + lm = adapter_for_provider(str(msg["provider"]), _PARSE_ONLY_KEY, _base_url(msg), settings=_settings_of(msg), clock=_clock_of(msg)) + request = serde.request_from_dict(msg["canonical_request"]) + body = base64.b64decode(msg["body_b64"]) + events = _parse_stream_body(lm, request, body) + event_dicts = [serde.stream_event_to_dict(e) for e in events] + try: + response = materialize_response(iter(events), request) + except StreamAssemblyError as exc: + # The trace parsed; assembly refused (MAP-9). Report the trace with + # the error so the golden pins both what arrived and the refusal. + raise _OpFailure(exc, {"events": event_dicts}) from exc + result: JsonObject = {"events": event_dicts} + result.update(_response_result(response)) + return result + + +def op_normalize_error(msg: JsonObject) -> JsonObject: + lm = adapter_for_provider(str(msg["provider"]), _PARSE_ONLY_KEY, _base_url(msg), settings=_settings_of(msg), clock=_clock_of(msg)) + err = lm.normalize_error(int(msg["status"]), str(msg["body_text"])) + return { + "class": type(err).__name__, + "code": err.code or canonical_error_code(err), + "provider_code": err.provider_code, + "message": err.message, + } + + +def op_serde_roundtrip(msg: JsonObject) -> JsonObject: + from_dict, to_dict = _serde_for_kind(str(msg["kind"])) + return {"value": to_dict(from_dict(msg["value"]))} + + +def op_validate(msg: JsonObject) -> JsonObject: + from_dict, to_dict = _serde_for_kind(str(msg["kind"])) + obj = from_dict(msg["value"]) + return {"ok": True, "normalized": to_dict(obj)} + + +def op_resolve_model(msg: JsonObject) -> JsonObject: + """PROTOCOL.md resolve_model: the router's pure ``resolve`` over + harness-supplied inputs only — the env map (never the process env) and, + when given, an explicit catalog of canonical ModelInfo values.""" + from .models import ModelRegistry + from .router import LMRouter, RouterConfig + + env = {str(k): str(v) for k, v in (msg.get("env") or {}).items()} + registry = None + if "catalog" in msg: + registry = ModelRegistry() + for entry in msg["catalog"] or []: + registry.add(serde.model_info_from_dict(entry), replace=False) + resolution = LMRouter(RouterConfig(registry=registry, env=env)).resolve(str(msg["model"])) + return {"provider": resolution.provider, "model": resolution.model, "source": resolution.source} + + +def op_build_models_request(msg: JsonObject) -> JsonObject: + lm = _adapter(msg) + return normalize_transport_request(lm._models_request()) + + +def op_parse_models_response(msg: JsonObject) -> JsonObject: + lm = adapter_for_provider(str(msg["provider"]), _PARSE_ONLY_KEY, _base_url(msg), settings=_settings_of(msg), clock=_clock_of(msg)) + body = base64.b64decode(msg["body_b64"]).decode("utf-8") + status = int(msg["status"]) + if status >= 400: + raise lm.normalize_error(status, body) + models = lm._models_from_body(body) + return {"models": [serde.model_info_to_dict(m) for m in models]} + + +def _headers_list(msg: JsonObject) -> list: + headers = msg.get("headers") or {} + return [(str(k), str(v)) for k, v in headers.items()] + + +def op_generation_build(msg: JsonObject) -> JsonObject: + """Wire request for image_generate / speech_generate (kind discriminates).""" + lm = _adapter(msg) + kind = str(msg["kind"]) + if kind == "image": + request = serde.image_generation_request_from_dict(msg["generation_request"]) + return normalize_transport_request(lm._image_generate_request(request)) + if kind == "speech": + request = serde.speech_generation_request_from_dict(msg["generation_request"]) + return normalize_transport_request(lm._speech_generate_request(request)) + raise ValueError(f"unknown generation kind: {kind}") + + +def op_generation_parse(msg: JsonObject) -> JsonObject: + """Canonical generation response from a pinned wire body (+ headers: + OpenAI speech is raw bytes typed only by content-type).""" + lm = adapter_for_provider(str(msg["provider"]), _PARSE_ONLY_KEY, _base_url(msg), settings=_settings_of(msg), clock=_clock_of(msg)) + kind = str(msg["kind"]) + status = int(msg["status"]) + body = base64.b64decode(msg["body_b64"]) + if status >= 400: + raise lm.normalize_error(status, body.decode("utf-8", "replace")) + resp = HttpResponse(status=status, reason="OK", headers=_headers_list(msg), body=body) + if kind == "image": + request = serde.image_generation_request_from_dict(msg["generation_request"]) + return serde.image_generation_response_to_dict(lm._image_generation_from_response(request, resp)) + if kind == "speech": + request = serde.speech_generation_request_from_dict(msg["generation_request"]) + return serde.speech_generation_response_to_dict(lm._speech_generation_from_response(request, resp)) + raise ValueError(f"unknown generation kind: {kind}") + + +def op_file_op_build(msg: JsonObject) -> JsonObject: + """Wire request for one files-lifecycle operation (file_op discriminates).""" + lm = _adapter(msg) + op = str(msg["file_op"]) + if op == "upload": + request = serde.file_upload_request_from_dict(msg["upload_request"]) + return normalize_transport_request(lm._file_upload_request(request)) + if op == "get": + return normalize_transport_request(lm._file_get_request(str(msg["file_id"]))) + if op == "list": + cursor = msg.get("cursor") + return normalize_transport_request(lm._file_list_request(int(msg.get("limit", 20)), cursor)) + if op == "delete": + return normalize_transport_request(lm._file_delete_request(str(msg["file_id"]))) + if op == "download": + return normalize_transport_request(lm._file_download_request(str(msg["file_id"]))) + raise ValueError(f"unknown file_op: {op}") + + +def op_file_op_parse(msg: JsonObject) -> JsonObject: + """Canonical FileInfo / FilePage from a pinned wire body (kind discriminates).""" + lm = adapter_for_provider(str(msg["provider"]), _PARSE_ONLY_KEY, _base_url(msg), settings=_settings_of(msg), clock=_clock_of(msg)) + kind = str(msg["kind"]) + status = int(msg["status"]) + body = base64.b64decode(msg["body_b64"]).decode("utf-8") + if status >= 400: + raise lm.normalize_error(status, body) + if kind == "info": + return {"file": serde.file_info_to_dict(lm._file_info_from_body(body))} + if kind == "page": + return {"page": serde.file_page_to_dict(lm._file_page_from_list_body(body))} + raise ValueError(f"unknown file parse kind: {kind}") + + +def op_cache_op_build(msg: JsonObject) -> JsonObject: + """Wire request for one cache-resource operation (cache_op discriminates).""" + lm = _adapter(msg) + op = str(msg["cache_op"]) + if op == "create": + prefix = serde.request_from_dict(msg["prefix_request"]) + lm._check_cache_prefix(prefix, msg.get("ttl_seconds")) + return normalize_transport_request(lm._cache_create_request(prefix, msg.get("ttl_seconds"), msg.get("label"))) + if op == "get": + return normalize_transport_request(lm._cache_get_request(str(msg["cache_id"]))) + if op == "list": + return normalize_transport_request(lm._cache_list_request(int(msg.get("limit", 20)), msg.get("cursor"))) + if op == "delete": + return normalize_transport_request(lm._cache_delete_request(str(msg["cache_id"]))) + if op == "update": + return normalize_transport_request(lm._cache_update_request(str(msg["cache_id"]), int(msg["ttl_seconds"]))) + raise ValueError(f"unknown cache_op: {op}") + + +def op_cache_op_parse(msg: JsonObject) -> JsonObject: + """Canonical CacheInfo / CachePage from a pinned wire body (kind discriminates).""" + lm = adapter_for_provider(str(msg["provider"]), _PARSE_ONLY_KEY, _base_url(msg), settings=_settings_of(msg), clock=_clock_of(msg)) + kind = str(msg["kind"]) + status = int(msg["status"]) + body = base64.b64decode(msg["body_b64"]).decode("utf-8") + if status >= 400: + raise lm.normalize_error(status, body) + if kind == "info": + return {"cache": serde.cache_info_to_dict(lm._cache_info_from_body(body))} + if kind == "page": + return {"page": serde.cache_page_to_dict(lm._cache_page_from_list_body(body))} + raise ValueError(f"unknown cache parse kind: {kind}") + + +def op_video_op_build(msg: JsonObject) -> JsonObject: + """Wire request(s) for one video-job operation (action discriminates). + + Always returns {"requests": [...]}: result_fetch may be zero requests + (xAI's terminal body carries a public URL) or one (Sora content, Veo's + key-bound file URI). + """ + lm = _adapter(msg) + action = str(msg["action"]) + if action == "submit": + request = serde.video_generation_request_from_dict(msg["video_request"]) + return {"requests": [normalize_transport_request(lm._video_submit_request(request))]} + if action == "status": + return {"requests": [normalize_transport_request(lm._video_status_request(str(msg["video_id"])))]} + if action == "result_fetch": + fetch = lm._video_result_fetch(msg["status_body"]) + return {"requests": [] if fetch is None else [normalize_transport_request(fetch)]} + if action == "list": + return {"requests": [normalize_transport_request(lm._video_list_request(int(msg.get("limit", 20)), msg.get("model")))]} + raise ValueError(f"unknown video action: {action}") + + +def op_video_op_parse(msg: JsonObject) -> JsonObject: + """Canonical video snapshots / parts from pinned wire bodies.""" + lm = adapter_for_provider(str(msg["provider"]), _PARSE_ONLY_KEY, _base_url(msg), settings=_settings_of(msg), clock=_clock_of(msg)) + kind = str(msg["kind"]) + if kind == "job": + status = int(msg["status"]) + body = base64.b64decode(msg["body_b64"]).decode("utf-8") + if status >= 400: + raise lm.normalize_error(status, body) + return {"job": serde.video_job_to_dict(lm._video_job_from_body(body, msg.get("video_id")))} + if kind == "list": + body = base64.b64decode(msg["body_b64"]).decode("utf-8") + return {"jobs": [serde.video_job_to_dict(j) for j in lm._video_jobs_from_list_body(body)]} + if kind == "part": + fetched = None + if msg.get("fetched_b64") is not None: + fetched = HttpResponse( + status=200, reason="OK", headers=_headers_list(msg), + body=base64.b64decode(msg["fetched_b64"]), + ) + return {"part": serde.part_to_dict(lm._video_part(msg["status_body"], fetched))} + raise ValueError(f"unknown video parse kind: {kind}") + + +def op_batch_op_build(msg: JsonObject) -> JsonObject: + """Wire request(s) for one batch operation (action discriminates). + + Always returns {"requests": [...]}: upload may be zero requests + (single-step providers), result_fetches may be several (OpenAI's + output and error files). + """ + lm = _adapter(msg) + action = str(msg["action"]) + if action == "upload": + request = serde.batch_request_from_dict(msg["batch_request"]) + upload = lm._batch_upload_request(request) + return {"requests": [] if upload is None else [normalize_transport_request(upload)]} + if action == "submit": + request = serde.batch_request_from_dict(msg["batch_request"]) + upload_body = msg.get("upload_body") + return {"requests": [normalize_transport_request(lm._batch_submit_request(request, upload_body))]} + if action == "status": + return {"requests": [normalize_transport_request(lm._batch_status_request(str(msg["batch_id"])))]} + if action == "cancel": + return {"requests": [normalize_transport_request(lm._batch_cancel_request(str(msg["batch_id"])))]} + if action == "list": + return {"requests": [normalize_transport_request(lm._batch_list_request(int(msg.get("limit", 20))))]} + if action == "result_fetches": + fetches = lm._batch_result_fetches(msg["status_body"]) + return {"requests": [normalize_transport_request(f) for f in fetches]} + raise ValueError(f"unknown batch action: {action}") + + +def op_batch_op_parse(msg: JsonObject) -> JsonObject: + """Canonical batch snapshots from pinned wire bodies (kind discriminates).""" + lm = adapter_for_provider(str(msg["provider"]), _PARSE_ONLY_KEY, _base_url(msg), settings=_settings_of(msg), clock=_clock_of(msg)) + kind = str(msg["kind"]) + if kind == "job": + status = int(msg["status"]) + body = base64.b64decode(msg["body_b64"]).decode("utf-8") + if status >= 400: + raise lm.normalize_error(status, body) + return {"job": serde.batch_job_to_dict(lm._batch_job_from_body(body))} + if kind == "list": + body = base64.b64decode(msg["body_b64"]).decode("utf-8") + return {"jobs": [serde.batch_job_to_dict(j) for j in lm._batch_jobs_from_list_body(body)]} + if kind == "entries": + fetched = tuple(base64.b64decode(b).decode("utf-8") for b in msg.get("fetched_b64", [])) + entries = lm._batch_entries(msg["status_body"], fetched) + return {"entries": [serde.batch_entry_to_dict(e) for e in entries]} + raise ValueError(f"unknown batch parse kind: {kind}") + + +def op_replay_live(msg: JsonObject) -> JsonObject: + """Live transcript replay: the pure websocket codec, no socket. + + Three transformations per case, all driven by the recorded transcript: + setup frames from the LiveConfig, wire frames from each canonical + client event, and canonical server events from each verbatim recorded + server frame (empty list = housekeeping frame, deliberately ignored). + The harness performs all comparison; session mechanics (locking, + queues, iteration sugar) are per-language and stay out of scope. + """ + lm = adapter_for_provider(str(msg["provider"]), _PARSE_ONLY_KEY, _base_url(msg), settings=_settings_of(msg), clock=_clock_of(msg)) + config = serde.live_config_from_dict(msg["live_config"]) + encoder = lm._live_encoder(config) + client_frames = [ + encoder(serde.live_client_event_from_dict(event)) + for event in msg.get("client_events", []) + ] + events = [] + for frame_b64 in msg.get("server_frames_b64", []): + raw = base64.b64decode(frame_b64) + events.append([serde.live_server_event_to_dict(e) for e in lm._decode_live_server_event(raw)]) + return { + "setup_frames": lm._live_setup_frames(config), + "client_frames": client_frames, + "events": events, + } + + +def op_explain_auth(msg: JsonObject) -> JsonObject: + """AUTH-7 resolution chain over harness-supplied inputs only. + + The harness owns every input: ``env`` (always passed, even empty, so the + real process environment never leaks in), ``api_keys_providers`` planted + with the fixture sentinel, and ``credentials_path`` pointing at a + harness-materialized borrowed file. ``report_text`` carries every human + rendering so the harness can enforce AUTH-5 (sentinel absence) itself. + """ + from .doctor import explain_auth + + provider = str(msg["provider"]) + sentinel = str(msg["sentinel"]) + kwargs: dict[str, Any] = {"env": {str(k): str(v) for k, v in (msg.get("env") or {}).items()}} + providers = msg.get("api_keys_providers") or [] + if providers: + kwargs["api_keys"] = {str(p): sentinel for p in providers} + files = msg.get("files") + if isinstance(files, dict): + kwargs["files"] = {str(k): str(v) for k, v in files.items()} + if kwargs["env"].get("HOME"): + kwargs["home"] = kwargs["env"]["HOME"] + if isinstance(msg.get("settings"), dict): + kwargs["settings"] = {str(k): str(v) for k, v in msg["settings"].items()} + credentials_path = msg.get("credentials_path") + if credentials_path is not None: + if provider == "claude-code": + kwargs["claude_credentials_path"] = str(credentials_path) + elif provider == "xai": + kwargs["xai_credentials_path"] = str(credentials_path) + else: + kwargs["codex_auth_path"] = str(credentials_path) + + report = explain_auth(provider, **kwargs) + return { + "configured": report.configured, + "steps": [{"kind": step.kind, "state": step.state} for step in report.steps], + "report_text": "\n".join((report.describe(), repr(report), str(report))), + } + + +def op_token_exchange_build(msg: JsonObject) -> JsonObject: + """PROTOCOL.md token_exchange_build: the exact token-exchange request a + chain rung sends under the fixed clock (auth/token-vectors.json).""" + from .cloud.chains import ChainContext, token_exchange_build + from .credentials import parse_rfc3339 + from .registry import lookup + + definition = lookup(str(msg["provider"])) + if definition is None: + raise ValueError(f"unknown provider: {msg['provider']}") + inputs = dict(msg.get("input") or msg.get("credential") or {}) + env = {str(k): str(v) for k, v in (inputs.get("env") or {}).items()} + files: dict[str, str] = {} + if inputs.get("certificate_pem") and env.get("AZURE_CLIENT_CERTIFICATE_PATH"): + # The harness gives the certificate and the key separately; the + # environment rung reads one PEM file carrying both. + files[env["AZURE_CLIENT_CERTIFICATE_PATH"]] = str(inputs["certificate_pem"]) + "\n" + str(inputs.get("private_key_pem") or "") + fixed = parse_rfc3339(str(msg["now"])) + ctx = ChainContext(env=env, files=files or None, now=lambda: fixed, + settings={str(k): str(v) for k, v in (inputs.get("settings") or msg.get("settings") or {}).items()}) + return token_exchange_build(definition.access, str(msg["rung"]), inputs, ctx) + + +def op_token_exchange_parse(msg: JsonObject) -> JsonObject: + from .cloud.chains import ChainContext, token_exchange_parse + from .credentials import credential_to_dict, parse_rfc3339 + from .registry import lookup + + definition = lookup(str(msg["provider"])) + if definition is None: + raise ValueError(f"unknown provider: {msg['provider']}") + fixed = parse_rfc3339(str(msg["now"])) + body = msg.get("body") + if body is None and msg.get("body_b64") is not None: + body = json.loads(base64.b64decode(str(msg["body_b64"]))) + ctx = ChainContext(env={}, now=lambda: fixed) + try: + credential = token_exchange_parse(definition.access, str(msg["rung"]), int(msg.get("status", 200)), dict(body or {}), ctx) + except LM15Error as exc: + return {"ok": False, "error": {"class": type(exc).__name__, "code": getattr(exc, "code", None)}} + return {"ok": True, "credential": credential_to_dict(credential)} + + +def op_sigv4_sign(msg: JsonObject) -> JsonObject: + """PROTOCOL.md sigv4_sign: the AWS test-suite vectors (auth/sigv4-vectors.json).""" + from .cloud import sigv4 + from .credentials import credential_from_dict, parse_rfc3339 + + credential = credential_from_dict(msg["credential"]) + req = msg["request"] + headers = {str(k): (v if isinstance(v, str) else ",".join(v)) for k, v in dict(req.get("headers") or {}).items() + if str(k).lower() not in ("host", "x-amz-date", "x-amz-security-token")} + signature = sigv4.sign(method=str(req["method"]), url=str(req["url"]), headers=headers, + payload=str(req.get("body") or "").encode("utf-8"), credentials=credential, + region=str(msg["region"]), service=str(msg["service"]), now=parse_rfc3339(str(msg["now"]))) + return {"canonical_request": signature.canonical_request, "string_to_sign": signature.string_to_sign, + "authorization": signature.authorization, "headers": signature.headers} + + +def op_surface_dump(msg: JsonObject) -> JsonObject: + return { + "types": _reflect_types(), + "enums": _reflect_enums(), + "providers": _reflect_providers(), + } + + +def _reflect_providers() -> JsonObject: + """Every registered provider's access policy: who supports what, by reflection. + + This is the support matrix the contract pins (spec/support-matrix.json): + a port that silently disagrees about endpoint support fails the audit, + not a user at runtime. + """ + from .registry import PROVIDERS + + out: JsonObject = {} + for provider in sorted(PROVIDERS): + manifest = PROVIDERS[provider].access + supports = manifest.supports + out[provider] = { + "supports": { + f.name: sorted(getattr(supports, f.name)) if f.name == "extra" else getattr(supports, f.name) + for f in dataclasses.fields(supports) + }, + "auth_modes": list(manifest.auth_modes), + "env_keys": list(manifest.env_keys), + } + return out + + +def _reflect_types() -> JsonObject: + """Every public dataclass in lm15.types, by reflection.""" + out: JsonObject = {} + for name in sorted(vars(lm15_types)): + obj = getattr(lm15_types, name) + if name.startswith("_") or not isinstance(obj, type): + continue + if obj.__module__ != lm15_types.__name__ or not dataclasses.is_dataclass(obj): + continue + out[obj.__name__] = {"fields": [f.name for f in dataclasses.fields(obj)]} + return out + + +def _reflect_enums() -> JsonObject: + """Every string vocabulary in lm15.types, by reflection. + + Harvests Literal type aliases (Role, FinishReason, …) and module-level + string collections (FINISH_REASONS, PART_TYPES, …) — never a + hand-maintained list. Unordered collections are sorted; Literal aliases + keep declaration order. + """ + from . import credentials as lm15_credentials + from . import features as lm15_features + + out: JsonObject = {} + # lm15.types, plus the auth vocabularies (spec/vocabularies.md AuthScheme, + # CredentialKind, CredentialPolicy, StreamFraming, ModelPlacement; 2026-09-03). + for module in (lm15_types, lm15_features, lm15_credentials): + for name in sorted(vars(module)): + if name.startswith("_") or name in ("AuthHeader",): + continue + vocabulary = _string_vocabulary(getattr(module, name)) + if vocabulary: + out[name] = vocabulary + return out + + +def _string_vocabulary(obj: Any) -> list[str] | None: + if get_origin(obj) is Literal: + args = get_args(obj) + if args and all(isinstance(a, str) for a in args): + return list(args) + return None + if isinstance(obj, (frozenset, set)): + if obj and all(isinstance(v, str) for v in obj): + return sorted(obj) + return None + if isinstance(obj, dict): + if obj and all(isinstance(k, str) for k in obj) and all(isinstance(v, type) for v in obj.values()): + return sorted(obj) + return None + if isinstance(obj, (tuple, list)): + if obj and all(isinstance(v, str) for v in obj): + return sorted(obj) + return None + return None + + +# ─── Framing ───────────────────────────────────────────────────────── + +HANDLERS: dict[str, Callable[[JsonObject], JsonObject]] = { + "capabilities": op_capabilities, + "build_request": op_build_request, + "ingest_openai_chat": op_ingest_openai_chat, + "parse_response": op_parse_response, + "replay_stream": op_replay_stream, + "normalize_error": op_normalize_error, + "serde_roundtrip": op_serde_roundtrip, + "validate": op_validate, + "surface_dump": op_surface_dump, + "explain_auth": op_explain_auth, + "resolve_model": op_resolve_model, + "token_exchange_build": op_token_exchange_build, + "token_exchange_parse": op_token_exchange_parse, + "sigv4_sign": op_sigv4_sign, + "build_models_request": op_build_models_request, + "parse_models_response": op_parse_models_response, + "replay_live": op_replay_live, + "generation_build": op_generation_build, + "generation_parse": op_generation_parse, + "file_op_build": op_file_op_build, + "file_op_parse": op_file_op_parse, + "video_op_build": op_video_op_build, + "video_op_parse": op_video_op_parse, + "batch_op_build": op_batch_op_build, + "batch_op_parse": op_batch_op_parse, + "cache_op_build": op_cache_op_build, + "cache_op_parse": op_cache_op_parse, +} + + +class _OpFailure(Exception): + """An op failed with a typed error plus extra fields for the error reply.""" + + def __init__(self, cause: BaseException, extra: JsonObject) -> None: + super().__init__(str(cause)) + self.cause = cause + self.extra = extra + + +def _error_reply(req_id: Any, exc: BaseException) -> JsonObject: + # lm15-typed errors report the canonical class name and ErrorCode; + # unexpected exceptions report the native exception name. A + # StreamAssemblyError also carries the partial Response it salvaged + # (MAP-9), so a golden can pin a raise and what survived it. + extra: JsonObject = {} + if isinstance(exc, _OpFailure): + extra = exc.extra + exc = exc.cause + error: JsonObject = {"type": type(exc).__name__, "message": str(exc)} + if isinstance(exc, LM15Error) and exc.code is not None: + error["code"] = exc.code + feature = getattr(exc, "feature", None) + if isinstance(feature, str) and feature: + # MAP-13: the config path a refusal is about, so a policy layer + # can act on it; pinned by cases as expect_lm15.raises.feature. + error["feature"] = feature + if isinstance(exc, StreamAssemblyError) and exc.partial is not None: + error["partial_response"] = _response_result(exc.partial)["canonical_response"] + if isinstance(exc, (UnknownModelError, AmbiguousModelError)): + # The payload spec/vocabularies.md pins for the router codes. + error["model"] = exc.model + if isinstance(exc, AmbiguousModelError): + error["providers"] = list(exc.providers) + error.update(extra) + return {"id": req_id, "ok": False, "error": error} + + +def handle_line(line: str) -> JsonObject: + try: + msg = json.loads(line) + except Exception as exc: + return _error_reply(None, exc) + + if not isinstance(msg, dict): + return _error_reply(None, ValueError("request must be a JSON object")) + + req_id = msg.get("id") + try: + op = msg.get("op") + handler = HANDLERS.get(str(op)) + if handler is None: + raise ValueError(f"unknown op: {op}") + result = handler(msg) + except Exception as exc: + return _error_reply(req_id, exc) + return {"id": req_id, "ok": True, "result": result} + + +def main(argv: list[str] | None = None) -> int: + for line in sys.stdin: + if not line.strip(): + continue + reply = handle_line(line) + sys.stdout.write(json.dumps(reply, separators=(",", ":")) + "\n") + sys.stdout.flush() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/video_jobs.py b/video_jobs.py new file mode 100644 index 0000000000..95ca02fd61 --- /dev/null +++ b/video_jobs.py @@ -0,0 +1,135 @@ +"""Explicit ticket handles over video_submit/status/result/list. + +Snapshot properties never perform I/O. Waiting never submits or retries a failed +request, and abandoning a wait does not cancel provider generation or billing. +See the contract's 2026-09-11-shared-handles-and-profile-migration decision. +""" +from __future__ import annotations + +import asyncio +import math +import time +from typing import TYPE_CHECKING + +from .types import VideoJobInfo, VideoPart, VideoStatus + +if TYPE_CHECKING: + from .providers.async_base import AsyncBaseProviderLM + from .providers.base import BaseProviderLM + +__all__ = ["AsyncVideoJob", "VideoJob", "VideoWaitTimeout"] + + +class VideoWaitTimeout(TimeoutError): + """Local polling deadline, not an HTTP timeout; the job may still be running.""" + + def __init__(self, info: VideoJobInfo): + self.info = info + super().__init__(f"video {info.id} still {info.status!r} when the wait deadline expired") + + +def _validate_wait(poll_every: float, timeout: float | None) -> None: + for name, value, positive in (("poll_every", poll_every, True), ("timeout", timeout, False)): + if value is None and name == "timeout": + continue + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError(f"{name} must be a finite number") + if value < 0 or (positive and value == 0): + raise ValueError(f"{name} must be {'positive' if positive else 'non-negative'}") + + +class _Snapshot: + _info: VideoJobInfo + + @property + def info(self) -> VideoJobInfo: + """The frozen snapshot from the last successful provider contact.""" + return self._info + + @property + def id(self) -> str: + return self._info.id + + @property + def status(self) -> VideoStatus: + return self._info.status + + @property + def progress(self) -> int | None: + return self._info.progress + + def __repr__(self) -> str: + return f"{type(self).__name__}(id={self.id!r}, status={self.status!r}, progress={self.progress!r})" + + +class VideoJob(_Snapshot): + def __init__(self, lm: BaseProviderLM, info: VideoJobInfo) -> None: + self._lm, self._info = lm, info + + def refresh(self) -> VideoJob: + self._info = self._lm.video_status(self.id) + return self + + def wait(self, poll_every: float = 5.0, timeout: float | None = 300.0) -> VideoJob: + """Wait for a terminal status, including failure; inspect status afterward. + + timeout=None deliberately waits indefinitely. The sync deadline bounds + polling, not an in-flight status call: its transport timeout still applies. + """ + _validate_wait(poll_every, timeout) + deadline = None if timeout is None else time.monotonic() + timeout + while not self.info.done: + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + raise VideoWaitTimeout(self.info) + time.sleep(poll_every if remaining is None else min(poll_every, remaining)) + if deadline is not None and time.monotonic() >= deadline: + raise VideoWaitTimeout(self.info) + self.refresh() + return self + + def result(self) -> VideoPart: + """Fetch through the raw result operation; never implicitly wait.""" + return self._lm.video_result(self.id) + + +class AsyncVideoJob(_Snapshot): + def __init__(self, lm: AsyncBaseProviderLM, info: VideoJobInfo) -> None: + self._lm, self._info = lm, info + + async def refresh(self) -> AsyncVideoJob: + self._info = await self._lm.video_status(self.id) + return self + + async def wait(self, poll_every: float = 5.0, timeout: float | None = 300.0) -> AsyncVideoJob: + """Native cancellable wait. Neither cancellation nor timeout cancels the job.""" + from .transports._timeouts import wait_for + + _validate_wait(poll_every, timeout) + deadline = None if timeout is None else time.monotonic() + timeout + while not self.info.done: + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + raise VideoWaitTimeout(self.info) + await asyncio.sleep(poll_every if remaining is None else min(poll_every, remaining)) + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + raise VideoWaitTimeout(self.info) + # Return provider exceptions as data so a provider's own TimeoutError + # is never mistaken for the local wait deadline. + async def status(): + try: + return await self._lm.video_status(self.id), None + except Exception as exc: + return None, exc + try: + info, error = await wait_for(status(), timeout=remaining) + except asyncio.TimeoutError as exc: + raise VideoWaitTimeout(self.info) from exc + if error is not None: + raise error + self._info = info + return self + + async def result(self) -> VideoPart: + return await self._lm.video_result(self.id) From caa8929c79002920d22d77c2bb5009219650ad67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Drey=C3=B8e=20Herfort?= Date: Tue, 29 Sep 2026 23:41:55 +0200 Subject: [PATCH 2/4] Squashed 'dspy/_vendor/lm15/' changes from 63b6bb319..6a745c84e 6a745c84e fix(openai-chat): read ogg, opus, flac, aac, aiff, webm and mpeg input_audio as their true media types git-subtree-dir: dspy/_vendor/lm15 git-subtree-split: 6a745c84e83a58aa4ed1237132d940f78ce65c35 --- providers/openai_chat.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/providers/openai_chat.py b/providers/openai_chat.py index f5a6eab2d6..35f32e77df 100644 --- a/providers/openai_chat.py +++ b/providers/openai_chat.py @@ -246,7 +246,13 @@ def _response_format_to_chat(format_config: dict[str, Any]) -> dict[str, Any]: _INGEST_GROQ_BUILTIN_INVERSE: dict[str, str] = {wire: name for name, wire in _GROQ_BUILTIN_MAP.items()} -_INGEST_AUDIO_MEDIA_TYPES: dict[str, str] = {"wav": "audio/wav", "mp3": "audio/mpeg"} +# MAP-12 rule 4: OpenAI's server takes wav and mp3, Gemini's any audio type, +# and DSPy writes the MIME subtype (mpeg for .mp3). Each format reads as its +# true media type; a builder with no audio slot raises at send (MAP-10). +_INGEST_AUDIO_MEDIA_TYPES: dict[str, str] = { + "wav": "audio/wav", "mp3": "audio/mpeg", "mpeg": "audio/mpeg", "ogg": "audio/ogg", "opus": "audio/opus", + "flac": "audio/flac", "aac": "audio/aac", "aiff": "audio/aiff", "webm": "audio/webm", +} def _ingest_unsupported(provider: str, what: str, why: str) -> UnsupportedFeatureError: From 0507d7a32349d70a67432dba75ab5fdf7d8b5b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Drey=C3=B8e=20Herfort?= Date: Tue, 29 Sep 2026 23:41:55 +0200 Subject: [PATCH 3/4] Record lm15 source and contract for b0bffc13bc4a --- dspy/_vendor/lm15-provenance.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dspy/_vendor/lm15-provenance.txt b/dspy/_vendor/lm15-provenance.txt index fe0e7f89ba..338d87095c 100644 --- a/dspy/_vendor/lm15-provenance.txt +++ b/dspy/_vendor/lm15-provenance.txt @@ -1,5 +1,5 @@ source=https://github.com/cmpnd-ai/lm15-python.git version=1.0.0rc3 -commit=55fce2e61a04ed3193acfeb8797b7c58d1b55398 +commit=b0bffc13bc4a12e115e4c2e92fbba40ca8f3450c contract=b721ce4f03877d72a91431b3a5ce653ffc0b3e38 -split=63b6bb319f38ad1d31060138da54cb8ac8718711 +split=6a745c84e83a58aa4ed1237132d940f78ce65c35 From d74cf542d98b9b7d95927ec24a0f820fc4890fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Drey=C3=B8e=20Herfort?= Date: Tue, 29 Sep 2026 23:45:48 +0200 Subject: [PATCH 4/4] test(lm15): an ogg dspy.Audio reaches Gemini inline with its media type Before the lm15 update, lm15's Chat Completions ingest refused every input_audio format but wav and mp3, so an ogg recording never reached the native Gemini request (DSPy fell back to LiteLLM, or raised on the lm15 engine). The test drives dspy.Predict through a fake transport and checks the inlineData part. --- tests/clients/test_lm15_audio_formats.py | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/clients/test_lm15_audio_formats.py diff --git a/tests/clients/test_lm15_audio_formats.py b/tests/clients/test_lm15_audio_formats.py new file mode 100644 index 0000000000..4f7373a6fb --- /dev/null +++ b/tests/clients/test_lm15_audio_formats.py @@ -0,0 +1,29 @@ +import json + +import dspy +import dspy.clients.execution as execution +from dspy._vendor.lm15.testing import FakeResponse, FakeTransport +from dspy.lm15 import RouterConfig + + +def test_ogg_audio_reaches_gemini_inline_with_its_media_type(monkeypatch): + reply = { + "candidates": [{"content": {"role": "model", "parts": [{"text": "[[ ## text ## ]]\nhej\n\n[[ ## completed ## ]]"}]}, + "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2}, + } + transport = FakeTransport([FakeResponse(status=200, body=json.dumps(reply).encode())]) + monkeypatch.setattr(execution, "RouterConfig", lambda **kwargs: RouterConfig( + **{**{k: v for k, v in kwargs.items() if k != "timeouts"}, "api_keys": {"gemini": "fake"}, "transport": transport}, + )) + + class Transcribe(dspy.Signature): + audio: dspy.Audio = dspy.InputField() + text: str = dspy.OutputField() + + with dspy.context(lm=dspy.LM("gemini/gemini-3.8-flash", cache=False), adapter=dspy.ChatAdapter()): + result = dspy.Predict(Transcribe)(audio=dspy.Audio(data="T2dnUw==", audio_format="ogg")) + + assert result.text == "hej" + parts = json.loads(transport.requests[0].body)["contents"][-1]["parts"] + assert {"inlineData": {"mimeType": "audio/ogg", "data": "T2dnUw=="}} in parts