from __future__ import annotations import asyncio import json from typing import Any import httpx from fastapi import HTTPException, status from aiprovider.config import settings from aiprovider.schemas import ( AIContentBlock, AIProviderStatusResponse, SituationalAnalysisRequest, SituationalAnalysisResponse, ) def _normalize_provider(value: str) -> str: return (value or "disabled").strip().lower() def _normalize_provider_api(value: str) -> str: return (value or "auto").strip().lower().replace("_", "-") def _resolve_provider_api(provider: str, configured_api: str) -> str: if configured_api and configured_api != "auto": return configured_api if provider in {"openai", "openai-compatible", "openai_compatible"}: return "openai-completions" if provider in { "anthropic", "anthropic-compatible", "anthropic_compatible", "claude-compatible", "claude_compatible", "minimax", "kimi-coding", "moonshot-anthropic", }: return "anthropic-messages" if provider == "ollama": return "ollama-generate" return "disabled" class ProviderService: def __init__(self, overrides: dict[str, Any] | None = None) -> None: overrides = overrides or {} self.provider = _normalize_provider(overrides.get("provider") or settings.AI_PROVIDER) self.provider_api = _resolve_provider_api( self.provider, _normalize_provider_api(overrides.get("provider_api") or settings.AI_PROVIDER_API), ) self.base_url = str(overrides.get("base_url") or settings.AI_BASE_URL).rstrip("/") self.api_key = str(overrides.get("api_key") or settings.AI_API_KEY) self.default_model = str(overrides.get("model") or settings.AI_MODEL) self.timeout = settings.AI_TIMEOUT_SECONDS self.http_retry_attempts = max(settings.AI_HTTP_RETRY_ATTEMPTS, 1) self.max_tokens = int(overrides.get("max_tokens") or settings.AI_MAX_TOKENS) self.anthropic_version = str( overrides.get("anthropic_version") or settings.AI_ANTHROPIC_VERSION ) self.model_provider_apis = self._parse_model_provider_apis( overrides.get("model_provider_apis") ) def get_status(self) -> AIProviderStatusResponse: enabled = self.provider != "disabled" has_credentials = bool(self.api_key) if self._requires_api_key() else True configured = enabled and bool(self.base_url and has_credentials and self.default_model) return AIProviderStatusResponse( provider=self.provider, api=self.provider_api if enabled else None, enabled=enabled, configured=configured, model=self.default_model or None, base_url=self.base_url if enabled else None, ) async def analyze(self, payload: SituationalAnalysisRequest) -> SituationalAnalysisResponse: if self.provider == "disabled": raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="AI provider is disabled. Configure AI_PROVIDER in .env to enable analysis.", ) model = payload.preferred_model or self.default_model has_credentials = bool(self.api_key) if self._requires_api_key() else True if not self.base_url or not has_credentials or not model: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="AI provider is not fully configured. Check AI_BASE_URL, AI_API_KEY, and AI_MODEL.", ) prompt = self._build_prompt(payload) provider_api = self._resolve_model_provider_api(model) if provider_api == "openai-completions": data = await self._request_openai_compatible(model, prompt, payload.system_prompt) content = self._extract_openai_content(data) content_blocks = self._extract_openai_blocks(data) elif provider_api == "anthropic-messages": data = await self._request_anthropic_messages( model, prompt, payload.thinking, payload.system_prompt, ) content = self._extract_anthropic_content(data) content_blocks = self._extract_anthropic_blocks(data) elif provider_api == "ollama-generate": data = await self._request_ollama(model, prompt, payload.system_prompt) content = self._extract_ollama_content(data) content_blocks = self._extract_ollama_blocks(data) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unsupported AI provider API: {self.provider_api}", ) text_blocks = [block.text for block in content_blocks if block.text] thinking_blocks = [block.thinking for block in content_blocks if block.thinking] return SituationalAnalysisResponse( provider=self.provider, model=model, content=content, content_blocks=content_blocks, text_blocks=text_blocks, thinking_blocks=thinking_blocks, raw_response=data, ) def _requires_api_key(self) -> bool: return self.provider_api != "ollama-generate" def _resolve_model_provider_api(self, model: str) -> str: return self.model_provider_apis.get(model) or self.provider_api def _parse_model_provider_apis(self, value: Any) -> dict[str, str]: if isinstance(value, dict): raw = value elif isinstance(value, str) and value.strip(): try: parsed = json.loads(value) except json.JSONDecodeError: return {} raw = parsed if isinstance(parsed, dict) else {} else: raw = {} return { str(model): _normalize_provider_api(str(provider_api)) for model, provider_api in raw.items() if model and provider_api } def _build_prompt(self, payload: SituationalAnalysisRequest) -> str: sections = [ f"任务标题:\n{payload.title}", f"分析目标:\n{payload.objective}", ] if payload.observations: sections.append("观测事实:\n" + "\n".join(f"- {item}" for item in payload.observations)) if payload.constraints: sections.append("约束条件:\n" + "\n".join(f"- {item}" for item in payload.constraints)) if payload.context: sections.append(f"附加上下文:\n{payload.context}") return "\n\n".join(sections) def _resolve_system_prompt(self, system_prompt: str | None) -> str | None: resolved = str(system_prompt or "").strip() return resolved or None async def _request_openai_compatible( self, model: str, prompt: str, system_prompt: str | None = None, ) -> dict[str, Any]: messages = [] resolved_system_prompt = self._resolve_system_prompt(system_prompt) if resolved_system_prompt: messages.append({"role": "system", "content": resolved_system_prompt}) messages.append({"role": "user", "content": prompt}) request_body = { "model": model, "messages": messages, "temperature": 0.2, "max_tokens": self.max_tokens, } return await self._post( path="/chat/completions", headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", }, request_body=request_body, ) async def _request_anthropic_messages( self, model: str, prompt: str, thinking: dict[str, Any] | None = None, system_prompt: str | None = None, ) -> dict[str, Any]: request_body = { "model": model, "messages": [ { "role": "user", "content": [ { "type": "text", "text": prompt, } ], } ], "max_tokens": self.max_tokens, "temperature": 0.2, } resolved_system_prompt = self._resolve_system_prompt(system_prompt) if resolved_system_prompt: request_body["system"] = resolved_system_prompt resolved_thinking = self._resolve_anthropic_thinking(thinking) if resolved_thinking: request_body["thinking"] = resolved_thinking if self.provider == "minimax" and self.base_url.endswith("/anthropic"): path = "/v1/messages" else: path = "/messages" return await self._post( path=path, headers={ "x-api-key": self.api_key, "anthropic-version": self.anthropic_version, "Content-Type": "application/json", }, request_body=request_body, ) def _resolve_anthropic_thinking(self, thinking: dict[str, Any] | None) -> dict[str, Any] | None: if thinking: return thinking # OpenClaw treats MiniMax's Anthropic-compatible path specially: # disable thinking by default unless the caller explicitly opts in. if self.provider == "minimax": return {"type": "disabled"} return None async def _request_anthropic_compatible( self, model: str, prompt: str, thinking: dict[str, Any] | None = None, system_prompt: str | None = None, ) -> dict[str, Any]: return await self._request_anthropic_messages(model, prompt, thinking, system_prompt) async def _request_ollama( self, model: str, prompt: str, system_prompt: str | None = None, ) -> dict[str, Any]: request_body = { "model": model, "stream": False, "prompt": prompt, "options": { "temperature": 0.2, "num_predict": self.max_tokens, }, } resolved_system_prompt = self._resolve_system_prompt(system_prompt) if resolved_system_prompt: request_body["system"] = resolved_system_prompt return await self._post( path="/api/generate", headers={ "Content-Type": "application/json", }, request_body=request_body, ) async def _post( self, path: str, headers: dict[str, str], request_body: dict[str, Any], ) -> dict[str, Any]: last_error: Exception | None = None for attempt in range(1, self.http_retry_attempts + 1): try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.post( f"{self.base_url}{path}", headers=headers, json=request_body, ) response.raise_for_status() return response.json() except httpx.HTTPStatusError as exc: last_error = exc if attempt < self.http_retry_attempts and exc.response.status_code >= 500: await asyncio.sleep(0.3 * attempt) continue detail = exc.response.text or "AI provider returned an error" raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"AI provider request failed: {detail}", ) from exc except httpx.HTTPError as exc: last_error = exc if attempt < self.http_retry_attempts: await asyncio.sleep(0.3 * attempt) continue raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Failed to reach AI provider: {exc}", ) from exc raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"AI provider request failed: {last_error}", ) def _extract_openai_content(self, payload: dict[str, Any]) -> str: choices = payload.get("choices") or [] if not choices: return "" message = choices[0].get("message") or {} content = message.get("content") if isinstance(content, str): if content: return content reasoning_content = message.get("reasoning_content") return reasoning_content if isinstance(reasoning_content, str) else "" if isinstance(content, list): return "".join( item.get("text", "") for item in content if isinstance(item, dict) ) reasoning_content = message.get("reasoning_content") if isinstance(reasoning_content, str): return reasoning_content return "" def _extract_openai_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]: choices = payload.get("choices") or [] if not choices: return [] message = choices[0].get("message") or {} content = message.get("content") if isinstance(content, str): blocks = [AIContentBlock(type="text", text=content)] if content else [] reasoning_content = message.get("reasoning_content") if isinstance(reasoning_content, str) and reasoning_content: blocks.append(AIContentBlock(type="thinking", thinking=reasoning_content)) return blocks if not isinstance(content, list): reasoning_content = message.get("reasoning_content") return [AIContentBlock(type="thinking", thinking=reasoning_content)] if isinstance(reasoning_content, str) and reasoning_content else [] blocks: list[AIContentBlock] = [] for item in content: if not isinstance(item, dict): continue blocks.append( AIContentBlock( type=str(item.get("type", "text")), text=item.get("text") if isinstance(item.get("text"), str) else None, metadata={k: v for k, v in item.items() if k not in {"type", "text"}}, ) ) reasoning_content = message.get("reasoning_content") if isinstance(reasoning_content, str) and reasoning_content: blocks.append(AIContentBlock(type="thinking", thinking=reasoning_content)) return blocks def _extract_anthropic_content(self, payload: dict[str, Any]) -> str: content = payload.get("content") if isinstance(content, str): return content if not isinstance(content, list): return "" fragments: list[str] = [] for item in content: if not isinstance(item, dict): continue if item.get("type") == "text" and isinstance(item.get("text"), str): fragments.append(item["text"]) return "".join(fragments) def _extract_anthropic_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]: content = payload.get("content") if not isinstance(content, list): return [] blocks: list[AIContentBlock] = [] for item in content: if not isinstance(item, dict): continue blocks.append( AIContentBlock( type=str(item.get("type", "unknown")), text=item.get("text") if isinstance(item.get("text"), str) else None, thinking=item.get("thinking") if isinstance(item.get("thinking"), str) else None, signature=item.get("signature") if isinstance(item.get("signature"), str) else None, metadata={ k: v for k, v in item.items() if k not in {"type", "text", "thinking", "signature"} }, ) ) return blocks def _extract_ollama_content(self, payload: dict[str, Any]) -> str: response = payload.get("response") if isinstance(response, str): return response return "" def _extract_ollama_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]: response = payload.get("response") if isinstance(response, str) and response: return [AIContentBlock(type="text", text=response)] return []