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) 第四节计费时序。
|
||||
|
||||
|
||||
@@ -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 审核修补项已完成。
|
||||
2. Phase 1:最高风险功能原型 —— 移植 `cmbot` 的 AI 调用并在服务端跑通一次标题/图片生成。
|
||||
2. Phase 1:最高风险功能原型 —— T-101 已移植 `cmbot` 的 AI 调用 provider 层;下一步 T-102/T-104 完成模型配置、别名解析并跑通一次标题/图片生成。
|
||||
3. Phase 2:计费核心 —— User/UserWallet/ApiKey 模型 + 点数扣减(并发安全,锁 Wallet 行)+ 计费规则 + 调用记录。
|
||||
4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。
|
||||
5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| 运营后台 | django-admin | 已定 | 近零代码即得用户/点数/记录的增删改查与检索,省 80% 后台工作量 |
|
||||
| 用户端 | Django 模板 SSR + Bootstrap 5 + django-allauth + crispy-forms | 已定 | 自助注册/登录/充值/API Key 管理/记录页;allauth 出注册登录邮箱验证,crispy + 现成 Bootstrap 模板出页面,单体不引前端框架 |
|
||||
| 后台美化 | 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` |
|
||||
| 图片结果存储 | 对象存储(S3 兼容 / 本地存储)返回 URL | 待定 | 同步响应默认返回 `image_url`,避免大 base64 进响应体 |
|
||||
| 配置变更审计 | django-admin LogEntry 或自建审计表 | 待定 | 模型/别名/密钥变更留痕,与「账目对得上」一致 |
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
|
||||
| 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-103 | 配置变更审计 | T-102 | AiModel/ModelAlias/密钥的后台变更留痕(谁、何时、改了什么);可在 admin 查看 | 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 generate_text(self, prompt: str, model: ResolvedModel,
|
||||
image: bytes | None = None,
|
||||
image_mime_type: str = "image/png",
|
||||
resolution: str = "1K",
|
||||
parameters: dict | None = None) -> list[str]: ...
|
||||
def generate_image(self, prompt: str, model: ResolvedModel, image: bytes,
|
||||
parameters: dict | 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: dict | None = None) -> bytes | str: ...
|
||||
parameters: dict | None = None) -> ImageGenerationResult: ...
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- `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`)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。
|
||||
- `parameters` 为供应商特有参数透传;适配器负责把统一入参翻译成各家上游格式。
|
||||
- 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。
|
||||
|
||||
+10
-10
@@ -12,10 +12,10 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-02
|
||||
- 阶段:Phase 0 地基与审核修补已完成;下一步进入 Phase 1 的 T-101
|
||||
- 技术栈:系统 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`
|
||||
- 生产代码:已有最小 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`
|
||||
- 测试:`makemigrations --check` 通过;`migrate` 通过;`manage.py check` 通过;`manage.py test` 通过(2 条 admin smoke tests);`./init.ps1` 通过
|
||||
- 阶段: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 + 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`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器)
|
||||
- 测试:`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`)
|
||||
- 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
|
||||
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test`
|
||||
@@ -31,9 +31,9 @@
|
||||
| `AGENTS.md` / `CLAUDE.md` | 已有 | 仓库级入口 |
|
||||
| `progress.md` | 已有 | 执行流水,已记录多轮文档决策;后续任务继续追加 |
|
||||
| `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 |
|
||||
| `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 创建 |
|
||||
| `tests/` | 待建 | 随各任务补充 |
|
||||
|
||||
@@ -41,10 +41,10 @@
|
||||
|
||||
任务状态以 [`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:无。
|
||||
- 下一个可领取任务:**T-101 Provider 适配器层 + 移植 cmbot 调用**。
|
||||
- 下一个可领取任务:**T-102 AiModel + ModelAlias 模型 + 别名解析**。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
@@ -62,14 +62,14 @@ python3.12 manage.py test
|
||||
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`。
|
||||
2. 读 `docs/00-ai-start-here.md`。
|
||||
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`。
|
||||
|
||||
## 维护规则
|
||||
|
||||
+23
@@ -266,3 +266,26 @@
|
||||
- 阻塞:无。
|
||||
- 决策:保留 `requirements.txt` 作为运行依赖来源,`pyproject.toml` 只承载 Python 版本元数据;`.env.example` 使用占位符,不提交 `.env`。
|
||||
- 下一步:领取 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
|
||||
PyMySQL>=1.1,<1.2
|
||||
cryptography>=42,<47
|
||||
requests>=2.32,<3
|
||||
|
||||
Reference in New Issue
Block a user