"""OpenRouter transport and model configuration. No credentials in UI or logs.""" import json import os from urllib.parse import urlsplit import httpx API_BASE = "https://openrouter.ai/api/v1" MODELS = { "anthropic/claude-opus-5.5": ("Claude Opus 5.5", "4", "20"), "anthropic/claude-sonnet-5.5": ("Claude Sonnet 5.5", "2", "10"), "anthropic/claude-fable-5.1": ("Claude Fable 5.1", "10", "50"), "deepseek/deepseek-v4.1-flash": ("DeepSeek V4.1 Flash", "0.30", "1.20"), } DEFAULT_MODEL = "anthropic/claude-sonnet-5.5" class RouterError(Exception): """A safe, user-facing error without request headers or document content.""" def build_payload(messages: list, model: str, search: bool, max_tokens: int, effort: str) -> dict: if model not in MODELS: raise RouterError("请选择列表中的模型。") if effort not in {"low", "medium", "high", "xhigh", "max"}: raise RouterError("无效的推理强度。") if not 1024 <= int(max_tokens) <= 32768: raise RouterError("输出上限需在 1024–32768 tokens 之间。") payload = { "model": model, "messages": messages, "stream": True, "stream_options": {"include_usage": True}, "max_tokens": int(max_tokens), "reasoning": {"effort": effort}, "provider": {"require_parameters": True}, } if search: payload["tools"] = [{"type": "openrouter:web_search", "parameters": { "engine": "native", "max_results": 5, "max_total_results": 10, }}] if any(part.get("type") == "file" for msg in messages if isinstance(msg.get("content"), list) for part in msg["content"]): payload["plugins"] = [{"id": "file-parser", "pdf": {"engine": "native"}}] return payload def stream_reply(payload: dict): """Yield SSE events; never retry a potentially billed request automatically.""" key = os.environ.get("OPENROUTER_API_KEY", "").strip() if not key: raise RouterError("请在 Space Settings → Secrets 中设置 OPENROUTER_API_KEY。") headers = {"Authorization": f"Bearer {key}", "X-Title": "Hailey AI Agent"} try: with httpx.Client(timeout=httpx.Timeout(300, connect=20)) as client: with client.stream("POST", f"{API_BASE}/chat/completions", headers=headers, json=payload) as response: if response.status_code != 200: descriptions = { 400: "请求不被接受,请检查文件、上下文长度或模型参数。", 401: "API Key 无效或已失效。", 402: "OpenRouter 余额或额度不足。", 403: "账号或供应商拒绝访问,请检查权限与地区设置。", 404: "模型或可用供应商暂不存在。", 413: "请求文件过大,请减少附件。", 429: "请求频率或额度受限,请稍后重试。", } raise RouterError(f"OpenRouter HTTP {response.status_code}:" + descriptions.get(response.status_code, "服务暂时不可用,请稍后重试。")) ended = False for line in response.iter_lines(): if not line.startswith("data:"): continue raw = line[5:].strip() if raw == "[DONE]": ended = True break if not raw: continue event = json.loads(raw) if event.get("error"): raise RouterError("上游模型在生成过程中报错。请在 OpenRouter Activity 查看本次请求。") yield event if not ended: raise RouterError("连接提前关闭,回复可能不完整;本次请求可能已产生费用。") except httpx.TimeoutException: raise RouterError("请求超时;本次请求可能已产生费用,请检查 OpenRouter Activity 后再重试。") from None except (httpx.HTTPError, ValueError): raise RouterError("OpenRouter 连接或响应异常,请稍后重试。") from None def citations_markdown(annotations: list) -> str: urls = [] for annotation in annotations: if annotation.get("type") != "url_citation": continue item = annotation.get("url_citation", {}) url = item.get("url", "") if urlsplit(url).scheme not in {"http", "https"} or url in urls: continue urls.append(url) if not urls: return "" # Fixed labels avoid injecting untrusted titles into Markdown. return "\n\n---\n**参考来源**\n" + "\n".join( f"- [来源 {i}](<{url.replace('>', '%3E').replace('<', '%3C')}>)" for i, url in enumerate(urls, 1) ) def usage_summary(usage: dict, model: str) -> str: parts = [MODELS[model][0]] if usage: parts.extend([f"输入 {usage.get('prompt_tokens', 0):,} tokens", f"输出 {usage.get('completion_tokens', 0):,} tokens"]) if usage.get("cost") is not None: parts.append(f"本次 API 返回费用 ${float(usage['cost']):.6f}") else: parts.append("本次费用未返回,请查看 OpenRouter Activity") searches = usage.get("server_tool_use", {}).get("web_search_requests") if searches is not None: parts.append(f"联网搜索 {searches} 次") else: parts.append("用量未返回,请查看 OpenRouter Activity") return " · ".join(parts)