229 lines
10 KiB
Python
229 lines
10 KiB
Python
"""Kimi Code and Z.AI endpoint auto-detection, LM Studio base-URL normalization.
|
||
|
||
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 hashlib
|
||
from typing import Dict, Optional
|
||
from hermes_cli.auth_constants import httpx
|
||
|
||
# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth").
|
||
logger = logging.getLogger("hermes_cli.auth")
|
||
|
||
|
||
# Kimi Code (kimi.com/code) issues keys prefixed "sk-kimi-" that only work
|
||
# on api.kimi.com/coding. Legacy keys from platform.moonshot.ai work on
|
||
# api.moonshot.ai/v1 (the old default). Auto-detect when user hasn't set
|
||
# KIMI_BASE_URL explicitly.
|
||
#
|
||
# Note: the base URL intentionally has NO /v1 suffix. The /coding endpoint
|
||
# speaks the Anthropic Messages protocol, and the anthropic SDK appends
|
||
# "/v1/messages" internally — so "/coding" + SDK suffix → "/coding/v1/messages"
|
||
# (the correct target). Using "/coding/v1" here would produce
|
||
# "/coding/v1/v1/messages" (a 404).
|
||
KIMI_CODE_BASE_URL = "https://api.kimi.com/coding"
|
||
|
||
|
||
def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) -> str:
|
||
"""Return the correct Kimi base URL based on the API key prefix.
|
||
|
||
If the user has explicitly set KIMI_BASE_URL, that always wins. Otherwise, sk-kimi- prefixed
|
||
keys route to api.kimi.com/coding/v1.
|
||
"""
|
||
if env_override:
|
||
return env_override
|
||
# No key → nothing to infer from. Return default without inspecting.
|
||
if not api_key:
|
||
return default_url
|
||
if api_key.startswith("sk-kimi-"):
|
||
return KIMI_CODE_BASE_URL
|
||
return default_url
|
||
|
||
|
||
# Z.AI has separate billing for general vs coding plans, and global vs China
|
||
# endpoints. A key that works on one may return "Insufficient balance" on
|
||
# another. We probe at setup time and store the working endpoint.
|
||
# Each entry lists candidate models to try in order — newer coding plan accounts
|
||
# may only have access to recent models (glm-5.1, glm-5v-turbo) while older
|
||
# ones still use glm-4.7.
|
||
ZAI_ENDPOINTS = [
|
||
# (id, base_url, probe_models, label)
|
||
("global", "https://api.z.ai/api/paas/v4", ["glm-5"], "Global"),
|
||
("cn", "https://open.bigmodel.cn/api/paas/v4", ["glm-5"], "China"),
|
||
("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
|
||
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
|
||
]
|
||
|
||
|
||
def _probe_single_zai_endpoint(
|
||
api_key: str, endpoint: tuple, timeout: float,
|
||
) -> Optional[Dict[str, str]]:
|
||
"""Probe a single Z.AI endpoint. Returns endpoint info dict or None.
|
||
|
||
Preserves the per-endpoint candidate-model loop: endpoints carry a ``probe_models`` LIST and
|
||
each model is tried in order until one succeeds (some plans only accept newer/older GLM slugs).
|
||
"""
|
||
ep_id, base_url, probe_models, label = endpoint
|
||
for model in probe_models:
|
||
try:
|
||
resp = httpx.post(
|
||
f"{base_url}/chat/completions",
|
||
headers={
|
||
"Authorization": f"Bearer {api_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={
|
||
"model": model,
|
||
"stream": False,
|
||
"max_tokens": 1,
|
||
"messages": [{"role": "user", "content": "ping"}],
|
||
},
|
||
timeout=timeout,
|
||
)
|
||
if resp.status_code == 200:
|
||
logger.debug("Z.AI endpoint probe: %s (%s) model=%s OK", ep_id, base_url, model)
|
||
return {
|
||
"id": ep_id,
|
||
"base_url": base_url,
|
||
"model": model,
|
||
"label": label,
|
||
}
|
||
logger.debug("Z.AI endpoint probe: %s model=%s returned %s", ep_id, model, resp.status_code)
|
||
except Exception as exc:
|
||
logger.debug("Z.AI endpoint probe: %s model=%s failed: %s", ep_id, model, exc)
|
||
return None
|
||
|
||
|
||
def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str, str]]:
|
||
"""Probe z.ai endpoints in parallel to find one that accepts this API key.
|
||
|
||
Returns {"id": ..., "base_url": ..., "model": ..., "label": ...} for the first working endpoint
|
||
(in ZAI_ENDPOINTS priority order), or None if all fail. For endpoints with multiple candidate
|
||
models, each worker tries its endpoint's models in order and returns the first that succeeds.
|
||
"""
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
|
||
# No `with` block: a context manager would join ALL probe threads on
|
||
# exit, defeating the early return below. shutdown(wait=False) lets the
|
||
# surviving daemon-style probes drain in the background instead of
|
||
# blocking the caller on slow/unreachable endpoints.
|
||
pool = ThreadPoolExecutor(max_workers=len(ZAI_ENDPOINTS))
|
||
try:
|
||
futures = {
|
||
pool.submit(_probe_single_zai_endpoint, api_key, ep, timeout): ep[0]
|
||
for ep in ZAI_ENDPOINTS
|
||
}
|
||
by_id = {ep_id: f for f, ep_id in futures.items()}
|
||
results: Dict[str, Dict[str, str]] = {}
|
||
for future in as_completed(futures):
|
||
ep_id = futures[future]
|
||
try:
|
||
result = future.result()
|
||
if result is not None:
|
||
results[ep_id] = result
|
||
except Exception:
|
||
pass
|
||
# Early exit in PRIORITY order: walk endpoints highest-priority
|
||
# first; if one has succeeded and every higher-priority probe
|
||
# has already finished (without success), no later completion
|
||
# can win — return now instead of waiting out slow endpoints
|
||
# (main's sequential loop also stopped at first success).
|
||
for ep in ZAI_ENDPOINTS:
|
||
if not by_id[ep[0]].done():
|
||
break # a higher-priority probe is still in flight
|
||
if ep[0] in results:
|
||
return results[ep[0]]
|
||
|
||
# All probes finished: first match in priority order, if any.
|
||
for ep in ZAI_ENDPOINTS:
|
||
if ep[0] in results:
|
||
return results[ep[0]]
|
||
return None
|
||
finally:
|
||
pool.shutdown(wait=False)
|
||
|
||
|
||
def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> str:
|
||
"""Return the correct Z.AI base URL by probing endpoints.
|
||
|
||
If the user has explicitly set GLM_BASE_URL, that always wins. Otherwise, probe the candidate
|
||
endpoints to find one that accepts the key. The detected endpoint is cached in provider state
|
||
(auth.json) keyed on a hash of the API key so subsequent starts skip the probe.
|
||
"""
|
||
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, _store_provider_state, detect_zai_endpoint
|
||
if env_override:
|
||
return env_override
|
||
|
||
# No API key set → don't probe (would fire N×M HTTPS requests with an
|
||
# empty Bearer token, all returning 401). This path is hit during
|
||
# auxiliary-client auto-detection when the user has no Z.AI credentials
|
||
# at all — the caller discards the result immediately, so the probe is
|
||
# pure latency for every AIAgent construction.
|
||
if not api_key:
|
||
return default_url
|
||
|
||
# Check provider-state cache for a previously-detected endpoint.
|
||
auth_store = _load_auth_store()
|
||
state = _load_provider_state(auth_store, "zai") or {}
|
||
cached = state.get("detected_endpoint")
|
||
if isinstance(cached, dict) and cached.get("base_url"):
|
||
key_hash = cached.get("key_hash", "")
|
||
if key_hash == hashlib.sha256(api_key.encode()).hexdigest()[:16]:
|
||
logger.debug("Z.AI: using cached endpoint %s", cached["base_url"])
|
||
return cached["base_url"]
|
||
|
||
# Probe — may take up to ~8s per endpoint.
|
||
detected = detect_zai_endpoint(api_key)
|
||
if detected and detected.get("base_url"):
|
||
# Persist the detection result keyed on the API key hash.
|
||
key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16]
|
||
detected_endpoint = {
|
||
"base_url": detected["base_url"],
|
||
"endpoint_id": detected.get("id", ""),
|
||
"model": detected.get("model", ""),
|
||
"label": detected.get("label", ""),
|
||
"key_hash": key_hash,
|
||
}
|
||
# Persist failure (disk full, permissions, lock timeout) must not
|
||
# break resolution — detection already succeeded; worst case the
|
||
# next start re-probes.
|
||
try:
|
||
with _auth_store_lock():
|
||
# Reload auth_store under lock to avoid overwriting concurrent changes
|
||
auth_store = _load_auth_store()
|
||
state_under_lock = _load_provider_state(auth_store, "zai") or {}
|
||
state_under_lock["detected_endpoint"] = detected_endpoint
|
||
# set_active=False: this runs from credential-pool env seeding
|
||
# (agent/credential_pool.py) for ANY user with a Z.AI key in env,
|
||
# and caching a probe result must not flip their active provider.
|
||
_store_provider_state(auth_store, "zai", state_under_lock, set_active=False)
|
||
_save_auth_store(auth_store)
|
||
except Exception as exc:
|
||
logger.warning("Z.AI: could not persist detected endpoint (%s); will re-probe next start", exc)
|
||
logger.info("Z.AI: auto-detected endpoint %s (%s)", detected["label"], detected["base_url"])
|
||
return detected["base_url"]
|
||
|
||
logger.debug("Z.AI: probe failed, falling back to default %s", default_url)
|
||
return default_url
|
||
|
||
|
||
def _normalize_lmstudio_runtime_base_url(base_url: str) -> str:
|
||
"""Return the OpenAI-compatible LM Studio runtime base URL.
|
||
|
||
LM Studio's native management API lives under ``/api/v1`` while its OpenAI-compatible chat
|
||
endpoint lives under ``/v1``. Users often paste either form into ``LM_BASE_URL`` or
|
||
``model.base_url``; normalize before the OpenAI SDK appends ``/chat/completions``.
|
||
"""
|
||
root = str(base_url or "").strip().rstrip("/")
|
||
for suffix in ("/api/v1", "/api", "/v1"):
|
||
if root.endswith(suffix):
|
||
root = root[: -len(suffix)].rstrip("/")
|
||
break
|
||
return (root or "http://127.0.0.1:1234") + "/v1"
|