Files
hermes-agent/plugins/model-providers/solstice/__init__.py
ethernet 51609d827b fix(solstice): load the transport on first use, not at discovery (#134233)
Provider discovery imports every bundled plugin's __init__, and it runs in
interpreters without the app's HTTP stack: PM's runtime calls
lazy_installs_allowed() -> hermes_cli.config, whose import-time
_inject_profile_env_vars() lists providers. solstice/__init__ imported
transport.py at the top, which pulls agent.gemini_native_adapter -> httpx,
so every install logged "Failed to load bundled provider plugin solstice:
No module named 'httpx'" and solstice went unregistered in that process.

INFERENCE_BASE_URL now lives in __init__ and SolsticeClient is imported
inside create_client, matching the other provider plugins.
2026-10-08 18:06:07 -04:00

66 lines
3.2 KiB
Python

"""Solstice provider profile: a user's own per-user-quota Gemini subscription, signed in with OAuth.
Pre-release (``hidden=True``): no picker, setup list or accounts tab offers it until the user signs in
by name (``hermes auth add solstice``); after that it lists like any signed-in provider. Login and
token refresh go through the generic PKCE plugin flow with a Nous-portal broker as the token endpoint
(``auth.py``); inference goes straight to Google on the per-user-quota methods (``transport.py``).
"""
from __future__ import annotations
from dataclasses import replace
from typing import Any
from hermes_cli.auth_oauth_pkce_plugin import OAuthPKCEConfig, pkce_auth_handler, pkce_refresh_credential
from providers import register_provider
from providers.base import ProviderProfile
from .auth import broker_token_request, discover_client
# Lives here, not in transport.py: discovery imports this module from interpreters without the app's
# HTTP stack (PM's runtime reads config, which lists providers), so the transport loads on first use.
INFERENCE_BASE_URL = "https://generativelanguage.googleapis.com/v1alpha"
# Verified on the per-user-quota endpoint, which has no model listing a user token may read (its
# /models answers 403 ACCESS_TOKEN_SCOPE_INSUFFICIENT). Quota is per model, so lite stays last as the
# reserve once the flash family is exhausted.
FALLBACK_MODELS = ("gemini-3.5-flash", "gemini-flash-latest", "gemini-flash-lite-latest")
# The broker owns client_id/scope (discovered per login); refresh needs neither, the broker adds them.
_BASE_CFG = OAuthPKCEConfig(
client_id="", authorize_url="", token_url="", label="Solstice",
redirect_path="/gemini-auth/callback", token_request=broker_token_request,
# A refresh token is issued only for offline access, and Google omits it on a repeat consent.
extra_authorize_params={"access_type": "offline", "prompt": "consent"},
)
def _auth_handler(action: str, args: Any) -> bool:
cfg = _BASE_CFG
if action == "add":
client = discover_client()
cfg = replace(_BASE_CFG, client_id=client["client_id"], authorize_url=client["authorize_url"],
scopes=tuple(client["scope"].split()))
return pkce_auth_handler(cfg)(action, args)
class SolsticeProfile(ProviderProfile):
def create_client(self, **client_kwargs: Any) -> Any:
from .transport import SolsticeClient
allowed = {"api_key", "base_url", "default_headers", "timeout", "http_client"}
return SolsticeClient(**{k: v for k, v in client_kwargs.items() if k in allowed})
def get_max_tokens(self, model: str | None) -> int | None:
return None # output caps are the backend's; a Hermes default would truncate long answers
register_provider(SolsticeProfile(
name="solstice", aliases=("solstice-oauth",), display_name="Solstice",
description="Solstice (your own per-user quota, OAuth sign-in)",
auth_type="oauth_external", base_url=INFERENCE_BASE_URL, hidden=True,
supports_health_check=False, supports_model_listing=False, supports_vision=True,
fallback_models=FALLBACK_MODELS, default_aux_model="gemini-flash-lite-latest",
auth_handler=_auth_handler, refresh_credential=pkce_refresh_credential(_BASE_CFG),
))