2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM 每小時自動更新 中文 EN
LIVE 快訊
← 返回網站更新紀錄 Site Changelog

提交原始紀錄

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>

SHA
56059538a166460009ff9d32aa7bfc8dd21ce8b2
提交時間
作者
avwire
提交者
avwire

變更檔案 (5)

  • .github/workflows/bench.yml modified · +1 / −1
  • README.md modified · +1 / −1
  • pipeline/providers.py modified · +28 / −11
  • pipeline/write.py modified · +54 / −21
  • tests/test_write_offline.py modified · +75 / −3

完整差異

@@ -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:

你發現了隱藏主題!

8-bit 主題已解鎖。重新整理後,就能在主題選單選擇 8-bit。