From 0259b7f379b91b40ef2dd101a49cef18743a735f Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Tue, 30 Jun 2026 09:40:33 +0800 Subject: [PATCH 1/8] feat(auth): support Codex OAuth provider --- pyproject.toml | 1 + src/bub/builtin/auth.py | 452 ++++++++++++++++++++++++++ src/bub/builtin/cli.py | 56 ++++ src/bub/builtin/codex_provider.py | 511 ++++++++++++++++++++++++++++++ src/bub/builtin/hook_impl.py | 1 + src/bub/builtin/model_runner.py | 19 +- tests/test_builtin_cli.py | 36 ++- tests/test_builtin_codex.py | 209 ++++++++++++ tests/test_framework.py | 2 +- uv.lock | 143 +++++++++ 10 files changed, 1421 insertions(+), 9 deletions(-) create mode 100644 src/bub/builtin/auth.py create mode 100644 src/bub/builtin/codex_provider.py create mode 100644 tests/test_builtin_codex.py diff --git a/pyproject.toml b/pyproject.toml index 859f509e..7b3d7122 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "pluggy>=1.6.0", "inquirer-textual>=0.5.1", "typer>=0.9.0", + "authlib>=1.7.2", "any-llm-sdk[anthropic]", "rich>=13.0.0", "prompt-toolkit>=3.0.0", diff --git a/src/bub/builtin/auth.py b/src/bub/builtin/auth.py new file mode 100644 index 00000000..367667b9 --- /dev/null +++ b/src/bub/builtin/auth.py @@ -0,0 +1,452 @@ +"""Authentication helpers for builtin providers.""" + +from __future__ import annotations + +import json +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 +from pathlib import Path +from typing import Any + +from authlib.integrations.httpx_client import OAuth2Client + +CODEX_PROVIDER = "openai" +DEFAULT_CODEX_REDIRECT_URI = "http://localhost:1455/auth/callback" + +_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" + + +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.""" + + +@dataclass(frozen=True) +class OpenAICodexOAuthTokens: + access_token: str + refresh_token: str + expires_at: int + 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, + "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()) + if tokens.expires_at > now + refresh_skew_seconds: + return tokens.access_token + + try: + refreshed = refresher(tokens.refresh_token) + except Exception: + return tokens.access_token if tokens.expires_at > now else 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 extract_openai_codex_account_id(access_token: str) -> str | None: + 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 Exception: + return None + if not isinstance(payload, dict): + 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 _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"), payload.get("last_refresh")) + 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, last_refresh_raw: object) -> int: + if isinstance(expires_raw, (int, float)): + return int(expires_raw) + if isinstance(expires_raw, str): + return _rfc3339_to_unix(expires_raw) + if isinstance(last_refresh_raw, (int, float)): + return int(last_refresh_raw) + 3600 + if isinstance(last_refresh_raw, str): + return _rfc3339_to_unix(last_refresh_raw) + 3600 + return int(time.time()) + 3600 + + +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: + try: + return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()) + except (ValueError, AttributeError): + return int(time.time()) diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index ae2b5d23..bd5b9f14 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -17,6 +17,13 @@ import typer from bub import __version__, configure +from bub.builtin.auth import ( + DEFAULT_CODEX_REDIRECT_URI, + CodexOAuthLoginError, + OpenAICodexOAuthTokens, + login_openai_codex_oauth, + resolve_codex_home, +) from bub.channels.message import ChannelMessage from bub.envelope import field_of from bub.framework import BubFramework @@ -127,6 +134,55 @@ def onboard(ctx: typer.Context) -> None: typer.echo(f"Saved config to {framework.config_file}") +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: + 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") + + +def login( + provider: str = typer.Argument(..., help="Authentication provider"), + 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: + """Authenticate with a provider and persist the resulting credentials.""" + + if provider != "openai": + typer.echo(f"Unsupported auth provider: {provider}", err=True) + raise typer.Exit(1) + + 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") + + @lru_cache(maxsize=1) def _find_uv() -> str: import shutil diff --git a/src/bub/builtin/codex_provider.py b/src/bub/builtin/codex_provider.py new file mode 100644 index 00000000..3d7d05b5 --- /dev/null +++ b/src/bub/builtin/codex_provider.py @@ -0,0 +1,511 @@ +"""OpenAI Codex OAuth provider for any-llm.""" + +from __future__ import annotations + +import time +from collections.abc import AsyncIterator, Sequence +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, CompletionParams +from any_llm.types.model import Model +from any_llm.types.responses import Response, ResponsesParams +from openai import AsyncStream +from openai.types.responses import ResponseStreamEvent + +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): + """any-llm provider backed by OpenAI 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 = False + 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 + + 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: + """Implement Chat Completions via Codex Responses while returning any-llm's completion type.""" + + responses_params = self._completion_params_to_responses_params(params, **kwargs) + response = await self._aresponses(responses_params) + if isinstance(response, AsyncStream): + msg = "OpenAI Codex completion streaming is disabled for Bub's any-llm provider." + raise OpenAICodexTransportError(None, msg) + return self._response_to_completion(response, model=params.model_id) + + @override + async def _aresponses(self, params: ResponsesParams, **kwargs: Any) -> Response | AsyncStream[ResponseStreamEvent]: + payload = self._build_responses_payload(params, **kwargs) + response = await self.client.responses.create(**payload) + if params.stream: + return cast("AsyncStream[ResponseStreamEvent]", response) + if isinstance(response, AsyncStream): + return self._collect_response_events(await self._collect_events(response)) + return cast("Response", response) + + def _completion_params_to_responses_params(self, params: CompletionParams, **kwargs: Any) -> ResponsesParams: + completion_kwargs = self._convert_completion_params(params, **kwargs) + tools = completion_kwargs.pop("tools", None) + tool_choice = completion_kwargs.pop("tool_choice", None) + response_format = completion_kwargs.pop("response_format", None) + parallel_tool_calls = completion_kwargs.pop("parallel_tool_calls", None) + max_output_tokens = completion_kwargs.pop("max_completion_tokens", None) + completion_kwargs.pop("stream", None) + completion_kwargs.pop("n", None) + completion_kwargs.pop("stop", None) + completion_kwargs.pop("logprobs", None) + completion_kwargs.pop("top_logprobs", None) + completion_kwargs.pop("logit_bias", None) + completion_kwargs.pop("stream_options", None) + + reasoning_effort = completion_kwargs.pop("reasoning_effort", None) + reasoning = completion_kwargs.pop("reasoning", None) + if reasoning is None and reasoning_effort not in {None, "auto"}: + reasoning = {"effort": reasoning_effort} + + return ResponsesParams( + model=params.model_id, + input=cast("Any", params.messages), + tools=self._convert_tools_for_responses(cast("list[dict[str, Any] | Any] | None", tools or params.tools)), + tool_choice=self._convert_tool_choice_for_responses( + cast("str | dict[str, Any] | None", tool_choice or params.tool_choice) + ), + max_output_tokens=max_output_tokens, + parallel_tool_calls=parallel_tool_calls or params.parallel_tool_calls, + response_format=response_format or params.response_format, + reasoning=reasoning, + **completion_kwargs, + ) + + def _build_responses_payload(self, params: ResponsesParams, **kwargs: Any) -> dict[str, Any]: + payload = params.model_dump(exclude_none=True, exclude={"response_format"}) + payload["stream"] = True + payload.pop("max_output_tokens", None) + payload["store"] = payload.get("store", self._store) + payload["instructions"] = payload.get("instructions") or self._default_instructions + payload["include"] = payload.get("include") or list(self._default_include) + + text = payload.get("text") + if isinstance(text, dict): + payload["text"] = {**self._default_text, **text} + elif text is None: + payload["text"] = dict(self._default_text) + + payload.update(kwargs) + return payload + + @staticmethod + async def _collect_events(response: AsyncIterator[Any]) -> list[Any]: + events: list[Any] = [] + async for event in response: + events.append(event) + return events + + @staticmethod + def _collect_response_events(events: list[Any]) -> Response: + text_parts: list[str] = [] + tool_calls: dict[str, dict[str, Any]] = {} + usage: dict[str, Any] | Any | None = None + completed_response: Response | None = None + + for event in events: + event_type = getattr(event, "type", None) + if event_type == "response.output_text.delta": + if isinstance(delta := getattr(event, "delta", None), str): + text_parts.append(delta) + continue + if event_type == "response.output_item.done": + OpenaiCodexProvider._record_stream_tool_call( + tool_calls, + OpenaiCodexProvider._function_call_from_output_item(event), + ) + continue + if event_type == "response.function_call_arguments.done": + OpenaiCodexProvider._record_stream_tool_call( + tool_calls, + OpenaiCodexProvider._function_call_from_arguments_done(event), + ) + continue + if event_type == "response.completed": + completed = getattr(event, "response", None) + completed_response = completed if isinstance(completed, Response) else None + usage = getattr(completed, "usage", None) or usage + continue + usage = getattr(event, "usage", None) or usage + + if completed_response is not None and completed_response.output: + return completed_response + + return OpenaiCodexProvider._build_response_from_stream( + completed_response=completed_response, + text="".join(text_parts), + tool_calls=tool_calls, + usage=usage, + ) + + @staticmethod + def _build_response_from_stream( + *, + completed_response: Response | None, + text: str, + tool_calls: dict[str, dict[str, Any]], + usage: dict[str, Any] | Any | None, + ) -> Response: + output: list[dict[str, Any]] = [] + if text: + output.append({ + "id": "msg_codex", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": text, "annotations": []}], + }) + output.extend( + { + "id": call.get("id"), + "type": "function_call", + "call_id": call["call_id"], + "name": call.get("name"), + "arguments": call.get("arguments", ""), + "status": "completed", + } + for call in tool_calls.values() + ) + + payload = { + "id": getattr(completed_response, "id", None) or "resp_codex", + "object": getattr(completed_response, "object", None) or "response", + "created_at": getattr(completed_response, "created_at", None) or time.time(), + "status": getattr(completed_response, "status", None) or "completed", + "error": getattr(completed_response, "error", None), + "incomplete_details": getattr(completed_response, "incomplete_details", None), + "instructions": getattr(completed_response, "instructions", None), + "metadata": getattr(completed_response, "metadata", None) or {}, + "model": getattr(completed_response, "model", None) or "gpt-5.5", + "output": output, + "parallel_tool_calls": getattr(completed_response, "parallel_tool_calls", None) or False, + "temperature": getattr(completed_response, "temperature", None), + "tool_choice": getattr(completed_response, "tool_choice", None) or "auto", + "tools": getattr(completed_response, "tools", None) or [], + "top_p": getattr(completed_response, "top_p", None), + "truncation": getattr(completed_response, "truncation", None) or "disabled", + "usage": OpenaiCodexProvider._response_usage_payload(getattr(completed_response, "usage", None) or usage), + "store": getattr(completed_response, "store", None) or False, + } + return Response.model_validate(payload) + + @staticmethod + def _response_usage_payload(usage: dict[str, Any] | Any | None) -> dict[str, Any]: + model_dump = getattr(usage, "model_dump", None) + if callable(model_dump): + payload = model_dump() + elif isinstance(usage, dict): + payload = dict(usage) + else: + payload = {} + input_tokens = payload.get("input_tokens") + output_tokens = payload.get("output_tokens") + total_tokens = payload.get("total_tokens") + if not isinstance(input_tokens, int): + input_tokens = 0 + if not isinstance(output_tokens, int): + output_tokens = 0 + if not isinstance(total_tokens, int): + total_tokens = input_tokens + output_tokens + return { + "input_tokens": input_tokens, + "input_tokens_details": payload.get("input_tokens_details") or {"cached_tokens": 0}, + "output_tokens": output_tokens, + "output_tokens_details": payload.get("output_tokens_details") or {"reasoning_tokens": 0}, + "total_tokens": total_tokens, + } + + @staticmethod + def _record_stream_tool_call(tool_calls: dict[str, dict[str, Any]], tool_call: dict[str, Any] | None) -> None: + if tool_call is None: + return + tool_calls[tool_call["call_id"]] = tool_call + + @staticmethod + def _function_call_from_output_item(event: Any) -> dict[str, Any] | None: + item = getattr(event, "item", None) + if getattr(item, "type", None) != "function_call": + return None + call_id = getattr(item, "call_id", None) or getattr(item, "id", None) + if not isinstance(call_id, str) or not call_id: + return None + return { + "call_id": call_id, + "id": getattr(item, "id", None), + "name": getattr(item, "name", None), + "arguments": getattr(item, "arguments", "") or "", + } + + @staticmethod + def _function_call_from_arguments_done(event: Any) -> dict[str, Any] | None: + call_id = getattr(event, "call_id", None) or getattr(event, "item_id", None) + if not isinstance(call_id, str) or not call_id: + return None + return { + "call_id": call_id, + "id": getattr(event, "item_id", None), + "name": getattr(event, "name", None), + "arguments": getattr(event, "arguments", "") or "", + } + + def _response_to_completion(self, response: Response, *, model: str) -> ChatCompletion: + message: dict[str, Any] = { + "role": "assistant", + "content": self._response_output_text(response) or None, + } + if tool_calls := self._response_tool_calls(response): + message["tool_calls"] = tool_calls + if reasoning := self._response_reasoning(response): + message["reasoning"] = reasoning + + payload = { + "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": self._completion_finish_reason(response), + "message": message, + } + ], + "usage": self._completion_usage(response), + } + return self._convert_completion_response(payload) + + @staticmethod + def _completion_finish_reason(response: Response) -> str: + if OpenaiCodexProvider._response_tool_calls(response): + return "tool_calls" + status = getattr(response, "status", None) + if status in {"incomplete", "failed", "cancelled"}: + return "length" + return "stop" + + @staticmethod + def _response_output_text(response: Response) -> str: + output_text = getattr(response, "output_text", None) + if isinstance(output_text, str): + return output_text + + parts: list[str] = [] + for item in getattr(response, "output", []) or []: + if getattr(item, "type", None) != "message": + continue + for content in getattr(item, "content", []) or []: + if getattr(content, "type", None) == "output_text": + text = getattr(content, "text", None) + if isinstance(text, str): + parts.append(text) + return "".join(parts) + + @staticmethod + def _response_reasoning(response: Response) -> str | None: + parts: list[str] = [] + for item in getattr(response, "output", []) or []: + if getattr(item, "type", None) != "reasoning": + continue + summary = getattr(item, "summary", None) + if isinstance(summary, str): + parts.append(summary) + elif isinstance(summary, list): + parts.extend(str(part) for part in summary if part) + return "\n".join(parts) or None + + @staticmethod + def _response_tool_calls(response: Response) -> list[dict[str, Any]]: + calls: list[dict[str, Any]] = [] + for item in getattr(response, "output", []) or []: + if getattr(item, "type", None) != "function_call": + continue + name = getattr(item, "name", None) + if not isinstance(name, str) or not name: + continue + calls.append({ + "id": getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{len(calls)}", + "type": "function", + "function": { + "name": name, + "arguments": getattr(item, "arguments", None) or "{}", + }, + }) + return calls + + @staticmethod + def _completion_usage(response: Response) -> dict[str, Any] | None: + usage = getattr(response, "usage", None) + if usage is None: + return None + if hasattr(usage, "model_dump"): + payload = usage.model_dump() + elif isinstance(usage, dict): + payload = dict(usage) + else: + payload = { + key: value + for key in ("input_tokens", "output_tokens", "total_tokens") + if (value := getattr(usage, key, None)) is not None + } + prompt_tokens = int(payload.get("input_tokens") or 0) + completion_tokens = int(payload.get("output_tokens") or 0) + total_tokens = int(payload.get("total_tokens") or prompt_tokens + completion_tokens) + return { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + } + + @staticmethod + def _convert_tools_for_responses(tools: list[dict[str, Any] | Any] | None) -> list[dict[str, Any]] | None: + if not tools: + return None + + converted: list[dict[str, Any]] = [] + for tool in tools: + if not isinstance(tool, dict): + converted.append(cast("dict[str, Any]", tool)) + continue + function = tool.get("function") + if not isinstance(function, dict): + converted.append(dict(tool)) + continue + response_tool = { + "type": tool.get("type", "function"), + "name": function.get("name"), + "description": function.get("description", ""), + "parameters": function.get("parameters", {}), + } + if "strict" in function: + response_tool["strict"] = function["strict"] + converted.append(response_tool) + return converted + + @staticmethod + def _convert_tool_choice_for_responses(tool_choice: str | dict[str, Any] | None) -> str | dict[str, Any] | None: + if not isinstance(tool_choice, dict): + return tool_choice + function = tool_choice.get("function") + if not isinstance(function, dict): + return tool_choice + function_name = function.get("name") + if not isinstance(function_name, str) or not function_name: + return tool_choice + + converted = dict(tool_choice) + converted.pop("function", None) + converted["type"] = converted.get("type", "function") + converted["name"] = function_name + return converted + + +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/hook_impl.py b/src/bub/builtin/hook_impl.py index 8216a631..3608fe5f 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -196,6 +196,7 @@ def register_cli_commands(self, app: typer.Typer) -> None: app.command("run")(cli.run) app.command("chat")(cli.chat) + app.command("login")(cli.login) app.command("onboard")(cli.onboard) app.command("hooks", hidden=True)(cli.list_hooks) app.command("gateway")(cli.gateway) diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py index 0684f8fc..b42cbd22 100644 --- a/src/bub/builtin/model_runner.py +++ b/src/bub/builtin/model_runner.py @@ -10,6 +10,7 @@ from typing import Any, Literal, cast from any_llm import AnyLLM +from any_llm.constants import LLMProvider from any_llm.types.completion import ( ChatCompletion, ChatCompletionChunk, @@ -23,6 +24,7 @@ from loguru import logger from pydantic import TypeAdapter, ValidationError +from bub.builtin.codex_provider import OpenaiCodexProvider, should_use_openai_codex_provider from bub.builtin.settings import AgentSettings, ModelCandidate from bub.builtin.tape import Tape from bub.runtime import AsyncStreamEvents, BubError, ErrorKind, StreamEvent, StreamState @@ -42,14 +44,23 @@ def __init__(self, settings: AgentSettings) -> None: 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) yield ( candidate, - AnyLLM.create( - candidate.provider, - **self.settings.model_client_kwargs(candidate.provider), - ), + 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( self, *, model: str, messages: list[dict[str, Any]], tools: list[Tool] ) -> CompletionResult: diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py index 344589ce..b597f747 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -17,6 +17,9 @@ from bub.framework import BubFramework from bub.hookspecs import hookimpl +TEST_ACCESS_TOKEN = "access" # noqa: S105 +TEST_REFRESH_TOKEN = "refresh" # noqa: S105 + def _fake_result(answer: Any, command: str | None = "enter") -> InquirerResult[Any]: return InquirerResult(None, answer, command) @@ -344,11 +347,36 @@ def test_onboard_collects_builtin_runtime_config_with_custom_provider(tmp_path: } -def test_login_command_is_not_registered() -> None: - result = CliRunner().invoke(_create_app(), ["login"]) +def test_login_openai_command_runs_codex_oauth(tmp_path: Path) -> None: + tokens = cli.OpenAICodexOAuthTokens( + access_token=TEST_ACCESS_TOKEN, + refresh_token=TEST_REFRESH_TOKEN, + expires_at=1_900_000_000, + account_id="acct_123", + ) + login = patch("bub.builtin.cli.login_openai_codex_oauth", return_value=tokens) - assert result.exit_code == 2 - assert "No such command 'login'" in result.stderr + 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 "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 cli._prompt_for_codex_redirect + + +def test_login_rejects_unknown_provider() -> None: + result = CliRunner().invoke(_create_app(), ["login", "github"]) + + assert result.exit_code == 1 + assert "Unsupported auth provider: github" in result.stderr def test_build_bub_requirement_uses_direct_url_json(monkeypatch) -> None: diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py new file mode 100644 index 00000000..0945f313 --- /dev/null +++ b/tests/test_builtin_codex.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import base64 +import json +import time +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +from any_llm.constants import LLMProvider +from any_llm.types.completion import ChatCompletion +from any_llm.types.responses import Response + +from bub.builtin.auth import ( + 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 OpenaiCodexProvider, should_use_openai_codex_provider +from bub.builtin.model_runner import ModelRunner +from bub.builtin.settings import ModelCandidate + +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) -> str: + header = _b64({"alg": "none"}) + payload = _b64({"https://api.openai.com/auth": {"chatgpt_account_id": account_id}}) + 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 _response_payload(*, output: list[dict[str, Any]]) -> dict[str, Any]: + return { + "id": "resp_1", + "object": "response", + "created_at": 0, + "status": "completed", + "error": None, + "incomplete_details": None, + "instructions": None, + "metadata": {}, + "model": "gpt-5-codex", + "output": output, + "parallel_tool_calls": False, + "temperature": None, + "tool_choice": "auto", + "tools": [], + "top_p": None, + "truncation": "disabled", + "usage": { + "input_tokens": 1, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 2, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 3, + }, + "store": False, + } + + +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_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 + ) + 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_codex_provider_converts_response_to_chat_completion() -> None: + response = Response.model_validate( + _response_payload( + output=[ + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "hello", "annotations": []}], + } + ] + ) + ) + provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) + + completion = provider._response_to_completion(response, model="gpt-5-codex") + + assert isinstance(completion, ChatCompletion) + assert completion.choices[0].message.content == "hello" + assert completion.choices[0].finish_reason == "stop" + assert completion.usage is not None + assert completion.usage.prompt_tokens == 1 + assert completion.usage.completion_tokens == 2 + + +def test_codex_provider_converts_function_call_to_chat_completion_tool_call() -> None: + response = Response.model_validate( + _response_payload( + output=[ + { + "id": "fc_1", + "type": "function_call", + "call_id": "call_1", + "name": "tool_name", + "arguments": '{"ok": true}', + "status": "completed", + } + ] + ) + ) + provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) + + completion = provider._response_to_completion(response, model="gpt-5-codex") + + assert completion.choices[0].finish_reason == "tool_calls" + tool_calls = completion.choices[0].message.tool_calls + assert tool_calls is not None + assert tool_calls[0].id == "call_1" + assert tool_calls[0].function.name == "tool_name" + assert tool_calls[0].function.arguments == '{"ok": true}' diff --git a/tests/test_framework.py b/tests/test_framework.py index f39b8b03..d86ac519 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -160,7 +160,7 @@ def test_builtin_cli_exposes_gateway_command(write_config) -> None: gateway_result = runner.invoke(app, ["gateway", "--help"]) assert help_result.exit_code == 0 - assert "login" not in help_result.stdout + assert "login" in help_result.stdout assert "gateway" in help_result.stdout assert "onboard" in help_result.stdout assert "│ message" not in help_result.stdout diff --git a/uv.lock b/uv.lock index 68c87778..2e4df50c 100644 --- a/uv.lock +++ b/uv.lock @@ -205,12 +205,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "authlib" +version = "1.7.2" +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" } +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" }, +] + [[package]] name = "bub" source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "any-llm-sdk" }, + { name = "authlib" }, { name = "httpx", extra = ["socks"] }, { name = "inquirer-textual" }, { name = "loguru" }, @@ -246,6 +260,7 @@ dev = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.13.3" }, { name = "any-llm-sdk", extras = ["anthropic"] }, + { name = "authlib", specifier = ">=1.7.2" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "inquirer-textual", specifier = ">=0.5.1" }, { name = "logfire", marker = "extra == 'logfire'", specifier = ">=4.31.0" }, @@ -292,6 +307,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -386,6 +458,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -688,6 +810,18 @@ wheels = [ { 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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/26/abe1ad855eb334b5ebc9c6495d4798e12bee70e5e8e815d54570710b8312/joserfc-1.7.2.tar.gz", hash = "sha256:537ffb8888b2df039cb5b6d017d7cff6f09d521ce65d89cc9b8ab752b1cff947", size = 233183, upload-time = "2026-06-29T09:03:10.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/80/d1b30336582cced4dce0dae776508a6011723e32f907bc7a702c0b25890a/joserfc-1.7.2-py3-none-any.whl", hash = "sha256:ddd818c0ca9b4f17bbc2d72cb3966e6ded7502be089316c62c3cc64ae86132b5", size = 70426, upload-time = "2026-06-29T09:03:09.393Z" }, +] + [[package]] name = "librt" version = "0.9.0" @@ -1279,6 +1413,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" From ddcde2275dd55283e780c7b897dd523d9435053b Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Tue, 30 Jun 2026 12:28:10 +0800 Subject: [PATCH 2/8] fix(ci): satisfy prek ruff rules --- src/bub/builtin/auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bub/builtin/auth.py b/src/bub/builtin/auth.py index 367667b9..5e62e787 100644 --- a/src/bub/builtin/auth.py +++ b/src/bub/builtin/auth.py @@ -271,11 +271,11 @@ def _parse_tokens(payload: dict[str, Any]) -> OpenAICodexOAuthTokens | None: def _parse_expiry(expires_raw: object, last_refresh_raw: object) -> int: - if isinstance(expires_raw, (int, float)): + if isinstance(expires_raw, int | float): return int(expires_raw) if isinstance(expires_raw, str): return _rfc3339_to_unix(expires_raw) - if isinstance(last_refresh_raw, (int, float)): + if isinstance(last_refresh_raw, int | float): return int(last_refresh_raw) + 3600 if isinstance(last_refresh_raw, str): return _rfc3339_to_unix(last_refresh_raw) + 3600 @@ -288,7 +288,7 @@ def _tokens_from_token_payload(payload: dict[str, Any], *, account_id: str | Non 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)): + if not isinstance(expires_in, int | float): raise CodexOAuthResponseError access = access_token.strip() From 80ec2c079ce2258322e1e532e8e6f99bd446058d Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Tue, 30 Jun 2026 14:53:23 +0800 Subject: [PATCH 3/8] fix(cli): restore pluggable login commands --- src/bub/builtin/auth.py | 49 +++++++++++++++++++++++++++++++ src/bub/builtin/cli.py | 57 +----------------------------------- src/bub/builtin/hook_impl.py | 2 +- tests/test_builtin_cli.py | 11 +++---- 4 files changed, 57 insertions(+), 62 deletions(-) diff --git a/src/bub/builtin/auth.py b/src/bub/builtin/auth.py index 5e62e787..2ce4d88e 100644 --- a/src/bub/builtin/auth.py +++ b/src/bub/builtin/auth.py @@ -1,5 +1,6 @@ """Authentication helpers for builtin providers.""" +# ruff: noqa: B008 from __future__ import annotations import json @@ -18,6 +19,7 @@ from pathlib import Path from typing import Any +import typer from authlib.integrations.httpx_client import OAuth2Client CODEX_PROVIDER = "openai" @@ -29,6 +31,8 @@ _CODEX_OAUTH_SCOPE = "openid profile email offline_access" _CODEX_OAUTH_ORIGINATOR = "codex_cli_rs" +app = typer.Typer(name="login", help="Authentication related commands") + class CodexOAuthLoginError(RuntimeError): """Raised when Codex OAuth login cannot complete.""" @@ -225,6 +229,51 @@ def login_openai_codex_oauth( 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: + 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: parts = access_token.split(".") if len(parts) != 3: diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index bd5b9f14..48725103 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -17,13 +17,7 @@ import typer from bub import __version__, configure -from bub.builtin.auth import ( - DEFAULT_CODEX_REDIRECT_URI, - CodexOAuthLoginError, - OpenAICodexOAuthTokens, - login_openai_codex_oauth, - resolve_codex_home, -) +from bub.builtin.auth import app as login_app # noqa: F401 from bub.channels.message import ChannelMessage from bub.envelope import field_of from bub.framework import BubFramework @@ -134,55 +128,6 @@ def onboard(ctx: typer.Context) -> None: typer.echo(f"Saved config to {framework.config_file}") -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: - 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") - - -def login( - provider: str = typer.Argument(..., help="Authentication provider"), - 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: - """Authenticate with a provider and persist the resulting credentials.""" - - if provider != "openai": - typer.echo(f"Unsupported auth provider: {provider}", err=True) - raise typer.Exit(1) - - 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") - - @lru_cache(maxsize=1) def _find_uv() -> str: import shutil diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 3608fe5f..f388a007 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -196,7 +196,7 @@ def register_cli_commands(self, app: typer.Typer) -> None: app.command("run")(cli.run) app.command("chat")(cli.chat) - app.command("login")(cli.login) + app.add_typer(cli.login_app) app.command("onboard")(cli.onboard) app.command("hooks", hidden=True)(cli.list_hooks) app.command("gateway")(cli.gateway) diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py index b597f747..729efe2b 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -11,6 +11,7 @@ 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.configure as configure import bub.inquirer as bub_inquirer @@ -348,13 +349,13 @@ 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 = cli.OpenAICodexOAuthTokens( + tokens = auth.OpenAICodexOAuthTokens( access_token=TEST_ACCESS_TOKEN, refresh_token=TEST_REFRESH_TOKEN, expires_at=1_900_000_000, account_id="acct_123", ) - login = patch("bub.builtin.cli.login_openai_codex_oauth", return_value=tokens) + login = patch("bub.builtin.auth.login_openai_codex_oauth", return_value=tokens) with login as login_mock: result = CliRunner().invoke( @@ -369,14 +370,14 @@ def test_login_openai_command_runs_codex_oauth(tmp_path: Path) -> None: 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 cli._prompt_for_codex_redirect + assert login_mock.call_args.kwargs["prompt_for_redirect"] is auth._prompt_for_codex_redirect def test_login_rejects_unknown_provider() -> None: result = CliRunner().invoke(_create_app(), ["login", "github"]) - assert result.exit_code == 1 - assert "Unsupported auth provider: github" in result.stderr + assert result.exit_code == 2 + assert "No such command 'github'" in result.stderr def test_build_bub_requirement_uses_direct_url_json(monkeypatch) -> None: From 0a39da4c42e168259f6814778c6502a7c035cf04 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sun, 5 Jul 2026 18:46:38 +0800 Subject: [PATCH 4/8] fix: support codex tool call round trips --- src/bub/builtin/codex_provider.py | 686 +++++++++++++++--------------- src/bub/builtin/model_runner.py | 8 +- tests/test_builtin_agent.py | 46 +- tests/test_builtin_codex.py | 303 +++++++++---- 4 files changed, 581 insertions(+), 462 deletions(-) diff --git a/src/bub/builtin/codex_provider.py b/src/bub/builtin/codex_provider.py index 3d7d05b5..77606890 100644 --- a/src/bub/builtin/codex_provider.py +++ b/src/bub/builtin/codex_provider.py @@ -1,18 +1,17 @@ -"""OpenAI Codex OAuth provider for any-llm.""" +"""OpenAI Codex OAuth provider for any-llm Responses calls.""" from __future__ import annotations import time -from collections.abc import AsyncIterator, Sequence +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, CompletionParams +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 openai import AsyncStream -from openai.types.responses import ResponseStreamEvent from bub.builtin.auth import ( extract_openai_codex_account_id, @@ -35,7 +34,7 @@ def __init__(self, status_code: int | None, message: str, body: str | None = Non class OpenaiCodexProvider(BaseOpenAIProvider): - """any-llm provider backed by OpenAI Codex OAuth credentials.""" + """OpenAI-compatible Responses provider backed by Codex OAuth credentials.""" API_BASE = DEFAULT_CODEX_BASE_URL ENV_API_KEY_NAME = "OPENAI_CODEX_API_KEY" @@ -43,7 +42,7 @@ class OpenaiCodexProvider(BaseOpenAIProvider): PROVIDER_NAME = "openaicodex" PROVIDER_DOCUMENTATION_URL = "https://platform.openai.com/docs/codex" - SUPPORTS_COMPLETION_STREAMING = False + SUPPORTS_COMPLETION_STREAMING = True SUPPORTS_COMPLETION = True SUPPORTS_COMPLETION_REASONING = True SUPPORTS_RESPONSES = True @@ -52,6 +51,8 @@ class OpenaiCodexProvider(BaseOpenAIProvider): SUPPORTS_IMAGE_GENERATION = False SUPPORTS_AUDIO_TRANSCRIPTION = False SUPPORTS_AUDIO_SPEECH = False + SUPPORTS_EMBEDDING = False + SUPPORTS_MODERATION = False def __init__( self, @@ -100,247 +101,105 @@ 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: - """Implement Chat Completions via Codex Responses while returning any-llm's completion type.""" + 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, + ) - responses_params = self._completion_params_to_responses_params(params, **kwargs) - response = await self._aresponses(responses_params) - if isinstance(response, AsyncStream): - msg = "OpenAI Codex completion streaming is disabled for Bub's any-llm provider." - raise OpenAICodexTransportError(None, msg) - return self._response_to_completion(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) -> Response | AsyncStream[ResponseStreamEvent]: - payload = self._build_responses_payload(params, **kwargs) - response = await self.client.responses.create(**payload) - if params.stream: - return cast("AsyncStream[ResponseStreamEvent]", response) - if isinstance(response, AsyncStream): - return self._collect_response_events(await self._collect_events(response)) - return cast("Response", response) - - def _completion_params_to_responses_params(self, params: CompletionParams, **kwargs: Any) -> ResponsesParams: - completion_kwargs = self._convert_completion_params(params, **kwargs) - tools = completion_kwargs.pop("tools", None) - tool_choice = completion_kwargs.pop("tool_choice", None) - response_format = completion_kwargs.pop("response_format", None) - parallel_tool_calls = completion_kwargs.pop("parallel_tool_calls", None) - max_output_tokens = completion_kwargs.pop("max_completion_tokens", None) - completion_kwargs.pop("stream", None) - completion_kwargs.pop("n", None) - completion_kwargs.pop("stop", None) - completion_kwargs.pop("logprobs", None) - completion_kwargs.pop("top_logprobs", None) - completion_kwargs.pop("logit_bias", None) - completion_kwargs.pop("stream_options", None) - - reasoning_effort = completion_kwargs.pop("reasoning_effort", None) - reasoning = completion_kwargs.pop("reasoning", None) - if reasoning is None and reasoning_effort not in {None, "auto"}: - reasoning = {"effort": reasoning_effort} + 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", params.messages), - tools=self._convert_tools_for_responses(cast("list[dict[str, Any] | Any] | None", tools or params.tools)), - tool_choice=self._convert_tool_choice_for_responses( - cast("str | dict[str, Any] | None", tool_choice or params.tool_choice) - ), - max_output_tokens=max_output_tokens, - parallel_tool_calls=parallel_tool_calls or params.parallel_tool_calls, - response_format=response_format or params.response_format, + 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, - **completion_kwargs, ) - def _build_responses_payload(self, params: ResponsesParams, **kwargs: Any) -> dict[str, Any]: - payload = params.model_dump(exclude_none=True, exclude={"response_format"}) - payload["stream"] = True - payload.pop("max_output_tokens", None) - payload["store"] = payload.get("store", self._store) - payload["instructions"] = payload.get("instructions") or self._default_instructions - payload["include"] = payload.get("include") or list(self._default_include) - - text = payload.get("text") - if isinstance(text, dict): - payload["text"] = {**self._default_text, **text} - elif text is None: - payload["text"] = dict(self._default_text) - - payload.update(kwargs) - return payload - @staticmethod - async def _collect_events(response: AsyncIterator[Any]) -> list[Any]: - events: list[Any] = [] - async for event in response: - events.append(event) - return events + 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 _collect_response_events(events: list[Any]) -> Response: - text_parts: list[str] = [] - tool_calls: dict[str, dict[str, Any]] = {} - usage: dict[str, Any] | Any | None = None - completed_response: Response | None = None - - for event in events: - event_type = getattr(event, "type", None) - if event_type == "response.output_text.delta": - if isinstance(delta := getattr(event, "delta", None), str): - text_parts.append(delta) - continue - if event_type == "response.output_item.done": - OpenaiCodexProvider._record_stream_tool_call( - tool_calls, - OpenaiCodexProvider._function_call_from_output_item(event), - ) - continue - if event_type == "response.function_call_arguments.done": - OpenaiCodexProvider._record_stream_tool_call( - tool_calls, - OpenaiCodexProvider._function_call_from_arguments_done(event), - ) - continue - if event_type == "response.completed": - completed = getattr(event, "response", None) - completed_response = completed if isinstance(completed, Response) else None - usage = getattr(completed, "usage", None) or usage - continue - usage = getattr(event, "usage", None) or usage - - if completed_response is not None and completed_response.output: - return completed_response - - return OpenaiCodexProvider._build_response_from_stream( - completed_response=completed_response, - text="".join(text_parts), - tool_calls=tool_calls, - usage=usage, - ) + 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 - @staticmethod - def _build_response_from_stream( + async def _response_stream_to_completion_chunks( + self, + events: AsyncIterator[Any], *, - completed_response: Response | None, - text: str, - tool_calls: dict[str, dict[str, Any]], - usage: dict[str, Any] | Any | None, - ) -> Response: - output: list[dict[str, Any]] = [] - if text: - output.append({ - "id": "msg_codex", - "type": "message", - "role": "assistant", - "status": "completed", - "content": [{"type": "output_text", "text": text, "annotations": []}], - }) - output.extend( - { - "id": call.get("id"), - "type": "function_call", - "call_id": call["call_id"], - "name": call.get("name"), - "arguments": call.get("arguments", ""), - "status": "completed", - } - for call in tool_calls.values() - ) - - payload = { - "id": getattr(completed_response, "id", None) or "resp_codex", - "object": getattr(completed_response, "object", None) or "response", - "created_at": getattr(completed_response, "created_at", None) or time.time(), - "status": getattr(completed_response, "status", None) or "completed", - "error": getattr(completed_response, "error", None), - "incomplete_details": getattr(completed_response, "incomplete_details", None), - "instructions": getattr(completed_response, "instructions", None), - "metadata": getattr(completed_response, "metadata", None) or {}, - "model": getattr(completed_response, "model", None) or "gpt-5.5", - "output": output, - "parallel_tool_calls": getattr(completed_response, "parallel_tool_calls", None) or False, - "temperature": getattr(completed_response, "temperature", None), - "tool_choice": getattr(completed_response, "tool_choice", None) or "auto", - "tools": getattr(completed_response, "tools", None) or [], - "top_p": getattr(completed_response, "top_p", None), - "truncation": getattr(completed_response, "truncation", None) or "disabled", - "usage": OpenaiCodexProvider._response_usage_payload(getattr(completed_response, "usage", None) or usage), - "store": getattr(completed_response, "store", None) or False, - } - return Response.model_validate(payload) - - @staticmethod - def _response_usage_payload(usage: dict[str, Any] | Any | None) -> dict[str, Any]: - model_dump = getattr(usage, "model_dump", None) - if callable(model_dump): - payload = model_dump() - elif isinstance(usage, dict): - payload = dict(usage) - else: - payload = {} - input_tokens = payload.get("input_tokens") - output_tokens = payload.get("output_tokens") - total_tokens = payload.get("total_tokens") - if not isinstance(input_tokens, int): - input_tokens = 0 - if not isinstance(output_tokens, int): - output_tokens = 0 - if not isinstance(total_tokens, int): - total_tokens = input_tokens + output_tokens - return { - "input_tokens": input_tokens, - "input_tokens_details": payload.get("input_tokens_details") or {"cached_tokens": 0}, - "output_tokens": output_tokens, - "output_tokens_details": payload.get("output_tokens_details") or {"reasoning_tokens": 0}, - "total_tokens": total_tokens, - } + model: str, + ) -> AsyncIterator[ChatCompletionChunk]: + mapper = CodexCompletionChunkMapper(model=model) + async for event in events: + for chunk in mapper.map_event(event): + yield chunk @staticmethod - def _record_stream_tool_call(tool_calls: dict[str, dict[str, Any]], tool_call: dict[str, Any] | None) -> None: - if tool_call is None: - return - tool_calls[tool_call["call_id"]] = tool_call - - @staticmethod - def _function_call_from_output_item(event: Any) -> dict[str, Any] | None: - item = getattr(event, "item", None) - if getattr(item, "type", None) != "function_call": - return None - call_id = getattr(item, "call_id", None) or getattr(item, "id", None) - if not isinstance(call_id, str) or not call_id: - return None - return { - "call_id": call_id, - "id": getattr(item, "id", None), - "name": getattr(item, "name", None), - "arguments": getattr(item, "arguments", "") or "", - } - - @staticmethod - def _function_call_from_arguments_done(event: Any) -> dict[str, Any] | None: - call_id = getattr(event, "call_id", None) or getattr(event, "item_id", None) - if not isinstance(call_id, str) or not call_id: - return None - return { - "call_id": call_id, - "id": getattr(event, "item_id", None), - "name": getattr(event, "name", None), - "arguments": getattr(event, "arguments", "") or "", - } - - def _response_to_completion(self, response: Response, *, model: str) -> ChatCompletion: - message: dict[str, Any] = { - "role": "assistant", - "content": self._response_output_text(response) or None, - } - if tool_calls := self._response_tool_calls(response): - message["tool_calls"] = tool_calls - if reasoning := self._response_reasoning(response): - message["reasoning"] = reasoning - - payload = { + 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()), @@ -348,137 +207,258 @@ def _response_to_completion(self, response: Response, *, model: str) -> ChatComp "choices": [ { "index": 0, - "finish_reason": self._completion_finish_reason(response), - "message": message, + "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": self._completion_usage(response), + "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), + }, } - return self._convert_completion_response(payload) - - @staticmethod - def _completion_finish_reason(response: Response) -> str: - if OpenaiCodexProvider._response_tool_calls(response): - return "tool_calls" - status = getattr(response, "status", None) - if status in {"incomplete", "failed", "cancelled"}: - return "length" - return "stop" - - @staticmethod - def _response_output_text(response: Response) -> str: - output_text = getattr(response, "output_text", None) - if isinstance(output_text, str): - return output_text - - parts: list[str] = [] - for item in getattr(response, "output", []) or []: - if getattr(item, "type", None) != "message": - continue - for content in getattr(item, "content", []) or []: - if getattr(content, "type", None) == "output_text": - text = getattr(content, "text", None) - if isinstance(text, str): - parts.append(text) - return "".join(parts) + 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, + }) - @staticmethod - def _response_reasoning(response: Response) -> str | None: - parts: list[str] = [] - for item in getattr(response, "output", []) or []: - if getattr(item, "type", None) != "reasoning": - continue - summary = getattr(item, "summary", None) - if isinstance(summary, str): - parts.append(summary) - elif isinstance(summary, list): - parts.extend(str(part) for part in summary if part) - return "\n".join(parts) or None - @staticmethod - def _response_tool_calls(response: Response) -> list[dict[str, Any]]: - calls: list[dict[str, Any]] = [] - for item in getattr(response, "output", []) or []: - if getattr(item, "type", None) != "function_call": - continue - name = getattr(item, "name", None) - if not isinstance(name, str) or not name: - continue - calls.append({ - "id": getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{len(calls)}", - "type": "function", - "function": { - "name": name, - "arguments": getattr(item, "arguments", None) or "{}", - }, - }) - return calls - - @staticmethod - def _completion_usage(response: Response) -> dict[str, Any] | None: - usage = getattr(response, "usage", None) - if usage is None: - return None - if hasattr(usage, "model_dump"): - payload = usage.model_dump() - elif isinstance(usage, dict): - payload = dict(usage) - else: - payload = { - key: value - for key in ("input_tokens", "output_tokens", "total_tokens") - if (value := getattr(usage, key, None)) is not None - } - prompt_tokens = int(payload.get("input_tokens") or 0) - completion_tokens = int(payload.get("output_tokens") or 0) - total_tokens = int(payload.get("total_tokens") or prompt_tokens + completion_tokens) - return { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": total_tokens, +def _completion_usage_from_response_usage(usage: Any) -> dict[str, int]: + 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), } + 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) + return { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + } - @staticmethod - def _convert_tools_for_responses(tools: list[dict[str, Any] | Any] | None) -> list[dict[str, Any]] | None: - if not tools: - return None - converted: list[dict[str, Any]] = [] - for tool in tools: - if not isinstance(tool, dict): - converted.append(cast("dict[str, Any]", tool)) - continue - function = tool.get("function") - if not isinstance(function, dict): - converted.append(dict(tool)) - continue - response_tool = { - "type": tool.get("type", "function"), - "name": function.get("name"), - "description": function.get("description", ""), - "parameters": function.get("parameters", {}), - } - if "strict" in function: - response_tool["strict"] = function["strict"] - converted.append(response_tool) - return converted +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 + + tool_calls = payload.get("tool_calls") + if isinstance(tool_calls, Sequence) and not isinstance(tool_calls, str): + content = payload.get("content") + 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": payload.get("content") or ""}) + return response_input + + +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 "", + } - @staticmethod - def _convert_tool_choice_for_responses(tool_choice: str | dict[str, Any] | None) -> str | dict[str, Any] | None: - if not isinstance(tool_choice, dict): - return tool_choice - function = tool_choice.get("function") - if not isinstance(function, dict): - return tool_choice - function_name = function.get("name") - if not isinstance(function_name, str) or not function_name: - return tool_choice - converted = dict(tool_choice) - converted.pop("function", None) - converted["type"] = converted.get("type", "function") - converted["name"] = function_name - return converted +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( diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py index 9a7a97a1..62e94760 100644 --- a/src/bub/builtin/model_runner.py +++ b/src/bub/builtin/model_runner.py @@ -40,13 +40,7 @@ def _stream_usage_options(llm: AnyLLM, *, stream: bool) -> dict[str, Any] | None: - """Make streaming completions report token usage. - - OpenAI-style streaming responses omit the `usage` block unless the request - sets `stream_options.include_usage`; without it every streamed run records - zero tokens (and zero cost). Only OpenAI-compatible providers accept the - field, so gate on the provider base class — anthropic/gemini reject it. - """ + """Make streaming completions report token usage.""" if stream and isinstance(llm, BaseOpenAIProvider): return {"include_usage": True} return None diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index b7de505a..fc377bb3 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -24,11 +24,11 @@ class _FakeModelRunner(ModelRunner): def __init__(self, settings: AgentSettings) -> None: super().__init__(settings) - self.completion_kwargs: dict[str, Any] | None = None + self.response_kwargs: dict[str, Any] | None = None - async def completion_response(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: - self.completion_kwargs = kwargs - return _chat_stream("done") + async def completion_response(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: # type: ignore[override] + self.response_kwargs = kwargs + return _completion_stream("done") def _make_agent() -> Agent: @@ -51,26 +51,22 @@ def _model_runner(agent: Agent) -> _FakeModelRunner: return agent.model_runner -def _chat_chunk(content: str) -> ChatCompletionChunk: - return ChatCompletionChunk.model_validate({ +async def _completion_stream(content: str) -> AsyncIterator[ChatCompletionChunk]: + yield ChatCompletionChunk.model_validate({ "id": "chatcmpl_test", "object": "chat.completion.chunk", "created": 0, - "model": "test:model", + "model": "test-model", "choices": [ { "index": 0, - "finish_reason": "stop", + "finish_reason": None, "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.""" @@ -209,9 +205,9 @@ async def test_agent_run_passes_model_to_llm() -> None: ) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs - assert completion_kwargs is not None - assert completion_kwargs["model"] == "openai:gpt-4o" + response_kwargs = _model_runner(agent).response_kwargs + assert response_kwargs is not None + assert response_kwargs["model"] == "openai:gpt-4o" @pytest.mark.asyncio @@ -239,9 +235,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 - assert completion_kwargs is not None - assert completion_kwargs["model"] == "test:model" + response_kwargs = _model_runner(agent).response_kwargs + assert response_kwargs is not None + assert response_kwargs["model"] == "test:model" @pytest.mark.asyncio @@ -267,9 +263,9 @@ 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 - assert completion_kwargs is not None - assert completion_kwargs["model"] == "openai:gpt-4o" + response_kwargs = _model_runner(agent).response_kwargs + assert response_kwargs is not None + assert response_kwargs["model"] == "openai:gpt-4o" assert agent.settings.model == default_model @@ -301,10 +297,10 @@ def denied_agent_tool() -> str: ) [event async for event in result] - completion_kwargs = _model_runner(agent).completion_kwargs - assert completion_kwargs is not None - assert [tool.name for tool in completion_kwargs["tools"]] == ["tests_allowed_agent_tool"] - system_prompt = completion_kwargs["messages"][0]["content"] + response_kwargs = _model_runner(agent).response_kwargs + assert response_kwargs is not None + assert [tool.name for tool in response_kwargs["tools"]] == ["tests_allowed_agent_tool"] + system_prompt = response_kwargs["messages"][0]["content"] assert "- tests_allowed_agent_tool(): Allowed tool" in system_prompt assert "tests_denied_agent_tool" not in system_prompt diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py index 0945f313..85613098 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -4,12 +4,14 @@ import json import time from pathlib import Path +from types import SimpleNamespace from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock +import pytest from any_llm.constants import LLMProvider -from any_llm.types.completion import ChatCompletion -from any_llm.types.responses import Response +from any_llm.types.completion import CompletionParams +from any_llm.types.responses import ResponsesParams from bub.builtin.auth import ( OpenAICodexOAuthTokens, @@ -18,7 +20,15 @@ openai_codex_oauth_resolver, save_openai_codex_oauth_tokens, ) -from bub.builtin.codex_provider import OpenaiCodexProvider, should_use_openai_codex_provider +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 ModelRunner from bub.builtin.settings import ModelCandidate @@ -38,35 +48,6 @@ def _b64(payload: dict[str, Any]) -> str: return base64.urlsafe_b64encode(raw).decode().rstrip("=") -def _response_payload(*, output: list[dict[str, Any]]) -> dict[str, Any]: - return { - "id": "resp_1", - "object": "response", - "created_at": 0, - "status": "completed", - "error": None, - "incomplete_details": None, - "instructions": None, - "metadata": {}, - "model": "gpt-5-codex", - "output": output, - "parallel_tool_calls": False, - "temperature": None, - "tool_choice": "auto", - "tools": [], - "top_p": None, - "truncation": "disabled", - "usage": { - "input_tokens": 1, - "input_tokens_details": {"cached_tokens": 0}, - "output_tokens": 2, - "output_tokens_details": {"reasoning_tokens": 0}, - "total_tokens": 3, - }, - "store": False, - } - - def test_openai_codex_oauth_tokens_round_trip(tmp_path: Path) -> None: tokens = OpenAICodexOAuthTokens( access_token=_jwt_with_account("acct_123"), @@ -156,54 +137,222 @@ def test_model_runner_creates_codex_provider_for_codex_model(monkeypatch) -> Non provider_class.assert_called_once_with(api_key=None, api_base=None) -def test_codex_provider_converts_response_to_chat_completion() -> None: - response = Response.model_validate( - _response_payload( - output=[ - { - "id": "msg_1", - "type": "message", - "role": "assistant", - "status": "completed", - "content": [{"type": "output_text", "text": "hello", "annotations": []}], - } - ] - ) +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}, + ) - completion = provider._response_to_completion(response, model="gpt-5-codex") - - assert isinstance(completion, ChatCompletion) - assert completion.choices[0].message.content == "hello" - assert completion.choices[0].finish_reason == "stop" - assert completion.usage is not None - assert completion.usage.prompt_tokens == 1 - assert completion.usage.completion_tokens == 2 - - -def test_codex_provider_converts_function_call_to_chat_completion_tool_call() -> None: - response = Response.model_validate( - _response_payload( - output=[ - { - "id": "fc_1", - "type": "function_call", - "call_id": "call_1", - "name": "tool_name", - "arguments": '{"ok": true}', - "status": "completed", - } - ] - ) + 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": '{"cmd":"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": '{"cmd":"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", + } + + +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), + ), + ) + + +@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 + + +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='{"cmd":') + 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]) == ( + '{"cmd":"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='{"cmd":' + ) + 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), + ) + + +@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 = provider._response_to_completion(response, model="gpt-5-codex") + completion = await provider._acompletion(params) + chunks = [chunk async for chunk in completion] - assert completion.choices[0].finish_reason == "tool_calls" - tool_calls = completion.choices[0].message.tool_calls - assert tool_calls is not None - assert tool_calls[0].id == "call_1" - assert tool_calls[0].function.name == "tool_name" - assert tool_calls[0].function.arguments == '{"ok": true}' + 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]) == ( + '{"cmd":"pwd"}' + ) + assert chunks[-1].choices[0].finish_reason == "tool_calls" From 2ac163788457c5d640afd5981594602c606f96a6 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sun, 5 Jul 2026 18:54:47 +0800 Subject: [PATCH 5/8] test: align agent steering fixture --- tests/test_builtin_agent.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index 10e12952..c5d265b4 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -296,9 +296,9 @@ 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 - assert completion_kwargs is not None - completion_messages = completion_kwargs["messages"] + response_kwargs = _model_runner(agent).response_kwargs + assert response_kwargs is not None + completion_messages = response_kwargs["messages"] assert completion_messages[-3:] == [ {"role": "user", "content": "first steer"}, {"role": "user", "content": "second steer"}, @@ -314,9 +314,9 @@ async def test_agent_run_injects_steering_messages_once_by_session() -> None: 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 - assert completion_kwargs is not None - completion_messages = completion_kwargs["messages"] + response_kwargs = _model_runner(agent).response_kwargs + assert response_kwargs is not None + completion_messages = response_kwargs["messages"] assert completion_messages[-1] == {"role": "user", "content": "again"} assert {"role": "user", "content": "ignore me"} not in completion_messages From 549d8142fe1a85016d46b298f3c1f8fc76965fbb Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sun, 5 Jul 2026 19:01:28 +0800 Subject: [PATCH 6/8] test: keep completion fixture naming --- tests/test_builtin_agent.py | 58 ++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index c5d265b4..9559dd4f 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -25,11 +25,11 @@ class _FakeModelRunner(ModelRunner): def __init__(self, settings: AgentSettings) -> None: super().__init__(settings) - self.response_kwargs: dict[str, Any] | None = None + self.completion_kwargs: dict[str, Any] | None = None - async def completion_response(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: # type: ignore[override] - self.response_kwargs = kwargs - return _completion_stream("done") + async def completion_response(self, **kwargs: Any) -> AsyncIterator[ChatCompletionChunk]: + self.completion_kwargs = kwargs + return _chat_stream("done") def _make_agent() -> Agent: @@ -58,22 +58,26 @@ def _model_runner(agent: Agent) -> _FakeModelRunner: return agent.model_runner -async def _completion_stream(content: str) -> AsyncIterator[ChatCompletionChunk]: - yield ChatCompletionChunk.model_validate({ +def _chat_chunk(content: str) -> ChatCompletionChunk: + return ChatCompletionChunk.model_validate({ "id": "chatcmpl_test", "object": "chat.completion.chunk", "created": 0, - "model": "test-model", + "model": "test:model", "choices": [ { "index": 0, - "finish_reason": None, + "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.""" @@ -212,9 +216,9 @@ async def test_agent_run_passes_model_to_llm() -> None: ) [event async for event in result] - response_kwargs = _model_runner(agent).response_kwargs - assert response_kwargs is not None - assert response_kwargs["model"] == "openai:gpt-4o" + completion_kwargs = _model_runner(agent).completion_kwargs + assert completion_kwargs is not None + assert completion_kwargs["model"] == "openai:gpt-4o" @pytest.mark.asyncio @@ -242,9 +246,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] - response_kwargs = _model_runner(agent).response_kwargs - assert response_kwargs is not None - assert response_kwargs["model"] == "test:model" + completion_kwargs = _model_runner(agent).completion_kwargs + assert completion_kwargs is not None + assert completion_kwargs["model"] == "test:model" @pytest.mark.asyncio @@ -270,9 +274,9 @@ async def test_agent_run_model_override_does_not_mutate_default() -> None: ) [event async for event in result] - response_kwargs = _model_runner(agent).response_kwargs - assert response_kwargs is not None - assert response_kwargs["model"] == "openai:gpt-4o" + completion_kwargs = _model_runner(agent).completion_kwargs + assert completion_kwargs is not None + assert completion_kwargs["model"] == "openai:gpt-4o" assert agent.settings.model == default_model @@ -296,9 +300,9 @@ 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] - response_kwargs = _model_runner(agent).response_kwargs - assert response_kwargs is not None - completion_messages = response_kwargs["messages"] + completion_kwargs = _model_runner(agent).completion_kwargs + assert completion_kwargs is not None + completion_messages = completion_kwargs["messages"] assert completion_messages[-3:] == [ {"role": "user", "content": "first steer"}, {"role": "user", "content": "second steer"}, @@ -314,9 +318,9 @@ async def test_agent_run_injects_steering_messages_once_by_session() -> None: result = await agent.run_stream(session_id="user/s1", prompt="again", state={"_runtime_workspace": "/tmp"}) # noqa: S108 [event async for event in result] - response_kwargs = _model_runner(agent).response_kwargs - assert response_kwargs is not None - completion_messages = response_kwargs["messages"] + completion_kwargs = _model_runner(agent).completion_kwargs + assert completion_kwargs is not None + completion_messages = completion_kwargs["messages"] assert completion_messages[-1] == {"role": "user", "content": "again"} assert {"role": "user", "content": "ignore me"} not in completion_messages @@ -349,10 +353,10 @@ def denied_agent_tool() -> str: ) [event async for event in result] - response_kwargs = _model_runner(agent).response_kwargs - assert response_kwargs is not None - assert [tool.name for tool in response_kwargs["tools"]] == ["tests_allowed_agent_tool"] - system_prompt = response_kwargs["messages"][0]["content"] + completion_kwargs = _model_runner(agent).completion_kwargs + assert completion_kwargs is not None + assert [tool.name for tool in completion_kwargs["tools"]] == ["tests_allowed_agent_tool"] + system_prompt = completion_kwargs["messages"][0]["content"] assert "- tests_allowed_agent_tool(): Allowed tool" in system_prompt assert "tests_denied_agent_tool" not in system_prompt From 3eca9ff5ae84d5c4f99870eb040d0cf8ff02d3ff Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sun, 5 Jul 2026 19:26:30 +0800 Subject: [PATCH 7/8] test: cover codex null tool name stream event --- tests/test_builtin_codex.py | 53 ++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/test_builtin_codex.py b/tests/test_builtin_codex.py index 85613098..5c250127 100644 --- a/tests/test_builtin_codex.py +++ b/tests/test_builtin_codex.py @@ -29,7 +29,7 @@ resolve_openai_codex_api_base, should_use_openai_codex_provider, ) -from bub.builtin.model_runner import ModelRunner +from bub.builtin.model_runner import ModelOutputAccumulator, ModelRunner from bub.builtin.settings import ModelCandidate TEST_REFRESH_TOKEN = "refresh" # noqa: S105 @@ -356,3 +356,54 @@ async def test_codex_completion_stream_maps_custom_tool_call_input_deltas_to_com '{"cmd":"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), + ) + + +@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" From 08df33f9eac678fc349376dac6a72f980f92a66f Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Mon, 6 Jul 2026 00:17:29 +0800 Subject: [PATCH 8/8] chore: restore stream usage docstring --- src/bub/builtin/model_runner.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py index ad4a4ccc..00a8c06e 100644 --- a/src/bub/builtin/model_runner.py +++ b/src/bub/builtin/model_runner.py @@ -40,7 +40,13 @@ def _stream_usage_options(llm: AnyLLM, *, stream: bool) -> dict[str, Any] | None: - """Make streaming completions report token usage.""" + """Make streaming completions report token usage. + + OpenAI-style streaming responses omit the `usage` block unless the request + sets `stream_options.include_usage`; without it every streamed run records + zero tokens (and zero cost). Only OpenAI-compatible providers accept the + field, so gate on the provider base class — anthropic/gemini reject it. + """ if stream and isinstance(llm, BaseOpenAIProvider): return {"include_usage": True} return None