feat: add ai provider adapters

This commit is contained in:
QiuSW
2026-07-02 10:33:15 +08:00
parent 09811ead5c
commit c9fbabb04f
14 changed files with 1072 additions and 20 deletions
+1 -1
View File
@@ -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) 第四节计费时序。
+26
View File
@@ -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",
]
+128
View File
@@ -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
+376
View File
@@ -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))
+56
View File
@@ -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)
+262
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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 管理、个人中心/记录页、充值页。
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 模型 + 别名解析。
+1
View File
@@ -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