diff --git a/Dockerfile b/Dockerfile index b782e515..bf6f90c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ WORKDIR /app COPY pyproject.toml uv.lock README.md LICENSE entrypoint.sh ./ # Copy the full source and install COPY src ./src -RUN uv sync --no-dev --no-editable && uv pip install "any-llm-sdk[gemini,xai]" && \ +RUN uv sync --locked --no-dev --no-editable && \ chmod +x /app/entrypoint.sh WORKDIR /workspace diff --git a/README.md b/README.md index 32b3b6ee..c1699ce0 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ See the [Build docs](https://bub.build/docs/build/) for hook guides, packaging, | `bub gateway` | Channel listener (Telegram, etc.) | | `bub install` | Install or sync Bub plugin deps | | `bub update` | Upgrade Bub plugin deps | -| `bub login openai` | OpenAI Codex OAuth | +| `bub login codex` | ChatGPT plan login | Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-skill`, `,fs.read path=README.md`). @@ -131,7 +131,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk | Variable | Default | Description | | --------------------------- | ---------------------------- | ---------------------------------------------------- | | `BUB_MODEL` | `openrouter:openrouter/free` | Model identifier | -| `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) | +| `BUB_API_KEY` | — | Provider key (optional with `bub login codex`) | | `BUB_API_BASE` | — | Custom provider endpoint | | `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client | | `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call | diff --git a/pyproject.toml b/pyproject.toml index 66b46370..6235fc2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,7 @@ dependencies = [ "pluggy>=1.6.0", "inquirer-textual>=0.8.0", "typer>=0.24.1", - "authlib>=1.7.2", - "any-llm-sdk[anthropic]>=1.22.1", + "republic>=0.6.0a1", "rich>=14.3.4", "prompt-toolkit>=3.0.52", "python-telegram-bot>=22.7", diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 864b69e0..14a904cb 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -1,4 +1,4 @@ -"""Runtime engine to process prompts with any-llm-sdk.""" +"""Runtime engine to process prompts with Republic.""" from __future__ import annotations @@ -15,6 +15,7 @@ from pathlib import Path from typing import Any +import republic from loguru import logger from bub.builtin.commands import strip_command_prefix, validate_command_prefix @@ -25,6 +26,7 @@ from bub.builtin.settings import load_settings from bub.envelope import field_of from bub.framework import BubFramework +from bub.prompt import LegacyPrompt, UserContent, prompt_text, to_content from bub.skills import discover_skills, render_skills_prompt from bub.store import AsyncTapeStore, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeStore, is_async_tape_store from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState @@ -39,7 +41,7 @@ class Agent: - """Agent that processes prompts using hooks, tools, tape, and any-llm-sdk.""" + """Agent that processes prompts using hooks, tools, tape, and Republic.""" def __init__( self, @@ -115,7 +117,7 @@ async def run_stream( self, *, session_id: str, - prompt: str | list[dict], + prompt: list[UserContent] | LegacyPrompt, state: TurnState | None = None, model: str | None = None, allowed_skills: Collection[str] | None = None, @@ -127,8 +129,9 @@ async def run_stream( Args: session_id: Session identity within the workspace. A ``temp/`` prefix prevents the turn's fork from merging back into its parent tape. - prompt: Text or multimodal content parts. Text beginning with this - agent's command prefix after stripping whitespace invokes a command. + prompt: User content (text, images, audio and video); legacy text and content blocks are converted. + A text-only prompt beginning with this agent's command prefix after + stripping whitespace invokes a command. state: Mutable turn state. None loads state through framework hooks using this agent's store; supplied state skips that loading. The current agent is always bound into the state. @@ -154,11 +157,12 @@ async def run_stream( "gen_ai.conversation.id": session_id, }, ) - span.messages("gen_ai.input.messages", [{"role": "user", "content": prompt}]) stack = AsyncExitStack() try: with span.activate(): - if not prompt: + content = to_content(prompt) + span.messages("gen_ai.input.messages", [republic.user(*content)]) + if not any(content): events = self._events_from_iterable([ StreamEvent("text", {"delta": "error: empty prompt"}), StreamEvent("final", {"text": "error: empty prompt", "ok": False}), @@ -180,7 +184,11 @@ async def run_stream( tape.fork_tape(merge_back=not session_id.startswith("temp/")) ) await tape.ensure_bootstrap_anchor() - command = strip_command_prefix(prompt, self.command_prefix) if isinstance(prompt, str) else None + command = ( + strip_command_prefix(content[0], self.command_prefix) + if len(content) == 1 and isinstance(content[0], str) + else None + ) if command is not None: result = await self._run_command(tape=tape, line=command) events = self._events_from_iterable([ @@ -190,7 +198,7 @@ async def run_stream( else: events = await self._agent_loop( tape=tape, - prompt=prompt, + prompt=content, model=model, allowed_skills=allowed_skills, allowed_tools=allowed_tools, @@ -283,18 +291,18 @@ async def _agent_loop( self, *, tape: Tape, - prompt: str | list[dict], + prompt: list[UserContent], model: str | None = None, allowed_skills: Collection[str] | None = None, allowed_tools: Collection[str] | None = None, ) -> AsyncStreamEvents: - next_prompt: str | list[dict] = prompt + next_prompt = prompt display_model = model or self.settings.model await tape.append_event( "loop.start", { "model": display_model, - "prompt": prompt, + "prompt": _prompt_payload(prompt), "allowed_skills": list(allowed_skills) if allowed_skills else None, "allowed_tools": list(allowed_tools) if allowed_tools else None, }, @@ -313,7 +321,7 @@ async def _agent_loop( async def _stream_events_with_auto_handoff( self, tape: Tape, - prompt: str | list[dict], + prompt: list[UserContent], state: StreamState, model: str | None = None, allowed_skills: Collection[str] | None = None, @@ -321,12 +329,15 @@ async def _stream_events_with_auto_handoff( ) -> AsyncGenerator[StreamEvent, None]: auto_handoff_remaining = MAX_AUTO_HANDOFF_RETRIES display_model = model or self.settings.model - next_prompt: str | list[dict] | None = prompt + next_prompt: list[UserContent] | None = prompt for step in range(1, self.settings.max_steps + 1): start = time.monotonic() should_continue = False logger.info("loop.step step={} tape={} model={}", step, tape.name, display_model) - await tape.append_event("loop.step.start", {"step": step, "prompt": next_prompt}) + await tape.append_event( + "loop.step.start", + {"step": step, "prompt": None if next_prompt is None else _prompt_payload(next_prompt)}, + ) try: output = await self._run_once( tape=tape, @@ -407,7 +418,8 @@ async def _stream_events_with_auto_handoff( ) return - next_prompt = await self.framework.continue_prompt(prompt=next_prompt, tape=tape, state=state) + continuation = await self.framework.continue_prompt(prompt=next_prompt, tape=tape, state=state) + next_prompt = None if continuation is None else [continuation] await tape.append_event( "loop.step", { @@ -433,24 +445,17 @@ async def _run_once( self, *, tape: Tape, - prompt: str | list[dict] | None, + prompt: list[UserContent] | None, model: str | None = None, allowed_tools: Collection[str] | None = None, allowed_skills: Collection[str] | None = None, ) -> AsyncStreamEvents: - if isinstance(prompt, str): - prompt_text = prompt - elif prompt is None: - prompt_text = "" - else: - prompt_text = _extract_text_from_parts(prompt) if allowed_skills is not None: allowed_skills = {name.casefold() for name in allowed_skills} tape.context.state["allowed_skills"] = list(allowed_skills) return await self._run_once_stream( tape=tape, prompt=prompt, - prompt_text=prompt_text, model=model, allowed_skills=allowed_skills, tools=self._allowed_tools(tape, allowed_tools), @@ -492,8 +497,7 @@ async def _run_once_stream( self, *, tape: Tape, - prompt: str | list[dict] | None, - prompt_text: str, + prompt: list[UserContent] | None, model: str | None, allowed_skills: set[str] | None, tools: list[Tool], @@ -502,7 +506,7 @@ async def _run_once_stream( tools, code_mode_prompt = await self._prepare_code_mode(tools, tape) tools, deferred_prompt = await self._prepare_deferred_tools(tools, tape) system_prompt = self._system_prompt( - prompt_text, + prompt or [], state=tape.context.state, allowed_skills=allowed_skills, tools_prompt="\n\n".join(block for block in (code_mode_prompt, deferred_prompt) if block), @@ -512,13 +516,11 @@ async def _run_once_stream( model_tools_for_call = model_tools(tools) if (span := current_span()) and span.recording: span.set(**{ - "gen_ai.tool.definitions": [ - tool.to_schema()["function"] | {"type": "function"} for tool in model_tools_for_call - ] + "gen_ai.tool.definitions": [tool.to_schema() | {"type": "function"} for tool in model_tools_for_call] }) steering_inbox = self.framework.get_steering_inbox() steering_envelopes = await steering_inbox.drain_messages(tape.context.state) if steering_inbox else [] - steering_messages = list( + steering_messages: list[list[UserContent]] = list( await asyncio.gather(*[ self.framework.build_prompt( message, session_id=field_of(message, "session_id"), state=tape.context.state @@ -566,7 +568,7 @@ async def _prepare_code_mode(self, tools: list[Tool], tape: Tape) -> tuple[list[ def _system_prompt( self, - prompt: str, + prompt: list[UserContent], state: TurnState, allowed_skills: set[str] | None = None, tools_prompt: str = "", @@ -577,7 +579,7 @@ def _system_prompt( if tools_prompt: blocks.append(tools_prompt) workspace = workspace_from_state(state) - if skills_prompt := self._load_skills_prompt(prompt, workspace, allowed_skills): + if skills_prompt := self._load_skills_prompt(prompt_text(prompt), workspace, allowed_skills): blocks.append(skills_prompt) return "\n\n".join(blocks) @@ -616,6 +618,6 @@ def _parse_args(args_tokens: list[str]) -> Args: return Args(positional=positional, kwargs=kwargs) -def _extract_text_from_parts(parts: list[dict]) -> str: - """Extract text content from multimodal content parts.""" - return "\n".join(p.get("text", "") for p in parts if p.get("type") == "text") +def _prompt_payload(content: list[UserContent]) -> object: + """Serialize prompt content for tape events.""" + return republic.user(*content).to_dict().get("content") diff --git a/src/bub/builtin/auth.py b/src/bub/builtin/auth.py index 3700c83d..63b7a0ff 100644 --- a/src/bub/builtin/auth.py +++ b/src/bub/builtin/auth.py @@ -1,537 +1,50 @@ -"""Authentication helpers for builtin providers.""" +"""CLI entry points for Republic-managed account authentication.""" -# ruff: noqa: B008 from __future__ import annotations -import json -import math +import asyncio import os -import secrets -import threading -import time -import urllib.parse -import webbrowser -from base64 import urlsafe_b64decode, urlsafe_b64encode -from collections.abc import Callable -from contextlib import suppress -from dataclasses import dataclass -from datetime import UTC, datetime -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import subprocess from pathlib import Path -from typing import Any +from typing import Annotated import typer -from authlib.integrations.httpx_client import OAuth2Client -from loguru import logger +from republic.errors import AuthenticationError +from republic.providers import CodexAuth -CODEX_PROVIDER = "openai" -DEFAULT_CODEX_REDIRECT_URI = "http://localhost:1455/auth/callback" +app = typer.Typer(name="login", help="Account authentication") -_CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" -_CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" # noqa: S105 -_CODEX_OAUTH_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" -_CODEX_OAUTH_SCOPE = "openid profile email offline_access" -_CODEX_OAUTH_ORIGINATOR = "codex_cli_rs" -app = typer.Typer(name="login", help="Authentication related commands") +@app.callback() +def login() -> None: + """Authenticate a provider account.""" -class CodexOAuthLoginError(RuntimeError): - """Raised when Codex OAuth login cannot complete.""" - - -class CodexOAuthStateMismatchError(CodexOAuthLoginError): - """Raised when OAuth state validation fails.""" - - -class CodexOAuthMissingCodeError(CodexOAuthLoginError): - """Raised when OAuth redirect does not include an authorization code.""" - - -class CodexOAuthResponseError(TypeError): - """Raised when the OAuth token endpoint returns a malformed payload.""" - - -class CodexOAuthRefreshError(RuntimeError): - """Raised when an expired Codex OAuth token cannot be refreshed.""" - - -@dataclass(frozen=True) -class OpenAICodexOAuthTokens: - access_token: str - refresh_token: str - expires_at: int | None - account_id: str | None = None - - -def resolve_codex_home(codex_home: str | Path | None = None) -> Path: - if codex_home is not None: - return Path(codex_home).expanduser() - return Path(os.getenv("CODEX_HOME", "~/.codex")).expanduser() - - -def resolve_codex_auth_path(codex_home: str | Path | None = None) -> Path: - return resolve_codex_home(codex_home) / "auth.json" - - -def load_openai_codex_oauth_tokens(codex_home: str | Path | None = None) -> OpenAICodexOAuthTokens | None: - auth_path = resolve_codex_auth_path(codex_home) - try: - payload = json.loads(auth_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return None - if not isinstance(payload, dict): - return None - return _parse_tokens(payload) - - -def save_openai_codex_oauth_tokens( - tokens: OpenAICodexOAuthTokens, - codex_home: str | Path | None = None, -) -> Path: - auth_path = resolve_codex_auth_path(codex_home) - auth_path.parent.mkdir(parents=True, exist_ok=True) - - try: - existing = json.loads(auth_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - existing = {} - payload: dict[str, Any] = existing if isinstance(existing, dict) else {} - - token_payload = payload.get("tokens") - if not isinstance(token_payload, dict): - token_payload = {} - token_payload.update({ - "access_token": tokens.access_token, - "refresh_token": tokens.refresh_token, - }) - if tokens.expires_at is None: - token_payload.pop("expires_at", None) - else: - token_payload["expires_at"] = _unix_to_rfc3339(tokens.expires_at) - if tokens.account_id: - token_payload["account_id"] = tokens.account_id - payload["tokens"] = token_payload - payload["last_refresh"] = _unix_to_rfc3339(int(time.time())) - - auth_path.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") - with suppress(OSError): - os.chmod(auth_path, 0o600) - return auth_path - - -def refresh_openai_codex_oauth_tokens( - refresh_token: str, - *, - timeout_seconds: float = 15.0, - client_id: str = _CODEX_OAUTH_CLIENT_ID, - token_url: str = _CODEX_OAUTH_TOKEN_URL, -) -> OpenAICodexOAuthTokens: - with OAuth2Client(client_id=client_id, timeout=timeout_seconds, trust_env=True) as oauth: - payload = oauth.refresh_token(url=token_url, refresh_token=refresh_token) - return _tokens_from_token_payload(payload, account_id=None) - - -def openai_codex_oauth_resolver( - codex_home: str | Path | None = None, - *, - refresh_skew_seconds: int = 120, - refresh_timeout_seconds: float = 15.0, - refresher: Callable[[str], OpenAICodexOAuthTokens] | None = None, -) -> Callable[[str], str | None]: - """Build a provider-scoped OAuth token resolver with refresh support.""" - - lock = threading.Lock() - if refresher is None: - refresher = lambda refresh_token: refresh_openai_codex_oauth_tokens( - refresh_token, - timeout_seconds=refresh_timeout_seconds, - ) - - def _resolve(provider: str) -> str | None: - if provider != CODEX_PROVIDER: - return None - with lock: - tokens = load_openai_codex_oauth_tokens(codex_home) - if tokens is None: - return None - - now = int(time.time()) - # Unknown expiry is not evidence that the token needs refreshing. - if tokens.expires_at is None or tokens.expires_at > now + refresh_skew_seconds: - return tokens.access_token - +@app.command("codex") +def login_codex( + executable: Annotated[str, typer.Option("--executable", help="Codex CLI executable")] = "codex", + device_auth: Annotated[bool, typer.Option("--device-auth", help="Use device authorization")] = False, + codex_home: Annotated[Path | None, typer.Option("--codex-home", help="Codex credential directory")] = None, +) -> None: + """Log in with the Codex CLI using file credential storage.""" + try: + if codex_home is None: + auth = asyncio.run(CodexAuth.login(executable=executable, device_auth=device_auth)) + else: + home = codex_home.expanduser().resolve() + command = [executable, "login", "--config", 'cli_auth_credentials_store="file"'] + if device_auth: + command.append("--device-auth") try: - refreshed = refresher(tokens.refresh_token) - except Exception as exc: - logger.warning("Codex OAuth token refresh failed: {}", type(exc).__name__) - if tokens.expires_at > time.time(): - return tokens.access_token - raise CodexOAuthRefreshError( - f"Codex OAuth token refresh failed ({type(exc).__name__}). " - "Check your connection or sign in again with `bub login openai`." - ) from None - - persisted = OpenAICodexOAuthTokens( - access_token=refreshed.access_token, - refresh_token=refreshed.refresh_token, - expires_at=refreshed.expires_at, - account_id=refreshed.account_id or tokens.account_id, - ) - save_openai_codex_oauth_tokens(persisted, codex_home) - return persisted.access_token - - return _resolve - - -def login_openai_codex_oauth( - *, - codex_home: str | Path | None = None, - prompt_for_redirect: Callable[[str], str] | None = None, - open_browser: bool = True, - browser_opener: Callable[[str], Any] | None = None, - redirect_uri: str = DEFAULT_CODEX_REDIRECT_URI, - timeout_seconds: float = 300.0, - client_id: str = _CODEX_OAUTH_CLIENT_ID, - authorize_url: str = _CODEX_OAUTH_AUTHORIZE_URL, - token_url: str = _CODEX_OAUTH_TOKEN_URL, - scope: str = _CODEX_OAUTH_SCOPE, - originator: str = _CODEX_OAUTH_ORIGINATOR, -) -> OpenAICodexOAuthTokens: - """Run the OpenAI Codex OAuth flow and persist tokens.""" - - verifier = _build_pkce_verifier() - state = secrets.token_hex(16) - oauth_url = _build_authorize_url( - client_id=client_id, - redirect_uri=redirect_uri, - code_challenge=verifier, - state=state, - authorize_url=authorize_url, - scope=scope, - originator=originator, - ) - - if open_browser: - opener = browser_opener or webbrowser.open - opener(oauth_url) - - if prompt_for_redirect is None: - callback_values = _wait_for_local_oauth_callback(redirect_uri=redirect_uri, timeout_seconds=timeout_seconds) - if callback_values is None: - raise CodexOAuthLoginError( - "Did not receive OAuth callback. " - f"redirect_uri={redirect_uri!r}, timeout_seconds={timeout_seconds}. " - "Try increasing --timeout or use --manual." - ) - code, returned_state = callback_values - else: - code, returned_state = _extract_code_and_state(prompt_for_redirect(oauth_url)) - - if returned_state and returned_state != state: - raise CodexOAuthStateMismatchError - if not isinstance(code, str) or not code.strip(): - raise CodexOAuthMissingCodeError - - tokens = _exchange_openai_codex_authorization_code( - code=code.strip(), - verifier=verifier, - redirect_uri=redirect_uri, - timeout_seconds=timeout_seconds, - client_id=client_id, - token_url=token_url, - ) - save_openai_codex_oauth_tokens(tokens, codex_home) - return tokens - - -def _prompt_for_codex_redirect(authorize_url: str) -> str: - typer.echo("Open this URL in your browser and complete the Codex sign-in flow:\n") - typer.echo(authorize_url) - typer.echo("\nPaste the full callback URL or the authorization code.") - return str(typer.prompt("callback")).strip() - - -def _render_codex_login_result(tokens: OpenAICodexOAuthTokens, auth_path: Path) -> None: + subprocess.run(command, env={**os.environ, "CODEX_HOME": str(home)}, check=True) + except OSError: + raise AuthenticationError(f"Cannot start {executable}; install the Codex CLI.") from None + except subprocess.CalledProcessError as exc: + raise AuthenticationError(f"Codex login failed (exit {exc.returncode})") from None + auth = CodexAuth.from_file(home / "auth.json") + except AuthenticationError as exc: + typer.secho(f"login: failed: {exc}", fg="red", err=True) + raise typer.Exit(1) from None typer.echo("login: ok") - typer.echo(f"account_id: {tokens.account_id or '-'}") - typer.echo(f"auth_file: {auth_path}") - typer.echo("usage: set BUB_MODEL=openai: and omit BUB_API_KEY") - - -@app.command() -def openai( - codex_home: Path | None = typer.Option(None, "--codex-home", help="Directory to store Codex OAuth credentials"), - open_browser: bool = typer.Option(True, "--browser/--no-browser", help="Open the OAuth URL in a browser"), - manual: bool = typer.Option( - False, - "--manual", - help="Paste the callback URL or code instead of waiting for a local callback server", - ), - timeout_seconds: float = typer.Option(300.0, "--timeout", help="OAuth wait timeout in seconds"), -) -> None: - """Login with OpenAI OAuth.""" - - resolved_codex_home = resolve_codex_home(codex_home) - prompt_for_redirect = _prompt_for_codex_redirect if manual or not open_browser else None - - try: - tokens = login_openai_codex_oauth( - codex_home=resolved_codex_home, - prompt_for_redirect=prompt_for_redirect, - open_browser=open_browser, - redirect_uri=DEFAULT_CODEX_REDIRECT_URI, - timeout_seconds=timeout_seconds, - ) - except CodexOAuthLoginError as exc: - typer.echo(f"Codex login failed: {exc}", err=True) - raise typer.Exit(1) from exc - - _render_codex_login_result(tokens, resolved_codex_home / "auth.json") - - -def extract_openai_codex_account_id(access_token: str) -> str | None: - payload = _decode_unverified_jwt_payload(access_token) - if payload is None: - return None - auth = payload.get("https://api.openai.com/auth") - if not isinstance(auth, dict): - return None - account_id = auth.get("chatgpt_account_id") - if not isinstance(account_id, str): - return None - return account_id.strip() or None - - -def _decode_unverified_jwt_payload(access_token: str) -> dict[str, Any] | None: - """Read local JWT claims without treating them as verified credentials.""" - - parts = access_token.split(".") - if len(parts) != 3: - return None - payload_segment = parts[1] - padding = "=" * (-len(payload_segment) % 4) - try: - payload = json.loads(urlsafe_b64decode((payload_segment + padding).encode("ascii")).decode("utf-8")) - except (UnicodeError, ValueError): - return None - return payload if isinstance(payload, dict) else None - - -def _jwt_expiry(access_token: str) -> int | None: - payload = _decode_unverified_jwt_payload(access_token) - if payload is None: - return None - exp = payload.get("exp") - if type(exp) is int: - return exp - if type(exp) is float and math.isfinite(exp): - return int(exp) - return None - - -def _parse_tokens(payload: dict[str, Any]) -> OpenAICodexOAuthTokens | None: - tokens = payload.get("tokens") - if not isinstance(tokens, dict): - return None - - access_token = tokens.get("access_token") - refresh_token = tokens.get("refresh_token") - if not isinstance(access_token, str) or not isinstance(refresh_token, str): - return None - access = access_token.strip() - refresh = refresh_token.strip() - if not access or not refresh: - return None - - expires_at = _parse_expiry(tokens.get("expires_at"), access) - account_id = tokens.get("account_id") - return OpenAICodexOAuthTokens( - access_token=access, - refresh_token=refresh, - expires_at=expires_at, - account_id=account_id if isinstance(account_id, str) else None, - ) - - -def _parse_expiry(expires_raw: object, access_token: str) -> int | None: - try: - if isinstance(expires_raw, str): - if (expires_at := _rfc3339_to_unix(expires_raw)) is not None: - return expires_at - elif isinstance(expires_raw, int | float) and not isinstance(expires_raw, bool): - return int(expires_raw) - except (ValueError, OverflowError): - pass - return _jwt_expiry(access_token) - - -def _tokens_from_token_payload(payload: dict[str, Any], *, account_id: str | None) -> OpenAICodexOAuthTokens: - access_token = payload.get("access_token") - refresh_token = payload.get("refresh_token") - expires_in = payload.get("expires_in") - if not isinstance(access_token, str) or not isinstance(refresh_token, str): - raise CodexOAuthResponseError - if not isinstance(expires_in, int | float): - raise CodexOAuthResponseError - - access = access_token.strip() - return OpenAICodexOAuthTokens( - access_token=access, - refresh_token=refresh_token.strip(), - expires_at=int(time.time() + float(expires_in)), - account_id=account_id or extract_openai_codex_account_id(access), - ) - - -def _exchange_openai_codex_authorization_code( - code: str, - *, - verifier: str, - redirect_uri: str, - timeout_seconds: float, - client_id: str, - token_url: str, -) -> OpenAICodexOAuthTokens: - with OAuth2Client( - client_id=client_id, - redirect_uri=redirect_uri, - code_challenge_method="S256", - timeout=timeout_seconds, - ) as oauth: - payload = oauth.fetch_token( - url=token_url, - grant_type="authorization_code", - code=code, - code_verifier=verifier, - ) - return _tokens_from_token_payload( - payload, - account_id=extract_openai_codex_account_id(str(payload.get("access_token", ""))), - ) - - -def _build_pkce_verifier() -> str: - return urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii").rstrip("=") - - -def _build_authorize_url( - *, - client_id: str, - redirect_uri: str, - code_challenge: str, - state: str, - authorize_url: str, - scope: str, - originator: str, -) -> str: - with OAuth2Client( - client_id=client_id, - redirect_uri=redirect_uri, - scope=scope, - code_challenge_method="S256", - trust_env=True, - ) as oauth: - url, _ = oauth.create_authorization_url( - authorize_url, - state=state, - code_verifier=code_challenge, - id_token_add_organizations="true", # noqa: S106 - codex_cli_simplified_flow="true", - originator=originator, - ) - return str(url) - - -def _extract_code_and_state(input_value: str) -> tuple[str | None, str | None]: - raw = input_value.strip() - if not raw: - return None, None - - parsed = urllib.parse.urlsplit(raw) - query = urllib.parse.parse_qs(parsed.query) - code = query.get("code", [None])[0] - state = query.get("state", [None])[0] - if isinstance(code, str) or isinstance(state, str): - return code if isinstance(code, str) else None, state if isinstance(state, str) else None - - if "code=" in raw: - parsed_query = urllib.parse.parse_qs(raw) - code = parsed_query.get("code", [None])[0] - state = parsed_query.get("state", [None])[0] - return code if isinstance(code, str) else None, state if isinstance(state, str) else None - - return raw, None - - -def _wait_for_local_oauth_callback( - *, redirect_uri: str, timeout_seconds: float -) -> tuple[str | None, str | None] | None: - parsed_redirect = urllib.parse.urlsplit(redirect_uri) - if parsed_redirect.scheme != "http" or (parsed_redirect.hostname or "").lower() not in {"127.0.0.1", "localhost"}: - return None - if parsed_redirect.port is None: - return None - - path = parsed_redirect.path or "/" - state: dict[str, str | None] = {"code": None, "state": None} - done = threading.Event() - lock = threading.Lock() - - class Handler(BaseHTTPRequestHandler): - def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - return - - def do_GET(self) -> None: - parsed = urllib.parse.urlsplit(self.path) - if parsed.path != path: - self.send_response(404) - self.end_headers() - return - - query = urllib.parse.parse_qs(parsed.query) - with lock: - code = query.get("code", [None])[0] - returned_state = query.get("state", [None])[0] - state["code"] = code if isinstance(code, str) else None - state["state"] = returned_state if isinstance(returned_state, str) else None - done.set() - - body = ( - b"

Authentication successful. Return to your terminal.

" - ) - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - try: - server = ThreadingHTTPServer((parsed_redirect.hostname or "localhost", parsed_redirect.port), Handler) - except OSError: - return None - - server.timeout = 0.2 - deadline = time.monotonic() + timeout_seconds - try: - while not done.is_set() and time.monotonic() < deadline: - server.handle_request() - finally: - server.server_close() - - if not done.is_set(): - return None - with lock: - return state["code"], state["state"] - - -def _unix_to_rfc3339(timestamp: int) -> str: - return datetime.fromtimestamp(timestamp, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def _rfc3339_to_unix(value: str) -> int | None: - try: - parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - return int(parsed.timestamp()) if parsed.tzinfo is not None else None - except (ValueError, OverflowError, OSError): - return None + typer.echo(f"account_id: {auth.account_id}") + typer.echo("Use BUB_MODEL=codex: for ChatGPT plan access.") diff --git a/src/bub/builtin/codex_provider.py b/src/bub/builtin/codex_provider.py deleted file mode 100644 index e6569d03..00000000 --- a/src/bub/builtin/codex_provider.py +++ /dev/null @@ -1,521 +0,0 @@ -"""OpenAI Codex OAuth provider for any-llm Responses calls.""" - -from __future__ import annotations - -import time -from collections.abc import AsyncIterator, Mapping, Sequence -from dataclasses import dataclass -from typing import Any, cast, override - -from any_llm.exceptions import MissingApiKeyError -from any_llm.providers.openai.base import BaseOpenAIProvider -from any_llm.types.completion import ChatCompletion, ChatCompletionChunk, CompletionParams -from any_llm.types.model import Model -from any_llm.types.responses import Response, ResponsesParams - -from bub.builtin.auth import ( - extract_openai_codex_account_id, - load_openai_codex_oauth_tokens, - openai_codex_oauth_resolver, -) - -DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api" -DEFAULT_CODEX_ORIGINATOR = "bub" -DEFAULT_CODEX_INCLUDE = ["reasoning.encrypted_content"] -DEFAULT_CODEX_INSTRUCTIONS = "You are Codex." -DEFAULT_CODEX_TEXT_CONFIG = {"verbosity": "medium"} - - -class OpenAICodexTransportError(RuntimeError): - def __init__(self, status_code: int | None, message: str, body: str | None = None) -> None: - super().__init__(message) - self.status_code = status_code - self.body = body - - -class OpenaiCodexProvider(BaseOpenAIProvider): - """OpenAI-compatible Responses provider backed by Codex OAuth credentials.""" - - API_BASE = DEFAULT_CODEX_BASE_URL - ENV_API_KEY_NAME = "OPENAI_CODEX_API_KEY" - ENV_API_BASE_NAME = "OPENAI_CODEX_API_BASE" - PROVIDER_NAME = "openaicodex" - PROVIDER_DOCUMENTATION_URL = "https://platform.openai.com/docs/codex" - - SUPPORTS_COMPLETION_STREAMING = True - SUPPORTS_COMPLETION = True - SUPPORTS_COMPLETION_REASONING = True - SUPPORTS_RESPONSES = True - SUPPORTS_LIST_MODELS = False - SUPPORTS_BATCH = False - SUPPORTS_IMAGE_GENERATION = False - SUPPORTS_AUDIO_TRANSCRIPTION = False - SUPPORTS_AUDIO_SPEECH = False - SUPPORTS_EMBEDDING = False - SUPPORTS_MODERATION = False - - def __init__( - self, - *, - api_key: str | None = None, - api_base: str | None = None, - codex_home: str | None = None, - default_instructions: str = DEFAULT_CODEX_INSTRUCTIONS, - default_include: Sequence[str] = tuple(DEFAULT_CODEX_INCLUDE), - default_text: dict[str, Any] | None = None, - originator: str = DEFAULT_CODEX_ORIGINATOR, - store: bool = False, - **kwargs: Any, - ) -> None: - self._codex_home = codex_home - self._default_instructions = default_instructions - self._default_include = list(default_include) - self._default_text = dict(default_text or DEFAULT_CODEX_TEXT_CONFIG) - self._originator = originator - self._store = store - super().__init__(api_key=api_key, api_base=api_base, **kwargs) - - @override - def _verify_and_set_api_key(self, api_key: str | None = None) -> str | None: - if api_key: - return api_key - resolved = openai_codex_oauth_resolver(self._codex_home)("openai") - if not resolved: - raise MissingApiKeyError(self.PROVIDER_NAME, self.ENV_API_KEY_NAME) - return resolved - - @override - def _init_client(self, api_key: str | None = None, api_base: str | None = None, **kwargs: Any) -> None: - default_headers = dict(kwargs.pop("default_headers", {})) - default_headers.update(build_openai_codex_default_headers(api_key or "", originator=self._originator)) - super()._init_client( - api_key=api_key, - api_base=resolve_openai_codex_api_base(api_base), - default_headers=default_headers, - **kwargs, - ) - - @staticmethod - @override - def _convert_list_models_response(response: Any) -> Sequence[Model]: - raise NotImplementedError("OpenAI Codex OAuth provider does not support listing models.") - - @override - async def _acompletion( - self, - params: CompletionParams, - **kwargs: Any, - ) -> ChatCompletion | AsyncIterator[ChatCompletionChunk]: - responses_params = self._completion_params_to_responses_params(params) - if params.stream: - response = await self._aresponses(responses_params.model_copy(update={"stream": True})) - if not hasattr(response, "__aiter__"): - raise OpenAICodexTransportError(None, "OpenAI Codex Responses API returned a non-streaming result.") - return self._response_stream_to_completion_chunks( - cast("AsyncIterator[Any]", response), - model=params.model_id, - ) - - response = await self._aresponses(responses_params.model_copy(update={"stream": False})) - return self._response_to_completion(cast("Response", response), model=params.model_id) - - @override - async def _aresponses(self, params: ResponsesParams, **kwargs: Any) -> Any: - return await super()._aresponses(self._with_codex_response_defaults(params), **kwargs) - - def _completion_params_to_responses_params(self, params: CompletionParams) -> ResponsesParams: - reasoning = None - if params.reasoning_effort not in {None, "auto"}: - reasoning = {"effort": params.reasoning_effort} - - return ResponsesParams( - model=params.model_id, - input=cast("Any", _completion_messages_to_responses_input(params.messages)), - tools=self._completion_tools_to_response_tools(cast("Sequence[Any] | None", params.tools)), - tool_choice=self._completion_tool_choice_to_response_tool_choice(params.tool_choice), - response_format=params.response_format, - stream=params.stream, - parallel_tool_calls=params.parallel_tool_calls, - reasoning=reasoning, - ) - - @staticmethod - def _completion_tools_to_response_tools(tools: Sequence[Any] | None) -> list[dict[str, Any]] | None: - if not tools: - return None - response_tools: list[dict[str, Any]] = [] - for tool in tools: - payload = tool.model_dump(exclude_none=True) if hasattr(tool, "model_dump") else dict(tool) - function = payload.get("function") - if payload.get("type") == "function" and isinstance(function, dict): - response_tools.append({ - "type": "function", - "name": function.get("name", ""), - "description": function.get("description", ""), - "parameters": function.get("parameters", {}), - }) - else: - response_tools.append(payload) - return response_tools - - @staticmethod - def _completion_tool_choice_to_response_tool_choice(tool_choice: str | dict[str, Any] | None) -> Any: - if not isinstance(tool_choice, dict): - return tool_choice - function = tool_choice.get("function") - if tool_choice.get("type") == "function" and isinstance(function, dict): - return {"type": "function", "name": function.get("name", "")} - return tool_choice - - async def _response_stream_to_completion_chunks( - self, - events: AsyncIterator[Any], - *, - model: str, - ) -> AsyncIterator[ChatCompletionChunk]: - mapper = CodexCompletionChunkMapper(model=model) - async for event in events: - for chunk in mapper.map_event(event): - yield chunk - - @staticmethod - def _response_to_completion(response: Response, *, model: str) -> ChatCompletion: - text_parts: list[str] = [] - tool_calls: list[dict[str, Any]] = [] - for item in getattr(response, "output", []) or []: - if getattr(item, "type", None) == "message": - for content in getattr(item, "content", []) or []: - text = getattr(content, "text", None) - if isinstance(text, str): - text_parts.append(text) - if getattr(item, "type", None) in {"function_call", "custom_tool_call"}: - tool_calls.append({ - "id": getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{len(tool_calls)}", - "type": "function", - "function": { - "name": getattr(item, "name", "") or "", - "arguments": _tool_item_arguments(item) or "{}", - }, - }) - - usage = _completion_usage_from_response_usage(getattr(response, "usage", None)) - return ChatCompletion.model_validate({ - "id": getattr(response, "id", None) or "chatcmpl_codex", - "object": "chat.completion", - "created": int(getattr(response, "created_at", None) or time.time()), - "model": getattr(response, "model", None) or model, - "choices": [ - { - "index": 0, - "finish_reason": "tool_calls" if tool_calls else "stop", - "message": { - "role": "assistant", - "content": "".join(text_parts) or None, - "tool_calls": tool_calls or None, - }, - } - ], - "usage": usage, - }) - - def _with_codex_response_defaults(self, params: ResponsesParams) -> ResponsesParams: - update: dict[str, Any] = {} - if params.store is None: - update["store"] = self._store - if params.instructions is None: - update["instructions"] = self._default_instructions - if params.include is None: - update["include"] = list(self._default_include) - if isinstance(params.text, dict): - update["text"] = {**self._default_text, **params.text} - elif params.text is None: - update["text"] = dict(self._default_text) - return params.model_copy(update=update) - - -@dataclass -class _CodexToolState: - started: bool = False - arguments_seen: bool = False - - -class CodexCompletionChunkMapper: - def __init__(self, *, model: str) -> None: - self.model = model - self.created = int(time.time()) - self._tool_states: dict[int, _CodexToolState] = {} - self._tool_indexes_by_identifier: dict[str, int] = {} - - def map_event(self, event: Any) -> list[ChatCompletionChunk]: - event_type = getattr(event, "type", None) - if event_type == "response.output_text.delta": - return [self._delta_chunk({"content": _string_attr(event, "delta")})] - if event_type in {"response.reasoning_text.delta", "response.reasoning_summary_text.delta"}: - return [self._delta_chunk({"reasoning": _string_attr(event, "delta")})] - if event_type == "response.output_item.added": - return self._tool_item_chunks(event) - if event_type in {"response.function_call_arguments.delta", "response.custom_tool_call_input.delta"}: - return self._tool_arguments_delta_chunks(event) - if event_type == "response.function_call_arguments.done": - return self._tool_arguments_done_chunks(event) - if event_type == "response.output_item.done": - return self._tool_item_chunks(event, done=True) - if event_type == "response.completed": - response = getattr(event, "response", None) - return [self._terminal_chunk(response=response)] - if event_type in {"response.failed", "response.incomplete"}: - raise OpenAICodexTransportError(None, f"OpenAI Codex response ended with {event_type}") - if event_type == "error": - message = getattr(event, "message", None) or "OpenAI Codex response stream error" - raise OpenAICodexTransportError(None, str(message)) - return [] - - def _tool_item_chunks(self, event: Any, *, done: bool = False) -> list[ChatCompletionChunk]: - output_index = getattr(event, "output_index", None) - item = getattr(event, "item", None) - if getattr(item, "type", None) not in {"function_call", "custom_tool_call"} or not isinstance( - output_index, int - ): - return [] - - self._remember_tool_identifiers(item, output_index) - state = self._tool_states.setdefault(output_index, _CodexToolState()) - if done and state.started and state.arguments_seen: - return [] - - tool_delta: dict[str, Any] = { - "index": output_index, - "id": getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{output_index}", - "type": "function", - "function": { - "name": getattr(item, "name", None) or "", - "arguments": "" if state.arguments_seen else _tool_item_arguments(item), - }, - } - state.started = True - if tool_delta["function"]["arguments"]: - state.arguments_seen = True - return [self._delta_chunk({"tool_calls": [tool_delta]})] - - def _tool_arguments_delta_chunks(self, event: Any) -> list[ChatCompletionChunk]: - output_index = self._tool_index_for_event(event) - if output_index is None: - return [] - delta = _string_attr(event, "delta") - if not delta: - return [] - state = self._tool_states.setdefault(output_index, _CodexToolState()) - state.arguments_seen = True - return [self._delta_chunk({"tool_calls": [{"index": output_index, "function": {"arguments": delta}}]})] - - def _tool_arguments_done_chunks(self, event: Any) -> list[ChatCompletionChunk]: - output_index = self._tool_index_for_event(event) - if output_index is None: - return [] - state = self._tool_states.setdefault(output_index, _CodexToolState()) - if state.arguments_seen: - return [] - arguments = _string_attr(event, "arguments") - if not arguments: - return [] - state.arguments_seen = True - name = _string_attr(event, "name") - function: dict[str, str] = {"arguments": arguments} - if name: - function["name"] = name - return [self._delta_chunk({"tool_calls": [{"index": output_index, "function": function}]})] - - def _tool_index_for_event(self, event: Any) -> int | None: - output_index = getattr(event, "output_index", None) - if isinstance(output_index, int): - return output_index - for attr in ("item_id", "call_id"): - identifier = getattr(event, attr, None) - if isinstance(identifier, str): - known_index = self._tool_indexes_by_identifier.get(identifier) - if known_index is not None: - return known_index - return None - - def _remember_tool_identifiers(self, item: Any, output_index: int) -> None: - for attr in ("id", "call_id"): - identifier = getattr(item, attr, None) - if isinstance(identifier, str) and identifier: - self._tool_indexes_by_identifier[identifier] = output_index - - def _delta_chunk(self, delta: dict[str, Any]) -> ChatCompletionChunk: - return ChatCompletionChunk.model_validate({ - "id": "chatcmpl_codex", - "object": "chat.completion.chunk", - "created": self.created, - "model": self.model, - "choices": [{"index": 0, "delta": delta, "finish_reason": None}], - }) - - def _terminal_chunk(self, *, response: Any) -> ChatCompletionChunk: - usage = _completion_usage_from_response_usage(getattr(response, "usage", None)) - finish_reason = "tool_calls" if self._tool_states else "stop" - return ChatCompletionChunk.model_validate({ - "id": getattr(response, "id", None) or "chatcmpl_codex", - "object": "chat.completion.chunk", - "created": int(getattr(response, "created_at", None) or self.created), - "model": getattr(response, "model", None) or self.model, - "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}], - "usage": usage, - }) - - -def _completion_usage_from_response_usage(usage: Any) -> dict[str, Any]: - payload = usage.model_dump(exclude_none=True) if hasattr(usage, "model_dump") else usage - if not isinstance(payload, dict): - payload = { - "input_tokens": getattr(usage, "input_tokens", 0), - "output_tokens": getattr(usage, "output_tokens", 0), - "total_tokens": getattr(usage, "total_tokens", 0), - "input_tokens_details": getattr(usage, "input_tokens_details", None), - } - prompt_tokens = int(payload.get("input_tokens") or payload.get("prompt_tokens") or 0) - completion_tokens = int(payload.get("output_tokens") or payload.get("completion_tokens") or 0) - total_tokens = int(payload.get("total_tokens") or prompt_tokens + completion_tokens) - completion_usage: dict[str, Any] = { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": total_tokens, - } - input_details = _mapping_from_value(payload.get("input_tokens_details")) - cached_tokens = input_details.get("cached_tokens") - if isinstance(cached_tokens, int) and not isinstance(cached_tokens, bool): - completion_usage["prompt_tokens_details"] = {"cached_tokens": cached_tokens} - return completion_usage - - -def _string_attr(obj: Any, name: str) -> str: - value = getattr(obj, name, None) - return value if isinstance(value, str) else "" - - -def _tool_item_arguments(item: Any) -> str: - return _string_attr(item, "arguments") or _string_attr(item, "input") - - -def _completion_messages_to_responses_input(messages: Sequence[Any]) -> list[dict[str, Any]]: - response_input: list[dict[str, Any]] = [] - for message in messages: - payload = _mapping_from_value(message) - if not payload: - continue - - role = payload.get("role") - if role == "tool": - tool_result = _completion_tool_result_to_response_item(payload) - if tool_result is not None: - response_input.append(tool_result) - continue - - content = _completion_content_to_responses_content(payload.get("content"), role=role) - tool_calls = payload.get("tool_calls") - if isinstance(tool_calls, Sequence) and not isinstance(tool_calls, str): - if content: - response_input.append({"role": "assistant", "content": content}) - response_input.extend(_completion_tool_calls_to_response_items(tool_calls)) - continue - - if isinstance(role, str): - response_input.append({"role": role, "content": content or ""}) - return response_input - - -def _completion_content_to_responses_content(content: Any, *, role: str | None) -> Any: - """Translate Chat Completions parts without changing strings or Responses parts.""" - if not isinstance(content, list): - return content - parts: list[Any] = [] - for value in content: - part = _mapping_from_value(value) - match part.get("type"): - case "text": - parts.append({**part, "type": "output_text" if role == "assistant" else "input_text"}) - case "image_url": - image = _mapping_from_value(part.get("image_url")) - parts.append({ - "type": "input_image", - "image_url": image.get("url"), - "detail": image.get("detail", "auto"), - }) - case "file": - parts.append({"type": "input_file", **_mapping_from_value(part.get("file"))}) - case _: - parts.append(value) - return parts - - -def _completion_tool_calls_to_response_items(tool_calls: Sequence[Any]) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - for tool_call in tool_calls: - payload = _mapping_from_value(tool_call) - function = _mapping_from_value(payload.get("function")) - call_id = payload.get("id") - name = function.get("name") - if not isinstance(call_id, str) or not isinstance(name, str): - continue - arguments = function.get("arguments") - items.append({ - "type": "function_call", - "call_id": call_id, - "name": name, - "arguments": arguments if isinstance(arguments, str) else "{}", - "status": "completed", - }) - return items - - -def _completion_tool_result_to_response_item(message: Mapping[str, Any]) -> dict[str, Any] | None: - call_id = message.get("tool_call_id") - if not isinstance(call_id, str) or not call_id: - return None - content = message.get("content") - return { - "type": "function_call_output", - "call_id": call_id, - "output": content if isinstance(content, str) else "", - } - - -def _mapping_from_value(value: Any) -> Mapping[str, Any]: - if isinstance(value, Mapping): - return value - if hasattr(value, "model_dump"): - dumped = value.model_dump(exclude_none=True) - if isinstance(dumped, Mapping): - return dumped - return {} - - -def should_use_openai_codex_provider( - provider: str, model_id: str, *, api_key: str | None, api_base: str | None -) -> bool: - if provider != "openai" or api_base: - return False - if api_key: - return extract_openai_codex_account_id(api_key) is not None - return load_openai_codex_oauth_tokens() is not None - - -def resolve_openai_codex_api_base(api_base: str | None) -> str: - raw = (api_base or DEFAULT_CODEX_BASE_URL).rstrip("/") - if raw.endswith("/responses"): - raw = raw[: -len("/responses")] - if raw.endswith("/codex"): - return raw - return f"{raw}/codex" - - -def build_openai_codex_default_headers(api_key: str, *, originator: str = DEFAULT_CODEX_ORIGINATOR) -> dict[str, str]: - account_id = extract_openai_codex_account_id(api_key) - if account_id is None: - raise OpenAICodexTransportError(None, "OpenAI Codex OAuth token is missing chatgpt_account_id") - return { - "chatgpt-account-id": account_id, - "OpenAI-Beta": "responses=experimental", - "originator": originator, - } diff --git a/src/bub/builtin/context.py b/src/bub/builtin/context.py index 1476bc06..54fc7ffb 100644 --- a/src/bub/builtin/context.py +++ b/src/bub/builtin/context.py @@ -6,7 +6,9 @@ from collections.abc import Iterable from typing import Any -from bub.tape import TapeContext, TapeEntry +import republic + +from bub.tape import TapeContext, TapeEntry, to_message from bub.tools import render_result @@ -16,82 +18,53 @@ def default_tape_context() -> TapeContext: return TapeContext(select=_select_messages) -def _select_messages(entries: Iterable[TapeEntry], _context: TapeContext) -> list[dict[str, Any]]: - messages: list[dict[str, Any]] = [] - pending_calls: list[dict[str, Any]] = [] +def _select_messages(entries: Iterable[TapeEntry], _context: TapeContext) -> list[republic.Message]: + messages: list[republic.Message] = [] + calls: dict[str, republic.ToolCall] = {} + pending_calls: list[republic.ToolCall] = [] for entry in entries: match entry.kind: case "anchor": - _append_anchor_entry(messages, entry) + messages.append(_anchor_message(entry)) case "message": - _append_message_entry(messages, entry) + if isinstance(entry.payload, dict): + messages.append(to_message(entry.payload, calls)) case "tool_call": - pending_calls = _append_tool_call_entry(messages, entry) + pending_calls = _append_tool_call_entry(messages, calls, entry) case "tool_result": _append_tool_result_entry(messages, pending_calls, entry) pending_calls = [] return messages -def _append_anchor_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> None: +def _anchor_message(entry: TapeEntry) -> republic.Message: payload = entry.payload content = f"[Anchor created: {payload.get('name')}]: {json.dumps(payload.get('state'), ensure_ascii=False)}" - messages.append({"role": "assistant", "content": content}) - - -def _append_message_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> None: - payload = entry.payload - if isinstance(payload, dict): - messages.append(dict(payload)) + return republic.assistant(content) -def _append_tool_call_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> list[dict[str, Any]]: - calls = _normalize_tool_calls(entry.payload.get("calls")) - if calls: - messages.append({"role": "assistant", "content": entry.payload.get("content") or "", "tool_calls": calls}) - return calls +def _append_tool_call_entry( + messages: list[republic.Message], calls: dict[str, republic.ToolCall], entry: TapeEntry +) -> list[republic.ToolCall]: + raw_calls = entry.payload.get("calls") + if not isinstance(raw_calls, list) or not (tool_calls := [item for item in raw_calls if isinstance(item, dict)]): + return [] + fields = {key: entry.payload[key] for key in ("reasoning", "provider_data") if key in entry.payload} + message = to_message( + {"role": "assistant", "content": entry.payload.get("content") or "", "tool_calls": tool_calls, **fields}, + calls, + ) + messages.append(message) + return list(message.tool_calls) def _append_tool_result_entry( - messages: list[dict[str, Any]], - pending_calls: list[dict[str, Any]], - entry: TapeEntry, + messages: list[republic.Message], pending_calls: list[republic.ToolCall], entry: TapeEntry ) -> None: - results = entry.payload.get("results") + results: Any = entry.payload.get("results") if not isinstance(results, list): return - for index, result in enumerate(results): - messages.append(_build_tool_result_message(result, pending_calls, index)) - - -def _build_tool_result_message( - result: object, - pending_calls: list[dict[str, Any]], - index: int, -) -> dict[str, Any]: - message: dict[str, Any] = {"role": "tool", "content": render_result(result)} - if index >= len(pending_calls): - return message - - call = pending_calls[index] - call_id = call.get("id") - if isinstance(call_id, str) and call_id: - message["tool_call_id"] = call_id - - function = call.get("function") - if isinstance(function, dict): - name = function.get("name") - if isinstance(name, str) and name: - message["name"] = name - return message - - -def _normalize_tool_calls(value: object) -> list[dict[str, Any]]: - if not isinstance(value, list): - return [] - calls: list[dict[str, Any]] = [] - for item in value: - if isinstance(item, dict): - calls.append(dict(item)) - return calls + # Results without a recorded call cannot be expressed as tool messages. + for call, result in zip(pending_calls, results, strict=False): + messages.append(republic.tool(call, render_result(result))) diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 89c2b1f7..70e2746f 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -3,8 +3,9 @@ from datetime import datetime from difflib import get_close_matches from pathlib import Path -from typing import Any, cast +from typing import cast +import republic import typer from loguru import logger @@ -18,7 +19,7 @@ from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot from bub.channels.base import Channel from bub.channels.contracts import MessageHandler -from bub.channels.message import ChannelMessage, MediaItem, audio_format_from_mime_type +from bub.channels.message import ChannelMessage, MediaItem from bub.envelope import Envelope, content_of, field_of from bub.environment import Environment from bub.errors import BubError @@ -26,6 +27,7 @@ from bub.hooks import hookimpl from bub.hooks.interception import ToolCall, ToolCallDecision, ToolCallResult from bub.model_selection import ModelChoice, ModelOptions +from bub.prompt import UserContent from bub.sidecars import TapeSidecar from bub.store import TapeStore from bub.streaming import AsyncStreamEvents @@ -57,16 +59,6 @@ """ -def _input_audio_part(data_url: str, mime_type: str) -> dict[str, Any] | None: - prefix, separator, data = data_url.partition("base64,") - if not separator or not prefix.startswith("data:audio/") or not data: - return None - return { - "type": "input_audio", - "input_audio": {"data": data, "format": audio_format_from_mime_type(mime_type)}, - } - - class BuiltinImpl: """Default hook implementations for basic runtime operations.""" @@ -144,38 +136,28 @@ async def save_state(self, session_id: str, state: TurnState, message: ChannelMe # events, so nothing to write here — this hook only closes the lifespan. @hookimpl - async def build_prompt(self, message: ChannelMessage, session_id: str, state: TurnState) -> str | list[dict]: + async def build_prompt(self, message: ChannelMessage, session_id: str, state: TurnState) -> list[UserContent]: content = content_of(message) if strip_command_prefix(content, self._get_agent(state).command_prefix) is not None: message.kind = "command" - return content.strip() + return [content.strip()] context = field_of(message, "context_str") now = datetime.now().astimezone().isoformat(timespec="seconds") context_prefix = f"{context}\n---Date: {now}---\n" if context else "" text = f"{context_prefix}{content}" - media = field_of(message, "media") or [] - if not media: - return text - - media_parts: list[dict] = [] - for item in cast("list[MediaItem]", media): + prompt: list[UserContent] = [text] + for item in cast("list[MediaItem]", field_of(message, "media") or []): + if item.type not in {"image", "audio", "video"} or not (url := await item.get_url()): + continue match item.type: - case "image" | "video": - data_url = await item.get_url() - if not data_url: - continue - part_type = f"{item.type}_url" - media_parts.append({"type": part_type, part_type: {"url": data_url}}) + case "image": + prompt.append(republic.image(url, media_type=item.mime_type)) case "audio": - data_url = await item.get_url() - if data_url and (audio_part := _input_audio_part(data_url, item.mime_type)): - media_parts.append(audio_part) - case _: - pass - if media_parts: - return [{"type": "text", "text": text}, *media_parts] - return text + prompt.append(republic.audio(url, media_type=item.mime_type)) + case "video": + prompt.append(republic.video(url, media_type=item.mime_type)) + return prompt @hookimpl async def run_model_stream(self, prompt: str | list[dict], session_id: str, state: TurnState) -> AsyncStreamEvents: diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py index aae88010..8b4f248f 100644 --- a/src/bub/builtin/model_runner.py +++ b/src/bub/builtin/model_runner.py @@ -1,37 +1,22 @@ -"""LLM completion and model-output helpers for the builtin agent.""" +"""Republic requests and model-output helpers for the builtin agent.""" from __future__ import annotations import asyncio -import inspect import re -from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Iterator -from contextlib import aclosing -from dataclasses import dataclass +from collections.abc import AsyncGenerator, AsyncIterator +from contextlib import AsyncExitStack, aclosing, asynccontextmanager +from dataclasses import asdict from datetime import UTC, datetime +from json import JSONDecodeError from time import monotonic -from typing import Any, Literal, cast +from typing import Any -from any_llm import AnyLLM -from any_llm.constants import LLMProvider -from any_llm.providers.anthropic.base import BaseAnthropicProvider -from any_llm.providers.openai.base import BaseOpenAIProvider -from any_llm.types.completion import ( - ChatCompletion, - ChatCompletionChunk, - ChatCompletionMessage, - ChatCompletionMessageFunctionToolCall, - ChatCompletionMessageToolCall, - ChoiceDeltaToolCall, - Function, - ParsedChatCompletion, -) +import republic from loguru import logger -from pydantic import TypeAdapter, ValidationError +from republic.events import Completed, ReasoningDelta, RefusalDelta, TextDelta, ToolCallReady -from bub.builtin.codex_provider import OpenaiCodexProvider, should_use_openai_codex_provider from bub.builtin.settings import AgentSettings, ModelCandidate -from bub.channels.message import audio_mime_type_from_format from bub.errors import BubError, ErrorKind from bub.hooks.interception import ( AgentHooks, @@ -39,6 +24,7 @@ LlmCallRequest, LlmCallResult, ) +from bub.prompt import UserContent from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.tape import Tape from bub.tools import Tool, ToolContext, ToolExecutor @@ -48,60 +34,6 @@ r"context.{0,20}(?:length|window)|maximum.{0,20}context|token.{0,10}limit|prompt.{0,10}too long|tokens? > \d+ maximum", re.IGNORECASE, ) -TOOL_ARGUMENTS_ADAPTER = TypeAdapter(dict[str, Any]) -CompletionResult = ChatCompletion | ParsedChatCompletion[Any] | AsyncIterator[ChatCompletionChunk] -GOOGLE_FILE_CONTENT_PROVIDERS = frozenset({LLMProvider.GEMINI, LLMProvider.VERTEXAI}) - - -def _extra_options(llm: AnyLLM, *, stream: bool) -> dict[str, Any]: - """Return provider-specific extra completion options.""" - if isinstance(llm, BaseAnthropicProvider): - return {"cache_control": {"type": "ephemeral"}} - elif stream and isinstance(llm, BaseOpenAIProvider): - return {"stream_options": {"include_usage": True}} - return {} - - -def _adapt_messages_for_provider(messages: list[dict[str, Any]], provider: LLMProvider) -> list[dict[str, Any]]: - """Translate canonical multimodal blocks when a provider uses a different wire format.""" - if provider not in GOOGLE_FILE_CONTENT_PROVIDERS: - return messages - - adapted_messages: list[dict[str, Any]] = [] - for message in messages: - content = message.get("content") - if not isinstance(content, list): - adapted_messages.append(message) - continue - - adapted_content: list[Any] = [] - changed = False - for part in content: - if not isinstance(part, dict): - adapted_content.append(part) - continue - - if part.get("type") == "video_url": - video_url = part.get("video_url") - url = video_url.get("url") if isinstance(video_url, dict) else None - if isinstance(url, str) and url: - adapted_content.append({"type": "file", "file": {"file_data": url}}) - changed = True - continue - elif part.get("type") == "input_audio": - input_audio = part.get("input_audio") - data = input_audio.get("data") if isinstance(input_audio, dict) else None - audio_format = input_audio.get("format") if isinstance(input_audio, dict) else None - if isinstance(data, str) and data and isinstance(audio_format, str) and audio_format: - mime_type = audio_mime_type_from_format(audio_format) - file_data = f"data:{mime_type};base64,{data}" - adapted_content.append({"type": "file", "file": {"file_data": file_data}}) - changed = True - continue - adapted_content.append(part) - - adapted_messages.append({**message, "content": adapted_content} if changed else message) - return adapted_messages class ModelRunner: @@ -109,69 +41,61 @@ def __init__(self, settings: AgentSettings, hooks: AgentHooks | None = None) -> self.settings = settings self.hooks = hooks - def iter_llm_clients(self, model: str) -> Iterator[tuple[ModelCandidate, AnyLLM]]: - for candidate in self.settings.model_candidates(model): - client_kwargs = self.settings.model_client_kwargs(candidate.provider_name or candidate.provider) - yield ( - candidate, - self.create_llm_client(candidate, client_kwargs), - ) - - @staticmethod - def create_llm_client(candidate: ModelCandidate, client_kwargs: dict[str, Any]) -> AnyLLM: - if candidate.provider == LLMProvider.OPENAI and should_use_openai_codex_provider( - candidate.provider.value, - candidate.model_id, - api_key=client_kwargs.get("api_key"), - api_base=client_kwargs.get("api_base"), - ): - return OpenaiCodexProvider(**client_kwargs) - return AnyLLM.create(candidate.provider, **client_kwargs) - - async def completion_response( + @asynccontextmanager + async def _completion( self, *, model: str, - messages: list[dict[str, Any]], + messages: list[republic.Message], tools: list[Tool], max_tokens: int | None = None, reasoning_effort: str | None = None, - ) -> CompletionResult: - tool_payloads = [tool.to_schema() for tool in tools] or None - clients = list(self.iter_llm_clients(model)) + ) -> AsyncIterator[tuple[ModelCandidate, republic.Stream[Any]]]: completion_error: Exception | None = None - for index, (candidate, llm) in enumerate(clients): - try: - if span := current_span(): - span.rename(f"chat {candidate.model_id}") - span.set(**{ - "gen_ai.provider.name": candidate.provider.value, - "gen_ai.request.model": candidate.model_id, - }) - streaming = llm.SUPPORTS_COMPLETION_STREAMING - completion_messages = _adapt_messages_for_provider(messages, candidate.provider) - completion_kwargs = { - **self.settings.completion_args, - **_extra_options(llm, stream=streaming), - "model": candidate.model_id, - "messages": completion_messages, - "tools": tool_payloads, - "max_tokens": max_tokens if max_tokens is not None else self.settings.max_tokens, - "stream": streaming, - } - if reasoning_effort is not None: - completion_kwargs["reasoning_effort"] = reasoning_effort - return cast("CompletionResult", await llm.acompletion(**completion_kwargs)) - except Exception as exc: - event("bub.model.attempt_failed", model=candidate.name, error=repr(exc)) - if completion_error is None: - completion_error = exc - if index == len(clients) - 1: - raise completion_error from None - logger.warning("model candidate failed; trying fallback model={} error={}", candidate.name, exc) + candidates = self.settings.model_candidates(model) + for index, candidate in enumerate(candidates): + async with AsyncExitStack() as stack: + try: + client_kwargs = self.settings.model_client_kwargs(candidate.provider_name or candidate.provider) + chat_model = await stack.enter_async_context( + republic.get_model(f"{candidate.provider}:{candidate.model_id}", **client_kwargs) + ) + if span := current_span(): + span.rename(f"chat {candidate.model_id}") + span.set(**{ + "gen_ai.provider.name": candidate.provider, + "gen_ai.request.model": candidate.model_id, + }) + options = self._chat_options(candidate.provider, tools, max_tokens, reasoning_effort) + stream = await stack.enter_async_context(chat_model.stream(messages, **options)) + except Exception as exc: + event("bub.model.attempt_failed", model=candidate.name, error=type(exc).__name__) + if completion_error is None: + completion_error = exc + if index == len(candidates) - 1: + raise completion_error from None + logger.warning( + "model candidate failed; trying fallback model={} error={}", candidate.name, type(exc).__name__ + ) + else: + yield candidate, stream + return raise RuntimeError("no model candidates available") + def _chat_options( + self, provider: str, tools: list[Tool], max_tokens: int | None, reasoning_effort: str | None + ) -> dict[str, Any]: + options = dict(self.settings.completion_args) + if provider == "anthropic": + options["extra_body"] = {"cache_control": {"type": "ephemeral"}} | options.get("extra_body", {}) + options["tools"] = [republic.Tool(item.name, item.description, item.parameters) for item in tools] + if provider != "codex": + options["max_tokens"] = max_tokens if max_tokens is not None else self.settings.max_tokens + if reasoning_effort is not None: + options["reasoning_effort"] = reasoning_effort + return options + def run( self, *, @@ -179,8 +103,8 @@ def run( model: str, tools: list[Tool], system_prompt: str | None, - prompt: str | list[dict] | None, - steering_messages: list[list[dict[str, Any]] | str] | None = None, + prompt: list[UserContent] | None, + steering_messages: list[list[UserContent]] | None = None, ) -> AsyncStreamEvents: state = StreamState() @@ -194,7 +118,6 @@ async def iterator() -> AsyncGenerator[StreamEvent, None]: model=model, steering_messages=steering_messages, ) - output = ModelOutputAccumulator() request = LlmCallRequest( run_id=run_id, model=model, @@ -206,8 +129,7 @@ async def iterator() -> AsyncGenerator[StreamEvent, None]: if self.hooks is not None: request, decision = await self.hooks.before_llm_call(request, state=tape.context.state) if decision is not None: - await self.record_chat( - tape=tape, + await tape.record_chat( run_id=run_id, system_prompt=system_prompt, new_messages=new_messages, @@ -217,39 +139,22 @@ async def iterator() -> AsyncGenerator[StreamEvent, None]: yield StreamEvent("text", {"delta": decision.text}) yield StreamEvent("final", {"ok": True, "text": decision.text}) return - llm_started = datetime.now(UTC) - after_fired = False - - async def fire_after(error: Exception | None = None) -> None: - """Fire after_llm_call once per completed call (success or Exception failure); cancellation/consumer close bypasses it.""" - - nonlocal after_fired - if after_fired: - return - after_fired = True - await self._fire_after_llm_call(request, output, state, llm_started, tape, error=error) - - try: - completion_started = monotonic() - async with aclosing(self._traced_completion(request, tools, tape, state, output)) as events: - async for event in events: - yield event + completion_started = monotonic() + async with self._traced_completion(request, tools, tape, state) as (candidate, completion, events): + async for event in events: + yield event + response = completion.response completion_elapsed = monotonic() - completion_started - except Exception as exc: - # Cancellation / consumer close (BaseException) intentionally - # bypasses after_llm_call: only real completions and failures - # are terminal observations. - await fire_after(exc) - raise - await fire_after() + text = response.text or response.refusal or "" + provider = candidate.provider_name or candidate.provider yield StreamEvent("usage", {"usage": state.usage, "elapsed_seconds": completion_elapsed}) - tool_calls = output.tool_calls + tool_calls = response.tool_calls if tool_calls: tool_map = {tool_item.name: tool_item for tool_item in tools} - serialized_tool_calls = [tool_call.model_dump(exclude_none=True) for tool_call in tool_calls] - tool_invocations = [tool_invocation_from_native(tool_call, tool_map) for tool_call in tool_calls] + serialized_tool_calls = [asdict(tool_call) for tool_call in tool_calls] + tool_invocations = [_tool_invocation(tool_call, tool_map) for tool_call in tool_calls] yield StreamEvent("tool_call", {"tool_calls": serialized_tool_calls}) tape.context.state["_runtime_tool_names"] = tuple(tool_map) context = ToolContext(tape=tape, run_id=run_id, state=tape.context.state) @@ -258,47 +163,49 @@ async def fire_after(error: Exception | None = None) -> None: context=context, call_ids=[call.id for call in tool_calls], ) - await self.record_chat( - tape=tape, - run_id=run_id, - system_prompt=system_prompt, - new_messages=new_messages, - response_text=output.text or None, - tool_calls=serialized_tool_calls, - tool_results=execution.tool_results, - response=output.response, - model=request.model, - usage=state.usage, - ) - yield StreamEvent("tool_result", {"tool_results": execution.tool_results}) - yield StreamEvent( - "final", {"ok": True, "tool_calls": serialized_tool_calls, "tool_results": execution.tool_results} - ) - return - - text = output.text - await self.record_chat( - tape=tape, + tool_results = execution.tool_results + else: + serialized_tool_calls, tool_results = [], None + assistant_fields = { + key: value + for key, value in { + "reasoning": response.reasoning, + "provider_data": [ + asdict(part) for part in response.message.parts if isinstance(part, republic.ProviderData) + ], + }.items() + if value + } + await tape.record_chat( run_id=run_id, system_prompt=system_prompt, new_messages=new_messages, - response_text=text, - response=output.response, + response_text=(text or None) if tool_calls else text, + tool_calls=serialized_tool_calls, + tool_results=tool_results, model=request.model, usage=state.usage, + assistant_fields=assistant_fields, + provider=provider, ) - yield StreamEvent("final", {"ok": True, "text": text}) + if tool_calls: + yield StreamEvent("tool_result", {"tool_results": tool_results}) + yield StreamEvent( + "final", {"ok": True, "tool_calls": serialized_tool_calls, "tool_results": tool_results} + ) + else: + yield StreamEvent("final", {"ok": True, "text": text}) return AsyncStreamEvents(iterator(), state=state) - def _traced_completion( + @asynccontextmanager + async def _traced_completion( self, request: LlmCallRequest, tools: list[Tool], tape: Tape, state: StreamState, - output: ModelOutputAccumulator, - ) -> AsyncStreamEvents: + ) -> AsyncIterator[tuple[ModelCandidate, republic.Stream[Any], AsyncIterator[StreamEvent]]]: provider, _, model = request.model.partition(":") span = Span( f"chat {model or request.model}", @@ -314,50 +221,53 @@ def _traced_completion( ) span.messages("gen_ai.input.messages", request.messages) if span.recording: - span.set(**{ - "gen_ai.tool.definitions": [tool.to_schema()["function"] | {"type": "function"} for tool in tools] - }) + span.set(**{"gen_ai.tool.definitions": [tool.to_schema() | {"type": "function"} for tool in tools]}) + started = datetime.now(UTC) + # Partial output for hook errors and cancellation traces. + text: list[str] = [] + calls: list[republic.ToolCall] = [] - async def iterator() -> AsyncGenerator[StreamEvent, None]: - async with asyncio.timeout(self.settings.model_timeout_seconds): - completion = await self.completion_response( - model=request.model, - messages=list(request.messages), - tools=tools, - max_tokens=request.max_tokens, - reasoning_effort=tape.context.state.get("reasoning_effort"), - ) - try: - async with aclosing(self._completion_events(completion, state, output)) as events: - async for item in events: - yield item - finally: - close = getattr(completion, "aclose", None) or getattr(completion, "close", None) - if close is not None: - result = close() - if inspect.isawaitable(result): - await result - - async def finish() -> None: - if not span.recording: - return + try: + async with asyncio.timeout(self.settings.model_timeout_seconds), AsyncExitStack() as stack: + with span.activate(): + candidate, stream = await stack.enter_async_context( + self._completion( + model=request.model, + messages=list(request.messages), + tools=tools, + max_tokens=request.max_tokens, + reasoning_effort=tape.context.state.get("reasoning_effort"), + ) + ) + events = await stack.enter_async_context(aclosing(_stream_events(stream, span, state, text, calls))) + yield candidate, stream, events + except BaseException as exc: + span.fail(exc) + if isinstance(exc, Exception): + await self._fire_after_llm_call(request, "".join(text), calls, state, started, tape, error=exc) + raise + else: + completed = stream.response + await self._fire_after_llm_call( + request, completed.text or completed.refusal or "", completed.tool_calls, state, started, tape + ) + finally: usage = state.usage or {} span.set(**{ - "gen_ai.usage.input_tokens": usage.get("prompt_tokens", usage.get("input_tokens")), - "gen_ai.usage.output_tokens": usage.get("completion_tokens", usage.get("output_tokens")), + "gen_ai.usage.input_tokens": usage.get("input_tokens"), + "gen_ai.usage.output_tokens": usage.get("output_tokens"), }) span.messages( "gen_ai.output.messages", [ { "role": "assistant", - "content": output.text, - "tool_calls": [call.model_dump(exclude_none=True) for call in output.tool_calls], + "content": "".join(text), + "tool_calls": [asdict(call) for call in calls], } ], ) - - return AsyncStreamEvents(iterator(), state=state, span=span, on_close=finish) + span.end() @staticmethod def generate_run_id() -> str: @@ -366,7 +276,8 @@ def generate_run_id() -> str: async def _fire_after_llm_call( self, request: LlmCallRequest, - output: ModelOutputAccumulator, + text: str, + tool_calls: list[republic.ToolCall], state: StreamState, started: datetime, tape: Tape, @@ -377,8 +288,8 @@ async def _fire_after_llm_call( duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000) result = LlmCallResult( run_id=request.run_id, - text=output.text or None, - tool_calls=[call.model_dump(exclude_none=True) for call in output.tool_calls], + text=text or None, + tool_calls=[asdict(call) for call in tool_calls], usage=state.usage, error=error, duration_ms=duration_ms, @@ -391,207 +302,67 @@ async def build_messages( tape: Tape, run_id: str, system_prompt: str | None, - prompt: str | list[dict] | None, + prompt: list[UserContent] | None, model: str, - steering_messages: list[list[dict[str, Any]] | str] | None = None, - ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + steering_messages: list[list[UserContent]] | None = None, + ) -> tuple[list[republic.Message], list[republic.Message]]: try: messages = await tape.read_messages() except BubError as exc: - await self.record_context_error( - tape=tape, + await tape.record_chat( run_id=run_id, system_prompt=system_prompt, + context_error=exc, + new_messages=[], + response_text=None, error=exc, model=model, ) raise - steering_messages_native = [{"role": "user", "content": message} for message in (steering_messages or [])] + contents = [*(steering_messages or []), *([] if prompt is None else [prompt])] + new_messages = [republic.user(*content) for content in contents] if system_prompt: - messages = [{"role": "system", "content": system_prompt}, *messages] - new_messages = [*steering_messages_native] - if prompt is not None: - new_messages.append({"role": "user", "content": prompt}) + messages = [republic.system(system_prompt), *messages] messages.extend(new_messages) return messages, new_messages - async def record_context_error( - self, - *, - tape: Tape, - run_id: str, - system_prompt: str | None, - error: BubError, - model: str, - ) -> None: - await self.record_chat( - tape=tape, - run_id=run_id, - system_prompt=system_prompt, - context_error=error, - new_messages=[], - response_text=None, - error=error, - model=model, - ) - async def record_chat( - self, - *, - tape: Tape, - run_id: str, - system_prompt: str | None, - new_messages: list[dict[str, Any]], - response_text: str | None, - context_error: BubError | None = None, - tool_calls: list[dict[str, Any]] | None = None, - tool_results: list[Any] | None = None, - error: BubError | None = None, - response: Any | None = None, - provider: str | None = None, - model: str | None = None, - usage: dict[str, Any] | None = None, - ) -> None: - await tape.record_chat( - run_id=run_id, - system_prompt=system_prompt, - new_messages=new_messages, - response_text=response_text, - context_error=context_error, - tool_calls=tool_calls, - tool_results=tool_results, - error=error, - response=response, - provider=provider, - model=model, - usage=usage, - ) - - async def _completion_events( - self, - completion: CompletionResult, - state: StreamState, - output: ModelOutputAccumulator, - ) -> AsyncGenerator[StreamEvent, None]: - if isinstance(completion, ChatCompletion): - self._trace_response(completion) - if usage := Tape._extract_usage(completion): - state.usage = usage - output.response = completion - message = completion.choices[0].message - for event in self._completion_message_events(message, output): - yield event - return - - async for chunk in completion: - self._trace_response(chunk) - async for event in self._completion_chunk_events(chunk, state, output): - yield event - - @staticmethod - def _trace_response(response: ChatCompletion | ChatCompletionChunk) -> None: - if span := current_span(): - span.set(**{"gen_ai.response.model": response.model, "gen_ai.response.id": response.id}) - reasons = [choice.finish_reason for choice in response.choices if choice.finish_reason is not None] - if reasons: - span.set(**{"gen_ai.response.finish_reasons": reasons}) - - def _completion_message_events( - self, - message: ChatCompletionMessage, - output: ModelOutputAccumulator, - ) -> Iterable[StreamEvent]: - if message.reasoning: - yield StreamEvent("reasoning", {"delta": self.reasoning_text(message.reasoning)}) - if message.content: - output.add_text(message.content) - yield StreamEvent("text", {"delta": message.content}) - output.add_message_tool_calls(cast("Iterable[ChatCompletionMessageToolCall]", message.tool_calls or [])) - - async def _completion_chunk_events( - self, - chunk: ChatCompletionChunk, - state: StreamState, - output: ModelOutputAccumulator, - ) -> AsyncGenerator[StreamEvent, None]: - if usage := Tape._extract_usage(chunk): - state.usage = usage - for choice in chunk.choices: - delta = choice.delta - if delta.reasoning: - yield StreamEvent("reasoning", {"delta": self.reasoning_text(delta.reasoning)}) - if delta.content: - output.add_text(delta.content) - yield StreamEvent("text", {"delta": delta.content}) - if delta.tool_calls: - output.merge_delta_tool_calls(delta.tool_calls) - - @staticmethod - def reasoning_text(reasoning: object) -> str: - content = getattr(reasoning, "content", reasoning) - return "" if content is None else str(content) - - -@dataclass -class StreamToolCall: - id: str | None = None - type: Literal["function"] | None = None - name: str | None = None - arguments: str = "" - - def merge(self, delta: ChoiceDeltaToolCall) -> None: - if delta.id: - self.id = delta.id - if delta.type: - self.type = delta.type - if delta.function is None: - return - if delta.function.name: - if self.name is None or self.name == delta.function.name: - self.name = delta.function.name - else: - self.name += delta.function.name - if delta.function.arguments: - self.arguments += delta.function.arguments - - def as_tool_call(self, index: int) -> ChatCompletionMessageFunctionToolCall: - return ChatCompletionMessageFunctionToolCall( - id=self.id or f"call_{index}", - type=self.type or "function", - function=Function(name=self.name or "", arguments=self.arguments or "{}"), - ) - - -class ModelOutputAccumulator: - def __init__(self) -> None: - self.response: ChatCompletion | ParsedChatCompletion[Any] | None = None - self._text_parts: list[str] = [] - self._message_calls: list[ChatCompletionMessageToolCall] = [] - self._stream_calls: dict[int, StreamToolCall] = {} - - def add_text(self, text: str) -> None: - self._text_parts.append(text) - - def add_message_tool_calls(self, calls: Iterable[ChatCompletionMessageToolCall]) -> None: - self._message_calls.extend(calls) - - def merge_delta_tool_calls(self, deltas: Iterable[ChoiceDeltaToolCall]) -> None: - for delta in deltas: - self._stream_calls.setdefault(delta.index, StreamToolCall()).merge(delta) - - @property - def text(self) -> str: - return "".join(self._text_parts) - - @property - def tool_calls(self) -> list[ChatCompletionMessageToolCall]: - if self._message_calls: - return list(self._message_calls) - return [self._stream_calls[index].as_tool_call(index) for index in sorted(self._stream_calls)] +async def _stream_events( + stream: republic.Stream[Any], + span: Span, + state: StreamState, + text: list[str], + calls: list[republic.ToolCall], +) -> AsyncGenerator[StreamEvent, None]: + native_events = aiter(stream) + while True: + with span.activate(): + try: + item = await anext(native_events) + except StopAsyncIteration: + break + match item: + case TextDelta(chunk=delta) | RefusalDelta(chunk=delta): + text.append(delta) + yield StreamEvent("text", {"delta": delta}) + case ReasoningDelta(chunk=delta): + yield StreamEvent("reasoning", {"delta": delta}) + case ToolCallReady(call=call): + calls.append(call) + case Completed(response=completed): + state.usage = { + **asdict(completed.token_usage), + "total_tokens": completed.token_usage.total_tokens, + } + span.set(**{ + "gen_ai.response.model": completed.model, + "gen_ai.response.id": completed.id, + "gen_ai.response.finish_reasons": [completed.finish_reason] if completed.finish_reason else None, + }) -def tool_invocation_from_native( - tool_call: ChatCompletionMessageToolCall, +def _tool_invocation( + tool_call: republic.ToolCall, tool_map: dict[str, Tool], ) -> tuple[Tool, dict[str, Any]]: """Resolve a model tool call to (runtime tool, arguments). @@ -603,7 +374,13 @@ def tool_invocation_from_native( call, the placeholder raises a clear tool error rather than succeeding with an empty result. """ - tool_name, arguments = parse_native_function_call(tool_call) + tool_name = tool_call.name + try: + arguments = tool_call.args + except JSONDecodeError as exc: + raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") from exc + if not isinstance(arguments, dict): + raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") tool_obj = tool_map.get(tool_name) if tool_obj is None: @@ -614,16 +391,6 @@ def raise_unknown_tool(**_: Any) -> None: return tool_obj, arguments -def parse_native_function_call(tool_call: ChatCompletionMessageToolCall) -> tuple[str, dict[str, Any]]: - if tool_call.type != "function": - raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") - try: - arguments = TOOL_ARGUMENTS_ADAPTER.validate_json(tool_call.function.arguments or "{}") - except ValidationError as exc: - raise BubError(ErrorKind.INVALID_INPUT, "Expected a function tool call with JSON object arguments.") from exc - return tool_call.function.name, arguments - - def is_context_length_error(error_msg: str) -> bool: """Check whether an error message indicates a context-length / prompt-too-long failure.""" return bool(CONTEXT_LENGTH_PATTERNS.search(error_msg)) diff --git a/src/bub/builtin/onboarding.py b/src/bub/builtin/onboarding.py index a62da049..d7518d8a 100644 --- a/src/bub/builtin/onboarding.py +++ b/src/bub/builtin/onboarding.py @@ -3,32 +3,29 @@ from __future__ import annotations import asyncio -import inspect -import os from typing import Any from urllib.parse import urlsplit +import republic import typer -from any_llm import AnyLLM -from any_llm.exceptions import AuthenticationError, MissingApiKeyError, UnsupportedProviderError +from republic.errors import APITimeoutError, AuthenticationError, ProviderNotFoundError, UnsupportedFeatureError from bub import configure, inquirer -from bub.builtin.codex_provider import should_use_openai_codex_provider -from bub.builtin.settings import DEFAULT_MODEL, AgentSettings +from bub.builtin.settings import DEFAULT_MODEL, AgentSettings, CustomProvider PROVIDERS = { "openrouter": "OpenRouter (hosted model gateway)", "openai": "OpenAI (official API)", "openai-compatible": "OpenAI-compatible (custom URL / local server)", "anthropic": "Anthropic (Claude API)", - "gemini": "Google Gemini (native API)", - "azure": "Azure AI (Azure endpoint)", - "bedrock": "Amazon Bedrock (AWS)", + "google": "Google Gemini (native API)", + "codex": "Codex (ChatGPT plan login)", + "azure-openai": "Azure OpenAI (Azure endpoint)", "ollama": "Ollama (local server)", - "groq": "Groq", "mistral": "Mistral AI", "deepseek": "DeepSeek", - "custom": "Other provider (SDK provider name)", + "magpie": "Magpie (local model gateway)", + "custom": "Other registered Republic provider", } OPENAI_BASE = "https://api.openai.com/v1" CONNECTION_TIMEOUT = 10 @@ -37,31 +34,12 @@ RETRY_CONNECTION = "Retry connection" -def _provider_class(provider: str) -> type[AnyLLM] | None: - try: - return AnyLLM.get_provider_class(provider) - except (ImportError, UnsupportedProviderError): - return None - - def _default_base(provider: str) -> str: - provider_class = _provider_class(provider) - if provider_class is None: + try: + client = republic.get_provider(provider, api_key="display-only", api_base="", env_prefix="BUB_DISPLAY_ONLY") + except (AuthenticationError, ProviderNotFoundError, ValueError): return "" - env_name = provider_class.ENV_API_BASE_NAME - return ( - (os.getenv(env_name) if env_name else None) - or provider_class.API_BASE - or ("http://localhost:11434" if provider == "ollama" else "") - ) - - -def _has_environment_key(provider: str) -> bool: - if AgentSettings().model_client_kwargs(provider)["api_key"]: - return True - provider_class = _provider_class(provider) - names = (provider_class.ENV_API_KEY_NAME or "").split("/") if provider_class else [] - return any(os.getenv(name) for name in names) + return client.api_base def _endpoint(provider: str, api_base: str | None) -> str: @@ -98,20 +76,9 @@ def _ask_base(default: str, *, required: bool) -> str: async def _discover_models(provider: str, **client_args: Any) -> list[str]: - async with asyncio.timeout(CONNECTION_TIMEOUT): - if not AnyLLM.get_provider_class(provider).SUPPORTS_LIST_MODELS: - raise NotImplementedError - llm = AnyLLM.create(provider, **client_args) - try: - models = await llm.alist_models() - return sorted({model.id.strip() for model in models if isinstance(model.id, str) and model.id.strip()}) - finally: - client = getattr(llm, "client", None) - close = getattr(client, "aclose", None) or getattr(client, "close", None) - if callable(close): - result = close() - if inspect.isawaitable(result): - await result + async with republic.get_provider(provider, **{**client_args, "timeout": CONNECTION_TIMEOUT}) as client: + models = await client.list_models() + return sorted({model.id.strip() for model in models if isinstance(model.id, str) and model.id.strip()}) def discover_models(provider: str, **client_args: Any) -> list[str]: @@ -120,16 +87,14 @@ def discover_models(provider: str, **client_args: Any) -> list[str]: def _connection_error(exc: Exception) -> str: - # SDK errors may contain request URLs, response bodies or credentials. - if isinstance(exc, AuthenticationError | MissingApiKeyError): + # Provider errors may contain request URLs, response bodies or credentials. + if isinstance(exc, AuthenticationError): return "Authentication failed or API key missing. Check the key and its permissions." - if isinstance(exc, TimeoutError): + if isinstance(exc, TimeoutError | APITimeoutError): return f"Connection timed out after {CONNECTION_TIMEOUT} seconds. Check the URL and network." - if isinstance(exc, NotImplementedError | UnsupportedProviderError): + if isinstance(exc, NotImplementedError | UnsupportedFeatureError | ProviderNotFoundError): return "Model discovery is unavailable for this provider. You can enter a model ID manually." - if isinstance(exc, ImportError): - return "The provider SDK is not installed. Install its dependencies or enter a model ID manually." - status = getattr(exc, "status_code", None) or getattr(getattr(exc, "original_exception", None), "status_code", None) + status = getattr(exc, "status_code", None) if status in {401, 403}: return f"Authentication rejected (HTTP {status}). Check the API key and its permissions." if status == 404: @@ -151,9 +116,13 @@ def _choose_model(models: list[str], default: str) -> str: def _connection_config( - current_config: dict[str, object], provider: str, api_base: str, api_key: str + current_config: dict[str, object], provider: str, api_base: str, api_key: str, api_format: str | None ) -> dict[str, object]: + if isinstance(named := current_config.get("providers"), dict) and provider in named: + return {"providers": {provider: {"api_base": api_base or None, "api_key": api_key or None}}} config: dict[str, object] = {} + if api_format is not None: + config["client_args"] = {**AgentSettings.model_validate(current_config).client_args, "api_format": api_format} for name, value in (("api_base", api_base), ("api_key", api_key)): existing = current_config.get(name) if isinstance(existing, dict): @@ -167,12 +136,15 @@ def _connection_config( def _select_connection( current_config: dict[str, object], current_provider: str, current_endpoint: str -) -> tuple[str, str, str]: +) -> tuple[str, str, str, str | None]: choice = ( "openai-compatible" if current_provider == "openai" and current_endpoint != OPENAI_BASE else current_provider ) choices = dict(PROVIDERS) choices.setdefault(choice, choice) + for registered in republic.all_providers(): + if registered != "typesafe": + choices.setdefault(registered, registered) selected = inquirer.ask_fuzzy("LLM provider", choices=list(choices.values()), default=choices[choice]) choice = next((name for name, label in choices.items() if label == selected), selected) provider = "openai" if choice == "openai-compatible" else choice @@ -186,10 +158,13 @@ def _select_connection( for name in ("api_base", "api_key") if isinstance(value := current_config.get(name), dict) or provider == current_provider } - stored = AgentSettings.model_construct( - api_base=explicit.get("api_base"), api_key=explicit.get("api_key") - ).model_client_kwargs(provider) - api_base, api_key = stored["api_base"] or "", stored["api_key"] or "" + stored = AgentSettings.model_construct(api_base=explicit.get("api_base"), api_key=explicit.get("api_key")) + api_base = AgentSettings._provider_value(stored.api_base, provider) or "" + api_key = AgentSettings._provider_value(stored.api_key, provider) or "" + if isinstance(named := current_config.get("providers"), dict) and provider in named: + custom = CustomProvider.model_validate(named[provider]) + api_base = custom.api_base or api_base + api_key = custom.api_key or api_key previous_endpoint = _endpoint(provider, api_base) if choice == "openai" and previous_endpoint != OPENAI_BASE: api_base = OPENAI_BASE @@ -197,35 +172,33 @@ def _select_connection( typer.echo(f"API endpoint: {endpoint}" if api_base else f"Default API endpoint: {endpoint}") if ( choice in {"openai-compatible", "custom"} - or provider in {"azure", "ollama"} + or provider in {"azure-openai", "ollama", "magpie"} or (choice != "openai" and api_base and _endpoint(provider, api_base) != _default_base(provider).rstrip("/")) ): - api_base = _ask_base(api_base, required=choice == "openai-compatible" or provider == "azure") + api_base = _ask_base(api_base, required=choice == "openai-compatible" or provider == "azure-openai") if _endpoint(provider, api_base) != previous_endpoint: api_key = "" - return provider, api_base, api_key + return provider, api_base, api_key, "chat" if choice == "openai-compatible" else None -def _ask_key(provider: str, api_base: str, api_key: str) -> str: +def _ask_key(api_key: str) -> str: prompt = "API key (Enter to keep current key)" if api_key else "API key (optional)" api_key = inquirer.ask_secret(prompt).strip() or api_key - if ( - provider == "openai" - and api_base - and api_base.rstrip("/") != OPENAI_BASE - and not api_key - and not _has_environment_key(provider) - ): - # The OpenAI SDK requires a nonempty key even for servers without auth. - return "not-required" return api_key def _configure_connection( - current_config: dict[str, object], provider: str, api_base: str, api_key: str, model_default: str + current_config: dict[str, object], + provider: str, + api_base: str, + api_key: str, + model_default: str, + api_format: str | None, ) -> dict[str, object]: action = "" models: list[str] = [] + settings = AgentSettings.model_validate(current_config) + provider_type = settings.providers[provider].type if provider in settings.providers else provider typer.echo("Leave the API key blank to keep the current key or use environment credentials.") while action != MANUAL_MODEL: if action == EDIT_CONNECTION: @@ -234,21 +207,20 @@ def _configure_connection( if _endpoint(provider, api_base) != previous_endpoint: api_key = model_default = "" if action != RETRY_CONNECTION: - api_key = _ask_key(provider, api_base, api_key) - config = _connection_config(current_config, provider, api_base, api_key) + api_key = _ask_key(api_key) if provider_type != "codex" else "" + config = _connection_config(current_config, provider, api_base, api_key, api_format) settings = AgentSettings.model_validate(configure.merge({}, current_config, config)) client_args = settings.model_client_kwargs(provider) + provider_type = settings.providers[provider].type if provider in settings.providers else provider if (client_args["api_base"] or "", client_args["api_key"] or "") != (api_base, api_key): typer.echo("BUB_* environment settings override this connection's URL or API key.") - if should_use_openai_codex_provider( - provider, model_default, api_key=client_args["api_key"], api_base=client_args["api_base"] - ): - typer.echo("Using OpenAI OAuth login. Model discovery is unavailable; enter a model ID manually.") + if provider_type == "codex": + typer.echo("Using Codex account login. Model discovery is unavailable; enter a model ID manually.") break typer.echo("Checking connection and fetching models...") try: - models = discover_models(provider, **client_args) - except (NotImplementedError, UnsupportedProviderError) as exc: + models = discover_models(provider_type, **client_args) + except (NotImplementedError, UnsupportedFeatureError, ProviderNotFoundError) as exc: typer.secho(_connection_error(exc), fg="yellow") break except Exception as exc: @@ -271,8 +243,9 @@ def collect_model_config(current_config: dict[str, object]) -> dict[str, object] if not separator: current_provider, _, fallback = DEFAULT_MODEL.partition(":") model_default = settings.model.strip() or fallback + current_provider = current_provider.lower() current_endpoint = _endpoint(current_provider, settings.model_client_kwargs(current_provider)["api_base"]) - provider, api_base, api_key = _select_connection(current_config, current_provider, current_endpoint) + provider, api_base, api_key, api_format = _select_connection(current_config, current_provider, current_endpoint) if (provider, _endpoint(provider, api_base)) != (current_provider, current_endpoint): model_default = "" - return _configure_connection(current_config, provider, api_base, api_key, model_default) + return _configure_connection(current_config, provider, api_base, api_key, model_default, api_format) diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index 59d96c0b..b1c2b657 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -7,13 +7,12 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from any_llm import AnyLLM -from any_llm.constants import LLMProvider +import republic from pydantic import BaseModel, Field, field_validator from pydantic.fields import FieldInfo from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict -from bub import Settings, config, ensure_config +from bub import Settings, __version__, config, ensure_config from bub.builtin.commands import validate_command_prefix if TYPE_CHECKING: @@ -24,21 +23,37 @@ DEFAULT_MAX_TOKENS = 16384 +def _provider_env_name(provider: str) -> str: + return provider.upper().replace("-", "_") + + +USER_AGENT = f"bub/{__version__}" +"""Sent to model providers instead of Republic's default.""" + + @dataclass(frozen=True) class ModelCandidate: - provider: LLMProvider + provider: str model_id: str name: str provider_name: str | None = None class CustomProvider(BaseModel): - """A named endpoint that speaks one of any-llm's provider APIs.""" + """A named endpoint that speaks a registered Republic provider API.""" - type: LLMProvider + type: str api_key: str | None = None api_base: str | None = None + @field_validator("type") + @classmethod + def registered_type(cls, value: str) -> str: + provider = value.lower() + if provider not in republic.all_providers(): + raise ValueError(f"Unknown Republic provider type: {value!r}") + return provider + class ProviderSpecificEnvSource(PydanticBaseSettingsSource): def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: @@ -54,11 +69,12 @@ def __call__(self) -> dict[str, Any]: @staticmethod def _provider_specific(setting_name: str) -> dict[str, str]: + env_names = {_provider_env_name(name): name for name in republic.all_providers()} setting_regex = re.compile(rf"^BUB_(.+)_{setting_name.upper()}$") result: dict[str, str] = {} for key, value in os.environ.items(): if match := setting_regex.match(key): - result[match.group(1).lower()] = value + result[env_names.get(match.group(1), match.group(1).lower())] = value return result @@ -114,29 +130,41 @@ def model_candidates(self, model: str) -> list[ModelCandidate]: candidates: list[ModelCandidate] = [] for candidate in candidate_names: - prefix, sep, rest = candidate.partition(":") - if sep and (custom := self.providers.get(prefix)): - candidates.append( - ModelCandidate(provider=custom.type, model_id=rest, name=candidate, provider_name=prefix) - ) + provider, separator, model_id = candidate.partition(":") + if not separator or not provider or not model_id: + raise ValueError(f"Expected a provider:model identifier, got {candidate!r}") + if custom := self.providers.get(provider): + candidates.append(ModelCandidate(custom.type, model_id, candidate, provider)) continue - provider, model_id = AnyLLM.split_model_provider(candidate) - candidates.append(ModelCandidate(provider=provider, model_id=model_id, name=candidate)) + provider = provider.lower() + candidates.append(ModelCandidate(provider=provider, model_id=model_id, name=f"{provider}:{model_id}")) return candidates def model_client_kwargs(self, provider: str) -> dict[str, Any]: custom = self.providers.get(provider) + provider = provider if custom else provider.lower() + options = dict(self.client_args) + # Headers from client_args, including a User-Agent, take precedence. + options["headers"] = {"User-Agent": USER_AGENT, **(options.get("headers") or {})} + prefix = _provider_env_name(provider) + options.setdefault("env_prefix", f"BUB_{prefix}") return { - **self.client_args, - "api_key": (custom and custom.api_key) or self._provider_value(self.api_key, provider), - "api_base": (custom and custom.api_base) or self._provider_value(self.api_base, provider), + **options, + "api_key": (custom and custom.api_key) or self._credential_value(self.api_key, provider, "api_key", prefix), + "api_base": (custom and custom.api_base) + or self._credential_value(self.api_base, provider, "api_base", prefix), } + @classmethod + def _credential_value( + cls, value: str | dict[str, str] | None, provider: str, setting: str, prefix: str + ) -> str | None: + explicit = cls._provider_value(value, provider) + return explicit or os.getenv(f"{prefix}_{setting.upper()}", explicit) + @staticmethod def _provider_value(value: str | dict[str, str] | None, provider: str) -> str | None: - if isinstance(value, dict): - return value.get(provider) - return value + return value.get(provider) if isinstance(value, dict) else value @property def home(self) -> pathlib.Path: diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index b13881fd..71170880 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -404,7 +404,7 @@ async def stream_events( def _update_token_speed(self, data: dict[str, Any]) -> None: usage = data.get("usage") - tokens = usage.get("completion_tokens", usage.get("output_tokens")) if isinstance(usage, dict) else None + tokens = usage.get("output_tokens") if isinstance(usage, dict) else None elapsed = data.get("elapsed_seconds") self._last_token_speed = None if ( diff --git a/src/bub/channels/message.py b/src/bub/channels/message.py index 9027a2a3..3fd3f1ef 100644 --- a/src/bub/channels/message.py +++ b/src/bub/channels/message.py @@ -9,32 +9,6 @@ type MessageKind = Literal["error", "normal", "command"] type MediaType = Literal["image", "audio", "video", "document"] -_AUDIO_FORMAT_TO_MIME_TYPE = { - "aiff": "audio/aiff", - "flac": "audio/flac", - "m4a": "audio/mp4", - "mp3": "audio/mpeg", - "ogg": "audio/ogg", - "wav": "audio/wav", - "webm": "audio/webm", -} -_AUDIO_MIME_TYPE_TO_FORMAT = { - **{mime_type: audio_format for audio_format, mime_type in _AUDIO_FORMAT_TO_MIME_TYPE.items()}, - "audio/x-aiff": "aiff", - "audio/x-flac": "flac", - "audio/x-m4a": "m4a", - "audio/x-wav": "wav", -} - - -def audio_format_from_mime_type(mime_type: str) -> str: - normalized = mime_type.partition(";")[0].strip().lower() - return _AUDIO_MIME_TYPE_TO_FORMAT.get(normalized, normalized.removeprefix("audio/") or "unknown") - - -def audio_mime_type_from_format(audio_format: str) -> str: - return _AUDIO_FORMAT_TO_MIME_TYPE.get(audio_format, f"audio/{audio_format}") - @dataclass class MediaItem: diff --git a/src/bub/framework.py b/src/bub/framework.py index a8761138..600ec6ee 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -7,7 +7,7 @@ from collections.abc import AsyncGenerator from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import pluggy import typer @@ -24,6 +24,7 @@ from bub.hooks.runtime import _SKIP_VALUE, HookRuntime from bub.hooks.specs import BUB_HOOK_NAMESPACE, BubHookSpecs from bub.model_selection import ModelOptions +from bub.prompt import UserContent, to_content, to_legacy_prompt from bub.sidecars import TapeSidecar from bub.store import AsyncTapeStore, TapeStore from bub.streaming import StreamState @@ -131,20 +132,19 @@ def _main( self._hook_runtime.call_many_sync("register_cli_commands", app=app) return app - async def build_prompt( - self, message: Envelope, session_id: str, state: dict[str, Any] - ) -> str | list[dict[str, Any]]: - """Build prompt for one message turn.""" + async def build_prompt(self, message: Envelope, session_id: str, state: dict[str, Any]) -> list[UserContent]: + """Build prompt content for one message turn, converting legacy hook results.""" prompt = await self._hook_runtime.call_first( "build_prompt", message=message, session_id=session_id, state=state ) if not prompt: prompt = content_of(message) - return cast("str | list[dict[str, Any]]", prompt) + return to_content(prompt) - async def continue_prompt(self, prompt: str | list[dict] | None, tape: Tape, state: StreamState) -> str | None: + async def continue_prompt(self, prompt: list[UserContent] | None, tape: Tape, state: StreamState) -> str | None: """Return an optional user message for the next step of an agent loop.""" - next_prompt = await self._hook_runtime.call_first("continue_prompt", prompt=prompt, tape=tape, state=state) + legacy = None if prompt is None else to_legacy_prompt(prompt) + next_prompt = await self._hook_runtime.call_first("continue_prompt", prompt=legacy, tape=tape, state=state) if next_prompt is None or isinstance(next_prompt, str): return next_prompt raise TypeError("hook.continue_prompt must return str or None") @@ -219,7 +219,7 @@ async def resolve_session(self, message: Envelope) -> str: async def _run_model( self, inbound: Envelope, - prompt: str | list[dict], + prompt: list[UserContent], session_id: str, state: dict[str, Any], stream_output: bool, @@ -232,7 +232,7 @@ async def _run_model( error=RuntimeError("no model skill returned output"), message=inbound, ) - return prompt if isinstance(prompt, str) else content_of(inbound) + return text if isinstance(text := to_legacy_prompt(prompt), str) else content_of(inbound) return output stream = await self._hook_runtime.run_model_stream(prompt=prompt, session_id=session_id, state=state) if stream is None: @@ -241,7 +241,7 @@ async def _run_model( error=RuntimeError("no model skill returned output"), message=inbound, ) - return prompt if isinstance(prompt, str) else content_of(inbound) + return text if isinstance(text := to_legacy_prompt(prompt), str) else content_of(inbound) else: parts: list[str] = [] events = self._channel_router.wrap_stream(inbound, stream) if self._channel_router is not None else stream @@ -475,7 +475,7 @@ def get_agent_hooks(self) -> AgentHooks: """Return the model and tool interception adapter for this framework's hooks.""" return self._agent_hooks - def get_system_prompt(self, prompt: str | list[dict], state: dict[str, Any]) -> str: + def get_system_prompt(self, prompt: list[UserContent], state: dict[str, Any]) -> str: """Join nonempty system-prompt hook results from low to high priority. Hooks contribute additional blocks; a higher-priority hook does not replace @@ -483,7 +483,9 @@ def get_system_prompt(self, prompt: str | list[dict], state: dict[str, Any]) -> """ return "\n\n".join( result - for result in reversed(self._hook_runtime.call_many_sync("system_prompt", prompt=prompt, state=state)) + for result in reversed( + self._hook_runtime.call_many_sync("system_prompt", prompt=to_legacy_prompt(prompt), state=state) + ) if result ) diff --git a/src/bub/hooks/interception.py b/src/bub/hooks/interception.py index 931b321a..f94f7d47 100644 --- a/src/bub/hooks/interception.py +++ b/src/bub/hooks/interception.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Any, Literal +import republic from loguru import logger from bub.hooks.runtime import _SKIP_VALUE, HookRuntime @@ -19,13 +20,13 @@ class LlmCallRequest: """Outgoing agent-loop LLM request exposed to interception hooks. Hooks may return a modified copy (``dataclasses.replace``) to change the - model or messages for this call. Tool objects are not exposed; + model or messages (``republic.Message`` objects) for this call. Tool objects are not exposed; ``tool_names`` is observational and altering the toolset is out of scope. """ run_id: str model: str - messages: list[dict[str, Any]] + messages: list[republic.Message] tool_names: tuple[str, ...] = () max_tokens: int | None = None diff --git a/src/bub/hooks/runtime.py b/src/bub/hooks/runtime.py index 2b0bf112..9a353d74 100644 --- a/src/bub/hooks/runtime.py +++ b/src/bub/hooks/runtime.py @@ -11,6 +11,7 @@ from loguru import logger from bub.envelope import Envelope +from bub.prompt import LegacyPrompt, UserContent, to_content, to_legacy_prompt from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.turn import TurnState @@ -162,8 +163,11 @@ def _iter_hookimpls(self, hook_name: str) -> list[Any]: def _kwargs_for_impl(impl: Any, kwargs: dict[str, Any]) -> dict[str, Any]: return {name: kwargs[name] for name in impl.argnames if name in kwargs} - async def run_model(self, prompt: str | list[dict], session_id: str, state: TurnState) -> str | None: - """Run the first model hook found and return its text.""" + async def run_model( + self, prompt: list[UserContent] | LegacyPrompt, session_id: str, state: TurnState + ) -> str | None: + """Run the first model hook found and return its text; hooks receive the legacy prompt form.""" + prompt = to_legacy_prompt(to_content(prompt)) for _, plugin in reversed(self._plugin_manager.list_name_plugin()): if hasattr(plugin, "run_model"): @@ -182,9 +186,10 @@ async def run_model(self, prompt: str | list[dict], session_id: str, state: Turn return None async def run_model_stream( - self, prompt: str | list[dict], session_id: str, state: TurnState + self, prompt: list[UserContent] | LegacyPrompt, session_id: str, state: TurnState ) -> AsyncStreamEvents | None: - """Run the first streaming model hook, falling back to a text hook.""" + """Run the first streaming model hook, falling back to a text hook; hooks receive the legacy prompt form.""" + prompt = to_legacy_prompt(to_content(prompt)) for _, plugin in reversed(self._plugin_manager.list_name_plugin()): if hasattr(plugin, "run_model_stream"): diff --git a/src/bub/hooks/specs.py b/src/bub/hooks/specs.py index 73a0bab6..24b4f5ad 100644 --- a/src/bub/hooks/specs.py +++ b/src/bub/hooks/specs.py @@ -21,6 +21,7 @@ ToolCallResult, ) from bub.model_selection import ModelOptions +from bub.prompt import LegacyPrompt, UserContent from bub.sidecars import TapeSidecar from bub.store import AsyncTapeStore, TapeStore from bub.streaming import AsyncStreamEvents, StreamState @@ -36,7 +37,14 @@ class BubHookSpecs: - """Hook contract for Bub framework extensions.""" + """Hook contract for Bub framework extensions. + + ``build_prompt`` returns user content: text, ``republic.Image``, + ``republic.Audio`` and ``republic.Video``; legacy text and content-block + results are still converted by inspecting the returned value. Hooks that + receive a prompt get the legacy form: text without media, otherwise content + blocks with media URLs (see ``bub.prompt.to_legacy_prompt``). + """ @hookspec(firstresult=True) def resolve_session(self, message: Envelope) -> str: @@ -44,11 +52,11 @@ def resolve_session(self, message: Envelope) -> str: raise NotImplementedError @hookspec(firstresult=True) - def build_prompt(self, message: Envelope, session_id: str, state: TurnState) -> str | list[dict]: - """Build model prompt for this turn. + def build_prompt(self, message: Envelope, session_id: str, state: TurnState) -> list[UserContent] | LegacyPrompt: + """Build model prompt content for this turn. - Returns either a plain text string or a list of content parts - (OpenAI multimodal format) when media attachments are present. + Return a list of text and Republic media such as ``republic.image(...)``. + Legacy text and content-block lists are still accepted and converted. """ raise NotImplementedError diff --git a/src/bub/prompt.py b/src/bub/prompt.py new file mode 100644 index 00000000..d0f23aa8 --- /dev/null +++ b/src/bub/prompt.py @@ -0,0 +1,58 @@ +"""User prompt content and the legacy prompt forms accepted for compatibility.""" + +from __future__ import annotations + +from typing import Any + +import republic + +from bub.errors import BubError, ErrorKind + +type UserContent = str | republic.Image | republic.Audio | republic.Video +type LegacyPrompt = str | list[dict[str, Any]] +"""Prompt forms used before ``list[UserContent]``: text, or text and media content blocks.""" + +_MEDIA_TYPES = (republic.Image, republic.Audio, republic.Video) + + +def to_content(prompt: list[UserContent] | LegacyPrompt) -> list[UserContent]: + """Return user content, converting text and legacy content blocks by their value.""" + from bub.tape import to_message + + if isinstance(prompt, str): + return [prompt] + if not isinstance(prompt, list): + raise BubError(ErrorKind.INVALID_INPUT, "Expected user content, text, or a list of content blocks.") + content: list[UserContent] = [] + for item in prompt: + if isinstance(item, str | republic.Image | republic.Audio | republic.Video): + content.append(item) + elif isinstance(item, dict): + for part in to_message({"role": "user", "content": [item]}).parts: + if isinstance(part, republic.Text): + content.append(part.text) + elif isinstance(part, _MEDIA_TYPES): + content.append(part) + else: + raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported prompt content block: {item.get('type')}") + else: + raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported prompt content: {type(item).__name__}") + return content + + +def to_legacy_prompt(content: list[UserContent]) -> LegacyPrompt: + """Return the legacy form: text without media, otherwise content blocks with media URLs.""" + if all(isinstance(item, str) for item in content): + return prompt_text(content) + blocks: list[dict[str, Any]] = [] + for item in content: + if isinstance(item, str): + blocks.append({"type": "text", "text": item}) + else: + blocks.append({"type": item.kind, "media_type": item.media_type, "url": item.data_url}) + return blocks + + +def prompt_text(content: list[UserContent]) -> str: + """Join the text items of a prompt.""" + return "\n".join(item for item in content if isinstance(item, str)) diff --git a/src/bub/tape.py b/src/bub/tape.py index 627a074a..b3e26b15 100644 --- a/src/bub/tape.py +++ b/src/bub/tape.py @@ -6,16 +6,16 @@ import hashlib import inspect import json -from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Mapping +from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Mapping, MutableMapping from dataclasses import asdict, dataclass, field, replace from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any -from pydantic import BaseModel +import republic from bub import tracing -from bub.errors import BubError +from bub.errors import BubError, ErrorKind from bub.sidecars import TapeSidecar, sidecar_tape_name __all__ = [ @@ -37,6 +37,8 @@ "UnavailableTapeStore", "build_messages", "is_async_tape_store", + "to_message", + "to_messages", "utc_now", ] @@ -111,10 +113,8 @@ def anchor(cls, name: str, state: dict[str, Any] | None = None, **meta: Any) -> return cls(id=0, kind="anchor", payload=payload, meta=dict(meta)) @classmethod - def tool_call(cls, calls: list[dict[str, Any]], *, content: str | None = None, **meta: Any) -> TapeEntry: - payload: dict[str, Any] = {"calls": calls} - if content is not None: - payload["content"] = content + def tool_call(cls, calls: list[dict[str, Any]], *, content: str = "", **meta: Any) -> TapeEntry: + payload: dict[str, Any] = {"calls": calls, "content": content} return cls(id=0, kind="tool_call", payload=payload, meta=dict(meta)) @classmethod @@ -140,7 +140,7 @@ def __repr__(self) -> str: LAST_ANCHOR = _LastAnchor() type AnchorSelector = str | None | _LastAnchor -type SelectedMessages = list[dict[str, Any]] | Coroutine[Any, Any, list[dict[str, Any]]] +type SelectedMessages = list[republic.Message] | Coroutine[Any, Any, list[republic.Message]] type ContextSelector = Callable[[Iterable[TapeEntry], "TapeContext"], SelectedMessages] @@ -166,15 +166,99 @@ def build_messages(entries: Iterable[TapeEntry], context: TapeContext) -> Select return _default_messages(entries) -def _default_messages(entries: Iterable[TapeEntry]) -> list[dict[str, Any]]: - messages: list[dict[str, Any]] = [] - for entry in entries: - if entry.kind != "message": - continue - payload = entry.payload - if isinstance(payload, dict): - messages.append(dict(payload)) - return messages +def _default_messages(entries: Iterable[TapeEntry]) -> list[republic.Message]: + return to_messages( + entry.payload for entry in entries if entry.kind == "message" and isinstance(entry.payload, dict) + ) + + +def to_messages(payloads: Iterable[Mapping[str, Any]]) -> list[republic.Message]: + """Convert stored message payloads to Republic messages, resolving tool results to earlier calls.""" + calls: dict[str, republic.ToolCall] = {} + return [to_message(payload, calls) for payload in payloads] + + +def to_message( + payload: Mapping[str, Any], calls: MutableMapping[str, republic.ToolCall] | None = None +) -> republic.Message: + """Convert one stored message payload; assistant tool calls are added to ``calls``. + + Payloads are ``republic.Message.to_dict()`` output. Older Bub payloads, with media URLs, + ``tool_call_id``, ``reasoning`` and ``provider_data`` fields, and tool calls in the Chat + Completions ``{"function": {"name", "arguments"}}`` form, are converted to that form first. + """ + calls = {} if calls is None else calls + role = payload.get("role") + if role not in {"system", "user", "assistant", "tool"}: + raise BubError(ErrorKind.INVALID_INPUT, f"Unknown message role: {role}") + data = dict(payload) + raw_content = data.pop("content", None) + if role == "tool" and raw_content is not None and not isinstance(raw_content, str | list): + from bub.tools import render_result + + raw_content = render_result(raw_content) + content = _content_parts(raw_content) + if isinstance(tool_calls := data.get("tool_calls"), list): + data["tool_calls"] = [_tool_call_dict(call) for call in tool_calls] + if role == "tool" and "tool_call" not in data: + call_id = data.pop("tool_call_id", None) + if not isinstance(call_id, str) or not call_id: + raise BubError(ErrorKind.INVALID_INPUT, "Tool result without a tool call ID.") + # Republic announces calls missing from the conversation before their results. + call = calls.get(call_id) or republic.ToolCall(call_id, data.pop("name", None) or "", "{}") + data["tool_call"] = asdict(call) + if reasoning := data.pop("reasoning", None): + content.append({"type": "reasoning", "text": reasoning}) + content.extend({"type": "provider_data", **item} for item in data.pop("provider_data", None) or []) + data["content"] = content + try: + message = republic.Message.from_dict(data) + except (KeyError, TypeError, ValueError) as exc: + raise BubError(ErrorKind.INVALID_INPUT, f"Invalid {role} message: {exc}") from exc + if role == "system" and any(not isinstance(part, republic.Text) for part in message.parts): + raise BubError(ErrorKind.INVALID_INPUT, "System messages only support text content.") + calls.update((call.id, call) for call in message.tool_calls) + return message + + +def _tool_call_dict(call: object) -> object: + """Convert a Chat Completions tool call, as recorded by older Bub versions, to Republic's form.""" + if not isinstance(call, dict) or not isinstance(function := call.get("function"), dict): + return call + arguments = function.get("arguments") + return { + "id": call.get("id") or "", + "name": function.get("name") or "", + "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments or {}), + } + + +def _content_parts(content: object) -> list[dict[str, Any]]: + if content is None: + return [] + if isinstance(content, str): + return [{"type": "text", "text": content}] + if not isinstance(content, list): + raise BubError(ErrorKind.INVALID_INPUT, "Expected text or a list of message content blocks.") + parts: list[dict[str, Any]] = [] + for block in content: + match block: + case {"type": "image" | "audio" | "video" as kind, "url": str(url), "media_type": str(mime)} if ( + url.startswith("data:") and "data" not in block + ): + # Older payloads keep inline media as data URLs. + media = {"image": republic.image, "audio": republic.audio, "video": republic.video}[kind]( + url, media_type=mime + ) + parts.append({"type": kind, "media_type": media.media_type, "data": media.base64_data}) + case {"type": "image" | "audio" | "video" as kind} if "url" not in block and "data" not in block: + raise BubError(ErrorKind.INVALID_INPUT, f"The {kind} content block needs a url or data.") + case {"type": "text" | "reasoning" | "image" | "audio" | "video" | "provider_data"}: + parts.append(block) + case _: + kind = block.get("type") if isinstance(block, dict) else type(block).__name__ + raise BubError(ErrorKind.INVALID_INPUT, f"Unsupported message content block: {kind}") + return parts @dataclass(frozen=True) @@ -258,17 +342,16 @@ async def info(self) -> TapeInfo: if not isinstance(token_usage, int) or isinstance(token_usage, bool): continue last_token_usage = token_usage - prompt_tokens = usage.get("prompt_tokens") - prompt_details = usage.get("prompt_tokens_details") - cached_tokens = prompt_details.get("cached_tokens") if isinstance(prompt_details, Mapping) else None + input_tokens = usage.get("input_tokens") + cached_tokens = usage.get("cached_tokens") if ( - isinstance(prompt_tokens, int) - and not isinstance(prompt_tokens, bool) - and prompt_tokens > 0 + isinstance(input_tokens, int) + and not isinstance(input_tokens, bool) + and input_tokens > 0 and isinstance(cached_tokens, int) and not isinstance(cached_tokens, bool) ): - last_token_cache_hit_rate = cached_tokens / prompt_tokens + last_token_cache_hit_rate = cached_tokens / input_tokens break return TapeInfo( name=self.name, @@ -302,7 +385,7 @@ async def append_event(self, name: str, payload: dict[str, Any], **meta: Any) -> tracing.event(f"bub.{name}", **payload) await self.store.append(self.name, TapeEntry.event(name, payload, **(tracing.correlation() | meta))) - async def read_messages(self) -> list[dict[str, Any]]: + async def read_messages(self) -> list[republic.Message]: query = self.context.build_query(self.query()) entries = await self.store.fetch_all(query) context_entries = (entry for entry in entries if entry.meta.get("context") is not False) @@ -332,16 +415,16 @@ async def record_chat( # noqa: C901 *, run_id: str, system_prompt: str | None, - new_messages: list[dict[str, Any]], + new_messages: list[republic.Message], response_text: str | None, context_error: BubError | None = None, tool_calls: list[dict[str, Any]] | None = None, tool_results: list[Any] | None = None, error: BubError | None = None, - response: Any | None = None, provider: str | None = None, model: str | None = None, usage: dict[str, Any] | None = None, + assistant_fields: dict[str, Any] | None = None, ) -> None: tape_name = self.name meta = {"run_id": run_id, **tracing.correlation()} @@ -350,40 +433,30 @@ async def record_chat( # noqa: C901 if context_error is not None: await self.store.append(tape_name, TapeEntry.error(context_error, **meta)) for message in new_messages: - await self.store.append(tape_name, TapeEntry.message(message, **meta)) + await self.store.append(tape_name, TapeEntry.message(message.to_dict(), **meta)) if tool_calls: - await self.store.append(tape_name, TapeEntry.tool_call(tool_calls, content=response_text, **meta)) + call_entry = TapeEntry.tool_call(tool_calls, content=response_text or "", **meta) + call_entry.payload.update(assistant_fields or {}) + await self.store.append(tape_name, call_entry) if tool_results is not None: await self.store.append(tape_name, TapeEntry.tool_result(tool_results, **meta)) if error is not None and error is not context_error: await self.store.append(tape_name, TapeEntry.error(error, **meta)) if response_text is not None and not tool_calls: await self.store.append( - tape_name, TapeEntry.message({"role": "assistant", "content": response_text}, **meta) + tape_name, + TapeEntry.message({"role": "assistant", "content": response_text, **(assistant_fields or {})}, **meta), ) data: dict[str, Any] = {"status": "error" if error is not None else "ok"} - resolved_usage = usage or self._extract_usage(response) - if resolved_usage is not None: - data["usage"] = resolved_usage + if usage is not None: + data["usage"] = usage if provider: data["provider"] = provider if model: data["model"] = model await self.store.append(tape_name, TapeEntry.event("run", data, **meta)) - @staticmethod - def _extract_usage(response: object) -> dict[str, Any] | None: - usage = getattr(response, "usage", None) - if usage is None: - return None - if isinstance(usage, dict): - return usage - if isinstance(usage, BaseModel): - payload = usage.model_dump(exclude_none=True) - return payload if isinstance(payload, dict) else None - return None - async def _archive_tape(self, tape_name: str, stamp: str) -> Path: from bub.store import TapeQuery diff --git a/src/bub/tools.py b/src/bub/tools.py index 0d60a399..e972e200 100644 --- a/src/bub/tools.py +++ b/src/bub/tools.py @@ -175,15 +175,8 @@ def render(self, result: Any) -> str: return self.renderer(result) def to_schema(self) -> dict[str, Any]: - """Build an any-llm completion tool payload.""" - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": self.parameters, - }, - } + """Build a function tool schema for hooks and diagnostics.""" + return {"name": self.name, "description": self.description, "parameters": self.parameters} @classmethod def from_callable( diff --git a/src/bub/tracing.py b/src/bub/tracing.py index 5716fab7..d9b0fe11 100644 --- a/src/bub/tracing.py +++ b/src/bub/tracing.py @@ -6,11 +6,13 @@ import importlib import json import os -from collections.abc import Iterator, Mapping +from collections.abc import Iterator, Mapping, Sequence from contextlib import contextmanager, suppress from contextvars import ContextVar from typing import TYPE_CHECKING, Any +import republic + if TYPE_CHECKING: import opentelemetry.trace as otel else: @@ -70,7 +72,8 @@ def _json(value: Any) -> str: def _parts(message: Mapping[str, Any]) -> list[dict[str, Any]]: content = message.get("content") if message.get("role") == "tool": - return [{"type": "tool_call_response", "id": message.get("tool_call_id", ""), "response": content}] + call_id = message.get("tool_call_id") or (message.get("tool_call") or {}).get("id", "") + return [{"type": "tool_call_response", "id": call_id, "response": content}] parts: list[dict[str, Any]] = [] if isinstance(content, str) and content: parts.append({"type": "text", "content": content}) @@ -82,15 +85,14 @@ def _parts(message: Mapping[str, Any]) -> list[dict[str, Any]]: # Preserve media structure without copying inline binary payloads. parts.append({"type": "text", "content": f"[{part.get('type', 'media')} omitted]"}) for call in message.get("tool_calls") or []: - function = call.get("function", {}) - arguments = function.get("arguments", {}) + arguments = call.get("arguments", {}) if isinstance(arguments, str): with suppress(ValueError): arguments = json.loads(arguments) parts.append({ "type": "tool_call", "id": call.get("id", ""), - "name": function.get("name", ""), + "name": call.get("name", ""), "arguments": arguments, }) return parts @@ -153,9 +155,10 @@ def rename(self, name: str) -> None: if self.recording: self._span.update_name(name) - def messages(self, key: str, messages: list[dict[str, Any]]) -> None: + def messages(self, key: str, messages: Sequence[Mapping[str, Any] | republic.Message]) -> None: if self.recording: - normalized = [{"role": m.get("role", "user"), "parts": _parts(m)} for m in messages] + dicts = [m.to_dict() if isinstance(m, republic.Message) else m for m in messages] + normalized = [{"role": m.get("role", "user"), "parts": _parts(m)} for m in dicts] direction = "input" if key == "gen_ai.input.messages" else "output" self.set(**{ key: _json(normalized), diff --git a/src/bub/turn.py b/src/bub/turn.py index cf54d8e0..8b90ed36 100644 --- a/src/bub/turn.py +++ b/src/bub/turn.py @@ -3,10 +3,13 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from bub.envelope import Envelope +if TYPE_CHECKING: + from bub.prompt import UserContent + type TurnState = dict[str, Any] @@ -15,7 +18,7 @@ class TurnResult: """Result of one complete message turn.""" session_id: str - prompt: str | list[dict[str, Any]] + prompt: list[UserContent] model_output: str outbounds: list[Envelope] = field(default_factory=list) state: TurnState = field(default_factory=dict) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..698a4849 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Bub behavior tests and shared model response fixtures.""" diff --git a/tests/conftest.py b/tests/conftest.py index 380894b0..a92497a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,23 @@ from __future__ import annotations +import os from collections.abc import Callable, Generator from pathlib import Path +from typing import Any +import httpx2 import pytest import bub.configure as configure +from tests.model_fakes import ProviderService + + +@pytest.fixture(autouse=True) +def isolate_bub_environment(monkeypatch: pytest.MonkeyPatch) -> None: + # Importing bub.framework loads the developer's .env into os.environ; tests set what they need. + for name in list(os.environ): + if name.startswith("BUB_"): + monkeypatch.delenv(name) @pytest.fixture(autouse=True) @@ -37,3 +49,47 @@ def _load(content: str = "") -> Path: return config_file return _load + + +@pytest.fixture +def provider_service() -> ProviderService: + return ProviderService() + + +@pytest.fixture +def codex_executable(tmp_path: Path) -> Path: + import sys + + executable = tmp_path / "codex" + executable.write_text( + f"#!{sys.executable}\n" + + """ +import json +import os +import sys +from pathlib import Path +from typing import Any +args = sys.argv[1:] +if not args or args[0] != "login" or 'cli_auth_credentials_store="file"' not in args: + sys.exit(2) +home = Path(os.environ["CODEX_HOME"]) +home.mkdir(parents=True, exist_ok=True) +account = "device-account" if "--device-auth" in args else "browser-account" +(home / "auth.json").write_text(json.dumps({"tokens": {"access_token": "test-token", "account_id": account}})) +""", + encoding="utf-8", + ) + executable.chmod(0o755) + return executable + + +@pytest.fixture +def provider_transport(provider_service: ProviderService, monkeypatch: pytest.MonkeyPatch) -> ProviderService: + constructor = httpx2.AsyncClient + + def client(**kwargs: Any) -> httpx2.AsyncClient: + kwargs.setdefault("transport", httpx2.MockTransport(provider_service._respond)) + return constructor(**kwargs) + + monkeypatch.setattr(httpx2, "AsyncClient", client) + return provider_service diff --git a/tests/model_fakes.py b/tests/model_fakes.py new file mode 100644 index 00000000..cea7755e --- /dev/null +++ b/tests/model_fakes.py @@ -0,0 +1,79 @@ +"""HTTP provider fixtures shared by runtime behavior tests.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx2 + + +def sse(events: list[Any]) -> httpx2.Response: + return httpx2.Response( + 200, + headers={"content-type": "text/event-stream"}, + text="".join(f"data: {event if isinstance(event, str) else json.dumps(event)}\n\n" for event in events), + ) + + +def chat_events(text: str = "done") -> list[Any]: + return [ + {"choices": [{"delta": {"content": text}, "finish_reason": "stop"}]}, + {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}}, + "[DONE]", + ] + + +def tool_events(calls: list[dict[str, Any]], text: str = "") -> list[Any]: + return [ + {"choices": [{"delta": {"content": text, "tool_calls": calls}, "finish_reason": "tool_calls"}]}, + "[DONE]", + ] + + +class ProviderService: + """Record HTTP requests and return queued provider responses.""" + + def __init__(self) -> None: + self.requests: list[httpx2.Request] = [] + self.responses: list[httpx2.Response] = [] + + def reply(self, response: httpx2.Response) -> None: + self.responses.append(response) + + def reply_chat( + self, + text: str = "done", + calls: list[dict[str, Any]] | None = None, + *, + model: str = "actual-model", + input_tokens: int = 3, + output_tokens: int = 2, + ) -> None: + delta: dict[str, Any] = {"content": text} + if calls: + delta["tool_calls"] = [ + {"index": index, "id": call["id"], "function": {"name": call["name"], "arguments": call["arguments"]}} + for index, call in enumerate(calls) + ] + self.reply( + sse([ + { + "id": "response-1", + "model": model, + "choices": [{"delta": delta, "finish_reason": "tool_calls" if calls else "stop"}], + }, + {"choices": [], "usage": {"prompt_tokens": input_tokens, "completion_tokens": output_tokens}}, + "[DONE]", + ]) + ) + + def body(self, index: int = -1) -> Any: + return json.loads(self.requests[index].content) + + def client(self) -> httpx2.AsyncClient: + return httpx2.AsyncClient(transport=httpx2.MockTransport(self._respond)) + + def _respond(self, request: httpx2.Request) -> httpx2.Response: + self.requests.append(request) + return self.responses.pop(0) diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 5ce6fbd6..33a8885a 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -3,11 +3,16 @@ from __future__ import annotations from dataclasses import replace +from pathlib import Path from typing import Any import pluggy import pytest +import republic +from republic.errors import StreamIncompleteError +from bub.builtin.model_runner import ModelRunner +from bub.builtin.settings import AgentSettings from bub.errors import BubError from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.interception import ( @@ -20,7 +25,11 @@ ToolCallResult, ) from bub.hooks.runtime import HookRuntime +from bub.prompt import to_content +from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.tape import Tape, TapeContext from bub.tools import Tool, ToolExecutor +from tests.model_fakes import ProviderService, sse def make_hooks(*plugins: Any) -> AgentHooks: @@ -32,7 +41,7 @@ def make_hooks(*plugins: Any) -> AgentHooks: def request() -> LlmCallRequest: - return LlmCallRequest(run_id="run-1", model="openai:gpt-x", messages=[{"role": "user", "content": "hi"}]) + return LlmCallRequest(run_id="run-1", model="openai:gpt-x", messages=[republic.user("hi")]) class TestBeforeLlmCall: @@ -48,14 +57,14 @@ class AppendMessage: def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest: # must see SwapModel's change (registration-order chaining) assert request.model == "anthropic:claude" - return replace(request, messages=[*request.messages, {"role": "user", "content": "extra"}]) + return replace(request, messages=[*request.messages, republic.user("extra")]) # pluggy LIFO: last registered runs first -> register AppendMessage first hooks = make_hooks(AppendMessage(), SwapModel()) result, decision = await hooks.before_llm_call(request(), state={}) assert decision is None assert result.model == "anthropic:claude" - assert result.messages[-1]["content"] == "extra" + assert result.messages[-1].text == "extra" @pytest.mark.asyncio async def test_none_and_bad_returns_leave_request_unchanged(self) -> None: @@ -259,48 +268,45 @@ def before_llm_call(self, request: LlmCallRequest, state: dict) -> None: class TestModelRunnerHookIntegration: """Regression tests for PR #255 review findings (effective request, exactly-once).""" - def _runner_and_tape(self, hooks: AgentHooks, captured: dict): - import bub - from bub.builtin.model_runner import ModelRunner - from bub.builtin.settings import AgentSettings - from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore - from bub.tape import Tape, TapeContext - - class FakeRunner(ModelRunner): - async def completion_response(self, *, model, messages, tools, max_tokens=None, reasoning_effort=None): - captured.update(model=model, max_tokens=max_tokens) - - async def chunks(): - return - yield # pragma: no cover - - return chunks() - - settings = AgentSettings.model_construct(model="openai:orig", max_tokens=100, model_timeout_seconds=None) - runner = FakeRunner(settings, hooks=hooks) - store = AsyncTapeStoreAdapter(InMemoryTapeStore()) - tape = Tape(bub.home / "tapes", store, TapeContext(anchor=None)).scoped("t1") + def _runner_and_tape(self, hooks: AgentHooks, tmp_path: Path, api_format: str | None = None): + runner = ModelRunner( + AgentSettings( + model="openai:orig", max_tokens=100, client_args={"api_format": api_format} if api_format else {} + ), + hooks=hooks, + ) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("t1") return runner, tape @pytest.mark.asyncio - async def test_rewritten_model_and_max_tokens_reach_provider_and_tape(self) -> None: + async def test_rewritten_model_and_max_tokens_reach_provider_and_tape( + self, tmp_path: Path, provider_transport: ProviderService + ) -> None: class Reroute: @hookimpl def before_llm_call(self, request: LlmCallRequest, state: dict) -> LlmCallRequest: return replace(request, model="anthropic:new", max_tokens=42) - captured: dict = {} - runner, tape = self._runner_and_tape(make_hooks(Reroute()), captured) - events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") + provider_transport.reply( + sse([ + {"type": "message_start", "message": {"id": "test", "usage": {}}}, + {"type": "message_stop"}, + ]) + ) + runner, tape = self._runner_and_tape(make_hooks(Reroute()), tmp_path) + events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi")) async for _ in events: pass - assert captured == {"model": "anthropic:new", "max_tokens": 42} + assert provider_transport.body()["model"] == "new" + assert provider_transport.body()["max_tokens"] == 42 entries = list(await tape.store.fetch_all(tape.query().kinds("event"))) run_events = [e for e in entries if e.payload.get("name") == "run"] assert run_events[-1].payload["data"]["model"] == "anthropic:new" @pytest.mark.asyncio - async def test_after_llm_call_not_fired_on_early_close(self) -> None: + async def test_after_llm_call_not_fired_on_early_close( + self, tmp_path: Path, provider_transport: ProviderService + ) -> None: observed: list[LlmCallResult] = [] class Observe: @@ -308,28 +314,19 @@ class Observe: def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: observed.append(result) - captured: dict = {} - runner, tape = self._runner_and_tape(make_hooks(Observe()), captured) - - from bub.builtin.model_runner import ModelRunner # noqa: F401 - - async def fake_events(completion, state, output): - from bub.streaming import StreamEvent + provider_transport.reply_chat("done") + runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat") - yield StreamEvent("text", {"delta": "a"}) - yield StreamEvent("text", {"delta": "b"}) - - runner._completion_events = fake_events # type: ignore[method-assign] - events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") + events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi")) iterator = events.__aiter__() await iterator.__anext__() await iterator.aclose() - # Consumer close is intentionally NOT a terminal observation: - # after_llm_call fires only for real completions and Exception failures. assert observed == [] @pytest.mark.asyncio - async def test_after_llm_call_fires_exactly_once_on_success(self) -> None: + async def test_after_llm_call_fires_exactly_once_on_success( + self, tmp_path: Path, provider_transport: ProviderService + ) -> None: observed: list[LlmCallResult] = [] class Observe: @@ -337,14 +334,69 @@ class Observe: def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: observed.append(result) - captured: dict = {} - runner, tape = self._runner_and_tape(make_hooks(Observe()), captured) - events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt="hi") + provider_transport.reply_chat("done") + runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat") + events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi")) async for _ in events: pass assert len(observed) == 1 assert observed[0].error is None + @pytest.mark.asyncio + async def test_failed_stream_reports_partial_text_once_without_trying_fallback( + self, tmp_path: Path, provider_transport: ProviderService + ) -> None: + observed: list[LlmCallResult] = [] + + class Observe: + @hookimpl + def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: + observed.append(result) + + provider_transport.reply(sse([{"choices": [{"delta": {"content": "partial"}}]}])) + runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat") + runner.settings.fallback_models = ["openai:fallback"] + events = runner.run(tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi")) + with pytest.raises(StreamIncompleteError) as exc: + async for _ in events: + pass + assert len(observed) == 1 + assert observed[0].text == "partial" + assert observed[0].error is exc.value + assert observed[0].usage is None + assert len(provider_transport.requests) == 1 + assert not await tape.read_messages() + + @pytest.mark.asyncio + async def test_refusal_text_reaches_the_hook_final_event_and_tape( + self, tmp_path: Path, provider_transport: ProviderService + ) -> None: + observed: list[LlmCallResult] = [] + + class Observe: + @hookimpl + def after_llm_call(self, request: LlmCallRequest, result: LlmCallResult, state: dict) -> None: + observed.append(result) + + provider_transport.reply( + sse([ + {"choices": [{"delta": {"refusal": "Cannot fulfill this request."}, "finish_reason": "stop"}]}, + "[DONE]", + ]) + ) + runner, tape = self._runner_and_tape(make_hooks(Observe()), tmp_path, "chat") + events = [ + event + async for event in runner.run( + tape=tape, model="openai:orig", tools=[], system_prompt=None, prompt=to_content("hi") + ) + ] + assert events[-1].data == {"ok": True, "text": "Cannot fulfill this request."} + assert len(observed) == 1 + assert observed[0].text == "Cannot fulfill this request." + assert observed[0].error is None + assert (await tape.read_messages())[-1] == republic.assistant("Cannot fulfill this request.") + class TestToolCancellation: @pytest.mark.asyncio diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index 519c1c66..c7d75477 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch import pytest -from any_llm.types.completion import ChatCompletionChunk +import republic import bub.builtin.codemode import bub.builtin.tools # noqa: F401 — registers builtin tools (incl. `model`) @@ -16,23 +16,22 @@ from bub.builtin.settings import AgentSettings from bub.builtin.steering import InMemorySteeringInbox from bub.errors import BubError +from bub.prompt import UserContent from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState from bub.tape import TapeContext from bub.tools import REGISTRY, tool +from tests.model_fakes import ProviderService # --------------------------------------------------------------------------- # Agent.run() tests: merge_back logic and model passthrough # --------------------------------------------------------------------------- -class _FakeModelRunner(ModelRunner): - def __init__(self, settings: AgentSettings) -> None: - super().__init__(settings) - self.completion_kwargs: dict[str, Any] | None = None - - async def completion_response(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: - self.completion_kwargs = kwargs - return _chat_stream("done") +@pytest.fixture(autouse=True) +def model_provider(provider_transport: ProviderService) -> ProviderService: + provider_transport.reply_chat() + provider_transport.reply_chat() + return provider_transport def _make_agent() -> Agent: @@ -43,49 +42,26 @@ def _make_agent() -> Agent: framework.get_system_prompt.return_value = "" framework.get_tape_sidecars.return_value = () - async def build_prompt(message: dict[str, Any], session_id: str, state: dict[str, Any]) -> str: - return str(message["content"]) + async def build_prompt(message: dict[str, Any], session_id: str, state: dict[str, Any]) -> list[UserContent]: + return [str(message["content"])] framework.build_prompt = build_prompt with patch.object(Agent, "__init__", lambda self, fw: None): agent = Agent.__new__(Agent) - agent.settings = AgentSettings.model_construct(model="test:model", api_key="k", api_base="b", client_args={}) + agent.settings = AgentSettings.model_construct( + model="openai:model", api_key="k", api_base=None, client_args={"api_format": "chat"} + ) agent.command_prefix = agent.settings.command_prefix agent.framework = framework agent.tools = REGISTRY.copy() agent.tape_store = None agent.skill_dirs = None - agent.model_runner = _FakeModelRunner(agent.settings) + agent.model_runner = ModelRunner(agent.settings) return agent -def _model_runner(agent: Agent) -> _FakeModelRunner: - assert isinstance(agent.model_runner, _FakeModelRunner) - return agent.model_runner - - -def _chat_chunk(content: str) -> ChatCompletionChunk: - return ChatCompletionChunk.model_validate({ - "id": "chatcmpl_test", - "object": "chat.completion.chunk", - "created": 0, - "model": "test:model", - "choices": [ - { - "index": 0, - "finish_reason": "stop", - "delta": {"role": "assistant", "content": content}, - } - ], - }) - - -async def _chat_stream(content: str) -> AsyncIterator[ChatCompletionChunk]: - yield _chat_chunk(content) - - class _ForkCapture: """Captures fork_tape enter and exit behavior.""" @@ -109,7 +85,7 @@ def __init__(self, fork_capture: _ForkCapture) -> None: self._fork = fork_capture self.name = "test-tape" self.context = TapeContext(state={}) - self.messages: list[dict[str, Any]] = [] + self.messages: list[republic.Message] = [] self.events: list[tuple[str, str, dict[str, Any]]] = [] async def ensure_bootstrap_anchor(self) -> None: @@ -120,7 +96,7 @@ async def fork_tape(self, merge_back: bool = True) -> AsyncGenerator[_FakeTape, async with self._fork.fork_tape(self.name, merge_back=merge_back): yield self - async def read_messages(self) -> list[dict[str, Any]]: + async def read_messages(self) -> list[republic.Message]: return list(self.messages) async def append_event(self, name: str, payload: dict[str, Any], **meta: Any) -> None: @@ -131,13 +107,13 @@ async def record_chat( *, run_id: str, system_prompt: str | None, - new_messages: list[dict[str, Any]], + new_messages: list[republic.Message], response_text: str | None, context_error: BubError | None = None, tool_calls: list[dict[str, Any]] | None = None, tool_results: list[Any] | None = None, error: BubError | None = None, - response: Any | None = None, + assistant_fields: dict[str, Any] | None = None, provider: str | None = None, model: str | None = None, usage: dict[str, Any] | None = None, @@ -154,7 +130,7 @@ async def record_chat( if error is not None and error is not context_error: self.events.append((self.name, "error", error.as_dict())) if response_text is not None: - self.messages.append({"role": "assistant", "content": response_text}) + self.messages.append(republic.assistant(response_text)) self.events.append((self.name, "run", {"run_id": run_id, "model": model, "error": error is not None})) @@ -209,8 +185,10 @@ async def test_agent_run_temp_session_does_not_merge_back() -> None: @pytest.mark.asyncio -async def test_agent_run_passes_model_to_llm() -> None: - """The model parameter should be forwarded to any-llm.""" +async def test_agent_run_passes_model_to_llm( + model_provider: ProviderService, +) -> None: + """The model parameter should be forwarded to Republic.""" agent = _make_agent() fork_capture = _ForkCapture() fake_tapes = _FakeTapeFactory(fork_capture) @@ -224,9 +202,9 @@ async def test_agent_run_passes_model_to_llm() -> None: ) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - assert completion_kwargs["model"] == "openai:gpt-4o" + assert completion_kwargs["model"] == "gpt-4o" @pytest.mark.asyncio @@ -244,8 +222,10 @@ async def test_agent_run_empty_prompt_returns_error() -> None: @pytest.mark.asyncio -async def test_agent_run_model_defaults_to_none() -> None: - """When model is not specified, settings.model is used for any-llm.""" +async def test_agent_run_model_defaults_to_none( + model_provider: ProviderService, +) -> None: + """When model is not specified, settings.model is used for Republic.""" agent = _make_agent() fork_capture = _ForkCapture() fake_tapes = _FakeTapeFactory(fork_capture) @@ -254,9 +234,9 @@ async def test_agent_run_model_defaults_to_none() -> None: result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - assert completion_kwargs["model"] == "test:model" + assert completion_kwargs["model"] == "model" @pytest.mark.asyncio @@ -264,8 +244,8 @@ async def test_agent_run_model_defaults_to_none() -> None: async def test_agent_loop_awaits_continue_prompt_hook_with_stream_state(continuation: str | None) -> None: agent = _make_agent() tape = _FakeTape(_ForkCapture()) - prompts: list[str | list[dict] | None] = [] - continuation_prompts: list[str | list[dict] | None] = [] + prompts: list[list[UserContent] | None] = [] + continuation_prompts: list[list[UserContent] | None] = [] observed_usage: list[dict[str, Any] | None] = [] async def run_once(**kwargs: Any) -> AsyncStreamEvents: @@ -277,7 +257,7 @@ async def iterator() -> AsyncIterator[StreamEvent]: return AsyncStreamEvents(iterator(), state=StreamState(usage={"step": len(prompts)})) - async def continue_prompt(*, prompt: str | list[dict] | None, tape: _FakeTape, state: StreamState) -> str | None: + async def continue_prompt(*, prompt: list[UserContent] | None, tape: _FakeTape, state: StreamState) -> str | None: continuation_prompts.append(prompt) observed_usage.append(state.usage) return continuation @@ -289,23 +269,25 @@ async def continue_prompt(*, prompt: str | list[dict] | None, tape: _FakeTape, s event async for event in agent._stream_events_with_auto_handoff( tape=tape, # type: ignore[arg-type] - prompt="initial prompt", + prompt=["initial prompt"], state=StreamState(), ) ] assert [event.kind for event in events] == ["final", "final"] - assert prompts == ["initial prompt", continuation] - assert continuation_prompts == ["initial prompt"] + assert prompts == [["initial prompt"], None if continuation is None else [continuation]] + assert continuation_prompts == [["initial prompt"]] assert observed_usage == [{"step": 1}] @pytest.mark.asyncio -async def test_agent_run_model_override_does_not_mutate_default() -> None: +async def test_agent_run_model_override_does_not_mutate_default( + model_provider: ProviderService, +) -> None: """A per-call model override must not leak into the agent's configured model. The override is resolved per turn (``model or self.settings.model``) and - forwarded to any-llm; it must never be written back to ``settings.model``. + forwarded to Republic; it must never be written back to ``settings.model``. This is the agent-layer half of the guarantee that a session-scoped model switch (state['model'] -> run_stream(model=...)) cannot bleed across sessions the way a process-global env var would. @@ -323,14 +305,16 @@ async def test_agent_run_model_override_does_not_mutate_default() -> None: ) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - assert completion_kwargs["model"] == "openai:gpt-4o" + assert completion_kwargs["model"] == "gpt-4o" assert agent.settings.model == default_model @pytest.mark.asyncio -async def test_agent_run_injects_steering_messages_once_by_session() -> None: +async def test_agent_run_injects_steering_messages_once_by_session( + model_provider: ProviderService, +) -> None: agent = _make_agent() fork_capture = _ForkCapture() fake_tapes = _FakeTapeFactory(fork_capture) @@ -349,7 +333,7 @@ async def test_agent_run_injects_steering_messages_once_by_session() -> None: result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None completion_messages = completion_kwargs["messages"] assert completion_messages[-3:] == [ @@ -358,16 +342,16 @@ async def test_agent_run_injects_steering_messages_once_by_session() -> None: {"role": "user", "content": "hello"}, ] assert fake_tapes.tape.messages == [ - {"role": "user", "content": "first steer"}, - {"role": "user", "content": "second steer"}, - {"role": "user", "content": "hello"}, - {"role": "assistant", "content": "done"}, + republic.user("first steer"), + republic.user("second steer"), + republic.user("hello"), + republic.assistant("done"), ] result = await agent.run_stream(session_id="user/s1", prompt="again", state={"_runtime_workspace": "/tmp"}) # noqa: S108 [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None completion_messages = completion_kwargs["messages"] assert completion_messages[-1] == {"role": "user", "content": "again"} @@ -375,7 +359,9 @@ async def test_agent_run_injects_steering_messages_once_by_session() -> None: @pytest.mark.asyncio -async def test_agent_run_resolves_allowed_tool_aliases_and_limits_prompt() -> None: +async def test_agent_run_resolves_allowed_tool_aliases_and_limits_prompt( + model_provider: ProviderService, +) -> None: allowed_name = "tests.allowed_agent_tool" denied_name = "tests.denied_agent_tool" REGISTRY.pop(allowed_name, None) @@ -402,16 +388,18 @@ def denied_agent_tool() -> str: ) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - assert [tool.name for tool in completion_kwargs["tools"]] == ["tests_allowed_agent_tool"] + assert [tool["function"]["name"] for tool in completion_kwargs["tools"]] == ["tests_allowed_agent_tool"] system_prompt = completion_kwargs["messages"][0]["content"] assert "tests_allowed_agent_tool" not in system_prompt assert "tests_denied_agent_tool" not in system_prompt @pytest.mark.asyncio -async def test_agent_run_excludes_command_tools() -> None: +async def test_agent_run_excludes_command_tools( + model_provider: ProviderService, +) -> None: visible_name = "tests.visible_agent_tool" internal_name = "tests.internal_agent_tool" REGISTRY.pop(visible_name, None) @@ -437,9 +425,9 @@ def internal_agent_tool() -> str: ) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - assert [tool.name for tool in completion_kwargs["tools"]] == ["tests_visible_agent_tool"] + assert [tool["function"]["name"] for tool in completion_kwargs["tools"]] == ["tests_visible_agent_tool"] system_prompt = completion_kwargs["messages"][0]["content"] assert "tests_visible_agent_tool" not in system_prompt assert "tests_internal_agent_tool" not in system_prompt @@ -503,7 +491,7 @@ async def test_run_command_model_switches_session_model_directly() -> None: @pytest.mark.asyncio @pytest.mark.parametrize("allow_run_code", [True, False]) async def test_code_mode_exposes_preserved_tools_and_run_code_with_stub( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, allow_run_code: bool + model_provider: ProviderService, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, allow_run_code: bool ) -> None: from bub.builtin.codemode import CODE_TOOLS_STATE_KEY, RUN_CODE_TOOL_NAME, run_code @@ -517,9 +505,9 @@ async def test_code_mode_exposes_preserved_tools_and_run_code_with_stub( result = await agent.run_stream(session_id="user/s1", prompt="hello", state=state, allowed_tools=allowed_tools) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - tool_names = [tool.name for tool in completion_kwargs["tools"]] + tool_names = [tool["function"]["name"] for tool in completion_kwargs["tools"]] system_prompt = completion_kwargs["messages"][0]["content"] if not allow_run_code: assert sorted(tool_names) == ["bash", "bash_output", "fs_read", "tape_info"] @@ -541,7 +529,9 @@ async def test_code_mode_exposes_preserved_tools_and_run_code_with_stub( @pytest.mark.asyncio -async def test_run_code_is_hidden_when_session_code_mode_is_off(tmp_path: Path) -> None: +async def test_run_code_is_hidden_when_session_code_mode_is_off( + model_provider: ProviderService, tmp_path: Path +) -> None: from bub.builtin.codemode import RUN_CODE_TOOL_NAME, run_code agent = _make_agent() @@ -551,10 +541,10 @@ async def test_run_code_is_hidden_when_session_code_mode_is_off(tmp_path: Path) result = await agent.run_stream(session_id="user/s1", prompt="hello", state={"_runtime_workspace": str(tmp_path)}) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs + completion_kwargs = model_provider.body() assert completion_kwargs is not None - assert RUN_CODE_TOOL_NAME not in [tool.name for tool in completion_kwargs["tools"]] - assert "tape_info" in [tool.name for tool in completion_kwargs["tools"]] + assert RUN_CODE_TOOL_NAME not in [tool["function"]["name"] for tool in completion_kwargs["tools"]] + assert "tape_info" in [tool["function"]["name"] for tool in completion_kwargs["tools"]] assert "" not in completion_kwargs["messages"][0]["content"] diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py index 6ab2a7f1..a8d50e91 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -13,7 +13,6 @@ from inquirer_textual.common.PromptSettings import PromptSettings from typer.testing import CliRunner -import bub.builtin.auth as auth import bub.builtin.cli as cli import bub.builtin.onboarding as onboarding import bub.configure as configure @@ -21,14 +20,10 @@ from bub.framework import BubFramework from bub.hooks import hookimpl -TEST_ACCESS_TOKEN = "access" # noqa: S105 -TEST_REFRESH_TOKEN = "refresh" # noqa: S105 - @pytest.fixture(autouse=True) def no_model_discovery_network(monkeypatch): monkeypatch.setattr(onboarding, "discover_models", lambda *args, **kwargs: []) - monkeypatch.setattr("bub.builtin.codex_provider.load_openai_codex_oauth_tokens", lambda: None) def _fake_result(answer: Any, command: str | None = "enter") -> InquirerResult[Any]: @@ -543,29 +538,19 @@ def test_onboard_collects_builtin_runtime_config_with_custom_provider(tmp_path: } -def test_login_openai_command_runs_codex_oauth(tmp_path: Path) -> None: - tokens = auth.OpenAICodexOAuthTokens( - access_token=TEST_ACCESS_TOKEN, - refresh_token=TEST_REFRESH_TOKEN, - expires_at=1_900_000_000, - account_id="acct_123", +def test_login_command_reports_the_authenticated_account( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, codex_executable: Path +) -> None: + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "credentials")) + result = CliRunner().invoke( + _create_app(), ["login", "codex", "--executable", str(codex_executable), "--device-auth"] ) - login = patch("bub.builtin.auth.login_openai_codex_oauth", return_value=tokens) - - with login as login_mock: - result = CliRunner().invoke( - _create_app(), - ["login", "openai", "--codex-home", str(tmp_path), "--manual", "--no-browser"], - ) - - assert result.exit_code == 0 + assert result.exit_code == 0, result.output assert "login: ok" in result.stdout - assert "account_id: acct_123" in result.stdout - assert f"auth_file: {tmp_path / 'auth.json'}" in result.stdout - login_mock.assert_called_once() - assert login_mock.call_args.kwargs["codex_home"] == tmp_path - assert login_mock.call_args.kwargs["open_browser"] is False - assert login_mock.call_args.kwargs["prompt_for_redirect"] is auth._prompt_for_codex_redirect + assert "account_id: device-account" in result.stdout + assert "BUB_MODEL=codex:" in result.stdout + assert (tmp_path / "credentials" / "auth.json").exists() + assert "test-token" not in result.stdout def test_login_rejects_unknown_provider() -> None: diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py index 38f28d1c..95ef56e7 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -1,605 +1,237 @@ from __future__ import annotations -import base64 import json -import time -from datetime import UTC, datetime +import os +import sys from pathlib import Path -from types import SimpleNamespace -from typing import Any -from unittest.mock import AsyncMock, MagicMock -import httpx import pytest -from any_llm.constants import LLMProvider -from any_llm.types.completion import CompletionParams -from any_llm.types.responses import ResponsesParams -from openai.types.responses.response_input_param import ResponseInputParam -from openai.types.responses.response_output_text import ResponseOutputText -from pydantic import TypeAdapter, ValidationError +from typer.testing import CliRunner -from bub.builtin.auth import ( - CodexOAuthRefreshError, - OpenAICodexOAuthTokens, - extract_openai_codex_account_id, - load_openai_codex_oauth_tokens, - openai_codex_oauth_resolver, - save_openai_codex_oauth_tokens, -) -from bub.builtin.codex_provider import ( - DEFAULT_CODEX_INCLUDE, - DEFAULT_CODEX_INSTRUCTIONS, - DEFAULT_CODEX_TEXT_CONFIG, - OpenaiCodexProvider, - build_openai_codex_default_headers, - resolve_openai_codex_api_base, - should_use_openai_codex_provider, -) -from bub.builtin.model_runner import ModelOutputAccumulator, ModelRunner -from bub.builtin.settings import AgentSettings, CustomProvider, ModelCandidate +from bub.builtin.auth import app +from bub.builtin.context import default_tape_context +from bub.builtin.model_runner import ModelRunner +from bub.builtin.settings import AgentSettings from bub.channels.message import ChannelMessage, MediaItem from bub.framework import BubFramework +from bub.prompt import to_content +from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.tape import Tape, TapeContext, TapeEntry +from bub.tools import Tool +from tests.model_fakes import ProviderService, sse -TEST_REFRESH_TOKEN = "refresh" # noqa: S105 -TEST_REFRESH_TOKEN_OLD = "refresh_old" # noqa: S105 -TEST_REFRESH_TOKEN_NEW = "refresh_new" # noqa: S105 - -def _jwt_with_account(account_id: str, *, exp: int | None = None) -> str: - header = _b64({"alg": "none"}) - claims: dict[str, Any] = {"https://api.openai.com/auth": {"chatgpt_account_id": account_id}} - if exp is not None: - claims["exp"] = exp - payload = _b64(claims) - return f"{header}.{payload}.sig" - - -def _b64(payload: dict[str, Any]) -> str: - raw = json.dumps(payload, separators=(",", ":")).encode() - return base64.urlsafe_b64encode(raw).decode().rstrip("=") - - -def _write_codex_auth_without_expiry(home: Path, access_token: str, last_refresh: int) -> None: - (home / "auth.json").write_text( - json.dumps({ - "last_refresh": datetime.fromtimestamp(last_refresh, tz=UTC).isoformat(), - "tokens": {"access_token": access_token, "refresh_token": TEST_REFRESH_TOKEN}, - }) - ) - - -def test_openai_codex_oauth_tokens_round_trip(tmp_path: Path) -> None: - tokens = OpenAICodexOAuthTokens( - access_token=_jwt_with_account("acct_123"), - refresh_token=TEST_REFRESH_TOKEN, - expires_at=1_900_000_000, - account_id="acct_123", - ) - - auth_path = save_openai_codex_oauth_tokens(tokens, tmp_path) - loaded = load_openai_codex_oauth_tokens(tmp_path) - - assert auth_path == tmp_path / "auth.json" - assert loaded == tokens - assert auth_path.stat().st_mode & 0o777 == 0o600 - - -def test_openai_codex_oauth_resolver_refreshes_expired_token(tmp_path: Path) -> None: - save_openai_codex_oauth_tokens( - OpenAICodexOAuthTokens( - access_token=_jwt_with_account("acct_old"), - refresh_token=TEST_REFRESH_TOKEN_OLD, - expires_at=int(time.time()) - 1, - account_id="acct_old", - ), - tmp_path, - ) - refreshed = OpenAICodexOAuthTokens( - access_token=_jwt_with_account("acct_new"), - refresh_token=TEST_REFRESH_TOKEN_NEW, - expires_at=int(time.time()) + 3600, - account_id="acct_new", - ) - - resolver = openai_codex_oauth_resolver(tmp_path, refresher=lambda refresh_token: refreshed) - - assert resolver("openai") == refreshed.access_token - assert load_openai_codex_oauth_tokens(tmp_path) == refreshed - - -def test_codex_auth_without_expires_at_uses_access_token_jwt_exp(tmp_path: Path) -> None: - now = int(time.time()) - token = _jwt_with_account("acct_123", exp=now + 10 * 86400) - _write_codex_auth_without_expiry(tmp_path, token, last_refresh=now - 7200) - refresh_calls: list[str] = [] - - def fail_refresh(refresh_token: str) -> OpenAICodexOAuthTokens: - refresh_calls.append(refresh_token) - raise RuntimeError("refresh should not run") - - loaded = load_openai_codex_oauth_tokens(tmp_path) - assert loaded is not None - assert loaded.expires_at == now + 10 * 86400 - assert openai_codex_oauth_resolver(tmp_path, refresher=fail_refresh)("openai") == token - assert refresh_calls == [] - - -@pytest.mark.parametrize( - "expiry_offset, refresh_duration, expected_token", [(60, 0, True), (-60, 0, False), (1, 2, False)] -) -def test_codex_provider_uses_valid_token_or_reports_refresh_failure( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, expiry_offset: int, refresh_duration: int, expected_token: bool +@pytest.mark.asyncio +@pytest.mark.parametrize("content_kind", ["string", "text", "image", "assistant-history"]) +async def test_codex_sends_content_with_file_credentials( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider_service: ProviderService, content_kind: str ) -> None: - now = int(time.time()) - monkeypatch.setattr("bub.builtin.auth.time.time", lambda: now) - token = _jwt_with_account("acct_123", exp=now + expiry_offset) - _write_codex_auth_without_expiry(tmp_path, token, last_refresh=now - 7200) - - def fail_refresh(refresh_token: str, **kwargs: Any) -> OpenAICodexOAuthTokens: - monkeypatch.setattr("bub.builtin.auth.time.time", lambda: now + refresh_duration) - raise RuntimeError("refresh unavailable") - - monkeypatch.setattr("bub.builtin.auth.refresh_openai_codex_oauth_tokens", fail_refresh) - if expected_token: - OpenaiCodexProvider(codex_home=str(tmp_path)) - else: - with pytest.raises(CodexOAuthRefreshError, match="Codex OAuth"): - OpenaiCodexProvider(codex_home=str(tmp_path)) - - -def test_codex_auth_explicit_expiry_precedes_jwt_exp_and_missing_claim_stays_unknown(tmp_path: Path) -> None: - now = int(time.time()) - token = _jwt_with_account("acct_123", exp=now + 10 * 86400) - _write_codex_auth_without_expiry(tmp_path, token, last_refresh=now - 7200) - auth_path = tmp_path / "auth.json" - payload = json.loads(auth_path.read_text()) - payload["tokens"]["expires_at"] = now + 3600 - auth_path.write_text(json.dumps(payload)) - loaded = load_openai_codex_oauth_tokens(tmp_path) - assert loaded is not None - assert loaded.expires_at == now + 3600 - - for fallback_token in (_jwt_with_account("acct_123"), "not-a-jwt"): - _write_codex_auth_without_expiry(tmp_path, fallback_token, last_refresh=now - 7200) - loaded = load_openai_codex_oauth_tokens(tmp_path) - assert loaded is not None - assert loaded.expires_at is None - refresher = MagicMock(side_effect=RuntimeError("refresh should not run")) - assert openai_codex_oauth_resolver(tmp_path, refresher=refresher)("openai") == fallback_token - refresher.assert_not_called() - - -def test_codex_auth_uses_valid_token_when_expiry_metadata_is_invalid(tmp_path: Path) -> None: - now = int(time.time()) - token = _jwt_with_account("acct_123", exp=now + 86400) - _write_codex_auth_without_expiry(tmp_path, token, last_refresh=now - 7200) - auth_path = tmp_path / "auth.json" - payload = json.loads(auth_path.read_text()) - payload["tokens"]["expires_at"] = "invalid" - auth_path.write_text(json.dumps(payload)) - - refresher = MagicMock(side_effect=RuntimeError("refresh should not run")) - assert openai_codex_oauth_resolver(tmp_path, refresher=refresher)("openai") == token - refresher.assert_not_called() - - -def test_extract_openai_codex_account_id() -> None: - assert extract_openai_codex_account_id(_jwt_with_account("acct_123")) == "acct_123" - assert extract_openai_codex_account_id("not-a-jwt") is None - - -def test_codex_provider_selection_requires_oauth_file_or_oauth_token(monkeypatch) -> None: - monkeypatch.setattr( - "bub.builtin.codex_provider.load_openai_codex_oauth_tokens", - lambda: OpenAICodexOAuthTokens( - access_token=_jwt_with_account("acct_123"), - refresh_token=TEST_REFRESH_TOKEN, - expires_at=1_900_000_000, - ), - ) - - assert should_use_openai_codex_provider("openai", "gpt-5.5", api_key=None, api_base=None) is True - assert ( - should_use_openai_codex_provider("openai", "gpt-4o", api_key=_jwt_with_account("acct_123"), api_base=None) - is True + monkeypatch.setenv("CODEX_HOME", str(tmp_path)) + (tmp_path / "auth.json").write_text( + json.dumps({"tokens": {"access_token": "test-token", "account_id": "test-account"}}) ) - assert should_use_openai_codex_provider("openai", "gpt-5-codex", api_key="sk-test", api_base=None) is False - assert should_use_openai_codex_provider("openai", "gpt-5-codex", api_key=None, api_base="https://api.test") is False - - -def test_codex_provider_selection_uses_normal_openai_without_oauth(monkeypatch) -> None: - monkeypatch.setattr("bub.builtin.codex_provider.load_openai_codex_oauth_tokens", lambda: None) - - assert should_use_openai_codex_provider("openai", "gpt-5.5", api_key=None, api_base=None) is False - - -def test_model_runner_creates_codex_provider_for_codex_model(monkeypatch) -> None: - fake_provider = MagicMock() - provider_class = MagicMock(return_value=fake_provider) - monkeypatch.setattr("bub.builtin.model_runner.OpenaiCodexProvider", provider_class) - monkeypatch.setattr( - "bub.builtin.codex_provider.load_openai_codex_oauth_tokens", - lambda: OpenAICodexOAuthTokens( - access_token=_jwt_with_account("acct_123"), - refresh_token=TEST_REFRESH_TOKEN, - expires_at=1_900_000_000, - ), - ) - candidate = ModelCandidate(provider=LLMProvider.OPENAI, model_id="gpt-5.5", name="openai:gpt-5.5") - - client = ModelRunner.create_llm_client(candidate, {"api_key": None, "api_base": None}) - - assert client is fake_provider - provider_class.assert_called_once_with(api_key=None, api_base=None) - - -def test_custom_openai_provider_leaves_codex_oauth_in_place(monkeypatch) -> None: - codex_class = MagicMock(return_value="codex") - create = MagicMock(return_value="relay") - monkeypatch.setattr("bub.builtin.model_runner.OpenaiCodexProvider", codex_class) - monkeypatch.setattr("bub.builtin.model_runner.AnyLLM.create", create) - monkeypatch.setattr( - "bub.builtin.codex_provider.load_openai_codex_oauth_tokens", - lambda: OpenAICodexOAuthTokens( - access_token=_jwt_with_account("acct_123"), - refresh_token=TEST_REFRESH_TOKEN, - expires_at=1_900_000_000, - ), - ) - settings = AgentSettings.model_construct( - model="relay:qwen/kimi-k3", - fallback_models=["openai:gpt-5.5"], - api_key=None, - api_base=None, - client_args={}, - providers={"relay": CustomProvider(type=LLMProvider.OPENAI, api_base="https://relay.test/v1", api_key="k")}, - ) - - clients = [client for _, client in ModelRunner(settings).iter_llm_clients("relay:qwen/kimi-k3")] - - assert clients == ["relay", "codex"] - create.assert_called_once_with(LLMProvider.OPENAI, api_key="k", api_base="https://relay.test/v1") - codex_class.assert_called_once_with(api_key=None, api_base=None) - - -def test_codex_provider_adds_response_defaults() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - params = ResponsesParams(model="gpt-5-codex", input="hello", stream=True, text={"format": {"type": "text"}}) - - prepared = provider._with_codex_response_defaults(params) - - assert prepared.store is False - assert prepared.instructions == DEFAULT_CODEX_INSTRUCTIONS - assert prepared.include == DEFAULT_CODEX_INCLUDE - assert prepared.text == {**DEFAULT_CODEX_TEXT_CONFIG, "format": {"type": "text"}} - - -def test_codex_provider_preserves_explicit_response_options() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - params = ResponsesParams( - model="gpt-5-codex", - input="hello", - instructions="custom", - include=[], - store=True, - text={"verbosity": "low"}, - ) - - prepared = provider._with_codex_response_defaults(params) - - assert prepared.store is True - assert prepared.instructions == "custom" - assert prepared.include == [] - assert prepared.text == {**DEFAULT_CODEX_TEXT_CONFIG, "verbosity": "low"} - - -def test_codex_completion_params_use_official_responses_payload_fields() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - params = CompletionParams( - model_id="gpt-5.5", - messages=[{"role": "user", "content": "hello"}], - max_tokens=100, - temperature=0.2, - top_p=0.9, - presence_penalty=0.1, - frequency_penalty=0.1, - user="user_123", - stream=True, - stream_options={"include_usage": True}, - ) - - responses_params = provider._completion_params_to_responses_params(params) - payload = responses_params.model_dump(exclude_none=True, exclude={"response_format"}) - - assert payload == { - "model": "gpt-5.5", - "input": [{"role": "user", "content": "hello"}], - "stream": True, - } - - -def test_codex_completion_params_convert_chat_tool_messages_to_responses_items() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - params = CompletionParams( - model_id="gpt-5.5", - messages=[ - {"role": "user", "content": "run bash"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": {"name": "bash", "arguments": '{"command":"pwd"}'}, - } - ], - }, - {"role": "tool", "tool_call_id": "call_1", "name": "bash", "content": "workspace"}, - ], - stream=True, - ) - - responses_params = provider._completion_params_to_responses_params(params) - payload = responses_params.model_dump(exclude_none=True, exclude={"response_format"}) - - assert payload == { - "model": "gpt-5.5", - "input": [ - {"role": "user", "content": "run bash"}, - { - "type": "function_call", - "call_id": "call_1", - "name": "bash", - "arguments": '{"command":"pwd"}', - "status": "completed", - }, - {"type": "function_call_output", "call_id": "call_1", "output": "workspace"}, - ], - "stream": True, - } - - -def test_codex_provider_resolves_codex_api_base_and_headers() -> None: - token = _jwt_with_account("acct_123") - - assert resolve_openai_codex_api_base(None) == "https://chatgpt.com/backend-api/codex" - assert resolve_openai_codex_api_base("https://example.test/responses") == "https://example.test/codex" - assert build_openai_codex_default_headers(token) == { - "chatgpt-account-id": "acct_123", - "OpenAI-Beta": "responses=experimental", - "originator": "bub", - } - - -def _validate_codex_input(items: list[dict[str, Any]]) -> None: - for item in items: - if item.get("role") == "assistant": - # Assistant history requires output_text, as the real endpoint does. - for part in item["content"]: - ResponseOutputText.model_validate({"annotations": [], **part}) - else: - TypeAdapter(ResponseInputParam).validate_python([item]) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("content_kind", ["string", "text", "image", "file", "assistant-history"]) -async def test_codex_completion_accepts_chat_content_without_bad_request(content_kind: str, tmp_path: Path) -> None: framework = BubFramework(config_file=tmp_path / "config.yml") framework.load_builtin_hooks() - message = ChannelMessage(session_id="content-test", channel="cli", content="Reply exactly BUB_READY.") + message = ChannelMessage(session_id="content", channel="cli", content="Reply BUB_READY.") if content_kind == "image": - message.media = [MediaItem(type="image", mime_type="image/png", url="https://example.test/image.png")] - content = await framework.build_prompt(message, message.session_id, {}) + message.media = [MediaItem("image", "image/png", url="https://example.test/image.png")] + prompt = await framework.build_prompt(message, message.session_id, {}) if content_kind == "text": - content = [{"type": "text", "text": message.content}] - elif content_kind == "file": - content = [{"type": "text", "text": message.content}, {"type": "file", "file": {"file_id": "file-test"}}] - messages = [{"role": "user", "content": content}] - if content_kind == "assistant-history": - messages = [ - { - "role": "assistant", - "content": [{"type": "text", "text": "Checking the previous request."}], - "tool_calls": [ - {"id": "call_test", "type": "function", "function": {"name": "check", "arguments": "{}"}} - ], - }, - {"role": "tool", "tool_call_id": "call_test", "content": "Ready"}, - *messages, - ] - - def respond(request: httpx.Request) -> httpx.Response: - payload = json.loads(request.content) - try: - _validate_codex_input(payload["input"]) - except ValidationError: - # Reproduce the endpoint's rejection of Chat Completions content parts. - return httpx.Response( - 400, json={"error": {"message": "Invalid Responses content", "type": "invalid_request_error"}} + prompt = [{"type": "text", "text": message.content}] + provider_service.reply( + sse([ + {"type": "response.output_text.delta", "delta": "BUB_READY"}, + {"type": "response.completed", "response": {"status": "completed"}}, + ]) + ) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("content") + await tape.ensure_bootstrap_anchor() + if content_kind == "assistant-history": + await tape.store.append( + tape.name, + TapeEntry.message({ + "role": "assistant", + "content": [{"type": "text", "text": "Checking."}], + "tool_calls": [{"id": "call-1", "name": "check", "arguments": "{}"}], + }), + ) + await tape.store.append( + tape.name, TapeEntry.message({"role": "tool", "tool_call_id": "call-1", "content": "Ready"}) ) events = [ - {"type": "response.output_text.delta", "delta": "BUB_READY"}, - {"type": "response.completed", "response": {"id": "resp_test", "model": "gpt-5.5"}}, + event + async for event in runner.run( + tape=tape, model="codex:test", tools=[], system_prompt=None, prompt=to_content(prompt) + ) ] - return httpx.Response( - 200, - headers={"content-type": "text/event-stream"}, - text="".join(f"data: {json.dumps(event)}\n\n" for event in events), - ) - - async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123"), http_client=http_client) - stream = await provider.acompletion(model="gpt-5.5", messages=messages, stream=True) - answer = "".join([chunk.choices[0].delta.content or "" async for chunk in stream if chunk.choices]) - - assert answer == "BUB_READY" - - -async def _codex_response_events(): - yield SimpleNamespace(type="response.output_text.delta", delta="hel") - yield SimpleNamespace(type="response.output_text.delta", delta="lo") - yield SimpleNamespace( - type="response.completed", - response=SimpleNamespace( - id="resp_123", - created_at=1, - model="gpt-5-codex", - usage=SimpleNamespace( - input_tokens=3, - output_tokens=2, - total_tokens=5, - input_tokens_details={"cached_tokens": 2}, - ), - ), - ) - - -@pytest.mark.asyncio -async def test_codex_completion_stream_maps_response_events_to_completion_chunks() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - provider._aresponses = AsyncMock(return_value=_codex_response_events()) # type: ignore[method-assign] - params = CompletionParams( - model_id="gpt-5-codex", - messages=[{"role": "user", "content": "hello"}], - stream=True, - ) - - completion = await provider._acompletion(params) - chunks = [chunk async for chunk in completion] - - assert [chunk.choices[0].delta.content for chunk in chunks[:2]] == ["hel", "lo"] - assert chunks[-1].choices[0].finish_reason == "stop" - assert chunks[-1].usage is not None - assert chunks[-1].usage.prompt_tokens == 3 - assert chunks[-1].usage.completion_tokens == 2 - assert chunks[-1].usage.prompt_tokens_details is not None - assert chunks[-1].usage.prompt_tokens_details.cached_tokens == 2 - - -async def _codex_tool_response_events(): - yield SimpleNamespace( - type="response.output_item.added", - output_index=0, - item=SimpleNamespace(type="function_call", id="fc_1", call_id="call_1", name="bash", arguments=""), - ) - yield SimpleNamespace(type="response.function_call_arguments.delta", output_index=0, delta='{"command":') - yield SimpleNamespace(type="response.function_call_arguments.delta", output_index=0, delta='"pwd"}') - yield SimpleNamespace( - type="response.completed", - response=SimpleNamespace(id="resp_123", created_at=1, model="gpt-5-codex", usage=None), - ) - - -@pytest.mark.asyncio -async def test_codex_completion_stream_maps_response_tool_calls_to_completion_chunks() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - provider._aresponses = AsyncMock(return_value=_codex_tool_response_events()) # type: ignore[method-assign] - params = CompletionParams( - model_id="gpt-5-codex", - messages=[{"role": "user", "content": "hello"}], - stream=True, - ) - - completion = await provider._acompletion(params) - chunks = [chunk async for chunk in completion] - - first_tool_delta = chunks[0].choices[0].delta.tool_calls[0] - assert first_tool_delta.id == "call_1" - assert first_tool_delta.function.name == "bash" - assert "".join(chunk.choices[0].delta.tool_calls[0].function.arguments or "" for chunk in chunks[1:3]) == ( - '{"command":"pwd"}' - ) - assert chunks[-1].choices[0].finish_reason == "tool_calls" - - -async def _codex_custom_tool_response_events(): - yield SimpleNamespace( - type="response.output_item.added", - output_index=1, - item=SimpleNamespace(type="custom_tool_call", id="ctc_1", call_id="call_1", name="bash", input=""), - ) - yield SimpleNamespace( - type="response.custom_tool_call_input.delta", item_id="ctc_1", call_id="call_1", delta='{"command":' - ) - yield SimpleNamespace( - type="response.custom_tool_call_input.delta", item_id="ctc_1", call_id="call_1", delta='"pwd"}' - ) - yield SimpleNamespace( - type="response.completed", - response=SimpleNamespace(id="resp_123", created_at=1, model="gpt-5-codex", usage=None), - ) + request = provider_service.requests[0] + body = provider_service.body() + assert str(request.url) == "https://chatgpt.com/backend-api/codex/responses" + assert request.headers["authorization"] == "Bearer test-token" + assert request.headers["ChatGPT-Account-Id"] == "test-account" + assert body["store"] is False + assert body["stream"] is True + assert "max_output_tokens" not in body + assert body["include"] == ["reasoning.encrypted_content"] + assert body["input"][-1]["content"][0]["type"] == "input_text" + if content_kind == "image": + assert body["input"][-1]["content"][-1] == { + "type": "input_image", + "image_url": "https://example.test/image.png", + } + if content_kind == "assistant-history": + assert body["input"][1]["type"] == "function_call" + assert body["input"][2]["type"] == "function_call_output" + assert events[-1].data == {"ok": True, "text": "BUB_READY"} @pytest.mark.asyncio -async def test_codex_completion_stream_maps_custom_tool_call_input_deltas_to_completion_chunks() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - provider._aresponses = AsyncMock(return_value=_codex_custom_tool_response_events()) # type: ignore[method-assign] - params = CompletionParams( - model_id="gpt-5-codex", - messages=[{"role": "user", "content": "hello"}], - stream=True, - ) - - completion = await provider._acompletion(params) - chunks = [chunk async for chunk in completion] - - first_tool_delta = chunks[0].choices[0].delta.tool_calls[0] - assert first_tool_delta.index == 1 - assert first_tool_delta.id == "call_1" - assert first_tool_delta.function.name == "bash" - assert "".join(chunk.choices[0].delta.tool_calls[0].function.arguments or "" for chunk in chunks[1:3]) == ( - '{"command":"pwd"}' - ) - assert chunks[-1].choices[0].finish_reason == "tool_calls" - - -async def _codex_tool_done_name_null_response_events(): - yield SimpleNamespace( - type="response.function_call_arguments.done", - item_id="fc_1", - output_index=0, - name=None, - arguments='{"message":"hello"}', - ) - yield SimpleNamespace( - type="response.output_item.done", - output_index=0, - item=SimpleNamespace( - type="function_call", - id="fc_1", - call_id="call_1", - name="echo", - arguments='{"message":"hello"}', - status="completed", - ), - ) - yield SimpleNamespace( - type="response.completed", - response=SimpleNamespace(id="resp_123", created_at=1, model="gpt-5-codex", usage=None), - ) +async def test_codex_replays_encrypted_reasoning_and_tool_results_after_reload( + tmp_path: Path, provider_service: ProviderService +) -> None: + reasoning = {"type": "reasoning", "id": "reason-1", "encrypted_content": "opaque", "summary": []} + provider_service.reply( + sse([ + {"type": "response.output_item.done", "item": reasoning}, + { + "type": "response.output_item.done", + "item": { + "type": "function_call", + "id": "fc-1", + "call_id": "call-1", + "name": "echo", + "arguments": "{}", + }, + }, + {"type": "response.completed", "response": {"status": "completed"}}, + ]) + ) + provider_service.reply( + sse([ + {"type": "response.output_text.delta", "delta": "done"}, + {"type": "response.completed", "response": {"status": "completed"}}, + ]) + ) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(api_key="test", client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("codex") + await tape.ensure_bootstrap_anchor() + tools = [Tool(name="echo", handler=lambda: "echoed")] + _ = [ + event + async for event in runner.run( + tape=tape, model="codex:test", tools=tools, system_prompt=None, prompt=to_content("echo") + ) + ] + reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( + "codex" + ) + events = [ + event + async for event in runner.run( + tape=reopened, model="codex:test", tools=tools, system_prompt=None, prompt=None + ) + ] + assert provider_service.body()["input"][1:] == [ + reasoning, + {"type": "function_call", "call_id": "call-1", "name": "echo", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call-1", "output": "echoed"}, + ] + assert events[-1].data == {"ok": True, "text": "done"} @pytest.mark.asyncio -async def test_codex_completion_stream_keeps_tool_name_when_arguments_done_name_is_null() -> None: - provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) - provider._aresponses = AsyncMock(return_value=_codex_tool_done_name_null_response_events()) # type: ignore[method-assign] - params = CompletionParams( - model_id="gpt-5-codex", - messages=[{"role": "user", "content": "call echo"}], - stream=True, - ) - - completion = await provider._acompletion(params) - output = ModelOutputAccumulator() - chunks = [chunk async for chunk in completion] - for chunk in chunks: - tool_calls = chunk.choices[0].delta.tool_calls - if tool_calls: - output.merge_delta_tool_calls(tool_calls) - - tool_call = output.tool_calls[0] - assert tool_call.id == "call_1" - assert tool_call.function.name == "echo" - assert tool_call.function.arguments == '{"message":"hello"}' - assert chunks[-1].choices[0].finish_reason == "tool_calls" +async def test_named_endpoint_falls_back_to_codex_with_its_own_credentials( + tmp_path: Path, provider_service: ProviderService +) -> None: + import httpx2 + + provider_service.reply(httpx2.Response(503, json={"error": {"message": "unavailable"}})) + provider_service.reply( + sse([ + {"type": "response.output_text.delta", "delta": "ready"}, + {"type": "response.completed", "response": {"status": "completed"}}, + ]) + ) + async with provider_service.client() as client: + runner = ModelRunner( + AgentSettings( + model="relay:sample", + fallback_models=["codex:sample"], + api_key={"codex": "codex-key"}, + providers={"relay": {"type": "openai", "api_key": "relay-key", "api_base": "https://relay.test/v1"}}, + client_args={"http_client": client, "max_retries": 0}, + ) + ) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("fallback") + events = [ + event + async for event in runner.run( + tape=tape, model="relay:sample", tools=[], system_prompt=None, prompt=to_content("hello") + ) + ] + assert [str(request.url) for request in provider_service.requests] == [ + "https://relay.test/v1/responses", + "https://chatgpt.com/backend-api/codex/responses", + ] + assert [request.headers["authorization"] for request in provider_service.requests] == [ + "Bearer relay-key", + "Bearer codex-key", + ] + assert events[-1].data == {"ok": True, "text": "ready"} + + +@pytest.mark.parametrize("device_auth", [False, True]) +@pytest.mark.parametrize("directory_option", [False, True]) +def test_login_stores_credentials_in_the_selected_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, codex_executable: Path, device_auth: bool, directory_option: bool +) -> None: + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "default-home")) + directory = tmp_path / "selected-home" if directory_option else tmp_path / "default-home" + args = ["codex", "--executable", str(codex_executable)] + if directory_option: + args.extend(["--codex-home", str(directory)]) + if device_auth: + args.append("--device-auth") + result = CliRunner().invoke(app, args) + assert result.exit_code == 0, result.output + account = "device-account" if device_auth else "browser-account" + assert f"account_id: {account}" in result.output + assert json.loads((directory / "auth.json").read_text())["tokens"]["account_id"] == account + assert "test-token" not in result.output + assert os.environ["CODEX_HOME"] == str(tmp_path / "default-home") + if directory_option: + assert not (tmp_path / "default-home" / "auth.json").exists() + + +@pytest.mark.parametrize("directory_option", [False, True]) +def test_login_reports_a_missing_executable(tmp_path: Path, directory_option: bool) -> None: + args = ["codex", "--executable", str(tmp_path / "missing")] + if directory_option: + args.extend(["--codex-home", str(tmp_path)]) + result = CliRunner().invoke(app, args) + assert result.exit_code == 1 + assert "Cannot start" in result.output + + +@pytest.mark.parametrize("directory_option", [False, True]) +def test_failed_login_does_not_report_saved_credentials_as_success( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, directory_option: bool +) -> None: + monkeypatch.setenv("CODEX_HOME", str(tmp_path)) + (tmp_path / "auth.json").write_text( + json.dumps({"tokens": {"access_token": "test-token", "account_id": "saved-account"}}) + ) + executable = tmp_path / "failed-codex" + executable.write_text(f"#!{sys.executable}\nimport sys\nsys.exit(2)\n") + executable.chmod(0o755) + args = ["codex", "--executable", str(executable)] + if directory_option: + args.extend(["--codex-home", str(tmp_path)]) + result = CliRunner().invoke(app, args) + assert result.exit_code == 1 + assert "login: failed" in result.output + assert "saved-account" not in result.output + assert "login: ok" not in result.output diff --git a/tests/test_builtin_hook_impl.py b/tests/test_builtin_hook_impl.py index a2cc26f6..ba85e663 100644 --- a/tests/test_builtin_hook_impl.py +++ b/tests/test_builtin_hook_impl.py @@ -207,9 +207,9 @@ async def test_build_prompt_marks_commands_and_prefixes_context(tmp_path: Path) command_prompt = await impl.build_prompt(command, session_id="s", state={}) normal_prompt = await impl.build_prompt(normal, session_id="s", state={}) - assert command_prompt == ",help" + assert command_prompt == [",help"] assert command.kind == "command" - prompt_lines = normal_prompt.splitlines() + prompt_lines = normal_prompt[0].splitlines() assert prompt_lines[0] == normal.context_str assert prompt_lines[2] == "hello" @@ -230,7 +230,7 @@ async def test_build_prompt_uses_system_timezone_for_context_date( prompt = await impl.build_prompt(message, session_id="s", state={}) - date_line = prompt.splitlines()[1] + date_line = prompt[0].splitlines()[1] assert date_line.startswith("---Date: ") assert date_line.endswith("+08:00---") finally: diff --git a/tests/test_builtin_model_runner.py b/tests/test_builtin_model_runner.py index 6fee8704..85a15bfd 100644 --- a/tests/test_builtin_model_runner.py +++ b/tests/test_builtin_model_runner.py @@ -1,405 +1,465 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Iterator +import json +from contextlib import nullcontext from pathlib import Path -from typing import Any +import httpx2 import pytest -from any_llm.constants import LLMProvider -from any_llm.providers.anthropic.base import BaseAnthropicProvider -from any_llm.providers.openai.base import BaseOpenAIProvider -from any_llm.types.completion import ( - ChatCompletion, - ChatCompletionChunk, - ChatCompletionMessageFunctionToolCall, - Function, -) -from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall, Custom +import republic +from republic.errors import APIStatusError, StreamIncompleteError from bub.builtin.context import default_tape_context -from bub.builtin.model_runner import ( - ModelRunner, - _adapt_messages_for_provider, - parse_native_function_call, - tool_invocation_from_native, -) -from bub.builtin.settings import AgentSettings, ModelCandidate +from bub.builtin.model_runner import ModelRunner +from bub.builtin.settings import AgentSettings from bub.errors import BubError, ErrorKind -from bub.store import FileTapeStore -from bub.tape import AsyncTapeStoreAdapter, InMemoryTapeStore, Tape, TapeContext -from bub.tools import Tool, ToolExecutor +from bub.prompt import to_content +from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.tape import Tape, TapeContext, TapeEntry +from bub.tools import Tool +from tests.model_fakes import ProviderService, chat_events, sse, tool_events -@pytest.mark.parametrize("provider", [LLMProvider.GEMINI, LLMProvider.VERTEXAI]) -def test_adapt_messages_converts_video_url_for_google_providers(provider: LLMProvider) -> None: - messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "describe this video"}, - {"type": "video_url", "video_url": {"url": "data:video/mp4;base64,dmlkZW8="}}, - {"type": "input_audio", "input_audio": {"data": "YXVkaW8=", "format": "ogg"}}, - ], - } - ] - - result = _adapt_messages_for_provider(messages, provider) - - assert result == [ - { - "role": "user", - "content": [ - {"type": "text", "text": "describe this video"}, - {"type": "file", "file": {"file_data": "data:video/mp4;base64,dmlkZW8="}}, - {"type": "file", "file": {"file_data": "data:audio/ogg;base64,YXVkaW8="}}, - ], - } - ] - assert messages[0]["content"][1]["type"] == "video_url" - - -def test_adapt_messages_keeps_native_multimodal_blocks_for_openrouter() -> None: - messages = [ - { - "role": "user", - "content": [ - {"type": "video_url", "video_url": {"url": "data:video/mp4;base64,dmlkZW8="}}, - {"type": "input_audio", "input_audio": {"data": "YXVkaW8=", "format": "ogg"}}, - ], - } - ] - - assert _adapt_messages_for_provider(messages, LLMProvider.OPENROUTER) is messages +@pytest.mark.asyncio +@pytest.mark.parametrize("outcome", ["success", "http_error", "incomplete"]) +async def test_model_call_closes_its_owned_client( + tmp_path: Path, provider_service: ProviderService, monkeypatch: pytest.MonkeyPatch, outcome: str +) -> None: + clients = [] + constructor = httpx2.AsyncClient + + def create_client(**kwargs): + client = constructor(**kwargs, transport=httpx2.MockTransport(provider_service._respond)) + clients.append(client) + return client + + monkeypatch.setattr(httpx2, "AsyncClient", create_client) + error = None + if outcome == "http_error": + provider_service.reply(httpx2.Response(401, json={"error": "unauthorized"})) + error = APIStatusError + else: + provider_service.reply(sse(chat_events() if outcome == "success" else chat_events()[:-1])) + if outcome == "incomplete": + error = StreamIncompleteError + runner = ModelRunner(AgentSettings(client_args={"api_format": "chat", "max_retries": 0})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("owned") + with pytest.raises(error) if error is not None else nullcontext(): + events = [ + event + async for event in runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt="hello") + ] + assert events[-1].data == {"ok": True, "text": "done"} + assert len(clients) == 1 + assert clients[0].is_closed @pytest.mark.asyncio -async def test_unknown_tool_placeholder_surfaces_error_without_hooks() -> None: - tool_call = ChatCompletionMessageFunctionToolCall( - id="call-1", - type="function", - function=Function(name="missing_tool", arguments="{}"), +async def test_unknown_tool_returns_an_error_result(tmp_path: Path, provider_service: ProviderService) -> None: + provider_service.reply( + sse( + tool_events([ + {"index": 0, "id": "call-1", "function": {"name": "missing_tool", "arguments": "{}"}}, + ]) + ) ) - invocation = tool_invocation_from_native(tool_call, {}) - - execution = await ToolExecutor().execute_async([invocation]) - - assert execution.error is not None - assert "missing_tool" in execution.error.message + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("unknown") + events = [ + event + async for event in runner.run( + tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("Run the tool.") + ) + ] + result = next(event.data["tool_results"][0] for event in events if event.kind == "tool_result") + assert result["kind"] == "tool" + assert "missing_tool" in result["message"] + assert events[-1].kind == "final" @pytest.mark.asyncio -@pytest.mark.parametrize("streaming", [False, True]) -@pytest.mark.parametrize("content", [None, "", "Check byte equality.\nOnly exact equality counts."]) +@pytest.mark.parametrize("content", ["", "Check byte equality.\nOnly exact equality counts."]) @pytest.mark.parametrize("continuation_prompt", ["Continue.", "", None]) -async def test_tool_call_text_survives_into_next_request_after_tape_reload( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - streaming: bool, - content: str | None, - continuation_prompt: str | None, +async def test_tool_turn_replays_after_tape_reload( + tmp_path: Path, provider_service: ProviderService, content: str, continuation_prompt: str | None ) -> None: - calls = [ - {"id": f"call-{name}", "type": "function", "function": {"name": name, "arguments": "{}"}} - for name in ("inspect", "compare") + provider_service.reply( + sse( + tool_events( + [ + {"index": index, "id": f"call-{name}", "function": {"name": name, "arguments": "{}"}} + for index, name in enumerate(("inspect", "compare")) + ], + text=content, + ) + ) + ) + provider_service.reply(sse(chat_events("Compared."))) + tools = [Tool(name="inspect", handler=lambda: "files found"), Tool(name="compare", handler=lambda: "bytes differ")] + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + root = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("turn") + async with root.fork_tape() as tape: + await tape.ensure_bootstrap_anchor() + events = [ + event + async for event in runner.run( + tape=tape, + model="openai:test", + tools=tools, + system_prompt=None, + prompt=to_content("Compare the outputs."), + ) + ] + reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("turn") + follow_up = [ + event + async for event in runner.run( + tape=reopened, + model="openai:test", + tools=tools, + system_prompt=None, + prompt=None if continuation_prompt is None else to_content(continuation_prompt), + ) + ] + assert next(event.data["tool_results"] for event in events if event.kind == "tool_result") == [ + "files found", + "bytes differ", ] - response = BaseOpenAIProvider._convert_completion_response({ - "id": "completion-1", - "object": "chat.completion", - "created": 0, - "model": "test-model", - "choices": [ - { - "index": 0, - "finish_reason": "tool_calls", - "message": {"role": "assistant", "content": content, "tool_calls": calls}, - } - ], - }) - - async def stream() -> AsyncIterator[ChatCompletionChunk]: - deltas = [{"content": part} for part in (content or "").splitlines(keepends=True)] - deltas.append({"tool_calls": [{"index": index, **call} for index, call in enumerate(calls)]}) - for delta in deltas: - yield ChatCompletionChunk.model_validate({ - "id": "completion-1", - "object": "chat.completion.chunk", - "created": 0, - "model": "test-model", - "choices": [{"index": 0, "delta": delta}], - }) - - requests: list[list[dict[str, Any]]] = [] + messages = provider_service.body()["messages"] + users = [message["content"] for message in messages if message["role"] == "user"] + assert users == ["Compare the outputs.", *([continuation_prompt] if continuation_prompt is not None else [])] + assistant = next(message for message in messages if message["role"] == "assistant") + assert (assistant.get("content") or "") == content + assert {call["id"]: call["function"]["name"] for call in assistant["tool_calls"]} == { + "call-inspect": "inspect", + "call-compare": "compare", + } + assert {message["tool_call_id"]: message["content"] for message in messages if message["role"] == "tool"} == { + "call-inspect": "files found", + "call-compare": "bytes differ", + } + assert follow_up[-1].data == {"ok": True, "text": "Compared."} - async def complete(**kwargs: Any) -> ChatCompletion | AsyncIterator[ChatCompletionChunk]: - requests.append(kwargs["messages"]) - return stream() if streaming else response - runner = ModelRunner(AgentSettings.model_construct(model="test-model", model_timeout_seconds=None)) - monkeypatch.setattr(runner, "completion_response", complete) - tools = [Tool(name="inspect", handler=lambda: "files found"), Tool(name="compare", handler=lambda: "bytes differ")] - root = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("test-tape") - async with root.fork_tape() as tape: +@pytest.mark.asyncio +async def test_continuation_sends_steering_without_a_prompt(tmp_path: Path, provider_service: ProviderService) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("steering") await tape.ensure_bootstrap_anchor() events = [ event async for event in runner.run( - tape=tape, model="test-model", tools=tools, system_prompt=None, prompt="Compare the outputs." + tape=tape, + model="openai:test", + tools=[], + system_prompt=None, + prompt=None, + steering_messages=[["new user direction"]], ) ] + assert provider_service.body()["messages"] == [{"role": "user", "content": "new user direction"}] + assert events[-1].data == {"ok": True, "text": "done"} + assert republic.user("new user direction") in await tape.read_messages() - reloaded = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( - "test-tape" - ) - async for _ in runner.run( - tape=reloaded, model="test-model", tools=tools, system_prompt=None, prompt=continuation_prompt - ): - pass - assert "".join(event.data["delta"] for event in events if event.kind == "text") == (content or "") - expected_messages = [ - {"role": "user", "content": "Compare the outputs."}, - {"role": "assistant", "content": content or "", "tool_calls": calls}, - {"role": "tool", "content": "files found", "tool_call_id": "call-inspect", "name": "inspect"}, - {"role": "tool", "content": "bytes differ", "tool_call_id": "call-compare", "name": "compare"}, - ] - if continuation_prompt is not None: - expected_messages.append({"role": "user", "content": continuation_prompt}) - assert requests[1] == expected_messages - if continuation_prompt is None: - persisted = await reloaded.store.fetch_all(reloaded.query().kinds("message")) - assert [entry.payload for entry in persisted if entry.payload.get("role") == "user"] == [ - {"role": "user", "content": "Compare the outputs."} +@pytest.mark.asyncio +async def test_tool_result_without_its_call_in_context_is_still_sent( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("orphan") + await tape.store.append( + tape.name, + TapeEntry.message({"role": "tool", "tool_call_id": "call-1", "name": "inspect", "content": "Ready"}), + ) + events = [ + event + async for event in runner.run( + tape=tape, model="openai:test", tools=[], system_prompt="Be brief.", prompt=to_content("Continue.") + ) ] + messages = provider_service.body()["messages"] + assert messages[0] == {"role": "system", "content": "Be brief."} + assert messages[1]["tool_calls"][0]["id"] == "call-1" + assert messages[1]["tool_calls"][0]["function"]["name"] == "inspect" + assert messages[2] == {"role": "tool", "tool_call_id": "call-1", "content": "Ready"} + assert events[-1].data == {"ok": True, "text": "done"} @pytest.mark.asyncio -async def test_build_messages_keeps_steering_when_continuation_has_no_prompt(tmp_path: Path) -> None: - runner = ModelRunner(AgentSettings.model_construct(model="test-model")) - tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("steering") - await tape.ensure_bootstrap_anchor() - - messages, new_messages = await runner.build_messages( - tape=tape, - run_id="run-1", - system_prompt=None, - prompt=None, - model="test-model", - steering_messages=["new user direction"], - ) - - assert messages == new_messages == [{"role": "user", "content": "new user direction"}] - - @pytest.mark.parametrize("arguments", ["[]", "null", "1", "not json"]) -def test_function_tool_call_rejects_non_object_arguments(arguments: str) -> None: - call = ChatCompletionMessageFunctionToolCall( - id="call-1", type="function", function=Function(name="inspect", arguments=arguments) +async def test_invalid_tool_arguments_do_not_execute_the_handler( + tmp_path: Path, provider_service: ProviderService, arguments: str +) -> None: + provider_service.reply( + sse(tool_events([{"index": 0, "id": "call-1", "function": {"name": "inspect", "arguments": arguments}}])) ) + invoked = [] + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("invalid") + with pytest.raises(BubError) as exc: + _ = [ + event + async for event in runner.run( + tape=tape, + model="openai:test", + tools=[Tool(name="inspect", handler=lambda: invoked.append(True))], + system_prompt=None, + prompt=to_content("Inspect."), + ) + ] + assert exc.value.kind == ErrorKind.INVALID_INPUT + assert not invoked + assert not await tape.store.fetch_all(tape.query().kinds("tool_result")) - with pytest.raises(BubError) as exc_info: - parse_native_function_call(call) - assert exc_info.value.kind == ErrorKind.INVALID_INPUT +@pytest.mark.asyncio +async def test_streaming_reports_usage_and_records_it_in_tape( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(max_tokens=100, client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("usage") + events = [ + event + async for event in runner.run( + tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello") + ) + ] + assert provider_service.body()["stream_options"] == {"include_usage": True} + usage = next(event.data for event in events if event.kind == "usage") + assert usage["usage"]["input_tokens"] == 3 + assert usage["usage"]["output_tokens"] == 2 + assert usage["usage"]["total_tokens"] == 5 + assert usage["elapsed_seconds"] >= 0 + assert events[-1].data == {"ok": True, "text": "done"} + assert (await tape.info()).last_token_usage == 5 -def test_custom_tool_call_is_not_treated_as_a_function_call() -> None: - call = ChatCompletionMessageCustomToolCall( - id="call-1", type="custom", custom=Custom(name="inspect", input="raw input") +@pytest.mark.asyncio +async def test_anthropic_request_enables_caching_and_generation_options( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply( + sse([ + {"type": "message_start", "message": {"id": "test", "usage": {}}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "done"}}, + {"type": "message_stop"}, + ]) ) + async with provider_service.client() as client: + runner = ModelRunner( + AgentSettings(max_tokens=100, client_args={"http_client": client}, completion_args={"temperature": 0.2}) + ) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("cache") + events = [ + event + async for event in runner.run( + tape=tape, model="anthropic:test", tools=[], system_prompt=None, prompt=to_content("hello") + ) + ] + assert provider_service.body()["cache_control"] == {"type": "ephemeral"} + assert provider_service.body()["temperature"] == 0.2 + assert provider_service.body()["max_tokens"] == 100 + assert events[-1].data == {"ok": True, "text": "done"} - with pytest.raises(BubError) as exc_info: - parse_native_function_call(call) - - assert exc_info.value.kind == ErrorKind.INVALID_INPUT +@pytest.mark.asyncio +async def test_session_reasoning_and_runtime_options_override_completion_defaults( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + runner = ModelRunner( + AgentSettings( + client_args={ + "http_client": client, + "api_format": "chat", + "extra_body": {"metadata": {"client": True}}, + }, + completion_args={ + "reasoning_effort": "low", + "tools": [republic.Tool("ignored")], + "max_tokens": 1, + "extra_body": {"metadata": {"request": True}}, + }, + ) + ) + tape = Tape( + tmp_path, + AsyncTapeStoreAdapter(InMemoryTapeStore()), + TapeContext(anchor=None, state={"reasoning_effort": "high"}), + ).scoped("reasoning") + _ = [ + event + async for event in runner.run( + tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello") + ) + ] + request = provider_service.body() + assert request["reasoning_effort"] == "high" + assert request["model"] == "test" + assert request["messages"] == [{"role": "user", "content": "hello"}] + assert request["max_completion_tokens"] == 16384 + assert request["stream"] is True + assert "tools" not in request + assert request["metadata"] == {"client": True, "request": True} -class _FakeStreamingOpenAIProvider(BaseOpenAIProvider): - SUPPORTS_COMPLETION_STREAMING = True - def __init__(self) -> None: - self.completion_kwargs: dict[str, Any] | None = None +@pytest.mark.asyncio +@pytest.mark.parametrize("request_reasoning", [None, "medium"]) +async def test_wire_reasoning_follows_republic_extra_body_precedence( + tmp_path: Path, provider_service: ProviderService, request_reasoning: str | None +) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + runner = ModelRunner( + AgentSettings( + client_args={"http_client": client, "api_format": "chat", "extra_body": {"reasoning_effort": "low"}}, + completion_args={"extra_body": {"reasoning_effort": request_reasoning} if request_reasoning else {}}, + ) + ) + tape = Tape( + tmp_path, + AsyncTapeStoreAdapter(InMemoryTapeStore()), + TapeContext(anchor=None, state={"reasoning_effort": "high"}), + ).scoped("wire-reasoning") + _ = [ + event + async for event in runner.run( + tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello") + ) + ] + assert provider_service.body()["reasoning_effort"] == (request_reasoning or "low") - async def acompletion(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: - self.completion_kwargs = kwargs - include_usage = kwargs.get("stream_options") == {"include_usage": True} - async def stream() -> AsyncIterator[ChatCompletionChunk]: - yield ChatCompletionChunk.model_validate({ - "id": "chatcmpl_test", - "object": "chat.completion.chunk", - "created": 0, - "model": "gpt-test", +@pytest.mark.asyncio +async def test_truncated_stream_never_executes_tools(tmp_path: Path, provider_service: ProviderService) -> None: + provider_service.reply( + sse([ + { "choices": [ { - "index": 0, - "finish_reason": None, - "delta": {"role": "assistant", "content": "done"}, + "delta": { + "tool_calls": [ + {"index": 0, "id": "call-1", "function": {"name": "echo", "arguments": "{}"}} + ] + } } - ], - }) - final_chunk: dict[str, Any] = { - "id": "chatcmpl_test", - "object": "chat.completion.chunk", - "created": 0, - "model": "gpt-test", - "choices": [], + ] } - if include_usage: - final_chunk["usage"] = {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5} - yield ChatCompletionChunk.model_validate(final_chunk) - - return stream() - - -class _FakeStreamingAnthropicProvider(BaseAnthropicProvider): - def __init__(self) -> None: - self.completion_kwargs: dict[str, Any] | None = None - - def _init_client(self, api_key: str | None = None, api_base: str | None = None, **kwargs: Any) -> None: - pass - - async def acompletion(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: - self.completion_kwargs = kwargs - - async def stream() -> AsyncIterator[ChatCompletionChunk]: - if False: - yield - - return stream() - - -class _FakeOpenAIModelRunner(ModelRunner): - def __init__(self, settings: AgentSettings, llm: _FakeStreamingOpenAIProvider) -> None: - super().__init__(settings) - self._llm = llm - - def iter_llm_clients(self, model: str) -> Iterator[tuple[ModelCandidate, _FakeStreamingOpenAIProvider]]: - yield ModelCandidate(provider=LLMProvider.OPENAI, model_id=model, name=f"openai:{model}"), self._llm - - -class _FakeAnthropicModelRunner(ModelRunner): - def __init__(self, settings: AgentSettings, llm: _FakeStreamingAnthropicProvider) -> None: - super().__init__(settings) - self._llm = llm - - def iter_llm_clients(self, model: str) -> Iterator[tuple[ModelCandidate, _FakeStreamingAnthropicProvider]]: - yield ModelCandidate(provider=LLMProvider.ANTHROPIC, model_id=model, name=f"anthropic:{model}"), self._llm + ]) + ) + invoked = [] + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client, "api_format": "chat"})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("truncated") + with pytest.raises(republic.errors.StreamIncompleteError): + _ = [ + event + async for event in runner.run( + tape=tape, + model="openai:test", + tools=[Tool(name="echo", handler=lambda: invoked.append(True))], + system_prompt=None, + prompt=to_content("hello"), + ) + ] + assert not invoked + assert not await tape.store.fetch_all(tape.query().kinds("tool_call", "tool_result")) @pytest.mark.asyncio -async def test_streaming_openai_usage_is_requested_and_recorded_in_tape( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +@pytest.mark.parametrize("next_model", ["google:model-one", "google:model-two", "openai:model-one"]) +async def test_provider_state_is_replayed_only_to_its_api_format_after_reload( + tmp_path: Path, provider_service: ProviderService, next_model: str ) -> None: - clock = iter([10.0, 12.0]) - monkeypatch.setattr("bub.builtin.model_runner.monotonic", lambda: next(clock)) - store = InMemoryTapeStore() - tape = Tape(tmp_path, AsyncTapeStoreAdapter(store), TapeContext()).scoped("test-tape") - llm = _FakeStreamingOpenAIProvider() - runner = _FakeOpenAIModelRunner( - AgentSettings.model_construct(model="openai:gpt-test", max_tokens=100, model_timeout_seconds=None), - llm, - ) - - await tape.ensure_bootstrap_anchor() - events = [ - event async for event in runner.run(tape=tape, model="gpt-test", tools=[], system_prompt=None, prompt="hello") - ] - - assert llm.completion_kwargs is not None - assert llm.completion_kwargs["stream"] is True - assert llm.completion_kwargs["stream_options"] == {"include_usage": True} - assert [(event.kind, event.data) for event in events] == [ - ("text", {"delta": "done"}), - ( - "usage", + opaque = {"executableCode": {"language": "PYTHON", "code": "1 + 1"}} + provider_service.reply( + sse([ { - "usage": {"completion_tokens": 2, "prompt_tokens": 3, "total_tokens": 5}, - "elapsed_seconds": 2.0, - }, - ), - ("final", {"ok": True, "text": "done"}), - ] - run_events = [ - entry for entry in store.read("test-tape") or [] if entry.kind == "event" and entry.payload.get("name") == "run" - ] - assert len(run_events) == 1 - assert run_events[0].payload["data"]["usage"] == { - "completion_tokens": 2, - "prompt_tokens": 3, - "total_tokens": 5, - } - - -@pytest.mark.asyncio -async def test_anthropic_prompt_caching_is_requested() -> None: - llm = _FakeStreamingAnthropicProvider() - runner = _FakeAnthropicModelRunner( - AgentSettings.model_construct(model="anthropic:claude-test", max_tokens=100), - llm, + "candidates": [ + { + "content": { + "parts": [ + opaque, + { + "functionCall": {"id": "call-1", "name": "lookup", "args": {}}, + "thoughtSignature": "signature", + }, + ] + }, + "finishReason": "STOP", + } + ] + } + ]) ) - - await runner.completion_response(model="claude-test", messages=[{"role": "user", "content": "hello"}], tools=[]) - - assert llm.completion_kwargs is not None - assert llm.completion_kwargs["stream"] is True - assert llm.completion_kwargs["cache_control"] == {"type": "ephemeral"} - assert "stream_options" not in llm.completion_kwargs - - -@pytest.mark.asyncio -async def test_run_applies_reasoning_effort_from_tape_state(tmp_path: Path) -> None: - tape = Tape( - tmp_path, - AsyncTapeStoreAdapter(InMemoryTapeStore()), - TapeContext(state={"reasoning_effort": "high"}), - ).scoped("test-tape") - llm = _FakeStreamingOpenAIProvider() - runner = _FakeOpenAIModelRunner( - AgentSettings.model_construct( - model="openai:gpt-test", - max_tokens=100, - model_timeout_seconds=None, - completion_args={"reasoning_effort": "low"}, - ), - llm, + provider_service.reply( + sse( + chat_events() + if next_model.startswith("openai:") + else [{"candidates": [{"content": {"parts": [{"text": "done"}]}, "finishReason": "STOP"}]}] + ) ) - - await tape.ensure_bootstrap_anchor() - events = runner.run(tape=tape, model="gpt-test", tools=[], system_prompt=None, prompt="hello") - [event async for event in events] - - assert llm.completion_kwargs is not None - assert llm.completion_kwargs["reasoning_effort"] == "high" + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("metadata") + await tape.ensure_bootstrap_anchor() + tools = [Tool(name="lookup", handler=lambda: "lookup result")] + _ = [ + event + async for event in runner.run( + tape=tape, model="google:model-one", tools=tools, system_prompt=None, prompt=to_content("lookup") + ) + ] + reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( + "metadata" + ) + if next_model.startswith("openai:"): + runner.settings.client_args["api_format"] = "chat" + events = [ + event + async for event in runner.run( + tape=reopened, model=next_model, tools=tools, system_prompt=None, prompt=to_content("Continue.") + ) + ] + request = json.dumps(provider_service.body()) + assert "lookup result" in request + assert ("signature" in request) is next_model.startswith("google:") + assert ("executableCode" in request) is next_model.startswith("google:") + assert events[-1].data == {"ok": True, "text": "done"} @pytest.mark.asyncio -async def test_completion_args_are_forwarded_without_overriding_managed_args() -> None: - llm = _FakeStreamingOpenAIProvider() - runner = _FakeOpenAIModelRunner( - AgentSettings.model_construct( - model="openai:gpt-test", - max_tokens=100, - completion_args={ - "reasoning_effort": "high", - "model": "ignored-model", - "max_tokens": 1, - "stream": False, - "stream_options": {"include_usage": False}, - }, - ), - llm, - ) - - await runner.completion_response( - model="gpt-test", - messages=[{"role": "user", "content": "hello"}], - tools=[], - max_tokens=42, +async def test_named_tool_choice_is_sent_to_anthropic(tmp_path: Path, provider_service: ProviderService) -> None: + provider_service.reply( + sse([ + {"type": "message_start", "message": {"id": "test", "usage": {}}}, + {"type": "message_stop"}, + ]) ) - - assert llm.completion_kwargs is not None - assert llm.completion_kwargs["reasoning_effort"] == "high" - assert llm.completion_kwargs["model"] == "gpt-test" - assert llm.completion_kwargs["max_tokens"] == 42 - assert llm.completion_kwargs["stream"] is True - assert llm.completion_kwargs["stream_options"] == {"include_usage": True} + async with provider_service.client() as client: + runner = ModelRunner( + AgentSettings(client_args={"http_client": client}, completion_args={"tool_choice": republic.Tool("echo")}) + ) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("choice") + _ = [ + event + async for event in runner.run( + tape=tape, + model="anthropic:test", + tools=[Tool(name="echo", handler=lambda: "done")], + system_prompt=None, + prompt=to_content("hello"), + ) + ] + assert provider_service.body()["tool_choice"] == {"type": "tool", "name": "echo"} diff --git a/tests/test_builtin_tools.py b/tests/test_builtin_tools.py index 343775d2..ab8107fe 100644 --- a/tests/test_builtin_tools.py +++ b/tests/test_builtin_tools.py @@ -54,10 +54,10 @@ async def test_tape_info_formats_token_cache_hit_rate(tmp_path) -> None: new_messages=[], response_text=None, usage={ - "prompt_tokens": 8, - "completion_tokens": 2, + "input_tokens": 8, + "output_tokens": 2, "total_tokens": 10, - "prompt_tokens_details": {"cached_tokens": 3}, + "cached_tokens": 3, }, ) diff --git a/tests/test_channels.py b/tests/test_channels.py index 41b945db..52d55474 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -1166,7 +1166,7 @@ async def source() -> asyncio.AsyncIterator[StreamEvent]: yield StreamEvent("text", {"delta": " "}) yield StreamEvent("text", {"delta": "first paragraph\n\n"}) yield StreamEvent("text", {"delta": "second paragraph"}) - yield StreamEvent("usage", {"usage": {"completion_tokens": 84}, "elapsed_seconds": 2.0}) + yield StreamEvent("usage", {"usage": {"output_tokens": 84}, "elapsed_seconds": 2.0}) yield StreamEvent("final", {}) yielded = [event async for event in channel.stream_events(message, source())] @@ -1181,15 +1181,15 @@ async def source() -> asyncio.AsyncIterator[StreamEvent]: @pytest.mark.parametrize( ("usage", "elapsed", "expected"), [ - ({"completion_tokens": 84, "prompt_tokens": 1000}, 2.0, "42.0"), + ({"output_tokens": 84, "input_tokens": 1000}, 2.0, "42.0"), ({"output_tokens": 21}, 2.0, "10.5"), - ({"completion_tokens": 0}, 2.0, "0.0"), + ({"output_tokens": 0}, 2.0, "0.0"), (None, 2.0, "-"), ({"total_tokens": 100}, 2.0, "-"), - ({"completion_tokens": True}, 2.0, "-"), - ({"completion_tokens": -1}, 2.0, "-"), - ({"completion_tokens": 84}, 0.0, "-"), - ({"completion_tokens": 84}, float("nan"), "-"), + ({"output_tokens": True}, 2.0, "-"), + ({"output_tokens": -1}, 2.0, "-"), + ({"output_tokens": 84}, 0.0, "-"), + ({"output_tokens": 84}, float("nan"), "-"), ], ) def test_cli_toolbar_shows_latest_call_token_speed(usage, elapsed, expected) -> None: diff --git a/tests/test_framework.py b/tests/test_framework.py index dc35f96b..51243f2e 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -8,6 +8,7 @@ from unittest.mock import patch import pytest +import republic import typer from typer.testing import CliRunner @@ -145,6 +146,25 @@ def provide_tape_sidecar(self) -> Sidecar: assert cast(Any, sidecars["shared"]).source == "high" +@pytest.mark.asyncio +async def test_build_prompt_converts_legacy_content_blocks_by_value() -> None: + framework = BubFramework() + + class LegacyPlugin: + @hookimpl + def build_prompt(self, message, session_id, state): + return [ + {"type": "text", "text": "describe"}, + {"type": "image", "media_type": "image/png", "url": "https://example.test/a.png"}, + ] + + framework.plugin_manager.register(LegacyPlugin(), name="legacy") + + prompt = await framework.build_prompt({"content": "ignored"}, session_id="s", state={}) + + assert prompt == ["describe", republic.Image("image/png", url="https://example.test/a.png")] + + @pytest.mark.asyncio async def test_continue_prompt_awaits_high_priority_async_hook() -> None: framework = BubFramework() @@ -162,6 +182,7 @@ class AsyncPlugin: @hookimpl async def continue_prompt(self, prompt: str, tape: Any, state: StreamState) -> str: called.append("async") + # Hooks receive the legacy prompt form. assert prompt == "current prompt" assert state.usage == {"total_tokens": 42} return "async prompt" @@ -169,7 +190,7 @@ async def continue_prompt(self, prompt: str, tape: Any, state: StreamState) -> s framework.plugin_manager.register(SyncPlugin(), name="sync") framework.plugin_manager.register(AsyncPlugin(), name="async") - prompt = await framework.continue_prompt(prompt="current prompt", tape=tape, state=state) + prompt = await framework.continue_prompt(prompt=["current prompt"], tape=tape, state=state) assert prompt == "async prompt" assert called == ["async"] @@ -191,7 +212,7 @@ def continue_prompt(self, prompt: str, tape: Any, state: StreamState) -> None: framework.plugin_manager.register(NoPromptPlugin()) result = await framework.continue_prompt( - prompt="initial prompt", tape=cast(Any, SimpleNamespace()), state=StreamState() + prompt=["initial prompt"], tape=cast(Any, SimpleNamespace()), state=StreamState() ) assert result is None diff --git a/tests/test_hook_runtime.py b/tests/test_hook_runtime.py index a336d679..3605d401 100644 --- a/tests/test_hook_runtime.py +++ b/tests/test_hook_runtime.py @@ -1,5 +1,6 @@ import pluggy import pytest +import republic from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub.hooks.runtime import HookRuntime @@ -139,3 +140,27 @@ async def run_model(self, prompt, session_id, state): assert stream is not None events = [event async for event in stream] assert [(event.kind, event.data) for event in events] == [("text", {"delta": "plain"})] + + +@pytest.mark.asyncio +async def test_model_hooks_receive_the_legacy_prompt_form() -> None: + received: list[object] = [] + + class PlainPlugin: + @hookimpl + async def run_model(self, prompt, session_id, state): + received.append(prompt) + return "plain" + + runtime = _runtime_with_plugins(("plain", PlainPlugin())) + + await runtime.run_model(prompt=["hello", "world"], session_id="s", state={}) + await runtime.run_model(prompt=["look", republic.Image("image/png", data=b"png")], session_id="s", state={}) + + assert received == [ + "hello\nworld", + [ + {"type": "text", "text": "look"}, + {"type": "image", "media_type": "image/png", "url": "data:image/png;base64,cG5n"}, + ], + ] diff --git a/tests/test_image_message.py b/tests/test_image_message.py index fb19fc03..486db122 100644 --- a/tests/test_image_message.py +++ b/tests/test_image_message.py @@ -2,16 +2,17 @@ from __future__ import annotations -import base64 from pathlib import Path from types import SimpleNamespace import pytest +import republic from bub.builtin.hook_impl import BuiltinImpl from bub.channels.message import ChannelMessage, MediaItem from bub.channels.telegram import TelegramChannel, TelegramMessageParser, _extract_media_items from bub.framework import BubFramework +from bub.prompt import prompt_text, to_content, to_legacy_prompt # --------------------------------------------------------------------------- # MediaItem & ChannelMessage @@ -298,18 +299,19 @@ def _build_impl(tmp_path: Path) -> tuple[BubFramework, BuiltinImpl]: @pytest.mark.asyncio -async def test_build_prompt_returns_string_without_media(tmp_path: Path) -> None: +async def test_build_prompt_returns_text_without_media(tmp_path: Path) -> None: _, impl = _build_impl(tmp_path) message = ChannelMessage(session_id="s", channel="tg", content="hello") result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, str) - assert "hello" in result + assert len(result) == 1 + assert isinstance(result[0], str) + assert "hello" in result[0] @pytest.mark.asyncio -async def test_build_prompt_returns_multimodal_parts_with_image_media(tmp_path: Path) -> None: +async def test_build_prompt_returns_text_and_inline_image(tmp_path: Path) -> None: _, impl = _build_impl(tmp_path) message = ChannelMessage( session_id="s", @@ -320,17 +322,10 @@ async def test_build_prompt_returns_multimodal_parts_with_image_media(tmp_path: result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, list) assert len(result) == 2 - - text_part = result[0] - assert text_part["type"] == "text" - assert "describe this" in text_part["text"] - - image_part = result[1] - assert image_part["type"] == "image_url" - expected = base64.b64encode(b"\xff\xd8").decode("utf-8") - assert image_part["image_url"]["url"] == f"data:image/jpeg;base64,{expected}" + assert isinstance(result[0], str) + assert "describe this" in result[0] + assert result[1] == republic.Image("image/jpeg", data=b"\xff\xd8") @pytest.mark.asyncio @@ -348,14 +343,11 @@ async def test_build_prompt_with_multiple_images(tmp_path: Path) -> None: result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, list) - assert len(result) == 3 - assert result[1]["type"] == "image_url" - assert result[2]["type"] == "image_url" + assert result[1:] == [republic.Image("image/jpeg", data=b"A"), republic.Image("image/jpeg", data=b"B")] @pytest.mark.asyncio -async def test_build_prompt_returns_video_url_part_with_video_media(tmp_path: Path) -> None: +async def test_build_prompt_returns_inline_video(tmp_path: Path) -> None: _, impl = _build_impl(tmp_path) message = ChannelMessage( session_id="s", @@ -366,15 +358,9 @@ async def test_build_prompt_returns_video_url_part_with_video_media(tmp_path: Pa result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, list) assert len(result) == 2 - assert result[0]["type"] == "text" - assert "describe this video" in result[0]["text"] - expected = base64.b64encode(b"video").decode("utf-8") - assert result[1] == { - "type": "video_url", - "video_url": {"url": f"data:video/mp4;base64,{expected}"}, - } + assert "describe this video" in result[0] + assert result[1] == republic.Video("video/mp4", data=b"video") @pytest.mark.asyncio @@ -389,16 +375,16 @@ async def test_build_prompt_skips_video_when_download_is_too_large(tmp_path: Pat result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, str) - assert "describe this video" in result + assert len(result) == 1 + assert "describe this video" in result[0] @pytest.mark.asyncio @pytest.mark.parametrize( - ("mime_type", "expected_format"), - [("audio/mpeg", "mp3"), ("audio/ogg", "ogg"), ("audio/x-wav", "wav")], + "mime_type", + ["audio/mpeg", "audio/ogg", "audio/x-wav"], ) -async def test_build_prompt_returns_input_audio_part(tmp_path: Path, mime_type: str, expected_format: str) -> None: +async def test_build_prompt_returns_inline_audio(tmp_path: Path, mime_type: str) -> None: _, impl = _build_impl(tmp_path) message = ChannelMessage( session_id="s", @@ -409,20 +395,12 @@ async def test_build_prompt_returns_input_audio_part(tmp_path: Path, mime_type: result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, list) - assert result[0]["type"] == "text" - assert "listen to this" in result[0]["text"] - assert result[1] == { - "type": "input_audio", - "input_audio": { - "data": base64.b64encode(b"audio").decode("utf-8"), - "format": expected_format, - }, - } + assert "listen to this" in result[0] + assert result[1] == republic.Audio(mime_type, data=b"audio") @pytest.mark.asyncio -async def test_build_prompt_skips_remote_audio_url(tmp_path: Path) -> None: +async def test_build_prompt_preserves_remote_audio_url(tmp_path: Path) -> None: _, impl = _build_impl(tmp_path) message = ChannelMessage( session_id="s", @@ -433,8 +411,7 @@ async def test_build_prompt_skips_remote_audio_url(tmp_path: Path) -> None: result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, str) - assert "listen to this" in result + assert result[1] == republic.Audio("audio/ogg", url="https://example.com/audio.ogg") @pytest.mark.asyncio @@ -449,35 +426,32 @@ async def test_build_prompt_command_ignores_media(tmp_path: Path) -> None: result = await impl.build_prompt(message, session_id="s", state={}) - assert isinstance(result, str) - assert result == ",help" + assert result == [",help"] assert message.kind == "command" # --------------------------------------------------------------------------- -# _extract_text_from_parts +# Prompt content compatibility # --------------------------------------------------------------------------- -def test_extract_text_from_parts() -> None: - from bub.builtin.agent import _extract_text_from_parts - - parts = [ +def test_legacy_prompt_blocks_convert_to_user_content() -> None: + content = to_content([ {"type": "text", "text": "hello"}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,X"}}, + {"type": "image", "media_type": "image/jpeg", "url": "data:image/jpeg;base64,WA=="}, {"type": "text", "text": "world"}, - ] - assert _extract_text_from_parts(parts) == "hello\nworld" - - -def test_extract_text_from_parts_empty() -> None: - from bub.builtin.agent import _extract_text_from_parts + ]) - assert _extract_text_from_parts([]) == "" + assert content == ["hello", republic.Image("image/jpeg", data=b"X"), "world"] + assert prompt_text(content) == "hello\nworld" + assert to_content("hello") == ["hello"] + assert to_content(content) == content -def test_extract_text_from_parts_no_text_parts() -> None: - from bub.builtin.agent import _extract_text_from_parts - - parts = [{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,X"}}] - assert _extract_text_from_parts(parts) == "" +def test_user_content_converts_back_to_legacy_prompt() -> None: + assert to_legacy_prompt(["hello", "world"]) == "hello\nworld" + assert to_legacy_prompt([]) == "" + assert to_legacy_prompt(["look", republic.Image("image/png", url="https://example.test/a.png")]) == [ + {"type": "text", "text": "look"}, + {"type": "image", "media_type": "image/png", "url": "https://example.test/a.png"}, + ] diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 2ae8478b..1c68bd1a 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -3,26 +3,25 @@ import asyncio import os from pathlib import Path -from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch -import httpx +import httpx2 as httpx import pytest +import republic import typer -from any_llm import AnyLLM -from any_llm.constants import LLMProvider -from any_llm.exceptions import AuthenticationError +from republic.errors import APITimeoutError, AuthenticationError, UnsupportedFeatureError from typer.testing import CliRunner from bub import configure, inquirer -from bub.builtin import codex_provider, onboarding +from bub.builtin import onboarding +from bub.builtin.onboarding import discover_models +from bub.builtin.settings import USER_AGENT from bub.framework import BubFramework from bub.hooks import hookimpl @pytest.fixture(autouse=True) def isolated_environment(monkeypatch): - monkeypatch.setattr(codex_provider, "load_openai_codex_oauth_tokens", lambda: None) with patch.dict(os.environ, {}, clear=True): yield @@ -64,7 +63,7 @@ def discover(provider, api_base=None, api_key=None, **kwargs): return answers, calls -@pytest.mark.parametrize("provider", [p for p in onboarding.PROVIDERS if p != "custom"]) +@pytest.mark.parametrize("provider", [p for p in onboarding.PROVIDERS if p not in {"custom", "codex"}]) def test_all_providers_check_connection_before_selecting_models(prompts, provider): answers, calls = prompts answers["provider"] = provider @@ -73,7 +72,7 @@ def test_all_providers_check_connection_before_selecting_models(prompts, provide names = [name for name, _ in calls] assert names[0] == "LLM provider" assert names.index("API key (optional)") < names.index("discover") < names.index("LLM model (type to search)") - if provider in {"openai-compatible", "azure", "ollama"}: + if provider in {"openai-compatible", "azure-openai", "ollama", "magpie"}: assert names.index("API base URL") < names.index("API key (optional)") else: assert "API base URL" not in names @@ -120,7 +119,12 @@ def discover(provider, api_base=None, api_key=None, **kwargs): config = onboarding.collect_model_config({}) assert probes == [("openai", "https://wrong.test/v1", "bad-key"), ("openai", "https://correct.test/v1", "good-key")] - assert config == {"model": "openai:model-b", "api_base": "https://correct.test/v1", "api_key": "good-key"} + assert config == { + "model": "openai:model-b", + "api_base": "https://correct.test/v1", + "api_key": "good-key", + "client_args": {"api_format": "chat"}, + } assert [name for name, _ in calls].count("LLM provider") == 1 assert "bad-key" not in capsys.readouterr().out @@ -162,18 +166,24 @@ def discover(provider, **kwargs): monkeypatch.setattr(onboarding, "discover_models", discover) config = onboarding.collect_model_config({}) - assert probes == [ - {"api_base": "https://first.test/v1", "api_key": "first-key"}, - {"api_base": "https://first.test/v1", "api_key": "first-key"}, - {"api_base": "https://second.test/v1", "api_key": "second-key"}, - ] + headers = {"User-Agent": USER_AGENT} + first = { + "api_base": "https://first.test/v1", + "api_key": "first-key", + "env_prefix": "BUB_OPENAI", + "api_format": "chat", + "headers": headers, + } + second = {**first, "api_base": "https://second.test/v1", "api_key": "second-key"} + assert probes == [first, first, second] + assert config["client_args"] == {"api_format": "chat"} assert probes[-1] == onboarding.AgentSettings.model_validate(config).model_client_kwargs("openai") assert [name for name, _ in calls].count("LLM provider") == 1 def test_unsupported_discovery_goes_directly_to_manual_entry(prompts, monkeypatch, capsys): _, calls = prompts - monkeypatch.setattr(onboarding, "discover_models", Mock(side_effect=NotImplementedError)) + monkeypatch.setattr(onboarding, "discover_models", Mock(side_effect=UnsupportedFeatureError("not supported"))) menu = Mock(side_effect=AssertionError("Unavailable discovery must not offer edit/retry")) monkeypatch.setattr(inquirer, "ask_select", menu) @@ -185,28 +195,15 @@ def test_unsupported_discovery_goes_directly_to_manual_entry(prompts, monkeypatc menu.assert_not_called() -def test_azure_capability_skips_client_creation_and_prompts_for_model(prompts, monkeypatch, capsys): +def test_unavailable_discovery_goes_to_manual_entry(prompts, monkeypatch, capsys): answers, _ = prompts - answers["provider"] = "azure" - provider_class = SimpleNamespace( - SUPPORTS_LIST_MODELS=False, API_BASE=None, ENV_API_BASE_NAME="AZURE_AI_CHAT_ENDPOINT" - ) - monkeypatch.setattr(AnyLLM, "get_provider_class", lambda provider: provider_class) - create = Mock(side_effect=AssertionError("Unsupported discovery must not construct an SDK client")) - monkeypatch.setattr(AnyLLM, "create", create) - monkeypatch.setattr( - onboarding, - "discover_models", - lambda provider, **kwargs: asyncio.run(onboarding._discover_models(provider, **kwargs)), - ) + answers["provider"] = "azure-openai" + monkeypatch.setattr(onboarding, "discover_models", Mock(side_effect=UnsupportedFeatureError("not supported"))) menu = Mock(side_effect=AssertionError("Unsupported discovery must not offer retry")) monkeypatch.setattr(inquirer, "ask_select", menu) - config = onboarding.collect_model_config({}) - - assert config == {"model": "azure:model-b", "api_base": answers["base"], "api_key": answers["key"]} + assert config["model"] == "azure-openai:model-b" assert "Model discovery is unavailable" in capsys.readouterr().out - create.assert_not_called() menu.assert_not_called() @@ -231,12 +228,12 @@ def test_successful_discovery_still_allows_unlisted_model(prompts, monkeypatch): assert onboarding.collect_model_config({})["model"] == "openai:private-model" -def test_compatible_server_without_auth_gets_a_working_saved_key(prompts): +def test_compatible_server_without_auth_keeps_auth_unset(prompts): answers, calls = prompts answers.update(provider="openai-compatible", key="") config = onboarding.collect_model_config({}) - assert config["api_key"] == "not-required" - assert dict(calls)["discover"] == ("openai", "https://example.test/v1", "not-required") + assert "api_key" not in config + assert dict(calls)["discover"] == ("openai", "https://example.test/v1", None) def test_environment_key_is_used_for_check_but_not_written_to_config(prompts, monkeypatch): @@ -249,64 +246,60 @@ def test_environment_key_is_used_for_check_but_not_written_to_config(prompts, mo assert "api_base" not in config -@pytest.mark.parametrize("variable", ["BUB_API_BASE", "BUB_OPENAI_API_BASE"]) -def test_connection_check_honors_environment_overrides_and_client_options(prompts, monkeypatch, capsys, variable): - monkeypatch.setenv(variable, "https://override.test/v1") - received = [] +@pytest.fixture +def model_discovery(prompts, monkeypatch): + requests = [] + constructor = httpx.AsyncClient - def discover(*args, **kwargs): - received.append((args, kwargs)) - return ["model-a", "model-b"] + def respond(request): + requests.append(request) + return httpx.Response(200, json={"data": [{"id": "model-a"}, {"id": "model-b"}]}) - monkeypatch.setattr(onboarding, "discover_models", discover) - onboarding.collect_model_config({"client_args": {"default_headers": {"X-Workspace": "test"}}}) - assert received == [ - ( - ("openai",), - {"api_base": "https://override.test/v1", "api_key": "test-key", "default_headers": {"X-Workspace": "test"}}, - ) - ] - assert "environment settings override" in capsys.readouterr().out + monkeypatch.setattr(onboarding, "discover_models", discover_models) + monkeypatch.setattr( + httpx, "AsyncClient", lambda **kwargs: constructor(**kwargs, transport=httpx.MockTransport(respond)) + ) + return requests -def test_connection_check_overrides_auth_fields_in_client_args_like_runtime(prompts, monkeypatch): - received = [] +@pytest.mark.parametrize("variable", ["BUB_API_BASE", "BUB_OPENAI_API_BASE"]) +def test_connection_check_honors_environment_endpoint_and_headers(model_discovery, monkeypatch, capsys, variable): + monkeypatch.setenv(variable, "https://override.test/v1") + config = onboarding.collect_model_config({"client_args": {"headers": {"X-Workspace": "test"}}}) + assert str(model_discovery[0].url) == "https://override.test/v1/models" + assert model_discovery[0].headers["X-Workspace"] == "test" + assert model_discovery[0].headers["authorization"] == "Bearer test-key" + assert config["model"] == "openai:model-b" + assert "environment settings override" in capsys.readouterr().out - def discover(*args, **kwargs): - received.append((args, kwargs)) - return ["model-a", "model-b"] - monkeypatch.setattr(onboarding, "discover_models", discover) - onboarding.collect_model_config({ - "client_args": { - "api_key": "unused-key", - "api_base": "https://unused.test/v1", - "timeout": 5, - } +def test_connection_check_uses_entered_credentials_and_endpoint(model_discovery): + config = onboarding.collect_model_config({ + "client_args": {"api_key": "unused-key", "api_base": "https://unused.test/v1", "timeout": 5} }) - assert received == [(("openai",), {"api_base": None, "api_key": "test-key", "timeout": 5})] + assert str(model_discovery[0].url) == "https://api.openai.com/v1/models" + assert model_discovery[0].headers["authorization"] == "Bearer test-key" + assert config["model"] == "openai:model-b" @pytest.mark.parametrize("compatible", [False, True]) -def test_other_provider_config_does_not_shadow_environment_connection(prompts, monkeypatch, compatible): - answers, calls = prompts +def test_other_provider_config_does_not_shadow_environment_connection( + prompts, model_discovery, monkeypatch, compatible +): + answers, _ = prompts answers.update(provider="openai-compatible" if compatible else "openai", key="") monkeypatch.setenv("BUB_OPENAI_API_KEY", "environment-key") monkeypatch.setenv("BUB_OPENAI_API_BASE", "https://example.test/v1") - current = { - "api_key": {"anthropic": "other-key"}, - "api_base": {"anthropic": "https://other.test"}, - } + current = {"api_key": {"anthropic": "other-key"}, "api_base": {"anthropic": "https://other.test"}} update = onboarding.collect_model_config(current) - assert dict(calls)["discover"] == ("openai", "https://example.test/v1", "environment-key") + assert str(model_discovery[0].url) == "https://example.test/v1/models" + assert model_discovery[0].headers["authorization"] == "Bearer environment-key" assert "api_key" not in update merged = configure.merge({}, current, update) - settings = onboarding.AgentSettings.model_validate(merged) - assert settings.model_client_kwargs(LLMProvider.OPENAI) == { - "api_base": "https://example.test/v1", - "api_key": "environment-key", - } assert merged["api_key"] == {"anthropic": "other-key"} + assert merged["model"] == "openai:model-b" + if compatible: + assert merged["client_args"]["api_format"] == "chat" def test_provider_credentials_keep_other_providers_when_merged(prompts): @@ -354,9 +347,9 @@ def test_changed_endpoint_clears_saved_credentials_even_with_the_same_menu_choic "api_base": "https://old.test/v1", "api_key": "old-key", }) - assert dict(calls)["discover"] == ("openai", answers["base"], "not-required") + assert dict(calls)["discover"] == ("openai", answers["base"], None) assert "API key (Enter to keep current key)" not in dict(calls) - assert config["api_key"] == "not-required" + assert config["api_key"] is None def test_editing_endpoint_clears_key_before_blank_key_is_reused(prompts, monkeypatch): @@ -379,7 +372,7 @@ def discover(provider, **kwargs): "api_base": "https://first.test/v1", "api_key": "saved-key", }) - assert [probe["api_key"] for probe in probes] == ["saved-key", "not-required"] + assert [probe["api_key"] for probe in probes] == ["saved-key", None] assert [name for name, _ in calls].count("API key (Enter to keep current key)") == 1 assert config["api_base"] == "https://second.test/v1" @@ -431,7 +424,7 @@ def test_blank_compatible_key_uses_environment_for_discovery_and_saved_config(pr monkeypatch.setattr( onboarding, "discover_models", lambda *args, **kwargs: asyncio.run(real_discover(*args, **kwargs)) ) - real_create = AnyLLM.create + real_create = republic.get_provider requests = [] def respond(request): @@ -450,11 +443,11 @@ def respond(request): def create_client(provider, **kwargs): return real_create(provider, **kwargs, http_client=httpx.AsyncClient(transport=httpx.MockTransport(respond))) - monkeypatch.setattr(AnyLLM, "create", create_client) + monkeypatch.setattr(republic, "get_provider", create_client) config = onboarding.collect_model_config({}) assert "api_key" not in config runtime_settings = onboarding.AgentSettings.model_validate(config) - onboarding.discover_models("openai", **runtime_settings.model_client_kwargs(LLMProvider.OPENAI)) + onboarding.discover_models("openai", **runtime_settings.model_client_kwargs("openai")) assert len(requests) == 2 assert all(request.headers["authorization"] == "Bearer environment-key" for request in requests) assert all(str(request.url) == "https://example.test/v1/models" for request in requests) @@ -469,42 +462,21 @@ def test_compatible_key_detects_provider_mapping_in_environment(prompts, monkeyp assert dict(calls)["discover"] == ("openai", "https://example.test/v1", "environment-key") -def test_oauth_login_skips_api_key_discovery_and_preserves_runtime_auth(prompts, monkeypatch, capsys): +def test_codex_skips_api_key_and_model_discovery(prompts, capsys): answers, calls = prompts - answers["key"] = "" - monkeypatch.setattr(codex_provider, "load_openai_codex_oauth_tokens", lambda: object()) + answers.update(provider="codex", key="") config = onboarding.collect_model_config({}) - assert config == {"model": "openai:model-b"} + assert config == {"model": "codex:model-b"} assert "discover" not in dict(calls) - assert "OAuth login" in capsys.readouterr().out - assert codex_provider.should_use_openai_codex_provider("openai", "model-b", api_key=None, api_base=None) - - -@pytest.mark.parametrize("initial", [{}, {"api_base": None}, {"api_base": ""}, {"api_base": {"openai": ""}}]) -def test_leaving_default_url_blank_during_edit_does_not_disable_oauth(prompts, monkeypatch, initial): - answers, _ = prompts - answers.update(key="", base="") - actions = iter([onboarding.EDIT_CONNECTION, onboarding.MANUAL_MODEL]) - monkeypatch.setattr(inquirer, "ask_select", lambda *args, **kwargs: next(actions)) - - def fail(*args, **kwargs): - raise AuthenticationError("no API key") - - monkeypatch.setattr(onboarding, "discover_models", fail) - config = onboarding.collect_model_config(initial) - settings = onboarding.AgentSettings.model_validate(config) - api_base = settings.model_client_kwargs("openai")["api_base"] - assert not api_base - monkeypatch.setattr(codex_provider, "load_openai_codex_oauth_tokens", lambda: object()) - assert codex_provider.should_use_openai_codex_provider("openai", "model-b", api_key=None, api_base=api_base) + assert "API key (optional)" not in dict(calls) + assert "Codex account login" in capsys.readouterr().out -def test_explicit_official_base_does_not_switch_to_oauth(prompts, monkeypatch): +def test_openai_does_not_implicitly_select_codex(prompts): answers, calls = prompts answers["key"] = "" - monkeypatch.setattr(codex_provider, "load_openai_codex_oauth_tokens", lambda: object()) config = onboarding.collect_model_config({"model": "openai:model-b", "api_base": onboarding.OPENAI_BASE}) - assert config["api_base"] == onboarding.OPENAI_BASE + assert config["model"] == "openai:model-b" assert "discover" in dict(calls) @@ -553,27 +525,18 @@ def test_compatible_url_is_validated_before_sending_credentials(monkeypatch, inv def test_discovery_uses_entered_endpoint_and_credentials_and_closes_client(monkeypatch): requests = [] clients = [] - create = AnyLLM.create + constructor = httpx.AsyncClient def respond(request): requests.append(request) - return httpx.Response( - 200, - json={ - "object": "list", - "data": [ - {"id": name, "object": "model", "created": 0, "owned_by": "test"} - for name in ["model-b", "model-a", "model-a"] - ], - }, - ) + return httpx.Response(200, json={"data": [{"id": name} for name in ["model-b", "model-a", "model-a"]]}) - def create_client(provider, **kwargs): - client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + def create_client(**kwargs): + client = constructor(**kwargs, transport=httpx.MockTransport(respond)) clients.append(client) - return create(provider, **kwargs, http_client=client) + return client - monkeypatch.setattr(AnyLLM, "create", create_client) + monkeypatch.setattr(httpx, "AsyncClient", create_client) assert onboarding.discover_models("openai", api_base="https://example.test/custom/v1", api_key="test-key") == [ "model-a", "model-b", @@ -585,23 +548,44 @@ def create_client(provider, **kwargs): assert clients[0].is_closed -def test_discovery_timeout_cancels_request_and_closes_client(monkeypatch): - closed = AsyncMock() - cancelled = [] +def test_discovery_timeout_is_passed_to_the_client_and_closes_it(monkeypatch): + clients = [] + timeouts = [] + constructor = httpx.AsyncClient + + def respond(request): + timeouts.append(request.extensions["timeout"]) + raise httpx.ReadTimeout("timed out", request=request) + + def create_client(**kwargs): + client = constructor(**kwargs, transport=httpx.MockTransport(respond)) + clients.append(client) + return client - async def list_models(): - try: - await asyncio.Event().wait() - finally: - cancelled.append(True) + monkeypatch.setattr(httpx, "AsyncClient", create_client) + with pytest.raises(APITimeoutError) as exc: + # A configured client timeout does not apply to the connection check. + onboarding.discover_models( + "openai", api_base="https://example.test/v1", api_key="test-key", timeout=600, max_retries=0 + ) + assert timeouts[0]["read"] == onboarding.CONNECTION_TIMEOUT + assert "timed out" in onboarding._connection_error(exc.value) + assert clients[0].is_closed - monkeypatch.setattr(onboarding, "CONNECTION_TIMEOUT", 0.01) + +def test_onboarding_updates_named_endpoint_and_discovers_its_provider_type(prompts, monkeypatch): + _, calls = prompts monkeypatch.setattr( - AnyLLM, - "create", - lambda *args, **kwargs: SimpleNamespace(alist_models=list_models, client=SimpleNamespace(close=closed)), + inquirer, "ask_fuzzy", lambda message, **kwargs: "relay" if message == "LLM provider" else "model-b" ) - with pytest.raises(TimeoutError): - onboarding.discover_models("openai", api_base="https://example.test/v1", api_key="test-key") - assert cancelled == [True] - closed.assert_awaited_once() + original = { + "model": "relay:sample", + "providers": {"relay": {"type": "openai", "api_base": "https://old.test/v1", "api_key": "old-key"}}, + } + update = onboarding.collect_model_config(original) + settings = onboarding.AgentSettings.model_validate(configure.merge({}, original, update)) + assert update["model"] == "relay:model-b" + assert settings.providers["relay"].type == "openai" + assert settings.providers["relay"].api_base == "https://example.test/v1" + assert settings.providers["relay"].api_key == "test-key" + assert dict(calls)["discover"] == ("openai", "https://example.test/v1", "test-key") diff --git a/tests/test_provider_messages.py b/tests/test_provider_messages.py new file mode 100644 index 00000000..966ccc12 --- /dev/null +++ b/tests/test_provider_messages.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +from pathlib import Path + +import httpx2 +import pytest + +from bub.builtin.context import default_tape_context +from bub.builtin.model_runner import ModelRunner +from bub.builtin.settings import AgentSettings +from bub.channels.message import ChannelMessage, MediaItem +from bub.errors import BubError, ErrorKind +from bub.framework import BubFramework +from bub.prompt import to_content +from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore +from bub.tape import Tape, TapeContext +from bub.tools import Tool +from tests.model_fakes import ProviderService, chat_events, sse + + +@pytest.mark.asyncio +async def test_channel_media_reaches_gemini_and_survives_tape_reload( + tmp_path: Path, provider_service: ProviderService +) -> None: + async def image(): + return b"image" + + async def audio(): + return b"audio" + + async def video(): + return b"video" + + framework = BubFramework(config_file=tmp_path / "config.yml") + framework.load_builtin_hooks() + channel = ChannelMessage( + session_id="media", + channel="telegram", + content="Describe the attachments.", + media=[ + MediaItem("image", "image/png", data_fetcher=image), + MediaItem("audio", "audio/ogg", data_fetcher=audio), + MediaItem("video", "video/mp4", data_fetcher=video), + ], + ) + prompt = await framework.build_prompt(channel, channel.session_id, {}) + for _ in range(2): + provider_service.reply( + sse([{"candidates": [{"content": {"parts": [{"text": "ready"}]}, "finishReason": "STOP"}]}]) + ) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped("media") + await tape.ensure_bootstrap_anchor() + events = [ + event + async for event in runner.run( + tape=tape, model="google:test", tools=[], system_prompt=None, prompt=to_content(prompt) + ) + ] + reopened = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( + "media" + ) + follow_up = [ + event + async for event in runner.run( + tape=reopened, model="google:test", tools=[], system_prompt=None, prompt=to_content("Continue.") + ) + ] + expected = [ + {"inlineData": {"mimeType": "image/png", "data": "aW1hZ2U="}}, + {"inlineData": {"mimeType": "audio/ogg", "data": "YXVkaW8="}}, + {"inlineData": {"mimeType": "video/mp4", "data": "dmlkZW8="}}, + ] + assert provider_service.body(0)["contents"][0]["parts"][1:] == expected + assert provider_service.body(1)["contents"][0]["parts"][1:] == expected + assert events[-1].data == {"ok": True, "text": "ready"} + assert follow_up[-1].data == {"ok": True, "text": "ready"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("external_client", [False, True]) +async def test_consumer_close_releases_the_request_and_respects_client_ownership( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, external_client: bool +) -> None: + closed = asyncio.Event() + + class Body(httpx2.AsyncByteStream): + async def __aiter__(self): + yield b'data: {"choices":[{"delta":{"content":"first"}}]}\n\n' + await asyncio.Event().wait() + + async def aclose(self): + closed.set() + + async with httpx2.AsyncClient( + transport=httpx2.MockTransport(lambda _: httpx2.Response(200, stream=Body())) + ) as client: + monkeypatch.setattr(httpx2, "AsyncClient", lambda **kwargs: client) + client_args = {"api_format": "chat"} + if external_client: + client_args["http_client"] = client + runner = ModelRunner(AgentSettings(client_args=client_args)) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("cancel") + events = runner.run(tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello")) + assert (await anext(events)).data == {"delta": "first"} + await events.aclose() + assert closed.is_set() + assert client.is_closed is not external_client + assert not await tape.store.fetch_all(tape.query().kinds("message")) + + +@pytest.mark.asyncio +async def test_primary_success_does_not_require_an_available_fallback( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + runner = ModelRunner( + AgentSettings( + fallback_models=["not-installed:test"], client_args={"http_client": client, "api_format": "chat"} + ) + ) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("fallback") + events = [ + event + async for event in runner.run( + tape=tape, model="openai:test", tools=[], system_prompt=None, prompt=to_content("hello") + ) + ] + assert len(provider_service.requests) == 1 + assert events[-1].data == {"ok": True, "text": "done"} + + +@pytest.mark.asyncio +async def test_fresh_process_replays_tool_signatures_to_the_provider( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply( + sse([ + { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": {"id": "call-1", "name": "lookup", "args": {}}, + "thoughtSignature": "signature", + } + ] + }, + "finishReason": "STOP", + } + ] + } + ]) + ) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(FileTapeStore(tmp_path)), default_tape_context()).scoped( + "persisted" + ) + await tape.ensure_bootstrap_anchor() + _ = [ + event + async for event in runner.run( + tape=tape, + model="google:test", + tools=[Tool(name="lookup", handler=lambda: "lookup result")], + system_prompt=None, + prompt=to_content("Look it up."), + ) + ] + code = """ +import asyncio, json, sys +from pathlib import Path +import httpx2 +from bub.builtin.context import default_tape_context +from bub.builtin.model_runner import ModelRunner +from bub.builtin.settings import AgentSettings +from bub.store import FileTapeStore, AsyncTapeStoreAdapter +from bub.tape import Tape +async def main(): + bodies = [] + def respond(request): + bodies.append(json.loads(request.content)) + event = {"candidates": [{"content": {"parts": [{"text": "ready"}]}, "finishReason": "STOP"}]} + return httpx2.Response(200, headers={"content-type": "text/event-stream"}, text="data: " + json.dumps(event) + "\\n\\n") + async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as client: + directory = Path(sys.argv[1]) + tape = Tape(directory, AsyncTapeStoreAdapter(FileTapeStore(directory)), default_tape_context()).scoped("persisted") + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + events = [event async for event in runner.run(tape=tape, model="google:test", tools=[], system_prompt=None, prompt=["Continue."])] + print(json.dumps({"request": bodies[0], "result": events[-1].data})) +asyncio.run(main()) +""" + result = subprocess.run([sys.executable, "-c", code, str(tmp_path)], capture_output=True, text=True, check=True) + replay = json.loads(result.stdout) + assistant = next(content for content in replay["request"]["contents"] if content["role"] == "model") + call = next(part for part in assistant["parts"] if "functionCall" in part) + assert call["functionCall"] == {"id": "call-1", "name": "lookup", "args": {}} + assert call["thoughtSignature"] == "signature" + assert "lookup result" in json.dumps(replay["request"]) + assert replay["result"] == {"ok": True, "text": "ready"} + + +@pytest.mark.asyncio +async def test_media_urls_preserve_explicit_mime_types(tmp_path: Path, provider_service: ProviderService) -> None: + provider_service.reply(sse([{"candidates": [{"content": {"parts": [{"text": "ready"}]}, "finishReason": "STOP"}]}])) + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("urls") + events = [ + event + async for event in runner.run( + tape=tape, + model="google:test", + tools=[], + system_prompt=None, + prompt=to_content([ + { + "type": "image", + "media_type": "image/png", + "url": "https://example.test/photo.png?signature=opaque", + }, + {"type": "image", "media_type": "image/webp", "url": "https://example.test/attachment"}, + ]), + ) + ] + assert provider_service.body()["contents"][0]["parts"] == [ + {"fileData": {"mimeType": "image/png", "fileUri": "https://example.test/photo.png?signature=opaque"}}, + {"fileData": {"mimeType": "image/webp", "fileUri": "https://example.test/attachment"}}, + ] + assert events[-1].data == {"ok": True, "text": "ready"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "block", + [ + {"type": "image", "url": "https://example.test/photo"}, + {"type": "audio", "media_type": "audio/wav"}, + {"type": "document", "media_type": "application/pdf", "url": "https://example.test/file.pdf"}, + ], +) +async def test_invalid_content_fails_without_a_provider_request( + tmp_path: Path, provider_service: ProviderService, block: dict +) -> None: + async with provider_service.client() as client: + runner = ModelRunner(AgentSettings(client_args={"http_client": client})) + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("invalid") + with pytest.raises(BubError) as exc: + _ = [ + event + async for event in runner.run( + tape=tape, model="google:test", tools=[], system_prompt=None, prompt=to_content([block]) + ) + ] + assert exc.value.kind == ErrorKind.INVALID_INPUT + assert not provider_service.requests diff --git a/tests/test_settings.py b/tests/test_settings.py index 5c34d950..8f960027 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,15 +1,21 @@ from __future__ import annotations import os +from pathlib import Path from unittest.mock import patch +import httpx2 import pytest -from any_llm.constants import LLMProvider from pydantic import ValidationError +from bub.builtin.model_runner import ModelRunner from bub.builtin.settings import DEFAULT_MODEL, AgentSettings, load_settings from bub.builtin.spill import SpillSettings from bub.configure import ensure_config +from bub.prompt import to_content +from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore +from bub.tape import Tape, TapeContext +from tests.model_fakes import ProviderService, chat_events, sse def _settings_with_env(env: dict[str, str]) -> AgentSettings: @@ -53,20 +59,6 @@ def test_settings_no_keys_return_none() -> None: assert settings.completion_args == {} -@pytest.mark.parametrize("provider", ["openai", LLMProvider.OPENAI, "acme"]) -def test_client_options_resolve_provider_names_and_enum_values(provider: str) -> None: - settings = _settings_with_env({ - f"BUB_{provider.upper()}_API_KEY": "environment-key", - f"BUB_{provider.upper()}_API_BASE": "https://example.test/v1", - "BUB_CLIENT_ARGS": '{"api_key": "ignored-key", "api_base": "https://ignored.test", "timeout": 5}', - }) - assert settings.model_client_kwargs(provider) == { - "api_key": "environment-key", - "api_base": "https://example.test/v1", - "timeout": 5, - } - - def test_settings_provider_names_are_lowercased() -> None: settings = _settings_with_env({"BUB_OPENROUTER_API_KEY": "sk-or"}) @@ -98,7 +90,7 @@ def test_settings_load_values_from_yaml(load_config) -> None: api_base: openai: https://api.openai.com client_args: - extra_headers: + headers: HTTP-Referer: https://openclaw.ai X-Title: OpenClaw completion_args: @@ -114,7 +106,7 @@ def test_settings_load_values_from_yaml(load_config) -> None: assert settings.api_key == {"openai": "sk-yaml"} assert settings.api_base == {"openai": "https://api.openai.com"} assert settings.client_args == { - "extra_headers": {"HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw"}, + "headers": {"HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw"}, } assert settings.completion_args == {"reasoning_effort": "high"} @@ -125,7 +117,7 @@ def test_env_settings_override_yaml(load_config) -> None: api_key: sk-yaml max_steps: 77 client_args: - extra_headers: + headers: HTTP-Referer: https://yaml.example X-Title: YAML App """.strip() @@ -135,7 +127,7 @@ def test_env_settings_override_yaml(load_config) -> None: { "BUB_MODEL": "anthropic:claude-3-7-sonnet", "BUB_API_KEY": "sk-env", - "BUB_CLIENT_ARGS": '{"extra_headers":{"HTTP-Referer":"https://env.example","X-Title":"Env App"}}', + "BUB_CLIENT_ARGS": '{"headers":{"HTTP-Referer":"https://env.example","X-Title":"Env App"}}', "BUB_COMPLETION_ARGS": '{"reasoning_effort":"medium"}', "BUB_MAX_STEPS": "12", }, @@ -148,7 +140,7 @@ def test_env_settings_override_yaml(load_config) -> None: assert settings.api_key == "sk-env" assert settings.max_steps == 12 assert settings.client_args == { - "extra_headers": {"HTTP-Referer": "https://env.example", "X-Title": "Env App"}, + "headers": {"HTTP-Referer": "https://env.example", "X-Title": "Env App"}, } assert settings.completion_args == {"reasoning_effort": "medium"} @@ -194,41 +186,182 @@ def test_load_settings_returns_loaded_config(load_config) -> None: assert settings.model == "openrouter:openrouter/free" -def test_custom_provider_names_an_endpoint_beside_the_builtin_ones(load_config) -> None: +async def _run(settings: AgentSettings, tmp_path: Path) -> str: + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), TapeContext(anchor=None)).scoped("settings") + events = [ + event + async for event in ModelRunner(settings).run( + tape=tape, model=settings.model, tools=[], system_prompt=None, prompt=to_content("Hello") + ) + ] + assert events[-1].data["ok"] + return events[-1].data["text"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("provider", ["openai", "acme"]) +async def test_client_options_resolve_provider_names( + provider: str, tmp_path: Path, provider_transport: ProviderService +) -> None: + provider_transport.reply_chat() + env = { + f"BUB_{provider.upper()}_API_KEY": "environment-key", + f"BUB_{provider.upper()}_API_BASE": "https://example.test/v1", + "BUB_CLIENT_ARGS": '{"api_key": "ignored-key", "api_base": "https://ignored.test", "api_format": "chat"}', + } + if provider == "acme": + env["BUB_PROVIDERS"] = '{"acme": {"type": "openai"}}' + settings = _settings_with_env(env) + settings.model = f"{provider}:model" + assert await _run(settings, tmp_path) == "done" + assert provider_transport.requests[0].url.host == "example.test" + assert provider_transport.requests[0].headers["authorization"] == "Bearer environment-key" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "provider,header", [("google", "x-goog-api-key"), ("azure-openai", "api-key"), ("openrouter", "authorization")] +) +@pytest.mark.parametrize("explicit_key", [False, True]) +async def test_provider_requests_use_environment_or_explicit_credentials( + provider: str, header: str, explicit_key: bool, tmp_path: Path, provider_service: ProviderService +) -> None: + prefix = provider.upper().replace("-", "_") + provider_service.reply( + sse( + [{"candidates": [{"content": {"parts": [{"text": "done"}]}, "finishReason": "STOP"}]}] + if provider == "google" + else chat_events() + ) + ) + with patch.dict( + os.environ, + {f"{prefix}_API_KEY": "environment-key", f"{prefix}_API_BASE": "https://provider.test/v1"}, + clear=True, + ): + async with provider_service.client() as client: + settings = AgentSettings( + model=f"{provider}:model:variant", + api_key={provider: "explicit-key"} if explicit_key else None, + client_args={"http_client": client, "api_format": "gemini" if provider == "google" else "chat"}, + ) + assert await _run(settings, tmp_path) == "done" + request = provider_service.requests[0] + key = "explicit-key" if explicit_key else "environment-key" + assert request.headers[header] == (f"Bearer {key}" if header == "authorization" else key) + assert request.url.host == "provider.test" + if provider == "google": + assert "model:variant" in request.url.path + else: + assert provider_service.body()["model"] == "model:variant" + + +@pytest.mark.asyncio +async def test_provider_and_completion_extras_follow_republic_deep_merge( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply(sse([{"candidates": [{"content": {"parts": [{"text": "done"}]}, "finishReason": "STOP"}]}])) + async with provider_service.client() as client: + settings = AgentSettings( + model="google:test", + max_tokens=100, + client_args={ + "http_client": client, + "extra_body": {"generationConfig": {"maxOutputTokens": 1, "temperature": 0.4, "topK": 12}}, + }, + completion_args={"extra_body": {"generationConfig": {"temperature": 0.9, "topP": 0.8}}}, + ) + assert await _run(settings, tmp_path) == "done" + assert provider_service.body()["contents"] == [{"role": "user", "parts": [{"text": "Hello"}]}] + assert provider_service.body()["generationConfig"] == { + "maxOutputTokens": 1, + "temperature": 0.9, + "topK": 12, + "topP": 0.8, + } + + +@pytest.mark.asyncio +async def test_named_endpoint_and_builtin_fallback_use_separate_credentials( + load_config, tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply(httpx2.Response(503, json={"error": {"message": "unavailable"}})) + provider_service.reply(sse(chat_events())) with patch.dict(os.environ, {}, clear=True): - load_config( - """ + load_config(""" model: relay:qwen/kimi-k3 fallback_models: - openai:gpt-5 api_key: - openai: sk-openai + openai: openai-key +api_base: + openai: https://openai.test/v1 providers: relay: type: openai api_base: https://relay.test/v1 api_key: relay-key -""".strip(), - ) +""") + async with provider_service.client() as client: + settings = load_settings() + settings.client_args = {"http_client": client, "api_format": "chat", "max_retries": 0} + assert await _run(settings, tmp_path) == "done" + assert [request.url.host for request in provider_service.requests] == ["relay.test", "openai.test"] + assert [request.headers["authorization"] for request in provider_service.requests] == [ + "Bearer relay-key", + "Bearer openai-key", + ] + assert [provider_service.body(index)["model"] for index in range(2)] == ["qwen/kimi-k3", "gpt-5"] + + +@pytest.mark.asyncio +async def test_named_endpoint_uses_its_environment_credentials( + tmp_path: Path, provider_service: ProviderService +) -> None: + provider_service.reply( + sse([ + {"type": "message_start", "message": {"id": "test", "usage": {}}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "done"}}, + {"type": "message_stop"}, + ]) + ) + with patch.dict( + os.environ, + { + "BUB_MODEL": "relay:test", + "BUB_PROVIDERS": '{"relay": {"type": "anthropic", "api_base": "https://relay.test"}}', + "BUB_RELAY_API_KEY": "environment-key", + }, + clear=True, + ): + async with provider_service.client() as client: + settings = AgentSettings(client_args={"http_client": client}) + assert await _run(settings, tmp_path) == "done" + assert provider_service.requests[0].url.host == "relay.test" + assert provider_service.requests[0].headers["x-api-key"] == "environment-key" - settings = load_settings() - relay, openai = settings.model_candidates(settings.model) - assert (relay.provider, relay.model_id, relay.provider_name) == (LLMProvider.OPENAI, "qwen/kimi-k3", "relay") - assert (openai.provider, openai.model_id, openai.provider_name) == (LLMProvider.OPENAI, "gpt-5", None) - assert settings.model_client_kwargs("relay") == {"api_key": "relay-key", "api_base": "https://relay.test/v1"} - assert settings.model_client_kwargs("openai") == {"api_key": "sk-openai", "api_base": None} +def test_custom_provider_rejects_unknown_type() -> None: + with pytest.raises(ValidationError, match="providers"): + _settings_with_env({"BUB_PROVIDERS": '{"relay": {"type": "nope"}}'}) -def test_custom_provider_falls_back_to_per_provider_settings() -> None: - settings = _settings_with_env({ - "BUB_PROVIDERS": '{"relay": {"type": "anthropic", "api_base": "https://relay.test"}}', - "BUB_RELAY_API_KEY": "env-key", - }) +@pytest.mark.parametrize("provider", ["azure-openai", "github-copilot"]) +def test_hyphenated_provider_names_resolve_environment_credentials(provider: str) -> None: + prefix = provider.upper().replace("-", "_") + settings = _settings_with_env({f"BUB_{prefix}_API_KEY": "provider-key"}) + assert settings.api_key == {provider: "provider-key"} - assert settings.model_client_kwargs("relay") == {"api_key": "env-key", "api_base": "https://relay.test"} +def test_model_clients_identify_as_bub() -> None: + import republic -def test_custom_provider_rejects_unknown_type() -> None: - with pytest.raises(ValidationError, match="providers"): - _settings_with_env({"BUB_PROVIDERS": '{"relay": {"type": "nope"}}'}) + import bub + + settings = AgentSettings(api_key="sk-test") + kwargs = settings.model_client_kwargs("openai") + provider = republic.get_provider("openai", **kwargs) + + assert provider.headers["User-Agent"] == f"bub/{bub.__version__}" + custom = AgentSettings(api_key="sk-test", client_args={"headers": {"User-Agent": "mine", "X-Team": "a"}}) + assert custom.model_client_kwargs("openai")["headers"] == {"User-Agent": "mine", "X-Team": "a"} diff --git a/tests/test_spill.py b/tests/test_spill.py index 1e05cb01..6cf9696a 100644 --- a/tests/test_spill.py +++ b/tests/test_spill.py @@ -6,7 +6,7 @@ import pluggy import pytest -from any_llm.types.completion import ChatCompletion +import republic from bub.builtin.context import default_tape_context from bub.builtin.hook_impl import BuiltinImpl @@ -27,6 +27,7 @@ from bub.store import AsyncTapeStoreAdapter, FileTapeStore, InMemoryTapeStore, TapeStore from bub.tape import Tape from bub.tools import Tool, ToolContext, ToolExecutor, model_tools +from tests.model_fakes import ProviderService class _SpillHooks: @@ -121,11 +122,11 @@ async def test_oversized_result_is_bounded_and_readable_across_merge(tmp_path: P system_prompt=None, new_messages=[], response_text=None, - tool_calls=[{"id": "call-1", "type": "function", "function": {"name": "large", "arguments": "{}"}}], + tool_calls=[{"id": "call-1", "name": "large", "arguments": "{}"}], tool_results=execution.tool_results, ) request_messages = await tape.read_messages() - request_body = json.dumps(request_messages, ensure_ascii=False) + request_body = json.dumps([message.to_dict() for message in request_messages], ensure_ascii=False) assert handle in request_body assert output not in request_body @@ -331,9 +332,9 @@ async def test_tape_archive_preserves_spilled_results_and_clears_the_session(tmp await tape.record_chat( run_id="run-1", system_prompt=None, - new_messages=[{"role": "user", "content": "archive this"}], + new_messages=[republic.user("archive this")], response_text=None, - tool_calls=[{"id": "call-1", "type": "function", "function": {"name": "large", "arguments": "{}"}}], + tool_calls=[{"id": "call-1", "name": "large", "arguments": "{}"}], tool_results=execution.tool_results, ) @@ -366,7 +367,7 @@ async def test_code_mode_results_stay_structured_and_are_not_spilled(tmp_path: P @pytest.mark.parametrize("fails", [False, True]) @pytest.mark.asyncio async def test_result_spill_requires_reader_in_current_model_tools( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fails: bool + tmp_path: Path, provider_transport: ProviderService, fails: bool ) -> None: class SpillPlugin: @hookimpl(trylast=True) @@ -377,7 +378,7 @@ async def after_tool_call(self, call: ToolCall, result: ToolCallResult, state: d plugin_manager.add_hookspecs(BubHookSpecs) plugin_manager.register(SpillPlugin()) runner = ModelRunner( - AgentSettings.model_construct(model="test-model", model_timeout_seconds=None), + AgentSettings(model="openai:test", client_args={"api_format": "chat"}), hooks=AgentHooks(HookRuntime(plugin_manager)), ) output = "large output " * 2000 @@ -387,32 +388,8 @@ def large() -> str: raise RuntimeError(output) return output - async def complete(**kwargs: Any) -> ChatCompletion: - return ChatCompletion.model_validate({ - "id": "completion-1", - "object": "chat.completion", - "created": 0, - "model": "test-model", - "choices": [ - { - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "call-large", - "type": "function", - "function": {"name": "large", "arguments": "{}"}, - } - ], - }, - } - ], - }) - - monkeypatch.setattr(runner, "completion_response", complete) + for _ in range(3): + provider_transport.reply_chat(calls=[{"id": "call-large", "name": "large", "arguments": "{}"}]) root = _root_tape(tmp_path, InMemoryTapeStore(), threshold=100) async with root.fork_tape() as tape: for reader_available in (False, True, False): @@ -422,7 +399,7 @@ async def complete(**kwargs: Any) -> ChatCompletion: events = [ event async for event in runner.run( - tape=tape, model="test-model", tools=model_tools(tools), system_prompt=None, prompt="Run large." + tape=tape, model="openai:test", tools=model_tools(tools), system_prompt=None, prompt="Run large." ) ] result = next(event.data["tool_results"][0] for event in events if event.kind == "tool_result") diff --git a/tests/test_tape.py b/tests/test_tape.py index 242b4433..ef4f5db8 100644 --- a/tests/test_tape.py +++ b/tests/test_tape.py @@ -3,10 +3,11 @@ from pathlib import Path import pytest +import republic from bub.builtin.context import default_tape_context from bub.store import AsyncTapeStoreAdapter, ForkTapeStore, InMemoryTapeStore -from bub.tape import Tape, TapeContext, TapeEntry +from bub.tape import Tape, TapeContext, TapeEntry, to_messages def test_tape_reexports_legacy_store_objects() -> None: @@ -33,13 +34,35 @@ async def test_legacy_tool_call_without_content_replays_with_its_result(tmp_path store = InMemoryTapeStore() tape = Tape(tmp_path, AsyncTapeStoreAdapter(store), default_tape_context()).scoped("test-tape") await tape.ensure_bootstrap_anchor() - calls = [{"id": "call-1", "type": "function", "function": {"name": "inspect", "arguments": "{}"}}] + calls = [{"id": "call-1", "name": "inspect", "arguments": "{}"}] store.append("test-tape", TapeEntry(id=0, kind="tool_call", payload={"calls": calls})) store.append("test-tape", TapeEntry.tool_result(["files found"])) + call = republic.ToolCall("call-1", "inspect", "{}") assert await tape.read_messages() == [ - {"role": "assistant", "content": "", "tool_calls": calls}, - {"role": "tool", "content": "files found", "tool_call_id": "call-1", "name": "inspect"}, + republic.assistant("", tool_calls=[call]), + republic.tool(call, "files found"), + ] + + +@pytest.mark.asyncio +async def test_chat_completions_tool_calls_from_older_tapes_replay_with_their_results(tmp_path: Path) -> None: + store = InMemoryTapeStore() + tape = Tape(tmp_path, AsyncTapeStoreAdapter(store), default_tape_context()).scoped("test-tape") + await tape.ensure_bootstrap_anchor() + calls = [ + {"id": "call-1", "type": "function", "function": {"name": "fs_read", "arguments": '{"path": "a.txt"}'}}, + {"id": "call-2", "type": "function", "function": {"name": "tape_info", "arguments": {}}}, + ] + store.append("test-tape", TapeEntry.tool_call(calls, content="Let me look.")) + store.append("test-tape", TapeEntry.tool_result(["hello", {"entries": 3}])) + + read = republic.ToolCall("call-1", "fs_read", '{"path": "a.txt"}') + info = republic.ToolCall("call-2", "tape_info", "{}") + assert await tape.read_messages() == [ + republic.assistant("Let me look.", tool_calls=[read, info]), + republic.tool(read, "hello"), + republic.tool(info, '{"entries": 3}'), ] @@ -50,7 +73,7 @@ async def test_text_only_response_remains_a_standalone_assistant_message(tmp_pat await tape.ensure_bootstrap_anchor() await tape.record_chat(run_id="run-1", system_prompt=None, new_messages=[], response_text=content) - assert await tape.read_messages() == [{"role": "assistant", "content": content}] + assert await tape.read_messages() == [republic.assistant(content)] @pytest.mark.asyncio @@ -87,10 +110,10 @@ async def test_tape_info_reports_last_token_cache_hit_rate(tmp_path: Path) -> No new_messages=[], response_text=None, usage={ - "prompt_tokens": 80, - "completion_tokens": 20, + "input_tokens": 80, + "output_tokens": 20, "total_tokens": 100, - "prompt_tokens_details": {"cached_tokens": 60}, + "cached_tokens": 60, }, ) @@ -108,7 +131,7 @@ async def test_tape_info_omits_cache_hit_rate_when_usage_has_no_cache_details(tm system_prompt=None, new_messages=[], response_text=None, - usage={"prompt_tokens": 80, "completion_tokens": 20, "total_tokens": 100}, + usage={"input_tokens": 80, "output_tokens": 20, "total_tokens": 100}, ) info = await tape.info() @@ -134,3 +157,45 @@ def select_events(entries, _context): await tape.append_event("hidden", {}, context=False) assert await tape.read_messages() == [{"role": "assistant", "content": "visible"}] + + +@pytest.mark.asyncio +async def test_new_messages_round_trip_through_the_tape(tmp_path: Path) -> None: + tape = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()).scoped("test-tape") + await tape.ensure_bootstrap_anchor() + prompt = republic.user("Describe this.", republic.image(b"png-bytes", media_type="image/png")) + await tape.record_chat(run_id="run-1", system_prompt=None, new_messages=[prompt], response_text="A picture.") + + assert await tape.read_messages() == [prompt, republic.assistant("A picture.")] + + +def test_legacy_payloads_convert_to_republic_messages() -> None: + call = republic.ToolCall("call-1", "inspect", "{}") + payloads = [ + { + "role": "user", + "content": [{"type": "image", "url": "data:image/png;base64,cG5n", "media_type": "image/png"}], + }, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "call-1", "name": "inspect", "arguments": "{}"}], + "reasoning": "Look first.", + "provider_data": [{"api_format": "responses", "payload": {"type": "reasoning"}}], + }, + {"role": "tool", "tool_call_id": "call-1", "content": {"files": 2}}, + ] + + assert to_messages(payloads) == [ + republic.user(republic.Image("image/png", data=b"png")), + republic.Message( + "assistant", + ( + republic.Text(""), + republic.Reasoning("Look first."), + republic.ProviderData("responses", {"type": "reasoning"}), + ), + tool_calls=(call,), + ), + republic.tool(call, '{"files": 2}'), + ] diff --git a/tests/test_tool_loading.py b/tests/test_tool_loading.py index 2ef62e4c..19adac98 100644 --- a/tests/test_tool_loading.py +++ b/tests/test_tool_loading.py @@ -5,31 +5,17 @@ from typing import Any import pytest -from any_llm.types.completion import ChatCompletion from bub.builtin.agent import Agent from bub.framework import BubFramework from bub.tools import Tool - - -def _completion(message: dict[str, Any]) -> ChatCompletion: - return ChatCompletion.model_validate({ - "id": "reply", - "model": "test-model", - "created": 0, - "object": "chat.completion", - "choices": [ - {"index": 0, "finish_reason": "tool_calls" if "tool_calls" in message else "stop", "message": message} - ], - }) +from tests.model_fakes import ProviderService def _tool_call(call_id: str, name: str, arguments: dict[str, Any]) -> dict[str, Any]: return { "role": "assistant", - "tool_calls": [ - {"id": call_id, "type": "function", "function": {"name": name, "arguments": json.dumps(arguments)}} - ], + "tool_calls": [{"id": call_id, "name": name, "arguments": json.dumps(arguments)}], } @@ -44,29 +30,22 @@ def _system_prompt(request: dict[str, Any]) -> str: @pytest.fixture def framework(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> BubFramework: monkeypatch.setenv("BUB_HOME", str(tmp_path)) + monkeypatch.setenv("BUB_CLIENT_ARGS", '{"api_format":"chat"}') framework = BubFramework(config_file=tmp_path / "config.yml") framework.workspace = tmp_path framework.load_builtin_hooks() return framework -def _scripted_provider(monkeypatch: pytest.MonkeyPatch, replies: list[dict[str, Any]]) -> list[dict[str, Any]]: - requests: list[dict[str, Any]] = [] - - class Provider: - SUPPORTS_COMPLETION_STREAMING = False - - async def acompletion(self, **kwargs: Any) -> ChatCompletion: - requests.append(kwargs) - return _completion(replies[len(requests) - 1]) - - monkeypatch.setattr("bub.builtin.model_runner.AnyLLM.create", lambda *args, **kwargs: Provider()) - return requests +def _scripted_provider(provider: ProviderService, replies: list[dict[str, Any]]) -> ProviderService: + for message in replies: + provider.reply_chat(message.get("content", ""), message.get("tool_calls")) + return provider @pytest.mark.asyncio async def test_deferred_tool_is_loaded_through_tool_describe_and_persists_on_tape( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch + framework: BubFramework, provider_transport: ProviderService ) -> None: calls: list[str] = [] @@ -78,7 +57,7 @@ def lookup(name: str) -> str: supplied = Tool.from_callable(lookup, name="provider.lookup", deferred=True) other = Tool.from_callable(lambda: "other", name="other", deferred=True) requests = _scripted_provider( - monkeypatch, + provider_transport, [ _tool_call("describe", "tool_describe", {"names": ["provider_lookup"]}), _tool_call("lookup", "provider_lookup", {"name": "Ada"}), @@ -93,30 +72,30 @@ def lookup(name: str) -> str: assert any(event.data.get("text") == "Hello Ada" for event in events if event.kind == "final") assert calls == ["Ada"] - assert _tool_names(requests[0]) == ["direct", "tool_describe"] - assert _tool_names(requests[1]) == ["direct", "tool_describe", "provider_lookup"] - describe_result = next(message for message in requests[1]["messages"] if message["role"] == "tool") + assert _tool_names(requests.body(0)) == ["direct", "tool_describe"] + assert _tool_names(requests.body(1)) == ["direct", "tool_describe", "provider_lookup"] + describe_result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool") assert json.loads(describe_result["content"])["tools"][0]["name"] == "provider_lookup" - prompt = _system_prompt(requests[0]) + prompt = _system_prompt(requests.body(0)) assert "" in prompt assert "- provider_lookup" in prompt assert "- other" in prompt stream = await agent.run_stream(session_id="loading", prompt="Again.", model="openrouter:test-model") _ = [event async for event in stream] - assert _tool_names(requests[3]) == ["direct", "tool_describe", "provider_lookup"] + assert _tool_names(requests.body(3)) == ["direct", "tool_describe", "provider_lookup"] @pytest.mark.asyncio async def test_allowed_tools_filter_covers_direct_and_deferred_tools( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch + framework: BubFramework, provider_transport: ProviderService ) -> None: direct = Tool.from_callable(lambda: "direct", name="direct") denied = Tool.from_callable(lambda: "denied", name="denied") allowed_deferred = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True) denied_deferred = Tool.from_callable(lambda: "secret", name="secret", deferred=True) requests = _scripted_provider( - monkeypatch, + provider_transport, [ _tool_call("describe", "tool_describe", {"names": ["provider_lookup", "secret"]}), {"role": "assistant", "content": "done"}, @@ -132,40 +111,40 @@ async def test_allowed_tools_filter_covers_direct_and_deferred_tools( ) _ = [event async for event in stream] - assert _tool_names(requests[0]) == ["direct", "tool_describe"] - assert _tool_names(requests[1]) == ["direct", "tool_describe", "provider_lookup"] - prompt = _system_prompt(requests[0]) + assert _tool_names(requests.body(0)) == ["direct", "tool_describe"] + assert _tool_names(requests.body(1)) == ["direct", "tool_describe", "provider_lookup"] + prompt = _system_prompt(requests.body(0)) assert "- provider_lookup" in prompt assert "secret" not in prompt - describe_result = next(message for message in requests[1]["messages"] if message["role"] == "tool") + describe_result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool") assert json.loads(describe_result["content"])["unknown"] == ["secret"] @pytest.mark.asyncio async def test_tool_describe_is_hidden_without_deferred_tools( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch + framework: BubFramework, provider_transport: ProviderService ) -> None: from bub.builtin.tools import tool_describe direct = Tool.from_callable(lambda: "direct", name="direct") - requests = _scripted_provider(monkeypatch, [{"role": "assistant", "content": "done"}]) + requests = _scripted_provider(provider_transport, [{"role": "assistant", "content": "done"}]) agent = Agent(framework, tools=[direct, tool_describe], skill_dirs=[]) stream = await agent.run_stream(session_id="plain", prompt="Hi.", model="openrouter:test-model") _ = [event async for event in stream] - assert _tool_names(requests[0]) == ["direct"] - assert "" not in _system_prompt(requests[0]) + assert _tool_names(requests.body(0)) == ["direct"] + assert "" not in _system_prompt(requests.body(0)) @pytest.mark.asyncio async def test_calling_unloaded_deferred_tool_returns_load_guidance( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch + framework: BubFramework, provider_transport: ProviderService ) -> None: calls: list[str] = [] supplied = Tool.from_callable(lambda: calls.append("lookup") or "ok", name="provider.lookup", deferred=True) requests = _scripted_provider( - monkeypatch, + provider_transport, [_tool_call("lookup", "provider_lookup", {}), {"role": "assistant", "content": "done"}], ) agent = Agent(framework, tools=[supplied], skill_dirs=[]) @@ -174,33 +153,33 @@ async def test_calling_unloaded_deferred_tool_returns_load_guidance( _ = [event async for event in stream] assert calls == [] - result = next(message for message in requests[1]["messages"] if message["role"] == "tool") + result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool") assert result["content"] == "Tool `provider_lookup` is not loaded. Call `tool_describe` with its name first." @pytest.mark.asyncio async def test_code_mode_exposes_deferred_tools_to_code_without_loading( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + framework: BubFramework, provider_transport: ProviderService, tmp_path: Path ) -> None: from bub.builtin.codemode import CODE_TOOLS_STATE_KEY, run_code direct = Tool.from_callable(lambda: "direct", name="direct") supplied = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True) - requests = _scripted_provider(monkeypatch, [{"role": "assistant", "content": "done"}]) + requests = _scripted_provider(provider_transport, [{"role": "assistant", "content": "done"}]) agent = Agent(framework, tools=[direct, supplied, run_code], skill_dirs=[]) state: dict[str, Any] = {"code_mode": True, "_runtime_workspace": str(tmp_path)} stream = await agent.run_stream(session_id="code", prompt="Hi.", model="openrouter:test-model", state=state) _ = [event async for event in stream] - assert _tool_names(requests[0]) == ["run_code"] + assert _tool_names(requests.body(0)) == ["run_code"] assert [tool.name for tool in state[CODE_TOOLS_STATE_KEY]] == ["direct", "provider_lookup"] - assert "" not in _system_prompt(requests[0]) + assert "" not in _system_prompt(requests.body(0)) @pytest.mark.asyncio async def test_code_exposure_tools_are_hidden_from_the_model_outside_code_mode( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch + framework: BubFramework, provider_transport: ProviderService ) -> None: from bub.builtin.tools import tool_describe @@ -208,7 +187,7 @@ async def test_code_exposure_tools_are_hidden_from_the_model_outside_code_mode( code_tool = Tool.from_callable(lambda: "secret", name="secret", exposure="code") deferred_code_tool = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True, exposure="code") requests = _scripted_provider( - monkeypatch, + provider_transport, [_tool_call("secret", "secret", {}), {"role": "assistant", "content": "done"}], ) agent = Agent(framework, tools=[direct, code_tool, deferred_code_tool, tool_describe], skill_dirs=[]) @@ -216,41 +195,41 @@ async def test_code_exposure_tools_are_hidden_from_the_model_outside_code_mode( stream = await agent.run_stream(session_id="plain", prompt="Hi.", model="openrouter:test-model") _ = [event async for event in stream] - assert _tool_names(requests[0]) == ["direct"] - assert "" not in _system_prompt(requests[0]) - result = next(message for message in requests[1]["messages"] if message["role"] == "tool") + assert _tool_names(requests.body(0)) == ["direct"] + assert "" not in _system_prompt(requests.body(0)) + result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool") assert result["content"].startswith("Tool `secret` does not exist.") @pytest.mark.asyncio async def test_code_exposure_tools_are_exposed_to_code_in_code_mode( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + framework: BubFramework, provider_transport: ProviderService, tmp_path: Path ) -> None: from bub.builtin.codemode import CODE_TOOLS_STATE_KEY, run_code direct = Tool.from_callable(lambda: "direct", name="direct") code_tool = Tool.from_callable(lambda: "secret", name="secret", exposure="code") - requests = _scripted_provider(monkeypatch, [{"role": "assistant", "content": "done"}]) + requests = _scripted_provider(provider_transport, [{"role": "assistant", "content": "done"}]) agent = Agent(framework, tools=[direct, code_tool, run_code], skill_dirs=[]) state: dict[str, Any] = {"code_mode": True, "_runtime_workspace": str(tmp_path)} stream = await agent.run_stream(session_id="code", prompt="Hi.", model="openrouter:test-model", state=state) _ = [event async for event in stream] - assert _tool_names(requests[0]) == ["run_code"] + assert _tool_names(requests.body(0)) == ["run_code"] assert [tool.name for tool in state[CODE_TOOLS_STATE_KEY]] == ["direct", "secret"] @pytest.mark.asyncio async def test_tool_describe_does_not_describe_code_exposure_tools( - framework: BubFramework, monkeypatch: pytest.MonkeyPatch + framework: BubFramework, provider_transport: ProviderService ) -> None: from bub.builtin.tools import tool_describe deferred = Tool.from_callable(lambda: "lookup", name="provider.lookup", deferred=True) code_tool = Tool.from_callable(lambda: "secret", name="secret", exposure="code") requests = _scripted_provider( - monkeypatch, + provider_transport, [_tool_call("describe", "tool_describe", {"names": ["secret"]}), {"role": "assistant", "content": "done"}], ) agent = Agent(framework, tools=[deferred, code_tool, tool_describe], skill_dirs=[]) @@ -258,5 +237,5 @@ async def test_tool_describe_does_not_describe_code_exposure_tools( stream = await agent.run_stream(session_id="describe", prompt="Hi.", model="openrouter:test-model") _ = [event async for event in stream] - describe_result = next(message for message in requests[1]["messages"] if message["role"] == "tool") + describe_result = next(message for message in requests.body(1)["messages"] if message["role"] == "tool") assert json.loads(describe_result["content"]) == {"tools": [], "unknown": ["secret"]} diff --git a/tests/test_tools.py b/tests/test_tools.py index ebf30e45..d7cb900f 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -30,12 +30,9 @@ def test_tool_builds_completion_payload() -> None: ) assert sample_tool.to_schema() == { - "type": "function", - "function": { - "name": "tests_sample_tool", - "description": "Sample tool", - "parameters": parameters, - }, + "name": "tests_sample_tool", + "description": "Sample tool", + "parameters": parameters, } diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 1f3a9a3c..eb842fb0 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -10,14 +10,12 @@ from typing import Any import pytest -from any_llm.constants import LLMProvider -from any_llm.types.completion import ChatCompletion, ChatCompletionChunk from bub import tracing from bub.builtin.agent import Agent from bub.builtin.context import default_tape_context from bub.builtin.model_runner import ModelRunner -from bub.builtin.settings import AgentSettings, ModelCandidate +from bub.builtin.settings import AgentSettings from bub.framework import BubFramework from bub.hooks import hookimpl from bub.hooks.interception import LlmCallDecision, ToolCallDecision, ToolCallResult @@ -26,6 +24,7 @@ from bub.tape import Tape from bub.tools import Tool, ToolContext, ToolExecutor from bub.utils import workspace_from_state +from tests.model_fakes import ProviderService, chat_events, sse @pytest.fixture @@ -43,35 +42,18 @@ def spans(monkeypatch: pytest.MonkeyPatch) -> Iterator[Any]: @pytest.fixture -def agent(tmp_path: Path) -> Agent: +def agent(tmp_path: Path, provider_transport: ProviderService) -> Agent: framework = BubFramework(config_file=tmp_path / "config.yml") framework.load_builtin_hooks() agent = Agent(framework) - agent.settings = AgentSettings.model_construct(model="openai:test", api_key="unused", api_base=None) + agent.settings = AgentSettings(model="openai:test", api_key="unused", client_args={"api_format": "chat"}) agent.model_runner = ModelRunner(agent.settings, hooks=framework.get_agent_hooks()) agent.__dict__["tape"] = Tape(tmp_path, AsyncTapeStoreAdapter(InMemoryTapeStore()), default_tape_context()) return agent -def completion(text: str = "done", calls: list[dict[str, Any]] | None = None) -> ChatCompletion: - return ChatCompletion.model_validate({ - "id": "response-1", - "object": "chat.completion", - "created": 0, - "model": "actual-model", - "choices": [ - { - "index": 0, - "finish_reason": "tool_calls" if calls else "stop", - "message": {"role": "assistant", "content": text, "tool_calls": calls}, - } - ], - "usage": {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14}, - }) - - def call(name: str, call_id: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: - return {"id": call_id, "type": "function", "function": {"name": name, "arguments": json.dumps(arguments or {})}} + return {"id": call_id, "name": name, "arguments": json.dumps(arguments or {})} @pytest.mark.asyncio @@ -79,6 +61,7 @@ async def test_agent_trajectory_has_parallel_tools_messages_and_tape_links( spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch, + provider_transport: ProviderService, ) -> None: started = 0 both_started = asyncio.Event() @@ -92,12 +75,10 @@ async def handler() -> dict[str, str]: return {"value": "result"} monkeypatch.setitem(agent.tools, "trace_tool", Tool(name="trace_tool", handler=handler)) - replies = iter([completion("checking", [call("trace_tool", "call-1"), call("trace_tool", "call-2")]), completion()]) - - async def respond(**kwargs: Any) -> ChatCompletion: - return next(replies) - - monkeypatch.setattr(agent.model_runner, "completion_response", respond) + provider_transport.reply_chat( + "checking", [call("trace_tool", "call-1"), call("trace_tool", "call-2")], input_tokens=10, output_tokens=4 + ) + provider_transport.reply_chat(input_tokens=10, output_tokens=4) events = await agent.run_stream(session_id="trace-test", prompt="hello", state={}, allowed_tools=["trace_tool"]) async for _ in events: assert tracing.current_span() is None @@ -131,17 +112,10 @@ async def respond(**kwargs: Any) -> ChatCompletion: @pytest.mark.asyncio -async def test_subagent_is_nested_under_its_tool(spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch) -> None: - replies = iter([ - completion("", [call("subagent", "child", {"prompt": "child task"})]), - completion("child done"), - completion(), - ]) - - async def respond(**kwargs: Any) -> ChatCompletion: - return next(replies) - - monkeypatch.setattr(agent.model_runner, "completion_response", respond) +async def test_subagent_is_nested_under_its_tool(spans: Any, agent: Agent, provider_transport: ProviderService) -> None: + provider_transport.reply_chat(calls=[call("subagent", "child", {"prompt": "child task"})]) + provider_transport.reply_chat("child done") + provider_transport.reply_chat() events = await agent.run_stream( session_id="parent", prompt="delegate", state={"_runtime_agent": agent}, allowed_tools=["subagent"] ) @@ -163,30 +137,24 @@ async def respond(**kwargs: Any) -> ChatCompletion: async def test_stream_close_and_cancel_finish_spans_and_provider( spans: Any, agent: Agent, - monkeypatch: pytest.MonkeyPatch, + provider_transport: ProviderService, cancel: bool, ) -> None: closed = asyncio.Event() waiting = asyncio.Event() - async def chunks() -> AsyncIterator[ChatCompletionChunk]: - try: - yield ChatCompletionChunk.model_validate({ - "id": "chunk-1", - "object": "chat.completion.chunk", - "created": 0, - "model": "actual", - "choices": [{"index": 0, "delta": {"content": "first"}}], - }) + import httpx2 + + class Body(httpx2.AsyncByteStream): + async def __aiter__(self): + yield b'data: {"choices":[{"delta":{"content":"first"}}]}\n\n' waiting.set() await asyncio.Event().wait() - finally: - closed.set() - async def respond(**kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: - return chunks() + async def aclose(self): + closed.set() - monkeypatch.setattr(agent.model_runner, "completion_response", respond) + provider_transport.reply(httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=Body())) events = await agent.run_stream(session_id="cancel-test", prompt="hello", state={}, allowed_tools=[]) assert (await anext(events)).kind == "text" assert tracing.current_span() is None @@ -321,24 +289,19 @@ def after_tool_call(self, result: ToolCallResult) -> None: @pytest.mark.asyncio async def test_provider_fallback_records_actual_model( - spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch + spans: Any, agent: Agent, provider_service: ProviderService ) -> None: - class Client: - SUPPORTS_COMPLETION_STREAMING = False - - async def acompletion(self, **kwargs: Any) -> ChatCompletion: - if kwargs["model"] == "unavailable": - raise RuntimeError("try next") - return completion() - - candidates = [ - ModelCandidate(name=f"openai:{name}", provider=LLMProvider.OPENAI, model_id=name) - for name in ["unavailable", "fallback"] - ] - monkeypatch.setattr(agent.model_runner, "iter_llm_clients", lambda model: iter((c, Client()) for c in candidates)) - events = await agent.run_stream(session_id="fallback", prompt="hello", state={}, allowed_tools=[]) - async for _ in events: - pass + import httpx2 + + provider_service.reply(httpx2.Response(503, json={"error": {"message": "unavailable"}})) + provider_service.reply(sse(chat_events())) + async with provider_service.client() as client: + agent.model_runner.settings.fallback_models = ["openai:fallback"] + agent.model_runner.settings.model = "openai:unavailable" + agent.model_runner.settings.client_args = {"http_client": client, "api_format": "chat", "max_retries": 0} + events = await agent.run_stream(session_id="fallback", prompt="hello", state={}, allowed_tools=[]) + async for _ in events: + pass model = next(s for s in spans.get_finished_spans() if s.name.startswith("chat ")) assert model.attributes["gen_ai.request.model"] == "fallback" assert model.attributes["gen_ai.provider.name"] == "openai" @@ -374,28 +337,10 @@ async def main(): @pytest.mark.asyncio -async def test_streaming_usage_arrives_on_last_chunk(spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch) -> None: - async def chunks() -> AsyncIterator[ChatCompletionChunk]: - yield ChatCompletionChunk.model_validate({ - "id": "stream", - "object": "chat.completion.chunk", - "created": 0, - "model": "stream-model", - "choices": [{"index": 0, "delta": {"content": "done"}, "finish_reason": "stop"}], - }) - yield ChatCompletionChunk.model_validate({ - "id": "stream", - "object": "chat.completion.chunk", - "created": 0, - "model": "stream-model", - "choices": [], - "usage": {"prompt_tokens": 19, "completion_tokens": 3, "total_tokens": 22}, - }) - - async def respond(**kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: - return chunks() - - monkeypatch.setattr(agent.model_runner, "completion_response", respond) +async def test_streaming_usage_arrives_on_last_chunk( + spans: Any, agent: Agent, provider_transport: ProviderService +) -> None: + provider_transport.reply_chat(model="stream-model", input_tokens=19, output_tokens=3) events = await agent.run_stream(session_id="stream", prompt="hello", state={}, allowed_tools=[]) assert (await anext(events)).data == {"delta": "done"} assert not spans.get_finished_spans() @@ -410,15 +355,17 @@ async def respond(**kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: @pytest.mark.asyncio async def test_timeout_marks_model_and_agent_as_failed( - spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch + spans: Any, agent: Agent, provider_transport: ProviderService ) -> None: - agent.settings.model_timeout_seconds = 0 + import httpx2 - async def respond(**kwargs: Any) -> ChatCompletion: - await asyncio.Event().wait() - return completion() + class Body(httpx2.AsyncByteStream): + async def __aiter__(self): + await asyncio.Event().wait() + yield b"" - monkeypatch.setattr(agent.model_runner, "completion_response", respond) + agent.settings.model_timeout_seconds = 0.01 + provider_transport.reply(httpx2.Response(200, headers={"content-type": "text/event-stream"}, stream=Body())) events = await agent.run_stream(session_id="timeout", prompt="hello", state={}, allowed_tools=[]) with pytest.raises(TimeoutError): async for _ in events: @@ -431,13 +378,10 @@ async def respond(**kwargs: Any) -> ChatCompletion: @pytest.mark.asyncio async def test_concurrent_sessions_have_independent_traces( - spans: Any, agent: Agent, monkeypatch: pytest.MonkeyPatch + spans: Any, agent: Agent, provider_transport: ProviderService ) -> None: - async def respond(**kwargs: Any) -> ChatCompletion: - await asyncio.sleep(0) - return completion() - - monkeypatch.setattr(agent.model_runner, "completion_response", respond) + provider_transport.reply_chat() + provider_transport.reply_chat() async def run(session: str) -> None: events = await agent.run_stream(session_id=session, prompt="hello", state={}, allowed_tools=[]) diff --git a/uv.lock b/uv.lock index 57cb7973..6480ddab 100644 --- a/uv.lock +++ b/uv.lock @@ -146,43 +146,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "anthropic" -version = "0.94.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "docstring-parser" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/d7/11a649b986da06aaeb81334632f1843d70e3797f54ca4a9c5d604b7987d0/anthropic-0.94.0.tar.gz", hash = "sha256:dde8c57de73538c5136c1bca9b16da92e75446b53a73562cc911574e4934435c", size = 654236, upload-time = "2026-04-10T22:27:59.853Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/ac/7185750e8688f6ff79b0e3d6a61372c88b81ba81fcda8798c70598e18aca/anthropic-0.94.0-py3-none-any.whl", hash = "sha256:42550b401eed8fcd7f6654234560f99c428306301bca726d32bca4bfb6feb748", size = 627519, upload-time = "2026-04-10T22:27:57.541Z" }, -] - -[[package]] -name = "any-llm-sdk" -version = "1.22.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anthropic" }, - { name = "httpx" }, - { name = "openai" }, - { name = "openresponses-types" }, - { name = "pydantic" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bf/de/a29738e1c8702a79a514f9521430fe77686fb9f9122980f4899a7baf58b5/any_llm_sdk-1.22.1.tar.gz", hash = "sha256:b07c6fcef6d7fc13e0f0419bc1464f9316b17e663c34382fb7722a906241f635", size = 164931, upload-time = "2026-07-22T15:43:29.12Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/5a/85c558cc9fb6fd8a7fd3e7523c9c20545316190d22372026ddbf53cdc0c9/any_llm_sdk-1.22.1-py3-none-any.whl", hash = "sha256:950339b68ca1d99fb123c523d246140e1c947b0ff26e3fb9bc37194a49f3b509", size = 217209, upload-time = "2026-07-22T15:43:27.664Z" }, -] - [[package]] name = "anyio" version = "4.14.2" @@ -207,15 +170,15 @@ wheels = [ [[package]] name = "authlib" -version = "1.7.2" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/51/bc1729d3cfdc214b4935f4e886e4dd443c3065fd8e1e66423fe84b490f81/authlib-1.8.0.tar.gz", hash = "sha256:f3ecd5f1da737262fb53bf1a4d95c4ea1ad9dd509316587a255c99ab1838a4f0", size = 177759, upload-time = "2026-08-30T12:12:34.833Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c6/6f124bcfbbfb20fba22c939b4e43a06dccfc0e1ca20e5634ca573cb1e271/authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121", size = 260804, upload-time = "2026-08-30T12:12:33.162Z" }, ] [[package]] @@ -223,8 +186,6 @@ name = "bub" source = { editable = "." } dependencies = [ { name = "aiohttp" }, - { name = "any-llm-sdk" }, - { name = "authlib" }, { name = "httpx", extra = ["socks"] }, { name = "inquirer-textual" }, { name = "loguru" }, @@ -235,6 +196,7 @@ dependencies = [ { name = "python-telegram-bot" }, { name = "pyyaml" }, { name = "rapidfuzz" }, + { name = "republic" }, { name = "rich" }, { name = "typer" }, { name = "typing-extensions" }, @@ -267,8 +229,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.14.3" }, - { name = "any-llm-sdk", extras = ["anthropic"], specifier = ">=1.22.1" }, - { name = "authlib", specifier = ">=1.7.2" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "inquirer-textual", specifier = ">=0.8.0" }, { name = "logfire", marker = "extra == 'logfire'", specifier = ">=4.32.0" }, @@ -286,6 +246,7 @@ requires-dist = [ { name = "python-telegram-bot", specifier = ">=22.7" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "rapidfuzz", specifier = ">=3.14.5" }, + { name = "republic", specifier = ">=0.6.0a1" }, { name = "rich", specifier = ">=14.3.4" }, { name = "typer", specifier = ">=0.24.1" }, { name = "typing-extensions", specifier = ">=4.15.0" }, @@ -531,24 +492,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] -[[package]] -name = "distro" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, -] - -[[package]] -name = "docstring-parser" -version = "0.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, -] - [[package]] name = "executing" version = "2.2.1" @@ -560,11 +503,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.25.2" +version = "4.0.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, + { url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" }, ] [[package]] @@ -690,6 +633,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -710,13 +666,39 @@ socks = [ { name = "socksio" }, ] +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "idna" -version = "3.15" +version = "3.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] @@ -752,78 +734,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/05/f990751e8577b5b3c902acea8a3b03fe8909eada8a5372ada050a169ad7d/inquirer_textual-0.8.0-py3-none-any.whl", hash = "sha256:b2d2afa7fa619c24baad23b3a1bc4dc55cb6b10ad3d4bad09bc747c93ae569fb", size = 36436, upload-time = "2026-09-25T12:49:17.425Z" }, ] -[[package]] -name = "jiter" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, - { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, - { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, - { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, - { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, - { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, - { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, - { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, - { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, - { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, - { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, - { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, - { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, - { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, - { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, - { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, - { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, - { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, - { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, - { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, - { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, - { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, - { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, - { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, - { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, - { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, - { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, - { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, - { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, - { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, - { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, - { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, -] - [[package]] name = "joserfc" version = "1.7.2" @@ -1128,37 +1038,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "openai" -version = "2.31.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/fe/64b3d035780b3188f86c4f6f1bc202e7bb74757ef028802112273b9dcacf/openai-2.31.0.tar.gz", hash = "sha256:43ca59a88fc973ad1848d86b98d7fac207e265ebbd1828b5e4bdfc85f79427a5", size = 684772, upload-time = "2026-04-08T21:01:41.797Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/bc/a8f7c3aa03452fedbb9af8be83e959adba96a6b4a35e416faffcc959c568/openai-2.31.0-py3-none-any.whl", hash = "sha256:44e1344d87e56a493d649b17e2fac519d1368cbb0745f59f1957c4c26de50a0a", size = 1153479, upload-time = "2026-04-08T21:01:39.217Z" }, -] - -[[package]] -name = "openresponses-types" -version = "2.3.0.post1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/26/b612c3215f5599714fa94d63eb5ee59b4eb66dbdeeaf86bb4d848359484d/openresponses_types-2.3.0.post1.tar.gz", hash = "sha256:11b8896d3621d2ac2439f6ff106f34ddcb1bbd517c317a6c852a9df2e98a0753", size = 19254, upload-time = "2026-01-22T20:02:03.933Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/5f/e16dad89ed24f586da5b01b9b206d3adbf21fe1af8e4dc55d5b93158fde6/openresponses_types-2.3.0.post1-py3-none-any.whl", hash = "sha256:88f6abcef9cad839203abff420dd080978bf6eb33cc06ddc5d78da4ccdba7613", size = 13847, upload-time = "2026-01-22T20:02:02.582Z" }, -] - [[package]] name = "opentelemetry-api" version = "1.39.1" @@ -1438,7 +1317,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1446,80 +1325,115 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.50.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/d8/e0fe374bc0082dfd337ca319505c9302ce383aeeeacc33d93383edd159e5/pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a", size = 2222860, upload-time = "2026-10-08T14:26:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7d/0a2f829e3e1d809393faab907e3d9307245cd6043ebf54fad439f72f0003/pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30", size = 2025752, upload-time = "2026-10-08T14:26:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/67/d4/e2808af12de4809dd6a4b532e3877baa5f1000df51bd8ebb8756450c6c92/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5", size = 2048432, upload-time = "2026-10-08T14:26:43.593Z" }, + { url = "https://files.pythonhosted.org/packages/18/58/46ad42a321051585d89f4e563b115015332caf79ba4e96f2785715c7f52d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f", size = 2173565, upload-time = "2026-10-08T14:26:45.299Z" }, + { url = "https://files.pythonhosted.org/packages/92/69/541206e657ecea865e5f6d49a4a0d8062586d05da482d9d7ed77fdcdfc86/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2", size = 2361974, upload-time = "2026-10-08T14:26:47.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c0/cf4850441d0d3d3736abe5e8b6fcb49a50166aa0651ed8444172f81f4b4f/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145", size = 2440817, upload-time = "2026-10-08T14:26:48.937Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e2/2f793fa2f1b338aa03124efd4362956c84d7bf1b331dc86050bc0151e67d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e", size = 2160358, upload-time = "2026-10-08T14:26:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/35e314c08e721ec1f070408a4e41926b90347ecd2863c08c78daad5f8a16/pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f", size = 2231416, upload-time = "2026-10-08T14:26:52.733Z" }, + { url = "https://files.pythonhosted.org/packages/0f/3a/6f7c36afe35a9eca24e5f74f33ca641ac8cf308aae4ac7af4e585d464a40/pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c", size = 2242496, upload-time = "2026-10-08T14:26:54.485Z" }, + { url = "https://files.pythonhosted.org/packages/8d/af/77adf30285836c25f6a927e170df45f8dd4af713c89135b22abcbf3c6d67/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0", size = 2279176, upload-time = "2026-10-08T14:26:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/08/37/4e2a05247f82b59337bf45e9bb14aed85e2d83aecd21e49ad6cedf91882b/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4", size = 2450488, upload-time = "2026-10-08T14:26:58.218Z" }, + { url = "https://files.pythonhosted.org/packages/85/e6/75f25906212ecb94d69a0c8ed16f5c4ac312dc9961c2e93ff2a7fdf9a07a/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa", size = 2476664, upload-time = "2026-10-08T14:26:59.98Z" }, + { url = "https://files.pythonhosted.org/packages/75/72/ebb97b3becd0c0f2c722f698d34dec951ab1aa6086edf535f8769b545e3e/pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe", size = 2102409, upload-time = "2026-10-08T14:27:01.846Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/5ebf3e62f5d0f6e3503ffdfffd7d0c2f8d45690afd37fa0cfdcb1786d7d6/pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1", size = 2171536, upload-time = "2026-10-08T14:27:03.797Z" }, + { url = "https://files.pythonhosted.org/packages/58/1c/879ee9d5b63c60e5a077bab74ad93a4a48476090ca9c97534ab4263d0bae/pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7", size = 2162946, upload-time = "2026-10-08T14:27:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/81/25/f9a6958f73d92f66d620e3e1b091becf6b7a2ea89437118d397e2c6ca9ba/pydantic_core-2.50.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:049b0404792dcb942f1092bfdae5f819ef30445b0d174782e1909fbdd91bb48b", size = 2222904, upload-time = "2026-10-08T14:27:07.869Z" }, + { url = "https://files.pythonhosted.org/packages/c5/41/7f299b2ecf0ddbec8c2a68057ed53d29458ea5b0850615570849c454dd89/pydantic_core-2.50.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f16bc5bb12f4c581b0f40facc28dbf286db32d1bf4e7e4f4c4e0f7f4e34ecf9", size = 2024863, upload-time = "2026-10-08T14:27:09.78Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/a9852fa8780acca5dd81a21bb66d4b2fb41c39dc5672cec66c32cc7f13e9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1d084e92d0f4a096a5155b23d1ac603db8073ef98a3d1384badf1455e5ae742", size = 2047340, upload-time = "2026-10-08T14:27:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/82/cd174e776e71ebcb1d289a57cb3e89565123a84c5708865e12efa82313f9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0af2d2f745ec00a6616c4dfba4477f9156ccfb45690f6ffa5fd34f42e871ed4", size = 2173461, upload-time = "2026-10-08T14:27:13.948Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5c/9c4b2aea09ec7b7af966e79631b1970f1adeb6a8ed001bc8bb64a2d1c23d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f28a5a299d4cefd1066a6883d22600b9ae3606f881c3015d255784634647580", size = 2362629, upload-time = "2026-10-08T14:27:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/93afd007b61c50425c3c8f0402425779341c8d498bd224e5788eaf419c4c/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd70c2d9e73bca09fad54605dfeaae2b4e770b2800c65f5f5342901ed567b9f", size = 2440134, upload-time = "2026-10-08T14:27:17.716Z" }, + { url = "https://files.pythonhosted.org/packages/c9/93/a4ef199535547aaa7c5d2cbf009a1d6a2b252885b14c6e70404e1c661716/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e58acd43ac8d3905659c1d5309318dd576243e723dd7c3b5dd4f555479b77d4b", size = 2158480, upload-time = "2026-10-08T14:27:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/f216e028d3bfbd9f8c67f7a2f0f8db04240926f630e2e66a5a8ac45fe00d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:5a07c644047f5abc268b4c39f8cb5a30e08a3c349228cb70142c7d3ed87587c0", size = 2231837, upload-time = "2026-10-08T14:27:21.742Z" }, + { url = "https://files.pythonhosted.org/packages/56/3f/57a6acf26e0acb82238ba4e25b35cbd6013b1d74eac99c4f358ff56bf6d6/pydantic_core-2.50.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8ad8dad549cd1645be591a50b4573995ee7e018f6619ddc2bc6ea44b2ad9f694", size = 2242240, upload-time = "2026-10-08T14:27:23.564Z" }, + { url = "https://files.pythonhosted.org/packages/94/41/f5f4014b40f91db4479e87dc038ac48a460a60baadea7ff9c942bc64dc62/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1773001198030e16f946a7ff7760fc3ebd45b12150f66fd0f433780043dc13d8", size = 2277852, upload-time = "2026-10-08T14:27:25.517Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fa/485e4db093e10f29676fc696abf9d88f3626ee520e99cc488fb091222db9/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8ab9e74878172948e0426e3d27fba333fd6a1c3f9456d8e75643e8e6868ac1a1", size = 2450132, upload-time = "2026-10-08T14:27:27.493Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/fd07063cfc65e498528cea59b27b979eb5c890a54dcf5bb19ab0a589516d/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e15bb1535f68a27f28e579ba3a8f74e7b05d350c45f5622b76a311d5a19ae48a", size = 2477011, upload-time = "2026-10-08T14:27:29.972Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/6f34c285dc87ed2fae5f1b53c51bb732eda21dc0c6c73d6a5d9dcc7033cd/pydantic_core-2.50.0-cp313-cp313-win32.whl", hash = "sha256:c21e6a6e4e6d32fb6acbc4f0fa69e8319cac0d65eeaa8298d757371cc2a9c687", size = 2102027, upload-time = "2026-10-08T14:27:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/dbdb3ba6699d494e59db2f145aeb97990e28d1503bc9f7bdd57eb4c15677/pydantic_core-2.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f96ccd9368ecbf6685d8f6981584ab72b4f0ffd20685e747737e7e340277b8d", size = 2169974, upload-time = "2026-10-08T14:27:34.692Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/cfe0e52a9b45b5ba12b3a839db928669543948760a059a72f13cf0346c29/pydantic_core-2.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2b88f9f9fa52e1c34938ff1a18ee7fbffe482df0bb43c087a9a60578d273d68", size = 2162504, upload-time = "2026-10-08T14:27:36.989Z" }, + { url = "https://files.pythonhosted.org/packages/59/d9/6dd838672e5ccddf01556bfd1b4a6767e0c75abf9aaa4092cb87a56dbc4a/pydantic_core-2.50.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:62a93a9d206a3580c975c3c1f65a869cd063844d016c004f8d786a9309b3c591", size = 2224496, upload-time = "2026-10-08T14:27:39.281Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/c57d2efcc63fb8fb6ae9ec2818baf30e2a68751e8d1dedefaf3dab2e45fd/pydantic_core-2.50.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a8403eef4a66743e339102fb3cdd8c8b9016b8bd67924685893d062c88896f9", size = 2023860, upload-time = "2026-10-08T14:27:41.332Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/f44ea3f1b00288f715e2320cbcd0f604104325370ef4bee42c5ed2ab076e/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8354fbbe2abf0fb724b9303bef43bfd9b7a2779332813fa6d6559983954e7d8", size = 2049144, upload-time = "2026-10-08T14:27:43.695Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/2f5cf84ceaf6d7c351737124aebc135f50ce48a9dd28d2b5283103face54/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00963fde61cf8880d9e7b5635a9830e0591edfa45168447fdc5b47635c0f6437", size = 2173456, upload-time = "2026-10-08T14:27:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/97/de/dc0bd815a328e62b72bb93b5f3c762939670d4119fd27405b454970833da/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bbd7da16d8b2912cc56c9d0b85c4998a6cbf39b220f81c0d8c397c64672ae0e", size = 2365204, upload-time = "2026-10-08T14:27:48.117Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a5/458c4a29f52fdecb16b192fb27f117359da9c9c58401ebfd956637822dca/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed557fa2744617eac3e34dd39b85037efbf23cd33f06851c00fdb8f18ad8f4c2", size = 2445007, upload-time = "2026-10-08T14:27:50.637Z" }, + { url = "https://files.pythonhosted.org/packages/98/14/0c0e72e0663be91a456167a2f7ed49c65195b388e8f1e8b2bb5083b27458/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f816dc04e99627a5a6352ae51dfb30e603f2cb0b7009c91dc640c533def014", size = 2159911, upload-time = "2026-10-08T14:27:52.881Z" }, + { url = "https://files.pythonhosted.org/packages/62/7f/64af6921e17ed04dda0e07ebbd394b29fb557d33618cb0f3f81ec59c0508/pydantic_core-2.50.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:779b6c74526596a86d38248dedaecfb7851bbaf319c234be042c57acddd2c8c4", size = 2235761, upload-time = "2026-10-08T14:27:55.194Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/b6ff9f7207af629094f4deac947d04be3812d5c95054010ad8f710a052f0/pydantic_core-2.50.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:21b62d45f327eb0802f842c132fda6d01a8376a8077922dc4dda69011c64d34a", size = 2243726, upload-time = "2026-10-08T14:27:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/6f7101f3c746a1686987a32875ae2f7c19f518d783979cee1a8a32e0a9ef/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f31af62efd1fd0257735b72e6716b32d4f207654adeabfe524d44baf1bb6bed", size = 2277158, upload-time = "2026-10-08T14:27:59.714Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/67fe208f3ec7f5d8c47bf7cba70019a84b1cffad947ebc8caef14271b54f/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:933f2d639eb81a3e1f145aec415453cc00983236629933f028d9507222583a2e", size = 2450059, upload-time = "2026-10-08T14:28:02.17Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/4a9c56a69f5841bdd94ffff5876a12ad2733209d5b89cadb67638d69e7c7/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c05d035e72530f6b00297941b0162218601542b76870c3bf2756bd87f16fc538", size = 2479142, upload-time = "2026-10-08T14:28:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/30d36746051e42bb67df986ee874f787526edb99205f6b1583c33dd81202/pydantic_core-2.50.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab3f95f737fc1b258b8210308fc02ce1442cf5950cf6d30b09ad89ab9e8afebd", size = 1483929, upload-time = "2026-10-08T14:28:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/04/2c/c0e8949be4f99a74f02ae02d603bdd518c8a9eba72982a34d675134a792a/pydantic_core-2.50.0-cp314-cp314-win32.whl", hash = "sha256:f12d9690634414fc04b1a7072fdc35c34a9242232c1851fe4518383578bb09d4", size = 2104255, upload-time = "2026-10-08T14:28:08.729Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6f/a5a5baf99509c998e0f2335d66b815880c7d6c63231c8e4017b237d6837d/pydantic_core-2.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:63263d64884688554fb025a3906c7b00573980cfee75f9afeb86239d577384bc", size = 2170939, upload-time = "2026-10-08T14:28:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/40/bc/c89b93b69d59cfe61c1e91b1cd3d87cc740e57327ecfef84ce34b7dcadac/pydantic_core-2.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:753863dd4317ec8cc9eb3e6d9d01a8ef1a1726a8003b4354658d68a1ae05f9db", size = 2157035, upload-time = "2026-10-08T14:28:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5e/d0ad47a406c42db95876900f4bfc520313c28d07a0dd5080da4972220c27/pydantic_core-2.50.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4abc425789f8540e86ca4cfdbc8dc650433cc2ac7c6136fee9bcb29d5665a02", size = 2234007, upload-time = "2026-10-08T14:28:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/69a7ec8400c1e34e5e11ab967f850ffd8ca71bb3aa0a8db93df5912bb6f2/pydantic_core-2.50.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf150693a51ca21e8288cd08a9de05e5ab331776dfcfd0537b14523338f0502a", size = 2000888, upload-time = "2026-10-08T14:28:17.983Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/860bb499f7cdf4bb453e8080af5bc182487f2f025d94baeee29c907bde6f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158749408ee19682b8a2a7e7135cced7f41d6f2f9de96088b1b1609858a6a158", size = 2022761, upload-time = "2026-10-08T14:28:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/89/fd/f571420436e79b9f7cc8f8ec534dee1759c240b4dc086737608f14d05c93/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcbd1fe5083315243447c13f7252ae9fe9d128b1ae2857e3a916c609235dd863", size = 2192910, upload-time = "2026-10-08T14:28:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/b47c4a01ea978a7a3d02551e3625f0344ae1352d67ea78011c2eca5e39e4/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fdeda6272d60b1f6fb63f6a3dade55e274af62e1514d24a6549a12150c385cc", size = 2390178, upload-time = "2026-10-08T14:28:24.861Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/d109f26b3ee4443cca220eaaa4f3fbcf78ff07aeb577becc37d9224db63f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b16e164205a90b1050d2f5469f8a7829db7698ad198c69e6aab6cbfb5648b87", size = 2464453, upload-time = "2026-10-08T14:28:27.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/a4/7be2f608f6f8e7655155057a82a7e2f135ad9b37fcbadd34fdf11b1ade24/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17a5ca9c197788a6424749a09a3dce824cb2c17b72f835f8a5e330935b973609", size = 2150682, upload-time = "2026-10-08T14:28:29.65Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/a8d867f6d3981c232d0f3c2254458cdee8471de7bff3f7edd252cc00c30c/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:01340f4fbb4f854b1f36a6fe9dcd2b26c8936aead4e4ad1205624ac025c875fc", size = 2245995, upload-time = "2026-10-08T14:28:31.934Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a6/7297a39c8814beab877be5ba4f594c2e166108cdd20c1f1c51fb71d5389e/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99e203d5c2a814facef78dcd993b0fb7a933124a3475a38979fc09cceae210b7", size = 2264090, upload-time = "2026-10-08T14:28:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/c3/65/634fc407eaf61abec0d015d6fd4fd8456c18c71358e984f60b0a05092d49/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:2092ce156f92aff17e3345baba2b7c0c1701f32ba922c5de71bd6248fbe164e3", size = 2293708, upload-time = "2026-10-08T14:28:36.725Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/b87fcf8062b8907c77769385a4340a814fe359660593dbbe4d8f3f7e9e61/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:74cbb6cbd74445ca279668790e0c10eccac0428fd79fe061fce6c9e3982ad3fe", size = 2468464, upload-time = "2026-10-08T14:28:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/77/f4/d7aacec95f9e00dcbad84bee2fd0fc081ae292d380dc8a3268e4103de957/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:42fff617cb0b08505d8123d71e6e7a8e54210d9007498f564855bd843ce984b1", size = 2511897, upload-time = "2026-10-08T14:28:41.681Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d8/0a755b0069f9a0f4c57f98fd05b524473952556406244649d632d25f8646/pydantic_core-2.50.0-cp314-cp314t-win32.whl", hash = "sha256:36f9ed6ae1069913e4f6e86d8233e119e83e00a20c54f88faf9c81292f2fecc0", size = 2098906, upload-time = "2026-10-08T14:28:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/cd10a1fbd1ba1d730871e382ec2ac550487465c7c9be1a72b3f4f9181817/pydantic_core-2.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c81c2ec53ea9eb2227c14b3e6de35670b2de163b638f2a803e90a3bd5bbb0", size = 2148359, upload-time = "2026-10-08T14:28:46.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b1/a766eadfbc16b58d401f63dde3bf4e0943dcd2402011b6b8d9de2031ebe0/pydantic_core-2.50.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dec0dafc116ac29a84d143fdbc3b83fdb5d4ed339276be2251154537ab30e14d", size = 2175241, upload-time = "2026-10-08T14:28:48.934Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/43d0e765a80d6fc7d87b5f66a9023777aad6da7e7d64f486a4c2be8158b7/pydantic_core-2.50.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:af2b808a79bb04075e87c81a5b6179365b93f9a851f29dafd67abff72085d0c8", size = 2224750, upload-time = "2026-10-08T14:28:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/07e047c21ad90f184c7e8bfc6c9966b9ca51d4698e1a06460b65ca2ce105/pydantic_core-2.50.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2918547195ffb20118b829fdb8938e9dc92c9527fe6cbe58572594c96362880e", size = 2023936, upload-time = "2026-10-08T14:28:53.978Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a9/fb26fdd343ead65a699245c9d2150e84c47496a9b250859214a90cceecc9/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0393763d66f6f61715488d074a2cefac04aeb3ee281e36fb4925dd44deaf9e17", size = 2048872, upload-time = "2026-10-08T14:28:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4d/9fc4ea28a8ecc1b85fe77a616f8df9a51696476aaa93c5b256269d025044/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62ec6568896e0abf258cbcd22c406c1c8bf27d16224b21bec8f75c4ae88a8173", size = 2173752, upload-time = "2026-10-08T14:28:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/70/7e/74b55196339413d787b283c2568cca4679b0645e48229421228acfa8839f/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec786cb9d597dd75d993f8c1273e31bb2114c9bc22f67fba611e654e8347701b", size = 2365284, upload-time = "2026-10-08T14:29:01.434Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0e/4ebc7a851f0ef63fcaba205739c171e50dd183f8d79dcf0099c3b46aa60b/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fce74add1099da1ea09473268950270071fd56773e2968604efb3ab1d240e02", size = 2445279, upload-time = "2026-10-08T14:29:04.034Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/03d3524fc7d4960840e32fd88c8cf487b5516daa7b6845ffc74274e0247a/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:243088c95e23b12db9f2cd7661d584a3f00814e087489f40cde7f9feac56b694", size = 2160424, upload-time = "2026-10-08T14:29:06.628Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b9/7c530c84049089033dd746700bfbb51520ed9376bc90f3dbbcb319880acf/pydantic_core-2.50.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:42a56b0052ac11d9d0d87b1c94a1ce52e31914fd133f269585e0f63a4ed988f2", size = 2236109, upload-time = "2026-10-08T14:29:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/70e07379ebc2538c22f8c372a37c009b96a3a1bd7921aee1dad28ee41be0/pydantic_core-2.50.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:350001f5573451150d919722ee095aa28bec037d6e23b86d7f04581a910fe924", size = 2244011, upload-time = "2026-10-08T14:29:11.549Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/294810baacae4b088bfac60edab06036951e5ae6e0c20e8f6cfef7d70f66/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:21e38a011783d8afc8b9d79928e273d06f349b52ae84edf63ec18ad07f077484", size = 2278392, upload-time = "2026-10-08T14:29:13.998Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/6990b812f124cf3564b36492799df7a66cd3cf5678004ffe65dda85b136b/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:588d309ce5c85448379556d72011b191c0414ee2e80d7c3f1ebe2ceb2d9027b1", size = 2450012, upload-time = "2026-10-08T14:29:16.524Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c4/f8f1a763550a077c51d2c69b7a32db56d1b54e61c613bc19e6c7fd2e7cb2/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:a8ee3965e01f10e4ff92ba1727626328ab7ba93bcd5674ff2b78ea1048ee0cea", size = 2479233, upload-time = "2026-10-08T14:29:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/7a09baa1e0e4710be47b84aaf808e7b86247d7fa43251ac8934099113f8d/pydantic_core-2.50.0-cp315-cp315-win32.whl", hash = "sha256:c05b75ef3574c9ee4e05bbcf8513f7ccb155d426514be9efef5f6f53152d5f5c", size = 2104507, upload-time = "2026-10-08T14:29:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/f1cf61f747538834ea2c14c442264e4f29663c95294ad7933867633a1dea/pydantic_core-2.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:8447678b49412294801c9ea15ae31ea3bae7da65425268c92d40e032c38eac6f", size = 2171379, upload-time = "2026-10-08T14:29:24.613Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/9871261b760672cfa334498d94bc7dcbff2495345e639e806127f7087254/pydantic_core-2.50.0-cp315-cp315-win_arm64.whl", hash = "sha256:92016718bcf3e6f35a6bd986880191a8da7a35aa1f5b1e97544582ef938464cf", size = 2157182, upload-time = "2026-10-08T14:29:27.082Z" }, + { url = "https://files.pythonhosted.org/packages/de/3c/5107269aee5ee7855fa370ff837998792dbf124b8545244c370bec251a66/pydantic_core-2.50.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:71061800a3c225e730f7997f8a7fad6e0d0dcbe36609cdc7e60576a099a2832b", size = 2234440, upload-time = "2026-10-08T14:29:29.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ac/cb179b0c404337ef517515539f9899164a6d3d43d5c79c2556165da211c1/pydantic_core-2.50.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bc87bd34239835c8d73171acd039e591cea0a2ca8615e6188044ad170a40fca1", size = 2001074, upload-time = "2026-10-08T14:29:32.327Z" }, + { url = "https://files.pythonhosted.org/packages/b0/59/ef1714204f145e9497ab71e8bd82c5e40b5adf365d420be777d1e047cdaf/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c0e5b26d82bf31ab2b044781527b1b1a36e9ed400856b6aca5097eb1abb909", size = 2023016, upload-time = "2026-10-08T14:29:35.06Z" }, + { url = "https://files.pythonhosted.org/packages/87/e9/08a3ade34b4af09a83740b62a4442b7623127d388fc21d4938798dd85a26/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41b9f2821f0a105f88cd54ad03fe1392ec600618ffa8610f7c5e466ecd98c531", size = 2193165, upload-time = "2026-10-08T14:29:37.721Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/29aa3e540d3aca72139e5b7dc56395a940849d85da2b7a9ccfbff71d0928/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:364f62d8024997536e57865cb1c8b36effad3249bf392a29a2379ea69db28238", size = 2390198, upload-time = "2026-10-08T14:29:40.616Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a5cbdde0a2090c47002819d85317428e15b41d22e581cf38e9714b4903af/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:955d7878130dfc124d6a5343e1b87d2933244fe14a4a0a3e98787e8e660a8eb4", size = 2464777, upload-time = "2026-10-08T14:29:43.29Z" }, + { url = "https://files.pythonhosted.org/packages/c5/55/fff3a363b0dcf72fdd72311c676b1fa769f2c13342129c694b2eb9886580/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf81432281af66ba3285b26b09c2d478d84730dc50ff90926c1bdcef54048a33", size = 2150389, upload-time = "2026-10-08T14:29:46.271Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f3/0062361fd2377185d322dc84cd05ec4b96da352195c89939c7b509664721/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:34a4a0938eb30931baca56e55786c5a6871ac7aa891a38c8cabcdb7e49dab91b", size = 2246225, upload-time = "2026-10-08T14:29:49.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/662e3870143d5f1632a02ceb9ce2d5168e88caf3da80e9b42f495ceb1284/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f517a417cd02aa8fb05b603a0ac6d87b7b004c3ba4fcd279cad25cab7229043b", size = 2264404, upload-time = "2026-10-08T14:29:51.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/5e/8b14ebf111700362c6e519e40209d86f521da81ef171b2891af0bb5de311/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:d01029d54ff1f45c195b1f7e6fbf6e58fb7e7e12cda9a6e639570d9c581decb2", size = 2294152, upload-time = "2026-10-08T14:29:54.349Z" }, + { url = "https://files.pythonhosted.org/packages/55/29/85c486e0d25a8b523803032e97d7138edb7b7bc887fa8c6dd085e8824cdb/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d06dbbfe8da01a0574de27afc19915bb3e7dddfbd184bb97e958f94051d8531b", size = 2469160, upload-time = "2026-10-08T14:29:57.193Z" }, + { url = "https://files.pythonhosted.org/packages/12/bf/c451db7567e92d6601c4d2f6bdbb945cf60104182e2b241d8fe0b6f01414/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:5307a8bd49158b57a0ca4e10950d31085aa35d9007e934047043ccc6d28eea97", size = 2512204, upload-time = "2026-10-08T14:30:00.177Z" }, + { url = "https://files.pythonhosted.org/packages/43/1e/5b93a2513ced099acb0dc4dd6684742d6564ade60a59abcbabd161624e07/pydantic_core-2.50.0-cp315-cp315t-win32.whl", hash = "sha256:c2b246fa7cbdf9918488d1542a82bbb928cf71bcba66905c24131981e759ff0b", size = 2099026, upload-time = "2026-10-08T14:30:03.154Z" }, + { url = "https://files.pythonhosted.org/packages/37/f5/1b5967e31b025a23f2f627ffea1f5a874cff0adb72fa5f5af6bb36394ad9/pydantic_core-2.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:36c49d4e1769127461b609f110d91963790091ffcc2de401ac1d3b2f6a63bd54", size = 2148456, upload-time = "2026-10-08T14:30:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/213d6fe84816e8a5ed7b7539ecebb3f14c20739c2469f4e372f0f15bb042/pydantic_core-2.50.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f3abcabb04503023e1053add24472e78878de0bfd5c8a93686be01f4032c363c", size = 2175368, upload-time = "2026-10-08T14:30:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c8/45e2cbac6379b8cec8cffd7e6aed50754fce13d93f0cff28bbbfe18c1d1c/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b916ff828d604d4311a5639b7b3da51eaea3923833ec3e300a5ee35eade99691", size = 2217049, upload-time = "2026-10-08T14:30:23.532Z" }, + { url = "https://files.pythonhosted.org/packages/61/8c/b81c4139bff6305e7a98fa7845a99e05df1461d3c33d0fef417e02114ae7/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:732efbb50977ab7cf18f01d4c255834aeb14cb2425459f7bff681a1ba3a4ffa1", size = 2039121, upload-time = "2026-10-08T14:30:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/0f/49/679293741809cf835290bccf80216aebfcf5d94cb84a34709f18b21abfd5/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad9185366893714cd4514ae5e1a098227704fa395cb41a7855d75968ecedc826", size = 2109280, upload-time = "2026-10-08T14:30:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/f5/49/76f167aec9b9d77a82e38131539ca6c6fcf69d720965f76eda20b87b454d/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0def1dc09802a790e4a1b3cbc4401f0b53c58f273ce3671df9867f7bdf1fbe20", size = 2269313, upload-time = "2026-10-08T14:30:32.096Z" }, ] [[package]] @@ -1588,15 +1502,14 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.2.2" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, - { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338, upload-time = "2026-09-18T01:31:53.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3", size = 38664, upload-time = "2026-09-18T01:31:52.448Z" }, ] [[package]] @@ -1730,6 +1643,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, ] +[[package]] +name = "republic" +version = "0.6.0a1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "filelock" }, + { name = "httpx2" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a2/4c36a93722932d29095c6aca053829d77a5ae1d9a5b2e989e61544d528af/republic-0.6.0a1.tar.gz", hash = "sha256:3eb72270bb8f0e52c3fbe66aed37e88edf7b8f31bb3926db508aeb8f71306a93", size = 227363, upload-time = "2026-10-10T03:59:28.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/1e/c70546f7ab7947ccb44f305e297fb42924db6fc82ec23ecd9d58ecebd692/republic-0.6.0a1-py3-none-any.whl", hash = "sha256:7970fd013890e7c8b121bdc91f3d7cf93b2fa30605cf4540afb5b790a4a86516", size = 74563, upload-time = "2026-10-10T03:59:29.577Z" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -1792,15 +1720,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - [[package]] name = "socksio" version = "1.0.0" @@ -1883,15 +1802,12 @@ wheels = [ ] [[package]] -name = "tqdm" -version = "4.67.3" +name = "truststore" +version = "0.10.4" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] [[package]] @@ -1920,23 +1836,23 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] [[package]] @@ -1985,17 +1901,18 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.2.1" +version = "21.14.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, + { name = "packaging" }, { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/c5/aff062c66b42e2183201a7ace10c6b2e959a9a16525c8e8ca8e59410d27a/virtualenv-21.2.1.tar.gz", hash = "sha256:b66ffe81301766c0d5e2208fc3576652c59d44e7b731fc5f5ed701c9b537fa78", size = 5844770, upload-time = "2026-04-09T18:47:11.482Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/f9/f323b3b6058cff3853b31cf6a49c0425ed797bf9611572ec10d065e08bac/virtualenv-21.14.5.tar.gz", hash = "sha256:c4cb6c13e46b57225a999c7e22a09b163393878facc7ac4c059a57f46faa1647", size = 5467127, upload-time = "2026-10-02T17:42:25.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/0e/f083a76cb590e60dff3868779558eefefb8dfb7c9ed020babc7aa014ccbf/virtualenv-21.2.1-py3-none-any.whl", hash = "sha256:bd16b49c53562b28cf1a3ad2f36edb805ad71301dee70ddc449e5c88a9f919a2", size = 5828326, upload-time = "2026-04-09T18:47:09.331Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl", hash = "sha256:b0651e0174982bba17cc6f0aaa2ef496730cb85a3f35eb5d1119d1c904f7d1da", size = 5487482, upload-time = "2026-10-02T17:42:23.38Z" }, ] [[package]] diff --git a/website/src/content/docs/docs/concepts/tape-and-context.mdx b/website/src/content/docs/docs/concepts/tape-and-context.mdx index 8fc73204..5e1638d3 100644 --- a/website/src/content/docs/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/docs/concepts/tape-and-context.mdx @@ -64,16 +64,18 @@ Spill writes are recorded as `spill.write`. Framework-managed lifecycle outcomes Before the first turn on a tape, `TapeService.ensure_bootstrap_anchor` checks for an anchor entry. If none exists, it writes a `session/start` handoff with `state={"owner": "human"}`. This guarantees that context reconstruction has a starting anchor on every tape. -### default_tape_context: entries → OpenAI messages +### default_tape_context: entries → model messages -`default_tape_context()` builds a `TapeContext` whose `select` function (`_select_messages`) walks tape entries and emits OpenAI-compatible messages. The mapping is: +`default_tape_context()` builds a `TapeContext` whose `select` function (`_select_messages`) walks tape entries and returns a list of `republic.Message` objects. The mapping is: - `anchor` → an `assistant` message of the form `[Anchor created: ]: `. -- `message` → the entry payload, used as a chat message dict. +- `message` → the entry payload, converted with `bub.tape.to_message`. New user messages are stored as `republic.Message.to_dict()`; older Bub payloads (media URLs, `tool_call_id`, `reasoning`, `provider_data`) are still read. - `tool_call` → one `assistant` message with the saved text (or empty content for calls without text, including older entries) and a `tool_calls` array. -- `tool_result` → one `tool` role message per result; when a preceding `tool_call` is pending, Bub copies the call's `id` into `tool_call_id` by result position. +- `tool_result` → one `republic.tool(...)` message per result, paired with the preceding `tool_call` entry's calls by position. Results without a recorded call are skipped. -The context selector is a hook (`build_tape_context`), so plugins can replace it with a different strategy — compaction, summarization, retrieval — without touching the rest of the pipeline. +Saved reasoning and provider data are always included; Republic sends them back only to API formats that accept them. + +The context selector is a hook (`build_tape_context`), so plugins can replace it with a different strategy — compaction, summarization, retrieval — without touching the rest of the pipeline. A custom `select` must return Republic messages (or an awaitable of them); `bub.tape.to_messages` converts stored message payloads. Entries marked `context=False` are removed before the selector runs. This is how operational spill events remain queryable on the tape without appearing in either the default context or a plugin-defined context. diff --git a/website/src/content/docs/docs/getting-started/first-turn.mdx b/website/src/content/docs/docs/getting-started/first-turn.mdx index 67501223..4a17ac42 100644 --- a/website/src/content/docs/docs/getting-started/first-turn.mdx +++ b/website/src/content/docs/docs/getting-started/first-turn.mdx @@ -12,7 +12,7 @@ This tutorial walks through one full turn — the framework's name for one inbou You should have: - [Bub installed](/docs/getting-started/install/) and `bub hooks` reporting the `builtin` plugin -- one model access path: an API key exported as `BUB_API_KEY` or a provider-specific `BUB__API_KEY`; alternatively, OpenAI Codex OAuth via `bub login openai` with `BUB_MODEL=openai:` +- one model access path: an API key exported as `BUB_API_KEY` or a provider-specific `BUB__API_KEY`; alternatively, OpenAI Codex OAuth via `bub login codex` with `BUB_MODEL=codex:` ## 1. Create a workspace @@ -80,15 +80,15 @@ For interactive use, start a chat session instead: bub chat ``` -## 5. Optional: log in with OpenAI OAuth +## 5. Optional: use your ChatGPT plan -If you want to use the OpenAI Codex OAuth path instead of an API key, log in with OAuth: +Install the Codex CLI, then log in to use your ChatGPT plan: ```bash -bub login openai +bub login codex ``` -Bub stores the credential locally. Use it by setting `BUB_MODEL=openai:` for a model available to your Codex account, leaving `BUB_API_KEY` unset, and not setting a custom `BUB_API_BASE`. +The Codex CLI stores the credential in its local file store; Republic reads and refreshes it. Use it by setting `BUB_MODEL=codex:` for a model available to your Codex account, leaving `BUB_API_KEY` unset, and not setting a custom `BUB_API_BASE`. ## Next steps diff --git a/website/src/content/docs/docs/getting-started/index.mdx b/website/src/content/docs/docs/getting-started/index.mdx index f30b93b8..30f2177c 100644 --- a/website/src/content/docs/docs/getting-started/index.mdx +++ b/website/src/content/docs/docs/getting-started/index.mdx @@ -17,7 +17,7 @@ You should have: - a network connection that can reach `bub.build`, `astral.sh`, and PyPI - one model access path for the model-backed step: - an API key exported as `BUB_API_KEY` or as a provider-specific `BUB__API_KEY`, or - - OpenAI Codex OAuth via `bub login openai` with `BUB_MODEL=openai:` + - OpenAI Codex OAuth via `bub login codex` with `BUB_MODEL=codex:` If you only want to inspect Bub without running a model turn, the API key is optional — comma commands such as `,help` work without one. diff --git a/website/src/content/docs/docs/operate/configure.mdx b/website/src/content/docs/docs/operate/configure.mdx index 1208fb89..1656f4af 100644 --- a/website/src/content/docs/docs/operate/configure.mdx +++ b/website/src/content/docs/docs/operate/configure.mdx @@ -13,16 +13,16 @@ Bub reads its own configuration from these places, in order of precedence (highe 2. `.env` values loaded during CLI startup or by settings classes that explicitly declare `env_file=".env"`. 3. `~/.bub/config.yml` (created by `bub onboard`). -Downstream model SDKs may still read their own provider variables, such as `OPENROUTER_API_KEY`, when no explicit `BUB_API_KEY` is supplied. Bub's settings layer only maps `BUB_*` variables. +When no explicit Bub key is supplied, Bub reads `_API_KEY` and `_API_BASE`, using Republic provider names with hyphens replaced by underscores. Examples include `OPENROUTER_API_KEY`, `GOOGLE_API_KEY`, and `AZURE_OPENAI_API_KEY`. Explicit `BUB_*` settings take precedence. ## Before you begin - Bub is installed (`bub --help` works). -- You have one model provider's API key, or you have run `bub login openai` for OAuth. +- You have one model provider's API key, or you have run `bub login codex` for OAuth. ## 1. Run the interactive setup -`bub onboard` guides you through provider selection, API key and URL (when needed), connection checking, searchable model selection, channels, and streaming settings. Hosted providers use their default URL; choose **OpenAI-compatible** for a custom OpenAI-compatible server. Leave the API key blank to use environment credentials. If the model list is unavailable, you can edit the connection, retry, or enter a model ID manually. The connection check fetches models without generating a completion. An existing OpenAI OAuth login skips model discovery and uses manual model entry. The collected configuration replaces `~/.bub/config.yml`; existing file contents are not merged into the onboarding prompts. Plugins may add prompts through the `onboard_config` hook. +`bub onboard` guides you through provider selection, API key and URL (when needed), connection checking, searchable model selection, channels, and streaming settings. Hosted providers use their default URL; choose **OpenAI-compatible** for a custom OpenAI-compatible server. Leave the API key blank to use environment credentials. If the model list is unavailable, you can edit the connection, retry, or enter a model ID manually. The connection check fetches models without generating a completion. Selecting Codex skips model discovery and uses manual model entry. The collected configuration replaces `~/.bub/config.yml`; existing values supply prompt defaults and are preserved unless updated. Plugins may add prompts through the `onboard_config` hook. ```bash bub onboard @@ -55,14 +55,14 @@ Every YAML key has a matching environment variable. The pattern is `BUB_`. | -| `BUB_API_KEY` | API key for the active provider. Leave unset when using `bub login openai`. | -| `BUB_API_BASE` | Override the provider's base URL. Leave unset for Codex OAuth so Bub can route through the Codex backend. | +| `BUB_MODEL` | Model identifier, e.g. `openrouter:openrouter/free` or `codex:`. | +| `BUB_API_KEY` | API key for the active provider. Leave unset when using `bub login codex`. | +| `BUB_API_BASE` | Override the provider's base URL. Leave unset to use the selected provider's default endpoint. | | `BUB_HOME` | Bub's runtime data directory (history, tapes, managed plugin project). Default `~/.bub`; does not move the default config file. | | `BUB__API_KEY` | Per-provider key, e.g. `BUB_OPENROUTER_API_KEY`. | | `BUB__API_BASE` | Per-provider base URL. | -When you use `bub login openai`, Bub reads Codex OAuth credentials from `CODEX_HOME` or `~/.codex` and refreshes them automatically. Use a model id under the `openai:` provider and keep both `BUB_API_KEY` and `BUB_API_BASE` unset for that path. +Republic reads and refreshes Codex file credentials from `$CODEX_HOME/auth.json` or `~/.codex/auth.json`. Install the Codex CLI for a new login, then select `codex:` and leave `BUB_API_KEY` unset. Use `openai:` with API credentials for OpenAI API access. To reach an endpoint that speaks a supported API under a name of its own (a company proxy, a relay, a second account), declare it under `providers` and use that name as the model prefix; see [Custom providers](/docs/reference/settings/#custom-providers). diff --git a/website/src/content/docs/docs/reference/cli.mdx b/website/src/content/docs/docs/reference/cli.mdx index 81fe0aa8..44150b5a 100644 --- a/website/src/content/docs/docs/reference/cli.mdx +++ b/website/src/content/docs/docs/reference/cli.mdx @@ -109,13 +109,13 @@ Omit the name to run all onboarding hooks. Behavior notes: - All providers follow the same flow: select a provider, enter connection details, check the connection, choose a model, then configure channels and streaming. -- Provider labels distinguish **OpenAI (official API)** from **OpenAI-compatible (custom URL / local server)**. Compatible services are saved as `openai:` with your API base URL. -- Hosted providers use their default endpoint and normally ask only for an API key. Compatible services, Azure, Ollama, other providers, and custom endpoints supplied by preceding hooks also prompt for a URL. Blank API keys retain a key supplied in this run for the same endpoint or use environment credentials, including `OPENAI_API_KEY`, `BUB_API_KEY`, and `BUB_OPENAI_API_KEY`. Environment keys are not copied into the saved configuration. A compatible server gets the SDK's placeholder key only when no key is supplied or available from the environment. -- Bub fetches the provider's model list with a 10-second asynchronous timeout, then offers searchable model selection and manual entry. Blocking SDK calls may take longer. This checks access to the models endpoint; it does not test a model completion. If discovery fails, edit the URL/key, retry, or enter a model ID manually. Providers that do not support model discovery, including Azure in the current SDK, explain the limitation and proceed directly to manual entry without offering retries. -- When the effective connection uses OpenAI OAuth, model discovery is skipped and you enter a model ID manually. Displayed default URLs are not written to the configuration. Leave the URL blank to preserve the default and OAuth routing; entering a URL explicitly saves it, even if it matches the displayed default. +- Provider labels distinguish **OpenAI (official API)** from **OpenAI-compatible (custom URL / local server)**. Compatible services are saved as `openai:` with your API base URL and `client_args.api_format: chat`. +- Hosted providers use their default endpoint and normally ask only for an API key. Compatible services, Azure OpenAI, Ollama, other providers, and custom endpoints supplied by preceding hooks also prompt for a URL. Blank API keys retain a key supplied in this run for the same endpoint or use environment credentials, including `OPENAI_API_KEY`, `BUB_API_KEY`, and `BUB_OPENAI_API_KEY`. Environment keys are not copied into the saved configuration. +- Bub fetches the provider's model list with a 10-second asynchronous timeout, then offers searchable model selection and manual entry. This checks access to the models endpoint; it does not test a model completion. If discovery fails, edit the URL/key, retry, or enter a model ID manually. Providers that do not support model discovery, including Codex, explain the limitation and proceed directly to manual entry without offering retries. +- When you select the Codex provider, model discovery is skipped and you enter a model ID manually. Displayed default URLs are not written to the configuration. Leave the URL blank to preserve the default endpoint; entering a URL explicitly saves it, even if it matches the displayed default. - Each plugin's `onboard_config` hook receives a copy of the loaded configuration plus updates from earlier hooks. Existing fields are preserved unless a hook overrides them; non-dict returns abort with `TypeError`. - The merged dict is validated through `configure.validate` before being written. -- `onboard` is interactive. The provider labels and prompt order have changed, and connection results determine subsequent prompts. Scripts that supply a fixed sequence of answers must be updated; for unattended setup, write the configuration file directly. The bundled installers launch `onboard` only in interactive mode. +- `onboard` is interactive, and connection results determine subsequent prompts. For unattended setup, write the configuration file directly. The bundled installers launch `onboard` only in interactive mode. - After saving a configuration with at least one channel on Linux or Windows, onboarding asks whether to install the gateway as a per-user background service. The default is no. Installation failure leaves the saved configuration intact and exits with code `1`. Exit codes: @@ -189,25 +189,25 @@ bub login [OPTIONS] COMMAND [ARGS]... | --- | --- | --- | --- | | `--help` | flag | — | Show help and exit. | -### `bub login openai` +### `bub login codex` -Run the Codex OAuth flow to obtain OpenAI credentials, then save them under the resolved Codex home. +Run the installed Codex CLI with file credential storage. ```text -bub login openai [OPTIONS] +bub login codex [OPTIONS] ``` | Option | Type | Default | Description | | --- | --- | --- | --- | -| `--codex-home` | PATH | `$CODEX_HOME` or `~/.codex` | Directory to store the resulting `auth.json`. | -| `--browser` / `--no-browser` | flag | `--browser` | Open the OAuth authorize URL in the default browser. | -| `--manual` | flag | off | Skip the local callback server and prompt for the callback URL or code. | -| `--timeout` | FLOAT | `300.0` | OAuth wait timeout in seconds. | +| `--executable` | TEXT | `codex` | Codex CLI executable. | +| `--device-auth` | flag | off | Use device authorization for a terminal without a browser. | +| `--codex-home` | PATH | `$CODEX_HOME` or `~/.codex` | Credential directory for the login subprocess. | Behavior notes: -- On success prints `login: ok`, the account id, the auth file path, and a usage hint to set `BUB_MODEL=openai:` and omit `BUB_API_KEY`. -- On `CodexOAuthLoginError` exits with code `1`. +- On success prints `login: ok`, the account id, and a usage hint to set `BUB_MODEL=codex:` and omit `BUB_API_KEY`. +- Authentication failure exits with code `1`. Missing executables produce an installation hint. +- Credentials are stored in `$CODEX_HOME/auth.json` or `~/.codex/auth.json`. Use `--codex-home` to select a directory and `--device-auth` for headless login. ## `bub hooks` diff --git a/website/src/content/docs/docs/reference/hooks.mdx b/website/src/content/docs/docs/reference/hooks.mdx index a44ab197..abd744f0 100644 --- a/website/src/content/docs/docs/reference/hooks.mdx +++ b/website/src/content/docs/docs/reference/hooks.mdx @@ -17,7 +17,7 @@ For the *why* and *how* of each stage see [Turn pipeline](/docs/concepts/turn-pi | --- | --- | --- | --- | --- | --- | | `resolve_session` | firstresult | `(message: Envelope) -> str` | session id | `BubFramework.process_inbound` | Falls back to `"{channel}:{chat_id}"` when no impl returns a value. | | `load_state` | broadcast | `(message: Envelope, session_id: str) -> TurnState` | dict to merge | `BubFramework.process_inbound` | Framework reverses results, then merges in order so high-priority keys overwrite low-priority. | -| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> str \| list[dict]` | prompt or content parts | `BubFramework.process_inbound` | Falls back to `content_of(message)` when no impl returns a non-`None` value, or when the selected firstresult is falsy. A falsy selected value does not cause lower-priority impls to run. | +| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> list[UserContent]` | prompt content | `BubFramework.process_inbound` | Falls back to `content_of(message)` when no impl returns a non-`None` value, or when the selected firstresult is falsy. A falsy selected value does not cause lower-priority impls to run. | | `run_model` | firstresult | `(prompt, session_id, state) -> str` | model text | `HookRuntime.run_model` | Legacy text path. Implement either `run_model` or `run_model_stream`, not both. | | `run_model_stream` | firstresult | `(prompt, session_id, state) -> AsyncStreamEvents` | async stream | `HookRuntime.run_model_stream` | Preferred. Falls back to wrapping a `run_model` result in a one-chunk stream when no streaming impl exists. | | `continue_prompt` | firstresult | `(prompt: str \| list[dict] \| None, tape: Tape, state: StreamState) -> str \| None` | optional next-step user message | `Agent._stream_events_with_auto_handoff` | Runs before each continued agent-loop step. `prompt` is the prompt used by the completed step, or `None` if none was added. Implementations may be sync or async. If no hook returns a string, the next model call uses the existing conversation without adding a user message. | @@ -41,6 +41,12 @@ For the *why* and *how* of each stage see [Turn pipeline](/docs/concepts/turn-pi | `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` via `AgentHooks` | Per tool invocation. `proceed(arguments=…)` folds argument changes; `replace(result)` / `deny(message)` short-circuit. Veto only via the decision object — exceptions are logged and skipped. | | `after_tool_call` | terminal handler | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | none | `ToolExecutor` via `AgentHooks` | Fires for success, failure and deny/replace. Cancellation is not observed. Assigning `result.result` replaces the model-facing value; `result.error` remains the original `BubError` for failures. | +### Prompt content + +`build_prompt` returns `list[UserContent]` from `bub.prompt`, where `UserContent = str | republic.Image | republic.Audio | republic.Video`. Build media with `republic.image(...)`, `republic.audio(...)` or `republic.video(...)`. For compatibility, it may still return the legacy forms: a string, or a list of content-block dicts such as `{"type": "image", "media_type": "image/png", "url": "..."}`. The framework converts them by inspecting the returned value. + +Hooks that receive a prompt (`run_model`, `run_model_stream`, `continue_prompt`, `system_prompt`) still get the legacy form: a string when the prompt has no media, otherwise content blocks whose media use URLs (inline media become `data:` URLs). `bub.prompt.to_content(prompt)` converts it to user content. + ## How hooks are invoked `HookRuntime` (in `src/bub/hooks/runtime.py`) wraps the `pluggy.PluginManager` with the following semantics. diff --git a/website/src/content/docs/docs/reference/settings.mdx b/website/src/content/docs/docs/reference/settings.mdx index 2f14762c..59f1ad01 100644 --- a/website/src/content/docs/docs/reference/settings.mdx +++ b/website/src/content/docs/docs/reference/settings.mdx @@ -65,23 +65,33 @@ Loaded under the YAML root section. | `BUB_API_BASE` | unset | `api_base` | Default API base URL or per-provider mapping. | | `BUB__API_KEY` | unset | — | Provider-scoped API key, e.g. `BUB_OPENAI_API_KEY`. | | `BUB__API_BASE` | unset | — | Provider-scoped API base URL, e.g. `BUB_OPENROUTER_API_BASE`. | -| `BUB_PROVIDERS` | `{}` | `providers` | Named endpoints, each speaking one any-llm provider's API (`type`) with its own `api_base` and `api_key`. See [Custom providers](#custom-providers). | +| `BUB_PROVIDERS` | `{}` | `providers` | Named endpoints, each speaking one Republic provider's API (`type`) with its own `api_base` and `api_key`. See [Custom providers](#custom-providers). | | `BUB_MAX_STEPS` | unlimited | `max_steps` | Maximum agent loop iterations per turn. Must be a positive integer when set. | -| `BUB_MAX_TOKENS` | `16384` | `max_tokens` | Maximum tokens per model call. | +| `BUB_MAX_TOKENS` | `16384` | `max_tokens` | Maximum tokens per model call; omitted for Codex. | | `BUB_MODEL_TIMEOUT_SECONDS` | `null` | `model_timeout_seconds` | Per-call timeout in seconds. | | `BUB_CLIENT_ARGS` | `{}` | `client_args` | Extra kwargs passed to the underlying model client (JSON / dict). | -| `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | Extra kwargs passed to each completion call, e.g. `{"reasoning_effort":"high"}`. Bub-managed arguments take precedence. | +| `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | Extra kwargs passed to each completion call, e.g. `{"reasoning_effort":"high"}`. Bub supplies the tools and token limit; see option precedence below. | | `BUB_VERBOSE` | `0` | `verbose` | Logging verbosity level (`0`–`2`). | For example, `command_prefix: "!"` enables `!help` in builtin channels; `,help` becomes ordinary text. CLI controls (`quit`, `exit`, `thinking`), completion, and shell mode use the same prefix. Telegram keeps its existing native slash-command filtering; with `/` as the prefix, use `/bub /help`. -Provider-specific defaults are gathered at startup by scanning `os.environ` for `^BUB_(.+)_(API_KEY|API_BASE)$` and lowercasing the captured provider name. +Provider-specific defaults are gathered at startup by scanning `os.environ` for `^BUB_(.+)_(API_KEY|API_BASE)$` and resolving the captured environment prefix to the Republic provider name (for example, `AZURE_OPENAI` → `azure-openai`). + +Bub uses Republic for model requests. Use `codex:` for ChatGPT plan access and `openai:` for OpenAI API access. A stored Codex login never changes the `openai:` route. Provider identifiers follow Republic, including `google:` and `azure-openai:`. + +`client_args` accepts Republic provider options such as `headers`, `api_format`, `timeout`, and `max_retries`. Bub supplies the resolved connection settings (`api_key`, `api_base`); other options pass through unchanged. Protocol defaults follow Republic. Set `api_format: chat` explicitly for a Chat Completions endpoint. An injected HTTP client must be an `httpx2.AsyncClient` and stays caller-owned. + +`completion_args` accepts Republic chat options. Bub supplies `tools` and the runtime token limit; a session's `reasoning_effort` overrides the configured chat option. The runtime token limit is omitted for Codex because that endpoint does not support it. Republic handles wire-format conversion and Anthropic requests enable prompt caching. + +Provider-specific fields go in `extra_body`, which is accepted in both `client_args` and `completion_args`. Republic deep-merges provider defaults with request extras, with request values taking precedence, then merges them over the encoded request body. These explicit wire fields can override chat options, including reasoning and token limits; Bub passes them through unchanged. + +Provider names and available protocols follow [Republic's provider directory](https://getrepublic.org/providers/). Custom provider implementations must be registered with Republic. ### Custom providers -The model prefix normally names an any-llm provider, and `api_key` / `api_base` hold one value per provider. Use `providers` when an endpoint speaks an API any-llm already supports but needs a name of its own: a company proxy, a relay or local gateway, a self-hosted server, or a second account with the same vendor. Each name becomes a model prefix, and `type` is the any-llm provider whose API it speaks: +The model prefix normally names a Republic provider, and `api_key` / `api_base` hold one value per provider. Use `providers` when an endpoint speaks an API Republic already supports but needs a name of its own: a company proxy, a relay or local gateway, a self-hosted server, or a second account with the same vendor. Each name becomes a model prefix, and `type` is the Republic provider whose API it speaks: ```yaml model: proxy:gpt-5.5 @@ -99,9 +109,7 @@ providers: api_key: sk-relay-... ``` -Here `proxy:gpt-5.5` and `openai:gpt-5.5` both use the OpenAI API but go to different endpoints with different keys; `openai:` keeps its own `api_key` / `api_base`, or Codex OAuth. A field left out of an entry falls back to `api_key` / `api_base` and `BUB__API_KEY` / `BUB__API_BASE`. An unknown `type` is rejected when settings load. - -OpenAI Codex OAuth does not need a separate request-format setting. After `bub login openai`, Bub detects the stored Codex OAuth token when the selected model uses the `openai:` provider and no custom API base is set. +Here `proxy:gpt-5.5` and `openai:gpt-5.5` both use the OpenAI API but go to different endpoints with different keys; `openai:` keeps its own `api_key` / `api_base`. A field left out of an entry falls back to `api_key` / `api_base` and `BUB__API_KEY` / `BUB__API_BASE`. An unknown `type` is rejected when settings load. ## Spill sidecar — `SpillSettings` @@ -173,11 +181,11 @@ See [Operate › Channels › Telegram](/docs/operate/channels/telegram/) for de ## Login — third-party env vars -`bub login openai` reads one non-`BUB_*` env var: +`bub login codex` reads one non-`BUB_*` env var: | Env var | Default | Read by | Description | | --- | --- | --- | --- | -| `CODEX_HOME` | `~/.codex` | `bub login openai` (`src/bub/builtin/auth.py`) | Directory to store Codex OAuth `auth.json`. Overridden by `--codex-home`. | +| `CODEX_HOME` | `~/.codex` | `bub login codex` (`src/bub/builtin/auth.py`) | Directory to store Codex OAuth `auth.json`. Overridden by `--codex-home`. | ## Plugin-specific settings diff --git a/website/src/content/docs/docs/reference/types.mdx b/website/src/content/docs/docs/reference/types.mdx index eb3b0e8c..11fca1b4 100644 --- a/website/src/content/docs/docs/reference/types.mdx +++ b/website/src/content/docs/docs/reference/types.mdx @@ -31,7 +31,7 @@ type TurnState = dict[str, Any] @dataclass(frozen=True) class TurnResult: session_id: str - prompt: str | list[dict[str, Any]] + prompt: list[UserContent] model_output: str outbounds: list[Envelope] = field(default_factory=list) state: TurnState = field(default_factory=dict) @@ -67,6 +67,10 @@ from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState `StreamEvent.kind` is one of `text`, `reasoning`, `tool_call`, `tool_result`, `usage`, `error`, or `final`. `AsyncStreamEvents` wraps an async iterator and exposes terminal `error` and `usage` state. +Builtin `tool_call` events use Republic fields: `id`, `name`, `arguments`, and `metadata`. Usage dictionaries contain `input_tokens`, `output_tokens`, `reasoning_tokens`, `cached_tokens`, `cache_write_tokens`, and `total_tokens`. + +Builtin multipart prompts contain text blocks (`type: text`, `text`) or media blocks (`type: image|audio|video`, `media_type`, `url`). The URL can be remote or a base64 data URL. Bub converts these persisted blocks into native Republic parts; Republic owns provider encoding. Hooks supply these text and media blocks. + ### Errors — `bub.errors` ```python diff --git a/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx b/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx index 0415add1..be6c9b64 100644 --- a/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx +++ b/website/src/content/docs/zh-cn/docs/concepts/tape-and-context.mdx @@ -64,16 +64,18 @@ spill 写入以 `spill.write` event 记录。框架管理的生命周期结果 在某条 tape 的第一次 turn 之前,`TapeService.ensure_bootstrap_anchor` 会检查是否存在 anchor entry。如果没有,则写入一条 `session/start` handoff,`state={"owner": "human"}`。这保证每条 tape 在 context 重建时都有起始 anchor。 -### default_tape_context:entry → OpenAI 消息 +### default_tape_context:entry → 模型消息 -`default_tape_context()` 构造一个 `TapeContext`,其 `select` 函数(`_select_messages`)遍历 tape entry 并产出 OpenAI 兼容的消息。映射规则如下: +`default_tape_context()` 构造一个 `TapeContext`,其 `select` 函数(`_select_messages`)遍历 tape entry 并返回 `republic.Message` 列表。映射规则如下: - `anchor` → 形如 `[Anchor created: ]: ` 的 `assistant` 消息。 -- `message` → 直接以 entry payload 作为 chat 消息 dict。 +- `message` → 用 `bub.tape.to_message` 转换 entry payload。新的用户消息以 `republic.Message.to_dict()` 格式存储;旧版 Bub payload(媒体 URL、`tool_call_id`、`reasoning`、`provider_data`)仍可读取。 - `tool_call` → 一条包含已保存文本与 `tool_calls` 数组的 `assistant` 消息;无文本的调用(包括旧版 entry)使用空 content。 -- `tool_result` → 每个 result 一条 `tool` role 消息;若前面有 pending `tool_call`,Bub 会按 result 位置复制对应 call 的 `id` 到 `tool_call_id`。 +- `tool_result` → 每个 result 一条 `republic.tool(...)` 消息,按位置与前一个 `tool_call` entry 中的 call 配对;没有对应 call 的 result 会被跳过。 -context selector 本身是个 hook(`build_tape_context`),插件可以用其他策略(压缩、摘要、检索)替换它,而无需触动 pipeline 其余部分。 +已保存的 reasoning 与 provider data 总是会带上;Republic 只会把它们发回接受这些数据的 API format。 + +context selector 本身是个 hook(`build_tape_context`)。自定义 `select` 必须返回 Republic 消息(或返回它们的 awaitable);`bub.tape.to_messages` 可转换已存储的消息 payload。插件可以用其他策略(压缩、摘要、检索)替换它,而无需触动 pipeline 其余部分。 带有 `context=False` 标记的 entry 会在 selector 运行前被移除。spill 运维 event 因此仍可在 tape 上查询,但不会进入默认 context 或插件自定义 context。 diff --git a/website/src/content/docs/zh-cn/docs/getting-started/first-turn.mdx b/website/src/content/docs/zh-cn/docs/getting-started/first-turn.mdx index a056182c..b7ed31f8 100644 --- a/website/src/content/docs/zh-cn/docs/getting-started/first-turn.mdx +++ b/website/src/content/docs/zh-cn/docs/getting-started/first-turn.mdx @@ -12,7 +12,7 @@ sidebar: 你需要准备: - 已 [安装 Bub](/zh-cn/docs/getting-started/install/) 且 `bub hooks` 报告 `builtin` 插件 -- 一种模型访问方式:通过 `BUB_API_KEY` 或提供商专用 `BUB__API_KEY` 导出的 API key;也可以通过 `bub login openai` 完成 OpenAI Codex OAuth,并设置 `BUB_MODEL=openai:` +- 一种模型访问方式:通过 `BUB_API_KEY` 或提供商专用 `BUB__API_KEY` 导出的 API key;也可以通过 `bub login codex` 完成 OpenAI Codex OAuth,并设置 `BUB_MODEL=codex:` ## 1. 创建 workspace @@ -80,15 +80,15 @@ Bub 会解析 session、加载 state、构建用户 prompt,并用包含 `AGENT bub chat ``` -## 5. 可选:使用 OpenAI OAuth 登录 +## 5. 可选:使用 ChatGPT 套餐 -如果想使用 OpenAI Codex OAuth 路径而不是 API key,使用 OAuth 登录: +安装 Codex CLI,然后登录以使用 ChatGPT 套餐: ```bash -bub login openai +bub login codex ``` -Bub 会在本地保存凭据。使用该路径时,选择你的 Codex 账号可用的模型并设置 `BUB_MODEL=openai:`,同时保持 `BUB_API_KEY` 未设置,也不要设置自定义 `BUB_API_BASE`。 +Codex CLI 将凭据保存到本地文件,Republic 负责读取和刷新。使用该路径时,选择你的 Codex 账号可用的模型并设置 `BUB_MODEL=codex:`,同时保持 `BUB_API_KEY` 未设置,也不要设置自定义 `BUB_API_BASE`。 ## 下一步 diff --git a/website/src/content/docs/zh-cn/docs/getting-started/index.mdx b/website/src/content/docs/zh-cn/docs/getting-started/index.mdx index e5dc30c2..1aeb76f8 100644 --- a/website/src/content/docs/zh-cn/docs/getting-started/index.mdx +++ b/website/src/content/docs/zh-cn/docs/getting-started/index.mdx @@ -17,7 +17,7 @@ Bub 是一个用于在共享环境中构建 agent 的小型 Python 框架。内 - 可以访问 `bub.build`、`astral.sh` 和 PyPI 的网络连接 - 一种模型访问方式,用于 model-backed 阶段: - 通过 `BUB_API_KEY` 或提供商专用 `BUB__API_KEY` 导出的 API key,或 - - 通过 `bub login openai` 完成 OpenAI Codex OAuth,并设置 `BUB_MODEL=openai:` + - 通过 `bub login codex` 完成 OpenAI Codex OAuth,并设置 `BUB_MODEL=codex:` 如果只想检查 Bub 而不实际跑一次 model turn,API key 是可选的——`,help` 等 comma 命令无需它即可使用。 diff --git a/website/src/content/docs/zh-cn/docs/operate/configure.mdx b/website/src/content/docs/zh-cn/docs/operate/configure.mdx index 90a68854..d2da0a36 100644 --- a/website/src/content/docs/zh-cn/docs/operate/configure.mdx +++ b/website/src/content/docs/zh-cn/docs/operate/configure.mdx @@ -13,16 +13,16 @@ Bub 自己的配置按以下优先级(从高到低)读取: 2. CLI 启动阶段加载的 `.env` 值,或显式声明 `env_file=".env"` 的 settings class 读取到的 `.env` 值。 3. `~/.bub/config.yml`(由 `bub onboard` 创建)。 -当未显式提供 `BUB_API_KEY` 时,下游模型 SDK 仍可能读取自己的提供商变量,例如 `OPENROUTER_API_KEY`。Bub 的 settings 层只映射 `BUB_*` 变量。 +当没有显式配置 Bub 凭证时,Bub 读取 `_API_KEY` 和 `_API_BASE`。provider 使用 Republic 名称,将连字符替换为下划线,例如 `OPENROUTER_API_KEY`、`GOOGLE_API_KEY` 和 `AZURE_OPENAI_API_KEY`。显式的 `BUB_*` 设置优先。 ## 开始之前 - Bub 已安装(`bub --help` 可用)。 -- 你已拥有某个模型提供商的 API key,或已运行 `bub login openai` 完成 OAuth。 +- 你已拥有某个模型提供商的 API key,或已运行 `bub login codex` 完成 OAuth。 ## 1. 运行交互式初始化 -`bub onboard` 依次引导你选择 provider、填写 API key 和必要的 URL、检查连接、搜索选择模型,再配置 channel 与流式输出。托管服务使用默认 URL;自定义 OpenAI 兼容服务请选择 **OpenAI-compatible**。API key 留空可使用环境变量中的凭证。获取模型列表失败时,可以修改连接、重试或手动输入模型 ID。连接检查只获取模型列表,不发起模型生成请求。已登录 OpenAI OAuth 时会跳过模型发现,直接手动输入模型。收集到的配置会替换 `~/.bub/config.yml`;已有文件内容不会合并到本轮交互中。插件可通过 `onboard_config` hook 添加自己的交互提示。 +`bub onboard` 依次引导你选择 provider、填写 API key 和必要的 URL、检查连接、搜索选择模型,再配置 channel 与流式输出。托管服务使用默认 URL;自定义 OpenAI 兼容服务请选择 **OpenAI-compatible**。API key 留空可使用环境变量中的凭证。获取模型列表失败时,可以修改连接、重试或手动输入模型 ID。连接检查只获取模型列表,不发起模型生成请求。选择 Codex 时会跳过模型发现,直接手动输入模型。收集到的配置会替换 `~/.bub/config.yml`;已有配置会作为本轮交互的默认值,未更新的字段会保留。插件可通过 `onboard_config` hook 添加自己的交互提示。 ```bash bub onboard @@ -55,16 +55,16 @@ telegram: | 变量 | 用途 | |---|---| -| `BUB_MODEL` | 模型标识,如 `openrouter:openrouter/free` 或 `openai:`。 | -| `BUB_API_KEY` | 当前提供商的 API key。使用 `bub login openai` 时保持未设置。 | - -如果某个端点说的是已支持的协议,但需要单独起名(公司代理、中转站、第二个账号),就在 `providers` 下声明它,再用这个名字当模型前缀,见[自定义 provider](/zh-cn/docs/reference/settings/#自定义-provider)。 -| `BUB_API_BASE` | 覆盖提供商的基础 URL。使用 Codex OAuth 时保持未设置,以便 Bub 路由到 Codex backend。 | +| `BUB_MODEL` | 模型标识,如 `openrouter:openrouter/free` 或 `codex:`。 | +| `BUB_API_KEY` | 当前提供商的 API key。使用 `bub login codex` 时保持未设置。 | +| `BUB_API_BASE` | 覆盖提供商的基础 URL。保持未设置可使用所选 provider 的默认端点。 | | `BUB_HOME` | Bub 的运行时数据目录(history、tapes、托管插件项目)。默认 `~/.bub`;不会移动默认配置文件。 | | `BUB__API_KEY` | 单个提供商的 key,如 `BUB_OPENROUTER_API_KEY`。 | | `BUB__API_BASE` | 单个提供商的基础 URL。 | -使用 `bub login openai` 时,Bub 会从 `CODEX_HOME` 或 `~/.codex` 读取 Codex OAuth 凭据,并自动 refresh。这个路径下使用 `openai:` provider 的模型标识,同时保持 `BUB_API_KEY` 和 `BUB_API_BASE` 未设置。 +Republic 从 `$CODEX_HOME/auth.json` 或 `~/.codex/auth.json` 读取并刷新 Codex 文件凭据。新登录需要安装 Codex CLI,然后选择 `codex:` 并保持 `BUB_API_KEY` 未设置。OpenAI API 访问使用 `openai:` 和 API 凭据。 + +公司代理、中转站或第二个账号可在 `providers` 下单独命名,再使用该名称作为模型前缀,见[自定义 provider](/zh-cn/docs/reference/settings/#自定义-provider)。 完整变量表(包括 channel 防抖参数和高级模型客户端设置)见 [Settings 参考](/zh-cn/docs/reference/settings/)。 diff --git a/website/src/content/docs/zh-cn/docs/reference/cli.mdx b/website/src/content/docs/zh-cn/docs/reference/cli.mdx index 74f92a23..d46edf11 100644 --- a/website/src/content/docs/zh-cn/docs/reference/cli.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/cli.mdx @@ -109,13 +109,13 @@ bub onboard [OPTIONS] [PLUGIN_NAME] 行为说明: - 所有 provider 使用同一流程:选择 provider、填写连接信息、检查连接、选择模型,再配置 channel 和流式输出。 -- 选项明确区分 **OpenAI (official API)**(官方接口)与 **OpenAI-compatible (custom URL / local server)**(兼容接口或本地服务)。兼容服务保存为 `openai:`,并使用你填写的 API base URL。 -- 托管服务使用默认地址,通常只询问 API key。兼容服务、Azure、Ollama、其他 provider 以及前序钩子提供的自定义地址会额外询问 URL。API key 留空会保留本轮为相同地址提供的 key,或使用环境变量中的凭证,包括 `OPENAI_API_KEY`、`BUB_API_KEY` 和 `BUB_OPENAI_API_KEY`。环境变量中的 key 不会复制到配置文件;仅在没有输入 key 且环境变量也没有凭证时,才为兼容服务保存 SDK 所需的占位 key。 -- Bub 获取 provider 的模型列表,异步超时为 10 秒,随后支持搜索选择或手动输入模型。阻塞式 SDK 调用可能等待更久。检查只验证模型列表接口可访问,不发起模型生成请求。获取失败时可修改 URL/key、重试或手动输入;不支持模型发现的 provider(如当前 SDK 中的 Azure)会说明限制并直接进入手动输入,不提供重试菜单。 -- 实际连接使用 OpenAI OAuth 时会跳过模型发现,直接手动输入模型。界面显示的默认 URL 不会写入配置。URL 留空可保留默认地址和 OAuth 路由;明确输入 URL 时会保存该地址,即使它与显示的默认值相同。 +- 选项明确区分 **OpenAI (official API)**(官方接口)与 **OpenAI-compatible (custom URL / local server)**(兼容接口或本地服务)。兼容服务保存为 `openai:`,并使用你填写的 API base URL 和 `client_args.api_format: chat`。 +- 托管服务使用默认地址,通常只询问 API key。兼容服务、Azure OpenAI、Ollama、其他 provider 以及前序钩子提供的自定义地址会额外询问 URL。API key 留空会保留本轮为相同地址提供的 key,或使用环境变量中的凭证,包括 `OPENAI_API_KEY`、`BUB_API_KEY` 和 `BUB_OPENAI_API_KEY`。环境变量中的 key 不会复制到配置文件。 +- Bub 获取 provider 的模型列表,异步超时为 10 秒,随后支持搜索选择或手动输入模型。检查只验证模型列表接口可访问,不发起模型生成请求。获取失败时可修改 URL/key、重试或手动输入;不支持模型发现的 provider(如 Codex)会说明限制并直接进入手动输入,不提供重试菜单。 +- 选择 Codex provider 时会跳过模型发现,直接手动输入模型。界面显示的默认 URL 不会写入配置。URL 留空可保留默认地址;明确输入 URL 时会保存该地址,即使它与显示的默认值相同。 - 每个插件的 `onboard_config` 钩子拿到已加载配置的副本,以及此前钩子的累计更新;已有字段会保留,除非被钩子覆盖。返回值不是 dict 时抛 `TypeError` 并中止。 - 合并后的 dict 会经过 `configure.validate` 验证后才写入。 -- `onboard` 是交互命令。provider 标签和提示顺序已调整,连接检查结果也会决定后续提示。按固定顺序输入答案的脚本需要更新;无人值守配置请直接生成配置文件。内置安装器仅在交互模式下启动 `onboard`。 +- `onboard` 是交互命令,连接检查结果会决定后续提示。无人值守配置请直接生成配置文件。内置安装器仅在交互模式下启动 `onboard`。 - Linux 或 Windows 上保存了至少一个 channel 后,onboarding 会询问是否把 gateway 安装为当前用户的后台服务,默认选择否。安装失败不会回滚已保存的配置,并以退出码 `1` 结束。 退出码: @@ -189,25 +189,25 @@ bub login [OPTIONS] COMMAND [ARGS]... | --- | --- | --- | --- | | `--help` | flag | — | 显示帮助并退出。 | -### `bub login openai` +### `bub login codex` -跑 Codex OAuth 流程获取 OpenAI 凭据,并保存到解析后的 Codex home 目录下。 +调用已安装的 Codex CLI,并使用文件凭据存储。 ```text -bub login openai [OPTIONS] +bub login codex [OPTIONS] ``` | 选项 | 类型 | 默认值 | 描述 | | --- | --- | --- | --- | -| `--codex-home` | PATH | `$CODEX_HOME` 或 `~/.codex` | 写入 `auth.json` 的目录。 | -| `--browser` / `--no-browser` | flag | `--browser` | 在默认浏览器中打开 OAuth authorize URL。 | -| `--manual` | flag | off | 跳过本地回调服务器,改为提示粘贴 callback URL 或 code。 | -| `--timeout` | FLOAT | `300.0` | OAuth 等待超时时间(秒)。 | +| `--executable` | TEXT | `codex` | Codex CLI 可执行文件。 | +| `--device-auth` | flag | off | 使用设备授权,适用于没有浏览器的终端。 | +| `--codex-home` | PATH | `$CODEX_HOME` 或 `~/.codex` | 登录子进程使用的凭据目录。 | 行为说明: -- 成功时打印 `login: ok`、account id、auth 文件路径,并提示设置 `BUB_MODEL=openai:` 且不再提供 `BUB_API_KEY`。 -- 抛出 `CodexOAuthLoginError` 时以退出码 `1` 退出。 +- 成功时打印 `login: ok`、account id,并提示设置 `BUB_MODEL=codex:` 且不再提供 `BUB_API_KEY`。 +- 认证失败时以退出码 `1` 退出;找不到可执行文件时提示安装 CLI。 +- 凭据保存到 `$CODEX_HOME/auth.json` 或 `~/.codex/auth.json`。使用 `--codex-home` 指定目录,无浏览器登录使用 `--device-auth`。 ## `bub hooks` diff --git a/website/src/content/docs/zh-cn/docs/reference/hooks.mdx b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx index 5406fe2a..8f64f12f 100644 --- a/website/src/content/docs/zh-cn/docs/reference/hooks.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/hooks.mdx @@ -17,7 +17,7 @@ description: BubHookSpecs 中每个钩子的类型、签名、返回值与调用 | --- | --- | --- | --- | --- | --- | | `resolve_session` | firstresult | `(message: Envelope) -> str` | session id | `BubFramework.process_inbound` | 没有任何实现返回值时,框架回退为 `"{channel}:{chat_id}"`。 | | `load_state` | broadcast | `(message: Envelope, session_id: str) -> TurnState` | dict to merge | `BubFramework.process_inbound` | 框架先反转结果列表再依次合并,使高优先级的键覆盖低优先级。 | -| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> str \| list[dict]` | prompt or content parts | `BubFramework.process_inbound` | 没有实现返回非 `None` 值,或选中的 firstresult 为 falsy 时,回退到 `content_of(message)`。已选中的 falsy 值不会触发继续尝试低优先级实现。 | +| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> list[UserContent]` | prompt content | `BubFramework.process_inbound` | 没有实现返回非 `None` 值,或选中的 firstresult 为 falsy 时,回退到 `content_of(message)`。已选中的 falsy 值不会触发继续尝试低优先级实现。 | | `run_model` | firstresult | `(prompt, session_id, state) -> str` | model text | `HookRuntime.run_model` | 旧版纯文本路径。`run_model` 与 `run_model_stream` 选其一实现,不要同时实现。 | | `run_model_stream` | firstresult | `(prompt, session_id, state) -> AsyncStreamEvents` | async stream | `HookRuntime.run_model_stream` | 推荐路径。无流式实现时框架将 `run_model` 结果包装为单个 chunk 的流。 | | `continue_prompt` | firstresult | `(prompt: str \| list[dict] \| None, tape: Tape, state: StreamState) -> str \| None` | 可选的下一步 user 消息 | `Agent._stream_events_with_auto_handoff` | 每次 agent loop 继续前调用。`prompt` 是刚完成 step 使用的 prompt;如果上一步未添加消息则为 `None`。实现可同步或异步;若没有 hook 返回字符串,下一次模型调用只使用现有对话,不额外添加 user 消息。 | @@ -41,6 +41,12 @@ description: BubHookSpecs 中每个钩子的类型、签名、返回值与调用 | `before_tool_call` | chained | `(call: ToolCall, state: TurnState) -> ToolCallDecision \| None` | decision | `ToolExecutor` 经 `AgentHooks` | 逐次工具调用。`proceed(arguments=…)` 折叠参数修改;`replace(result)` / `deny(message)` 短路。否决只能通过 decision 对象——异常仅记日志并跳过。 | | `after_tool_call` | 终态处理 | `(call: ToolCall, result: ToolCallResult, state: TurnState) -> None` | 无 | `ToolExecutor` 经 `AgentHooks` | 成功、失败、deny/replace 会触发。取消不观察。赋值 `result.result` 会替换模型可见结果;失败时 `result.error` 仍是原始 `BubError`。 | +### Prompt 内容 + +`build_prompt` 返回 `bub.prompt` 中的 `list[UserContent]`,其中 `UserContent = str | republic.Image | republic.Audio | republic.Video`。媒体用 `republic.image(...)`、`republic.audio(...)` 或 `republic.video(...)` 构造。为保持兼容,它仍可返回旧格式:字符串,或 content block 字典列表(例如 `{"type": "image", "media_type": "image/png", "url": "..."}`)。框架会根据实际返回值进行转换。 + +接收 prompt 的 hook(`run_model`、`run_model_stream`、`continue_prompt`、`system_prompt`)仍收到旧格式:没有媒体时为字符串,否则为 content block 列表,媒体使用 URL(内联媒体转为 `data:` URL)。可用 `bub.prompt.to_content(prompt)` 转为 user content。 + ## 钩子如何被调用 `HookRuntime`(位于 `src/bub/hooks/runtime.py`)在 `pluggy.PluginManager` 之上提供以下语义。 diff --git a/website/src/content/docs/zh-cn/docs/reference/settings.mdx b/website/src/content/docs/zh-cn/docs/reference/settings.mdx index 3fde61cc..9644b27e 100644 --- a/website/src/content/docs/zh-cn/docs/reference/settings.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/settings.mdx @@ -65,23 +65,33 @@ class AgentSettings(Settings): | `BUB_API_BASE` | unset | `api_base` | 默认 API base URL,或按 provider 索引的映射。 | | `BUB__API_KEY` | unset | — | 按 provider 划分的 API key,例如 `BUB_OPENAI_API_KEY`。 | | `BUB__API_BASE` | unset | — | 按 provider 划分的 API base URL,例如 `BUB_OPENROUTER_API_BASE`。 | -| `BUB_PROVIDERS` | `{}` | `providers` | 自定义命名的端点,每个用 `type` 指定走哪个 any-llm provider 的协议,并有自己的 `api_base` 和 `api_key`。见[自定义 provider](#自定义-provider)。 | +| `BUB_PROVIDERS` | `{}` | `providers` | 自定义命名的端点,每个用 `type` 指定走哪个 Republic provider 的协议,并有自己的 `api_base` 和 `api_key`。见[自定义 provider](#自定义-provider)。 | | `BUB_MAX_STEPS` | 不限制 | `max_steps` | 单次 turn 内 agent 循环的最大步数;配置时必须为正整数。 | | `BUB_MAX_TOKENS` | `16384` | `max_tokens` | 单次模型调用的最大 token 数。 | | `BUB_MODEL_TIMEOUT_SECONDS` | `null` | `model_timeout_seconds` | 单次调用的超时秒数。 | | `BUB_CLIENT_ARGS` | `{}` | `client_args` | 传递给底层模型 client 的额外 kwargs(JSON / dict)。 | -| `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | 传递给每次 completion 调用的额外 kwargs,例如 `{"reasoning_effort":"high"}`;Bub 管理的参数优先。 | +| `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | 传递给每次 completion 调用的额外 kwargs,例如 `{"reasoning_effort":"high"}`;工具和 token 上限由 Bub 提供,优先级见下文。 | | `BUB_VERBOSE` | `0` | `verbose` | 日志详细级别(`0`–`2`)。 | 例如,配置 `command_prefix: "!"` 后,内置 channel 使用 `!help`,`,help` 按普通文本处理。 CLI 控制命令(`quit`、`exit`、`thinking`)、补全和 shell 模式使用同一前缀。 Telegram 保留现有的原生斜杠命令过滤;前缀设为 `/` 时,使用 `/bub /help`。 -启动时 `ProviderSpecificEnvSource` 会扫描 `os.environ` 中匹配 `^BUB_(.+)_(API_KEY|API_BASE)$` 的变量,把捕获到的 provider 名称小写后作为 key 收集。 +启动时 `ProviderSpecificEnvSource` 会扫描 `os.environ` 中匹配 `^BUB_(.+)_(API_KEY|API_BASE)$` 的变量,并将环境变量前缀解析为 Republic provider 名称,例如 `AZURE_OPENAI` → `azure-openai`。 + +Bub 使用 Republic 发起模型请求。ChatGPT 套餐访问使用 `codex:`,OpenAI API 访问使用 `openai:`。本地 Codex 登录不会改变 `openai:` 路由。provider 标识使用 Republic 的名称,例如 `google:` 和 `azure-openai:`。 + +`client_args` 接受 Republic provider 参数,例如 `headers`、`api_format`、`timeout` 和 `max_retries`。Bub 提供解析后的连接设置(`api_key`、`api_base`),其他参数原样传递。协议默认值遵循 Republic。Chat Completions 端点需显式设置 `api_format: chat`。注入的 HTTP client 必须是 `httpx2.AsyncClient`,生命周期由调用方管理。 + +`completion_args` 接受 Republic chat 参数。Bub 提供 `tools` 和运行时 token 上限;会话中的 `reasoning_effort` 覆盖配置中的同名 chat 参数。Codex 端点不支持 token 上限,因此不附加运行时的该参数。Republic 负责协议转换,Anthropic 请求开启 prompt caching。 + +提供商专用字段放入 `extra_body`,`client_args` 和 `completion_args` 都支持它。Republic 将 provider 默认值与请求级 extras 深合并,请求级值优先,再覆盖编码后的请求体。这些显式协议字段可覆盖 reasoning、token 上限等 chat 参数;Bub 原样传递。 + +Provider 名称和协议能力以 [Republic provider 目录](https://getrepublic.org/providers/)为准。自定义 provider 实现需注册到 Republic。 ### 自定义 provider -模型前缀通常是 any-llm 的 provider 名,`api_key` / `api_base` 每个 provider 只能放一个值。如果某个端点说的是 any-llm 已支持的协议,但需要单独起名,就用 `providers`:比如公司代理、中转站或本地网关、自建服务,或同一厂商的第二个账号。每个名字都可以当模型前缀用,`type` 指定它走哪个 any-llm provider 的协议: +模型前缀通常是 Republic 的 provider 名,`api_key` / `api_base` 每个 provider 只能放一个值。如果某个端点说的是 Republic 已支持的协议,但需要单独起名,就用 `providers`:比如公司代理、中转站或本地网关、自建服务,或同一厂商的第二个账号。每个名字都可以当模型前缀用,`type` 指定它走哪个 Republic provider 的协议: ```yaml model: proxy:gpt-5.5 @@ -99,9 +109,8 @@ providers: api_key: sk-relay-... ``` -这里 `proxy:gpt-5.5` 和 `openai:gpt-5.5` 都走 OpenAI 协议,但发往不同的地址、用不同的 key;`openai:` 仍使用它自己的 `api_key` / `api_base`,或 Codex OAuth。条目里没写的字段会回退到 `api_key` / `api_base` 以及 `BUB__API_KEY` / `BUB__API_BASE`。`type` 不是已知 provider 时,加载配置会直接报错。 +这里 `proxy:gpt-5.5` 和 `openai:gpt-5.5` 都走 OpenAI 协议,但发往不同的地址、用不同的 key;`openai:` 仍使用它自己的 `api_key` / `api_base`。条目里没写的字段会回退到 `api_key` / `api_base` 以及 `BUB__API_KEY` / `BUB__API_BASE`。`type` 不是已知 provider 时,加载配置会直接报错。 -OpenAI Codex OAuth 不需要单独的请求格式设置。运行 `bub login openai` 后,当模型使用 `openai:` provider 且没有自定义 API base 时,Bub 会检测并使用本地保存的 Codex OAuth token。 ## Spill sidecar —— `SpillSettings` @@ -173,11 +182,11 @@ class TelegramSettings(Settings): ## Login —— 第三方环境变量 -`bub login openai` 读取一个非 `BUB_*` 环境变量: +`bub login codex` 读取一个非 `BUB_*` 环境变量: | 环境变量 | 默认值 | 读取方 | 描述 | | --- | --- | --- | --- | -| `CODEX_HOME` | `~/.codex` | `bub login openai`(`src/bub/builtin/auth.py`) | Codex OAuth `auth.json` 的存放目录。可被 `--codex-home` 覆盖。 | +| `CODEX_HOME` | `~/.codex` | `bub login codex`(`src/bub/builtin/auth.py`) | Codex OAuth `auth.json` 的存放目录。可被 `--codex-home` 覆盖。 | ## 插件自定义配置 diff --git a/website/src/content/docs/zh-cn/docs/reference/types.mdx b/website/src/content/docs/zh-cn/docs/reference/types.mdx index b55aee87..e02ed259 100644 --- a/website/src/content/docs/zh-cn/docs/reference/types.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/types.mdx @@ -31,7 +31,7 @@ type TurnState = dict[str, Any] @dataclass(frozen=True) class TurnResult: session_id: str - prompt: str | list[dict[str, Any]] + prompt: list[UserContent] model_output: str outbounds: list[Envelope] = field(default_factory=list) state: TurnState = field(default_factory=dict) @@ -67,6 +67,10 @@ from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState `StreamEvent.kind` 可以是 `text`、`reasoning`、`tool_call`、`tool_result`、`usage`、`error` 或 `final`。`AsyncStreamEvents` 包装异步迭代器,并暴露最终 `error` 与 `usage` 状态。 +builtin 的 `tool_call` 事件使用 Republic 字段:`id`、`name`、`arguments` 和 `metadata`。usage 字典包含 `input_tokens`、`output_tokens`、`reasoning_tokens`、`cached_tokens`、`cache_write_tokens` 和 `total_tokens`。 + +builtin 的多部分 prompt 包含文本块(`type: text`、`text`)或媒体块(`type: image|audio|video`、`media_type`、`url`)。URL 可以是远程地址或 base64 data URL。Bub 将持久化内容块转换为 Republic 原生 part,provider 编码由 Republic 负责。hook 提供这些文本与媒体块。 + ### Error — `bub.errors` ```python