diff --git a/packages/loopx-codex-provider-routing/NATIVE.md b/packages/loopx-codex-provider-routing/NATIVE.md index 79f5039036..a688e3fab6 100644 --- a/packages/loopx-codex-provider-routing/NATIVE.md +++ b/packages/loopx-codex-provider-routing/NATIVE.md @@ -163,6 +163,13 @@ eligible and a subsequent accepted request clears its preference penalty. Selection reuses the existing private runtime observations, adds no probes, and never retries an admitted stream or overrides the subscription/API role. +Version 0.15.4 treats an API route as ready only while at least one configured +key is currently eligible after authentication, rate-limit and billing +observations. If the active API pool becomes unavailable, an available +subscription is selected again; when none is available, status reports +`all_unavailable` instead of claiming `api_active`. `api_fallback` stays enabled +through this transition. + **Check connection** only requests the official [`GET /v1/models`](https://developers.openai.com/api/reference/resources/models/methods/list). It makes no inference call and does not establish spendable quota. Its 401, 403, diff --git a/packages/loopx-codex-provider-routing/extension.toml b/packages/loopx-codex-provider-routing/extension.toml index 19c4d0d7de..d4ef58c8b1 100644 --- a/packages/loopx-codex-provider-routing/extension.toml +++ b/packages/loopx-codex-provider-routing/extension.toml @@ -1,6 +1,6 @@ schema_version = "loopx_extension_manifest_v0" id = "loopx-codex-provider-routing" -version = "0.15.3" +version = "0.15.4" requires_loopx_api = ">=1,<2" permissions = [] diff --git a/packages/loopx-codex-provider-routing/pyproject.toml b/packages/loopx-codex-provider-routing/pyproject.toml index 43727653c7..945ac14367 100644 --- a/packages/loopx-codex-provider-routing/pyproject.toml +++ b/packages/loopx-codex-provider-routing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "loopx-codex-provider-routing" -version = "0.15.3" +version = "0.15.4" description = "Native Codex account slots and exhausted-account handoff for LoopX" readme = "README.md" requires-python = ">=3.11" diff --git a/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs b/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs index 17428be2dd..e5adb08525 100644 --- a/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs +++ b/packages/loopx-codex-provider-routing/smoke/native-policy.test.mjs @@ -159,4 +159,11 @@ test("API is opt-in and strictly after every measured subscription; recovery ret assert.equal(decide(input({policy: enabled, api, observations: [sample("a", 100), sample("b", 100), {...sample("c", 100), observed_at: now - 121}]})).target, null); assert.equal(decide(input({policy: enabled, api, active: api.slot, observations: exhausted})).action, "api_active"); assert.equal(decide(input({policy: enabled, api, active: api.slot, observations: [sample("a", 80), sample("b", 20), sample("c", 100)]})).target, "b"); + const exhaustedApi = {...api, ready: false}; + assert.equal(decide(input({policy: enabled, api: exhaustedApi, active: api.slot, + observations: [sample("a", 80), sample("b", 20), sample("c", 100)]})).target, "b", + "An exhausted API pool returns to an available subscription"); + assert.equal(decide(input({policy: enabled, api: exhaustedApi, active: api.slot, + observations: exhausted})).action, "all_unavailable", + "An exhausted API pool is never projected as actively callable"); }); diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native-extension.toml b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native-extension.toml index b6706285c9..67d6d014c9 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native-extension.toml +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native-extension.toml @@ -1,6 +1,6 @@ schema_version = "loopx_extension_manifest_v0" id = "loopx-native-account-provider" -version = "0.15.3" +version = "0.15.4" requires_loopx_api = ">=1,<2" permissions = ["model-provider.local-read", "model-provider.transport-manage"] diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native.py b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native.py index d7750a2343..e9979843b5 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native.py +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native.py @@ -456,8 +456,11 @@ def tick(self, *, refresh: bool = False, manual_slot: str | None = None, # destination. Candidates stay strictly inside the allowlist. api = None if state["policy"].get("api_fallback") or native.get("api_slot"): + # A saved key pool is not proof that a request can proceed: + # authentication, rate-limit, and billing exhaustion all + # remove keys from the current candidate set. ready = bool(state["policy"].get("api_fallback") and native.get("api_fallback_supported") - and self.api_keys().status()["keys"]) + and self.api_keys().candidates("")) api = {"slot": native.get("api_slot") or "openai-api", "ready": ready} slots = list(dict.fromkeys([native["active_slot"], *state["policy"]["slots"]])) slots = [slot for slot in slots if slot != native.get("api_slot")] diff --git a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy.ts b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy.ts index f5a540d660..d5595c9018 100644 --- a/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy.ts +++ b/packages/loopx-codex-provider-routing/src/loopx_codex_provider_routing/native_policy.ts @@ -205,7 +205,13 @@ export function decide(input: { ? [input.manual_slot].find(slot => slot !== input.active && allowed.includes(slot)) : candidates.sort((a, b) => b.remaining - a.remaining)[0]?.slot; if (target) return output("switch", target); - if (!input.manual_slot && onApi) return output(input.policy.api_fallback ? "api_active" : "api_disabled_active", null, input.now + 60); + if (!input.manual_slot && onApi) { + if (input.policy.api_fallback !== true) return output("api_disabled_active", null, input.now + 60); + // No subscription was selectable above. Keep an actually callable API + // route active, but do not claim availability when every key is cooling or + // exhausted; the normal all-unavailable wait owns that state. + if (input.api?.ready === true) return output("api_active", null, input.now + 60); + } // A failed/stale subscription read is not permission to incur API costs. // Every authorized subscription must have fresh, measured exhaustion. if (!input.manual_slot && input.policy.api_fallback === true && input.api?.ready diff --git a/tests/presentation/test_openai_fallback.py b/tests/presentation/test_openai_fallback.py index 32b49ee495..7f14b33cca 100644 --- a/tests/presentation/test_openai_fallback.py +++ b/tests/presentation/test_openai_fallback.py @@ -60,6 +60,26 @@ def test_automatic_api_handoff_then_recovered_subscription_adoption(setup): assert state["calls"].count("switch") == 2 +def test_exhausted_api_pool_returns_to_a_recovered_subscription(setup): + provider, state = setup + provider.configure(expected_revision=0, enabled=True, slots=["a", "b"], api_fallback=True) + assert provider.tick(refresh=True)["active_slot"] == "openai-api" + + key = provider.api_keys().status()["keys"][0] + provider.api_keys().record(key["id"], status=429, code="insufficient_quota", + model="selected-model", headers={"retry-after": "3600"}) + state["now"] = 1301 # Past the account-switch cooldown but not the key cooldown. + blocked = provider.tick(refresh=True) + assert blocked["active_slot"] == "openai-api" + assert blocked["action"] == "all_unavailable" + assert state["calls"].count("switch") == 1, "No fallback may claim readiness while every key is exhausted" + + state["b_used"] = 10 + recovered = provider.tick(refresh=True) + assert recovered["active_slot"] == "b" and recovered["api_active"] is False + assert state["calls"].count("switch") == 2 + + def test_available_or_unknown_subscription_never_starts_a_paid_handoff(setup): provider, state = setup provider.configure(expected_revision=0, enabled=True, slots=["a", "b"], api_fallback=True)