mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 02:05:22 -04:00
fix(kimi): resolve Kimi Code API 403 errors and User-Agent restrictions (#3549)
* fix(kimi): resolve Kimi Code API 403 errors and User-Agent restrictions Kimi Code subscription keys require a whitelisted coding-agent User-Agent to avoid access_terminated_error 403s. This adds User-Agent probing and caching for Kimi Code endpoints. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(kimi): omit temperature for kimi-for-coding API calls Kimi Code rejects any non-default temperature with HTTP 400, which broke deep research probes and low-temp LLM rounds. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+12
-1
@@ -248,6 +248,9 @@ _PROVIDER_CURATED = {
|
|||||||
"zai-coding": [
|
"zai-coding": [
|
||||||
"glm-5.1", "glm-5v-turbo", "glm-5-turbo", "glm-4.7", "glm-4.5-air",
|
"glm-5.1", "glm-5v-turbo", "glm-5-turbo", "glm-4.7", "glm-4.5-air",
|
||||||
],
|
],
|
||||||
|
"kimi-code": [
|
||||||
|
"kimi-for-coding",
|
||||||
|
],
|
||||||
"deepseek": [
|
"deepseek": [
|
||||||
"deepseek-chat", "deepseek-reasoner",
|
"deepseek-chat", "deepseek-reasoner",
|
||||||
],
|
],
|
||||||
@@ -315,6 +318,8 @@ def _match_provider_curated(base_url: str, provider: str) -> str:
|
|||||||
parsed = urlparse(base_url)
|
parsed = urlparse(base_url)
|
||||||
if _host_match(base_url, "z.ai") and "/api/coding" in (parsed.path or ""):
|
if _host_match(base_url, "z.ai") and "/api/coding" in (parsed.path or ""):
|
||||||
return "zai-coding"
|
return "zai-coding"
|
||||||
|
if _host_match(base_url, "kimi.com") and "/coding" in (parsed.path or ""):
|
||||||
|
return "kimi-code"
|
||||||
for domain, key in _HOST_TO_CURATED:
|
for domain, key in _HOST_TO_CURATED:
|
||||||
if _host_match(base_url, domain):
|
if _host_match(base_url, domain):
|
||||||
return key
|
return key
|
||||||
@@ -703,6 +708,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
"""Probe a base URL's /models endpoint and return list of model IDs.
|
"""Probe a base URL's /models endpoint and return list of model IDs.
|
||||||
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
||||||
from src.endpoint_resolver import resolve_url
|
from src.endpoint_resolver import resolve_url
|
||||||
|
from src.llm_core import httpx_get_kimi_aware
|
||||||
base = resolve_url(_normalize_base(base_url))
|
base = resolve_url(_normalize_base(base_url))
|
||||||
provider = _safe_detect_provider(base)
|
provider = _safe_detect_provider(base)
|
||||||
if provider == "chatgpt-subscription":
|
if provider == "chatgpt-subscription":
|
||||||
@@ -738,7 +744,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
url = _safe_build_models_url(base)
|
url = _safe_build_models_url(base)
|
||||||
headers = _safe_build_headers(api_key, base)
|
headers = _safe_build_headers(api_key, base)
|
||||||
try:
|
try:
|
||||||
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
r = httpx_get_kimi_aware(url, headers, timeout=timeout, verify=llm_verify())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
# OpenAI format: {"data": [{"id": "model-name"}]}
|
# OpenAI format: {"data": [{"id": "model-name"}]}
|
||||||
@@ -754,6 +760,11 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
for _e in _PROVIDER_CURATED.get(_ck, []):
|
for _e in _PROVIDER_CURATED.get(_ck, []):
|
||||||
if _e not in set(models) and not any(m.startswith(_e) for m in models):
|
if _e not in set(models) and not any(m.startswith(_e) for m in models):
|
||||||
models.append(_e)
|
models.append(_e)
|
||||||
|
if _host_match(base, "kimi.com") and "/coding" in (urlparse(base).path or ""):
|
||||||
|
_ck = _match_provider_curated(base, None)
|
||||||
|
for _e in _PROVIDER_CURATED.get(_ck, []):
|
||||||
|
if _e not in set(models) and not any(m.startswith(_e) for m in models):
|
||||||
|
models.append(_e)
|
||||||
return [m for m in models if _is_chat_model(m)]
|
return [m for m in models if _is_chat_model(m)]
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
if api_key:
|
if api_key:
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ def setup_webhook_routes(
|
|||||||
"opencode-go": "https://opencode.ai/zen/go/v1",
|
"opencode-go": "https://opencode.ai/zen/go/v1",
|
||||||
"fireworks": "https://api.fireworks.ai/inference/v1",
|
"fireworks": "https://api.fireworks.ai/inference/v1",
|
||||||
"venice": "https://api.venice.ai/api/v1",
|
"venice": "https://api.venice.ai/api/v1",
|
||||||
|
"kimi-code": "https://api.kimi.com/coding/v1",
|
||||||
|
"kimicode": "https://api.kimi.com/coding/v1",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Model prefix → provider mapping for auto-detection
|
# Model prefix → provider mapping for auto-detection
|
||||||
@@ -210,6 +212,8 @@ def setup_webhook_routes(
|
|||||||
"mistral": "mistral",
|
"mistral": "mistral",
|
||||||
"llama": "groq",
|
"llama": "groq",
|
||||||
"mixtral": "groq",
|
"mixtral": "groq",
|
||||||
|
"kimi-for-coding": "kimi-code",
|
||||||
|
"kimi": "kimi-code",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
|
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
|
||||||
|
|||||||
+1
-1
@@ -606,7 +606,7 @@ _API_HOSTS = frozenset([
|
|||||||
"api.deepseek.com", "deepseek.com",
|
"api.deepseek.com", "deepseek.com",
|
||||||
"api.together.xyz", "api.fireworks.ai",
|
"api.together.xyz", "api.fireworks.ai",
|
||||||
"api.perplexity.ai", "api.x.ai",
|
"api.perplexity.ai", "api.x.ai",
|
||||||
"ollama.com", "api.venice.ai",
|
"ollama.com", "api.venice.ai", "api.kimi.com",
|
||||||
"api.githubcopilot.com",
|
"api.githubcopilot.com",
|
||||||
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
|
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
|
||||||
# Without these, `_is_api_model` falls back to keyword sniffing on the
|
# Without these, `_is_api_model` falls back to keyword sniffing on the
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from typing import Optional, Tuple, Dict
|
|||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
from src.llm_core import _detect_provider, _host_match, _ollama_api_root
|
from src.llm_core import _detect_provider, _host_match, _is_kimi_code_url, KIMI_CODE_USER_AGENT, _ollama_api_root
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -230,6 +230,8 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
|||||||
if provider == "openrouter":
|
if provider == "openrouter":
|
||||||
headers.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
|
headers.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
|
||||||
headers.setdefault("X-OpenRouter-Title", "Odysseus")
|
headers.setdefault("X-OpenRouter-Title", "Odysseus")
|
||||||
|
if _is_kimi_code_url(base):
|
||||||
|
headers.setdefault("User-Agent", KIMI_CODE_USER_AGENT)
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+151
-4
@@ -442,6 +442,146 @@ def _host_match(url: str, *domains: str) -> bool:
|
|||||||
return any(host == d or host.endswith("." + d) for d in domains)
|
return any(host == d or host.endswith("." + d) for d in domains)
|
||||||
|
|
||||||
|
|
||||||
|
# Kimi Code subscription keys (api.kimi.com/coding/v1) require a whitelisted
|
||||||
|
# coding-agent User-Agent; otherwise the API returns 403 access_terminated_error.
|
||||||
|
# Tried in order; first success is cached per base URL for later requests.
|
||||||
|
KIMI_CODE_USER_AGENTS: tuple[str, ...] = (
|
||||||
|
"claude-code/0.1.0",
|
||||||
|
"claude-code/1.0.0",
|
||||||
|
"KimiCLI/1.0",
|
||||||
|
"Kilo-Code/1.0",
|
||||||
|
"Roo-Code/1.0",
|
||||||
|
"Cursor/1.0",
|
||||||
|
)
|
||||||
|
KIMI_CODE_USER_AGENT = KIMI_CODE_USER_AGENTS[0]
|
||||||
|
_kimi_code_ua_cache: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_kimi_code_url(url: str) -> bool:
|
||||||
|
if not url or not _host_match(url, "kimi.com"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return "/coding" in (urlparse(url).path or "")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _kimi_code_base_key(url: str) -> str:
|
||||||
|
"""Normalize a Kimi Code chat/models URL to its OpenAI base (.../coding/v1)."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
path = (parsed.path or "").rstrip("/")
|
||||||
|
for suffix in ("/chat/completions", "/models", "/completions"):
|
||||||
|
if path.endswith(suffix):
|
||||||
|
path = path[: -len(suffix)]
|
||||||
|
path = path.rstrip("/") or "/coding/v1"
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_kimi_code_access_denied(status: int, body: bytes | str) -> bool:
|
||||||
|
if status != 403:
|
||||||
|
return False
|
||||||
|
text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else (body or "")
|
||||||
|
lower = text.lower()
|
||||||
|
return (
|
||||||
|
"access_terminated_error" in lower
|
||||||
|
or "coding agents" in lower
|
||||||
|
or "only available for coding" in lower
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _kimi_code_ua_candidates(url: str) -> list[str]:
|
||||||
|
if not _is_kimi_code_url(url):
|
||||||
|
return []
|
||||||
|
base_key = _kimi_code_base_key(url)
|
||||||
|
cached = _kimi_code_ua_cache.get(base_key)
|
||||||
|
if cached:
|
||||||
|
return [cached] + [ua for ua in KIMI_CODE_USER_AGENTS if ua != cached]
|
||||||
|
return list(KIMI_CODE_USER_AGENTS)
|
||||||
|
|
||||||
|
|
||||||
|
def _remember_kimi_code_user_agent(url: str, user_agent: str) -> None:
|
||||||
|
_kimi_code_ua_cache[_kimi_code_base_key(url)] = user_agent
|
||||||
|
|
||||||
|
|
||||||
|
def apply_kimi_code_headers(headers: Optional[Dict], url: str) -> Dict[str, str]:
|
||||||
|
"""Pick a Kimi Code User-Agent (cached probe when possible)."""
|
||||||
|
h = dict(headers or {})
|
||||||
|
if not _is_kimi_code_url(url):
|
||||||
|
return h
|
||||||
|
base_key = _kimi_code_base_key(url)
|
||||||
|
cached = _kimi_code_ua_cache.get(base_key)
|
||||||
|
if cached:
|
||||||
|
h["User-Agent"] = cached
|
||||||
|
return h
|
||||||
|
models_url = base_key.rstrip("/") + "/models"
|
||||||
|
from src.tls_overrides import llm_verify
|
||||||
|
for ua in KIMI_CODE_USER_AGENTS:
|
||||||
|
trial = dict(h)
|
||||||
|
trial["User-Agent"] = ua
|
||||||
|
try:
|
||||||
|
r = httpx.get(models_url, headers=trial, timeout=8, verify=llm_verify())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if _is_kimi_code_access_denied(r.status_code, r.content):
|
||||||
|
logger.debug("Kimi Code rejected User-Agent %s (403), trying next", ua)
|
||||||
|
continue
|
||||||
|
if r.status_code < 400:
|
||||||
|
_remember_kimi_code_user_agent(url, ua)
|
||||||
|
h["User-Agent"] = ua
|
||||||
|
return h
|
||||||
|
break
|
||||||
|
h.setdefault("User-Agent", KIMI_CODE_USER_AGENT)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def httpx_get_kimi_aware(url: str, headers: Optional[Dict], **kwargs):
|
||||||
|
h = apply_kimi_code_headers(headers, url)
|
||||||
|
if not _is_kimi_code_url(url):
|
||||||
|
return httpx.get(url, headers=h, **kwargs)
|
||||||
|
last = None
|
||||||
|
for ua in _kimi_code_ua_candidates(url):
|
||||||
|
trial = dict(h)
|
||||||
|
trial["User-Agent"] = ua
|
||||||
|
last = httpx.get(url, headers=trial, **kwargs)
|
||||||
|
if not _is_kimi_code_access_denied(last.status_code, last.content):
|
||||||
|
if last.status_code < 400:
|
||||||
|
_remember_kimi_code_user_agent(url, ua)
|
||||||
|
return last
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
def httpx_post_kimi_aware(url: str, headers: Optional[Dict], **kwargs):
|
||||||
|
h = apply_kimi_code_headers(headers, url)
|
||||||
|
if not _is_kimi_code_url(url):
|
||||||
|
return httpx.post(url, headers=h, **kwargs)
|
||||||
|
last = None
|
||||||
|
for ua in _kimi_code_ua_candidates(url):
|
||||||
|
trial = dict(h)
|
||||||
|
trial["User-Agent"] = ua
|
||||||
|
last = httpx.post(url, headers=trial, **kwargs)
|
||||||
|
if not _is_kimi_code_access_denied(last.status_code, last.content):
|
||||||
|
if last.status_code < 400:
|
||||||
|
_remember_kimi_code_user_agent(url, ua)
|
||||||
|
return last
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
async def httpx_post_kimi_aware_async(client, url: str, headers: Optional[Dict], **kwargs):
|
||||||
|
h = apply_kimi_code_headers(headers, url)
|
||||||
|
if not _is_kimi_code_url(url):
|
||||||
|
return await client.post(url, headers=h, **kwargs)
|
||||||
|
last = None
|
||||||
|
for ua in _kimi_code_ua_candidates(url):
|
||||||
|
trial = dict(h)
|
||||||
|
trial["User-Agent"] = ua
|
||||||
|
last = await client.post(url, headers=trial, **kwargs)
|
||||||
|
if not _is_kimi_code_access_denied(last.status_code, last.content):
|
||||||
|
if last.status_code < 400:
|
||||||
|
_remember_kimi_code_user_agent(url, ua)
|
||||||
|
return last
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
def _detect_provider(url: str) -> str:
|
def _detect_provider(url: str) -> str:
|
||||||
"""Detect the API provider from a configured endpoint URL.
|
"""Detect the API provider from a configured endpoint URL.
|
||||||
|
|
||||||
@@ -561,6 +701,12 @@ def _provider_label(url: str) -> str:
|
|||||||
if _host_match(url, "googleapis.com"): return "Google"
|
if _host_match(url, "googleapis.com"): return "Google"
|
||||||
if _host_match(url, "together.xyz", "together.ai"): return "Together"
|
if _host_match(url, "together.xyz", "together.ai"): return "Together"
|
||||||
if _host_match(url, "fireworks.ai"): return "Fireworks"
|
if _host_match(url, "fireworks.ai"): return "Fireworks"
|
||||||
|
if _host_match(url, "kimi.com"):
|
||||||
|
try:
|
||||||
|
if "/coding" in (urlparse(url).path or ""):
|
||||||
|
return "Kimi Code"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if _is_ollama_native_url(url): return "Ollama"
|
if _is_ollama_native_url(url): return "Ollama"
|
||||||
try:
|
try:
|
||||||
host = (urlparse(url).hostname or "").lower()
|
host = (urlparse(url).hostname or "").lower()
|
||||||
@@ -701,7 +847,7 @@ def _uses_max_completion_tokens(model: str) -> bool:
|
|||||||
# perfectly good model as failing. For these models we omit the field and let
|
# perfectly good model as failing. For these models we omit the field and let
|
||||||
# the API use its required default. (gpt-4.5 is intentionally excluded — it is
|
# the API use its required default. (gpt-4.5 is intentionally excluded — it is
|
||||||
# not a reasoning model and accepts temperature normally.)
|
# not a reasoning model and accepts temperature normally.)
|
||||||
_FIXED_TEMPERATURE_MODELS = ("o1", "o3", "o4", "gpt-5")
|
_FIXED_TEMPERATURE_MODELS = ("o1", "o3", "o4", "gpt-5", "kimi-for-coding")
|
||||||
|
|
||||||
def _restricts_temperature(model: str) -> bool:
|
def _restricts_temperature(model: str) -> bool:
|
||||||
"""Check if a model rejects any non-default temperature."""
|
"""Check if a model rejects any non-default temperature."""
|
||||||
@@ -1157,7 +1303,7 @@ def list_model_ids(
|
|||||||
from src.endpoint_resolver import build_models_url
|
from src.endpoint_resolver import build_models_url
|
||||||
|
|
||||||
models_url = build_models_url(base_chat_url)
|
models_url = build_models_url(base_chat_url)
|
||||||
r = httpx.get(models_url, headers=h, timeout=timeout)
|
r = httpx_get_kimi_aware(models_url, h, timeout=timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
@@ -1265,7 +1411,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
|||||||
payload[tok_key] = max_tokens
|
payload[tok_key] = max_tokens
|
||||||
try:
|
try:
|
||||||
note_model_activity(target_url, model)
|
note_model_activity(target_url, model)
|
||||||
r = httpx.post(target_url, headers=h, json=payload, timeout=timeout)
|
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(502, f"POST {target_url} failed: {e}")
|
raise HTTPException(502, f"POST {target_url} failed: {e}")
|
||||||
if not r.is_success:
|
if not r.is_success:
|
||||||
@@ -1473,7 +1619,7 @@ async def llm_call_async(
|
|||||||
try:
|
try:
|
||||||
note_model_activity(target_url, model)
|
note_model_activity(target_url, model)
|
||||||
client = _get_http_client()
|
client = _get_http_client()
|
||||||
r = await client.post(target_url, headers=h, json=payload, timeout=call_timeout)
|
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
|
||||||
duration = time.time() - start
|
duration = time.time() - start
|
||||||
if not r.is_success:
|
if not r.is_success:
|
||||||
friendly = _format_upstream_error(r.status_code, r.text, target_url)
|
friendly = _format_upstream_error(r.status_code, r.text, target_url)
|
||||||
@@ -1870,6 +2016,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
events.append(_stream_delta_event(part))
|
events.append(_stream_delta_event(part))
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
h = apply_kimi_code_headers(h, target_url)
|
||||||
try:
|
try:
|
||||||
client = _get_http_client()
|
client = _get_http_client()
|
||||||
async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r:
|
async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r:
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ _SOTA_HOSTS = frozenset({
|
|||||||
"api.together.xyz", "api.fireworks.ai",
|
"api.together.xyz", "api.fireworks.ai",
|
||||||
"api.perplexity.ai", "api.x.ai",
|
"api.perplexity.ai", "api.x.ai",
|
||||||
"generativelanguage.googleapis.com", "api.groq.com",
|
"generativelanguage.googleapis.com", "api.groq.com",
|
||||||
"openrouter.ai", "ollama.com", "api.venice.ai",
|
"openrouter.ai", "ollama.com", "api.venice.ai", "api.kimi.com",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Kimi Code host-allowlist behavior (follow-up to provider support).
|
||||||
|
|
||||||
|
Kimi Code (https://api.kimi.com/coding/v1) is a subscription, OpenAI-compatible
|
||||||
|
cloud API with native tool-calling. These tests pin the three host-list integrations:
|
||||||
|
- agent loop sends native tool schemas to Kimi Code (not fenced-block parsing),
|
||||||
|
- teacher escalation treats Kimi Code as SOTA (loop OFF, no added latency).
|
||||||
|
"""
|
||||||
|
from src import agent_loop, teacher_escalation
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentToolHosts:
|
||||||
|
def test_kimi_code_in_api_hosts(self):
|
||||||
|
assert "api.kimi.com" in agent_loop._API_HOSTS
|
||||||
|
|
||||||
|
def test_kimi_code_url_matches_api_host(self):
|
||||||
|
url = "https://api.kimi.com/coding/v1/chat/completions"
|
||||||
|
assert any(h in url for h in agent_loop._API_HOSTS)
|
||||||
|
|
||||||
|
def test_unknown_host_not_matched(self):
|
||||||
|
url = "https://example.invalid/v1/chat/completions"
|
||||||
|
assert not any(h in url for h in agent_loop._API_HOSTS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTeacherEscalationSota:
|
||||||
|
def test_kimi_code_is_sota_not_self_hosted(self):
|
||||||
|
assert teacher_escalation.is_self_hosted("https://api.kimi.com/coding/v1/chat/completions") is False
|
||||||
|
|
||||||
|
def test_known_cloud_still_sota(self):
|
||||||
|
assert teacher_escalation.is_self_hosted("https://api.openai.com/v1") is False
|
||||||
|
|
||||||
|
def test_local_endpoint_still_self_hosted(self):
|
||||||
|
assert teacher_escalation.is_self_hosted("http://localhost:8000/v1") is True
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Kimi Code User-Agent fallback list and 403 detection."""
|
||||||
|
from src.llm_core import (
|
||||||
|
KIMI_CODE_USER_AGENTS,
|
||||||
|
KIMI_CODE_USER_AGENT,
|
||||||
|
_is_kimi_code_access_denied,
|
||||||
|
_is_kimi_code_url,
|
||||||
|
_kimi_code_base_key,
|
||||||
|
_kimi_code_ua_cache,
|
||||||
|
_kimi_code_ua_candidates,
|
||||||
|
_remember_kimi_code_user_agent,
|
||||||
|
httpx_post_kimi_aware,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestKimiCodeUserAgents:
|
||||||
|
def test_default_is_first_fallback(self):
|
||||||
|
assert KIMI_CODE_USER_AGENT == KIMI_CODE_USER_AGENTS[0]
|
||||||
|
|
||||||
|
def test_multiple_fallbacks_configured(self):
|
||||||
|
assert len(KIMI_CODE_USER_AGENTS) >= 3
|
||||||
|
assert "KimiCLI/1.0" in KIMI_CODE_USER_AGENTS
|
||||||
|
|
||||||
|
def test_detects_coding_agent_403(self):
|
||||||
|
body = '{"error":{"message":"only available for Coding Agents","type":"access_terminated_error"}}'
|
||||||
|
assert _is_kimi_code_access_denied(403, body) is True
|
||||||
|
|
||||||
|
def test_non_403_not_access_denied(self):
|
||||||
|
assert _is_kimi_code_access_denied(401, "unauthorized") is False
|
||||||
|
|
||||||
|
def test_ua_candidates_prefers_cache(self):
|
||||||
|
_kimi_code_ua_cache.clear()
|
||||||
|
url = "https://api.kimi.com/coding/v1/chat/completions"
|
||||||
|
_remember_kimi_code_user_agent(url, "Kilo-Code/1.0")
|
||||||
|
candidates = _kimi_code_ua_candidates(url)
|
||||||
|
assert candidates[0] == "Kilo-Code/1.0"
|
||||||
|
assert len(candidates) == len(KIMI_CODE_USER_AGENTS)
|
||||||
|
_kimi_code_ua_cache.clear()
|
||||||
|
|
||||||
|
def test_non_kimi_url_has_no_candidates(self):
|
||||||
|
assert _kimi_code_ua_candidates("https://api.openai.com/v1") == []
|
||||||
|
|
||||||
|
def test_base_key_normalizes_chat_url(self):
|
||||||
|
assert _kimi_code_base_key("https://api.kimi.com/coding/v1/chat/completions") == (
|
||||||
|
"https://api.kimi.com/coding/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_post_retries_next_user_agent_on_403(self, monkeypatch):
|
||||||
|
_kimi_code_ua_cache.clear()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def __init__(self, status, text=""):
|
||||||
|
self.status_code = status
|
||||||
|
self.content = text.encode()
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, **kwargs):
|
||||||
|
calls.append(headers.get("User-Agent"))
|
||||||
|
if headers.get("User-Agent") == KIMI_CODE_USER_AGENTS[0]:
|
||||||
|
return _Resp(403, '{"error":{"type":"access_terminated_error"}}')
|
||||||
|
return _Resp(200, "{}")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.llm_core.httpx.post", fake_post)
|
||||||
|
url = "https://api.kimi.com/coding/v1/chat/completions"
|
||||||
|
r = httpx_post_kimi_aware(url, {"Authorization": "Bearer x"}, json={})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert calls[0] == KIMI_CODE_USER_AGENTS[0]
|
||||||
|
assert calls[1] == KIMI_CODE_USER_AGENTS[1]
|
||||||
|
_kimi_code_ua_cache.clear()
|
||||||
@@ -14,7 +14,7 @@ from src import llm_core
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"model",
|
"model",
|
||||||
["o1", "o1-mini", "o3", "o3-mini", "o4-mini", "gpt-5", "gpt-5-mini",
|
["o1", "o1-mini", "o3", "o3-mini", "o4-mini", "gpt-5", "gpt-5-mini",
|
||||||
"openrouter/openai/o3-mini", "OpenAI/GPT-5"],
|
"openrouter/openai/o3-mini", "OpenAI/GPT-5", "kimi-for-coding"],
|
||||||
)
|
)
|
||||||
def test_reasoning_models_restrict_temperature(model):
|
def test_reasoning_models_restrict_temperature(model):
|
||||||
assert llm_core._restricts_temperature(model) is True
|
assert llm_core._restricts_temperature(model) is True
|
||||||
@@ -62,6 +62,12 @@ def test_reasoning_model_payload_omits_temperature(monkeypatch):
|
|||||||
assert payload["max_completion_tokens"] == 5
|
assert payload["max_completion_tokens"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_for_coding_payload_omits_temperature(monkeypatch):
|
||||||
|
payload = _capture_openai_payload(monkeypatch, "kimi-for-coding", 0.1)
|
||||||
|
assert "temperature" not in payload
|
||||||
|
assert payload["max_tokens"] == 5
|
||||||
|
|
||||||
|
|
||||||
def test_normal_model_payload_keeps_temperature(monkeypatch):
|
def test_normal_model_payload_keeps_temperature(monkeypatch):
|
||||||
payload = _capture_openai_payload(monkeypatch, "gpt-4o", 0.2)
|
payload = _capture_openai_payload(monkeypatch, "gpt-4o", 0.2)
|
||||||
assert payload["temperature"] == 0.2
|
assert payload["temperature"] == 0.2
|
||||||
|
|||||||
@@ -205,6 +205,9 @@ class TestMatchProviderCurated:
|
|||||||
def test_ollama_url(self):
|
def test_ollama_url(self):
|
||||||
assert _match_provider_curated("https://ollama.com/api", "openai") == "ollama"
|
assert _match_provider_curated("https://ollama.com/api", "openai") == "ollama"
|
||||||
|
|
||||||
|
def test_kimi_code_url(self):
|
||||||
|
assert _match_provider_curated("https://api.kimi.com/coding/v1", "openai") == "kimi-code"
|
||||||
|
|
||||||
def test_no_url_match_returns_provider(self):
|
def test_no_url_match_returns_provider(self):
|
||||||
assert _match_provider_curated("https://localhost:1234", "openai") == "openai"
|
assert _match_provider_curated("https://localhost:1234", "openai") == "openai"
|
||||||
|
|
||||||
@@ -312,6 +315,12 @@ class TestCurateModels:
|
|||||||
assert curated == models
|
assert curated == models
|
||||||
assert extra == []
|
assert extra == []
|
||||||
|
|
||||||
|
def test_kimi_code_partitions(self):
|
||||||
|
models = ["kimi-for-coding", "other-model"]
|
||||||
|
curated, extra = _curate_models(models, "kimi-code")
|
||||||
|
assert "kimi-for-coding" in curated
|
||||||
|
assert "other-model" in extra
|
||||||
|
|
||||||
def test_curated_sorted_by_priority(self):
|
def test_curated_sorted_by_priority(self):
|
||||||
models = ["gpt-4o-mini", "gpt-4o", "o3"]
|
models = ["gpt-4o-mini", "gpt-4o", "o3"]
|
||||||
curated, _ = _curate_models(models, "openai")
|
curated, _ = _curate_models(models, "openai")
|
||||||
|
|||||||
Reference in New Issue
Block a user