From 7524ef3a9643d964ec78e1c0c1181f2144a7ca5e Mon Sep 17 00:00:00 2001 From: Carlos Nahuelcoy Date: Wed, 8 Jul 2026 15:58:05 -0400 Subject: [PATCH 1/4] Add native Player2 provider with assisted login (local app detection + device code flow) --- src/rotator_library/credential_tool.py | 122 ++++++++++ .../providers/player2_provider.py | 224 ++++++++++++++++++ .../providers/utilities/player2_auth.py | 224 ++++++++++++++++++ 3 files changed, 570 insertions(+) create mode 100644 src/rotator_library/providers/player2_provider.py create mode 100644 src/rotator_library/providers/utilities/player2_auth.py diff --git a/src/rotator_library/credential_tool.py b/src/rotator_library/credential_tool.py index 8240ba961..75ec68442 100644 --- a/src/rotator_library/credential_tool.py +++ b/src/rotator_library/credential_tool.py @@ -9,6 +9,7 @@ import re import time from pathlib import Path +from typing import Optional from dotenv import set_key, get_key # NOTE: Heavy imports (provider_factory, PROVIDER_PLUGINS) are deferred @@ -1150,6 +1151,118 @@ def _get_providers_by_category(providers: dict) -> dict: by_category[category].append((name, config)) return by_category +async def _setup_player2_credential(api_key_var: str): + """ + Assisted login wizard for Player2 (https://player2.game). + + Unlike a normal API key, a Player2 "p2Key" is meant to be obtained + through a login step rather than copy-pasted from a dashboard. This + wizard tries, in order: + + 1. Detecting a Player2 desktop app already running locally (instant, + no browser needed) - useful for the game-mod use case where players + already have the app open. + 2. The OAuth 2.0 Device Authorization Flow (a URL + short code the user + opens on any device) - works headless, over SSH, in Docker, etc. + + A manual "paste an existing p2Key" option is also offered, for people + who already have one. + + Both flows need a Player2 "Game Client ID" (`PLAYER2_CLIENT_ID`), + registered once by the proxy operator via the Player2 Developer + Dashboard at https://player2.game. It's stored in .env like any other + setting and reused for all subsequent logins. + """ + from .providers.utilities.player2_auth import ( + DEFAULT_CLIENT_ID, + Player2AuthError, + get_p2_key, + ) + + env_file = _get_env_file() + + # The Game Client ID ships with the code (see player2_auth.DEFAULT_CLIENT_ID + # for details on why it's currently provisional) - end users never create + # or enter their own. An env var override is supported for forks/self-hosters + # who register their own Player2 game for separate usage attribution. + client_id = ( + get_key(str(env_file), "PLAYER2_CLIENT_ID") if env_file.is_file() else None + ) or DEFAULT_CLIENT_ID + + console.print() + console.print("[bold]How would you like to sign in?[/bold]") + console.print(" 1. Auto (detect the Player2 app, or open a browser to approve)") + console.print(" 2. Paste an existing p2Key manually") + choice = Prompt.ask("Choice", choices=["1", "2"], default="1") + + p2_key: Optional[str] = None + + if choice == "2": + p2_key = Prompt.ask("[bold]Enter your p2Key[/bold]").strip() or None + else: + def on_verification(uri: str, user_code: str) -> None: + console.print() + console.print( + Panel( + Text.from_markup( + f"Open this URL to approve access:\n[bold cyan]{uri}[/bold cyan]\n\n" + f"Code: [bold]{user_code}[/bold]\n\n" + "[dim]Waiting for approval...[/dim]" + ), + style="blue", + title="Player2 Login", + expand=False, + ) + ) + try: + import webbrowser + + webbrowser.open(uri) + except Exception: + pass + + console.print("\n[dim]Checking for a locally running Player2 app...[/dim]") + try: + p2_key = await get_p2_key(client_id, on_verification=on_verification) + except Player2AuthError as e: + console.print(Panel(str(e), style="bold red", title="Login Failed")) + return + except Exception as e: + console.print( + Panel(f"Unexpected error during Player2 login: {e}", style="bold red", title="Error") + ) + return + + if not p2_key: + console.print("[dim]No key obtained, cancelling.[/dim]") + return + + # Reuse the same numbered-key convention as every other provider + # (PLAYER2_API_KEY_1, PLAYER2_API_KEY_2, ...). + key_index = 1 + while True: + key_name = f"{api_key_var}_{key_index}" + if env_file.is_file(): + with open(env_file, "r") as f: + if not any(line.startswith(f"{key_name}=") for line in f): + break + else: + break + key_index += 1 + + key_name = f"{api_key_var}_{key_index}" + set_key(str(env_file), key_name, p2_key) + + masked = f"{p2_key[:4]}...{p2_key[-4:]}" if len(p2_key) > 8 else "****" + console.print( + Panel( + Text.from_markup(f"Saved [yellow]{key_name}[/yellow] = {masked}"), + style="bold green", + title="Success", + expand=False, + ) + ) + async def setup_api_key(): """ @@ -1429,6 +1542,15 @@ async def setup_api_key(): saved_vars = [] + # Player2 gets an assisted login wizard instead of a plain paste + # prompt: a p2Key is normally obtained from the local desktop app or + # an approval step, not copy-pasted from a dashboard. + if provider_key == "player2": + await _setup_player2_credential(api_key_var) + console.print("\n[dim]Press Enter to continue...[/dim]") + input() + return + # Prompt for API key (if provider has one) if api_key_var: api_key = Prompt.ask( diff --git a/src/rotator_library/providers/player2_provider.py b/src/rotator_library/providers/player2_provider.py new file mode 100644 index 000000000..7aa636233 --- /dev/null +++ b/src/rotator_library/providers/player2_provider.py @@ -0,0 +1,224 @@ +# SPDX-License-Identifier: LGPL-3.0-only +# Copyright (c) 2026 Mirrowel + +""" +First-party provider for Player2 (https://player2.game). + +Player2 exposes an OpenAI-compatible `/chat/completions` endpoint intended +for AI-driven game NPCs. Authentication uses a single bearer token ("p2Key") +obtained either from the Player2 desktop app (if running locally) or via the +OAuth 2.0 Device Authorization Flow - see `utilities/player2_auth.py` and +`credential_tool.py`'s Player2 login wizard for how that key is acquired. + +Notable differences from a typical OpenAI-compatible provider: + +- The `/chat/completions` request schema does NOT include a `model` field + (confirmed against https://player2.game/api/api.yaml). The model actually + used is controlled by the user's Player2 account/app configuration, not by + the caller. Because of this, `model` is deliberately stripped from the + outgoing payload rather than passed through - this proxy still requires a + `player2/` model string for routing purposes, but the suffix is + cosmetic. +- There is no `/models` discovery endpoint, so `get_models()` returns a + single static placeholder model name. +- Error responses include a `402 Insufficient credits` case in addition to + the usual `401`/`429`, which this provider surfaces as a rate-limit style + error so it's retried/rotated the same way quota exhaustion is elsewhere. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, AsyncGenerator, Dict, List, Optional, Union + +import httpx +import litellm +from litellm.exceptions import RateLimitError + +from ..timeout_config import TimeoutConfig +from ..transaction_logger import ProviderLogger +from .provider_interface import ProviderInterface + +lib_logger = logging.getLogger("rotator_library") +lib_logger.propagate = False +if not lib_logger.handlers: + lib_logger.addHandler(logging.NullHandler()) + + +DEFAULT_API_BASE = "https://api.player2.game/v1" + +# Player2 doesn't expose model selection or discovery via the API - the +# model is whatever the user's Player2 account/app is configured to use. +PLACEHOLDER_MODEL = "default" + +# Fields Player2's /chat/completions schema actually documents. +# Deliberately excludes "model" - see module docstring. +SUPPORTED_PARAMS = { + "messages", + "max_tokens", + "stream", + "temperature", + "tool_choice", + "tools", + "response_format", +} + + +class Player2Provider(ProviderInterface): + """First-party Player2 provider using its native OpenAI-compatible API.""" + + skip_cost_calculation = True + default_rotation_mode: str = "sequential" + + def __init__(self): + pass + + def has_custom_logic(self) -> bool: + return True + + async def get_models(self, api_key: str, client: httpx.AsyncClient) -> List[str]: + """ + Player2 has no model discovery endpoint and doesn't accept a `model` + field in completion requests - the model is decided by the user's + Player2 account/app, not by the caller. A single placeholder is + returned so the proxy has something to route on. + """ + return [f"player2/{PLACEHOLDER_MODEL}"] + + async def get_auth_header(self, credential_identifier: str) -> Dict[str, str]: + """Player2 keys (p2Key) are used as a plain bearer token.""" + return {"Authorization": f"Bearer {credential_identifier}"} + + def _api_base(self, override: Optional[str] = None) -> str: + return (override or DEFAULT_API_BASE).rstrip("/") + + def _chat_url(self, api_base: Optional[str] = None) -> str: + return f"{self._api_base(api_base)}/chat/completions" + + def _build_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]: + payload = {k: v for k, v in kwargs.items() if k in SUPPORTED_PARAMS} + + # Fold max_completion_tokens into max_tokens if that's what was sent. + if "max_completion_tokens" in kwargs and "max_tokens" not in payload: + payload["max_tokens"] = kwargs["max_completion_tokens"] + + extra_body = kwargs.get("extra_body") + if isinstance(extra_body, dict): + payload.update({k: v for k, v in extra_body.items() if k in SUPPORTED_PARAMS}) + + return payload + + async def acompletion( + self, client: httpx.AsyncClient, **kwargs + ) -> Union[ + litellm.ModelResponse, + AsyncGenerator[litellm.ModelResponseStream, None], + ]: + api_key = kwargs.pop("credential_identifier") + transaction_context = kwargs.pop("transaction_context", None) + file_logger = ProviderLogger(transaction_context) + + api_base = kwargs.pop("api_base", None) + model = kwargs.get("model", f"player2/{PLACEHOLDER_MODEL}") + + payload = self._build_payload(kwargs) + url = self._chat_url(api_base) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Accept": "text/event-stream" if payload.get("stream") else "application/json", + } + + file_logger.log_request(payload) + + if payload.get("stream"): + return self._stream_completion( + client=client, + url=url, + headers=headers, + payload=payload, + model=model, + file_logger=file_logger, + ) + + response = await client.post( + url, + headers=headers, + json=payload, + timeout=TimeoutConfig.non_streaming(), + ) + await self._raise_for_status(response, model) + response_data = response.json() + response_data["model"] = model + file_logger.log_final_response(response_data) + return litellm.ModelResponse(**response_data) + + async def _stream_completion( + self, + client: httpx.AsyncClient, + url: str, + headers: Dict[str, str], + payload: Dict[str, Any], + model: str, + file_logger: ProviderLogger, + ) -> AsyncGenerator[litellm.ModelResponseStream, None]: + async with client.stream( + "POST", + url, + headers=headers, + json=payload, + timeout=TimeoutConfig.streaming(), + ) as response: + await self._raise_for_status(response, model) + + async for line in response.aiter_lines(): + file_logger.log_response_chunk(line) + if not line.startswith("data:"): + continue + + data_str = line[5:].strip() + if data_str == "[DONE]": + break + + try: + chunk = json.loads(data_str) + except json.JSONDecodeError: + lib_logger.warning(f"Could not decode JSON from Player2: {line}") + continue + + chunk["model"] = model + yield litellm.ModelResponseStream(**chunk) + + async def _raise_for_status(self, response: httpx.Response, model: str) -> None: + if response.status_code < 400: + return + + content = await response.aread() + error_text = content.decode("utf-8", errors="replace") if content else "" + + if response.status_code == 429: + raise RateLimitError( + f"Player2 rate limit exceeded: {error_text}", + llm_provider="player2", + model=model, + response=response, + ) + + if response.status_code == 402: + # Insufficient credits - treat like a quota/rate-limit condition + # so the credential is rotated instead of the request just + # failing outright. + raise RateLimitError( + f"Player2 insufficient credits: {error_text}", + llm_provider="player2", + model=model, + response=response, + ) + + raise httpx.HTTPStatusError( + f"Player2 HTTP {response.status_code}: {error_text}", + request=response.request, + response=response, + ) \ No newline at end of file diff --git a/src/rotator_library/providers/utilities/player2_auth.py b/src/rotator_library/providers/utilities/player2_auth.py new file mode 100644 index 000000000..e120d2366 --- /dev/null +++ b/src/rotator_library/providers/utilities/player2_auth.py @@ -0,0 +1,224 @@ +# SPDX-License-Identifier: LGPL-3.0-only +# Copyright (c) 2026 Mirrowel + +""" +Player2 authentication helpers. + +Player2 (https://player2.game) issues a single bearer token ("p2Key") that is +used to call its OpenAI-compatible `/chat/completions` endpoint. Unlike most +OAuth providers in this project, there is no separate access/refresh token +pair to manage: once a p2Key is obtained it is used exactly like a normal API +key (see PLAYER2_API_KEY / PLAYER2_API_KEY_N in the .env file). + +This module only handles *obtaining* that key interactively, via two paths: + +1. Local app detection (fast path): if the user already has the Player2 + desktop app open and logged in, we can get a key instantly by hitting the + app's local HTTP server. +2. Device Authorization Flow (fallback): works anywhere (headless servers, + Docker, CI), no local app or browser redirect required. The user is given + a short code / URL to open on any device to approve access, while we poll + for the resulting key. + +Both paths require a Player2 "Game Client ID", obtained from the Player2 +Developer Dashboard (https://player2.game) by registering an app/game. This +project treats that ID as a normal configuration value: `PLAYER2_CLIENT_ID`. + +Reference: https://player2.game/api/api.yaml +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Callable, Optional + +import httpx + +lib_logger = logging.getLogger("rotator_library") + +# Base URL for the Player2 desktop app's local HTTP server. +PLAYER2_LOCAL_APP_BASE = "http://127.0.0.1:4315/v1" + +# Base URL for the hosted Player2 Web API. +PLAYER2_API_BASE = "https://api.player2.game/v1" + +# Game Client ID used to authenticate against Player2's Web API. +# +# ⚠️ PROVISIONAL: this ID was created under a personal/contributor Player2 +# account for development purposes, purely so this PR could be tested +# end-to-end. Before merging, the maintainer of LLM-API-Key-Proxy should +# register their own "game" in the Player2 Developer Dashboard +# (https://player2.game) and replace this value with that Client ID, so the +# project (not a contributor's personal account) owns and controls it going +# forward. Forks/self-hosters can also override it via the PLAYER2_CLIENT_ID +# environment variable without touching this file. +DEFAULT_CLIENT_ID = "019f426e-760b-7ba6-9009-fe85034c9057" + +DEFAULT_LOCAL_APP_TIMEOUT = 2.0 +DEFAULT_POLL_TIMEOUT = 300.0 # 5 minutes, generous upper bound +MIN_POLL_INTERVAL = 2.0 + + +class Player2AuthError(Exception): + """Raised when a Player2 login flow cannot complete.""" + + +async def detect_local_app( + client_id: str, timeout: float = DEFAULT_LOCAL_APP_TIMEOUT +) -> Optional[str]: + """ + Attempts to obtain a p2Key from a Player2 desktop app running on the same + machine. This is the fast path: no browser, no waiting, no extra clicks, + as long as the user already has the app open and is logged in. + + Args: + client_id: The Game Client ID from the Player2 Developer Dashboard. + timeout: How long to wait for the local app to respond before giving + up and falling back to the device flow. Kept short since a + missing app should fail fast (connection refused), not hang. + + Returns: + The p2Key string if the app is running and logged in, otherwise None. + This function never raises for "app not running" - that is treated + as a normal, expected outcome, not an error. + """ + url = f"{PLAYER2_LOCAL_APP_BASE}/login/web/{client_id}" + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post(url) + if response.status_code == 200: + data = response.json() + p2_key = data.get("p2Key") + if p2_key: + lib_logger.info("Obtained Player2 key via local desktop app.") + return p2_key + else: + lib_logger.debug( + f"Player2 local app responded with {response.status_code}, " + "falling back to device flow." + ) + except (httpx.ConnectError, httpx.TimeoutException, httpx.RequestError) as e: + lib_logger.debug(f"Player2 desktop app not detected locally: {e}") + except Exception as e: + lib_logger.debug(f"Unexpected error probing Player2 local app: {e}") + return None + + +async def device_code_login( + client_id: str, + on_verification: Callable[[str, str], None], + poll_timeout: float = DEFAULT_POLL_TIMEOUT, +) -> str: + """ + Runs the OAuth 2.0 Device Authorization flow against the Player2 API. + + Args: + client_id: The Game Client ID from the Player2 Developer Dashboard. + on_verification: Callback invoked once as + `on_verification(verification_uri, user_code)`, so the caller can + display the URL/code and optionally open a browser. Called + exactly once, before polling begins. + poll_timeout: Hard upper bound (seconds) on how long to poll, in + addition to whatever `expiresIn` the API returns. + + Returns: + The p2Key string once the user approves access. + + Raises: + Player2AuthError: if the flow times out or the API returns an + unexpected/terminal error. + + Note: + Player2's documented responses for `/login/device/token` only list + 200 (success) and 500 (server error) - the exact status used to + signal "still waiting for user approval" isn't spelled out in the + public API reference. This implementation follows the standard + OAuth 2.0 Device Authorization Grant convention (RFC 8628), treating + non-200/500 client errors as "still pending" and retrying until + `expiresIn`/`poll_timeout` elapses. If Player2's real behavior + differs, this loop may need adjusting - test against a live + `client_id` before relying on it in production. + """ + async with httpx.AsyncClient(timeout=15.0, base_url=PLAYER2_API_BASE) as client: + new_resp = await client.post("/login/device/new", json={"client_id": client_id}) + new_resp.raise_for_status() + data = new_resp.json() + + device_code = data["deviceCode"] + user_code = data["userCode"] + verification_uri = data.get("verificationUriComplete") or data["verificationUri"] + interval = max(float(data.get("interval", 5)), MIN_POLL_INTERVAL) + expires_in = float(data.get("expiresIn", poll_timeout)) + + on_verification(verification_uri, user_code) + + deadline = time.monotonic() + min(expires_in, poll_timeout) + + while time.monotonic() < deadline: + await asyncio.sleep(interval) + + token_resp = await client.post( + "/login/device/token", + json={ + "client_id": client_id, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + ) + + if token_resp.status_code == 200: + p2_key = token_resp.json().get("p2Key") + if p2_key: + lib_logger.info("Obtained Player2 key via device authorization flow.") + return p2_key + # 200 without a key would be unexpected; keep polling once more + # rather than failing outright. + continue + + if token_resp.status_code == 500: + raise Player2AuthError( + f"Player2 device login failed with a server error: {token_resp.text}" + ) + + # Anything else (400/403/404/428, etc.) is treated as "authorization + # pending" per RFC 8628 convention. See the docstring note above. + lib_logger.debug( + f"Player2 device login still pending (status {token_resp.status_code})." + ) + + raise Player2AuthError( + "Timed out waiting for Player2 login approval. Please try again." + ) + + +async def get_p2_key( + client_id: str, + on_verification: Callable[[str, str], None], + local_app_timeout: float = DEFAULT_LOCAL_APP_TIMEOUT, + poll_timeout: float = DEFAULT_POLL_TIMEOUT, +) -> str: + """ + Convenience wrapper: tries the local desktop app first, then falls back + to the device authorization flow. + + Args: + client_id: The Game Client ID from the Player2 Developer Dashboard. + on_verification: Called only if the fallback device flow is used. + local_app_timeout: Timeout for the local app probe. + poll_timeout: Timeout for the device flow poll loop. + + Returns: + The p2Key string. + + Raises: + Player2AuthError: if both paths fail. + """ + p2_key = await detect_local_app(client_id, timeout=local_app_timeout) + if p2_key: + return p2_key + + return await device_code_login( + client_id, on_verification=on_verification, poll_timeout=poll_timeout + ) \ No newline at end of file From 3710c65cdc48878e762d46a05899bed68cceb036 Mon Sep 17 00:00:00 2001 From: Carlos Nahuelcoy Date: Wed, 8 Jul 2026 16:35:10 -0400 Subject: [PATCH 2/4] Document Player2 assisted login in README and .env.example --- .env.example | 6 ++++++ README.md | 1 + 2 files changed, 7 insertions(+) diff --git a/.env.example b/.env.example index 51ff42ea5..f156ee757 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,12 @@ # --- Chutes --- #CHUTES_API_KEY_1="YOUR_CHUTES_API_KEY" +# --- Player2 (https://player2.game) --- +# Don't paste this by hand: run the credential tool (option "Add API Key" -> Player2) +# and use the assisted login instead. It detects a locally running Player2 app, +# or opens a browser to approve access, and saves the resulting key here for you. +#PLAYER2_API_KEY_1="obtained via the assisted login wizard, not pasted manually" + # ------------------------------------------------------------------------------ # | [OAUTH] Provider OAuth 2.0 Credentials | # ------------------------------------------------------------------------------ diff --git a/README.md b/README.md index 72ecfa0c7..84b959252 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,7 @@ python -m rotator_library.credential_tool | Type | Providers | How to Add | |------|-----------|------------| | **API Keys** | Gemini, OpenAI, Anthropic, OpenRouter, Groq, Mistral, NVIDIA, Cohere, Chutes | Enter key in TUI or add to `.env` | +| **Assisted Login** | Player2 | Interactive wizard in the credential tool: auto-detects a local Player2 app, or opens a browser to approve access | | **OAuth** | Gemini CLI | Interactive browser login via credential tool | ### The `.env` File From 4f729be9482d5785514dd3c61250fd7404be94ca Mon Sep 17 00:00:00 2001 From: Carlos Nahuelcoy Date: Fri, 10 Jul 2026 15:52:20 -0400 Subject: [PATCH 3/4] Address review feedback: fix device-flow error handling, honor PLAYER2_API_BASE/env client ID, hide manual key input, add 402 quota cooldown --- .env.example | 4 ++ src/rotator_library/credential_tool.py | 8 ++- .../providers/player2_provider.py | 52 ++++++++++++-- .../providers/utilities/player2_auth.py | 68 +++++++++++++------ 4 files changed, 105 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index f156ee757..9b74d5568 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,10 @@ # and use the assisted login instead. It detects a locally running Player2 app, # or opens a browser to approve access, and saves the resulting key here for you. #PLAYER2_API_KEY_1="obtained via the assisted login wizard, not pasted manually" +# Advanced: override the Game Client ID used for Player2 login (self-hosters +# running their own registered Player2 game). Most people should leave this +# unset - the assisted login wizard uses a project-provided default. +#PLAYER2_CLIENT_ID="your-player2-game-client-id" # ------------------------------------------------------------------------------ # | [OAUTH] Provider OAuth 2.0 Credentials | diff --git a/src/rotator_library/credential_tool.py b/src/rotator_library/credential_tool.py index 75ec68442..195df864e 100644 --- a/src/rotator_library/credential_tool.py +++ b/src/rotator_library/credential_tool.py @@ -1186,8 +1186,10 @@ async def _setup_player2_credential(api_key_var: str): # or enter their own. An env var override is supported for forks/self-hosters # who register their own Player2 game for separate usage attribution. client_id = ( - get_key(str(env_file), "PLAYER2_CLIENT_ID") if env_file.is_file() else None - ) or DEFAULT_CLIENT_ID + os.environ.get("PLAYER2_CLIENT_ID") + or (get_key(str(env_file), "PLAYER2_CLIENT_ID") if env_file.is_file() else None) + or DEFAULT_CLIENT_ID + ) console.print() console.print("[bold]How would you like to sign in?[/bold]") @@ -1198,7 +1200,7 @@ async def _setup_player2_credential(api_key_var: str): p2_key: Optional[str] = None if choice == "2": - p2_key = Prompt.ask("[bold]Enter your p2Key[/bold]").strip() or None + p2_key = Prompt.ask("[bold]Enter your p2Key[/bold]", password=True).strip() or None else: def on_verification(uri: str, user_code: str) -> None: console.print() diff --git a/src/rotator_library/providers/player2_provider.py b/src/rotator_library/providers/player2_provider.py index 7aa636233..eef99129c 100644 --- a/src/rotator_library/providers/player2_provider.py +++ b/src/rotator_library/providers/player2_provider.py @@ -30,6 +30,7 @@ import json import logging +import os from typing import Any, AsyncGenerator, Dict, List, Optional, Union import httpx @@ -64,6 +65,10 @@ "response_format", } +# Player2 doesn't document a reset timestamp for "insufficient credits" (402) +# responses, so a fixed, conservative cooldown is used instead of retrying +# an out-of-credits key like a transient rate limit would be. +INSUFFICIENT_CREDITS_COOLDOWN_SECONDS = 86400 # 24 hours class Player2Provider(ProviderInterface): """First-party Player2 provider using its native OpenAI-compatible API.""" @@ -91,7 +96,7 @@ async def get_auth_header(self, credential_identifier: str) -> Dict[str, str]: return {"Authorization": f"Bearer {credential_identifier}"} def _api_base(self, override: Optional[str] = None) -> str: - return (override or DEFAULT_API_BASE).rstrip("/") + return (override or os.getenv("PLAYER2_API_BASE") or DEFAULT_API_BASE).rstrip("/") def _chat_url(self, api_base: Optional[str] = None) -> str: return f"{self._api_base(api_base)}/chat/completions" @@ -103,6 +108,11 @@ def _build_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]: if "max_completion_tokens" in kwargs and "max_tokens" not in payload: payload["max_tokens"] = kwargs["max_completion_tokens"] + # Unlike some sibling providers, extra_body is filtered (not merged + # wholesale) because Player2's schema is strict about which fields + # it accepts - passing through an unrecognized field risks a 4xx + # from Player2 itself. Any new Player2 param needs to be added to + # SUPPORTED_PARAMS above to be forwarded via extra_body. extra_body = kwargs.get("extra_body") if isinstance(extra_body, dict): payload.update({k: v for k, v in extra_body.items() if k in SUPPORTED_PARAMS}) @@ -191,6 +201,31 @@ async def _stream_completion( chunk["model"] = model yield litellm.ModelResponseStream(**chunk) + @staticmethod + def parse_quota_error( + error: Exception, error_body: Optional[str] = None + ) -> Optional[Dict[str, Any]]: + """ + Distinguishes a genuine "insufficient credits" (402) condition from + an ordinary rate limit, so it gets a long cooldown instead of being + retried again within seconds. See the comment in _raise_for_status + for why a custom attribute (rather than the exception's status_code) + is what we check here. + + Player2 doesn't document a reset timestamp for insufficient-credits + responses, so a conservative fixed 24h cooldown is used instead of + retrying an out-of-credits key like a transient rate limit. + """ + if getattr(error, "player2_error_reason", None) != "insufficient_credits": + return None + + return { + "retry_after": INSUFFICIENT_CREDITS_COOLDOWN_SECONDS, + "reason": "INSUFFICIENT_CREDITS", + "reset_timestamp": None, + "quota_reset_timestamp": None, + } + async def _raise_for_status(self, response: httpx.Response, model: str) -> None: if response.status_code < 400: return @@ -207,15 +242,22 @@ async def _raise_for_status(self, response: httpx.Response, model: str) -> None: ) if response.status_code == 402: - # Insufficient credits - treat like a quota/rate-limit condition - # so the credential is rotated instead of the request just - # failing outright. - raise RateLimitError( + # Insufficient credits - this is a persistent condition (it won't + # clear after a short rate-limit-style cooldown, unlike a real + # 429), so it's surfaced as a RateLimitError too (for credential + # rotation) but tagged with a custom attribute. litellm's + # RateLimitError normalizes response.status_code to 429 + # internally, so this tag is the only reliable way for + # parse_quota_error() above to tell "out of credits" apart from + # an actual rate limit and apply a much longer cooldown. + exc = RateLimitError( f"Player2 insufficient credits: {error_text}", llm_provider="player2", model=model, response=response, ) + exc.player2_error_reason = "insufficient_credits" + raise exc raise httpx.HTTPStatusError( f"Player2 HTTP {response.status_code}: {error_text}", diff --git a/src/rotator_library/providers/utilities/player2_auth.py b/src/rotator_library/providers/utilities/player2_auth.py index e120d2366..fd7f7209b 100644 --- a/src/rotator_library/providers/utilities/player2_auth.py +++ b/src/rotator_library/providers/utilities/player2_auth.py @@ -30,6 +30,7 @@ from __future__ import annotations import asyncio +import json import logging import time from typing import Callable, Optional @@ -127,20 +128,24 @@ async def device_code_login( The p2Key string once the user approves access. Raises: - Player2AuthError: if the flow times out or the API returns an - unexpected/terminal error. + Player2AuthError: if the flow times out, is denied, or the API + returns an unexpected/terminal error. Note: Player2's documented responses for `/login/device/token` only list - 200 (success) and 500 (server error) - the exact status used to - signal "still waiting for user approval" isn't spelled out in the - public API reference. This implementation follows the standard - OAuth 2.0 Device Authorization Grant convention (RFC 8628), treating - non-200/500 client errors as "still pending" and retrying until - `expiresIn`/`poll_timeout` elapses. If Player2's real behavior - differs, this loop may need adjusting - test against a live - `client_id` before relying on it in production. + 200 (success) and 500 (server error) - the exact error format used + while waiting for approval isn't spelled out in the public API + reference. This implementation assumes the standard RFC 8628 error + body (`{"error": "authorization_pending"}`, `"slow_down"`, + `"expired_token"`, `"access_denied"`, etc.) on non-200/500 responses: + only "authorization_pending" and "slow_down" are treated as + "keep waiting"; anything else fails immediately with Player2's own + error message instead of silently polling until timeout. """ + # RFC 8628 §3.5 - errors that mean "keep polling", not "give up". + PENDING_ERRORS = {"authorization_pending"} + SLOW_DOWN_ERRORS = {"slow_down"} + async with httpx.AsyncClient(timeout=15.0, base_url=PLAYER2_API_BASE) as client: new_resp = await client.post("/login/device/new", json={"client_id": client_id}) new_resp.raise_for_status() @@ -148,7 +153,11 @@ async def device_code_login( device_code = data["deviceCode"] user_code = data["userCode"] - verification_uri = data.get("verificationUriComplete") or data["verificationUri"] + verification_uri = data.get("verificationUriComplete") or data.get("verificationUri") + if not verification_uri: + raise Player2AuthError( + "Player2 did not return a verification URI for the device login." + ) interval = max(float(data.get("interval", 5)), MIN_POLL_INTERVAL) expires_in = float(data.get("expiresIn", poll_timeout)) @@ -173,26 +182,47 @@ async def device_code_login( if p2_key: lib_logger.info("Obtained Player2 key via device authorization flow.") return p2_key - # 200 without a key would be unexpected; keep polling once more - # rather than failing outright. - continue + # A 200 with no key is not a valid "success" response - fail + # rather than silently keep polling on something unexpected. + raise Player2AuthError( + "Player2 returned a successful response with no p2Key." + ) if token_resp.status_code == 500: raise Player2AuthError( f"Player2 device login failed with a server error: {token_resp.text}" ) - # Anything else (400/403/404/428, etc.) is treated as "authorization - # pending" per RFC 8628 convention. See the docstring note above. - lib_logger.debug( - f"Player2 device login still pending (status {token_resp.status_code})." + # Try to read a standard RFC 8628 error body to tell "still + # waiting" apart from a real, terminal failure. + try: + error_code = token_resp.json().get("error") + except (json.JSONDecodeError, ValueError): + error_code = None + + if error_code in PENDING_ERRORS: + lib_logger.debug("Player2 device login still pending.") + continue + + if error_code in SLOW_DOWN_ERRORS: + interval += 5.0 + lib_logger.debug( + f"Player2 asked us to slow down; polling interval is now {interval}s." + ) + continue + + # Anything else (access_denied, expired_token, invalid client_id, + # or an unrecognized/undocumented error) is a real, terminal + # failure - fail fast instead of polling until timeout. + raise Player2AuthError( + f"Player2 device login failed (status {token_resp.status_code}): " + f"{token_resp.text}" ) raise Player2AuthError( "Timed out waiting for Player2 login approval. Please try again." ) - async def get_p2_key( client_id: str, on_verification: Callable[[str, str], None], From b5ade454d1003a5e04d84228cbefce5eb1375eca Mon Sep 17 00:00:00 2001 From: Carlos Nahuelcoy Date: Mon, 13 Jul 2026 11:39:10 -0400 Subject: [PATCH 4/4] Wrap device flow init request in Player2AuthError instead of raising raw httpx/JSON/KeyError --- .../providers/utilities/player2_auth.py | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/rotator_library/providers/utilities/player2_auth.py b/src/rotator_library/providers/utilities/player2_auth.py index fd7f7209b..df6ec16a7 100644 --- a/src/rotator_library/providers/utilities/player2_auth.py +++ b/src/rotator_library/providers/utilities/player2_auth.py @@ -147,12 +147,26 @@ async def device_code_login( SLOW_DOWN_ERRORS = {"slow_down"} async with httpx.AsyncClient(timeout=15.0, base_url=PLAYER2_API_BASE) as client: - new_resp = await client.post("/login/device/new", json={"client_id": client_id}) - new_resp.raise_for_status() - data = new_resp.json() + try: + new_resp = await client.post("/login/device/new", json={"client_id": client_id}) + new_resp.raise_for_status() + data = new_resp.json() + device_code = data["deviceCode"] + user_code = data["userCode"] + except httpx.HTTPStatusError as e: + raise Player2AuthError( + f"Player2 rejected the device login request " + f"(status {e.response.status_code}): {e.response.text}" + ) from e + except httpx.RequestError as e: + raise Player2AuthError( + f"Could not reach Player2 to start the device login: {e}" + ) from e + except (ValueError, KeyError) as e: + raise Player2AuthError( + f"Player2 returned an unexpected response starting the device login: {e}" + ) from e - device_code = data["deviceCode"] - user_code = data["userCode"] verification_uri = data.get("verificationUriComplete") or data.get("verificationUri") if not verification_uri: raise Player2AuthError(