"""What AI Labs offers, in code.
This module is the single source of truth for every model id, every option
value and every limit the surface exposes. It is code rather than a table
for the same reason the achievement catalogue is: the *meaning* of an entry
lives in the executor that reads it, so a DB row could never be the whole
definition, and a config table would let production drift from the code that
interprets it.
**Every value here was measured against the live Singapore endpoint on the
date its entry records — nothing is copied from documentation.** That
discipline earned its keep twice while this file was being written:
* ``wan3.0-video-prime`` **silently ignores the ``size`` parameter.** Three
submissions on 2026-09-03 asking for ``1280*720``, ``960*960`` and
``720*1280`` all succeeded and all reported an output ratio unrelated to
the request (``1:1``, ``16:9``, ``16:9`` respectively), with ``SR`` pinned
at 1080 every time. So this catalogue exposes **no aspect-ratio control**
for video: shipping a dropdown that changes nothing would be worse than
shipping none. ``resolution`` *is* honoured (``480P`` → SR 480, ``1080P``
→ SR 1080), so that is the one quality knob offered.
* Half the plausible model ids were returning ``403
AllocationQuota.FreeTierOnly`` because of a per-model console switch, not
because they were unavailable. Picking "a stable older model" from the
docs would have shipped a dead capability. See
:mod:`app.domains.bailian.media_client` for the full story.
``server/scripts/verify_ai_labs_catalog.py`` re-checks this file against the
configured key and prints a per-entry verdict. Run it when adding an entry,
and after any Bailian console change.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from app.domains.bailian.media_client import (
PATH_MULTIMODAL,
PATH_VIDEO,
)
#: Bump when an entry is added, removed or re-pointed. Surfaced by the admin
#: console so an operator can tell which catalogue a deployment is serving.
CATALOG_VERSION = "2026-09-03.2"
# ── chat models ────────────────────────────────────────────────
#: Tier drives ordering and the badge the UI shows. Not a capability claim.
TIER_FLAGSHIP = "flagship"
TIER_FAST = "fast"
TIER_ALTERNATIVE = "alternative"
@dataclass(frozen=True)
class ChatModel:
"""One selectable chat model."""
#: Stable key stored on conversations. Re-pointing ``vendor_model`` later
#: keeps existing threads working; renaming a key would orphan them.
key: str
vendor_model: str
tier: str
#: Who made it — the UI groups by this so "other vendors' models" is an
#: honest label rather than everything looking like Qwen.
vendor: str
#: Verified by sending a real image part, not assumed from the name.
supports_vision: bool
#: Whether the vendor accepts ``enable_thinking``.
supports_thinking: bool
#: Our default for the toggle. False everywhere it is supported: the
#: qwen3.7 family defaults to deep thinking and takes 15-18s to emit a
#: first token, which reads as a broken page.
default_thinking: bool
verified_on: str
#: Rough first-token latency measured on a one-line prompt, in seconds.
#: Shown as a hint so an employee can pick "fast" deliberately.
measured_ttfb_seconds: float | None = None
CHAT_MODELS: tuple[ChatModel, ...] = (
ChatModel(
key="qwen3.8-max", vendor_model="qwen3.8-max",
tier=TIER_FLAGSHIP, vendor="qwen",
supports_vision=True, supports_thinking=True, default_thinking=False,
verified_on="2026-09-03", measured_ttfb_seconds=2.3,
),
ChatModel(
key="qwen3.8-flash", vendor_model="qwen3.8-flash",
tier=TIER_FAST, vendor="qwen",
supports_vision=True, supports_thinking=True, default_thinking=False,
verified_on="2026-09-03", measured_ttfb_seconds=3.8,
),
ChatModel(
key="qwen3-max", vendor_model="qwen3-max",
tier=TIER_ALTERNATIVE, vendor="qwen",
supports_vision=True, supports_thinking=True, default_thinking=False,
verified_on="2026-09-03", measured_ttfb_seconds=2.1,
),
ChatModel(
key="qwen3-vl-plus", vendor_model="qwen3-vl-plus",
tier=TIER_ALTERNATIVE, vendor="qwen",
supports_vision=True, supports_thinking=True, default_thinking=False,
verified_on="2026-09-03",
),
ChatModel(
key="deepseek-v4-pro", vendor_model="deepseek-v4-pro",
tier=TIER_ALTERNATIVE, vendor="deepseek",
supports_vision=True, supports_thinking=True, default_thinking=False,
verified_on="2026-09-03", measured_ttfb_seconds=11.4,
),
ChatModel(
key="glm-5.2", vendor_model="glm-5.2",
tier=TIER_ALTERNATIVE, vendor="zhipu",
supports_vision=True, supports_thinking=True, default_thinking=False,
verified_on="2026-09-03", measured_ttfb_seconds=16.5,
),
ChatModel(
key="kimi-k3", vendor_model="kimi-k3",
tier=TIER_ALTERNATIVE, vendor="moonshot",
supports_vision=True, supports_thinking=True, default_thinking=False,
verified_on="2026-09-03", measured_ttfb_seconds=10.4,
),
)
CHAT_MODELS_BY_KEY: dict[str, ChatModel] = {m.key: m for m in CHAT_MODELS}
#: What a new conversation gets. Flagship, thinking off — fastest first
#: token of the strong models (2.3s measured).
DEFAULT_CHAT_MODEL_KEY = "qwen3.8-max"
def chat_model(key: str | None) -> ChatModel:
"""Resolve a stored/requested key, falling back to the default.
Never raises: a conversation whose model was removed from the catalogue
must keep opening, and an unknown key from a client is a validation
concern for the router, not a reason to 500 a history read.
"""
if key and key in CHAT_MODELS_BY_KEY:
return CHAT_MODELS_BY_KEY[key]
return CHAT_MODELS_BY_KEY[DEFAULT_CHAT_MODEL_KEY]
# ── generation capabilities ────────────────────────────────────
CAP_IMAGE = "image"
CAP_IMAGE_EDIT = "image_edit"
CAP_VIDEO = "video"
CAP_SPEECH = "speech"
CAP_TRANSCRIBE = "transcribe"
#: How the executor talks to the vendor for a capability.
TRANSPORT_SYNC = "sync_multimodal"
TRANSPORT_ASYNC = "async_task"
#: What lands on the row when the job succeeds.
OUTPUT_IMAGE = "image"
OUTPUT_VIDEO = "video"
OUTPUT_AUDIO = "audio"
OUTPUT_TEXT = "text"
#: What the employee may attach as input.
INPUT_NONE = "none"
INPUT_IMAGE = "image"
INPUT_AUDIO = "audio"
@dataclass(frozen=True)
class Option:
"""One selectable value of a generation parameter."""
value: Any
#: Suffix of the i18n key the frontend renders
#: (``aiLabs.option..