Files
hermes-agent/plugins/model-providers/solstice/__init__.py
Teknium 22bc7eac71 refactor(solstice): rebuild the provider as a standard model-provider plugin
The whole provider now lives in plugins/model-providers/solstice/ on the
generic seams:
- auth.py: NAS config discovery plus the brokered exchange/refresh, given
  to the generic PKCE flow as token_request. A refresh that NAS rejects
  (it folds every Google failure into 502) is treated as a dead grant
  needing re-login.
- transport.py: a GeminiNativeClient subclass on the
  generateContentPerUserQuota methods with Bearer auth (~30 lines, where
  the PR duplicated a 257-line adapter).
- __init__.py: a hidden profile (create_client, fallback models,
  default_aux_model).

Removed from core: hermes_cli/solstice.py, agent/solstice_adapter.py, and
the provider's branches in main.py, auth_commands.py, models.py,
runtime_provider.py, model_setup_flows.py and agent_runtime_helpers.py.
The unused SOLSTICE_CLIENT_ID env read is gone too.
2026-10-05 23:33:41 -07:00

61 lines
2.9 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
from .transport import INFERENCE_BASE_URL, SolsticeClient
# 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:
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),
))