489 lines
19 KiB
Python
489 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from typing import Any
|
|
from uuid import NAMESPACE_URL, uuid4, uuid5
|
|
|
|
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")
|
|
)
|
|
self.session_id = str(uuid4())
|
|
|
|
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)
|
|
if payload.context.get("session_id") is not None:
|
|
self.session_id = str(uuid5(NAMESPACE_URL, f"planet:{payload.context['session_id']}"))
|
|
|
|
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 == "openai-responses":
|
|
data = await self._request_openai_responses(model, prompt, payload.system_prompt)
|
|
content_blocks = self._extract_responses_blocks(data)
|
|
content = "".join(block.text for block in content_blocks if block.text)
|
|
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_openai_responses(
|
|
self, model: str, prompt: str, system_prompt: str | None = None,
|
|
) -> dict[str, Any]:
|
|
request_body: dict[str, Any] = {
|
|
"model": model, "input": prompt, "max_output_tokens": self.max_tokens, "store": False,
|
|
}
|
|
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
|
if resolved_system_prompt:
|
|
request_body["instructions"] = resolved_system_prompt
|
|
return await self._post(
|
|
path="/responses",
|
|
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
|
|
request_body=request_body,
|
|
)
|
|
|
|
def _extract_responses_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]:
|
|
blocks: list[AIContentBlock] = []
|
|
for item in payload.get("output") or []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
if item.get("type") == "message":
|
|
for part in item.get("content") or []:
|
|
if not isinstance(part, dict):
|
|
continue
|
|
text = part.get("text") or part.get("refusal")
|
|
if isinstance(text, str) and text:
|
|
blocks.append(AIContentBlock(type="text", text=text))
|
|
elif item.get("type") == "reasoning":
|
|
for part in item.get("summary") or []:
|
|
if isinstance(part, dict) and isinstance(part.get("text"), str):
|
|
blocks.append(AIContentBlock(type="thinking", thinking=part["text"]))
|
|
return blocks
|
|
|
|
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, model)
|
|
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, model: str,
|
|
) -> dict[str, Any] | None:
|
|
if thinking:
|
|
if model.casefold() == "minimax-m3" and thinking.get("type") == "enabled":
|
|
return {"type": "adaptive"}
|
|
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]:
|
|
headers = {"User-Agent": "Planet/1.0", **headers}
|
|
if self.provider == "opencode-go":
|
|
headers["x-opencode-session"] = self.session_id
|
|
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 []
|