feat: add public model catalog discovery

This commit is contained in:
QiuSW
2026-07-04 10:14:16 +08:00
parent 3a661afaa5
commit 898329e047
16 changed files with 533 additions and 14 deletions
+1
View File
@@ -86,6 +86,7 @@
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-dashboard' %}">控制台</a>
<a class="btn btn-sm btn-primary" href="{% url 'portal-recharge' %}">充值</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-apikeys' %}">API Key</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-models' %}">可用模型</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-recharge-records' %}">充值记录</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-usage-records' %}">消费记录</a>
<form method="post" action="{% url 'portal-logout' %}">
+73
View File
@@ -0,0 +1,73 @@
{% extends "portal/base.html" %}
{% block title %}可用模型 - cmhub{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<h1 class="h3 mb-0">可用模型</h1>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-apikeys' %}">API Key</a>
</div>
<div class="cmhub-surface">
{% if models %}
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead>
<tr>
<th scope="col">别名</th>
<th scope="col">类型</th>
<th scope="col">能力</th>
<th scope="col">原图</th>
<th scope="col">价格</th>
</tr>
</thead>
<tbody>
{% for item in models %}
<tr>
<td><code>{{ item.alias }}</code></td>
<td>
{% if item.operation_type == "title" %}
生成标题
{% elif item.operation_type == "image" %}
生成图片
{% else %}
{{ item.operation_type }}
{% endif %}
</td>
<td>
{% for capability in item.capabilities %}
<span class="badge text-bg-light border">{{ capability }}</span>
{% empty %}
<span class="text-muted">无</span>
{% endfor %}
</td>
<td>
{% if item.requires_image %}
<span class="badge text-bg-warning">需要</span>
{% else %}
<span class="badge text-bg-light border">不需要</span>
{% endif %}
</td>
<td>
{% if item.prices %}
<div class="d-flex flex-wrap gap-2">
{% for price in item.prices %}
<span class="badge text-bg-light border">
{% if price.resolution == "default" %}默认{% else %}{{ price.resolution }}{% endif %}:{{ price.points_cost }} 点
</span>
{% endfor %}
</div>
{% else %}
<span class="badge text-bg-secondary">暂未定价</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">暂无可用模型</p>
{% endif %}
</div>
{% endblock %}
+87 -1
View File
@@ -10,7 +10,14 @@ from django.test import Client, TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from apps.billing.models import CallRecord, ExchangeRate, PointsLedger, RechargeOrder
from apps.ai.models import AiModel, ModelAlias
from apps.billing.models import (
CallRecord,
ExchangeRate,
PointsLedger,
PricingRule,
RechargeOrder,
)
from apps.users.models import ApiKey, UserWallet
@@ -77,6 +84,32 @@ class PortalAccountFlowTests(TestCase):
status=status,
)
def create_model_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",
) -> 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=True,
)
return ModelAlias.objects.create(
alias=alias,
operation_type=operation_type,
ai_model=ai_model,
)
def test_signup_creates_unverified_user_wallet_with_zero_points_and_no_ledger(self):
suffix = uuid.uuid4().hex[:8]
email = f"signup-{suffix}@example.com"
@@ -164,6 +197,59 @@ class PortalAccountFlowTests(TestCase):
self.assertEqual(response.status_code, 302)
self.assertTrue(response["Location"].startswith("/login?next="))
def test_models_page_requires_session_login(self):
response = self.client.get("/models")
self.assertEqual(response.status_code, 302)
self.assertTrue(response["Location"].startswith("/login?next="))
def test_models_page_lists_public_aliases_prices_and_unpriced_state(self):
user = self.create_verified_user()
title_alias = self.create_model_alias(alias="title-standard", capabilities=["text"])
image_alias = self.create_model_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="1k",
points_cost=12,
is_active=False,
)
self.client.force_login(user)
response = self.client.get("/models")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["models"]), 2)
self.assertContains(response, "可用模型")
self.assertContains(response, "title-standard")
self.assertContains(response, "生成标题")
self.assertContains(response, "默认")
self.assertContains(response, "2 点")
self.assertContains(response, "image-edit")
self.assertContains(response, "生成图片")
self.assertContains(response, "需要")
self.assertContains(response, "暂未定价")
self.assertNotContains(response, "secret-sku")
self.assertNotContains(response, "provider-secret.example")
self.assertNotContains(response, "encrypted-provider-key")
self.assertNotContains(response, "provider-extra-secret")
self.assertNotContains(response, "api_key")
self.assertNotContains(response, "api_key_encrypted")
self.assertNotContains(response, "extra_body")
def test_create_api_key_shows_plaintext_once_and_stores_only_hash(self):
user = self.create_verified_user()
self.client.force_login(user)
+2
View File
@@ -6,6 +6,7 @@ from .views import (
ApiKeyDeleteView,
ApiKeyListCreateView,
DashboardView,
ModelCatalogView,
RechargePageView,
RechargeRecordListView,
UsageRecordListView,
@@ -19,6 +20,7 @@ urlpatterns = [
path("dashboard", DashboardView.as_view(), name="portal-dashboard"),
path("apikeys", ApiKeyListCreateView.as_view(), name="portal-apikeys"),
path("apikeys/<int:pk>/delete", ApiKeyDeleteView.as_view(), name="portal-apikey-delete"),
path("models", ModelCatalogView.as_view(), name="portal-models"),
path("recharge", RechargePageView.as_view(), name="portal-recharge"),
path("records/recharge", RechargeRecordListView.as_view(), name="portal-recharge-records"),
path("records/usage", UsageRecordListView.as_view(), name="portal-usage-records"),
+10
View File
@@ -7,6 +7,7 @@ from django.urls import reverse, reverse_lazy
from django.views import View
from django.views.generic import FormView, TemplateView
from apps.ai.catalog import get_public_model_catalog
from apps.billing.payment_gateways import PaymentOrderCreateError
from apps.billing.pricing import NoExchangeRateError
from apps.billing.models import PointsLedger, RechargeOrder
@@ -139,6 +140,15 @@ class ApiKeyDeleteView(LoginRequiredMixin, View):
return redirect("portal-apikeys")
class ModelCatalogView(LoginRequiredMixin, TemplateView):
template_name = "portal/models.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["models"] = get_public_model_catalog()
return context
class RechargePageView(LoginRequiredMixin, FormView):
template_name = "portal/recharge.html"
form_class = RechargeCreateForm