{
  "sha": "026dd73cfc901ea6928753e3048cccb8a90ca354",
  "message": "Configure model priority and quality profiles (#30)\n\nSet Gemini 3.6 Flash as the default, align the fallback and usage display order, and tune per-model quality profiles.",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-07-30T11:31:47Z",
  "files": [
    {
      "filename": "README.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 3,
      "deletions": 3,
      "changes": 6
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 20,
      "deletions": 2,
      "changes": 22
    },
    {
      "filename": "pipeline/model_config.py",
      "previous_filename": "",
      "status": "added",
      "additions": 18,
      "deletions": 0,
      "changes": 18
    },
    {
      "filename": "pipeline/providers.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 43,
      "deletions": 44,
      "changes": 87
    },
    {
      "filename": "tests/test_providers_payloads.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 26,
      "deletions": 3,
      "changes": 29
    },
    {
      "filename": "tests/test_usage.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 16,
      "deletions": 0,
      "changes": 16
    },
    {
      "filename": "tests/test_write_offline.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 4,
      "deletions": 4,
      "changes": 8
    }
  ],
  "diff": "@@ -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\"] = ["
}
