Configure model priority and quality profiles (#30)
Set Gemini 3.6 Flash as the default, align the fallback and usage display order, and tune per-model quality profiles.
完整差異
@@ -94,10 +94,10 @@ python -m http.server -d site 8000\n | `ANTHROPIC_API_KEY` | — | Anthropic 撰稿(選用) |\n | `GEMINI_API_KEY` | — | Google Gemini 撰稿(選用,免費層) |\n | `NVIDIA_API_KEY` | — | NVIDIA NIM 撰稿(選用,免費額度) |\n-| `AVWIRE_PROVIDER_ORDER` | `anthropic,gemini,nvidia` | 撰稿優先鏈,主力在前;token 可用 `平台:模型` 指定模型,同平台可重複出現(例:`nvidia:nvidia/nemotron-3-ultra-550b-a55b,nvidia:nvidia/nemotron-3-super-120b-a12b,gemini`)。主力模型驗證失敗自動重試一次;帳號/額度失效會跳過同平台所有備援模型 |\n+| `AVWIRE_PROVIDER_ORDER` | `gemini:gemini-3.6-flash,gemini:gemini-3.5-flash,nvidia:z-ai/glm-5.2,nvidia:deepseek-ai/deepseek-v4-pro,nvidia:nvidia/nemotron-3-ultra-550b-a55b,nvidia:qwen/qwen3.5-397b-a17b,nvidia:nvidia/nemotron-3-super-120b-a12b,nvidia:mistralai/mistral-medium-3.5-128b` | 撰稿優先鏈,主力在前;主力模型驗證失敗自動重試一次,之後依序備援;帳號/額度失效會跳過同平台所有備援模型 |\n | `AVWIRE_MODEL` | `claude-opus-5` | Anthropic 模型 |\n-| `AVWIRE_GEMINI_MODEL` | `gemini-2.5-flash` | Gemini 模型 |\n-| `AVWIRE_NVIDIA_MODEL` | `deepseek-ai/deepseek-v3.1` | NVIDIA NIM 模型 |\n+| `AVWIRE_GEMINI_MODEL` | `gemini-3.6-flash` | Gemini 模型 |\n+| `AVWIRE_NVIDIA_MODEL` | `z-ai/glm-5.2` | NVIDIA NIM 模型 |\n | `NEWS_MAX_AGE_HOURS` | `120` | 新聞新鮮度窗口(小時),驗證範圍 24–336,非法值回退預設並警告 |\n | `AEROAPI_KEY` | — | FlightAware AeroAPI(選用統計) |\n | `AVWIRE_BASE_PATH` | `/avwire` | 站台子路徑(Pages 專案站台) |\n@@ -41,6 +41,7 @@\n parse_iso,\n story_category,\n )\n+from model_config import DEFAULT_PROVIDER_ORDER\n \n # Asia/Taipei via zoneinfo where tz data exists (Linux CI); the zone has had a\n # fixed +08:00 offset with no DST since 1980, so a static fallback is exact.\n@@ -1417,12 +1418,26 @@ def _usage_price_for(label: str, prices: dict):\n return None\n \n \n+def _configured_model_priority() -> dict:\n+ configured = (os.environ.get("AVWIRE_PROVIDER_ORDER")\n+ or DEFAULT_PROVIDER_ORDER)\n+ return {\n+ token.strip(): index\n+ for index, token in enumerate(configured.split(","))\n+ if token.strip()\n+ }\n+\n+\n def usage_rows(ledger: dict, prices: dict):\n """Per-model rows + totals with theoretical USD value at list prices."""\n rows = []\n totals = {"calls": 0, "in": 0, "out": 0, "usd": 0.0,\n "unknown": 0, "all_priced": True}\n- for label, m in sorted((ledger.get("models") or {}).items()):\n+ priority = _configured_model_priority()\n+ models = (ledger.get("models") or {}).items()\n+ for label, m in sorted(\n+ models,\n+ key=lambda item: (priority.get(item[0], len(priority)), item[0])):\n if not isinstance(m, dict):\n continue\n calls = int(m.get("calls") or 0)\n@@ -1645,7 +1660,10 @@ def recent_run_view(ledger: dict, now=None) -> dict:\n if row["duration_count"] else None)\n row["average_duration"] = _duration_text(average)\n model_rows.append(row)\n- model_rows.sort(key=lambda row: (-row["attempts"], row["label"]))\n+ priority = _configured_model_priority()\n+ model_rows.sort(\n+ key=lambda row: (\n+ priority.get(row["label"], len(priority)), row["label"]))\n \n totals = [run["durations"]["total"] for run in runs\n if run["durations"]["total"] is not None]\n@@ -0,0 +1,18 @@\n+"""Shared AVWIRE model priority configuration.\n+\n+The first entry is the default writer. The same order drives provider\n+fallback and model ordering on the private usage dashboard.\n+"""\n+\n+MODEL_ORDER = (\n+ "gemini:gemini-3.6-flash",\n+ "gemini:gemini-3.5-flash",\n+ "nvidia:z-ai/glm-5.2",\n+ "nvidia:deepseek-ai/deepseek-v4-pro",\n+ "nvidia:nvidia/nemotron-3-ultra-550b-a55b",\n+ "nvidia:qwen/qwen3.5-397b-a17b",\n+ "nvidia:nvidia/nemotron-3-super-120b-a12b",\n+ "nvidia:mistralai/mistral-medium-3.5-128b",\n+)\n+\n+DEFAULT_PROVIDER_ORDER = ",".join(MODEL_ORDER)\n@@ -20,8 +20,8 @@\n raises ProviderError failure of this one call (truncation,\n bad JSON, transient HTTP) -> try the next provider\n \n-The write stage tries providers in AVWIRE_PROVIDER_ORDER (default\n-"anthropic,gemini,nvidia") and falls through to the next on failure.\n+The write stage tries providers in AVWIRE_PROVIDER_ORDER (defaulting to the\n+configured MODEL_ORDER below) and falls through to the next on failure.\n An order token may pin a model with "name:model", and the same platform\n may appear multiple times with different models, e.g.:\n \n@@ -40,13 +40,14 @@\n import time\n \n import requests\n+from model_config import DEFAULT_PROVIDER_ORDER, MODEL_ORDER\n \n try:\n import anthropic\n except ImportError: # pragma: no cover - anthropic is in requirements.txt\n anthropic = None\n \n-DEFAULT_ORDER = "anthropic,gemini,nvidia"\n+DEFAULT_ORDER = DEFAULT_PROVIDER_ORDER\n # 550B-class NIM models regularly need >120s on the free tier.\n HTTP_TIMEOUT = (10, 180) # (connect, read) seconds\n \n@@ -59,29 +60,47 @@\n # (JSON syntax only - content failures NEVER take this path)\n # Only fields the respective endpoint documents are sent; models without a\n # profile fall back to conservative generic settings.\n-NVIDIA_DEFAULT_MODEL = "nvidia/nemotron-3-ultra-550b-a55b"\n-GEMINI_DEFAULT_MODEL = "gemini-2.5-flash"\n+NVIDIA_DEFAULT_MODEL = "z-ai/glm-5.2"\n+GEMINI_DEFAULT_MODEL = "gemini-3.6-flash"\n \n MODEL_PROFILES: dict = {\n- "nvidia/nemotron-3-ultra-550b-a55b": {\n- # Medium thinking via the official Nemotron chat-template kwargs.\n- "payload": {"temperature": 1.0, "top_p": 0.95,\n- "chat_template_kwargs": {"enable_thinking": True,\n- "medium_effort": True}},\n- "repair": {"chat_template_kwargs": {"enable_thinking": False}},\n+ # Gemini thinking is high for fact-sensitive bilingual drafting. Format\n+ # repair remains minimal because that call may only fix JSON syntax.\n+ # Temperature is intentionally omitted: Gemini 3.x uses its tuned default.\n+ "gemini-3.6-flash": {\n+ "generationConfig": {"maxOutputTokens": 16384,\n+ "thinkingConfig": {"thinkingLevel": "high"}},\n+ "repairGenerationConfig": {\n+ "maxOutputTokens": 8192,\n+ "thinkingConfig": {"thinkingLevel": "minimal"}},\n },\n- "nvidia/nemotron-3-super-120b-a12b": {\n- # Low effort via the official top-level field: the default full\n- # reasoning ate the whole 8192-token budget in live runs.\n- "payload": {"temperature": 1.0, "top_p": 0.95,\n- "reasoning_effort": "low"},\n- "repair": {"reasoning_effort": "none"},\n+ "gemini-3.5-flash": {\n+ "generationConfig": {"maxOutputTokens": 16384,\n+ "thinkingConfig": {"thinkingLevel": "high"}},\n+ "repairGenerationConfig": {\n+ "maxOutputTokens": 8192,\n+ "thinkingConfig": {"thinkingLevel": "minimal"}},\n+ },\n+ "z-ai/glm-5.2": {\n+ # The public NIM schema for GLM exposes no reasoning control.\n+ # Low temperature improves factual consistency and JSON stability.\n+ "reasoningMode": "provider_default",\n+ "payload": {"temperature": 0.1},\n+ "repair": {},\n },\n "deepseek-ai/deepseek-v4-pro": {\n+ # High reasoning is retained for evidence-heavy verification work.\n "payload": {"temperature": 1.0, "top_p": 0.95,\n "reasoning_effort": "high"},\n "repair": {"reasoning_effort": "none"},\n },\n+ "nvidia/nemotron-3-ultra-550b-a55b": {\n+ # Medium thinking balances verification quality with usable output.\n+ "payload": {"temperature": 1.0, "top_p": 0.95,\n+ "chat_template_kwargs": {"enable_thinking": True,\n+ "medium_effort": True}},\n+ "repair": {"chat_template_kwargs": {"enable_thinking": False}},\n+ },\n "qwen/qwen3.5-397b-a17b": {\n # NIM exposes thinking on/off only for Qwen; thinking-mode sampling\n # per the model card.\n@@ -90,38 +109,18 @@\n "chat_template_kwargs": {"enable_thinking": True}},\n "repair": {"chat_template_kwargs": {"enable_thinking": False}},\n },\n- "z-ai/glm-5.2": {\n- # The public NIM schema for GLM exposes no reasoning control;\n- # provider-default reasoning is used deliberately. The marker below\n- # is internal documentation and is never sent on the wire.\n- "reasoningMode": "provider_default",\n- "payload": {"temperature": 0.1},\n- "repair": {},\n+ "nvidia/nemotron-3-super-120b-a12b": {\n+ # Low effort avoids consuming the full output budget before the\n+ # bilingual JSON answer, a failure observed with full reasoning.\n+ "payload": {"temperature": 1.0, "top_p": 0.95,\n+ "reasoning_effort": "low"},\n+ "repair": {"reasoning_effort": "none"},\n },\n "mistralai/mistral-medium-3.5-128b": {\n- # Last fallback: rarely called, quality over latency -> high.\n- # NIM offers none|high only for this model.\n+ # NIM offers none|high only; keep high for the final fallback.\n "payload": {"temperature": 0.7, "reasoning_effort": "high"},\n "repair": {"reasoning_effort": "none"},\n },\n- # No temperature on Gemini 3.x: Google's docs recommend keeping the\n- # tuned defaults (the field is deprecated on newer 3.6-flash builds).\n- # 16384 out: fulltext-enriched groups produce long bilingual bodies,\n- # and thinking tokens count against maxOutputTokens on Gemini.\n- "gemini-3.6-flash": {\n- "generationConfig": {"maxOutputTokens": 16384,\n- "thinkingConfig": {"thinkingLevel": "medium"}},\n- "repairGenerationConfig": {\n- "maxOutputTokens": 8192,\n- "thinkingConfig": {"thinkingLevel": "minimal"}},\n- },\n- "gemini-3.5-flash": {\n- "generationConfig": {"maxOutputTokens": 16384,\n- "thinkingConfig": {"thinkingLevel": "medium"}},\n- "repairGenerationConfig": {\n- "maxOutputTokens": 8192,\n- "thinkingConfig": {"thinkingLevel": "minimal"}},\n- },\n }\n \n REPAIR_SYSTEM_PROMPT = (\n@@ -130,6 +130,28 @@ def fake_post(url, json=None, timeout=None, headers=None):\n print("test_nvidia_model_profiles: done")\n \n \n+def test_model_priority_defaults():\n+ expected = (\n+ "gemini:gemini-3.6-flash",\n+ "gemini:gemini-3.5-flash",\n+ "nvidia:z-ai/glm-5.2",\n+ "nvidia:deepseek-ai/deepseek-v4-pro",\n+ "nvidia:nvidia/nemotron-3-ultra-550b-a55b",\n+ "nvidia:qwen/qwen3.5-397b-a17b",\n+ "nvidia:nvidia/nemotron-3-super-120b-a12b",\n+ "nvidia:mistralai/mistral-medium-3.5-128b",\n+ )\n+ check(providers.MODEL_ORDER == expected,\n+ "model priority order matches the configured product order")\n+ check(providers.DEFAULT_ORDER == ",".join(expected),\n+ "fallback default derives from model priority order")\n+ check(providers.GEMINI_DEFAULT_MODEL == "gemini-3.6-flash",\n+ "Gemini 3.6 Flash is the default model")\n+ check(providers.NVIDIA_DEFAULT_MODEL == "z-ai/glm-5.2",\n+ "GLM 5.2 is the highest-priority NVIDIA default")\n+ print("test_model_priority_defaults: done")\n+\n+\n def test_nvidia_reasoning_trace_never_parsed():\n """reasoning_content is ignored; empty final content is a failure."""\n def fake_post(url, json=None, timeout=None, headers=None):\n@@ -182,7 +204,7 @@ def fake_post(url, json=None, timeout=None, headers=None):\n \n \n def test_gemini_model_profiles():\n- """Gemini 3.6/3.5 send thinkingLevel medium; unknown models send none."""\n+ """Gemini 3.6/3.5 send thinkingLevel high; unknown models send none."""\n for model in ("gemini-3.6-flash", "gemini-3.5-flash"):\n captured = []\n \n@@ -195,8 +217,8 @@ def fake_post(url, json=None, timeout=None, headers=None):\n lambda: provider.draft("sys", "user", SCHEMA))\n check(result == {"ok": 1}, f"{model}: draft parsed")\n config = captured[0]["generationConfig"]\n- check(config.get("thinkingConfig") == {"thinkingLevel": "medium"},\n- f"{model}: thinkingLevel medium")\n+ check(config.get("thinkingConfig") == {"thinkingLevel": "high"},\n+ f"{model}: thinkingLevel high")\n check("temperature" not in config\n and config.get("maxOutputTokens") == 16384,\n f"{model}: no temperature on Gemini 3.x (tuned defaults)")\n@@ -271,6 +293,7 @@ def fake_post(url, json=None, timeout=None, headers=None):\n \n def main():\n tests = [\n+ test_model_priority_defaults,\n test_nvidia_model_profiles,\n test_nvidia_reasoning_trace_never_parsed,\n test_nvidia_format_repair,\n@@ -249,6 +249,17 @@ def json(self):\n prices = json.loads((REPO / "config" / "model_prices.json")\n .read_text(encoding="utf-8"))\n rows, totals = build.usage_rows(usage.load_ledger(), prices)\n+priority_ledger = {\n+ "models": {\n+ label: {"calls": 1, "inputTokens": 0, "outputTokens": 0,\n+ "unknownCalls": 0}\n+ for label in reversed(providers.MODEL_ORDER)\n+ }\n+}\n+priority_rows, _ = build.usage_rows(priority_ledger, prices)\n+check("usage dashboard follows the configured model priority",\n+ [row["label"] for row in priority_rows]\n+ == list(providers.MODEL_ORDER))\n ultra = next(r for r in rows if "ultra" in r["label"])\n expect_usd = (ultra["in"] / 1e6 * 0.423) + (ultra["out"] / 1e6 * 2.61)\n check("theoretical USD uses the reference price table",\n@@ -400,6 +411,11 @@ def json(self):\n [row["group_id"] for row in view_with_old["runs"]]\n == ["boeing-777-9-test-hours", "nan"]\n and view_with_old["runs"][1]["durations"]["total"] is None)\n+check("recent model statistics follow configured model priority",\n+ [row["label"] for row in view_with_old["models"]]\n+ == ["gemini:gemini-3.6-flash",\n+ "nvidia:nvidia/nemotron-3-ultra-550b-a55b",\n+ "nvidia:nvidia/nemotron-3-super-120b-a12b"])\n check("page is noindex and shows totals",\n "noindex" in page and "API 用量" in page and "$0" in page)\n check("page shows exact Codex GPT-5 task totals and cost semantics",\n@@ -203,7 +203,7 @@ def test_publish_flow():\n "zh": {}, "en": {}, "sources": [{"name": "x", "url": "y"}]}]}\n hour_path.write_text(json.dumps(dummy), encoding="utf-8")\n \n- os.environ["ANTHROPIC_API_KEY"] = "test-key-not-real"\n+ os.environ["GEMINI_API_KEY"] = "test-key-not-real"\n \n def fake_draft(client, group):\n if group["id"] == "g-20260726-0503-1":\n@@ -240,7 +240,7 @@ def fake_draft(client, group):\n check(isinstance(biz["zh"]["body"], list) and isinstance(biz["en"]["body"], list),\n "zh/en bodies are paragraph lists")\n check(biz["image"] is None, "biz article image is null")\n- check(biz.get("writer", "").startswith("anthropic:"),\n+ check(biz.get("writer", "").startswith("gemini:"),\n "writer provenance recorded")\n check(len(biz.get("facts", [])) == 2, "verified facts stored on article")\n check(biz.get("riskFlags") == [], "publish requires empty risk flags")\n@@ -256,7 +256,7 @@ def fake_draft(client, group):\n entry = review[0]\n check(entry["id"] == "g-20260726-0503-1", f"queued group id: {entry['id']}")\n check(entry["approve"] is False, "queued entries await human approval")\n- check(entry["writer"].startswith("anthropic:"), "queue records the writer")\n+ check(entry["writer"].startswith("gemini:"), "queue records the writer")\n check(len(entry["facts"]) == 2, "verified facts stored with the entry")\n check(entry["riskFlags"] == ["accident_or_serious_incident", "investigation"],\n f"risk flags on queue entry, got {entry['riskFlags']}")\n@@ -389,7 +389,7 @@ def test_group_cap_and_unique_ids():\n json.dumps({"generatedUtc": "2026-07-26T05:03Z", "groups": groups}),\n encoding="utf-8")\n \n- os.environ["ANTHROPIC_API_KEY"] = "test-key-not-real"\n+ os.environ["GEMINI_API_KEY"] = "test-key-not-real"\n # Same en title every time; the quote must match THESE groups' titles.\n cap_draft = json.loads(json.dumps(DRAFT_BIZ))\n cap_draft["facts"] = [