feat: add ai provider adapters
This commit is contained in:
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
Phase 0 地基与审核修补已完成:Django + DRF 骨架、自定义 User、MySQL 8.4 配置、django-admin smoke test、解释器版本断言和 `.env.example` 均已落地。下一步是 T-101 Provider 适配器层。详见 [`docs/current-state.md`](docs/current-state.md)。
|
Phase 1 已完成 T-101:Provider 适配器层、`cmbot` AI 调用逻辑迁移骨架和 mock 单测已落地。下一步是 T-102 AiModel + ModelAlias 数据模型与别名解析。详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||||
|
|
||||||
> ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
|
> ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from .base import (
|
||||||
|
AiCapabilityError,
|
||||||
|
AiProviderConfigError,
|
||||||
|
AiProviderError,
|
||||||
|
AiResponseParseError,
|
||||||
|
ImageGenerationResult,
|
||||||
|
Provider,
|
||||||
|
ResolvedModel,
|
||||||
|
TextGenerationResult,
|
||||||
|
)
|
||||||
|
from .registry import default_registry, get_provider, register_provider, resolve_api_type
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AiCapabilityError",
|
||||||
|
"AiProviderConfigError",
|
||||||
|
"AiProviderError",
|
||||||
|
"AiResponseParseError",
|
||||||
|
"ImageGenerationResult",
|
||||||
|
"Provider",
|
||||||
|
"ResolvedModel",
|
||||||
|
"TextGenerationResult",
|
||||||
|
"default_registry",
|
||||||
|
"get_provider",
|
||||||
|
"register_provider",
|
||||||
|
"resolve_api_type",
|
||||||
|
]
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class AiProviderError(RuntimeError):
|
||||||
|
"""Base error for AI provider failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class AiProviderConfigError(AiProviderError, ValueError):
|
||||||
|
"""Raised when model/provider configuration is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class AiCapabilityError(AiProviderError):
|
||||||
|
"""Raised when a provider cannot perform the requested capability."""
|
||||||
|
|
||||||
|
|
||||||
|
class AiResponseParseError(AiProviderError):
|
||||||
|
"""Raised when a provider response cannot be parsed into the expected result."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResolvedModel:
|
||||||
|
"""Runtime model config resolved from future AiModel/ModelAlias records."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
model: str
|
||||||
|
api_key: str
|
||||||
|
api_type: str = "auto"
|
||||||
|
timeout_seconds: int = 0
|
||||||
|
connect_timeout_seconds: int = 30
|
||||||
|
extra_body: dict[str, Any] = field(default_factory=dict)
|
||||||
|
capabilities: frozenset[str] = field(default_factory=frozenset)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, data: Mapping[str, Any]) -> "ResolvedModel":
|
||||||
|
raw_capabilities = data.get("capabilities") or ()
|
||||||
|
if isinstance(raw_capabilities, str):
|
||||||
|
capabilities = frozenset(
|
||||||
|
item.strip() for item in raw_capabilities.split(",") if item.strip()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
capabilities = frozenset(str(item) for item in raw_capabilities)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
name=str(data.get("name") or data.get("model") or ""),
|
||||||
|
url=str(data.get("url") or ""),
|
||||||
|
model=str(data.get("model") or ""),
|
||||||
|
api_key=str(data.get("api_key") or ""),
|
||||||
|
api_type=str(data.get("api_type") or "auto"),
|
||||||
|
timeout_seconds=_to_int(data.get("timeout_seconds"), 0),
|
||||||
|
connect_timeout_seconds=_to_int(data.get("connect_timeout_seconds"), 30),
|
||||||
|
extra_body=dict(data.get("extra_body") or {}),
|
||||||
|
capabilities=capabilities,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TextGenerationResult:
|
||||||
|
text: str
|
||||||
|
titles: tuple[str, ...]
|
||||||
|
model_used: str
|
||||||
|
raw: Mapping[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ImageGenerationResult:
|
||||||
|
image: bytes
|
||||||
|
model_used: str
|
||||||
|
raw: Mapping[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class Provider(Protocol):
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def generate_text(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
model: ResolvedModel,
|
||||||
|
*,
|
||||||
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
|
resolution: str = "1K",
|
||||||
|
parameters: Mapping[str, Any] | None = None,
|
||||||
|
) -> TextGenerationResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
def generate_image(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
model: ResolvedModel,
|
||||||
|
*,
|
||||||
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
|
image_filename: str = "image.png",
|
||||||
|
resolution: str = "1K",
|
||||||
|
aspect_ratio: str = "1:1",
|
||||||
|
parameters: Mapping[str, Any] | None = None,
|
||||||
|
) -> ImageGenerationResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def validate_model_config(model: ResolvedModel) -> None:
|
||||||
|
errors = []
|
||||||
|
if not model.url.strip():
|
||||||
|
errors.append("missing url")
|
||||||
|
if not model.model.strip():
|
||||||
|
errors.append("missing model")
|
||||||
|
if not model.api_key.strip():
|
||||||
|
errors.append("missing api_key")
|
||||||
|
if model.timeout_seconds < 0:
|
||||||
|
errors.append("timeout_seconds must be >= 0")
|
||||||
|
if model.connect_timeout_seconds <= 0:
|
||||||
|
errors.append("connect_timeout_seconds must be > 0")
|
||||||
|
if errors:
|
||||||
|
raise AiProviderConfigError("; ".join(errors))
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(value: Any, default: int) -> int:
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
AiCapabilityError,
|
||||||
|
AiResponseParseError,
|
||||||
|
ImageGenerationResult,
|
||||||
|
ResolvedModel,
|
||||||
|
TextGenerationResult,
|
||||||
|
validate_model_config,
|
||||||
|
)
|
||||||
|
from .utils import (
|
||||||
|
API_CHAT,
|
||||||
|
API_GEMINI,
|
||||||
|
API_IMAGES,
|
||||||
|
API_IMAGES_EDITS,
|
||||||
|
extract_image_from_response,
|
||||||
|
extract_text_from_response,
|
||||||
|
extract_titles_from_response,
|
||||||
|
image_bytes_to_data_url,
|
||||||
|
normalize_api_url,
|
||||||
|
request_timeout,
|
||||||
|
resolution_to_size,
|
||||||
|
split_data_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseHttpProvider:
|
||||||
|
def __init__(self, session: requests.Session | None = None):
|
||||||
|
self.session = session or requests.Session()
|
||||||
|
if hasattr(self.session, "trust_env"):
|
||||||
|
self.session.trust_env = False
|
||||||
|
|
||||||
|
def _headers(self, model: ResolvedModel, *, json: bool = False) -> dict[str, str]:
|
||||||
|
headers = {"Authorization": f"Bearer {model.api_key}"}
|
||||||
|
if json:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def _timeout(self, model: ResolvedModel, resolution: str) -> tuple[int, int]:
|
||||||
|
return request_timeout(
|
||||||
|
model.connect_timeout_seconds,
|
||||||
|
model.timeout_seconds,
|
||||||
|
resolution,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_timeout(self, model: ResolvedModel, resolution: str) -> int:
|
||||||
|
return self._timeout(model, resolution)[1]
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionsProvider(BaseHttpProvider):
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"text", "image", "vision"}
|
||||||
|
|
||||||
|
def generate_text(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
model: ResolvedModel,
|
||||||
|
*,
|
||||||
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
|
resolution: str = "1K",
|
||||||
|
parameters: Mapping[str, Any] | None = None,
|
||||||
|
) -> TextGenerationResult:
|
||||||
|
validate_model_config(model)
|
||||||
|
url = normalize_api_url(model.url, API_CHAT)
|
||||||
|
payload = build_chat_text_payload(
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
image=image,
|
||||||
|
image_mime_type=image_mime_type,
|
||||||
|
parameters=parameters,
|
||||||
|
)
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
headers=self._headers(model, json=True),
|
||||||
|
json=payload,
|
||||||
|
timeout=self._timeout(model, resolution),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
raw = response.json()
|
||||||
|
titles = extract_titles_from_response(raw)
|
||||||
|
text = extract_text_from_response(raw)
|
||||||
|
if not text:
|
||||||
|
raise AiResponseParseError("AI response did not contain text")
|
||||||
|
return TextGenerationResult(text=text, titles=titles, model_used=model.model, raw=raw)
|
||||||
|
|
||||||
|
def generate_image(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
model: ResolvedModel,
|
||||||
|
*,
|
||||||
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
|
image_filename: str = "image.png",
|
||||||
|
resolution: str = "1K",
|
||||||
|
aspect_ratio: str = "1:1",
|
||||||
|
parameters: Mapping[str, Any] | None = None,
|
||||||
|
) -> ImageGenerationResult:
|
||||||
|
validate_model_config(model)
|
||||||
|
url = normalize_api_url(model.url, API_CHAT)
|
||||||
|
payload = build_chat_image_payload(
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
image=image,
|
||||||
|
image_mime_type=image_mime_type,
|
||||||
|
parameters=parameters,
|
||||||
|
)
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
headers=self._headers(model, json=True),
|
||||||
|
json=payload,
|
||||||
|
timeout=self._timeout(model, resolution),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
raw = response.json()
|
||||||
|
image_bytes = extract_image_from_response(
|
||||||
|
raw,
|
||||||
|
session=self.session,
|
||||||
|
timeout=self._read_timeout(model, resolution),
|
||||||
|
)
|
||||||
|
if not image_bytes:
|
||||||
|
raise AiResponseParseError("AI response did not contain an image")
|
||||||
|
return ImageGenerationResult(image=image_bytes, model_used=model.model, raw=raw)
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiProvider(ChatCompletionsProvider):
|
||||||
|
def generate_text(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
model: ResolvedModel,
|
||||||
|
*,
|
||||||
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
|
resolution: str = "1K",
|
||||||
|
parameters: Mapping[str, Any] | None = None,
|
||||||
|
) -> TextGenerationResult:
|
||||||
|
validate_model_config(model)
|
||||||
|
url = normalize_api_url(model.url, API_GEMINI).replace("{model}", model.model)
|
||||||
|
payload = build_gemini_payload(
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
image=image,
|
||||||
|
image_mime_type=image_mime_type,
|
||||||
|
response_modalities=["TEXT"],
|
||||||
|
parameters=parameters,
|
||||||
|
)
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
headers=self._headers(model, json=True),
|
||||||
|
json=payload,
|
||||||
|
timeout=self._timeout(model, resolution),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
raw = response.json()
|
||||||
|
titles = extract_titles_from_response(raw)
|
||||||
|
text = extract_text_from_response(raw)
|
||||||
|
if not text:
|
||||||
|
raise AiResponseParseError("AI response did not contain text")
|
||||||
|
return TextGenerationResult(text=text, titles=titles, model_used=model.model, raw=raw)
|
||||||
|
|
||||||
|
def generate_image(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
model: ResolvedModel,
|
||||||
|
*,
|
||||||
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
|
image_filename: str = "image.png",
|
||||||
|
resolution: str = "1K",
|
||||||
|
aspect_ratio: str = "1:1",
|
||||||
|
parameters: Mapping[str, Any] | None = None,
|
||||||
|
) -> ImageGenerationResult:
|
||||||
|
validate_model_config(model)
|
||||||
|
url = normalize_api_url(model.url, API_GEMINI).replace("{model}", model.model)
|
||||||
|
payload = build_gemini_payload(
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
image=image,
|
||||||
|
image_mime_type=image_mime_type,
|
||||||
|
response_modalities=["TEXT", "IMAGE"],
|
||||||
|
parameters=parameters,
|
||||||
|
)
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
headers=self._headers(model, json=True),
|
||||||
|
json=payload,
|
||||||
|
timeout=self._timeout(model, resolution),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
raw = response.json()
|
||||||
|
image_bytes = extract_image_from_response(
|
||||||
|
raw,
|
||||||
|
session=self.session,
|
||||||
|
timeout=self._read_timeout(model, resolution),
|
||||||
|
)
|
||||||
|
if not image_bytes:
|
||||||
|
raise AiResponseParseError("AI response did not contain an image")
|
||||||
|
return ImageGenerationResult(image=image_bytes, model_used=model.model, raw=raw)
|
||||||
|
|
||||||
|
|
||||||
|
class ImagesGenerationProvider(BaseHttpProvider):
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"image"}
|
||||||
|
|
||||||
|
def generate_text(self, *args: Any, **kwargs: Any) -> TextGenerationResult:
|
||||||
|
raise AiCapabilityError("images generation provider cannot generate text")
|
||||||
|
|
||||||
|
def generate_image(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
model: ResolvedModel,
|
||||||
|
*,
|
||||||
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
|
image_filename: str = "image.png",
|
||||||
|
resolution: str = "1K",
|
||||||
|
aspect_ratio: str = "1:1",
|
||||||
|
parameters: Mapping[str, Any] | None = None,
|
||||||
|
) -> ImageGenerationResult:
|
||||||
|
validate_model_config(model)
|
||||||
|
url = normalize_api_url(model.url, API_IMAGES)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model.model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"aspect_ratio": aspect_ratio,
|
||||||
|
"resolution": resolution,
|
||||||
|
"n": 1,
|
||||||
|
}
|
||||||
|
if image is not None:
|
||||||
|
payload["image_urls"] = [image_bytes_to_data_url(image, image_mime_type)]
|
||||||
|
apply_extra_body(payload, model, parameters)
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
headers=self._headers(model, json=True),
|
||||||
|
json=payload,
|
||||||
|
timeout=self._timeout(model, resolution),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
raw = response.json()
|
||||||
|
image_bytes = extract_image_from_response(
|
||||||
|
raw,
|
||||||
|
session=self.session,
|
||||||
|
timeout=self._read_timeout(model, resolution),
|
||||||
|
)
|
||||||
|
if not image_bytes:
|
||||||
|
raise AiResponseParseError("AI response did not contain an image")
|
||||||
|
return ImageGenerationResult(image=image_bytes, model_used=model.model, raw=raw)
|
||||||
|
|
||||||
|
|
||||||
|
class ImagesEditsProvider(BaseHttpProvider):
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"image", "vision"}
|
||||||
|
|
||||||
|
def generate_text(self, *args: Any, **kwargs: Any) -> TextGenerationResult:
|
||||||
|
raise AiCapabilityError("images edits provider cannot generate text")
|
||||||
|
|
||||||
|
def generate_image(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
model: ResolvedModel,
|
||||||
|
*,
|
||||||
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
|
image_filename: str = "image.png",
|
||||||
|
resolution: str = "1K",
|
||||||
|
aspect_ratio: str = "1:1",
|
||||||
|
parameters: Mapping[str, Any] | None = None,
|
||||||
|
) -> ImageGenerationResult:
|
||||||
|
validate_model_config(model)
|
||||||
|
if image is None:
|
||||||
|
raise AiCapabilityError("images edits provider requires an input image")
|
||||||
|
|
||||||
|
url = normalize_api_url(model.url, API_IMAGES_EDITS)
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"model": model.model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"n": "1",
|
||||||
|
"size": resolution_to_size(resolution),
|
||||||
|
}
|
||||||
|
apply_extra_body(data, model, parameters)
|
||||||
|
files = {"image": (image_filename, image, image_mime_type)}
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
headers=self._headers(model),
|
||||||
|
data=data,
|
||||||
|
files=files,
|
||||||
|
timeout=self._timeout(model, resolution),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
raw = response.json()
|
||||||
|
image_bytes = extract_image_from_response(
|
||||||
|
raw,
|
||||||
|
session=self.session,
|
||||||
|
timeout=self._read_timeout(model, resolution),
|
||||||
|
)
|
||||||
|
if not image_bytes:
|
||||||
|
raise AiResponseParseError("AI response did not contain an image")
|
||||||
|
return ImageGenerationResult(image=image_bytes, model_used=model.model, raw=raw)
|
||||||
|
|
||||||
|
|
||||||
|
def build_chat_text_payload(
|
||||||
|
model: ResolvedModel,
|
||||||
|
prompt: str,
|
||||||
|
*,
|
||||||
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
|
parameters: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
|
||||||
|
if image is not None:
|
||||||
|
content.append(
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": image_bytes_to_data_url(image, image_mime_type)},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model.model,
|
||||||
|
"messages": [{"role": "user", "content": content}],
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
apply_extra_body(payload, model, parameters)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_chat_image_payload(
|
||||||
|
model: ResolvedModel,
|
||||||
|
prompt: str,
|
||||||
|
*,
|
||||||
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
|
parameters: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return build_chat_text_payload(
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
image=image,
|
||||||
|
image_mime_type=image_mime_type,
|
||||||
|
parameters=parameters,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_gemini_payload(
|
||||||
|
model: ResolvedModel,
|
||||||
|
prompt: str,
|
||||||
|
*,
|
||||||
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
|
response_modalities: list[str],
|
||||||
|
parameters: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
parts: list[dict[str, Any]] = [{"text": prompt}]
|
||||||
|
if image is not None:
|
||||||
|
data_url = image_bytes_to_data_url(image, image_mime_type)
|
||||||
|
mime_type, data = split_data_url(data_url)
|
||||||
|
parts.append({"inlineData": {"mimeType": mime_type, "data": data}})
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"contents": [{"parts": parts}],
|
||||||
|
"generationConfig": {"responseModalities": response_modalities},
|
||||||
|
}
|
||||||
|
apply_extra_body(payload, model, parameters)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def apply_extra_body(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
model: ResolvedModel,
|
||||||
|
parameters: Mapping[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload.update(model.extra_body)
|
||||||
|
if parameters:
|
||||||
|
payload.update(dict(parameters))
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .base import AiProviderConfigError, Provider
|
||||||
|
from .openai_compatible import (
|
||||||
|
ChatCompletionsProvider,
|
||||||
|
GeminiProvider,
|
||||||
|
ImagesEditsProvider,
|
||||||
|
ImagesGenerationProvider,
|
||||||
|
)
|
||||||
|
from .utils import (
|
||||||
|
API_AUTO,
|
||||||
|
API_CHAT,
|
||||||
|
API_GEMINI,
|
||||||
|
API_IMAGES,
|
||||||
|
API_IMAGES_EDITS,
|
||||||
|
detect_api_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._providers: dict[str, Provider] = {}
|
||||||
|
|
||||||
|
def register(self, api_type: str, provider: Provider) -> None:
|
||||||
|
self._providers[api_type] = provider
|
||||||
|
|
||||||
|
def resolve_api_type(self, api_type: str, url: str = "") -> str:
|
||||||
|
return detect_api_type(url, api_type) if api_type == API_AUTO else api_type
|
||||||
|
|
||||||
|
def get(self, api_type: str, url: str = "") -> Provider:
|
||||||
|
resolved_api_type = self.resolve_api_type(api_type, url)
|
||||||
|
try:
|
||||||
|
return self._providers[resolved_api_type]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise AiProviderConfigError(
|
||||||
|
f"no provider registered for api_type: {resolved_api_type}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
default_registry = ProviderRegistry()
|
||||||
|
default_registry.register(API_CHAT, ChatCompletionsProvider())
|
||||||
|
default_registry.register(API_GEMINI, GeminiProvider())
|
||||||
|
default_registry.register(API_IMAGES, ImagesGenerationProvider())
|
||||||
|
default_registry.register(API_IMAGES_EDITS, ImagesEditsProvider())
|
||||||
|
|
||||||
|
|
||||||
|
def register_provider(api_type: str, provider: Provider) -> None:
|
||||||
|
default_registry.register(api_type, provider)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_api_type(api_type: str, url: str = "") -> str:
|
||||||
|
return default_registry.resolve_api_type(api_type, url)
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider(api_type: str, url: str = "") -> Provider:
|
||||||
|
return default_registry.get(api_type, url)
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .base import AiProviderConfigError, AiResponseParseError
|
||||||
|
|
||||||
|
API_AUTO = "auto"
|
||||||
|
API_CHAT = "chat"
|
||||||
|
API_GEMINI = "gemini"
|
||||||
|
API_IMAGES = "images"
|
||||||
|
API_IMAGES_EDITS = "images_edits"
|
||||||
|
|
||||||
|
SUPPORTED_API_TYPES = {
|
||||||
|
API_AUTO,
|
||||||
|
API_CHAT,
|
||||||
|
API_GEMINI,
|
||||||
|
API_IMAGES,
|
||||||
|
API_IMAGES_EDITS,
|
||||||
|
}
|
||||||
|
|
||||||
|
RESOLUTION_TIMEOUTS = {"512": 180, "1K": 240, "2K": 360, "4K": 600}
|
||||||
|
BASE64_KEYS = {"image_base64", "base64", "b64_json", "data"}
|
||||||
|
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||||
|
|
||||||
|
TITLE_LEAD = re.compile(r"^\s*(?:\d+\s*[\.\)、::]|[-*•])\s*")
|
||||||
|
TITLE_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩"
|
||||||
|
TITLE_QUOTES = "\"'「」『』“”‘’"
|
||||||
|
TITLE_SPLIT = re.compile(r"[,,\r\n]+")
|
||||||
|
|
||||||
|
|
||||||
|
def resolution_timeout(resolution: str, default: int = 240) -> int:
|
||||||
|
return RESOLUTION_TIMEOUTS.get(str(resolution).strip().upper(), default)
|
||||||
|
|
||||||
|
|
||||||
|
def request_timeout(connect_timeout: int, read_timeout: int, resolution: str) -> tuple[int, int]:
|
||||||
|
resolved_read_timeout = read_timeout if read_timeout > 0 else resolution_timeout(resolution)
|
||||||
|
return connect_timeout, resolved_read_timeout
|
||||||
|
|
||||||
|
|
||||||
|
def detect_api_type(url: str, api_type: str = API_AUTO) -> str:
|
||||||
|
if api_type and api_type != API_AUTO:
|
||||||
|
if api_type not in SUPPORTED_API_TYPES:
|
||||||
|
raise AiProviderConfigError(f"unsupported api_type: {api_type}")
|
||||||
|
return api_type
|
||||||
|
|
||||||
|
lower_url = str(url).lower()
|
||||||
|
if "generatecontent" in lower_url or "gemini" in lower_url:
|
||||||
|
return API_GEMINI
|
||||||
|
if "/images/edits" in lower_url:
|
||||||
|
return API_IMAGES_EDITS
|
||||||
|
if "/images" in lower_url:
|
||||||
|
return API_IMAGES
|
||||||
|
return API_CHAT
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_api_url(url: str, api_type: str) -> str:
|
||||||
|
raw = str(url).strip()
|
||||||
|
if not raw:
|
||||||
|
return raw
|
||||||
|
|
||||||
|
endpoint = {
|
||||||
|
API_CHAT: "chat/completions",
|
||||||
|
API_IMAGES: "images/generations",
|
||||||
|
API_IMAGES_EDITS: "images/edits",
|
||||||
|
}.get(api_type)
|
||||||
|
|
||||||
|
lower_path = urlparse(raw).path.lower().rstrip("/")
|
||||||
|
if api_type == API_GEMINI:
|
||||||
|
if "generatecontent" in lower_path:
|
||||||
|
return raw
|
||||||
|
return join_url(raw, "v1beta/models/{model}:generateContent")
|
||||||
|
|
||||||
|
if endpoint is None:
|
||||||
|
return raw
|
||||||
|
if lower_path.endswith("/" + endpoint):
|
||||||
|
return raw
|
||||||
|
if lower_path.endswith("/v1"):
|
||||||
|
return join_url(raw, endpoint)
|
||||||
|
return join_url(raw, "v1/" + endpoint)
|
||||||
|
|
||||||
|
|
||||||
|
def join_url(base_url: str, suffix: str) -> str:
|
||||||
|
base = str(base_url).rstrip("/") + "/"
|
||||||
|
return urljoin(base, suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def image_bytes_to_data_url(image: bytes, mime_type: str = "image/png") -> str:
|
||||||
|
encoded = base64.b64encode(image).decode("ascii")
|
||||||
|
return f"data:{mime_type};base64,{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def split_data_url(data_url: str) -> tuple[str, str]:
|
||||||
|
prefix, encoded = data_url.split(",", 1)
|
||||||
|
mime_type = prefix[len("data:") :].split(";", 1)[0]
|
||||||
|
return mime_type, encoded
|
||||||
|
|
||||||
|
|
||||||
|
def decode_image_data_url(data_url: str) -> bytes:
|
||||||
|
marker = ";base64,"
|
||||||
|
if marker not in data_url:
|
||||||
|
raise AiResponseParseError("unsupported data URL image format")
|
||||||
|
return base64.b64decode(data_url.split(marker, 1)[1])
|
||||||
|
|
||||||
|
|
||||||
|
def resolution_to_size(resolution: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"512": "512x512",
|
||||||
|
"512px": "512x512",
|
||||||
|
"1K": "1024x1024",
|
||||||
|
"2K": "2048x2048",
|
||||||
|
"4K": "4096x4096",
|
||||||
|
}
|
||||||
|
return mapping.get(str(resolution), str(resolution))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_image_from_response(
|
||||||
|
data: Any,
|
||||||
|
*,
|
||||||
|
session: requests.Session | None = None,
|
||||||
|
timeout: int = 60,
|
||||||
|
) -> bytes | None:
|
||||||
|
for key, value in walk_json_items(data):
|
||||||
|
if not isinstance(value, str):
|
||||||
|
continue
|
||||||
|
text = value.strip()
|
||||||
|
if text.startswith("data:image/"):
|
||||||
|
return decode_image_data_url(text)
|
||||||
|
if key and key.lower() in BASE64_KEYS and looks_like_base64(text):
|
||||||
|
try:
|
||||||
|
return base64.b64decode(text)
|
||||||
|
except (TypeError, ValueError, binascii.Error):
|
||||||
|
pass
|
||||||
|
|
||||||
|
for _key, value in walk_json_items(data):
|
||||||
|
if isinstance(value, str) and is_image_url(value):
|
||||||
|
return download_image_url(value, session=session, timeout=timeout)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def download_image_url(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
session: requests.Session | None = None,
|
||||||
|
timeout: int = 60,
|
||||||
|
) -> bytes:
|
||||||
|
client = session or requests.Session()
|
||||||
|
if hasattr(client, "trust_env"):
|
||||||
|
client.trust_env = False
|
||||||
|
response = client.get(url, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
|
||||||
|
def extract_titles_from_response(data: Any) -> tuple[str, ...]:
|
||||||
|
return tuple(clean_titles(extract_raw_text(data)))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_from_response(data: Any) -> str:
|
||||||
|
titles = extract_titles_from_response(data)
|
||||||
|
return titles[0] if titles else ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_raw_text(data: Any) -> str:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
choices = data.get("choices")
|
||||||
|
if isinstance(choices, list) and choices and isinstance(choices[0], dict):
|
||||||
|
message = choices[0].get("message")
|
||||||
|
if isinstance(message, dict):
|
||||||
|
text = content_to_text(message.get("content"))
|
||||||
|
if text.strip():
|
||||||
|
return text
|
||||||
|
legacy = choices[0].get("text")
|
||||||
|
if isinstance(legacy, str) and legacy.strip():
|
||||||
|
return legacy
|
||||||
|
|
||||||
|
candidates = data.get("candidates")
|
||||||
|
if isinstance(candidates, list) and candidates and isinstance(candidates[0], dict):
|
||||||
|
content = candidates[0].get("content")
|
||||||
|
if isinstance(content, dict) and isinstance(content.get("parts"), list):
|
||||||
|
texts = [
|
||||||
|
part.get("text")
|
||||||
|
for part in content["parts"]
|
||||||
|
if isinstance(part, dict) and isinstance(part.get("text"), str)
|
||||||
|
]
|
||||||
|
joined = "\n".join(text for text in texts if text)
|
||||||
|
if joined.strip():
|
||||||
|
return joined
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def content_to_text(content: Any) -> str:
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
texts = []
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and isinstance(part.get("text"), str):
|
||||||
|
texts.append(part["text"])
|
||||||
|
elif isinstance(part, str):
|
||||||
|
texts.append(part)
|
||||||
|
return "\n".join(texts)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def clean_titles(text: str) -> list[str]:
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for piece in TITLE_SPLIT.split(str(text)):
|
||||||
|
cleaned = clean_title_line(piece)
|
||||||
|
if cleaned:
|
||||||
|
out.append(cleaned)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def clean_title_line(line: str) -> str:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
return ""
|
||||||
|
stripped = TITLE_LEAD.sub("", stripped)
|
||||||
|
stripped = stripped.lstrip(TITLE_CIRCLED).strip()
|
||||||
|
stripped = stripped.strip(TITLE_QUOTES).strip()
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
|
def walk_json_items(value: Any, key: str | None = None) -> Iterable[tuple[str | None, Any]]:
|
||||||
|
yield key, value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for child_key, child_value in value.items():
|
||||||
|
yield from walk_json_items(child_value, str(child_key))
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for child_value in value:
|
||||||
|
yield from walk_json_items(child_value, key)
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_base64(text: str) -> bool:
|
||||||
|
if len(text) < 8:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
base64.b64decode(text, validate=True)
|
||||||
|
return True
|
||||||
|
except (TypeError, ValueError, binascii.Error):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_image_url(text: str) -> bool:
|
||||||
|
parsed = urlparse(text.strip())
|
||||||
|
if parsed.scheme.lower() not in ("http", "https"):
|
||||||
|
return False
|
||||||
|
suffix = Path(parsed.path).suffix.lower()
|
||||||
|
return suffix in IMAGE_EXTENSIONS
|
||||||
+176
-2
@@ -1,3 +1,177 @@
|
|||||||
from django.test import TestCase
|
import base64
|
||||||
|
|
||||||
# Create your tests here.
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from apps.ai.providers import AiCapabilityError, ResolvedModel, get_provider, resolve_api_type
|
||||||
|
from apps.ai.providers.openai_compatible import ChatCompletionsProvider, ImagesEditsProvider
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, payload, content=b""):
|
||||||
|
self.payload = payload
|
||||||
|
self.content = content
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
def __init__(self, *responses):
|
||||||
|
self.responses = list(responses)
|
||||||
|
self.posts = []
|
||||||
|
self.gets = []
|
||||||
|
self.trust_env = True
|
||||||
|
|
||||||
|
def post(self, url, **kwargs):
|
||||||
|
self.posts.append({"url": url, **kwargs})
|
||||||
|
return self.responses.pop(0)
|
||||||
|
|
||||||
|
def get(self, url, **kwargs):
|
||||||
|
self.gets.append({"url": url, **kwargs})
|
||||||
|
return self.responses.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderRegistryTests(SimpleTestCase):
|
||||||
|
def test_auto_api_type_resolves_chat_provider_from_url(self):
|
||||||
|
url = "https://api.vectorengine.ai/v1/chat/completions"
|
||||||
|
|
||||||
|
self.assertEqual(resolve_api_type("auto", url), "chat")
|
||||||
|
self.assertIsInstance(get_provider("auto", url), ChatCompletionsProvider)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionsProviderTests(SimpleTestCase):
|
||||||
|
def test_generate_text_builds_chat_payload_and_cleans_titles(self):
|
||||||
|
session = FakeSession(
|
||||||
|
FakeResponse(
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{"message": {"content": "1. Red Dress\n2. Blue Coat"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider = ChatCompletionsProvider(session=session)
|
||||||
|
model = ResolvedModel(
|
||||||
|
name="GPT-5.5 text",
|
||||||
|
url="https://api.vectorengine.ai/v1",
|
||||||
|
model="gpt-5.5",
|
||||||
|
api_key="test-key",
|
||||||
|
api_type="chat",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = provider.generate_text(
|
||||||
|
"Generate titles",
|
||||||
|
model,
|
||||||
|
parameters={"temperature": 0.2},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.text, "Red Dress")
|
||||||
|
self.assertEqual(result.titles, ("Red Dress", "Blue Coat"))
|
||||||
|
request = session.posts[0]
|
||||||
|
self.assertEqual(
|
||||||
|
request["url"],
|
||||||
|
"https://api.vectorengine.ai/v1/chat/completions",
|
||||||
|
)
|
||||||
|
self.assertEqual(request["headers"]["Authorization"], "Bearer test-key")
|
||||||
|
self.assertEqual(request["json"]["model"], "gpt-5.5")
|
||||||
|
self.assertFalse(request["json"]["stream"])
|
||||||
|
self.assertEqual(request["json"]["temperature"], 0.2)
|
||||||
|
|
||||||
|
def test_generate_image_parses_chat_multimodal_data_url(self):
|
||||||
|
generated = b"generated-image"
|
||||||
|
encoded = base64.b64encode(generated).decode("ascii")
|
||||||
|
session = FakeSession(
|
||||||
|
FakeResponse(
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "done"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/png;base64,{encoded}"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider = ChatCompletionsProvider(session=session)
|
||||||
|
model = ResolvedModel(
|
||||||
|
name="Nano Banana 2",
|
||||||
|
url="https://api.vectorengine.ai/v1/chat/completions",
|
||||||
|
model="gemini-3.1-flash-image-preview",
|
||||||
|
api_key="test-key",
|
||||||
|
api_type="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = provider.generate_image(
|
||||||
|
"Generate product image",
|
||||||
|
model,
|
||||||
|
image=b"input-image",
|
||||||
|
image_mime_type="image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.image, generated)
|
||||||
|
content = session.posts[0]["json"]["messages"][0]["content"]
|
||||||
|
self.assertEqual(content[0], {"type": "text", "text": "Generate product image"})
|
||||||
|
self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,"))
|
||||||
|
|
||||||
|
|
||||||
|
class ImagesEditsProviderTests(SimpleTestCase):
|
||||||
|
def test_generate_image_builds_multipart_request_and_parses_base64(self):
|
||||||
|
generated = b"edited-image"
|
||||||
|
encoded = base64.b64encode(generated).decode("ascii")
|
||||||
|
session = FakeSession(FakeResponse({"data": [{"b64_json": encoded}]}))
|
||||||
|
provider = ImagesEditsProvider(session=session)
|
||||||
|
model = ResolvedModel(
|
||||||
|
name="GPT Image 2",
|
||||||
|
url="https://api.vectorengine.ai/v1/images/edits",
|
||||||
|
model="gpt-image-2",
|
||||||
|
api_key="test-key",
|
||||||
|
api_type="images_edits",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = provider.generate_image(
|
||||||
|
"Replace background",
|
||||||
|
model,
|
||||||
|
image=b"source-image",
|
||||||
|
image_mime_type="image/png",
|
||||||
|
image_filename="source.png",
|
||||||
|
resolution="1K",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.image, generated)
|
||||||
|
request = session.posts[0]
|
||||||
|
self.assertEqual(request["url"], "https://api.vectorengine.ai/v1/images/edits")
|
||||||
|
self.assertEqual(request["headers"]["Authorization"], "Bearer test-key")
|
||||||
|
self.assertEqual(request["data"]["model"], "gpt-image-2")
|
||||||
|
self.assertEqual(request["data"]["size"], "1024x1024")
|
||||||
|
self.assertEqual(
|
||||||
|
request["files"]["image"],
|
||||||
|
("source.png", b"source-image", "image/png"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_images_edits_requires_input_image(self):
|
||||||
|
provider = ImagesEditsProvider(session=FakeSession())
|
||||||
|
model = ResolvedModel(
|
||||||
|
name="GPT Image 2",
|
||||||
|
url="https://api.vectorengine.ai/v1/images/edits",
|
||||||
|
model="gpt-image-2",
|
||||||
|
api_key="test-key",
|
||||||
|
api_type="images_edits",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(AiCapabilityError):
|
||||||
|
provider.generate_image("Replace background", model)
|
||||||
|
|
||||||
|
with self.assertRaises(AiCapabilityError):
|
||||||
|
provider.generate_text("Generate title", model)
|
||||||
|
|||||||
@@ -38,12 +38,12 @@
|
|||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
当前项目处于:**Phase 1 准备开始**。T-001~T-004 已完成并通过标准验证,下一步进入 T-101 Provider 适配器层。
|
当前项目处于:**Phase 1**。T-101 Provider 适配器层已完成并通过 mock 单测,下一步进入 T-102 AiModel + ModelAlias 数据模型与别名解析。
|
||||||
|
|
||||||
优先路径:
|
优先路径:
|
||||||
|
|
||||||
1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。
|
1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。
|
||||||
2. Phase 1:最高风险功能原型 —— 移植 `cmbot` 的 AI 调用并在服务端跑通一次标题/图片生成。
|
2. Phase 1:最高风险功能原型 —— T-101 已移植 `cmbot` 的 AI 调用 provider 层;下一步 T-102/T-104 完成模型配置、别名解析并跑通一次标题/图片生成。
|
||||||
3. Phase 2:计费核心 —— User/UserWallet/ApiKey 模型 + 点数扣减(并发安全,锁 Wallet 行)+ 计费规则 + 调用记录。
|
3. Phase 2:计费核心 —— User/UserWallet/ApiKey 模型 + 点数扣减(并发安全,锁 Wallet 行)+ 计费规则 + 调用记录。
|
||||||
4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。
|
4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。
|
||||||
5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。
|
5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
| 运营后台 | django-admin | 已定 | 近零代码即得用户/点数/记录的增删改查与检索,省 80% 后台工作量 |
|
| 运营后台 | django-admin | 已定 | 近零代码即得用户/点数/记录的增删改查与检索,省 80% 后台工作量 |
|
||||||
| 用户端 | Django 模板 SSR + Bootstrap 5 + django-allauth + crispy-forms | 已定 | 自助注册/登录/充值/API Key 管理/记录页;allauth 出注册登录邮箱验证,crispy + 现成 Bootstrap 模板出页面,单体不引前端框架 |
|
| 用户端 | Django 模板 SSR + Bootstrap 5 + django-allauth + crispy-forms | 已定 | 自助注册/登录/充值/API Key 管理/记录页;allauth 出注册登录邮箱验证,crispy + 现成 Bootstrap 模板出页面,单体不引前端框架 |
|
||||||
| 后台美化 | django-unfold 或 simpleui | 待定 | 仅外观,MVP 可先用原生 admin,后期按需引入 |
|
| 后台美化 | django-unfold 或 simpleui | 待定 | 仅外观,MVP 可先用原生 admin,后期按需引入 |
|
||||||
| AI 上游对接 | **Provider 适配器层**(按 `api_type` 注册)+ **能力别名** 映射 | 已定 | 对外只暴露 `generate text/image` 两接口与别名;换供应商改后台映射,不动对外契约。移植 `cmbot` 的调用逻辑到各适配器。当前 3 模型机制不同:文本 chat、`nano-banana2` chat 多模态返图、`gpt-image-2` images/edits 改图(详见 `04` 3.1) |
|
| AI 上游对接 | **Provider 适配器层**(按 `api_type` 注册)+ **能力别名** 映射 + `requests` HTTP 客户端 | 已定 | 对外只暴露 `generate text/image` 两接口与别名;换供应商改后台映射,不动对外契约。移植 `cmbot` 的调用逻辑到各适配器。当前 3 模型机制不同:文本 chat、`nano-banana2` chat 多模态返图、`gpt-image-2` images/edits 改图(详见 `04` 3.1) |
|
||||||
| 供应商密钥存储 | 应用层对称加密(如 `cryptography` Fernet)或 KMS | 待定 | `AiModel.api_key` 加密入库、admin 脱敏不回显;加密主密钥走环境变量,配置清单见 `env.md` |
|
| 供应商密钥存储 | 应用层对称加密(如 `cryptography` Fernet)或 KMS | 待定 | `AiModel.api_key` 加密入库、admin 脱敏不回显;加密主密钥走环境变量,配置清单见 `env.md` |
|
||||||
| 图片结果存储 | 对象存储(S3 兼容 / 本地存储)返回 URL | 待定 | 同步响应默认返回 `image_url`,避免大 base64 进响应体 |
|
| 图片结果存储 | 对象存储(S3 兼容 / 本地存储)返回 URL | 待定 | 同步响应默认返回 `image_url`,避免大 base64 进响应体 |
|
||||||
| 配置变更审计 | django-admin LogEntry 或自建审计表 | 待定 | 模型/别名/密钥变更留痕,与「账目对得上」一致 |
|
| 配置变更审计 | django-admin LogEntry 或自建审计表 | 待定 | 模型/别名/密钥变更留痕,与「账目对得上」一致 |
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| T-101 | Provider 适配器层 + 移植 cmbot 调用 | T-002 | 定义 `Provider` 接口(`capabilities`/`generate_text`/`generate_image`),按 `api_type` 注册;把 `ai_text_service.py`/`ai_image_service.py` 搬进 `apps/ai/providers/` 并去除桌面依赖;**注意 3 模型机制不同(chat / chat 多模态返图 nano-banana2 / images_edits 改图 gpt-image-2,见 `04` 3.1),两图片模型非标准生成需分别解析、首次对接抓真实响应**;mock 上游单测验证解析与适配器选取 | TODO |
|
| T-101 | Provider 适配器层 + 移植 cmbot 调用 | T-002 | 定义 `Provider` 接口(`capabilities`/`generate_text`/`generate_image`),按 `api_type` 注册;把 `ai_text_service.py`/`ai_image_service.py` 搬进 `apps/ai/providers/` 并去除桌面依赖;**注意 3 模型机制不同(chat / chat 多模态返图 nano-banana2 / images_edits 改图 gpt-image-2,见 `04` 3.1),两图片模型非标准生成需分别解析、首次对接抓真实响应**;mock 上游单测验证解析与适配器选取 | DONE |
|
||||||
| T-102 | AiModel + ModelAlias 模型 + 别名解析 | T-101 | AiModel 含 `capabilities`、`api_key` **加密存储**(admin 脱敏不回显);ModelAlias 映射别名→模型;`resolve_alias()` 能解析并按能力校验;配置迁移自 `ai_models.json`;后台改配置运行时热生效 | TODO |
|
| T-102 | AiModel + ModelAlias 模型 + 别名解析 | T-101 | AiModel 含 `capabilities`、`api_key` **加密存储**(admin 脱敏不回显);ModelAlias 映射别名→模型;`resolve_alias()` 能解析并按能力校验;配置迁移自 `ai_models.json`;后台改配置运行时热生效 | TODO |
|
||||||
| T-103 | 配置变更审计 | T-102 | AiModel/ModelAlias/密钥的后台变更留痕(谁、何时、改了什么);可在 admin 查看 | TODO |
|
| T-103 | 配置变更审计 | T-102 | AiModel/ModelAlias/密钥的后台变更留痕(谁、何时、改了什么);可在 admin 查看 | TODO |
|
||||||
| T-104 | 跑通一次真实/录制的标题或图片生成 | T-102 | 用别名 + 最小输入跑通一次生成,结论写入 `progress.md`(含耗时,验证图片同步可行性与超时配置) | TODO |
|
| T-104 | 跑通一次真实/录制的标题或图片生成 | T-102 | 用别名 + 最小输入跑通一次生成,结论写入 `progress.md`(含耗时,验证图片同步可行性与超时配置) | TODO |
|
||||||
|
|||||||
+9
-3
@@ -193,16 +193,22 @@ class Provider(Protocol):
|
|||||||
def capabilities(self) -> set[str]: ... # {"text","image","vision"}
|
def capabilities(self) -> set[str]: ... # {"text","image","vision"}
|
||||||
def generate_text(self, prompt: str, model: ResolvedModel,
|
def generate_text(self, prompt: str, model: ResolvedModel,
|
||||||
image: bytes | None = None,
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
resolution: str = "1K",
|
resolution: str = "1K",
|
||||||
parameters: dict | None = None) -> list[str]: ...
|
parameters: dict | None = None) -> TextGenerationResult: ...
|
||||||
def generate_image(self, prompt: str, model: ResolvedModel, image: bytes,
|
def generate_image(self, prompt: str, model: ResolvedModel,
|
||||||
|
image: bytes | None = None,
|
||||||
|
image_mime_type: str = "image/png",
|
||||||
|
image_filename: str = "image.png",
|
||||||
resolution: str = "1K", aspect_ratio: str = "1:1",
|
resolution: str = "1K", aspect_ratio: str = "1:1",
|
||||||
parameters: dict | None = None) -> bytes | str: ...
|
parameters: dict | None = None) -> ImageGenerationResult: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
要点:
|
要点:
|
||||||
|
|
||||||
- `ResolvedModel` 来自数据库 AiModel(形状同 `cmbot/config/ai_models.json`,外加 `capabilities`;`api_key` 在库中加密,使用时解密,不落明文)。
|
- `ResolvedModel` 来自数据库 AiModel(形状同 `cmbot/config/ai_models.json`,外加 `capabilities`;`api_key` 在库中加密,使用时解密,不落明文)。
|
||||||
|
- T-101 先用 `ResolvedModel` dataclass 承接配置,不建数据库表;T-102 再把它接到 AiModel/ModelAlias。
|
||||||
|
- `TextGenerationResult` 包含 `text`、清洗后的 `titles`、`model_used`、`raw`;`ImageGenerationResult` 包含图片 bytes、`model_used`、`raw`。图片落对象存储并返回 URL 属 T-302 之后的 API 编排职责。
|
||||||
- 适配器按 `api_type`(`chat`/`gemini`/`images`/`images_edits`/`auto`)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。
|
- 适配器按 `api_type`(`chat`/`gemini`/`images`/`images_edits`/`auto`)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。
|
||||||
- `parameters` 为供应商特有参数透传;适配器负责把统一入参翻译成各家上游格式。
|
- `parameters` 为供应商特有参数透传;适配器负责把统一入参翻译成各家上游格式。
|
||||||
- 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。
|
- 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。
|
||||||
|
|||||||
+10
-10
@@ -12,10 +12,10 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-02
|
- 日期:2026-07-02
|
||||||
- 阶段:Phase 0 地基与审核修补已完成;下一步进入 Phase 1 的 T-101
|
- 阶段:Phase 1,T-101 已完成;下一步 T-102
|
||||||
- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + django-admin;MySQL 8.4 已接入 settings;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md`
|
- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md`
|
||||||
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`
|
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器)
|
||||||
- 测试:`makemigrations --check` 通过;`migrate` 通过;`manage.py check` 通过;`manage.py test` 通过(2 条 admin smoke tests);`./init.ps1` 通过
|
- 测试:`manage.py test` 通过(7 tests);`manage.py check` 通过;`makemigrations --check` 通过;`compileall apps` 通过;`./init.ps1` 通过
|
||||||
- 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`)
|
- 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`)
|
||||||
- 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
|
- 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
|
||||||
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test`
|
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test`
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
| `AGENTS.md` / `CLAUDE.md` | 已有 | 仓库级入口 |
|
| `AGENTS.md` / `CLAUDE.md` | 已有 | 仓库级入口 |
|
||||||
| `progress.md` | 已有 | 执行流水,已记录多轮文档决策;后续任务继续追加 |
|
| `progress.md` | 已有 | 执行流水,已记录多轮文档决策;后续任务继续追加 |
|
||||||
| `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` |
|
| `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` |
|
||||||
| `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python` |
|
| `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests` |
|
||||||
| `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi |
|
| `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi |
|
||||||
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束 |
|
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers` |
|
||||||
| `manage.py` | 已有 | T-001 创建 |
|
| `manage.py` | 已有 | T-001 创建 |
|
||||||
| `tests/` | 待建 | 随各任务补充 |
|
| `tests/` | 待建 | 随各任务补充 |
|
||||||
|
|
||||||
@@ -41,10 +41,10 @@
|
|||||||
|
|
||||||
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。
|
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。
|
||||||
|
|
||||||
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补。
|
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用。
|
||||||
- 正在进行:无。
|
- 正在进行:无。
|
||||||
- 当前 blocker:无。
|
- 当前 blocker:无。
|
||||||
- 下一个可领取任务:**T-101 Provider 适配器层 + 移植 cmbot 调用**。
|
- 下一个可领取任务:**T-102 AiModel + ModelAlias 模型 + 别名解析**。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
@@ -62,14 +62,14 @@ python3.12 manage.py test
|
|||||||
python3.12 manage.py runserver
|
python3.12 manage.py runserver
|
||||||
```
|
```
|
||||||
|
|
||||||
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,标准测试可创建/销毁 `test_cmhub` 测试库并通过。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。
|
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,标准测试可创建/销毁 `test_cmhub` 测试库并通过。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析,不做数据库模型、别名解析或计费;这些从 T-102/T-201/T-302 继续。
|
||||||
|
|
||||||
## 开始编码前检查
|
## 开始编码前检查
|
||||||
|
|
||||||
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
|
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
|
||||||
2. 读 `docs/00-ai-start-here.md`。
|
2. 读 `docs/00-ai-start-here.md`。
|
||||||
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
|
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
|
||||||
4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-101)。
|
4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-102)。
|
||||||
5. 将该任务状态改为 `DOING`。
|
5. 将该任务状态改为 `DOING`。
|
||||||
|
|
||||||
## 维护规则
|
## 维护规则
|
||||||
|
|||||||
+23
@@ -266,3 +266,26 @@
|
|||||||
- 阻塞:无。
|
- 阻塞:无。
|
||||||
- 决策:保留 `requirements.txt` 作为运行依赖来源,`pyproject.toml` 只承载 Python 版本元数据;`.env.example` 使用占位符,不提交 `.env`。
|
- 决策:保留 `requirements.txt` 作为运行依赖来源,`pyproject.toml` 只承载 Python 版本元数据;`.env.example` 使用占位符,不提交 `.env`。
|
||||||
- 下一步:领取 T-101 Provider 适配器层 + 移植 cmbot 调用。
|
- 下一步:领取 T-101 Provider 适配器层 + 移植 cmbot 调用。
|
||||||
|
|
||||||
|
## 2026-07-02 T-101 Provider 适配器层 + 移植 cmbot 调用
|
||||||
|
|
||||||
|
- 状态:DONE
|
||||||
|
- 变更:
|
||||||
|
- 新增 `apps/ai/providers/`:`ResolvedModel`、`Provider` 协议、结果对象、错误类型、注册表、`chat`/`gemini`/`images`/`images_edits` 适配器。
|
||||||
|
- 从 `D:\chengma\cmbot\src\services\ai_text_service.py` / `ai_image_service.py` 移植纯 HTTP 与响应解析逻辑:URL 归一化、`api_type=auto` 识别、分辨率超时、chat/gemini payload、images/edits multipart、标题清洗、图片 data URL/base64/URL 解析。
|
||||||
|
- 服务端接口改为 bytes 输入,不依赖桌面端本地路径、GUI、线程或 Qt;HTTP session 可注入,便于测试。
|
||||||
|
- `requirements.txt` 新增 `requests>=2.32,<3`。
|
||||||
|
- `apps/ai/tests.py` 新增 5 条 mock 单测,覆盖 provider 选择、chat 文本请求构造和标题解析、chat 多模态返图解析、images/edits multipart 请求构造和 base64 图片解析、能力不支持错误。
|
||||||
|
- 同步 `docs/api.md`、`docs/03-tech-stack.md`、`docs/current-state.md`、`docs/06-tasks.md`、`README.md`、`docs/00-ai-start-here.md`。
|
||||||
|
- 验证:
|
||||||
|
- 脱敏读取 `D:\chengma\cmbot\config\ai_models.json`:顶层为 `models` 列表,共 3 个模型;仅打印非密钥字段,未暴露真实 key。
|
||||||
|
- `py -3.12 -m pip install -r requirements.txt`:通过,安装 `requests 2.34.2` 及依赖。
|
||||||
|
- `py -3.12 manage.py test apps.ai`:通过,5 tests OK。
|
||||||
|
- `py -3.12 manage.py test`:通过,7 tests OK。
|
||||||
|
- `py -3.12 manage.py check`:通过,0 issues。
|
||||||
|
- `py -3.12 manage.py makemigrations --check`:通过,No changes detected。
|
||||||
|
- `py -3.12 -m compileall apps`:通过。
|
||||||
|
- `./init.ps1`:通过,依赖同步含 `requests`,基础检查正常。
|
||||||
|
- 阻塞:无。
|
||||||
|
- 决策:T-101 不创建 AiModel/ModelAlias 数据表、不做别名解析数据库读取、不接计费;先用 `ResolvedModel` dataclass 承接后续 T-102 的数据库模型。
|
||||||
|
- 下一步:领取 T-102 AiModel + ModelAlias 模型 + 别名解析。
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ Django>=5.2,<5.3
|
|||||||
djangorestframework>=3.16,<3.17
|
djangorestframework>=3.16,<3.17
|
||||||
PyMySQL>=1.1,<1.2
|
PyMySQL>=1.1,<1.2
|
||||||
cryptography>=42,<47
|
cryptography>=42,<47
|
||||||
|
requests>=2.32,<3
|
||||||
|
|||||||
Reference in New Issue
Block a user