{
  "sha": "56059538a166460009ff9d32aa7bfc8dd21ce8b2",
  "message": "Model-level routing chain per the owner's API policy\n\nAVWIRE_PROVIDER_ORDER tokens may now pin a model (nvidia:nvidia/\nnemotron-3-ultra-550b-a55b) and repeat a platform with different\nmodels, so same-platform fallbacks (Ultra -> Super) and cross-platform\nones (-> Gemini -> Qwen on NIM) interleave freely. Policy semantics:\nthe primary model gets one retry on validation/quote failures; an\naccount-level auth or quota death disables every model on that\nplatform (shared key) and routing jumps to the next platform; reject\nand manual_review never switch providers.\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>",
  "author": {
    "login": "avwire",
    "name": "avwire",
    "email": ""
  },
  "committer": {
    "login": "avwire",
    "name": "avwire",
    "email": ""
  },
  "created_at": "2026-07-26T17:11:57Z",
  "files": [
    {
      "filename": ".github/workflows/bench.yml",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 1,
      "changes": 2
    },
    {
      "filename": "README.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 1,
      "changes": 2
    },
    {
      "filename": "pipeline/providers.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 28,
      "deletions": 11,
      "changes": 39
    },
    {
      "filename": "pipeline/write.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 54,
      "deletions": 21,
      "changes": 75
    },
    {
      "filename": "tests/test_write_offline.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 75,
      "deletions": 3,
      "changes": 78
    }
  ],
  "diff": "@@ -41,7 +41,7 @@ jobs:\\n             curl -sf https://integrate.api.nvidia.com/v1/models \\\\n               -H \"Authorization: Bearer $NVIDIA_API_KEY\" \\\\n               | python -c 'import json,sys; [print(m[\"id\"]) for m in json.load(sys.stdin).get(\"data\",[])]' \\\\n-              | grep -Ei \"deepseek|qwen|llama-3\" || echo \"(model list unavailable)\"\\n+              | grep -Ei \"nemotron|deepseek|qwen|glm|mistral|kimi|llama\" || echo \"(model list unavailable)\"\\n           fi\\n           if [ -n \"$GEMINI_API_KEY\" ]; then\\n             echo \"== Gemini generateContent models ==\"\\n@@ -75,7 +75,7 @@ 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` | 撰稿供應商優先順序（主力在前） |\\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_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@@ -22,6 +22,15 @@\\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+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+    nvidia:nvidia/nemotron-3-ultra-550b-a55b,\\n+    nvidia:nvidia/nemotron-3-super-120b-a12b,\\n+    gemini,\\n+    nvidia:qwen/qwen3.5-397b-a17b\\n+\\n+A bare name uses the platform's AVWIRE_*_MODEL env var / default.\\n \"\"\"\\n from __future__ import annotations\\n \\n@@ -85,9 +94,9 @@ def _body_snippet(response) -> str:\\n class AnthropicProvider:\\n     name = \"anthropic\"\\n \\n-    def __init__(self) -> None:\\n+    def __init__(self, model=None) -> None:\\n         # `or` (not a get() default) so an empty env var still falls back.\\n-        self.model = os.environ.get(\"AVWIRE_MODEL\") or \"claude-opus-5\"\\n+        self.model = model or os.environ.get(\"AVWIRE_MODEL\") or \"claude-opus-5\"\\n         self.label = f\"{self.name}:{self.model}\"\\n         self._client = None\\n \\n@@ -145,8 +154,9 @@ def draft(self, system_prompt: str, user_prompt: str, schema: dict):\\n class GeminiProvider:\\n     name = \"gemini\"\\n \\n-    def __init__(self) -> None:\\n-        self.model = os.environ.get(\"AVWIRE_GEMINI_MODEL\") or \"gemini-2.5-flash\"\\n+    def __init__(self, model=None) -> None:\\n+        self.model = (model or os.environ.get(\"AVWIRE_GEMINI_MODEL\")\\n+                      or \"gemini-2.5-flash\")\\n         self.label = f\"{self.name}:{self.model}\"\\n \\n     def available(self) -> bool:\\n@@ -219,8 +229,8 @@ def draft(self, system_prompt: str, user_prompt: str, schema: dict):\\n class NvidiaProvider:\\n     name = \"nvidia\"\\n \\n-    def __init__(self) -> None:\\n-        self.model = (os.environ.get(\"AVWIRE_NVIDIA_MODEL\")\\n+    def __init__(self, model=None) -> None:\\n+        self.model = (model or os.environ.get(\"AVWIRE_NVIDIA_MODEL\")\\n                       or \"deepseek-ai/deepseek-v3.1\")\\n         self.label = f\"{self.name}:{self.model}\"\\n \\n@@ -299,20 +309,27 @@ def draft(self, system_prompt: str, user_prompt: str, schema: dict):\\n \\n \\n def build_providers() -> list:\\n-    \"\"\"Instantiate available providers in AVWIRE_PROVIDER_ORDER order.\"\"\"\\n+    \"\"\"Instantiate available providers in AVWIRE_PROVIDER_ORDER order.\\n+\\n+    A token is either a platform name (\"nvidia\") or a pinned model\\n+    (\"nvidia:nvidia/nemotron-3-ultra-550b-a55b\"); the same platform may\\n+    appear multiple times with different models.\\n+    \"\"\"\\n     order = os.environ.get(\"AVWIRE_PROVIDER_ORDER\") or DEFAULT_ORDER\\n     providers, seen = [], set()\\n     for token in order.split(\",\"):\\n-        key = token.strip().lower()\\n-        if not key or key in seen:\\n+        name, _, model = token.strip().partition(\":\")\\n+        key = name.strip().lower()\\n+        model = model.strip() or None\\n+        if not key or (key, model) in seen:\\n             continue\\n-        seen.add(key)\\n+        seen.add((key, model))\\n         cls = _REGISTRY.get(key)\\n         if cls is None:\\n             print(f\"write: unknown provider '{key}' in AVWIRE_PROVIDER_ORDER; \"\\n                   \"ignoring\")\\n             continue\\n-        provider = cls()\\n+        provider = cls(model)\\n         if provider.available():\\n             providers.append(provider)\\n     return providers\\n@@ -469,6 +469,38 @@ def validate_draft(draft):\\n     return None\\n \\n \\n+class DraftInvalid(Exception):\\n+    \"\"\"Draft failed validation or quote verification on every allowed try.\"\"\"\\n+\\n+\\n+def _validated_draft(provider, group: dict, tries: int):\\n+    \"\"\"Call `provider` up to `tries` times until a draft survives validation\\n+    and quote verification.\\n+\\n+    Returns (candidate, facts). A genuine refusal returns (None, None); an\\n+    editorial reject returns (candidate, []). Raises DraftInvalid after the\\n+    last failed try. Provider errors propagate immediately - the routing\\n+    policy retries validation failures, never transport failures.\\n+    \"\"\"\\n+    for attempt in range(tries):\\n+        candidate = draft_group(provider, group)\\n+        if candidate is None:\\n+            return None, None\\n+        problem = validate_draft(candidate)\\n+        if problem is None:\\n+            if candidate.get(\"status\") == \"reject\":\\n+                return candidate, []\\n+            facts = verify_facts(candidate, group, provider.label)\\n+            if facts:\\n+                return candidate, facts\\n+            problem = \"no machine-verifiable sourceQuote\"\\n+        retrying = attempt + 1 < tries\\n+        print(f\"write: {provider.label} draft for group {group.get('id')} \"\\n+              f\"unusable ({problem})\"\\n+              + (\"; retrying once\" if retrying else \"\"))\\n+    raise DraftInvalid\\n+\\n+\\n _WS_RE = re.compile(r\"\\s+\")\\n \\n \\n@@ -769,20 +801,33 @@ def main() -> None:\\n                 break  # every provider is dead; the rest stays pending\\n             draft, writer, verified, queued_this = None, None, None, False\\n             for provider in list(alive):\\n+                if provider not in alive:\\n+                    # Removed mid-group by a platform-level auth/quota\\n+                    # death earlier in this same iteration.\\n+                    continue\\n+                # Routing policy: the primary model gets one retry on\\n+                # validation failures; every fallback gets a single try.\\n+                tries = 2 if provider is providers[0] else 1\\n                 try:\\n-                    candidate = draft_group(provider, group)\\n+                    candidate, facts = _validated_draft(provider, group,\\n+                                                        tries)\\n+                except DraftInvalid:\\n+                    continue  # try the next provider in the chain\\n                 except ProviderAuthError as exc:\\n-                    # Persistent config error: every later call on this\\n-                    # provider is doomed. Disable it and fall through.\\n+                    # Account-level failure: every model on this platform\\n+                    # shares the key, so skip same-platform fallbacks and\\n+                    # go straight to the next platform.\\n                     print(f\"write: FATAL {provider.label} auth error ({exc});\"\\n-                          \" disabling provider\")\\n-                    alive.remove(provider)\\n+                          f\" disabling all '{provider.name}' providers\")\\n+                    alive[:] = [p for p in alive if p.name != provider.name]\\n                     dead_auth.add(provider.name)\\n                     continue\\n                 except ProviderQuotaError as exc:\\n+                    # Quota/credits are account-level too.\\n                     print(f\"write: {provider.label} quota exhausted ({exc}); \"\\n-                          \"disabling provider for this run\")\\n-                    alive.remove(provider)\\n+                          f\"disabling all '{provider.name}' providers \"\\n+                          \"for this run\")\\n+                    alive[:] = [p for p in alive if p.name != provider.name]\\n                     continue\\n                 except ProviderError as exc:\\n                     print(f\"write: {provider.label} error on group \"\\n@@ -797,12 +842,6 @@ def main() -> None:\\n                     # Genuine refusal / safety block: content-based, so\\n                     # don't shop the group around to a laxer provider.\\n                     break\\n-                problem = validate_draft(candidate)\\n-                if problem:\\n-                    print(f\"write: {provider.label} draft for group \"\\n-                          f\"{group.get('id')} failed validation ({problem}); \"\\n-                          \"trying next provider\")\\n-                    continue\\n                 if candidate.get(\"status\") == \"reject\":\\n                     # Editorial rejection (insufficient evidence): also\\n                     # content-based - trying another model would just\\n@@ -811,12 +850,6 @@ def main() -> None:\\n                           f\"{group.get('id')}: \"\\n                           f\"{str(candidate.get('decisionReason') or '?')[:200]}\")\\n                     break\\n-                facts = verify_facts(candidate, group, provider.label)\\n-                if not facts:\\n-                    print(f\"write: {provider.label} draft for group \"\\n-                          f\"{group.get('id')} has no machine-verifiable \"\\n-                          \"sourceQuote; trying next provider\")\\n-                    continue\\n                 status = candidate.get(\"status\")\\n                 flags = candidate.get(\"riskFlags\") or []\\n                 if status == \"publish\" and (\\n@@ -883,8 +916,8 @@ def main() -> None:\\n           f\"({len(approvals)} human-approved), queued \"\\n           f\"{len(queued_entries)} for review, skipped {skipped}, \"\\n           f\"pending {remaining_count}\")\\n-    if providers and dead_auth and len(dead_auth) == len(providers):\\n-        # Every configured provider failed authentication: turn the Actions\\n+    if providers and dead_auth and dead_auth == {p.name for p in providers}:\\n+        # Every configured platform failed authentication: turn the Actions\\n         # run red instead of silently green.\\n         raise SystemExit(1)\\n \\n@@ -538,6 +538,77 @@ def test_provider_failover():\\n     print(\"test_provider_failover: done\")\\n \\n \\n+def test_model_chain_and_routing_policy():\\n+    \"\"\"name:model order tokens; platform-level death; primary retry-once.\"\"\"\\n+    # --- order tokens: same platform may appear with different models -----\\n+    saved = {k: os.environ.get(k) for k in (\\n+        \"AVWIRE_PROVIDER_ORDER\", \"NVIDIA_API_KEY\", \"GEMINI_API_KEY\",\\n+        \"ANTHROPIC_API_KEY\")}\\n+    os.environ[\"AVWIRE_PROVIDER_ORDER\"] = (\\n+        \"nvidia:nvidia/nemotron-3-ultra-550b-a55b, \"\\n+        \"nvidia:nvidia/nemotron-3-super-120b-a12b, \"\\n+        \"gemini:gemini-3-flash-preview, bogus, nvidia\")\\n+    os.environ[\"NVIDIA_API_KEY\"] = \"test-key-not-real\"\\n+    os.environ[\"GEMINI_API_KEY\"] = \"test-key-not-real\"\\n+    os.environ.pop(\"ANTHROPIC_API_KEY\", None)\\n+    try:\\n+        labels = [p.label for p in providers.build_providers()]\\n+    finally:\\n+        for key, value in saved.items():\\n+            if value is None:\\n+                os.environ.pop(key, None)\\n+            else:\\n+                os.environ[key] = value\\n+    check(labels == [\"nvidia:nvidia/nemotron-3-ultra-550b-a55b\",\\n+                     \"nvidia:nvidia/nemotron-3-super-120b-a12b\",\\n+                     \"gemini:gemini-3-flash-preview\",\\n+                     \"nvidia:deepseek-ai/deepseek-v3.1\"],\\n+          f\"order tokens honored in order, got {labels}\")\\n+\\n+    # --- platform auth death skips same-platform fallback models ----------\\n+    reset_data_dir()\\n+    ultra = FakeProvider(\"nvidia\", [providers.ProviderAuthError(\"HTTP 401\")])\\n+    superp = FakeProvider(\"nvidia\", [DRAFT_BIZ])\\n+    gem = FakeProvider(\"gemini\", [DRAFT_BIZ])\\n+\\n+    original = write.build_providers\\n+    write.build_providers = lambda: [ultra, superp, gem]\\n+    try:\\n+        write.main()\\n+    finally:\\n+        write.build_providers = original\\n+\\n+    check(ultra.calls == 1 and superp.calls == 0,\\n+          f\"account failure skips same-platform models \"\\n+          f\"(ultra {ultra.calls}, super {superp.calls})\")\\n+    articles = all_articles()\\n+    check(len(articles) == 1 and articles[0][\"writer\"] == \"gemini:fake\",\\n+          \"cross-platform fallback wrote the publishable article\")\\n+\\n+    # --- primary model retries ONCE on a validation failure ----------------\\n+    reset_data_dir()\\n+    pending_fixture = load(FIXTURES / \"pending.json\")\\n+    only_biz = {**pending_fixture, \"groups\": [pending_fixture[\"groups\"][1]]}\\n+    (DATA / \"pending.json\").write_text(\\n+        json.dumps(only_biz, ensure_ascii=False), encoding=\"utf-8\")\\n+    bad = json.loads(json.dumps(DRAFT_BIZ))\\n+    bad[\"cat\"] = \"not-a-cat\"\\n+    prim = FakeProvider(\"gemini\", [bad, DRAFT_BIZ])\\n+\\n+    original = write.build_providers\\n+    write.build_providers = lambda: [prim]\\n+    try:\\n+        write.main()\\n+    finally:\\n+        write.build_providers = original\\n+\\n+    check(prim.calls == 2, f\"primary retried exactly once, got {prim.calls}\")\\n+    articles = all_articles()\\n+    check(len(articles) == 1 and articles[0][\"writer\"] == \"gemini:fake\",\\n+          \"retry produced the published article\")\\n+    print(\"test_model_chain_and_routing_policy: done\")\\n+\\n+\\n def test_editorial_gate():\\n     \"\"\"Model reject skips the group; fabricated quotes are dropped/blocking.\"\"\"\\n     reset_data_dir()\\n@@ -566,7 +637,8 @@ def test_editorial_gate():\\n     articles = all_articles()\\n     check(len(articles) == 1,\\n           f\"only the quote-backed group publishes, got {len(articles)}\")\\n-    check(solo.calls == 3, f\"one call per group, got {solo.calls}\")\\n+    # 4 calls: g1 reject, g2 fabricated + one primary retry, g3 publish.\\n+    check(solo.calls == 4, f\"expected 4 calls incl. retry, got {solo.calls}\")\\n     facts = articles[0].get(\"facts\", [])\\n     check(len(facts) == 1 and \"unruly passengers\" in facts[0][\"sourceQuote\"],\\n           f\"fabricated fact dropped, verified fact kept: {facts}\")\\n@@ -611,8 +683,8 @@ def test_all_providers_auth_dead():\\n def main():\\n     tests = [test_publish_flow, test_group_cap_and_unique_ids,\\n              test_extract_json_and_validate_draft, test_provider_failover,\\n-             test_editorial_gate, test_all_providers_auth_dead,\\n-             test_no_api_key_end_to_end]\\n+             test_model_chain_and_routing_policy, test_editorial_gate,\\n+             test_all_providers_auth_dead, test_no_api_key_end_to_end]\\n     crashed = False\\n     for test in tests:\\n         try:"
}
