202 lines
7.3 KiB
Python
202 lines
7.3 KiB
Python
"""Qwen OAuth (qwen-cli token file) runtime credentials and status.
|
|
|
|
Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so
|
|
``hermes_cli.auth.<name>`` keeps resolving (and monkeypatching) as before. Origin-internal
|
|
helpers are imported lazily inside each function (no import cycle; patches on
|
|
``hermes_cli.auth.<helper>`` still intercept).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import json
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
from hermes_cli.auth_constants import (
|
|
AuthError,
|
|
DEFAULT_QWEN_BASE_URL,
|
|
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
|
QWEN_OAUTH_CLIENT_ID,
|
|
QWEN_OAUTH_TOKEN_URL,
|
|
_FORM_JSON_HEADERS,
|
|
_qwen_err,
|
|
httpx,
|
|
)
|
|
|
|
# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth").
|
|
logger = logging.getLogger("hermes_cli.auth")
|
|
|
|
|
|
def _qwen_cli_auth_path() -> Path:
|
|
return Path.home() / ".qwen" / "oauth_creds.json"
|
|
|
|
|
|
def _read_qwen_cli_tokens() -> Dict[str, Any]:
|
|
from hermes_cli.auth import _qwen_cli_auth_path
|
|
auth_path = _qwen_cli_auth_path()
|
|
if not auth_path.exists():
|
|
raise _qwen_err(
|
|
"Qwen CLI credentials not found. Run 'qwen auth qwen-oauth' first.",
|
|
"qwen_auth_missing",
|
|
)
|
|
try:
|
|
data = json.loads(auth_path.read_text(encoding="utf-8"))
|
|
except Exception as exc:
|
|
raise _qwen_err(
|
|
f"Failed to read Qwen CLI credentials from {auth_path}: {exc}",
|
|
"qwen_auth_read_failed",
|
|
) from exc
|
|
if not isinstance(data, dict):
|
|
raise _qwen_err(f"Invalid Qwen CLI credentials in {auth_path}.", "qwen_auth_invalid")
|
|
return data
|
|
|
|
|
|
def _save_qwen_cli_tokens(tokens: Dict[str, Any]) -> Path:
|
|
from hermes_cli.auth import _qwen_cli_auth_path, _write_private_file_atomic
|
|
auth_path = _qwen_cli_auth_path()
|
|
_write_private_file_atomic(auth_path, json.dumps(tokens, indent=2, sort_keys=True) + "\n")
|
|
return auth_path
|
|
|
|
|
|
def _qwen_access_token_is_expiring(expiry_date_ms: Any, skew_seconds: int = QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS) -> bool:
|
|
try:
|
|
expiry_ms = int(expiry_date_ms)
|
|
except Exception:
|
|
return True
|
|
return (time.time() + max(0, int(skew_seconds))) * 1000 >= expiry_ms
|
|
|
|
|
|
def _refresh_qwen_cli_tokens(tokens: Dict[str, Any], timeout_seconds: float = 20.0) -> Dict[str, Any]:
|
|
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
|
|
if not refresh_token:
|
|
raise _qwen_err(
|
|
"Qwen OAuth refresh token missing. Re-run 'qwen auth qwen-oauth'.",
|
|
"qwen_refresh_token_missing",
|
|
)
|
|
|
|
try:
|
|
response = httpx.post(
|
|
QWEN_OAUTH_TOKEN_URL,
|
|
headers=_FORM_JSON_HEADERS,
|
|
data={
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": refresh_token,
|
|
"client_id": QWEN_OAUTH_CLIENT_ID,
|
|
},
|
|
timeout=timeout_seconds,
|
|
)
|
|
except Exception as exc:
|
|
raise _qwen_err(f"Qwen OAuth refresh failed: {exc}", "qwen_refresh_failed") from exc
|
|
|
|
if response.status_code >= 400:
|
|
body = response.text.strip()
|
|
raise _qwen_err(
|
|
"Qwen OAuth refresh failed. Re-run 'qwen auth qwen-oauth'."
|
|
+ (f" Response: {body}" if body else ""),
|
|
"qwen_refresh_failed",
|
|
)
|
|
|
|
try:
|
|
payload = response.json()
|
|
except Exception as exc:
|
|
raise _qwen_err(
|
|
f"Qwen OAuth refresh returned invalid JSON: {exc}",
|
|
"qwen_refresh_invalid_json",
|
|
) from exc
|
|
|
|
if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip():
|
|
raise _qwen_err(
|
|
"Qwen OAuth refresh response missing access_token.",
|
|
"qwen_refresh_invalid_response",
|
|
)
|
|
|
|
expires_in = payload.get("expires_in")
|
|
try:
|
|
expires_in_seconds = int(expires_in)
|
|
except Exception:
|
|
expires_in_seconds = 6 * 60 * 60
|
|
|
|
refreshed = {
|
|
"access_token": str(payload.get("access_token", "") or "").strip(),
|
|
"refresh_token": str(payload.get("refresh_token", refresh_token) or refresh_token).strip(),
|
|
"token_type": str(payload.get("token_type", tokens.get("token_type", "Bearer")) or "Bearer").strip() or "Bearer",
|
|
"resource_url": str(payload.get("resource_url", tokens.get("resource_url", "portal.qwen.ai")) or "portal.qwen.ai").strip(),
|
|
"expiry_date": int(time.time() * 1000) + max(1, expires_in_seconds) * 1000,
|
|
}
|
|
_save_qwen_cli_tokens(refreshed)
|
|
return refreshed
|
|
|
|
|
|
def _mark_qwen_oauth_active(creds: Dict[str, Any]) -> None:
|
|
"""Set active_provider to qwen-oauth in auth.json.
|
|
|
|
Qwen tokens live in the Qwen CLI credential file, so this writes only a minimal provider-state
|
|
entry (base_url for display) and sets active_provider so ``get_active_provider()`` and the
|
|
setup wizard's credential check detect the provider.
|
|
"""
|
|
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store, _save_provider_state
|
|
with _auth_store_lock():
|
|
auth_store = _load_auth_store()
|
|
state: Dict[str, Any] = {}
|
|
if creds.get("base_url"):
|
|
state["base_url"] = str(creds["base_url"])
|
|
_save_provider_state(auth_store, "qwen-oauth", state)
|
|
_save_auth_store(auth_store)
|
|
|
|
|
|
def resolve_qwen_runtime_credentials(
|
|
*,
|
|
force_refresh: bool = False,
|
|
refresh_if_expiring: bool = True,
|
|
refresh_skew_seconds: int = QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
|
) -> Dict[str, Any]:
|
|
from hermes_cli.auth import _qwen_cli_auth_path, _refresh_qwen_cli_tokens
|
|
tokens = _read_qwen_cli_tokens()
|
|
access_token = str(tokens.get("access_token", "") or "").strip()
|
|
should_refresh = bool(force_refresh)
|
|
if not should_refresh and refresh_if_expiring:
|
|
should_refresh = _qwen_access_token_is_expiring(tokens.get("expiry_date"), refresh_skew_seconds)
|
|
if should_refresh:
|
|
tokens = _refresh_qwen_cli_tokens(tokens)
|
|
access_token = str(tokens.get("access_token", "") or "").strip()
|
|
if not access_token:
|
|
raise _qwen_err(
|
|
"Qwen OAuth access token missing. Re-run 'qwen auth qwen-oauth'.",
|
|
"qwen_access_token_missing",
|
|
)
|
|
|
|
base_url = os.getenv("HERMES_QWEN_BASE_URL", "").strip().rstrip("/") or DEFAULT_QWEN_BASE_URL
|
|
return {
|
|
"provider": "qwen-oauth",
|
|
"base_url": base_url,
|
|
"api_key": access_token,
|
|
"source": "qwen-cli",
|
|
"expires_at_ms": tokens.get("expiry_date"),
|
|
"auth_file": str(_qwen_cli_auth_path()),
|
|
}
|
|
|
|
|
|
def get_qwen_auth_status() -> Dict[str, Any]:
|
|
from hermes_cli.auth import _qwen_cli_auth_path, resolve_qwen_runtime_credentials
|
|
auth_path = _qwen_cli_auth_path()
|
|
try:
|
|
# Validate the runtime credentials, including refresh when the cached
|
|
# CLI token is expired. Otherwise stale tokens show up as "logged in"
|
|
# and `hermes model` walks users into a broken Qwen setup flow.
|
|
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=True)
|
|
return {
|
|
"logged_in": True,
|
|
"auth_file": str(auth_path),
|
|
"source": creds.get("source"),
|
|
"api_key": creds.get("api_key"),
|
|
"expires_at_ms": creds.get("expires_at_ms"),
|
|
}
|
|
except AuthError as exc:
|
|
return {
|
|
"logged_in": False,
|
|
"auth_file": str(auth_path),
|
|
"error": str(exc),
|
|
}
|