Files
cowork-local/core/routing/models.py
T
anhtnm1andClaude Opus 5 e29a0ccdbd refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%
Hồi quy đã vá
-------------
F-12  Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
      `chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
      `composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
      tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
      là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
      gắn 1 tệp, khớp bản trước refactor.

F-01  Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
      `folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
      `ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
      tiên hỏng thì đổi provider chính là lúc phải thử lại.

F-07  Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
      lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
      người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
      theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
      mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
      `check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.

F-11  Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
      mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
      và không bao giờ chạy.

Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:

    graph_renderer.py         -> graph_scene_builder.py + graph_export.py
    co4e_workflow_service.py  -> co4e_run_history.py
    json_config_repository.py -> config_sections.py
    agents_admin_tab.py       -> shared/agent_kind_visuals.py

File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.

Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.

Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.

859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:41:45 +09:00

216 lines
7.4 KiB
Python

"""Pydantic v2 data models for Auto Model Assessment & Routing.
These are the provider-agnostic shapes shared by every routing module — the
enricher, prober, scorer, selector and switch-controller all speak in terms of
these. They serialize cleanly to/from JSON so the assessment store and the
app config (``~/.cowork_local/…``) can round-trip them.
Terminology
-----------
* A **candidate** is a ``(provider, model_id)`` pair the app can call.
* An **assessment** is what we learned about one candidate: its static
metadata, the dynamic probe results per task type, and the derived
``fit_scores`` per task type.
* A **task type** is the kind of work a message represents (qa / coding / …).
* A **policy** is how we weigh quality vs cost vs latency when scoring.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Dict, List, Optional, Set
from pydantic import BaseModel, Field
# --------------------------------------------------------------------------- #
# Enums
# --------------------------------------------------------------------------- #
class TaskType(str, Enum):
"""The kinds of work a chat/agent turn can represent.
A message is classified into exactly one of these before routing (see
``classifier.py``). ``BENCHMARK_TASKS`` in the prober has one fixed prompt
per value so every candidate model is compared on the same yardstick.
"""
QA = "qa"
CODING = "coding"
REASONING = "reasoning"
SUMMARIZATION = "summarization"
CREATIVE = "creative"
class Policy(str, Enum):
"""How to trade off quality, cost and latency when scoring a model."""
QUALITY = "quality"
COST = "cost"
LATENCY = "latency"
BALANCED = "balanced"
class SwitchMode(str, Enum):
"""Per-surface routing behaviour, driven by the Off/Auto/Manual toggle.
* ``OFF`` — routing disabled; always use the manually-selected model.
* ``AUTO`` — silently switch to the best model when it clears the gain
threshold, then run the task.
* ``MANUAL`` — propose the switch and wait for the user to confirm before
running with the new model.
"""
OFF = "off"
AUTO = "auto"
MANUAL = "manual"
class SwitchStatus(str, Enum):
"""Lifecycle of a :class:`PendingSwitch` awaiting user confirmation."""
PENDING = "pending"
CONFIRMED = "confirmed"
REJECTED = "rejected"
EXPIRED = "expired"
# --------------------------------------------------------------------------- #
# Static metadata + dynamic probe
# --------------------------------------------------------------------------- #
class ModelMetadata(BaseModel):
"""Static, mostly-price/capability facts about one candidate model.
``cost_per_1k_*`` are USD per 1,000 tokens. They are ``None`` — not a
guess — when the price is genuinely unknown; ``metadata_incomplete`` is
then set True so the scorer/UI can flag it rather than silently trusting a
fabricated number (see ``metadata.py``).
"""
provider: str
model_id: str
tier: Optional[str] = None # e.g. "fast" | "powerful" — free-form, user-supplied
cost_per_1k_input: Optional[float] = None
cost_per_1k_output: Optional[float] = None
max_context: Optional[int] = None
capabilities: Set[str] = Field(default_factory=set) # e.g. {"vision", "tools"}
available: bool = True
metadata_incomplete: bool = False
@property
def key(self) -> str:
"""Stable ``provider/model_id`` identity used as a dict key everywhere."""
return candidate_key(self.provider, self.model_id)
@property
def avg_cost_per_1k(self) -> Optional[float]:
"""Blended input/output price, or None if either side is unknown.
A rough 1:3 input:output ratio (typical chat workload) is used so a
single scalar can feed the cost term of the fit score.
"""
ci, co = self.cost_per_1k_input, self.cost_per_1k_output
if ci is None or co is None:
return None
return (ci + 3.0 * co) / 4.0
class ProbeResult(BaseModel):
"""Outcome of running one benchmark task against one model.
``success=False`` means the call itself failed (network/auth/model error);
``error`` then holds a human-readable reason and ``quality_score`` stays 0.
"""
latency_ms: float = 0.0
success: bool = False
quality_score: float = 0.0 # 0..1, from the judge model
tokens_out: int = 0
error: Optional[str] = None
class ModelAssessment(BaseModel):
"""Everything we know about one candidate after an assessment run."""
metadata: ModelMetadata
# Keyed by TaskType.value (JSON-friendly string keys).
probes: Dict[str, ProbeResult] = Field(default_factory=dict)
fit_scores: Dict[str, float] = Field(default_factory=dict)
assessed_at: Optional[str] = None # ISO-8601 UTC timestamp
@property
def key(self) -> str:
"""Khoá định danh của model được chấm điểm (provider + model id)."""
return self.metadata.key
def fit_for(self, task_type: TaskType) -> float:
"""Fit score for ``task_type`` (0.0 if this model was never scored for it)."""
return float(self.fit_scores.get(task_type.value, 0.0))
# --------------------------------------------------------------------------- #
# Switch decision + pending confirmation
# --------------------------------------------------------------------------- #
class SwitchDecision(BaseModel):
"""The verdict of comparing the current model against the selector's best.
``should_switch`` is False when routing is Off, when the best candidate IS
the current model, or when the score gain is below ``min_score_gain``.
"""
should_switch: bool
from_model: Optional[str] = None # candidate key, or None if nothing active yet
to_model: Optional[str] = None
from_score: float = 0.0
to_score: float = 0.0
score_gain: float = 0.0
reason: str = ""
mode: SwitchMode = SwitchMode.OFF
task_type: Optional[str] = None
class PendingSwitch(BaseModel):
"""A Manual-mode switch proposal held until the user confirms/rejects.
Stored in-memory with a TTL; ``result`` caches the executed task output so
a repeated confirm of the same ``request_id`` is idempotent (returns the
cached result instead of running the task twice).
"""
request_id: str
task_payload: Dict[str, Any] = Field(default_factory=dict)
decision: SwitchDecision
created_at: float # epoch seconds (monotonic wall clock at creation)
expires_at: float
status: SwitchStatus = SwitchStatus.PENDING
result: Optional[Dict[str, Any]] = None # cached task result once executed
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def candidate_key(provider: str, model_id: str) -> str:
"""The canonical ``provider/model_id`` string used as a dict key."""
return f"{provider}/{model_id}"
def split_key(key: str) -> tuple[str, str]:
"""Inverse of :func:`candidate_key`. Splits on the first ``/`` only, so a
model id that itself contains ``/`` (some gateways use ``org/model``) is
preserved intact."""
provider, _, model_id = key.partition("/")
return provider, model_id
__all__ = [
"TaskType",
"Policy",
"SwitchMode",
"SwitchStatus",
"ModelMetadata",
"ProbeResult",
"ModelAssessment",
"SwitchDecision",
"PendingSwitch",
"candidate_key",
"split_key",
]