Model-level routing chain per the owner's API policy
AVWIRE_PROVIDER_ORDER tokens may now pin a model (nvidia:nvidia/
nemotron-3-ultra-550b-a55b) and repeat a platform with different
models, so same-platform fallbacks (Ultra -> Super) and cross-platform
ones (-> Gemini -> Qwen on NIM) interleave freely. Policy semantics:
the primary model gets one retry on validation/quote failures; an
account-level auth or quota death disables every model on that
platform (shared key) and routing jumps to the next platform; reject
and manual_review never switch providers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
完整差異
@@ -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: