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..2ce4d88e --- /dev/null +++ b/src/bub/builtin/auth.py @@ -0,0 +1,501 @@ +"""Authentication helpers for builtin providers.""" + +# ruff: noqa: B008 +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 + +import typer +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" + +app = typer.Typer(name="login", help="Authentication related commands") + + +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 _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: + 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..48725103 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -17,6 +17,7 @@ import typer from bub import __version__, configure +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 diff --git a/src/bub/builtin/codex_provider.py b/src/bub/builtin/codex_provider.py new file mode 100644 index 00000000..77606890 --- /dev/null +++ b/src/bub/builtin/codex_provider.py @@ -0,0 +1,491 @@ +"""OpenAI Codex OAuth provider for any-llm Responses calls.""" + +from __future__ import annotations + +import time +from collections.abc import AsyncIterator, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, cast, override + +from any_llm.exceptions import MissingApiKeyError +from any_llm.providers.openai.base import BaseOpenAIProvider +from any_llm.types.completion import ChatCompletion, ChatCompletionChunk, CompletionParams +from any_llm.types.model import Model +from any_llm.types.responses import Response, ResponsesParams + +from bub.builtin.auth import ( + extract_openai_codex_account_id, + load_openai_codex_oauth_tokens, + openai_codex_oauth_resolver, +) + +DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api" +DEFAULT_CODEX_ORIGINATOR = "bub" +DEFAULT_CODEX_INCLUDE = ["reasoning.encrypted_content"] +DEFAULT_CODEX_INSTRUCTIONS = "You are Codex." +DEFAULT_CODEX_TEXT_CONFIG = {"verbosity": "medium"} + + +class OpenAICodexTransportError(RuntimeError): + def __init__(self, status_code: int | None, message: str, body: str | None = None) -> None: + super().__init__(message) + self.status_code = status_code + self.body = body + + +class OpenaiCodexProvider(BaseOpenAIProvider): + """OpenAI-compatible Responses provider backed by Codex OAuth credentials.""" + + API_BASE = DEFAULT_CODEX_BASE_URL + ENV_API_KEY_NAME = "OPENAI_CODEX_API_KEY" + ENV_API_BASE_NAME = "OPENAI_CODEX_API_BASE" + PROVIDER_NAME = "openaicodex" + PROVIDER_DOCUMENTATION_URL = "https://platform.openai.com/docs/codex" + + SUPPORTS_COMPLETION_STREAMING = True + SUPPORTS_COMPLETION = True + SUPPORTS_COMPLETION_REASONING = True + SUPPORTS_RESPONSES = True + SUPPORTS_LIST_MODELS = False + SUPPORTS_BATCH = False + SUPPORTS_IMAGE_GENERATION = False + SUPPORTS_AUDIO_TRANSCRIPTION = False + SUPPORTS_AUDIO_SPEECH = False + SUPPORTS_EMBEDDING = False + SUPPORTS_MODERATION = False + + def __init__( + self, + *, + api_key: str | None = None, + api_base: str | None = None, + codex_home: str | None = None, + default_instructions: str = DEFAULT_CODEX_INSTRUCTIONS, + default_include: Sequence[str] = tuple(DEFAULT_CODEX_INCLUDE), + default_text: dict[str, Any] | None = None, + originator: str = DEFAULT_CODEX_ORIGINATOR, + store: bool = False, + **kwargs: Any, + ) -> None: + self._codex_home = codex_home + self._default_instructions = default_instructions + self._default_include = list(default_include) + self._default_text = dict(default_text or DEFAULT_CODEX_TEXT_CONFIG) + self._originator = originator + self._store = store + super().__init__(api_key=api_key, api_base=api_base, **kwargs) + + @override + def _verify_and_set_api_key(self, api_key: str | None = None) -> str | None: + if api_key: + return api_key + resolved = openai_codex_oauth_resolver(self._codex_home)("openai") + if not resolved: + raise MissingApiKeyError(self.PROVIDER_NAME, self.ENV_API_KEY_NAME) + return resolved + + @override + def _init_client(self, api_key: str | None = None, api_base: str | None = None, **kwargs: Any) -> None: + default_headers = dict(kwargs.pop("default_headers", {})) + default_headers.update(build_openai_codex_default_headers(api_key or "", originator=self._originator)) + super()._init_client( + api_key=api_key, + api_base=resolve_openai_codex_api_base(api_base), + default_headers=default_headers, + **kwargs, + ) + + @staticmethod + @override + def _convert_list_models_response(response: Any) -> Sequence[Model]: + raise NotImplementedError("OpenAI Codex OAuth provider does not support listing models.") + + @override + async def _acompletion( + self, + params: CompletionParams, + **kwargs: Any, + ) -> ChatCompletion | AsyncIterator[ChatCompletionChunk]: + responses_params = self._completion_params_to_responses_params(params) + if params.stream: + response = await self._aresponses(responses_params.model_copy(update={"stream": True})) + if not hasattr(response, "__aiter__"): + raise OpenAICodexTransportError(None, "OpenAI Codex Responses API returned a non-streaming result.") + return self._response_stream_to_completion_chunks( + cast("AsyncIterator[Any]", response), + model=params.model_id, + ) + + response = await self._aresponses(responses_params.model_copy(update={"stream": False})) + return self._response_to_completion(cast("Response", response), model=params.model_id) + + @override + async def _aresponses(self, params: ResponsesParams, **kwargs: Any) -> Any: + return await super()._aresponses(self._with_codex_response_defaults(params), **kwargs) + + def _completion_params_to_responses_params(self, params: CompletionParams) -> ResponsesParams: + reasoning = None + if params.reasoning_effort not in {None, "auto"}: + reasoning = {"effort": params.reasoning_effort} + + return ResponsesParams( + model=params.model_id, + input=cast("Any", _completion_messages_to_responses_input(params.messages)), + tools=self._completion_tools_to_response_tools(cast("Sequence[Any] | None", params.tools)), + tool_choice=self._completion_tool_choice_to_response_tool_choice(params.tool_choice), + response_format=params.response_format, + stream=params.stream, + parallel_tool_calls=params.parallel_tool_calls, + reasoning=reasoning, + ) + + @staticmethod + def _completion_tools_to_response_tools(tools: Sequence[Any] | None) -> list[dict[str, Any]] | None: + if not tools: + return None + response_tools: list[dict[str, Any]] = [] + for tool in tools: + payload = tool.model_dump(exclude_none=True) if hasattr(tool, "model_dump") else dict(tool) + function = payload.get("function") + if payload.get("type") == "function" and isinstance(function, dict): + response_tools.append({ + "type": "function", + "name": function.get("name", ""), + "description": function.get("description", ""), + "parameters": function.get("parameters", {}), + }) + else: + response_tools.append(payload) + return response_tools + + @staticmethod + def _completion_tool_choice_to_response_tool_choice(tool_choice: str | dict[str, Any] | None) -> Any: + if not isinstance(tool_choice, dict): + return tool_choice + function = tool_choice.get("function") + if tool_choice.get("type") == "function" and isinstance(function, dict): + return {"type": "function", "name": function.get("name", "")} + return tool_choice + + async def _response_stream_to_completion_chunks( + self, + events: AsyncIterator[Any], + *, + model: str, + ) -> AsyncIterator[ChatCompletionChunk]: + mapper = CodexCompletionChunkMapper(model=model) + async for event in events: + for chunk in mapper.map_event(event): + yield chunk + + @staticmethod + def _response_to_completion(response: Response, *, model: str) -> ChatCompletion: + text_parts: list[str] = [] + tool_calls: list[dict[str, Any]] = [] + for item in getattr(response, "output", []) or []: + if getattr(item, "type", None) == "message": + for content in getattr(item, "content", []) or []: + text = getattr(content, "text", None) + if isinstance(text, str): + text_parts.append(text) + if getattr(item, "type", None) in {"function_call", "custom_tool_call"}: + tool_calls.append({ + "id": getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{len(tool_calls)}", + "type": "function", + "function": { + "name": getattr(item, "name", "") or "", + "arguments": _tool_item_arguments(item) or "{}", + }, + }) + + usage = _completion_usage_from_response_usage(getattr(response, "usage", None)) + return ChatCompletion.model_validate({ + "id": getattr(response, "id", None) or "chatcmpl_codex", + "object": "chat.completion", + "created": int(getattr(response, "created_at", None) or time.time()), + "model": getattr(response, "model", None) or model, + "choices": [ + { + "index": 0, + "finish_reason": "tool_calls" if tool_calls else "stop", + "message": { + "role": "assistant", + "content": "".join(text_parts) or None, + "tool_calls": tool_calls or None, + }, + } + ], + "usage": usage, + }) + + def _with_codex_response_defaults(self, params: ResponsesParams) -> ResponsesParams: + update: dict[str, Any] = {} + if params.store is None: + update["store"] = self._store + if params.instructions is None: + update["instructions"] = self._default_instructions + if params.include is None: + update["include"] = list(self._default_include) + if isinstance(params.text, dict): + update["text"] = {**self._default_text, **params.text} + elif params.text is None: + update["text"] = dict(self._default_text) + return params.model_copy(update=update) + + +@dataclass +class _CodexToolState: + started: bool = False + arguments_seen: bool = False + + +class CodexCompletionChunkMapper: + def __init__(self, *, model: str) -> None: + self.model = model + self.created = int(time.time()) + self._tool_states: dict[int, _CodexToolState] = {} + self._tool_indexes_by_identifier: dict[str, int] = {} + + def map_event(self, event: Any) -> list[ChatCompletionChunk]: + event_type = getattr(event, "type", None) + if event_type == "response.output_text.delta": + return [self._delta_chunk({"content": _string_attr(event, "delta")})] + if event_type in {"response.reasoning_text.delta", "response.reasoning_summary_text.delta"}: + return [self._delta_chunk({"reasoning": _string_attr(event, "delta")})] + if event_type == "response.output_item.added": + return self._tool_item_chunks(event) + if event_type in {"response.function_call_arguments.delta", "response.custom_tool_call_input.delta"}: + return self._tool_arguments_delta_chunks(event) + if event_type == "response.function_call_arguments.done": + return self._tool_arguments_done_chunks(event) + if event_type == "response.output_item.done": + return self._tool_item_chunks(event, done=True) + if event_type == "response.completed": + response = getattr(event, "response", None) + return [self._terminal_chunk(response=response)] + if event_type in {"response.failed", "response.incomplete"}: + raise OpenAICodexTransportError(None, f"OpenAI Codex response ended with {event_type}") + if event_type == "error": + message = getattr(event, "message", None) or "OpenAI Codex response stream error" + raise OpenAICodexTransportError(None, str(message)) + return [] + + def _tool_item_chunks(self, event: Any, *, done: bool = False) -> list[ChatCompletionChunk]: + output_index = getattr(event, "output_index", None) + item = getattr(event, "item", None) + if getattr(item, "type", None) not in {"function_call", "custom_tool_call"} or not isinstance( + output_index, int + ): + return [] + + self._remember_tool_identifiers(item, output_index) + state = self._tool_states.setdefault(output_index, _CodexToolState()) + if done and state.started and state.arguments_seen: + return [] + + tool_delta: dict[str, Any] = { + "index": output_index, + "id": getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{output_index}", + "type": "function", + "function": { + "name": getattr(item, "name", None) or "", + "arguments": "" if state.arguments_seen else _tool_item_arguments(item), + }, + } + state.started = True + if tool_delta["function"]["arguments"]: + state.arguments_seen = True + return [self._delta_chunk({"tool_calls": [tool_delta]})] + + def _tool_arguments_delta_chunks(self, event: Any) -> list[ChatCompletionChunk]: + output_index = self._tool_index_for_event(event) + if output_index is None: + return [] + delta = _string_attr(event, "delta") + if not delta: + return [] + state = self._tool_states.setdefault(output_index, _CodexToolState()) + state.arguments_seen = True + return [self._delta_chunk({"tool_calls": [{"index": output_index, "function": {"arguments": delta}}]})] + + def _tool_arguments_done_chunks(self, event: Any) -> list[ChatCompletionChunk]: + output_index = self._tool_index_for_event(event) + if output_index is None: + return [] + state = self._tool_states.setdefault(output_index, _CodexToolState()) + if state.arguments_seen: + return [] + arguments = _string_attr(event, "arguments") + if not arguments: + return [] + state.arguments_seen = True + name = _string_attr(event, "name") + function: dict[str, str] = {"arguments": arguments} + if name: + function["name"] = name + return [self._delta_chunk({"tool_calls": [{"index": output_index, "function": function}]})] + + def _tool_index_for_event(self, event: Any) -> int | None: + output_index = getattr(event, "output_index", None) + if isinstance(output_index, int): + return output_index + for attr in ("item_id", "call_id"): + identifier = getattr(event, attr, None) + if isinstance(identifier, str): + known_index = self._tool_indexes_by_identifier.get(identifier) + if known_index is not None: + return known_index + return None + + def _remember_tool_identifiers(self, item: Any, output_index: int) -> None: + for attr in ("id", "call_id"): + identifier = getattr(item, attr, None) + if isinstance(identifier, str) and identifier: + self._tool_indexes_by_identifier[identifier] = output_index + + def _delta_chunk(self, delta: dict[str, Any]) -> ChatCompletionChunk: + return ChatCompletionChunk.model_validate({ + "id": "chatcmpl_codex", + "object": "chat.completion.chunk", + "created": self.created, + "model": self.model, + "choices": [{"index": 0, "delta": delta, "finish_reason": None}], + }) + + def _terminal_chunk(self, *, response: Any) -> ChatCompletionChunk: + usage = _completion_usage_from_response_usage(getattr(response, "usage", None)) + finish_reason = "tool_calls" if self._tool_states else "stop" + return ChatCompletionChunk.model_validate({ + "id": getattr(response, "id", None) or "chatcmpl_codex", + "object": "chat.completion.chunk", + "created": int(getattr(response, "created_at", None) or self.created), + "model": getattr(response, "model", None) or self.model, + "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}], + "usage": usage, + }) + + +def _completion_usage_from_response_usage(usage: Any) -> dict[str, 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, + } + + +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 "", + } + + +def _mapping_from_value(value: Any) -> Mapping[str, Any]: + if isinstance(value, Mapping): + return value + if hasattr(value, "model_dump"): + dumped = value.model_dump(exclude_none=True) + if isinstance(dumped, Mapping): + return dumped + return {} + + +def should_use_openai_codex_provider( + provider: str, model_id: str, *, api_key: str | None, api_base: str | None +) -> bool: + if provider != "openai" or api_base: + return False + if api_key: + return extract_openai_codex_account_id(api_key) is not None + return load_openai_codex_oauth_tokens() is not None + + +def resolve_openai_codex_api_base(api_base: str | None) -> str: + raw = (api_base or DEFAULT_CODEX_BASE_URL).rstrip("/") + if raw.endswith("/responses"): + raw = raw[: -len("/responses")] + if raw.endswith("/codex"): + return raw + return f"{raw}/codex" + + +def build_openai_codex_default_headers(api_key: str, *, originator: str = DEFAULT_CODEX_ORIGINATOR) -> dict[str, str]: + account_id = extract_openai_codex_account_id(api_key) + if account_id is None: + raise OpenAICodexTransportError(None, "OpenAI Codex OAuth token is missing chatgpt_account_id") + return { + "chatgpt-account-id": account_id, + "OpenAI-Beta": "responses=experimental", + "originator": originator, + } diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 5f0be861..7aa9d87c 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -208,6 +208,7 @@ def register_cli_commands(self, app: typer.Typer) -> None: app.command("run")(cli.run) app.command("chat")(cli.chat) + 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/src/bub/builtin/model_runner.py b/src/bub/builtin/model_runner.py index 0b97f488..ad4a4ccc 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.providers.openai.base import BaseOpenAIProvider from any_llm.types.completion import ( ChatCompletion, @@ -24,6 +25,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 @@ -38,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 @@ -56,14 +52,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..729efe2b 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -11,12 +11,16 @@ 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 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 +348,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 = 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.auth.login_openai_codex_oauth", return_value=tokens) + + with login as login_mock: + result = CliRunner().invoke( + _create_app(), + ["login", "openai", "--codex-home", str(tmp_path), "--manual", "--no-browser"], + ) + + assert result.exit_code == 0 + assert "login: ok" in result.stdout + assert "account_id: acct_123" in result.stdout + assert f"auth_file: {tmp_path / 'auth.json'}" in result.stdout + login_mock.assert_called_once() + assert login_mock.call_args.kwargs["codex_home"] == tmp_path + assert login_mock.call_args.kwargs["open_browser"] is False + assert login_mock.call_args.kwargs["prompt_for_redirect"] is auth._prompt_for_codex_redirect + + +def test_login_rejects_unknown_provider() -> None: + result = CliRunner().invoke(_create_app(), ["login", "github"]) assert result.exit_code == 2 - assert "No such command 'login'" in result.stderr + assert "No such command '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..5c250127 --- /dev/null +++ b/tests/test_builtin_codex.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import base64 +import json +import time +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from any_llm.constants import LLMProvider +from any_llm.types.completion import CompletionParams +from any_llm.types.responses import ResponsesParams + +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 ( + DEFAULT_CODEX_INCLUDE, + DEFAULT_CODEX_INSTRUCTIONS, + DEFAULT_CODEX_TEXT_CONFIG, + OpenaiCodexProvider, + build_openai_codex_default_headers, + resolve_openai_codex_api_base, + should_use_openai_codex_provider, +) +from bub.builtin.model_runner import ModelOutputAccumulator, ModelRunner +from bub.builtin.settings import 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 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_adds_response_defaults() -> None: + provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) + params = ResponsesParams(model="gpt-5-codex", input="hello", stream=True, text={"format": {"type": "text"}}) + + prepared = provider._with_codex_response_defaults(params) + + assert prepared.store is False + assert prepared.instructions == DEFAULT_CODEX_INSTRUCTIONS + assert prepared.include == DEFAULT_CODEX_INCLUDE + assert prepared.text == {**DEFAULT_CODEX_TEXT_CONFIG, "format": {"type": "text"}} + + +def test_codex_provider_preserves_explicit_response_options() -> None: + provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) + params = ResponsesParams( + model="gpt-5-codex", + input="hello", + instructions="custom", + include=[], + store=True, + text={"verbosity": "low"}, + ) + + prepared = provider._with_codex_response_defaults(params) + + assert prepared.store is True + assert prepared.instructions == "custom" + assert prepared.include == [] + assert prepared.text == {**DEFAULT_CODEX_TEXT_CONFIG, "verbosity": "low"} + + +def test_codex_completion_params_use_official_responses_payload_fields() -> None: + provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) + params = CompletionParams( + model_id="gpt-5.5", + messages=[{"role": "user", "content": "hello"}], + max_tokens=100, + temperature=0.2, + top_p=0.9, + presence_penalty=0.1, + frequency_penalty=0.1, + user="user_123", + stream=True, + stream_options={"include_usage": True}, + ) + + responses_params = provider._completion_params_to_responses_params(params) + payload = responses_params.model_dump(exclude_none=True, exclude={"response_format"}) + + assert payload == { + "model": "gpt-5.5", + "input": [{"role": "user", "content": "hello"}], + "stream": True, + } + + +def test_codex_completion_params_convert_chat_tool_messages_to_responses_items() -> None: + provider = OpenaiCodexProvider(api_key=_jwt_with_account("acct_123")) + params = CompletionParams( + model_id="gpt-5.5", + messages=[ + {"role": "user", "content": "run bash"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "bash", "arguments": '{"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 = await provider._acompletion(params) + chunks = [chunk async for chunk in completion] + + first_tool_delta = chunks[0].choices[0].delta.tool_calls[0] + assert first_tool_delta.index == 1 + assert first_tool_delta.id == "call_1" + assert first_tool_delta.function.name == "bash" + assert "".join(chunk.choices[0].delta.tool_calls[0].function.arguments or "" for chunk in chunks[1:3]) == ( + '{"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" diff --git a/tests/test_framework.py b/tests/test_framework.py index 1acc98b3..a9acf7d6 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -161,7 +161,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 62a78f0f..58675b3f 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"