feat: align aiprovider with adapter-based compatibility

This commit is contained in:
linkong
2026-04-07 17:54:44 +08:00
parent bc90e00e25
commit f12719914d
9 changed files with 299 additions and 50 deletions

View File

@@ -8,6 +8,7 @@ from fastapi import HTTPException, status
from aiprovider.config import settings
from aiprovider.schemas import (
AIContentBlock,
AIProviderStatusResponse,
SituationalAnalysisRequest,
SituationalAnalysisResponse,
@@ -18,9 +19,39 @@ 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) -> None:
self.provider = _normalize_provider(settings.AI_PROVIDER)
self.provider_api = _resolve_provider_api(
self.provider,
_normalize_provider_api(settings.AI_PROVIDER_API),
)
self.base_url = settings.AI_BASE_URL.rstrip("/")
self.api_key = settings.AI_API_KEY
self.default_model = settings.AI_MODEL
@@ -32,9 +63,11 @@ class ProviderService:
def get_status(self) -> AIProviderStatusResponse:
enabled = self.provider != "disabled"
configured = enabled and bool(self.base_url and self.api_key and self.default_model)
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,
@@ -49,7 +82,8 @@ class ProviderService:
)
model = payload.preferred_model or self.default_model
if not self.base_url or not self.api_key or not 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.",
@@ -57,28 +91,40 @@ class ProviderService:
prompt = self._build_prompt(payload)
if self.provider in {"openai", "openai_compatible"}:
if self.provider_api == "openai-completions":
data = await self._request_openai_compatible(model, prompt)
content = self._extract_openai_content(data)
elif self.provider in {"anthropic", "anthropic_compatible", "claude_compatible"}:
data = await self._request_anthropic_compatible(model, prompt)
content_blocks = self._extract_openai_blocks(data)
elif self.provider_api == "anthropic-messages":
data = await self._request_anthropic_messages(model, prompt, payload.thinking)
content = self._extract_anthropic_content(data)
elif self.provider == "ollama":
content_blocks = self._extract_anthropic_blocks(data)
elif self.provider_api == "ollama-generate":
data = await self._request_ollama(model, 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: {self.provider}",
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 _build_prompt(self, payload: SituationalAnalysisRequest) -> str:
sections = [
f"任务标题:\n{payload.title}",
@@ -113,7 +159,12 @@ class ProviderService:
request_body=request_body,
)
async def _request_anthropic_compatible(self, model: str, prompt: str) -> dict[str, Any]:
async def _request_anthropic_messages(
self,
model: str,
prompt: str,
thinking: dict[str, Any] | None = None,
) -> dict[str, Any]:
request_body = {
"model": model,
"system": self.system_prompt,
@@ -131,8 +182,15 @@ class ProviderService:
"max_tokens": self.max_tokens,
"temperature": 0.2,
}
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="/messages",
path=path,
headers={
"x-api-key": self.api_key,
"anthropic-version": self.anthropic_version,
@@ -141,6 +199,25 @@ class ProviderService:
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,
) -> dict[str, Any]:
return await self._request_anthropic_messages(model, prompt, thinking)
async def _request_ollama(self, model: str, prompt: str) -> dict[str, Any]:
request_body = {
"model": model,
@@ -218,6 +295,31 @@ class ProviderService:
)
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):
return [AIContentBlock(type="text", text=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", "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"}},
)
)
return blocks
def _extract_anthropic_content(self, payload: dict[str, Any]) -> str:
content = payload.get("content")
if isinstance(content, str):
@@ -233,8 +335,38 @@ class ProviderService:
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 []