From f12719914db53c22b6eca53b56768289dc5eb04e Mon Sep 17 00:00:00 2001 From: linkong Date: Tue, 7 Apr 2026 17:54:44 +0800 Subject: [PATCH] feat: align aiprovider with adapter-based compatibility --- TODO.md | 3 + aiprovider/.env.example | 48 ++++++++--- aiprovider/README.md | 44 ++++++---- aiprovider/config.py | 1 + aiprovider/provider_service.py | 150 +++++++++++++++++++++++++++++++-- aiprovider/schemas.py | 13 +++ backend/app/schemas/ai.py | 13 +++ backend/tests/test_api.py | 10 ++- docs/aiprovider.md | 67 ++++++++++++--- 9 files changed, 299 insertions(+), 50 deletions(-) diff --git a/TODO.md b/TODO.md index b2aa5385..6da363a7 100644 --- a/TODO.md +++ b/TODO.md @@ -13,6 +13,9 @@ - [x] 接入 `IPtoASN / IPtoCountry` 作为 prefix-centric geography 的主数据源 - [x] 接入 `OpenGeoFeed` 作为 prefix geography 的高质量覆盖/override 数据源 - [x] 把 RIR delegated 设计成 prefix geography 的 fallback,而不是主来源 +- [ ] 为 `aiprovider` 建立 `provider -> api adapter -> compat policy` 的配置中心,优先落成 `json` 或 `yaml` 文件,运行时按 `provider/model` 读取兼容设置,而不是把专项兼容继续散落在 Python 分支里 +- [ ] 为市面上主流 AI 服务补专项兼容配置并固化到配置文件中,至少覆盖 `OpenAI / Anthropic / MiniMax / Ollama / Moonshot / DeepSeek / Qwen / GLM / Gemini / OpenRouter / vLLM / LM Studio / One API` +- [ ] 在兼容配置中补齐可声明项:`api adapter`、`base_url pattern`、`auth header`、`thinking default`、`reasoning block mapping`、`stream path`、`tool-call capability`、`multimodal capability`、`provider-specific request patch` - [ ] 接入 `inetnum` / `inet6num` whois 作为比 RIR 更细粒度的后备层 - [x] 在 activity layer 之后继续补 `route leak` 和 `path instability / flap` detector - [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation,降低后续维护复杂度 diff --git a/aiprovider/.env.example b/aiprovider/.env.example index cd5f57e1..fdfad118 100644 --- a/aiprovider/.env.example +++ b/aiprovider/.env.example @@ -6,29 +6,51 @@ AI_TIMEOUT_SECONDS=60 AI_HTTP_RETRY_ATTEMPTS=2 AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。 -# Select one provider mode: -# - openai_compatible -# - claude_compatible +# Provider identity. Recommended values: +# - minimax +# - openai # - ollama -AI_PROVIDER=ollama +# Compatibility aliases still accepted: +# - openai_compatible +# - anthropic_compatible +# - claude_compatible +AI_PROVIDER=minimax + +# Request adapter style, following OpenClaw's API-seam pattern: +# - auto +# - openai-completions +# - anthropic-messages +# - ollama-generate +AI_PROVIDER_API=anthropic-messages # Common model selection -AI_MODEL=qwen2.5:7b +AI_MODEL=MiniMax-M2.7 + +# MiniMax CN Anthropic-compatible example +AI_BASE_URL=https://api.minimaxi.com/anthropic +AI_API_KEY=sk-cp-change-me +AI_MAX_TOKENS=1200 +AI_ANTHROPIC_VERSION=2023-06-01 # OpenAI-compatible example (vLLM / LM Studio / One API / local gateway) -# AI_PROVIDER=openai_compatible +# AI_PROVIDER=openai +# AI_PROVIDER_API=openai-completions # AI_BASE_URL=http://127.0.0.1:8001/v1 # AI_API_KEY=local-key +# AI_MODEL=your-local-model -# Claude-compatible example (Anthropic / MiniMax / Claude-compatible gateway) -# AI_PROVIDER=claude_compatible -# AI_BASE_URL=http://127.0.0.1:8002 +# Anthropic-compatible example (Claude-compatible gateway) +# AI_PROVIDER=anthropic +# AI_PROVIDER_API=anthropic-messages +# AI_BASE_URL=http://127.0.0.1:8002/anthropic # AI_API_KEY=local-key +# AI_MODEL=your-model # AI_MAX_TOKENS=1200 # AI_ANTHROPIC_VERSION=2023-06-01 # Ollama native example -AI_BASE_URL=http://127.0.0.1:11434 -AI_API_KEY= -AI_MAX_TOKENS=1200 -AI_ANTHROPIC_VERSION=2023-06-01 +# AI_PROVIDER=ollama +# AI_PROVIDER_API=ollama-generate +# AI_BASE_URL=http://127.0.0.1:11434 +# AI_API_KEY= +# AI_MODEL=qwen2.5:7b diff --git a/aiprovider/README.md b/aiprovider/README.md index 2aae82be..c65a0ba9 100644 --- a/aiprovider/README.md +++ b/aiprovider/README.md @@ -8,17 +8,27 @@ 当前支持: -- `AI_PROVIDER=openai` -- `AI_PROVIDER=openai_compatible` -- `AI_PROVIDER=anthropic` -- `AI_PROVIDER=anthropic_compatible` -- `AI_PROVIDER=claude_compatible` -- `AI_PROVIDER=ollama` +- provider identity: + - `AI_PROVIDER=openai` + - `AI_PROVIDER=anthropic` + - `AI_PROVIDER=minimax` + - `AI_PROVIDER=ollama` +- request adapter: + - `AI_PROVIDER_API=openai-completions` + - `AI_PROVIDER_API=anthropic-messages` + - `AI_PROVIDER_API=ollama-generate` + +兼容别名仍然保留: + +- `openai_compatible` +- `anthropic_compatible` +- `claude_compatible` 典型配置: ```env -AI_PROVIDER=openai_compatible +AI_PROVIDER=openai +AI_PROVIDER_API=openai-completions AI_BASE_URL=https://api.openai.com/v1 AI_API_KEY=your_api_key AI_MODEL=gpt-4o-mini @@ -26,13 +36,14 @@ AI_TIMEOUT_SECONDS=60 AI_PROVIDER_SERVICE_TOKEN=change_me ``` -Claude 兼容供应商示例: +MiniMax 中国大陆节点示例: ```env -AI_PROVIDER=claude_compatible -AI_BASE_URL=https://your-claude-compatible-endpoint.example.com -AI_API_KEY=your_api_key -AI_MODEL=your-claude-compatible-model +AI_PROVIDER=minimax +AI_PROVIDER_API=anthropic-messages +AI_BASE_URL=https://api.minimaxi.com/anthropic +AI_API_KEY=sk-cp-xxxxx +AI_MODEL=MiniMax-M2.7 AI_TIMEOUT_SECONDS=60 AI_MAX_TOKENS=1200 AI_ANTHROPIC_VERSION=2023-06-01 @@ -43,12 +54,15 @@ AI_PROVIDER_SERVICE_TOKEN=change_me - Anthropic 官方 Claude API - Claude 兼容网关 -- MiniMax 等提供 Claude/Anthropic 风格消息接口的服务 +- MiniMax 等提供 Anthropic Messages 风格接口的服务 + +这套命名方式参考了 OpenClaw 的接入模式: provider 负责标识供应商, `AI_PROVIDER_API` 负责标识协议适配层, 避免把“供应商”和“协议”绑死在一起。 Ollama 原生示例: ```env AI_PROVIDER=ollama +AI_PROVIDER_API=ollama-generate AI_BASE_URL=http://127.0.0.1:11434 AI_API_KEY= AI_MODEL=qwen2.5:7b @@ -58,8 +72,8 @@ AI_PROVIDER_SERVICE_TOKEN=change_me 本地模型接入建议: -- `vLLM`、`LM Studio`、`One API`:优先使用 `openai_compatible` -- `MiniMax`、Claude 兼容网关:使用 `claude_compatible` +- `vLLM`、`LM Studio`、`One API`:`AI_PROVIDER=openai` + `AI_PROVIDER_API=openai-completions` +- `MiniMax`、Claude 兼容网关:`AI_PROVIDER=minimax|anthropic` + `AI_PROVIDER_API=anthropic-messages` - `Ollama`:可直接使用 `ollama` 启动模板: diff --git a/aiprovider/config.py b/aiprovider/config.py index 4d668b4f..b9c1e218 100644 --- a/aiprovider/config.py +++ b/aiprovider/config.py @@ -9,6 +9,7 @@ class Settings(BaseSettings): SERVICE_VERSION: str = "0.1.0" AI_PROVIDER: str = "disabled" + AI_PROVIDER_API: str = "auto" AI_BASE_URL: str = "https://api.openai.com/v1" AI_API_KEY: str = "" AI_MODEL: str = "" diff --git a/aiprovider/provider_service.py b/aiprovider/provider_service.py index 763e9ca1..0bdbc6bb 100644 --- a/aiprovider/provider_service.py +++ b/aiprovider/provider_service.py @@ -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 [] diff --git a/aiprovider/schemas.py b/aiprovider/schemas.py index 5e07fbf1..27d1046e 100644 --- a/aiprovider/schemas.py +++ b/aiprovider/schemas.py @@ -3,6 +3,14 @@ from typing import Any from pydantic import BaseModel, Field +class AIContentBlock(BaseModel): + type: str + text: str | None = None + thinking: str | None = None + signature: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + class SituationalAnalysisRequest(BaseModel): title: str = Field(..., min_length=1, max_length=200) objective: str = Field(..., min_length=1, max_length=1000) @@ -10,17 +18,22 @@ class SituationalAnalysisRequest(BaseModel): observations: list[str] = Field(default_factory=list) constraints: list[str] = Field(default_factory=list) preferred_model: str | None = Field(default=None, max_length=200) + thinking: dict[str, Any] | None = None class SituationalAnalysisResponse(BaseModel): provider: str model: str content: str + content_blocks: list[AIContentBlock] = Field(default_factory=list) + text_blocks: list[str] = Field(default_factory=list) + thinking_blocks: list[str] = Field(default_factory=list) raw_response: dict[str, Any] = Field(default_factory=dict) class AIProviderStatusResponse(BaseModel): provider: str + api: str | None = None enabled: bool configured: bool model: str | None = None diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py index 5e07fbf1..27d1046e 100644 --- a/backend/app/schemas/ai.py +++ b/backend/app/schemas/ai.py @@ -3,6 +3,14 @@ from typing import Any from pydantic import BaseModel, Field +class AIContentBlock(BaseModel): + type: str + text: str | None = None + thinking: str | None = None + signature: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + class SituationalAnalysisRequest(BaseModel): title: str = Field(..., min_length=1, max_length=200) objective: str = Field(..., min_length=1, max_length=1000) @@ -10,17 +18,22 @@ class SituationalAnalysisRequest(BaseModel): observations: list[str] = Field(default_factory=list) constraints: list[str] = Field(default_factory=list) preferred_model: str | None = Field(default=None, max_length=200) + thinking: dict[str, Any] | None = None class SituationalAnalysisResponse(BaseModel): provider: str model: str content: str + content_blocks: list[AIContentBlock] = Field(default_factory=list) + text_blocks: list[str] = Field(default_factory=list) + thinking_blocks: list[str] = Field(default_factory=list) raw_response: dict[str, Any] = Field(default_factory=dict) class AIProviderStatusResponse(BaseModel): provider: str + api: str | None = None enabled: bool configured: bool model: str | None = None diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 8c0aabde..5faa479e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -165,7 +165,8 @@ async def test_ai_provider_status_with_auth(auth_headers): class _FakeAIProviderClient: async def get_status(self, request_id=None): return AIProviderStatusResponse( - provider="openai_compatible", + provider="minimax", + api="anthropic-messages", enabled=True, configured=True, model="test-model", @@ -193,6 +194,7 @@ async def test_ai_provider_status_with_auth(auth_headers): assert response.status_code == 200 data = response.json() assert "provider" in data + assert "api" in data assert "configured" in data finally: app.dependency_overrides.clear() @@ -207,6 +209,9 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers): provider="openai_compatible", model="test-model", content="1) 态势摘要: 测试返回", + content_blocks=[], + text_blocks=["1) 态势摘要: 测试返回"], + thinking_blocks=[], raw_response={"id": "mock-response"}, ) @@ -241,5 +246,8 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers): data = response.json() assert data["provider"] == "openai_compatible" assert data["content"] + assert "content_blocks" in data + assert "text_blocks" in data + assert "thinking_blocks" in data finally: app.dependency_overrides.clear() diff --git a/docs/aiprovider.md b/docs/aiprovider.md index 64227453..d82d75e7 100644 --- a/docs/aiprovider.md +++ b/docs/aiprovider.md @@ -31,22 +31,40 @@ The recommended default is: - timeout and lightweight retry - request tracing via `X-Request-ID` +This now follows an OpenClaw-like seam: + +- `AI_PROVIDER` identifies the vendor or logical provider +- `AI_PROVIDER_API` identifies the wire adapter + +That split makes MiniMax, Claude-compatible gateways, and self-hosted OpenAI-compatible services easier to model without overloading one config field. + ## Supported Providers -`aiprovider` currently supports: +`aiprovider` currently supports these provider identities: - `openai` -- `openai_compatible` - `anthropic` +- `minimax` +- `ollama` + +Supported request adapters: + +- `openai-completions` +- `anthropic-messages` +- `ollama-generate` + +Backward-compatible aliases still accepted: + +- `openai_compatible` - `anthropic_compatible` - `claude_compatible` -- `ollama` Provider mapping: -- `vLLM`, `LM Studio`, `One API`: `openai_compatible` -- `MiniMax`, Claude-compatible gateways: `claude_compatible` -- `Ollama`: `ollama` +- `vLLM`, `LM Studio`, `One API`: `AI_PROVIDER=openai`, `AI_PROVIDER_API=openai-completions` +- `MiniMax`: `AI_PROVIDER=minimax`, `AI_PROVIDER_API=anthropic-messages` +- Claude-compatible gateways: `AI_PROVIDER=anthropic`, `AI_PROVIDER_API=anthropic-messages` +- `Ollama`: `AI_PROVIDER=ollama`, `AI_PROVIDER_API=ollama-generate` ## API Surfaces @@ -137,9 +155,13 @@ Both backend and `aiprovider` return the same payload shape: ```json { - "provider": "openai_compatible", - "model": "gpt-4o-mini", + "provider": "minimax", + "api": "anthropic-messages", + "model": "MiniMax-M2.7", "content": "1) 态势摘要 ...", + "content_blocks": [], + "text_blocks": [], + "thinking_blocks": [], "raw_response": {} } ``` @@ -189,17 +211,37 @@ AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上 ### OpenAI-compatible example ```env -AI_PROVIDER=openai_compatible +AI_PROVIDER=openai +AI_PROVIDER_API=openai-completions AI_BASE_URL=http://127.0.0.1:8001/v1 AI_API_KEY=local-key AI_MODEL=your-local-model ``` -### Claude-compatible example +### MiniMax CN example ```env -AI_PROVIDER=claude_compatible -AI_BASE_URL=https://your-claude-compatible-endpoint.example.com +AI_PROVIDER=minimax +AI_PROVIDER_API=anthropic-messages +AI_BASE_URL=https://api.minimaxi.com/anthropic +AI_API_KEY=sk-cp-xxxxx +AI_MODEL=MiniMax-M2.7 +AI_MAX_TOKENS=1200 +AI_ANTHROPIC_VERSION=2023-06-01 +``` + +MiniMax note: + +- This follows the same Anthropic Messages request shape as the official MiniMax examples. +- For MiniMax, `aiprovider` now disables `thinking` by default unless the caller explicitly passes a `thinking` object. +- This mirrors OpenClaw's caution around MiniMax Anthropic-compatible behavior. + +### Anthropic-compatible example + +```env +AI_PROVIDER=anthropic +AI_PROVIDER_API=anthropic-messages +AI_BASE_URL=https://your-claude-compatible-endpoint.example.com/anthropic AI_API_KEY=your_api_key AI_MODEL=your-model AI_MAX_TOKENS=1200 @@ -210,6 +252,7 @@ AI_ANTHROPIC_VERSION=2023-06-01 ```env AI_PROVIDER=ollama +AI_PROVIDER_API=ollama-generate AI_BASE_URL=http://127.0.0.1:11434 AI_API_KEY= AI_MODEL=qwen2.5:7b