Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# fusionAIze Gate Changelog

## v2.2.3 - 2026-04-18

### Fixed

- **Kilo balance polling switched to tRPC**: the v2.2.0 implementation probed four REST URLs that Kilo never shipped (all 404/308). Replaced the probe-list with a single tRPC batch call to `https://app.kilo.ai/api/trpc/user.getCreditBlocks,kiloPass.getState,user.getAutoTopUpPaymentMethod?batch=1` — the same endpoint CodexBar uses. Correctly sums `amount_mUsd` across credit blocks for total, uses `totalBalance_mUsd` for remaining, and converts mUSD → USD. Live-verified against a real account: balance and block-level expiry now visible in `/dashboard/quotas`.

## v2.2.2 - 2026-04-18

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion faigate/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""fusionAIze Gate package."""

__version__ = "2.2.2"
__version__ = "2.2.3"
123 changes: 88 additions & 35 deletions faigate/quota_poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,52 +120,105 @@ async def _fetch_deepseek_balance(
return total, used


# Kilo hasn't published a stable balance schema; probe a short list of common
# candidates and parse the first one that returns a plausible payload.
_KILO_CANDIDATE_ENDPOINTS = (
"https://kilocode.ai/api/profile/balance",
"https://api.kilocode.ai/v1/user/balance",
"https://api.kilo.ai/v1/user/balance",
"https://api.kilocode.ai/v1/key",
# Kilo's balance API is a tRPC batch on app.kilo.ai — discovered via CodexBar's
# open-source implementation (https://github.com/steipete/CodexBar). Three
# procedures get called in one batched GET:
# - user.getCreditBlocks → credit balance per block, mUSD
# - kiloPass.getState → subscription state (unused here)
# - user.getAutoTopUpPaymentMethod → auto top-up state (unused here)
# Response is an array of {result: {data: {...}}} — we only parse entry 0.
# Amounts are in milli-USD (1_000_000 mUSD = $1.00).
_KILO_TRPC_BASE = "https://app.kilo.ai/api/trpc"
_KILO_TRPC_PROCEDURES = (
"user.getCreditBlocks",
"kiloPass.getState",
"user.getAutoTopUpPaymentMethod",
)
_KILO_MUSD_PER_USD = 1_000_000.0


def _kilo_trpc_url() -> str:
"""Build the full tRPC batch URL.

Equivalent to::

{base}/proc1,proc2,proc3?batch=1&input={"0":{"json":null},"1":...,"2":...}
"""
import urllib.parse

procs = ",".join(_KILO_TRPC_PROCEDURES)
inputs = {str(i): {"json": None} for i in range(len(_KILO_TRPC_PROCEDURES))}
input_json = json.dumps(inputs, separators=(",", ":"))
encoded = urllib.parse.quote(input_json, safe="")
return f"{_KILO_TRPC_BASE}/{procs}?batch=1&input={encoded}"


async def _fetch_kilo_balance(
client: httpx.AsyncClient,
api_key: str,
) -> tuple[float, float, str]:
"""Return ``(total, used, endpoint)`` for Kilo by probing candidates.
"""Return ``(total, used, endpoint)`` for Kilo via tRPC.

Accepts any payload that contains *any* of these field names and is
numeric-parseable: ``balance``, ``remaining``, ``credits``, ``total``,
``used``, ``consumed``. This is deliberately lenient — Kilo's schema is a
moving target. The first 2xx response wins; others raise.
Sums ``amount_mUsd`` across all credit blocks for total, computes used
as ``total - totalBalance_mUsd``. All values converted from mUSD to USD.
Raises ``RuntimeError`` if the endpoint returns non-2xx or the payload
schema has drifted.
"""
last_err: Exception | None = None
for url in _KILO_CANDIDATE_ENDPOINTS:
url = _kilo_trpc_url()
resp = await client.get(
url,
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
},
timeout=_HTTP_TIMEOUT,
)
if resp.status_code >= 400:
raise RuntimeError(f"kilo tRPC returned HTTP {resp.status_code}")
payload = resp.json()

# The response shape CodexBar handles: either a list of 3 entries, OR a
# dict keyed by "0","1","2". We only need entry 0 (getCreditBlocks).
entry0: Any = None
if isinstance(payload, list) and payload:
entry0 = payload[0]
elif isinstance(payload, dict):
entry0 = payload.get("0") or payload
if not isinstance(entry0, dict):
raise RuntimeError("kilo tRPC: unexpected response shape for entry 0")

# Unwrap {result: {data: {...}}} and optional {json: ...} envelope
result = entry0.get("result")
if isinstance(result, dict):
data = result.get("data")
if isinstance(data, dict) and "json" in data:
data = data["json"]
else:
data = entry0.get("data")
if not isinstance(data, dict):
raise RuntimeError("kilo tRPC: missing result.data for user.getCreditBlocks")

blocks = data.get("creditBlocks") or []
if not isinstance(blocks, list):
raise RuntimeError("kilo tRPC: creditBlocks is not a list")

total_musd = 0.0
for block in blocks:
if not isinstance(block, dict):
continue
try:
resp = await client.get(
url,
headers={"Authorization": f"Bearer {api_key}"},
timeout=_HTTP_TIMEOUT,
)
if resp.status_code >= 400:
last_err = RuntimeError(f"{url} → HTTP {resp.status_code}")
continue
data = resp.json()
total, used = _extract_numeric_balance(data)
if total is None and used is None:
last_err = RuntimeError(f"{url} → no recognizable balance fields")
continue
if total is None:
total = used or 0.0
if used is None:
used = 0.0
return total, used, url
except (httpx.HTTPError, ValueError, RuntimeError) as exc:
last_err = exc
total_musd += float(block.get("amount_mUsd", 0) or 0)
except (TypeError, ValueError):
continue
raise RuntimeError(f"kilo balance probe exhausted: {last_err}")

try:
remaining_musd = float(data.get("totalBalance_mUsd", 0) or 0)
except (TypeError, ValueError):
remaining_musd = 0.0

total_usd = total_musd / _KILO_MUSD_PER_USD
used_usd = max(0.0, (total_musd - remaining_musd) / _KILO_MUSD_PER_USD)
return total_usd, used_usd, url


def _extract_numeric_balance(payload: Any) -> tuple[float | None, float | None]:
Expand Down
2 changes: 1 addition & 1 deletion 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 = "faigate"
version = "2.2.2"
version = "2.2.3"
description = "Local OpenAI-compatible routing gateway for OpenClaw and other AI-native clients."
readme = "README.md"
license = "Apache-2.0"
Expand Down
Loading