Skip to content

Commit f2ae129

Browse files
author
André Lange
committed
feat: Phase 2b router integration with offerings/packages catalogs
- Router uses offering-specific pricing for cost estimation - Package scoring based on remaining credits and expiry dates - Dashboard shows package details and credits overview - Updated version to 1.17.0
1 parent 14a64c0 commit f2ae129

11 files changed

Lines changed: 1008 additions & 10 deletions

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
# fusionAIze Gate Changelog
22

3+
## v1.17.0 - 2026-04-01
4+
5+
### Added
6+
7+
- Added router integration with offerings catalog for price-aware routing decisions (Phase 2b)
8+
- Added package scoring based on remaining credits and expiry dates for intelligent routing
9+
- Added detailed package overview to dashboard with credits, expiry, and provider mapping
10+
- Added `_metadata_packages_detail()` function for detailed package insights
11+
- Enhanced dashboard with package details section and cost projection improvements
12+
13+
### Changed
14+
15+
- Updated router cost estimation to prefer offering-specific pricing over provider defaults
16+
- Improved provider dimension scoring with package score integration
17+
- Extended dashboard metadata catalogs summary with package details
18+
- No breaking changes; existing routing behavior remains compatible
19+
320
## v1.16.0 - 2026-04-01
421

522
### Added

faigate/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""fusionAIze Gate package."""
22

3-
__version__ = "1.16.0"
3+
__version__ = "1.17.0"

faigate/dashboard.py

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@
66

77
import json
88
import time
9+
from datetime import date
910
from pathlib import Path
1011
from typing import Any
1112

1213
from .lane_registry import get_route_add_recommendations
1314
from .metrics import MetricsStore
14-
from .provider_catalog import build_provider_refresh_guidance
15+
from .provider_catalog import (
16+
build_provider_refresh_guidance,
17+
get_offerings_catalog,
18+
get_packages_catalog,
19+
)
1520
from .provider_catalog_refresh import (
1621
build_catalog_alert_summary,
1722
build_catalog_alerts,
@@ -209,6 +214,100 @@ def _provider_catalog_summary(db_path: str) -> dict[str, Any]:
209214
store.close()
210215

211216

217+
def _metadata_catalogs_summary() -> dict[str, Any]:
218+
"""Return summary statistics for offerings and packages catalogs."""
219+
offerings = get_offerings_catalog()
220+
packages = get_packages_catalog()
221+
222+
# Count offerings by freshness
223+
freshness_counts = {"fresh": 0, "aging": 0, "stale": 0, "unknown": 0}
224+
for offering in offerings.values():
225+
pricing = offering.get("pricing", {})
226+
freshness = pricing.get("freshness_status", "unknown")
227+
if freshness in freshness_counts:
228+
freshness_counts[freshness] += 1
229+
else:
230+
freshness_counts["unknown"] += 1
231+
232+
# Count packages by type and expiry
233+
package_types = {}
234+
expiring_soon = 0
235+
today = date.today()
236+
for package in packages.values():
237+
pkg_type = package.get("type", "unknown")
238+
package_types[pkg_type] = package_types.get(pkg_type, 0) + 1
239+
240+
# Check expiry
241+
expiry_str = package.get("expiry_date")
242+
if expiry_str:
243+
try:
244+
expiry_date = date.fromisoformat(expiry_str)
245+
days_left = (expiry_date - today).days
246+
if 0 <= days_left <= 7:
247+
expiring_soon += 1
248+
except ValueError:
249+
pass
250+
251+
return {
252+
"offerings": {
253+
"total": len(offerings),
254+
"freshness": freshness_counts,
255+
},
256+
"packages": {
257+
"total": len(packages),
258+
"types": package_types,
259+
"expiring_soon": expiring_soon,
260+
},
261+
}
262+
263+
264+
def _metadata_packages_detail() -> list[dict[str, Any]]:
265+
"""Return detailed package information for dashboard."""
266+
packages = get_packages_catalog()
267+
today = date.today()
268+
result = []
269+
for package in packages.values():
270+
expiry_str = package.get("expiry_date")
271+
days_left = None
272+
if expiry_str:
273+
try:
274+
expiry_date = date.fromisoformat(expiry_str)
275+
days_left = (expiry_date - today).days
276+
except ValueError:
277+
pass
278+
total = package.get("total_credits")
279+
used = package.get("used_credits", 0)
280+
remaining = total - used if total is not None else None
281+
remaining_pct = (remaining / total * 100) if total and total > 0 else 0
282+
result.append(
283+
{
284+
"package_id": package.get("package_id"),
285+
"provider_id": package.get("provider_id"),
286+
"name": package.get("name"),
287+
"type": package.get("type"),
288+
"total_credits": total,
289+
"used_credits": used,
290+
"remaining_credits": remaining,
291+
"remaining_pct": remaining_pct,
292+
"expiry_date": expiry_str,
293+
"days_left": days_left,
294+
"currency": package.get("currency"),
295+
"price": package.get("price"),
296+
"renewal_policy": package.get("renewal_policy"),
297+
"notes": package.get("notes"),
298+
}
299+
)
300+
# Sort by expiry (soonest first, then no expiry), then by remaining percentage (lowest first)
301+
result.sort(
302+
key=lambda p: (
303+
0 if p["days_left"] is not None and p["days_left"] >= 0 else 1,
304+
p["days_left"] if p["days_left"] is not None else float("inf"),
305+
p["remaining_pct"] if p["remaining_pct"] is not None else float("inf"),
306+
)
307+
)
308+
return result
309+
310+
212311
def _lane_family_summary(
213312
provider_rows: list[dict[str, Any]],
214313
provider_map: dict[str, dict[str, Any]],
@@ -1029,6 +1128,15 @@ def build_dashboard_report(
10291128
"review_now": _safe_int(provider_catalog_alert_summary.get("review_now")),
10301129
"inspect": _safe_int(provider_catalog_alert_summary.get("inspect")),
10311130
},
1131+
"metadata_catalogs": {
1132+
"offerings_total": _safe_int(_metadata_catalogs_summary()["offerings"]["total"]),
1133+
"offerings_fresh": _safe_int(_metadata_catalogs_summary()["offerings"]["freshness"]["fresh"]),
1134+
"offerings_aging": _safe_int(_metadata_catalogs_summary()["offerings"]["freshness"]["aging"]),
1135+
"offerings_stale": _safe_int(_metadata_catalogs_summary()["offerings"]["freshness"]["stale"]),
1136+
"packages_total": _safe_int(_metadata_catalogs_summary()["packages"]["total"]),
1137+
"packages_expiring_soon": _safe_int(_metadata_catalogs_summary()["packages"]["expiring_soon"]),
1138+
"packages_detail": _metadata_packages_detail(),
1139+
},
10321140
"drivers": {
10331141
"top_provider": top_provider,
10341142
"top_cost_provider": top_provider_cost,
@@ -1125,6 +1233,42 @@ def _render_overview(report: dict[str, Any]) -> str:
11251233
f" Recent changes {_safe_int(report['cards']['provider_catalog']['recent_changes'])}",
11261234
]
11271235
)
1236+
1237+
# Add metadata catalogs section
1238+
lines.extend(
1239+
[
1240+
"",
1241+
"Metadata catalogs",
1242+
f" Offerings {_safe_int(report['cards']['metadata_catalogs']['offerings_total'])} total ({_safe_int(report['cards']['metadata_catalogs']['offerings_fresh'])} fresh)",
1243+
f" Packages {_safe_int(report['cards']['metadata_catalogs']['packages_total'])} total ({_safe_int(report['cards']['metadata_catalogs']['packages_expiring_soon'])} expiring soon)",
1244+
]
1245+
)
1246+
# Add package details if any packages exist
1247+
packages_detail = report["cards"]["metadata_catalogs"].get("packages_detail", [])
1248+
if packages_detail:
1249+
# Show top 3 packages (sorted by expiry and remaining)
1250+
lines.append("")
1251+
lines.append(" Package details")
1252+
for pkg in packages_detail[:3]:
1253+
provider = pkg.get("provider_id", "unknown")
1254+
name = pkg.get("name", "unnamed")
1255+
total = pkg.get("total_credits")
1256+
remaining = pkg.get("remaining_credits")
1257+
expiry = pkg.get("expiry_date")
1258+
days_left = pkg.get("days_left")
1259+
if total is not None and remaining is not None:
1260+
creds = f"{remaining}/{total}"
1261+
pct = pkg.get("remaining_pct", 0)
1262+
creds_line = f"{creds} ({pct:.0f}%)"
1263+
else:
1264+
creds_line = "n/a"
1265+
expiry_line = ""
1266+
if expiry:
1267+
if days_left is not None and days_left >= 0:
1268+
expiry_line = f", expires in {days_left} day{'s' if days_left != 1 else ''}"
1269+
else:
1270+
expiry_line = ", expired"
1271+
lines.append(f" - {provider}: {name} ({creds_line}{expiry_line})")
11281272
if report["alerts"]:
11291273
alert = report["alerts"][0]
11301274
lines.extend(

faigate/provider_catalog.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,47 @@ def _get_provider_pricing(provider_name: str) -> dict[str, Any]:
331331
return pricing
332332

333333

334+
def _get_pricing_for_provider_and_model(provider_name: str, model_id: str | None = None) -> dict[str, Any]:
335+
"""Get pricing metadata for a provider and optional specific model.
336+
337+
First tries the offerings catalog for the exact model-provider pair.
338+
If not found, falls back to provider-level pricing.
339+
"""
340+
# If model_id is provided, try offerings catalog
341+
if model_id:
342+
offering_pricing = get_offering_pricing(model_id, provider_name)
343+
if offering_pricing:
344+
# Normalize field names (same mapping as in _get_provider_pricing)
345+
field_mapping = {
346+
"input_cost_per_1m": "input",
347+
"output_cost_per_1m": "output",
348+
"cache_read_cost_per_1m": "cache_read",
349+
}
350+
normalized = {}
351+
for src, dst in field_mapping.items():
352+
if src in offering_pricing and dst not in offering_pricing:
353+
normalized[dst] = offering_pricing[src]
354+
elif dst in offering_pricing:
355+
normalized[dst] = offering_pricing[dst]
356+
# Preserve other fields (source_type, freshness_status, etc.)
357+
for key, value in offering_pricing.items():
358+
if key not in normalized:
359+
normalized[key] = value
360+
return normalized
361+
# Fall back to provider-level pricing
362+
return _get_provider_pricing(provider_name)
363+
364+
365+
def _get_packages_for_provider(provider_name: str) -> list[dict[str, Any]]:
366+
"""Return active packages for a provider from the packages catalog."""
367+
packages_catalog = get_packages_catalog()
368+
provider_packages = []
369+
for package_id, package in packages_catalog.items():
370+
if package.get("provider_id") == provider_name:
371+
provider_packages.append(package)
372+
return provider_packages
373+
374+
334375
_CATALOG: dict[str, dict[str, Any]] = {
335376
"deepseek-chat": {
336377
"recommended_model": get_active_model_id("deepseek/chat"),

faigate/router.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
import re
77
import time
88
from dataclasses import dataclass, field
9+
from datetime import date
910
from typing import Any
1011

1112
from .config import Config
1213
from .lane_registry import get_canonical_model_catalog, get_canonical_model_routes
14+
from .provider_catalog import _get_packages_for_provider, _get_pricing_for_provider_and_model
1315

1416
logger = logging.getLogger("faigate.router")
1517
_BOUNDARY_TEXT_RE = re.compile(r"[a-z0-9]")
@@ -612,6 +614,45 @@ def _estimated_request_cost_usd(provider: dict[str, Any], ctx: _RoutingContext |
612614
return round(prompt_cost + output_cost, 6)
613615

614616

617+
def _estimated_request_cost_usd_with_lane(
618+
provider_name: str,
619+
model_id: str | None,
620+
provider: dict[str, Any],
621+
ctx: _RoutingContext | None,
622+
) -> float:
623+
"""Estimate request cost using offerings catalog pricing if available."""
624+
if ctx is None:
625+
return 0.0
626+
# First check provider config pricing
627+
config_pricing = provider.get("pricing")
628+
if config_pricing and isinstance(config_pricing, dict):
629+
pricing = config_pricing
630+
else:
631+
pricing = _get_pricing_for_provider_and_model(provider_name, model_id)
632+
if not pricing:
633+
return 0.0
634+
prompt_rate = float(pricing.get("input", 0) or 0)
635+
output_rate = float(pricing.get("output", 0) or 0)
636+
cache_rate = float(pricing.get("cache_read", prompt_rate) or 0)
637+
prompt_tokens = max(1, int(ctx.total_tokens or 0))
638+
output_tokens = int(ctx.requested_output_tokens or 0)
639+
if output_tokens <= 0:
640+
output_tokens = min(1024, max(128, prompt_tokens // 2))
641+
642+
cache_cfg = provider.get("cache") or {}
643+
cache_mode = str(cache_cfg.get("mode") or "none")
644+
cache_min_prefix = int(cache_cfg.get("min_prefix_tokens") or 64)
645+
cache_threshold = max(64, cache_min_prefix)
646+
if ctx.stable_prefix_tokens >= cache_threshold and cache_mode != "none":
647+
cached_tokens = min(prompt_tokens, int(ctx.stable_prefix_tokens))
648+
prompt_cost = ((cached_tokens * cache_rate) + ((prompt_tokens - cached_tokens) * prompt_rate)) / 1_000_000
649+
else:
650+
prompt_cost = (prompt_tokens * prompt_rate) / 1_000_000
651+
652+
output_cost = (output_tokens * output_rate) / 1_000_000
653+
return round(prompt_cost + output_cost, 6)
654+
655+
615656
def _build_request_insights(
616657
*,
617658
last_user_message: str,
@@ -1572,7 +1613,8 @@ def _provider_dimension_details(
15721613
benchmark_score = self._benchmark_posture_score(lane, routing_posture)
15731614
benchmark_request_score = _benchmark_request_score(lane, ctx)
15741615
cost_tier = str(capabilities.get("cost_tier") or lane.get("quality_tier") or "")
1575-
estimated_request_cost_usd = _estimated_request_cost_usd(provider, ctx)
1616+
model_id = lane.get("canonical_model")
1617+
estimated_request_cost_usd = _estimated_request_cost_usd_with_lane(name, model_id, provider, ctx)
15761618
cost_score = _cost_posture_score(
15771619
estimated_cost_usd=estimated_request_cost_usd,
15781620
routing_posture=routing_posture,
@@ -1583,6 +1625,42 @@ def _provider_dimension_details(
15831625
kilo_score = int(kilo_fit.get("score") or 0)
15841626
adaptation_penalty = int(runtime_state.get("penalty", 0) or 0)
15851627
recovery_score = self._recovery_posture_score(lane, runtime_state, routing_posture)
1628+
# Package score based on remaining credits and expiry
1629+
package_score = 0
1630+
package_details = []
1631+
packages = _get_packages_for_provider(name)
1632+
for pkg in packages:
1633+
total = pkg.get("total_credits")
1634+
used = pkg.get("used_credits", 0)
1635+
expiry = pkg.get("expiry_date")
1636+
if total is not None and total > 0:
1637+
remaining = total - used
1638+
remaining_ratio = remaining / total
1639+
# Score based on remaining ratio (0-5 points)
1640+
remaining_score = min(5, int(remaining_ratio * 5))
1641+
expiry_score = 0
1642+
if expiry:
1643+
try:
1644+
expiry_date = date.fromisoformat(expiry)
1645+
days_left = (expiry_date - date.today()).days
1646+
if days_left > 0:
1647+
# Prefer packages that expire soon (use them up)
1648+
if days_left <= 7:
1649+
expiry_score = 5
1650+
elif days_left <= 30:
1651+
expiry_score = 2
1652+
except ValueError:
1653+
pass
1654+
package_score += remaining_score + expiry_score
1655+
package_details.append(
1656+
{
1657+
"package_id": pkg.get("package_id"),
1658+
"remaining": remaining,
1659+
"total": total,
1660+
"expiry_date": expiry,
1661+
"remaining_ratio": remaining_ratio,
1662+
}
1663+
)
15861664
image_score = 0
15871665
image_policy_score = 0
15881666
image_outputs_fit = True
@@ -1641,6 +1719,7 @@ def _provider_dimension_details(
16411719
+ freshness_score
16421720
+ kilo_score
16431721
+ recovery_score
1722+
+ package_score
16441723
+ image_score
16451724
+ image_policy_score
16461725
- adaptation_penalty
@@ -1671,6 +1750,8 @@ def _provider_dimension_details(
16711750
"kilo_reasons": list(kilo_fit.get("reasons") or []),
16721751
"adaptation_penalty": adaptation_penalty,
16731752
"recovery_score": recovery_score,
1753+
"package_score": package_score,
1754+
"package_details": package_details,
16741755
"image_score": image_score,
16751756
"image_policy_score": image_policy_score,
16761757
"headroom": headroom,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "faigate"
7-
version = "1.16.0"
7+
version = "1.17.0"
88
description = "Local OpenAI-compatible routing gateway for OpenClaw and other AI-native clients."
99
readme = "README.md"
1010
license = "Apache-2.0"

0 commit comments

Comments
 (0)