Source code for factor_agent.llm.openai_compatible

"""The OpenAI-compatible backend: chat completions at any ``base_url`` (OpenAI, DeepSeek, DashScope,
Gemini's compatible endpoint, a local vLLM or Ollama), the key from the environment variable the config names."""

import openai

from factor_agent.llm._common import key_from_environment
from factor_agent.llm.base import ProviderQuotaExhausted, ProviderReplyMalformed, Reply, Request


[docs] class OpenAICompatibleBackend: """Complete a request with one chat-completions call. Parameters ---------- model : str The model of every call. base_url : str The provider's endpoint, such as ``https://api.deepseek.com/v1``. api_key_env : str, optional The environment variable holding the key; None for a local server without one. max_tokens : int, optional The ``max_tokens`` of every call; the provider's default if None. max_completion_tokens : int, optional The ``max_completion_tokens`` of every call, in place of ``max_tokens``: OpenAI's reasoning models take only this one, and count the reasoning tokens in it. reasoning_effort : str, optional The ``reasoning_effort`` of every call (such as ``"low"``, ``"medium"``, ``"high"``), for a reasoning model; the provider's default if None. timeout_seconds : float Seconds a call may take. Raises ------ ValueError If both ``max_tokens`` and ``max_completion_tokens`` are given. """ def __init__( self, model: str, base_url: str, api_key_env: str | None = None, max_tokens: int | None = None, max_completion_tokens: int | None = None, reasoning_effort: str | None = None, timeout_seconds: float = 600.0, ): if max_tokens is not None and max_completion_tokens is not None: raise ValueError("openai_compatible: give max_tokens or max_completion_tokens, not both") self.model = model self.max_tokens = max_tokens self.max_completion_tokens = max_completion_tokens self.reasoning_effort = reasoning_effort key = key_from_environment(api_key_env) if api_key_env else "none" self._client = openai.OpenAI(api_key=key, base_url=base_url, timeout=timeout_seconds) #: The options a role's config may give besides ``backend`` and ``model``; ``base_url`` is needed. OPTIONS = ("base_url", "api_key_env", "max_tokens", "max_completion_tokens", "reasoning_effort", "timeout_seconds")
[docs] @classmethod def from_config(cls, spec: dict) -> "OpenAICompatibleBackend": """Build from a role's config: ``model``, ``base_url`` and optionally ``api_key_env``, ``max_tokens`` or ``max_completion_tokens``, ``reasoning_effort`` and ``timeout_seconds``.""" options = {k: spec[k] for k in cls.OPTIONS if k in spec and k != "base_url"} return cls(spec["model"], spec["base_url"], **options)
@property def params(self) -> dict: """The parameters every call sends with the prompt.""" params = { "max_tokens": self.max_tokens, "max_completion_tokens": self.max_completion_tokens, "reasoning_effort": self.reasoning_effort, } return {k: v for k, v in params.items() if v is not None}
[docs] def complete(self, request: Request) -> Reply: """Send ``request``'s prompt as one user message. Raises ------ ProviderQuotaExhausted If the provider answers HTTP 402 (Payment Required): the balance or quota is exhausted. openai.APIError If the call fails otherwise, after the SDK's retries (which never retry a 402). ProviderReplyMalformed If the provider answers something that is not a reply: no body (``null``), no choice, no message or no token usage. """ try: response = self._client.chat.completions.create( model=self.model, messages=[{"role": "user", "content": request.prompt}], **self.params, ) except openai.APIStatusError as exc: if exc.status_code == 402: raise ProviderQuotaExhausted(f"openai_compatible: {self.model}: {exc.message}") from exc raise lacking = _lacking(response) if lacking: raise ProviderReplyMalformed(f"openai_compatible: {self.model} answered a reply without {lacking}") usage = response.usage return Reply( text=response.choices[0].message.content or "", usage={"input_tokens": usage.prompt_tokens, "output_tokens": usage.completion_tokens}, model_id=response.model, params=self.params, raw=response.to_json(indent=None), )
def _lacking(response) -> str | None: """What ``response`` lacks to be a reply (its body, a choice, the message, the usage), or None.""" if response is None: return "a body" if not getattr(response, "choices", None): return "a choice" if getattr(response.choices[0], "message", None) is None: return "a message" if getattr(response, "usage", None) is None: return "token usage" return None