From 898329e0472cc219feaf86d8f9acd9ce6c41976b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 4 Jul 2026 10:14:16 +0800 Subject: [PATCH] feat: add public model catalog discovery --- README.md | 2 +- apps/ai/catalog.py | 93 ++++++++++++++ apps/api/tests.py | 152 ++++++++++++++++++++++- apps/api/urls.py | 2 + apps/api/views.py | 9 ++ apps/portal/templates/portal/base.html | 1 + apps/portal/templates/portal/models.html | 73 +++++++++++ apps/portal/tests.py | 88 ++++++++++++- apps/portal/urls.py | 2 + apps/portal/views.py | 10 ++ docs/00-ai-start-here.md | 3 +- docs/06-tasks.md | 7 ++ docs/api.md | 33 +++++ docs/current-state.md | 23 ++-- docs/routes.md | 3 + progress.md | 46 +++++++ 16 files changed, 533 insertions(+), 14 deletions(-) create mode 100644 apps/ai/catalog.py create mode 100644 apps/portal/templates/portal/models.html diff --git a/README.md b/README.md index ef9efcf..467d81f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR ## 当前状态 -Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档:用户可通过 allauth 自助注册、邮箱验证、登录、登出,扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录与分页消费记录;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水和调用记录,并通过计费层带原因手工调点;生产部署按 `docs/deployment.md` 执行。计划内 MVP 任务已完成,下一步是按部署文档上 VPS 配置真实邮件、支付、AI 模型和图片真实耗时验证。详见 [`docs/current-state.md`](docs/current-state.md)。 +Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现(`GET /api/v1/models` + portal 只读「可用模型」页):用户可通过 allauth 自助注册、邮箱验证、登录、登出,扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页消费记录与可用模型;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水和调用记录,并通过计费层带原因手工调点;生产部署按 `docs/deployment.md` 执行。计划内 MVP 任务和 T-601 增强任务已完成,同时生产侧仍需配置真实邮件、支付、AI 模型和图片真实耗时验证。详见 [`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) 第四节计费时序。 diff --git a/apps/ai/catalog.py b/apps/ai/catalog.py new file mode 100644 index 0000000..af19203 --- /dev/null +++ b/apps/ai/catalog.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from collections import defaultdict + +from apps.ai.aliases import REQUIRED_CAPABILITIES +from apps.ai.models import AiModel, ModelAlias +from apps.ai.providers import AiProviderConfigError, resolve_api_type +from apps.billing.models import PricingRule + + +PRICING_STATUS_PRICED = "priced" +PRICING_STATUS_UNPRICED = "unpriced" +PUBLIC_DEFAULT_RESOLUTION = "default" + + +def _has_required_capability(model_alias: ModelAlias) -> bool: + required_capability = REQUIRED_CAPABILITIES.get(model_alias.operation_type) + if required_capability is None: + return False + return required_capability in model_alias.ai_model.capabilities_set() + + +def _requires_image_input(ai_model: AiModel, operation_type: str) -> bool: + if operation_type != ModelAlias.OperationType.IMAGE: + return False + try: + resolved_api_type = resolve_api_type(ai_model.api_type, ai_model.url) + except AiProviderConfigError: + return False + return resolved_api_type == AiModel.ApiType.IMAGES_EDITS + + +def _pricing_rule_to_public_price(rule: PricingRule) -> dict: + return { + "resolution": rule.resolution or PUBLIC_DEFAULT_RESOLUTION, + "points_cost": rule.points_cost, + } + + +def _sort_public_prices(prices: list[dict]) -> list[dict]: + return sorted( + prices, + key=lambda price: ( + price["resolution"] != PUBLIC_DEFAULT_RESOLUTION, + price["resolution"], + ), + ) + + +def get_public_model_catalog() -> list[dict]: + aliases = [ + model_alias + for model_alias in ModelAlias.objects.select_related("ai_model") + .filter(is_active=True, ai_model__is_active=True) + .order_by("operation_type", "alias", "id") + if _has_required_capability(model_alias) + ] + if not aliases: + return [] + + alias_keys = {(item.operation_type, item.alias) for item in aliases} + prices_by_alias = defaultdict(list) + pricing_rules = PricingRule.objects.filter( + is_active=True, + operation_type__in={operation for operation, _alias in alias_keys}, + alias__in={alias for _operation, alias in alias_keys}, + ).order_by("operation_type", "alias", "resolution", "id") + + for rule in pricing_rules: + key = (rule.operation_type, rule.alias) + if key in alias_keys: + prices_by_alias[key].append(_pricing_rule_to_public_price(rule)) + + catalog = [] + for model_alias in aliases: + key = (model_alias.operation_type, model_alias.alias) + prices = _sort_public_prices(prices_by_alias.get(key, [])) + catalog.append( + { + "alias": model_alias.alias, + "operation_type": model_alias.operation_type, + "capabilities": sorted(model_alias.ai_model.capabilities_set()), + "requires_image": _requires_image_input( + model_alias.ai_model, + model_alias.operation_type, + ), + "pricing_status": ( + PRICING_STATUS_PRICED if prices else PRICING_STATUS_UNPRICED + ), + "prices": prices, + } + ) + return catalog diff --git a/apps/api/tests.py b/apps/api/tests.py index d4bfcbe..5c0d619 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -18,7 +18,8 @@ from rest_framework.test import APIClient from rest_framework.views import APIView from apps.api.authentication import ApiKeyAuthentication -from apps.api.views import ExternalApiView +from apps.api.throttles import GenerateRateThrottle +from apps.api.views import ExternalApiView, ModelsView from apps.ai.models import AiModel, ModelAlias from apps.ai.providers import ( AiCapabilityError, @@ -239,6 +240,155 @@ class BalanceApiTests(TestCase): self.assertEqual(response.data["error"]["code"], "unauthorized") +class ModelsCatalogApiTests(TestCase): + url = "/api/v1/models" + + def setUp(self): + cache.clear() + suffix = uuid.uuid4().hex[:8] + self.user = get_user_model().objects.create_user( + username=f"models-user-{suffix}", + email=f"models-user-{suffix}@example.com", + password="password", + ) + self.api_key, self.raw_key = ApiKey.create_for_user(self.user, name="models") + self.client = APIClient() + + def auth_header(self, raw_key: str | None = None) -> dict: + return {"HTTP_AUTHORIZATION": f"Bearer {raw_key or self.raw_key}"} + + def create_alias( + self, + *, + alias: str, + operation_type: str = ModelAlias.OperationType.TITLE, + capabilities: list[str] | None = None, + api_type: str = AiModel.ApiType.CHAT, + url: str = "https://provider-secret.example/v1/chat/completions", + model_sku: str = "secret-sku-gpt-5.5", + model_active: bool = True, + alias_active: bool = True, + ) -> ModelAlias: + ai_model = AiModel.objects.create( + name=f"{alias}-{uuid.uuid4().hex[:8]}", + url=url, + model=model_sku, + api_type=api_type, + api_key_encrypted="encrypted-provider-key", + capabilities=capabilities if capabilities is not None else ["text"], + extra_body={"internal": "provider-extra-secret"}, + is_active=model_active, + ) + return ModelAlias.objects.create( + alias=alias, + operation_type=operation_type, + ai_model=ai_model, + is_active=alias_active, + ) + + def test_models_returns_public_alias_catalog_without_internal_fields(self): + title_alias = self.create_alias(alias="title-standard", capabilities=["text"]) + image_alias = self.create_alias( + alias="image-edit", + operation_type=ModelAlias.OperationType.IMAGE, + capabilities=["image", "vision"], + api_type=AiModel.ApiType.IMAGES_EDITS, + url="https://provider-secret.example/v1/images/edits", + model_sku="secret-sku-image-2", + ) + PricingRule.objects.create( + operation_type=title_alias.operation_type, + alias=title_alias.alias, + resolution="", + points_cost=2, + ) + PricingRule.objects.create( + operation_type=image_alias.operation_type, + alias=image_alias.alias, + resolution="", + points_cost=10, + ) + PricingRule.objects.create( + operation_type=image_alias.operation_type, + alias=image_alias.alias, + resolution="1k", + points_cost=12, + ) + + response = self.client.get(self.url, **self.auth_header()) + + self.assertEqual(response.status_code, 200) + self.assertNotIn(GenerateRateThrottle, ModelsView.throttle_classes) + models = {item["alias"]: item for item in response.data["models"]} + self.assertEqual(set(models), {"title-standard", "image-edit"}) + self.assertEqual( + set(models["title-standard"]), + { + "alias", + "operation_type", + "capabilities", + "requires_image", + "pricing_status", + "prices", + }, + ) + self.assertEqual(models["title-standard"]["operation_type"], "title") + self.assertEqual(models["title-standard"]["capabilities"], ["text"]) + self.assertFalse(models["title-standard"]["requires_image"]) + self.assertEqual(models["title-standard"]["pricing_status"], "priced") + self.assertEqual( + models["title-standard"]["prices"], + [{"resolution": "default", "points_cost": 2}], + ) + self.assertEqual(models["image-edit"]["capabilities"], ["image", "vision"]) + self.assertTrue(models["image-edit"]["requires_image"]) + self.assertEqual( + models["image-edit"]["prices"], + [ + {"resolution": "default", "points_cost": 10}, + {"resolution": "1K", "points_cost": 12}, + ], + ) + response_body = json.dumps(response.data, ensure_ascii=False) + self.assertNotIn("secret-sku", response_body) + self.assertNotIn("provider-secret.example", response_body) + self.assertNotIn("encrypted-provider-key", response_body) + self.assertNotIn("provider-extra-secret", response_body) + self.assertNotIn("api_key", response_body) + self.assertNotIn("api_key_encrypted", response_body) + self.assertNotIn("extra_body", response_body) + self.assertNotIn("url", response_body) + self.assertNotIn("model_used", response_body) + + def test_models_rejects_missing_invalid_and_session_only_authentication(self): + missing = self.client.get(self.url) + invalid = self.client.get(self.url, **self.auth_header("sk_cmhub_invalid")) + self.client.force_login(self.user) + session_only = self.client.get(self.url) + + self.assertEqual(missing.status_code, 401) + self.assertEqual(missing.data["error"]["code"], "unauthorized") + self.assertEqual(invalid.status_code, 401) + self.assertEqual(invalid.data["error"]["code"], "unauthorized") + self.assertEqual(session_only.status_code, 401) + self.assertEqual(session_only.data["error"]["code"], "unauthorized") + + def test_models_only_lists_callable_active_aliases_and_allows_unpriced_alias(self): + self.create_alias(alias="title-unpriced", capabilities=["text"]) + self.create_alias(alias="title-inactive-alias", alias_active=False) + self.create_alias(alias="title-inactive-model", model_active=False) + self.create_alias(alias="title-wrong-capability", capabilities=["image"]) + + response = self.client.get(self.url, **self.auth_header()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["models"]), 1) + item = response.data["models"][0] + self.assertEqual(item["alias"], "title-unpriced") + self.assertEqual(item["pricing_status"], "unpriced") + self.assertEqual(item["prices"], []) + + @override_settings( PAYMENT_CALLBACK_MODE="mock", PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret", diff --git a/apps/api/urls.py b/apps/api/urls.py index a7288d1..7d985cf 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -5,6 +5,7 @@ from .views import ( BalanceView, GenerateImageView, GenerateTitleView, + ModelsView, RechargeCreateView, RechargeStatusView, WechatRechargeCallbackView, @@ -12,6 +13,7 @@ from .views import ( urlpatterns = [ path("v1/balance", BalanceView.as_view(), name="api-balance"), + path("v1/models", ModelsView.as_view(), name="api-models"), path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"), path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"), path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"), diff --git a/apps/api/views.py b/apps/api/views.py index b913677..bad60cd 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -25,6 +25,7 @@ from apps.api.serializers import ( RechargeStatusRequestSerializer, ) from apps.api.throttles import GenerateRateThrottle, throttle_api_auth_failure +from apps.ai.catalog import get_public_model_catalog from apps.billing.models import RechargeOrder from apps.billing.payment_gateways import ( PaymentOrderCreateError, @@ -117,6 +118,14 @@ class BalanceView(ExternalApiView): ) +class ModelsView(ExternalApiView): + def get(self, request): + return Response( + {"models": get_public_model_catalog()}, + status=status.HTTP_200_OK, + ) + + class PortalSessionApiView(APIView): authentication_classes = (SessionAuthentication,) permission_classes = (IsAuthenticated,) diff --git a/apps/portal/templates/portal/base.html b/apps/portal/templates/portal/base.html index 7872dce..01130e3 100644 --- a/apps/portal/templates/portal/base.html +++ b/apps/portal/templates/portal/base.html @@ -86,6 +86,7 @@ 控制台 充值 API Key + 可用模型 充值记录 消费记录