Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/loopx-codex-provider-routing/NATIVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/loopx-codex-provider-routing/extension.toml
Original file line number Diff line number Diff line change
@@ -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 = []

Expand Down
2 changes: 1 addition & 1 deletion packages/loopx-codex-provider-routing/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions tests/presentation/test_openai_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading