diff --git a/CHANGELOG.md b/CHANGELOG.md index 3609a2f..624c3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # fusionAIze Gate Changelog +## Unreleased + +### Changed + +- Reframed Claude-native bridge model aliases around routing intent instead of direct frontier spend: built-in Claude Code model ids now resolve to `auto`, `premium`, or `eco` so Gate can still choose the cheapest capable route for the request +- Tightened the shipped config and integration examples around coding auto modes, so `claude`, `opencode`, `openclaw`, and related coding clients can share clearer `coding-auto`, `coding-fast`, and `coding-premium` entry points instead of muddled provider-first defaults +- Updated the roadmap and implementation plan to prioritize cost-aware coding auto modes first, then stronger product surfaces and licensing-aware stack boundaries for Gate as a standalone product +- Added a dedicated dashboard IA document so the next web and shell dashboard work is grouped around operator jobs such as overview, providers, clients, routes, analytics, request log, integrations, and troubleshooting instead of one long admin surface + ## v1.13.0 - 2026-03-30 ### Added diff --git a/README.md b/README.md index eb00949..8fccd5c 100644 --- a/README.md +++ b/README.md @@ -432,6 +432,7 @@ Start here for the deeper deployment details: - [Publishing](./docs/PUBLISHING.md) - [Troubleshooting](./docs/TROUBLESHOOTING.md) - [Adaptive model orchestration](./docs/ADAPTIVE-ORCHESTRATION.md) +- [Dashboard IA](./docs/DASHBOARD-IA.md) - [Roadmap](./docs/FAIGATE-ROADMAP.md) - [Implementation plan](./docs/IMPLEMENTATION-PLAN.md) - [Claude Desktop feasibility](./docs/CLAUDE-DESKTOP-FEASIBILITY.md) diff --git a/config.yaml b/config.yaml index b663d73..63464d3 100644 --- a/config.yaml +++ b/config.yaml @@ -52,21 +52,9 @@ client_profiles: - default routing_mode: eco claude: - prefer_tiers: - - default - - mid - - high - - reasoning - routing_mode: auto + routing_mode: coding-auto codex: - prefer_providers: - - deepseek-chat - - anthropic-haiku - - gemini-flash - prefer_tiers: - - default - - mid - routing_mode: auto + routing_mode: coding-auto deepseek-cli: prefer_providers: - deepseek-chat @@ -87,35 +75,14 @@ client_profiles: - mid routing_mode: auto kilocode: - prefer_tiers: - - default - - mid - - high - - reasoning - routing_mode: auto + routing_mode: coding-fast openclaw: - prefer_tiers: - - default - - reasoning - routing_mode: auto + routing_mode: coding-auto opencode: - prefer_tiers: - - default - - mid - - high - - reasoning - routing_mode: auto + routing_mode: coding-auto # ── opencode named profiles (selectable as model in opencode UI) ───────── coding: - prefer_providers: - - deepseek-reasoner - - deepseek-chat - - anthropic-haiku - prefer_tiers: - - reasoning - - default - - mid - routing_mode: auto + routing_mode: coding-auto coding-free: prefer_providers: - kilocode @@ -126,15 +93,7 @@ client_profiles: - cheap routing_mode: free research: - prefer_providers: - - gemini-pro-high - - anthropic-sonnet - - anthropic-claude - prefer_tiers: - - high - - premium - - mid - routing_mode: auto + routing_mode: premium rules: - match: any: @@ -929,6 +888,14 @@ anthropic_bridge: claude-code: auto claude-code-fast: eco claude-code-premium: premium + claude-sonnet-4-6: auto + claude-sonnet-4-6-20251001: auto + claude-sonnet-4-6[1m]: auto + claude-opus-4-6: premium + claude-opus-4-6-20251001: premium + claude-opus-4-6[1m]: premium + claude-haiku-4-5: eco + claude-haiku-4-5-20251001: eco routing_modes: default: auto enabled: true @@ -943,6 +910,63 @@ routing_modes: - default - reasoning - mid + coding-auto: + aliases: + - code-auto + - dev-auto + best_for: Daily coding with strong cost control + description: Cheapest capable coding route + savings: High savings + select: + capability_values: + cost_tier: + - free + - cheap + - standard + prefer_tiers: + - default + - cheap + - fallback + - reasoning + - mid + routing_mode: eco + coding-fast: + aliases: + - code-fast + - dev-fast + best_for: Fast iterative coding loops + description: Speed-first coding workhorse + savings: High savings + select: + capability_values: + cost_tier: + - free + - cheap + - standard + prefer_tiers: + - cheap + - default + - fallback + prefer_providers: + - gemini-flash-lite + - gemini-flash + - anthropic-haiku + - deepseek-chat + routing_mode: eco + coding-premium: + aliases: + - code-premium + - dev-premium + best_for: Architecture, review, and high-risk coding changes + description: Strongest coding lanes with cost still visible + savings: Lowest savings + select: + prefer_tiers: + - reasoning + - default + - mid + - high + routing_mode: premium eco: aliases: [] best_for: Maximum savings diff --git a/docs/DASHBOARD-IA.md b/docs/DASHBOARD-IA.md new file mode 100644 index 0000000..cbf3732 --- /dev/null +++ b/docs/DASHBOARD-IA.md @@ -0,0 +1,455 @@ +# fusionAIze Gate Dashboard IA + +## Why this exists + +Gate already has more runtime substance than the current dashboard surface + communicates. + +We can already answer many operator questions through: + +- `/health` +- `/api/providers` +- `/api/stats` +- `/api/traces` +- `/api/recent` +- provider catalog and refresh guidance +- lane family and request-readiness metadata + +The gap is no longer "do we have data?". +The gap is "do we present the right information in the right order for the + operator's real jobs?". + +This document turns that into a product-surface plan. + +## What to borrow from LLM AIRouter + +The useful parts are not the hosted funnel or the copy. +The useful parts are the clear information architecture and the way the docs + and dashboard map to operator jobs. + +Worth adapting: + +- a clearer top-level split between overview, providers, analytics, stacks, + request history, and setup +- docs that explain one concept per page instead of burying everything in one + long reference +- explicit quickstart, CLI-tools, troubleshooting, cache, circuit-breaker, and + cost-management surfaces +- a product surface that feels like a coherent tool, not a pile of local + endpoints + +Not worth copying directly: + +- the hosted-router onboarding shape +- opaque "stack" abstractions that hide the actual route and YAML reality +- claims about secret storage or hosted key management that do not match Gate's + local-first design + +## What Gate should do differently + +Gate should not become "LLM AIRouter, but local". + +Gate's product advantage is different: + +- local-first +- operator-owned config +- agent-native +- direct, aggregator, and local-worker routes in one scoring core +- canonical lanes instead of provider-string roulette +- explainable routing, not black-box stack magic + +That means the dashboard should optimize for these questions: + +1. Is my gateway safe and request-ready right now? +2. Which clients are burning money or taking the slow path? +3. Which lane and route did Gate choose, and why? +4. Which providers, aggregators, or local workers are unhealthy, stale, or + quota-coupled? +5. What should I change next? + +## Design principles + +### 1. Jobs first, metrics second + +Each page should answer one operator job clearly. + +The operator should not need to mentally reconstruct: + +- where health lives +- where cost lives +- where route explainability lives +- where setup help lives + +### 2. Confidence before detail + +The first screen should answer: + +- service up? +- request-ready? +- fallback pressure? +- premium spend? +- top issue? + +The second step can show tables. + +### 3. Explainability by default + +If Gate used an expensive route, downgraded a lane, skipped a provider, or + protected a premium quota, the UI should say so plainly. + +### 4. Progressive disclosure + +Overview should stay compact. +Provider, route, and request detail pages can go deep. + +### 5. Read-only first, action-linked second + +The web surface should stay operationally safe. +In the near term it should link clearly to helper CLIs and config-edit flows + rather than pretending to be a full control plane. + +### 6. Distinctive but disciplined design + +The dashboard should feel intentionally designed, not like a default admin + table. + +That means: + +- stronger visual hierarchy +- clearer section identity +- better grouping +- more purposeful typography and color +- fewer undifferentiated tables + +It does not mean turning Gate into a heavy frontend app. + +## Recommended dashboard areas + +## 1. Overview + +Primary operator job: + +- "Tell me if Gate is healthy, trustworthy, and worth touching right now." + +Should show: + +- request-ready summary +- healthy vs unhealthy routes +- premium spend and 24h spend +- fallback share +- top alert +- top client +- top lane family +- "priority next" block + +Why this matters: + +- current cards already expose many of these signals +- they are just not grouped around one first-run confidence story yet + +## 2. Providers + +Primary operator job: + +- "Which upstreams exist, which ones are really usable, and which ones are the + weak links?" + +Should show: + +- provider identity and route type +- canonical lane and lane family +- request-readiness +- billing mode +- quota group / quota isolation +- health and runtime penalties +- freshness status +- route-add recommendation when a family is fragile + +Why this matters: + +- this is where Gate differentiates from simple proxy routers +- route-aware aggregator handling has to be visible here, not hidden in traces + +## 3. Clients + +Primary operator job: + +- "Which tools are using Gate, and which of them need cheaper or safer + defaults?" + +Should show: + +- client profile +- client tag +- request / token / cost totals +- failure rate +- average latency +- recommended scenario or routing mode +- top expensive / slow / failure-heavy clients + +Why this matters: + +- Gate is not only provider-native, it is client-native +- Claude Code, opencode, openclaw, Codex, and automation clients should be + legible as distinct traffic shapes + +## 4. Routes + +Primary operator job: + +- "Why did Gate choose this path instead of another one?" + +Should show: + +- chosen canonical lane +- chosen execution route +- selection path +- same-lane fallback vs cluster downgrade +- route penalty / cooldown / recovery state +- why-not-selected summaries for important skipped candidates + +Why this matters: + +- this is the bridge between "smart router" and "trustworthy operator tool" + +## 5. Analytics + +Primary operator job: + +- "Where is my money going, and how is traffic shifting over time?" + +Should show: + +- spend by client +- spend by lane family +- spend by provider / route type +- token trends +- fallback trend +- premium escalation share +- cache hit share +- projected monthly spend + +Why this matters: + +- the current raw stats are strong enough to build this now +- the UI should make cost-management a first-class narrative, not a derived + exercise + +## 6. Request Log + +Primary operator job: + +- "What just happened?" + +Should show: + +- recent requests +- route traces +- selected provider +- model / lane +- layer and rule +- success / failure +- latency +- trace id + +Why this matters: + +- request history is the fastest debugging entry point +- it should be easy to pivot from recent request -> trace -> provider detail + +## 7. Catalog + +Primary operator job: + +- "Are my assumptions about providers still fresh enough to trust?" + +Should show: + +- tracked sources +- due refreshes +- stale benchmark assumptions +- pricing drift alerts +- provider discovery guidance +- explicit next review action + +Why this matters: + +- Gate's provider-catalog and freshness model are already stronger than most + router surfaces +- this should be productized instead of buried + +## 8. Integrations + +Primary operator job: + +- "How do I wire my actual tools into Gate?" + +Should show: + +- Claude Code +- opencode +- openclaw +- Codex CLI +- Cline / Continue / Cursor-style OpenAI-compatible paths +- n8n and scripts + +Should include: + +- copy/paste env vars +- recommended model ids or routing modes +- note on when to use `auto` vs `coding-auto` vs `premium` + +Why this matters: + +- LLM AIRouter is right that tool setup deserves first-class visibility +- Gate already supports more surfaces than "just one CLI", so this should be a + stronger differentiator for us + +## 9. Troubleshooting + +Primary operator job: + +- "Something is wrong. What is the shortest path to the fix?" + +Should show: + +- unauthorized / missing key +- provider unhealthy / request-not-ready +- quota-domain confusion +- slow responses +- model not found +- bridge-compatibility mismatch +- local-worker reachability + +Why this matters: + +- user-centered design is not only about happy-path polish +- it is also about fast recovery when things break + +## Suggested navigation model + +Recommended top-level web navigation: + +- Overview +- Providers +- Clients +- Routes +- Analytics +- Request Log +- Catalog +- Integrations +- Troubleshooting + +Recommended shell helper mapping: + +- `faigate-dashboard --overview` +- `faigate-dashboard --providers` +- `faigate-dashboard --clients` +- `faigate-dashboard --activity` +- `faigate-dashboard --alerts` +- future: + - `--routes` + - `--catalog` + - `--integrations` + - `--troubleshooting` + +## Near-term implementation shape + +### `v1.15.x` first slice + +Ship the information architecture before chasing fancy visuals. + +Minimum meaningful surface: + +- overview +- providers +- clients +- routes +- analytics +- request log + +These can still be read-only and no-build. + +### `v1.15.x` second slice + +Add stronger operator guidance: + +- priority-next cards +- clearer expensive-client and premium-escalation flags +- quota-domain and billing-mode visibility +- route drilldowns with same-lane vs downgraded explanation + +### `v1.15.x` third slice + +Add setup and docs integration: + +- integrations page +- troubleshooting page +- quick links into helper CLIs and relevant docs + +## Design direction + +The current dashboard should evolve from "dense local admin page" to "operator + cockpit". + +Recommended visual moves: + +- stronger left-rail or top-nav sectioning +- overview cards grouped by confidence, spend, traffic, and actions +- more contrast between healthy, degraded, stale, and expensive states +- more deliberate typography pairing between headings and tabular detail +- fewer giant all-purpose tables +- compact detail panels that answer "why this matters" inline + +The target feeling: + +- serious +- technical +- calm under pressure +- more distinctive than default admin templates +- still lightweight enough to ship as part of Gate + +## Licensing boundary + +### Tier A — Apache 2.0 + +Should include: + +- local read-only dashboard +- provider, client, route, and request-log views +- local analytics from Gate's own metrics +- integrations and troubleshooting pages +- catalog freshness and route-readiness visibility + +These features strengthen adoption and product clarity. +They should stay in the open Gate surface. + +### Tier B — source-available or premium packs + +Reasonable later candidates: + +- saved custom views and operator alerts +- richer cost analytics overlays +- policy simulation and route what-if tools +- budget packs and team-aware dashboards + +### Tier C — commercial control plane + +Reasonable later candidates: + +- shared multi-instance dashboards +- org-wide governance and audit +- Grid-backed fleet visibility +- RBAC and centralized rollout controls + +## Success criteria + +The dashboard work is successful when a new operator can answer these questions + within a few minutes: + +1. Which client is costing me the most? +2. Which provider or route is currently the weakest link? +3. Are expensive lanes being used because they are needed, or because my + defaults are bad? +4. Can I explain the last major routing decision? +5. What is the next safest action to improve cost, reliability, or setup? diff --git a/docs/FAIGATE-ROADMAP.md b/docs/FAIGATE-ROADMAP.md index 483efca..f3076f1 100644 --- a/docs/FAIGATE-ROADMAP.md +++ b/docs/FAIGATE-ROADMAP.md @@ -31,7 +31,7 @@ This means the roadmap no longer needs to ask whether Gate should become a multi ## Parity Targets -The roadmap now treats three parity goals as distinct targets, not as one fuzzy promise. +The roadmap treats three parity goals as distinct targets, not one fuzzy promise. ### Full Anthropic parity @@ -77,12 +77,23 @@ Includes: Strategically, this matters beyond personal convenience. If Gate can serve Claude Desktop cleanly, it proves the local Claude-native gateway story much more strongly than API compatibility alone. -### `v1.14.x`: Claude-native daily-use hardening +## Current release target: `v1.14.x` + +`v1.13.0` shipped the Anthropic bridge as an opt-in early-adopter line. + +The next release should not chase more protocol breadth first. It should make +the existing gateway meaningfully cheaper and more trustworthy for daily coding +traffic across Claude Code, opencode, openclaw, and similar clients. + +### `v1.14.x`: coding auto modes plus Claude-native daily-use hardening This is the highest-leverage next line. Primary goals: +- make the cheapest capable route the default for coding traffic instead of + burning Sonnet or Opus too early +- align client profiles and named routing modes around the same routing intent - make the Anthropic bridge comfortable for real Claude Code workflows - close the highest-value Anthropic protocol gaps - prepare the bridge for a serious Claude Desktop parity track immediately after @@ -91,15 +102,29 @@ Primary goals: Expected slices: -1. SSE streaming parity for `/v1/messages` -2. fuller Anthropic block compatibility beyond the current text plus basic tool flow -3. stronger Claude-client validation fixtures and operator troubleshooting -4. sharper error and stop-reason compatibility +1. map Claude-native ids to routing intent instead of direct frontier providers + - `claude-sonnet-* -> auto` + - `claude-opus-* -> premium` + - `claude-haiku-* -> eco` +2. add and align coding routing modes + - `coding-auto` + - `coding-fast` + - `coding-premium` +3. stronger client defaults for + - `claude` + - `opencode` + - `openclaw` + - `codex` +4. SSE streaming parity for `/v1/messages` +5. fuller Anthropic block compatibility beyond the current text plus basic tool flow +6. stronger Claude-client validation fixtures and operator troubleshooting +7. sharper error and stop-reason compatibility Non-goals: - exact provider-side token counting for every backend - "full parity" marketing language before live client coverage proves it +- hosted or multi-user control-plane features ### `v1.15.x`: Claude Desktop parity or adaptive orchestration trust @@ -277,4 +302,717 @@ Recent shipped lines, newest first: Detailed design notes for the orchestration track still live in [Adaptive model orchestration](./ADAPTIVE-ORCHESTRATION.md). +<<<<<<< HEAD The next concrete execution line is tracked in [Implementation plan](./IMPLEMENTATION-PLAN.md). +======= +ClawRouter's transport binding model (`direct`, `wallet-router`, `aggregator`) is well-designed and faigate should adopt its vocabulary in `lane_registry.py` — this is already partly done (`route_type: direct / aggregator / wallet-router`). The area where faigate leads is the full provider-intelligence layer: ClawRouter does not model benchmark clusters, cache semantics, or per-client signal scoring. + +What faigate can learn from ClawRouter: deeper agent-native transport contracts, richer `x-openclaw-*` header semantics for multi-agent delegation flows. + +### Product surface priorities from LLM AIRouter and ClawRouter + +ClawRouter is strongest at framing the routing promise clearly: cheapest capable +model, explicit policies, and a legible routing pipeline. + +LLM AIRouter is strongest at framing the operating surface clearly: overview, +providers, analytics, stacks, routes, request log, provider limits, CLI tools, +and settings in one coherent dashboard story. + +The product goal for Gate is to combine both advantages without inheriting their +hosted-first or wallet-first assumptions: + +- local-first and operator-owned by default +- agent-native, not just app-dashboard-native +- one runtime that works for Claude Code, opencode, openclaw, n8n, curl, and custom apps +- explicit route intelligence, not black-box “AI chooses for you” marketing + +That means the next product-surface slices should be: + +1. overview dashboard that makes provider health, spend, lane families, and recent routing visible in one glance +2. providers view that exposes route type, quota domain, billing mode, lane family, and current readiness +3. analytics view that ties cost, token usage, and routing posture back to concrete clients and stacks +4. stacks view for named route bundles such as coding-default, coding-premium, local-only, or Claude-safe mirrors +5. routes and request-log views that explain why one route won and why cheaper alternatives lost +6. CLI and helper-tool surface as a first-class product feature, not a fallback for when the dashboard is missing something + +That should now be read more explicitly through operator jobs: + +1. `Overview` + - "is Gate safe and request-ready right now?" +2. `Providers` + - "which routes are usable, degraded, stale, or quota-coupled?" +3. `Clients` + - "which tools are expensive, slow, or misprofiled?" +4. `Routes` + - "why did Gate choose this lane and route?" +5. `Analytics` + - "where is the spend and fallback pressure?" +6. `Request Log` + - "what just happened?" +7. `Catalog` + - "are my provider assumptions still fresh enough to trust?" +8. `Integrations` + - "how do I wire Claude Code, opencode, openclaw, Codex, automation clients, and custom apps quickly?" +9. `Troubleshooting` + - "what is the shortest path from symptom to fix?" + +### Licensing and product-boundary read on those surfaces + +These surface expansions should follow the existing fusionAIze stack boundary: + +**Tier A — Apache 2.0 core** + +- local dashboard views over Gate's own runtime state +- provider inventory, lane metadata, route readiness, and request traces +- stack definitions and route explainability +- helper CLIs and exportable local reports + +**Tier B — source-available operator packs** + +- advanced alerts, saved routing policies, and heavier analytics overlays +- longer retention, richer usage forensics, and external callback packs +- team-aware budget controls and higher-level stack templates + +**Tier C — commercial control plane** + +- multi-instance shared state +- hosted or managed control-plane views +- org RBAC, audit trails, and enterprise governance overlays +- Grid/OS coordination features that should not bloat the local Gate runtime + +--- + +## `v1.8.0` to `v1.11.x`: adaptive model orchestration (original sequence for reference) + +Primary goals: + +- treat providers, aggregators, and direct routes as execution paths to canonical model lanes rather than as one flat list of alternatives +- let scenarios such as `quality`, `balanced`, `eco`, and `free` choose the right lane threshold and degradation path instead of only choosing a provider tier +- preserve same-lane quality when direct quota is exhausted by trying equivalent aggregator routes before dropping to a weaker model cluster +- keep benchmark and cost assumptions visible, curated, and refreshable so "magical" routing still stays explainable + +Release sequence: + +1. `v1.8.0` ✅ lane registry, provider lane metadata, and route-aware catalog surfaces +2. `v1.9.0` ✅ lane-aware router scoring and "why this lane?" traces +3. `v1.9.1` ✅ routing bug fixes, signal group expansion, mode-override hook +4. `v1.9.2`: pre-failure RPM/TPM headroom, trace-id header +5. `v1.10.x`: provider intelligence layer (capability tags, benchmark ranks, cache TTL, TTFT, pricing freshness) +6. `v1.11.x`: virtual key layer, gateway-level response caching, webhook observability, guardrail hooks +7. `v2.x`: team/org budget hierarchy, multi-instance Grid coordination, semantic caching, OTEL + +Non-negotiable guardrails: + +- never hide a downgrade from operators +- prefer same-lane route substitution before weaker-model degradation +- keep old configs compatible while lane metadata is introduced +- treat benchmarks and cost heuristics as curated operational inputs, not as magic constants + +## `v1.5.0`: guided control-center UX + +Primary goals: + +- make the standalone Gate shell feel like the first serious product surface instead of a loose set of helper scripts +- introduce one obvious happy path for first setup, validation, restart, and client connection +- replace raw JSON-first operator views with compact human summaries plus drill-downs where needed +- keep the Gate UX aligned with the later Grid orchestration direction so the products feel like one family + +Recommended minimal slices: + +1. `Quick Setup` happy path inside `faigate-menu` +2. compact summary cards for gateway, config, providers, and clients in the main operational menus +3. shorter, recommendation-first client quickstarts with per-client drilldown instead of long first-contact dumps +4. explicit next-step receipts after wizard, validation, restart, and client-setup actions + +Guardrails: + +- keep the shell UX scriptable and helper-driven; do not turn `faigate-menu` into a full-screen TUI yet +- prefer compact default output plus optional detail/raw views over large payload dumps +- keep wording calm and operational, especially when health, service-manager state, and bound port state disagree + +Post-`1.5.0` UX items already worth bookmarking: + +- readiness score and richer setup progress scoring +- port/runtime conflict auto-detection with one-step recovery suggestions +- client route previews that show where a given client would land right now +- richer action receipts and broader `what to do next` guidance +- more compact client cards before the long quickstart text + +## Licensing strategy + +The fusionAIze stack uses a three-tier open-core model. The tier boundaries are defined here before the features exist so there are no retroactive surprises for the community. + +**Non-negotiable rule**: a feature that ships as Tier A will never be moved to Tier B or Tier C. Only newly-built features can be Tier B or Tier C from day one. + +This is the lesson from LiteLLM's BSL transition: moving the proxy from Apache 2.0 to BSL 1.1 after the community had adopted it created lasting distrust and reputational damage. faigate will not repeat that mistake. + +### Tier A — Apache 2.0 (permanent) + +The full local gateway runtime, as it exists and as it will continue to evolve through routine improvements: + +- baseline gateway core: routing engine, heuristic rules, hook pipeline, fallback chains +- all provider adapters: direct, aggregator, wallet-router +- all built-in request hooks: locality, prefer-provider, profile-override, mode-override +- client profile system and opencode / openclaw / n8n / cli profiles +- config schema and YAML format +- SQLite metrics store and trace recording +- operator dashboard (read-only) +- `/api/route`, `/api/stats`, `/api/traces`, `/api/providers` endpoints +- all helper scripts: `faigate-menu`, `faigate-doctor`, `faigate-status`, `faigate-update`, etc. +- Homebrew formula and packaging +- everything shipped through v1.9.x and all future routine routing improvements + +### Tier B — Source-available (open-core) + +Features built for operators who run faigate at team or production scale. Defined as Tier B before they are built: + +- virtual key layer (`max_budget`, `budget_duration`, `rpm_limit`, `allowed_models`, key lifecycle) +- per-key budget enforcement and spend ledger +- webhook / callback observability output to external sinks (Langfuse, Helicone, Datadog) +- advanced guardrail hook implementations (PII detection via Presidio, prompt injection via Lakera) +- named routing strategy weight presets as a commercial operator convenience +- gateway-level response caching with Redis backend +- team and org budget hierarchy + +### Tier C — Proprietary / commercial (fusionAIze OS) + +Control-plane features that belong with the broader fusionAIze stack, not with the local gateway runtime: + +- managed control plane (fusionAIze Grid / OS) +- SSO / SAML / OIDC authentication for the operator UI +- RBAC and audit logs for team and org management +- multi-instance shared state and distributed rate-limit coordination (Grid) +- enterprise SLAs and priority support + +### Product stack and tier mapping + +| Product | Role | Tier | +|---|---|---| +| **Gate** | Local-first routing runtime | A core + selective B | +| **Lens** | Observability and spend analytics consuming Gate `/api/stats`, `/api/traces`, webhook events | B–C | +| **Grid** | Multi-instance coordination: distributed rate limits, shared virtual key registry, cross-instance cache | C | +| **OS** | SSO, RBAC, audit logs, team management — LiteLLM Enterprise's territory | C | +| **Fabric** | Content policy and guardrail enforcement via Gate's hook seam | B–C | + +## `v1.3.0`: guided setup and catalog-assisted updates + +Primary goals: + +- make first setup and later provider updates realistic without turning `config.yaml` into hand-edited drift bait +- keep routing modes, client defaults, and provider selection understandable across many clients +- improve provider-catalog freshness and update suggestions without silently rewriting operator intent +- start the provider-discovery and recommendation-link line only in a transparency-first, metadata-first shape + +Recommended minimal slices: + +1. wizard candidate selection, update suggestions, dry-run summaries, and backup-aware writes +2. provider-catalog source metadata, offer-track volatility flags, and freshness alerts +3. wizard and CLI usage polish so the guided flow is self-explanatory from `--help` +4. optional provider recommendation-link metadata with explicit disclosure, but still no ranking changes based on provider-link metadata + +Guardrails for any recommendation-link work in this line: + +- recommendation ranking must never use provider-link metadata as an input and must stay performance-led, preferring fit, quality, health, capability, and cost behavior +- provider-link metadata should stay operator-owned and secret-backed, not embedded in user-editable client configs +- docs and CLI output should disclose clearly when a shown signup link is informational only +- the first slice should be metadata and display only; managed short links, browser control-center surfaces, and richer landing-page flows can come later + +## `v1.2.0`: workstation operations baseline + +Primary goals: + +- add a dedicated workstation operations guide +- document macOS `launchd` as a first-class local-runtime path +- document Windows Task Scheduler / PowerShell as the baseline Windows path +- keep development checkouts and runtime installs clearly separated +- add a project-owned Homebrew packaging path for macOS workstations + +Recommended minimal slices: + +1. workstation baseline docs and path layout +2. macOS `launchd` example and instructions +3. Windows startup examples and documentation +4. optional lightweight install helpers only if the docs prove insufficient +5. Homebrew formula and `brew services` guidance for the packaged macOS path + +## Post-1.0 direction + +The first post-`1.0` block should stay narrow enough to ship as `v1.1.0`. + +Primary goals: + +- double-check and extend AI-native client support beyond the current OpenClaw, n8n, and CLI baseline +- ship the next wave of integration starters for requested and high-signal agent frameworks +- expose more useful per-client token and usage metrics in the operator surface +- audit the routing-stage stack so the responsibility of each layer stays clear +- keep a structured watch on ClawRouter-style product evolution without copying features blindly + +The current framework prioritization lives in [AI-NATIVE-MATRIX.md](./AI-NATIVE-MATRIX.md). + +## Big Picture + +The opportunity is not to build another thin router. + +The opportunity is to build a reusable AI gateway plane that works across: + +- local model workers +- direct provider APIs +- proxy providers +- OpenClaw +- workflow systems such as n8n +- CLI-native development environments +- agent tools +- future AI-native SaaS products + +If the core stays disciplined, fusionAIze Gate can become the common routing and policy layer shared by several products without collapsing into a bloated platform. + +That is the target shape: + +- one gateway core +- many providers +- many clients +- optional context and optimization layers +- clear operational boundaries + +## Design principles + +### 1. Gateway first + +fusionAIze Gate should stay a gateway and control plane, not a monolithic platform. + +### 2. Standard protocols first + +If a client can use the OpenAI-compatible API cleanly, keep it on that path before building a custom adapter. + +### 3. Multi-dimensional routing + +The design target is to exceed simpler router behavior by making routing explicitly multi-dimensional. + +That means fusionAIze Gate should increasingly consider: + +- capability support +- health and latency +- cost tier +- local vs cloud locality +- context window size +- cache behavior and cache pricing +- tool usage +- client identity +- modality requirements +- compliance or tenancy constraints + +The intent is not to claim that this is fully implemented today. The intent is to make this the guiding routing architecture. + +### 4. Optional extension layers + +Context, memory, optimization, and sidecar adapters should plug into the gateway cleanly, not become mandatory core behavior. + +## Current runtime baseline + +Today the runtime already supports: + +- one OpenAI-compatible endpoint +- multiple providers behind a single local base URL +- policy, static, heuristic, client-profile, and optional LLM-assisted routing stages +- direct model pinning and fallback chains +- local worker contracts and health probes +- route introspection and traces +- client-aware routing defaults for OpenClaw, n8n, and CLI callers + +The next runtime gap to close is not “more core abstraction”. It is “more real clients with less glue”. + +## `v1.1.0`: AI-native client expansion and operator visibility + +Primary goals: + +- add the first post-`1.0` starter wave for requested and high-signal AI-native clients +- add a curated framework matrix so external users can quickly see where fusionAIze Gate fits +- deepen client and token reporting in API and dashboard surfaces +- review policy, static, heuristic, hook, client-profile, and classifier boundaries with clearer ownership and tests + +Recommended minimal slices: + +1. AI-native client matrix plus roadmap update +2. first-wave starter templates for `SWE-AF`, `paperclip`, `ship-faster`, and the highest-fit external frameworks +3. per-client token and usage reporting in stats and dashboard views +4. routing-layer review plus targeted rule/test cleanup + +The plugin question should stay explicitly out of scope for `v1.1.0` and be revisited only after this release line lands. + +## OpenClaw direction + +OpenClaw remains a first-class integration surface. + +Current coverage: + +- one-agent traffic through the normal OpenAI-compatible path +- many-agent or delegated traffic through the same path with `x-openclaw-source` +- OpenClaw-side model aliases and profile defaults + +Near-term direction: + +- document one-agent and many-agent behavior explicitly +- keep the integration header-based and OpenAI-compatible +- avoid forking the core gateway logic just for OpenClaw + +## Modality expansion + +Inspired by the value of image-router patterns in other gateways, fusionAIze Gate should eventually support modality-aware routing beyond chat. + +Planned direction: + +- add a provider contract for image-generation-capable backends +- add modality-aware request classification +- route image tasks to the right backend without polluting the chat path + +This is a roadmap item, not a current runtime claim. + +## Architecture direction + +### Gateway core + +Responsibilities: + +- request normalization +- route selection +- fallback handling +- timeout boundaries +- usage and trace recording +- operational endpoints + +### Provider layer + +Responsibilities: + +- cloud providers +- OpenAI-compatible proxies +- local workers +- future modality-specific providers + +### Client layer + +Responsibilities: + +- OpenClaw +- n8n and workflow clients +- CLI wrappers and proxy clients +- future AI-native app integrations + +### Optional extension layer + +Responsibilities: + +- request hooks +- context or memory enrichment +- optimization hooks +- policy overlays + +## Release path to v1.0.0 + +`v0.3.0` is the first public fusionAIze Gate release. The path to `v1.0.0` should stay incremental and reviewable. + +### `v0.4.x`: deeper routing and extension hardening + +Primary goals: + +- deepen multi-dimensional scoring beyond simple fit checks for cache behavior, context windows, provider limits, locality, latency, and recent failures +- keep refining the simple dashboard around traces, provider/client breakdowns, route visibility, and safe operator ergonomics +- keep OpenClaw one-agent and many-agent flows on the same OpenAI-compatible path with clearer defaults +- harden the request hook seam for context, memory, and optimization layers, including fail-closed behavior and input sanitization + +This release line should deepen the gateway core without turning it into a monolith. + +### `v0.5.0`: operator distribution baseline + +Primary goals: + +- add the first modality-aware provider contract, starting with image generation +- publish an official Docker release path +- publish fusionAIze Gate to PyPI +- add provider and client onboarding helpers for many-provider and many-client deployments +- add a publish dry-run path for Python package and GHCR validation before real release tags +- add validation workflows so operators can catch config mistakes before rollout +- complete the public community-health baseline and security-overview baseline for the repo + +This is the first release line where installation and upgrade paths should feel productized for external users. + +### `v0.6.x`: modality expansion + +Primary goals: + +- add modality-aware provider contracts, starting with image generation +- extend that contract toward image editing where the provider surface supports it +- keep chat and image paths explicit instead of mixing modality-specific behavior into one opaque route +- expose modality-aware health, provider inventory, and routing visibility in the dashboard and operational endpoints + +This should borrow the useful parts of image-router patterns without copying another gateway's product shape. + +### `v0.7.x`: operations polish + +Primary goals: + +- expand the release-check baseline into stronger update alerts so operators can see when a newer release is available +- add an optional automatic update enabler for controlled deployments +- improve route traces, metrics, and dashboard filters for providers, clients, and profiles +- keep the dashboard simple, read-heavy, and operationally safe + +This release line is about day-2 operations rather than new routing concepts. + +The first small slice in this line is to turn `GET /api/update` from a plain boolean check into an operator-facing alert surface with update type, alert level, and recommended action. + +The next small slice is to keep auto-update conservative: + +- disabled by default +- no checkout mutation over HTTP +- helper-driven and operator-triggered only +- major upgrades still manual unless explicitly allowed + +### `v0.8.x`: many-provider and many-client onboarding + +Primary goals: + +- make onboarding repeatable for many providers and many clients on one gateway +- ship clearer presets and validation for OpenClaw, n8n, CLI wrappers, and future AI-native applications +- reduce manual config editing for common deployment shapes +- tighten integration coverage for delegated or many-agent traffic where headers identify sub-clients + +The target is faster adoption without custom glue for every client. + +Current `v0.8.x` baseline already includes: + +- onboarding report plus validation helpers +- staged provider rollout reporting +- client matrix reporting +- starter templates for OpenClaw, n8n, CLI, cloud providers, local workers, and image providers +- matching provider `.env` starter files +- delegated OpenClaw request examples +- starter custom-profile examples for future AI-native applications +- doctor checks for missing provider env placeholders +- JSON and Markdown onboarding exports + +### `v0.9.x`: pre-1.0 hardening + +Primary goals: + +- stabilize request hook boundaries and extension contracts +- expand integration and functional test coverage across real client flows +- complete documentation review across README, onboarding, integrations, troubleshooting, and release docs +- close obvious operational gaps discovered during earlier releases + +This release line should leave `v1.0.0` focused on stability and security gates, not backlog cleanup. + +Current `v0.9.x` baseline is aimed at: + +- conservative response headers and dashboard CSP defaults +- explicit JSON and multipart size guardrails +- bounded routing and operator header handling +- broader functional API tests around dashboard, routing, and upload surfaces +- documentation updates that make the hardened defaults visible to operators + +### `v1.0.0`: stable gateway baseline + +Primary goals: + +- declare a stable fusionAIze Gate gateway baseline for local-first, multi-provider routing +- publish the first separate npm CLI package for fusionAIze Gate-adjacent CLI usage +- complete a comprehensive security review before release + +The `v1.0.0` security review should explicitly cover: + +- cross-site scripting and HTML or CSS injection risks in the dashboard +- request, header, and parameter injection risks in proxy and routing paths +- dependency vulnerabilities and unsafe defaults +- local-worker and upstream proxy trust boundaries +- auth, secret-handling, and writable-path assumptions + +`v1.0.0` should only ship after those review results are addressed or documented with a clear mitigation plan. + +Current `v1.0.0` baseline is aimed at: + +- dashboard CSP hardening without turning the no-build UI into a separate frontend app +- reduced leakage of upstream provider failure details in client responses +- clearer trust-boundary validation for provider base URLs +- a documented release-gate security review with explicit residual risks +- a separate npm CLI package that complements the Python gateway instead of replacing it + +## Updated near-term PR sequence + +The next sequence should ladder directly into the release path above: + +1. `feat(provider): add modality-aware provider contracts, starting with image generation` +2. `feat(provider): extend modality contracts toward image editing where supported` +3. `feat(onboarding): add provider/client onboarding helpers and validation workflows` +4. `feat(dist): add Docker release path and PyPI publishing baseline` +5. `feat(ops): add update alerts and an optional auto-update enabler for controlled deployments` +6. `feat(cli): define the separate npm or TypeScript CLI package path for the v1.0.0 line` + +## Check on the earlier sequence + +The earlier near-term sequence is now effectively complete up through the routing and observability foundation: + +1. `docs: add fusionAIze Gate roadmap and rename note` -> done +2. `feat(config): add provider capability schema` -> done +3. `feat(router): add policy-based provider selection` -> done +4. `feat(provider): add local worker provider contract` -> done +5. `feat(api): add client profile support` -> done +6. `feat(obs): add route introspection and policy metrics` -> done, and now extended with traces and local worker probing +7. `feat(ext): add optional request hook interfaces` -> done +8. `feat(router): add first multi-dimensional route-fit inputs for cache, context windows, provider limits, and locality` -> done +9. `feat(obs): harden the simple dashboard around traces, provider/client filters, and route visibility` -> done + +## Detailed near-term backlog + +### 1. Optional request hook interfaces + +Why: + +- this creates the seam for context, memory, and optimization layers without hard-coupling them + +Examples: + +- optional memory or context enrichment before routing +- request-shaping hooks for RTK-like CLI optimization +- operator-controlled extension points that can stay disabled by default + +### 2. Multi-dimensional routing inputs + +Why: + +- routing should understand more than keywords and simple tier preferences + +Examples: + +- cache-read vs cache-miss economics +- context window fit +- locality and policy constraints +- latency/health tradeoffs +- provider-specific max context and cache behavior + +### 3. Simple dashboard hardening + +Why: + +- fusionAIze Gate already exposes a dashboard endpoint, but operators need a clearer read-only control surface + +Examples: + +- route trace table with provider and client filters +- provider health panel with capabilities and contract type +- quick links to dry-run routing and recent failure context + +### 4. Image generation and editing routing + +Why: + +- multi-modal routing is a natural next expansion for a gateway plane + +Examples: + +- image-generation-capable provider contracts +- image-editing-capable provider contracts +- explicit modality routing so chat, image generation, and image editing stay understandable + +### 5. Provider and client onboarding helpers + +Why: + +- many-provider, many-client deployments need a clearer adoption path than manual config editing alone + +Examples: + +- bootstrap helpers for provider credentials and base URLs +- starter profiles for OpenClaw, n8n, CLI, and future AI-native applications +- preflight config validation before a rollout or restart + +### 6. Update alerts and optional automatic update enablers + +Why: + +- operators need a safer path than only ad hoc manual updates + +Current baseline: + +- cached release checks via `GET /api/update` +- dashboard visibility for current vs latest known release +- local helper access via `faigate-update-check` +- opt-in eligibility reporting and helper-driven apply flow via `faigate-auto-update` + +This should remain opt-in and operationally conservative as it expands toward scheduled helper use, stronger rollout controls, clearer operator approval boundaries, and small rollout-ring/channel distinctions. + +### 7. Distribution channels + +Why: + +- the project should become easier to adopt without coupling packaging strategy to one runtime + +Examples: + +- GitHub Releases as the default channel now +- Docker images and PyPI packages by `v0.5.0` +- a separate npm or TypeScript CLI package by `v1.0.0`, not a Node rewrite of the core gateway + +### 8. Security review as a release gate + +Why: + +- `v1.0.0` needs a credible stability and security bar, not just a larger feature list + +Examples: + +- dashboard rendering review for XSS and HTML or CSS injection paths +- request routing review for injection, header abuse, and unsafe forwarding behavior +- dependency and configuration review for known vulnerabilities and insecure defaults +- documentation review so security expectations and deployment assumptions are explicit + +## Documentation direction + +fusionAIze Gate should be understandable from the outside in under a few minutes. + +That means keeping these docs current: + +- README for the landing page +- architecture for technical orientation +- integrations for OpenClaw, n8n, CLI, and future clients +- onboarding for many-provider and many-client adoption +- troubleshooting for operators +- process docs for contributors + +## Review cadence + +Every 4 or 5 merged PRs, run a broader review pass: + +- review unit tests +- review integration tests +- review functional coverage against real workflows +- update every relevant doc +- refresh the roadmap and process docs if the direction changed + +This is necessary because fusionAIze Gate is evolving quickly and the docs can drift even when individual PRs are clean. + +## Provider discovery and recommendation links + +fusionAIze Gate should be able to help operators and end users discover suitable providers, but it should not turn recommendation output into a monetized marketplace. + +That means the future recommendation-link line should stay deliberately staged: + +### First slices that make sense soon + +- add optional provider-catalog fields for signup URLs, disclosure labels, and source ownership +- surface those links in CLI or later browser-based control-center output only when they are available and disclosed +- allow operator-managed secret or env-backed provider-link overrides rather than baking them into normal client-visible config + +### Later slices that make sense after that + +- optional managed short-link or landing-page wrappers +- richer provider discovery views in a small browser control center +- trust/performance signals derived from historical provider behavior, so recommendations can explain quality and reliability more concretely + +The non-negotiable rule is simple: recommendation quality must stay fully independent from provider-link metadata, and signup links may only follow from a recommendation rather than shaping it. + +## Assumptions + +- OpenAI-compatible HTTP remains the default interoperability surface in the near term +- OpenClaw, n8n, and CLI tools should keep sharing one gateway unless a client truly requires a dedicated adapter +- modality expansion should stay contract-driven instead of adding ad hoc special cases +- context, memory, and optimization remain optional layers around the gateway core +>>>>>>> b0b5a2e (feat: refine routing defaults and operator dashboard) diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index 0bf7d25..23c11af 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -1,10 +1,16 @@ # Implementation Plan +## Goal + +Turn Gate's existing routing intelligence into the default daily-use behavior +for Claude Code, opencode, openclaw, and similar clients, then expose that +intelligence through a stronger standalone product surface. + ## Scope -This document turns the current roadmap into the next concrete release lines. +This document turns the roadmap into the next concrete release lines. -It is intentionally biased toward the biggest product levers: +It stays biased toward the biggest product levers: - Claude-native daily usability - routing trust and operator explainability @@ -20,8 +26,6 @@ It is not a parking lot for every possible feature. - do routing explainability before stronger live adaptation - keep `v2.x` work behind clean product boundaries -## Release Sequence - ## Parity Definitions ### Full Anthropic parity @@ -51,7 +55,8 @@ Working definition: ### Full Claude Desktop parity -Daily-use parity for Claude Desktop against local Gate where endpoint override is supported. +Daily-use parity for Claude Desktop against local Gate where endpoint override +is supported. Working definition: @@ -61,192 +66,143 @@ Working definition: ## Release Sequence -### `v1.14.x` - Anthropic protocol hardening plus Claude Code daily-use parity - -Goal: - -- move the Anthropic bridge from early-adopter-safe to comfortable for everyday Claude Code use -- close the highest-value Anthropic protocol gaps at the same time - -Why this matters first: - -- it is the biggest remaining daily workflow gap -- it unlocks real Claude Code testing without client reshaping -- it sharpens the product story around one local endpoint for both OpenAI-native and Claude-native clients - -Target slices: - -1. SSE streaming parity for `/v1/messages` -2. stronger Anthropic block compatibility - - richer `tool_use` - - richer `tool_result` - - clearer unsupported block handling -3. stronger client-facing parity behaviors - - stop reasons - - version/beta handling - - error mapping consistency -4. expanded client-near validation - - Claude Code workflow validation - - Claude Desktop workflow notes and smoke steps +### `v1.14.x` - coding auto modes and Claude daily-use trust + +Primary outcome: + +- the cheapest capable route becomes the default for coding traffic instead of + hardwiring Sonnet or Opus too early + +Implementation slices: + +1. map Claude-native model ids to routing intent instead of direct frontier providers + - `claude-sonnet-* -> auto` + - `claude-opus-* -> premium` + - `claude-haiku-* -> eco` +2. add clear coding routing modes + - `coding-auto` + - `coding-fast` + - `coding-premium` +3. align default client profiles + - `claude` + - `opencode` + - `openclaw` + - `codex` +4. harden Anthropic streaming parity + - SSE streaming parity for `/v1/messages` + - mid-stream failure handling + - stop-reason correctness + - stronger `tool_use` / `tool_result` continuity across longer sessions +5. validate against real workflows + - Claude Code + - opencode + - openclaw Success bar: -- Claude Code can be pointed at local Gate and used for normal iterative coding flows with acceptable behavior +- Claude Code can be pointed at local Gate and used for normal iterative coding + flows with acceptable behavior - streaming and tool-oriented workflows do not immediately fall off the happy path - -Deliberately not required: - -- exact provider-side token counting -- full parity claims across every Anthropic client feature - -### `v1.15.x` - Claude Desktop parity or adaptive orchestration trust - -This release should be chosen by evidence, not by taste. - -Decision rule: - -- if Claude Desktop local usage validates as the next real operator lever, do the desktop parity line first -- otherwise take the routing-value line first - -#### Option A: Claude Desktop parity - -Goal: - -- make Claude Desktop a genuinely usable local client against Gate - -Target slices: - -1. endpoint-override and config-path validation for supported desktop flows -2. desktop-specific session and response compatibility hardening -3. clearer local testing and troubleshooting instructions -4. release-readiness validation for desktop workflows - -Success bar: - -- Claude Desktop can be used locally against Gate without feeling like a fragile workaround - -Current gating note: - -- only take this line next if [Claude Desktop feasibility](./CLAUDE-DESKTOP-FEASIBILITY.md) clears the endpoint-override and repeatable-local-workflow bar - -#### Option B: Adaptive orchestration trust - -Goal: - -- make route selection understandable and trustworthy enough that operators rely on it instead of overriding it by hand - -Why it is second: - -- the routing engine already does more than the docs and surfaces make obvious -- the biggest leverage now is visibility, structured lane semantics, and safer aggregator handling - -Target slices: - -1. canonical lane visibility - - route preview speaks in lanes first, transports second - - dashboard and provider views summarize lane families clearly -2. route-aware aggregator handling - - clearer mirror/same-lane semantics - - quota-isolated vs quota-coupled route handling - - stronger aggregator readiness language -3. benchmark and cost clusters - - structured cluster metadata - - freshness/review age - - operator-visible inputs -4. operator explainability - - why lane won - - why route won - - why same-lane mirror was skipped - - why downgrade happened - -Success bar: - -- operators can look at a route decision and understand it without reading source code -- aggregator handling feels intentional instead of “maybe a fallback” - -### `v1.16.x` - Remaining parity or live adaptation under pressure - -Goal: - -- adapt routing under quota, latency, and failure pressure without becoming opaque - -Why it is third: - -- dynamic adaptation is only worth shipping once lane and route semantics are already trusted -- any still-open Claude Desktop parity work should be resolved before promising a broader "full parity" story - -Target slices: - -1. live pressure scoring - - quota pressure - - latency inflation - - failure pressure - - fallback pressure -2. same-lane-first reactions - - mirror route before weaker cluster -3. operator controls - - conservative defaults - - visible adaptation state - - clear cooldown and recovery behavior -4. richer traces - - actual attempted route order - - same-lane fallback vs cluster degrade +- coding clients enter through clear auto modes instead of muddled provider-first + defaults + +Guardrails: + +- do not hide premium escalations +- do not bypass the scoring engine with provider aliases unless the operator + asked for an explicit concrete provider +- keep bridge routing inside the same core, not as a parallel router + +### `v1.15.x` - product surface and operator trust + +Primary outcome: + +- Gate becomes legible as a standalone product, not just a strong core hidden + behind config files +- the dashboard answers operator jobs in a sane order instead of dumping one + long admin page + +Implementation slices: + +1. overview dashboard + - request-readiness first + - provider health + - spend and token trend + - top alerts + - priority-next actions +2. providers surface + - route type + - lane family + - quota group + - billing mode + - readiness +3. clients surface + - cost by client + - latency by client + - profile recommendations + - premium-escalation hotspots +4. routes surface + - chosen lane + - chosen execution route + - same-lane fallback vs downgrade + - why selected / why not selected +5. analytics surface + - cost by client + - cost by stack + - cost by lane family + - routing posture distribution + - downgrade and fallback visibility +6. request-log and route drilldowns + - recent request stream + - trace-first debugging + - provider, client, and lane pivots +7. integrations and troubleshooting surface + - Claude Code + - opencode + - openclaw + - Codex / Cursor / Continue / automation setups + - common symptom-to-fix views + +Design constraints: + +- keep the web surface read-heavy and operationally safe first +- do not hide YAML, traces, or helper CLIs behind opaque UI abstractions +- borrow the clarity of LLM AIRouter's docs and page structure, not its + hosted-router assumptions +- make Gate feel more intentional and polished than a default admin panel + +Reference: + +- [Dashboard IA](./DASHBOARD-IA.md) + +### `v1.16.x` - adaptive orchestration trust + +Primary outcome: + +- richer route decisions without turning Gate into a black box + +Implementation slices: + +1. benchmark and cost cluster refinement +2. live pressure adaptation under quota, latency, and failure +3. stronger operator explainability per routing decision +4. same-lane-first reactions before weaker-cluster degrade +5. richer traces that show attempted route order and downgrade reasons Success bar: -- route switching under pressure is visible, understandable, and mostly unsurprising to operators - -## Deferred Lines - -### Exact provider-side token counting - -Recommendation: - -- defer until after `v1.14.x` -- implement first for the providers that expose reliable count endpoints or deterministic usage feedback -- keep the current bridge estimate until a route-specific exact path exists - -### Virtual keys and per-key budgets - -Recommendation: - -- likely next after the `v1.16.x` trust line if operator-scale controls become the top demand -- this is the prerequisite for later team/org budget hierarchy - -### OTEL trace-context forwarding - -Recommendation: - -- can move earlier than other `v2.x` items -- good candidate for a narrower cross-cutting observability release if demand is high - -### Team and org budget hierarchy - -Recommendation: - -- defer until virtual keys and spend ledger are genuinely stable - -### Grid shared-state coordination - -Recommendation: - -- design the contract early -- implement later -- keep Gate itself free of hard Redis/Postgres coupling - -### Semantic caching - -Recommendation: - -- do not start before exact caching plus usage evidence +- operators can look at a route decision and understand it without reading + source code +- route switching under pressure is visible, understandable, and mostly + unsurprising to operators ## Concrete Next Actions ### Immediate -1. merge docs cleanup and roadmap reset -2. open a focused `v1.14.x` feature branch for bridge streaming and Claude-native parity hardening -3. define the `v1.14.x` validation matrix before the implementation expands +1. finish the `v1.14.x` validation matrix +2. close remaining Claude-native daily-use gaps under real workflows +3. keep product-surface work operator-first and trace-friendly ### `v1.14.x` validation matrix @@ -279,6 +235,32 @@ Use this matrix when deciding whether a release truly moved parity forward: | Exact token counting | Strongly preferred | Helpful | Helpful | | Real client workflow validation | Not sufficient alone | Required | Required | +## Deferred Lines + +### Exact provider-side token counting + +Recommendation: + +- defer until after `v1.14.x` +- implement first for providers that expose reliable count endpoints or + deterministic usage feedback +- keep the current bridge estimate until a route-specific exact path exists + +### Virtual keys and per-key budgets + +Recommendation: + +- likely next after the `v1.16.x` trust line if operator-scale controls become + the top demand + +### OTEL trace-context forwarding + +Recommendation: + +- can move earlier than other `v2.x` items +- good candidate for a narrower cross-cutting observability release if demand + is high + ## Open Questions - which Claude Code workflows are still meaningfully blocked after streaming lands? diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index c769f78..5050c90 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -146,7 +146,11 @@ If you want a small Node-facing helper instead of shell aliases, the separate np - starter: [examples/opencode-faigate.json](./examples/opencode-faigate.json) - recommended header: `X-faigate-Client: opencode` -- recommended model: pick one of the fusionAIze Gate model ids exposed by `GET /v1/models`, usually `auto` +- recommended models: + - `coding-auto` for normal coding sessions + - `coding-fast` for fast, cheaper iterations + - `coding-premium` for architecture, review, or higher-risk changes + - `auto` only when you really want the generic cross-client router surface The current opencode docs recommend `@ai-sdk/openai-compatible` for custom OpenAI-compatible providers and a custom `provider..options.baseURL` value for the gateway endpoint. This fusionAIze Gate starter follows that pattern and keeps the provider-local model ids aligned with `GET /v1/models`. diff --git a/docs/LP-BRIEFING.md b/docs/LP-BRIEFING.md new file mode 100644 index 0000000..c3651bd --- /dev/null +++ b/docs/LP-BRIEFING.md @@ -0,0 +1,536 @@ +# fusionAIze Gate Landing Page Briefing + +## Purpose + +This briefing turns Gate's current roadmap, dashboard direction, and licensing + logic into a clean landing-page concept for a public Gate product page under + the fusionAIze brand. + +It is written for: + +- ChatGPT or another creative assistant producing first-pass landing-page copy +- frontend design exploration +- future website implementation on `fusionaize.com` + +The goal is not to imitate hosted router products. +The goal is to present Gate as a serious, local-first, agent-native routing + product with stronger operator trust and a clearer product surface. + +## Product truth + +These are the statements the page should be able to support without hype: + +- Gate gives operators one local endpoint for AI traffic +- Gate routes across direct providers, aggregators, and local workers +- Gate supports OpenAI-compatible clients today and an Anthropic bridge as an + opt-in early-adopter line +- Gate already has client profiles, route introspection, provider metadata, and + operational traces +- Gate is local-first and operator-owned +- Gate is moving toward cheapest-capable routing as the default coding posture +- Gate is designed for Claude Code, opencode, openclaw, automation clients, and + serious operator workflows + +Do not claim: + +- full Anthropic parity today +- full Claude Desktop parity today +- hosted control-plane features that do not exist +- team budgets, Grid, or semantic caching as shipped product features + +## Core positioning + +Primary positioning sentence: + +> fusionAIze Gate is the local-first AI gateway that routes every request to +> the cheapest capable path you can trust. + +Short version: + +> One local endpoint. Direct providers, aggregators, and local workers in one +> routing core. + +Expanded positioning: + +> fusionAIze Gate gives Claude-native, OpenAI-compatible, and agent-native +> clients one local endpoint, then routes requests across direct providers, +> aggregator paths, and local workers with explainable policy, health-aware +> fallback, and operator-owned control. + +## Strategic angle + +The page should make one thing obvious: + +Gate is not trying to be: + +- a hosted black-box router +- a proxy that hides routing reality behind "stack" marketing +- a generic agent framework + +Gate is trying to be: + +- a trustworthy local routing plane +- a product operators can actually reason about +- the clean bridge between developer tools, AI clients, and provider reality + +## Audience + +Primary audiences: + +- technical solo operators running multiple AI tools locally +- AI-native developers using Claude Code, opencode, Codex CLI, openclaw, Cline, + Continue, Cursor-style surfaces, and automation tools +- small teams that want local control before they need a hosted control plane + +Secondary audiences: + +- consultancies and internal AI enablement teams +- engineering leaders who care about cost, fallback, and provider flexibility +- people evaluating alternatives to OpenRouter, LiteLLM, ClawRouter, and hosted + router products + +## What makes Gate memorable + +The page needs one unforgettable idea: + +> Gate does not just forward model traffic. It decides the safest and cheapest +> capable route, and shows you why. + +That memorable idea should show up in three layers: + +- routing intelligence +- operator trust +- local-first ownership + +## Competitive thesis + +### vs OpenRouter + +OpenRouter is hosted and black-box from the operator's point of view. + +Gate should contrast with: + +- local-first runtime +- operator-owned credentials and config +- explainable route choice +- direct provider traffic where possible + +Message: + +> Keep your keys. Keep your traffic local. Keep visibility into the route. + +### vs ClawRouter + +ClawRouter is closer philosophically because it is agent-native and routing + aware. + +Gate should differentiate on: + +- deeper provider intelligence +- canonical lanes +- route-aware aggregator handling +- broader operator surface +- stronger local analytics and dashboard direction + +Message: + +> Agent-native routing, but with a fuller operator plane. + +### vs LLM AIRouter + +LLM AIRouter's useful signal is not its hosted model. +The useful signal is its surface clarity: + +- overview +- providers +- stacks/routes +- analytics +- CLI setup +- troubleshooting +- cache and circuit-breaker docs + +Gate should borrow that clarity while staying honest about its different + product boundary. + +Message: + +> Product-grade surface clarity, without hosted-router compromises. + +## Landing-page message hierarchy + +### Hero + +Primary headline options: + +- One Local Endpoint. Every AI Route Under Control. +- Route Every AI Request to the Cheapest Capable Path. +- The Local-First Gateway for Claude, OpenAI, and Agent-Native Workflows. + +Recommended subheadline: + +> fusionAIze Gate routes Claude Code, opencode, openclaw, scripts, and local +> automation across direct providers, aggregators, and local workers with +> explainable routing, health-aware fallback, and operator-owned control. + +Primary CTA: + +- Run Gate locally + +Secondary CTA: + +- Explore the operator dashboard + +Micro-proof line: + +> Local-first. Agent-native. Explainable by default. + +### Section 1: Why Gate exists + +This section should explain the real operator pain: + +- too many tools +- too many provider surfaces +- hidden cost spikes +- weak fallback behavior +- no trustworthy answer to "why did it use this model?" + +Suggested message: + +> Most routers give you one endpoint. +> Gate gives you one endpoint and one routing brain you can inspect. + +### Section 2: Cheapest capable routing + +This is the product promise the page should lean into hardest. + +The page should explain that Gate is moving toward: + +- `eco` +- `auto` +- `premium` +- `coding-auto` +- `coding-fast` +- `coding-premium` + +These should be explained as routing intent, not provider lock-in. + +Suggested framing: + +- simple prompts go to cheaper capable lanes +- coding defaults stay cost-aware +- premium lanes are used when complexity or reliability justify them +- explicit client model picks still work, but resolve through routing intent + +### Section 3: One routing core across surfaces + +This section should make Gate feel broader than "just another CLI proxy". + +Show: + +- Claude Code +- OpenAI-compatible tools +- opencode +- openclaw +- Codex CLI +- scripts and automations +- local workers + +Suggested message: + +> One routing core for Claude-native, OpenAI-compatible, and agent-native +> clients. + +### Section 4: Operator dashboard + +This is where the current dashboard redesign matters. + +The page should show Gate's dashboard as a real product surface with distinct + operator jobs: + +- Overview +- Providers +- Clients +- Routes +- Analytics +- Catalog +- Integrations + +The pitch should not be "beautiful charts". +The pitch should be: + +> See health, spend, route choice, and integration status in one local cockpit. + +### Section 5: Why local-first matters + +This section should hit security and ownership clearly: + +- no hosted dependency required +- operator-owned configuration +- no black-box stack abstraction +- direct provider traffic when configured +- local observability + +Suggested message: + +> Gate is designed for operators who want control without giving up routing +> intelligence. + +### Section 6: Explainability and trust + +This section should make the route-intelligence story concrete: + +- chosen lane +- chosen route +- same-lane fallback vs downgrade +- billing mode and quota domain visibility +- provider freshness and readiness + +Suggested message: + +> If Gate chose an expensive route, a fallback route, or a weaker route, the +> operator should be able to see why. + +### Section 7: Product stack and licensing + +This section should clarify the fusionAIze strategy without sounding defensive. + +Suggested framing: + +- Gate core stays open and adoption-friendly +- advanced policy, control-plane, and org-governance layers belong in higher + tiers +- the product boundary is deliberate, not accidental + +## Dashboard direction for the LP and product surface + +The new Gate dashboard should feel like: + +- a calm operator cockpit +- a financial or trading dashboard in discipline, not in hype +- stronger visual hierarchy than a default admin panel +- serious, trustworthy, and brand-aligned + +It should not feel like: + +- a neon gamer UI +- a startup toy +- a default Tailwind admin clone +- a fake enterprise dashboard with lots of empty chrome + +### Recommended visual direction + +Use the fusionAIze brand in a dark operator variant: + +- deep blue-black backgrounds derived from the dark/navy family +- `#0052CC` as the primary electric action color +- `#C4D900` as the sparing intelligence / success / progress accent +- `#FFAA19` only for action or alert emphasis +- restrained glass, glow, and gradient treatment +- sharp typography and dense-but-readable data layout + +The closest reference mood is: + +- financial dashboard +- trading terminal +- control room + +Not: + +- cyberpunk chaos +- purple SaaS gradient wallpaper + +### Recommended dashboard IA + +The LP and the real product surface should align around these areas: + +1. Overview +2. Providers +3. Clients +4. Routes +5. Analytics +6. Request Log +7. Catalog +8. Integrations +9. Troubleshooting + +This information architecture should also drive future docs and product copy. + +## What to adapt from LLM AIRouter + +Useful to adapt: + +- one concept per doc page +- visible quickstart +- visible CLI tools page +- visible providers page +- visible stacks/routes page +- visible cost-management, cache, circuit-breaker, troubleshooting, and API + reference surfaces +- dashboard broken into sane operator jobs + +Do not adapt directly: + +- hosted-router setup funnel +- claims around server-side key custody as the core story +- opaque "stack" abstraction if it hides the real route or YAML truth + +Gate should translate these into local-first equivalents: + +- Integrations instead of hosted "connect provider" +- Route views instead of black-box stack cards +- Troubleshooting that respects local runtime and helper CLIs +- Dashboard views that expose route, lane, and quota reality + +## Recommended LP structure + +1. Hero +2. Trusted by operators who need one endpoint and real control +3. Cheapest capable routing explained simply +4. Supported surfaces and clients +5. Dashboard / operator cockpit +6. Local-first security and ownership +7. Explainable routing and provider intelligence +8. Integration examples +9. Open-core product boundary +10. CTA and install path + +## Recommended proof points + +Use proof points that are operational and concrete: + +- one local endpoint +- direct + aggregator + local-worker routes +- cheapest-capable routing modes +- route introspection +- filtered traces and recent requests +- provider readiness and quota-domain visibility +- Anthropic bridge as opt-in early-adopter line +- dashboard surfaces for overview, providers, clients, routes, analytics, and + integrations + +Avoid proof points that overclaim: + +- "full parity" +- "perfect token counting" +- "multi-instance enterprise control plane" + +## Recommended screenshots or product visuals + +The most useful future LP visuals would be: + +1. Overview cockpit + - request-ready + - premium spend + - top client + - top issue + +2. Providers view + - route type + - billing mode + - quota group + - readiness + +3. Routes view + - chosen lane + - chosen route + - why selected + - why not selected + +4. Integrations view + - Claude Code + - OpenAI-compatible tools + - opencode / openclaw + - setup snippets + +5. Analytics view + - cost by client + - cost by lane family + - traffic trend + - fallback share + +## LP copy themes + +Themes worth repeating: + +- cheapest capable by default +- operator-owned routing +- local-first and secure +- agent-native +- explainable, not black-box +- direct, aggregator, and local in one core +- built for real tools, not only demos + +Themes to avoid overusing: + +- "revolutionary" +- "all-in-one AI platform" +- "autonomous everything" +- vague AI-consulting phrasing + +## Licensing and product stack boundary + +This should stay clean and easy to communicate. + +### Tier A — Apache 2.0 + +Open Gate should include: + +- local runtime +- routing core +- bridge surfaces +- local dashboard +- provider, client, route, and request-log views +- integrations and troubleshooting pages +- client profiles, routing modes, stacks, traces, helper CLIs + +### Tier B — source-available / premium packs + +Reasonable later premium layers: + +- advanced saved policies +- richer analytics overlays +- team-aware budget and retention packs +- advanced observability packs +- policy simulation and what-if tooling + +### Tier C — commercial control plane + +Reasonable later commercial layers: + +- multi-instance coordination +- org governance +- RBAC and audit +- Grid-backed shared-state features +- centralized rollout and fleet visibility + +Short product-stack phrasing: + +> Open what accelerates adoption. Protect what creates operational moat. + +## Suggested prompt for ChatGPT or design exploration + +Use this prompt as the starting point: + +> Design a landing page for fusionAIze Gate, a local-first AI gateway for +> Claude-native, OpenAI-compatible, and agent-native clients. The page should +> communicate that Gate routes each request to the cheapest capable path across +> direct providers, aggregators, and local workers, while keeping operator +> control and explainability. The visual style should feel like a dark +> financial-dashboard cockpit: calm, precise, technical, premium, and +> trustworthy. Use the fusionAIze brand palette with deep blues, `#0052CC` as +> the primary action color, `#C4D900` as a restrained accent, and `#FFAA19` +> only for emphasis. Avoid generic AI SaaS aesthetics, purple gradients, and +> cookie-cutter admin UI. The page should include sections for hero, cheapest +> capable routing, supported clients, operator dashboard, local-first security, +> explainable routing, integrations, and open-core product boundary. + +## Success criteria + +The landing page is successful when a technical visitor understands within a + few seconds: + +1. Gate is local-first +2. Gate routes intelligently instead of just proxying blindly +3. Gate works across real coding and agent-native clients +4. Gate is cheaper-capable and explainable +5. Gate has a real product surface, not just YAML and raw endpoints diff --git a/docs/anthropic-bridge.md b/docs/anthropic-bridge.md index d2a35e3..ffe96e0 100644 --- a/docs/anthropic-bridge.md +++ b/docs/anthropic-bridge.md @@ -80,7 +80,20 @@ Good first aliases: - `claude-code-fast -> eco` - `claude-code-premium -> premium` -That keeps Claude-oriented clients on stable logical targets while Gate can still adapt the real route underneath. +Built-in bridge defaults also recognize common Claude Code model ids such as: + +- `claude-sonnet-4-6[1m]` +- `claude-sonnet-4-6-20251001` +- `claude-opus-4-6[1m]` +- `claude-haiku-4-5-20251001` + +By default those map to routing intents, not direct frontier providers: + +- `claude-sonnet-* -> auto` +- `claude-opus-* -> premium` +- `claude-haiku-* -> eco` + +That keeps Claude-oriented clients on stable logical targets while Gate can still adapt the real route underneath and avoid burning Sonnet or Opus for trivial turns. ## Limits And Fallback Design diff --git a/docs/examples/opencode-faigate.json b/docs/examples/opencode-faigate.json index 1e587b6..53ac0c1 100644 --- a/docs/examples/opencode-faigate.json +++ b/docs/examples/opencode-faigate.json @@ -19,36 +19,43 @@ "output": 8000 } }, - "deepseek-chat": { - "name": "DeepSeek Chat (via fusionAIze Gate)", + "coding-auto": { + "name": "fusionAIze Gate Coding Auto", "limit": { "context": 128000, "output": 8000 } }, - "deepseek-reasoner": { - "name": "DeepSeek Reasoner (via fusionAIze Gate)", + "coding-fast": { + "name": "fusionAIze Gate Coding Fast", "limit": { "context": 128000, "output": 8000 } }, - "gemini-flash-lite": { - "name": "Gemini Flash-Lite (via fusionAIze Gate)", + "coding-premium": { + "name": "fusionAIze Gate Coding Premium", "limit": { - "context": 1000000, + "context": 128000, "output": 8000 } }, - "gemini-flash": { - "name": "Gemini Flash (via fusionAIze Gate)", + "eco": { + "name": "fusionAIze Gate Eco", "limit": { - "context": 1000000, + "context": 128000, + "output": 8000 + } + }, + "premium": { + "name": "fusionAIze Gate Premium", + "limit": { + "context": 128000, "output": 8000 } }, - "openrouter-fallback": { - "name": "OpenRouter Fallback (via fusionAIze Gate)", + "free": { + "name": "fusionAIze Gate Free", "limit": { "context": 128000, "output": 8000 diff --git a/faigate/api/anthropic/models.py b/faigate/api/anthropic/models.py index b03b858..da5dc1c 100644 --- a/faigate/api/anthropic/models.py +++ b/faigate/api/anthropic/models.py @@ -96,15 +96,7 @@ def parse_anthropic_messages_request(payload: Mapping[str, Any]) -> AnthropicMes raise AnthropicBridgeError("Anthropic messages payload requires a model") raw_system = payload.get("system") - system: str | list[str] | None - if raw_system is None: - system = None - elif isinstance(raw_system, str): - system = raw_system - elif isinstance(raw_system, list) and all(isinstance(item, str) for item in raw_system): - system = list(raw_system) - else: - raise AnthropicBridgeError("'system' must be a string, a list of strings, or null") + system = _parse_system_prompt(raw_system) raw_messages = payload.get("messages", []) if not isinstance(raw_messages, list): @@ -149,6 +141,37 @@ def parse_anthropic_token_count_request(payload: Mapping[str, Any]) -> Anthropic ) +def _parse_system_prompt(raw: Any) -> str | list[str] | None: + """Normalize Anthropic system prompts into the narrow bridge shape. + + Claude-native clients can send system prompts either as a single string or + as a list of text blocks. The bridge keeps the internal representation + intentionally small by flattening text blocks to plain strings. + """ + + if raw is None: + return None + if isinstance(raw, str): + return raw + if not isinstance(raw, list): + raise AnthropicBridgeError( + "'system' must be a string, a list of strings, a list of text blocks, or null" + ) + + normalized: list[str] = [] + for item in raw: + if isinstance(item, str): + normalized.append(item) + continue + if not isinstance(item, Mapping): + raise AnthropicBridgeError("'system' blocks must be strings or text block mappings") + block_type = str(item.get("type", "") or "").strip() + if block_type != "text": + raise AnthropicBridgeError("Anthropic bridge v1 supports only text blocks in 'system'") + normalized.append(str(item.get("text", "") or "")) + return normalized + + def _parse_message(raw: Any) -> AnthropicMessage: if not isinstance(raw, Mapping): raise AnthropicBridgeError("Anthropic message entries must be mappings") diff --git a/faigate/assets/brand/fusionaize-app-icon.svg b/faigate/assets/brand/fusionaize-app-icon.svg new file mode 100644 index 0000000..947732a --- /dev/null +++ b/faigate/assets/brand/fusionaize-app-icon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/faigate/assets/brand/fusionaize-logo-white.svg b/faigate/assets/brand/fusionaize-logo-white.svg new file mode 100644 index 0000000..7d7ec75 --- /dev/null +++ b/faigate/assets/brand/fusionaize-logo-white.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/faigate/assets/brand/fusionaize-logo.svg b/faigate/assets/brand/fusionaize-logo.svg new file mode 100644 index 0000000..eaeac73 --- /dev/null +++ b/faigate/assets/brand/fusionaize-logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/faigate/assets/fonts/Montserrat/Montserrat-VariableFont_wght.ttf b/faigate/assets/fonts/Montserrat/Montserrat-VariableFont_wght.ttf new file mode 100644 index 0000000..451e692 Binary files /dev/null and b/faigate/assets/fonts/Montserrat/Montserrat-VariableFont_wght.ttf differ diff --git a/faigate/assets/fonts/Open-Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf b/faigate/assets/fonts/Open-Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..8a2c9d9 Binary files /dev/null and b/faigate/assets/fonts/Open-Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf differ diff --git a/faigate/assets/fonts/Open-Sans/OpenSans-VariableFont_wdth,wght.ttf b/faigate/assets/fonts/Open-Sans/OpenSans-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..9c57fbd Binary files /dev/null and b/faigate/assets/fonts/Open-Sans/OpenSans-VariableFont_wdth,wght.ttf differ diff --git a/faigate/bridges/anthropic/__init__.py b/faigate/bridges/anthropic/__init__.py index 57bb9dd..4d7c63b 100644 --- a/faigate/bridges/anthropic/__init__.py +++ b/faigate/bridges/anthropic/__init__.py @@ -8,6 +8,7 @@ canonical_to_openai_body, dispatch_anthropic_count_tokens, dispatch_anthropic_messages, + openai_sse_to_anthropic, ) __all__ = [ @@ -18,4 +19,5 @@ "canonical_to_openai_body", "dispatch_anthropic_count_tokens", "dispatch_anthropic_messages", + "openai_sse_to_anthropic", ] diff --git a/faigate/bridges/anthropic/adapter.py b/faigate/bridges/anthropic/adapter.py index 03ccb4b..1129959 100644 --- a/faigate/bridges/anthropic/adapter.py +++ b/faigate/bridges/anthropic/adapter.py @@ -8,6 +8,8 @@ from __future__ import annotations import json +from collections.abc import AsyncIterator +from dataclasses import dataclass from typing import Any from uuid import uuid4 @@ -32,6 +34,17 @@ ) +@dataclass +class _AnthropicStreamToolState: + """Tracks one streamed tool block while OpenAI-style deltas arrive.""" + + index: int + tool_use_id: str | None = None + name: str | None = None + started: bool = False + closed: bool = False + + def anthropic_request_to_canonical( request: AnthropicMessagesRequest, *, @@ -164,7 +177,9 @@ def dispatch_anthropic_count_tokens( ) -def approximate_anthropic_input_tokens(request: CanonicalChatRequest) -> tuple[int, str]: +def approximate_anthropic_input_tokens( + request: CanonicalChatRequest, +) -> tuple[int, str]: """Return a lightweight token estimate for Anthropic bridge requests. The gateway does not yet maintain provider-specific tokenizers or a stable @@ -242,7 +257,7 @@ def _assistant_message_to_canonical(message: AnthropicMessage) -> CanonicalMessa def _user_message_to_canonical(message: AnthropicMessage) -> list[CanonicalMessage]: - canonical_messages: list[CanonicalMessage] = [] + tool_messages: list[CanonicalMessage] = [] pending_text: list[AnthropicContentBlock] = [] for block in message.content: if block.type == "text": @@ -252,13 +267,28 @@ def _user_message_to_canonical(message: AnthropicMessage) -> list[CanonicalMessa raise AnthropicBridgeError( "Anthropic bridge v1 supports only text and tool_result blocks in user messages" ) - if pending_text: - canonical_messages.append( - CanonicalMessage(role="user", content=_text_blocks_to_string(pending_text)) + if not block.tool_use_id: + # Claude-native clients can emit tool_result-like user blocks without a + # stable tool_use_id. Falling back to user text keeps the session + # usable instead of hard-failing the whole turn. + pending_text.append( + AnthropicContentBlock( + type="text", + text=_anthropic_tool_result_to_string(block), + metadata={**dict(block.metadata), "tool_result_without_id": True}, + ) ) - pending_text = [] - canonical_messages.append(_anthropic_tool_result_to_canonical_message(block)) - if pending_text or not canonical_messages: + continue + tool_messages.append(_anthropic_tool_result_to_canonical_message(block)) + + if not tool_messages: + return [CanonicalMessage(role="user", content=_text_blocks_to_string(pending_text))] + + canonical_messages = list(tool_messages) + if pending_text: + # OpenAI-style tool continuity requires tool messages to follow the + # assistant tool_calls immediately. Preserve any surrounding user text + # as a trailing user turn once all tool_result blocks are emitted. canonical_messages.append( CanonicalMessage(role="user", content=_text_blocks_to_string(pending_text)) ) @@ -279,12 +309,18 @@ def _anthropic_tool_use_to_openai_call(block: AnthropicContentBlock) -> dict[str "type": "function", "function": { "name": block.name, - "arguments": json.dumps(block.input or {}, separators=(",", ":"), sort_keys=True), + "arguments": json.dumps( + block.input or {}, + separators=(",", ":"), + sort_keys=True, + ), }, } -def _anthropic_tool_result_to_canonical_message(block: AnthropicContentBlock) -> CanonicalMessage: +def _anthropic_tool_result_to_canonical_message( + block: AnthropicContentBlock, +) -> CanonicalMessage: tool_use_id = block.tool_use_id if not tool_use_id: raise AnthropicBridgeError("Anthropic tool_result blocks require a tool_use_id") @@ -436,3 +472,286 @@ def map_stop_reason_to_anthropic( if normalized in {"length", "max_tokens"}: return "max_tokens" return normalized + + +def anthropic_sse_event(event_type: str, payload: dict[str, Any]) -> bytes: + """Encode one Anthropic-style SSE event.""" + + body = json.dumps(payload, separators=(",", ":")) + return f"event: {event_type}\ndata: {body}\n\n".encode() + + +async def openai_sse_to_anthropic( + stream: AsyncIterator[bytes], + *, + requested_model: str, + resolved_model: str | None = None, +) -> AsyncIterator[bytes]: + """Translate OpenAI-compatible SSE chunks into Anthropic-style message events. + + This intentionally supports the common bridge path first: + + - text deltas + - streamed tool calls represented as function-call deltas + - stop reasons and optional usage payloads + + Unknown or malformed upstream chunks are ignored conservatively instead of + terminating the client-visible stream abruptly. + """ + + message_id = f"msg_{uuid4().hex}" + output_tokens = 0 + usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0} + text_block_started = False + text_block_closed = False + tool_states: dict[int, _AnthropicStreamToolState] = {} + tool_blocks_closed = False + stop_reason: str | None = None + + yield anthropic_sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": message_id, + "type": "message", + "role": "assistant", + "model": resolved_model or requested_model, + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": dict(usage), + }, + }, + ) + + try: + async for raw_line in stream: + line = raw_line.decode("utf-8", errors="replace").strip() + if not line or not line.startswith("data:"): + continue + payload_text = line[5:].strip() + if not payload_text: + continue + if payload_text == "[DONE]": + break + + try: + payload = json.loads(payload_text) + except json.JSONDecodeError: + continue + + if isinstance(payload, dict) and "error" in payload: + if text_block_started and not text_block_closed: + yield anthropic_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ) + text_block_closed = True + for tool_index in sorted(tool_states): + state = tool_states[tool_index] + if state.started and not state.closed: + yield anthropic_sse_event( + "content_block_stop", + { + "type": "content_block_stop", + "index": _anthropic_tool_index( + tool_index, + text_block_started, + ), + }, + ) + state.closed = True + tool_blocks_closed = True + yield anthropic_sse_event( + "error", + { + "type": "error", + "error": payload.get("error") + or {"type": "api_error", "message": "Upstream error"}, + }, + ) + return + + usage_payload = payload.get("usage") or {} + prompt_tokens = int(usage_payload.get("prompt_tokens") or 0) + completion_tokens = int(usage_payload.get("completion_tokens") or 0) + if prompt_tokens: + usage["input_tokens"] = prompt_tokens + if completion_tokens: + usage["output_tokens"] = completion_tokens + + choices = payload.get("choices") or [] + if not choices: + continue + choice = choices[0] or {} + delta = choice.get("delta") or {} + finish_reason = str(choice.get("finish_reason") or "").strip() or None + + text_delta = delta.get("content") + if isinstance(text_delta, str) and text_delta: + if tool_states and not tool_blocks_closed: + for tool_index in sorted(tool_states): + state = tool_states[tool_index] + if state.started and not state.closed: + yield anthropic_sse_event( + "content_block_stop", + { + "type": "content_block_stop", + "index": _anthropic_tool_index( + tool_index, + text_block_started=True, + ), + }, + ) + state.closed = True + tool_blocks_closed = True + if not text_block_started: + yield anthropic_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ) + text_block_started = True + output_tokens += _estimate_text_tokens(text_delta) + usage["output_tokens"] = max(usage["output_tokens"], output_tokens) + yield anthropic_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": text_delta}, + }, + ) + + delta_tool_calls = delta.get("tool_calls") or [] + if isinstance(delta_tool_calls, list) and delta_tool_calls: + if text_block_started and not text_block_closed: + yield anthropic_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ) + text_block_closed = True + for tool_delta in delta_tool_calls: + if not isinstance(tool_delta, dict): + continue + raw_index = int(tool_delta.get("index") or 0) + state = tool_states.setdefault( + raw_index, _AnthropicStreamToolState(index=raw_index) + ) + function = tool_delta.get("function") or {} + if tool_delta.get("id"): + state.tool_use_id = str(tool_delta["id"]) + if function.get("name"): + state.name = str(function["name"]) + if not state.started and state.name: + state.started = True + state.tool_use_id = state.tool_use_id or f"toolu_{uuid4().hex[:24]}" + yield anthropic_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": _anthropic_tool_index( + raw_index, + text_block_started, + ), + "content_block": { + "type": "tool_use", + "id": state.tool_use_id, + "name": state.name, + "input": {}, + }, + }, + ) + raw_arguments = function.get("arguments") + if state.started and isinstance(raw_arguments, str) and raw_arguments: + yield anthropic_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": _anthropic_tool_index( + raw_index, + text_block_started, + ), + "delta": { + "type": "input_json_delta", + "partial_json": raw_arguments, + }, + }, + ) + + if finish_reason: + stop_reason = map_stop_reason_to_anthropic( + finish_reason, + has_tool_calls=bool(tool_states), + ) + except Exception as exc: + if text_block_started and not text_block_closed: + yield anthropic_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ) + text_block_closed = True + for tool_index in sorted(tool_states): + state = tool_states[tool_index] + if state.started and not state.closed: + yield anthropic_sse_event( + "content_block_stop", + { + "type": "content_block_stop", + "index": _anthropic_tool_index( + tool_index, + text_block_started, + ), + }, + ) + state.closed = True + yield anthropic_sse_event( + "error", + { + "type": "error", + "error": { + "type": "api_error", + "message": f"Streaming request failed unexpectedly: {exc}", + }, + }, + ) + return + + if text_block_started and not text_block_closed: + yield anthropic_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ) + for tool_index in sorted(tool_states): + state = tool_states[tool_index] + if state.started and not state.closed: + yield anthropic_sse_event( + "content_block_stop", + { + "type": "content_block_stop", + "index": _anthropic_tool_index(tool_index, text_block_started), + }, + ) + + yield anthropic_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": { + "stop_reason": (stop_reason or ("tool_use" if tool_states else "end_turn")), + "stop_sequence": None, + }, + "usage": dict(usage), + }, + ) + yield anthropic_sse_event("message_stop", {"type": "message_stop"}) + + +def _anthropic_tool_index(raw_index: int, text_block_started: bool) -> int: + """Return the Anthropic content index for one streamed tool block.""" + + return raw_index + (1 if text_block_started else 0) diff --git a/faigate/config.py b/faigate/config.py index 424673f..92ebbb7 100644 --- a/faigate/config.py +++ b/faigate/config.py @@ -110,6 +110,23 @@ _SUPPORTED_PROVIDER_TRANSPORT_PROBE_STRATEGIES = {"models", "chat", "models_or_chat", "none"} _SUPPORTED_PROVIDER_TRANSPORT_COMPATIBILITY = {"native", "aggregator", "compat-layer"} _SUPPORTED_PROVIDER_TRANSPORT_CONFIDENCE = {"high", "medium", "low"} +_DEFAULT_ANTHROPIC_BRIDGE_MODEL_ALIASES = { + "claude-code": "auto", + "claude-code-fast": "eco", + "claude-code-premium": "premium", + # Claude Code currently sends its own Anthropic model ids. These built-ins + # let the bridge accept them without per-machine operator tuning. They map + # to routing intents, not direct frontier providers, so Gate can still + # score the cheapest capable route for the current request shape. + "claude-sonnet-4-6": "auto", + "claude-sonnet-4-6-20251001": "auto", + "claude-sonnet-4-6[1m]": "auto", + "claude-opus-4-6": "premium", + "claude-opus-4-6-20251001": "premium", + "claude-opus-4-6[1m]": "premium", + "claude-haiku-4-5": "eco", + "claude-haiku-4-5-20251001": "eco", +} _CLIENT_PROFILE_PRESET_SPECS: dict[str, dict[str, Any]] = { "openclaw": { @@ -1256,6 +1273,7 @@ def _normalize_routing_modes(data: dict[str, Any]) -> dict[str, Any]: f"routing mode '{normalized_name}'", dict(spec.get("select", {}) or {}), provider_names, + extra_keys={"routing_mode"}, ), } @@ -1792,7 +1810,7 @@ def _normalize_anthropic_bridge(data: dict[str, Any]) -> dict[str, Any]: if not isinstance(model_aliases, dict): raise ConfigError("'anthropic_bridge.model_aliases' must be a mapping") - normalized_aliases: dict[str, str] = {} + normalized_aliases: dict[str, str] = dict(_DEFAULT_ANTHROPIC_BRIDGE_MODEL_ALIASES) for key, value in model_aliases.items(): alias = str(key or "").strip() target = str(value or "").strip() @@ -1987,7 +2005,7 @@ def anthropic_bridge(self) -> dict: "enabled": False, "route_prefix": "/v1", "allow_claude_code_hints": True, - "model_aliases": {}, + "model_aliases": dict(_DEFAULT_ANTHROPIC_BRIDGE_MODEL_ALIASES), }, ) diff --git a/faigate/dashboard_web.py b/faigate/dashboard_web.py new file mode 100644 index 0000000..5c19798 --- /dev/null +++ b/faigate/dashboard_web.py @@ -0,0 +1,3070 @@ +"""Built-in operator dashboard HTML for fusionAIze Gate.""" + +import re +from pathlib import Path + +from . import __version__ + +# ruff: noqa: E501 + +_VENDOR_DIR = Path(__file__).resolve().parent / "vendor" +_ASSET_DIR = Path(__file__).resolve().parent / "assets" / "brand" + + +def _read_vendor_asset(name: str) -> str: + """Return one vendored dashboard asset as text.""" + + try: + return (_VENDOR_DIR / name).read_text(encoding="utf-8") + except OSError: + return "" + + +def _read_brand_asset(name: str) -> str: + """Return one brand asset as text.""" + + try: + return (_ASSET_DIR / name).read_text(encoding="utf-8") + except OSError: + return "" + + +def _inline_svg(name: str) -> str: + """Return one SVG asset sanitized for inline HTML embedding.""" + + svg = _read_brand_asset(name) + if not svg: + return "" + svg = svg.replace('', "").strip() + fill_rules = { + cls: fill + for cls, fill in re.findall( + r"\.(st\d+)\s*\{[^{}]*fill:\s*([^;]+);[^{}]*\}", + svg, + flags=re.DOTALL, + ) + } + for cls, fill in fill_rules.items(): + svg = re.sub( + rf'\sclass="{re.escape(cls)}"', + f' fill="{fill.strip()}"', + svg, + ) + svg = re.sub(r".*?", "", svg, flags=re.DOTALL).strip() + svg = re.sub(r"", "", svg, flags=re.DOTALL).strip() + return svg + + +DASHBOARD_HTML = """ + + + + +fusionAIze Gate + + + +
+ + +
+
+
+
+
Gateway health is loading
+

OperatorCockpit

+

Health, spend, and route guidance.

+
+
+ + + + Updated +
+
+
+ +
+
+
+ Command bar +

Operator scope

+
Filter by provider, client, layer, or status.
+
+
+ + All traffic +
+
+
+ + + + + + +
+
+
No active filters
+
+
+ + +
+
+
+ +
+
+
+
+
+
+

What needs attention

+

Start with the top risk or cost signal.

+
+ Loading +
+
+
+ + + +
+
+
+
+
+

Priority next

+

One next move and the evidence behind it.

+
+
+
+ Operator focus + loading +
+
Loading priority path
+
Calculating next action.
+
+
+ + +
+
+
+
+
+
+
+
+

Spend and traffic

+

Cost and request pulse.

+
+
+ +
+
+
+
+

Recent request log

+

Recent requests with route, client, latency, and cost.

+
+
+
+
+
+
+
+
+
+

Lane families

+

Volume and fallback pressure by family.

+
+
+
+
+
+
+
+

Operator evidence

+

Checks for cost posture and fallback drift.

+
+
+
+
+
+
+ +
+
+
+
+
+
+

Provider warnings

+

Readiness, health, and premium risk.

+
+
+
+
+
+
+
+

Remediation guidance

+

Fix health, keys, then quota.

+
+
+
+
+
+
+
+
+

Provider fleet

+

Sorted by readiness, health, quota, spend, and latency.

+
+ Inventory +
+
ProviderStatusLaneRouteBilling + quotaRequestsCostLatencyOperator note
+
+
+ +
+
+
+
+
+
+

Client posture

+

Clients carrying spend, failures, or latency.

+
+
+
ClientProfileRequestsSuccessTokensCostCost / reqLatencyProviders
+
+
+ +
+
+
+
+
+
+

Selection paths

+

Selected path and fallback availability.

+
+
+
+
+
+
+
+

Route pressure

+

Cooldown, degradation, and recovery pressure.

+
+
+
+
+
+
+
+
+

Routing breakdown

+

Layer, rule, provider, family, and selected path.

+
+
+
LayerRuleProviderLane familySelection pathRequestsCostLatency
+
+
+ +
+
+
+
+
+
+

30-day cost trend

+

Requests and spend over 30 days.

+
+
+
+
+
+
+
+

24-hour traffic pulse

+

Traffic for spikes, fallbacks, and cold periods.

+
+
+
+
+
+
+
+
+
+

Modality mix

+

Request types by provider and layer.

+
+
+
ModalityProviderLayerRequestsCostLatency
+
+
+
+
+

Operator actions

+

Update checks and local operator events.

+
+
+
EventActionStatusTargetEligibleEvents
+
+
+
+ +
+
+
+
+
+
+
+

Catalog alerts

+

Freshness, evidence, and model mismatch risk.

+
+
+
+
+
+
+
+

Refresh guidance

+

What to review now and what is still safe to trust.

+
+
+
+
+
+
+
+
+

Tracked provider assumptions

+

Configured vs recommended model, freshness, volatility, and notes.

+
+
+
ProviderStatusConfiguredRecommendedOffer trackVolatilityReviewedWhy it matters
+
+
+ +
+
+
+
+ Claude Code +

Point Claude Code at the local Anthropic endpoint. Default to `coding-auto` unless you need a premium lane.

+ export ANTHROPIC_BASE_URL=http://127.0.0.1:8090 +export ANTHROPIC_AUTH_TOKEN=dummy-local-token +claude --model coding-auto +
+
+ OpenAI-compatible tools +

Point Cursor, Continue, Cline, scripts, and shells at one local endpoint. Use Gate modes instead of raw provider ids.

+ export OPENAI_BASE_URL=http://127.0.0.1:8090/v1 +export OPENAI_API_KEY=dummy-local-token +# models: auto, coding-auto, coding-fast, coding-premium, eco, premium +
+
+ Agent-native clients +

Keep `opencode`, `openclaw`, and automation clients on named entry modes for clear routing and pricing.

+ Recommended entry modes + +light coding -> coding-auto +cheap background -> eco +high-trust coding -> coding-premium +manual hard task -> premium +
+
+
+
+
+
+

Quick connect posture

+

Connect, validate, then route through intent modes.

+
+
+
+
+ Local-first security +

Keys, routes, and traces stay local unless you wire in remote services.

+
+
+ Agent-native routing +

Different clients have different economics. Keep client identity visible.

+
+
+ Explainable by default +

Lane family, selection path, route type, quota coupling, and freshness stay visible.

+
+
+
+
+
+
+

Troubleshooting shortcuts

+

Fast checks for common integration failures.

+
+
+
+

If a model is unexpectedly expensive, check the client model id, then the lane, then the route type.

+
+
+
+
+
+ + + + +""" + +DASHBOARD_HTML = ( + DASHBOARD_HTML.replace("/*__UPLOT_CSS__*/", _read_vendor_asset("uPlot.min.css")) + .replace("/*__UPLOT_JS__*/", _read_vendor_asset("uplot.iife.min.js")) + .replace("__BRAND_LOGO__", _inline_svg("fusionaize-logo.svg")) + .replace("__FAIGATE_VERSION__", __version__) +) diff --git a/faigate/main.py b/faigate/main.py index dcb7839..048e1b5 100644 --- a/faigate/main.py +++ b/faigate/main.py @@ -12,6 +12,7 @@ import asyncio import json import logging +import mimetypes import os import re import time @@ -21,10 +22,11 @@ from contextlib import asynccontextmanager, suppress from dataclasses import asdict, dataclass from hashlib import sha256 +from pathlib import Path from typing import Any from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse from starlette.datastructures import UploadFile from . import __version__ @@ -34,9 +36,11 @@ anthropic_request_to_canonical, canonical_response_to_anthropic, dispatch_anthropic_count_tokens, + openai_sse_to_anthropic, ) from .canonical import CanonicalChatRequest, CanonicalChatResponse, CanonicalResponseMessage from .config import Config, load_config +from .dashboard_web import DASHBOARD_HTML from .hooks import ( AppliedHooks, HookExecutionError, @@ -71,6 +75,7 @@ logger = logging.getLogger("faigate") _SAFE_TOKEN_RE = re.compile(r"[^a-z0-9._-]+") +_DASHBOARD_ASSETS_DIR = Path(__file__).resolve().parent / "assets" # ── Globals (initialized in lifespan) ────────────────────────── _config: Config @@ -117,6 +122,57 @@ def _client_error_response(message: str, *, error_type: str, status_code: int) - return JSONResponse({"error": message, "type": error_type}, status_code=status_code) +def _openai_sse_data(payload: dict[str, Any]) -> bytes: + """Return one OpenAI-style SSE data frame.""" + return f"data: {json.dumps(payload, separators=(',', ':'))}\n\n".encode() + + +async def _safe_openai_sse_stream( + stream: AsyncIterator[bytes], + *, + provider_name: str, + trace_id: str | None, +) -> AsyncIterator[bytes]: + """Keep streaming responses well-formed when the upstream fails mid-turn.""" + + try: + async for chunk in stream: + yield chunk + except ProviderError as exc: + logger.warning( + "Streaming response from %s failed after stream start: %s", + provider_name, + exc.detail[:200], + ) + yield _openai_sse_data( + { + "error": { + "message": str(exc.detail or "Streaming request failed"), + "type": classify_runtime_issue(status=exc.status, detail=exc.detail), + "provider": provider_name, + "trace_id": trace_id or "", + } + } + ) + yield b"data: [DONE]\n\n" + except Exception: + logger.exception( + "Streaming response from %s failed unexpectedly after stream start", + provider_name, + ) + yield _openai_sse_data( + { + "error": { + "message": "Streaming request failed unexpectedly", + "type": "provider_error", + "provider": provider_name, + "trace_id": trace_id or "", + } + } + ) + yield b"data: [DONE]\n\n" + + def _request_hook_error_response(exc: Exception) -> JSONResponse: """Return a sanitized request-hook failure response.""" logger.warning("Request hook processing failed: %s", exc) @@ -420,7 +476,19 @@ def _resolve_anthropic_requested_model(request: CanonicalChatRequest) -> Canonic """Apply configured Anthropic bridge aliases without changing wire parsing.""" alias_map = _config.anthropic_bridge.get("model_aliases", {}) - requested_model = str(alias_map.get(request.requested_model, request.requested_model)) + requested_model_raw = str(request.requested_model or "").strip() + requested_model = str( + alias_map.get( + requested_model_raw, + alias_map.get( + requested_model_raw.lower(), + alias_map.get( + _normalize_anthropic_model_alias(requested_model_raw), + request.requested_model, + ), + ), + ) + ) if requested_model == request.requested_model: return request metadata = dict(request.metadata) @@ -438,6 +506,21 @@ def _resolve_anthropic_requested_model(request: CanonicalChatRequest) -> Canonic ) +def _normalize_anthropic_model_alias(model_id: str) -> str: + """Return a stable alias key for Claude-native model ids. + + Claude Code sometimes sends model ids with display-oriented suffixes like + ``[1m]``. The bridge should treat those as the same model family for alias + resolution instead of forcing operators to encode every formatting variant. + """ + + normalized = str(model_id or "").strip().lower() + if not normalized: + return "" + normalized = re.sub(r"\[[^\]]+]", "", normalized).strip() + return normalized + + def _collect_operator_context(headers: dict[str, str]) -> tuple[str, str]: """Return operator action and client tag hints from request headers.""" max_chars = int((_config.security or {}).get("max_header_value_chars", 160)) @@ -3041,6 +3124,23 @@ async def dashboard(): return _DASHBOARD_HTML +@app.get("/dashboard/assets/{asset_kind}/{asset_name:path}") +async def dashboard_asset(asset_kind: str, asset_name: str): + """Serve packaged dashboard assets such as fonts.""" + safe_kind = asset_kind.strip() + if safe_kind not in {"brand", "fonts"}: + return JSONResponse({"error": {"message": "Asset kind not found"}}, status_code=404) + asset_path = (_DASHBOARD_ASSETS_DIR / safe_kind / asset_name).resolve() + try: + asset_path.relative_to((_DASHBOARD_ASSETS_DIR / safe_kind).resolve()) + except ValueError: + return JSONResponse({"error": {"message": "Asset path is invalid"}}, status_code=404) + if not asset_path.is_file(): + return JSONResponse({"error": {"message": "Asset not found"}}, status_code=404) + media_type, _ = mimetypes.guess_type(str(asset_path)) + return FileResponse(asset_path, media_type=media_type) + + # ── Main completion endpoint ─────────────────────────────────── @@ -3070,7 +3170,11 @@ async def chat_completions(request: Request): if execution.stream: return StreamingResponse( - execution.result, + _safe_openai_sse_stream( + execution.result, + provider_name=execution.provider_name, + trace_id=execution.trace_id, + ), media_type="text/event-stream", headers={ "X-faigate-Provider": execution.provider_name, @@ -3125,12 +3229,6 @@ async def anthropic_messages(request: Request): headers = _collect_anthropic_bridge_headers(request) try: wire_request = parse_anthropic_messages_request(body) - if wire_request.stream: - return _anthropic_error_response( - "Anthropic bridge v1 does not support streaming yet", - error_type="not_supported_error", - status_code=501, - ) canonical_request = anthropic_request_to_canonical(wire_request, headers=headers) canonical_request = _resolve_anthropic_requested_model(canonical_request) execution = await _execute_chat_completion_body(canonical_request.to_openai_body(), headers) @@ -3159,11 +3257,46 @@ async def anthropic_messages(request: Request): status_code=execution.status_code, ) - if execution.stream or not isinstance(execution.result, dict): + bridge_headers = _anthropic_bridge_response_headers( + source=str(canonical_request.metadata.get("source") or "claude-code"), + requested_model=str( + canonical_request.metadata.get("requested_model_original") or wire_request.model + ), + resolved_model=str(canonical_request.requested_model or wire_request.model), + anthropic_version=str(headers.get("anthropic-version") or "") or None, + anthropic_beta=str(headers.get("anthropic-beta") or "") or None, + ) + + if execution.stream: + return StreamingResponse( + openai_sse_to_anthropic( + _safe_openai_sse_stream( + execution.result, + provider_name=execution.provider_name, + trace_id=execution.trace_id, + ), + requested_model=str( + canonical_request.metadata.get("requested_model_original") or wire_request.model + ), + resolved_model=str(canonical_request.requested_model or wire_request.model), + ), + media_type="text/event-stream", + headers={ + "X-faigate-Provider": execution.provider_name, + "X-faigate-Profile": execution.client_profile, + "X-faigate-Layer": execution.decision.layer, + "X-faigate-Rule": execution.decision.rule_name, + "X-faigate-Hooks": ",".join(execution.hook_state.applied_hooks), + "X-faigate-Hook-Errors": str(len(execution.hook_state.errors)), + "x-faigate-trace-id": execution.trace_id or str(uuid.uuid4()), + **bridge_headers, + }, + ) + if not isinstance(execution.result, dict): return _anthropic_error_response( - "Anthropic bridge v1 does not support streaming responses", - error_type="not_supported_error", - status_code=501, + "Anthropic bridge returned an unsupported upstream response shape", + error_type="api_error", + status_code=502, ) canonical_response = _openai_result_to_canonical_response(execution.result) @@ -3182,15 +3315,7 @@ async def anthropic_messages(request: Request): response.headers["X-faigate-Hooks"] = ",".join(execution.hook_state.applied_hooks) response.headers["X-faigate-Hook-Errors"] = str(len(execution.hook_state.errors)) response.headers["x-faigate-trace-id"] = execution.trace_id or str(uuid.uuid4()) - for key, value in _anthropic_bridge_response_headers( - source=str(canonical_request.metadata.get("source") or "claude-code"), - requested_model=str( - canonical_request.metadata.get("requested_model_original") or wire_request.model - ), - resolved_model=str(canonical_request.requested_model or wire_request.model), - anthropic_version=str(headers.get("anthropic-version") or "") or None, - anthropic_beta=str(headers.get("anthropic-beta") or "") or None, - ).items(): + for key, value in bridge_headers.items(): response.headers[key] = value return response @@ -3302,7 +3427,7 @@ def _dashboard_csp() -> str: "default-src 'self'; " f"style-src 'self' {style_hash}; " f"script-src 'self' {script_hash}; " - "img-src 'self' data:; connect-src 'self'; object-src 'none'; " + "img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; " "base-uri 'none'; frame-ancestors 'none'; form-action 'self'" ) @@ -3735,3 +3860,6 @@ def _dashboard_csp() -> str: setInterval(load, 30000); """ + +# Keep the runtime wired to the extracted operator cockpit UI. +_DASHBOARD_HTML = DASHBOARD_HTML diff --git a/faigate/providers.py b/faigate/providers.py index 7adbaca..f6fdf4d 100644 --- a/faigate/providers.py +++ b/faigate/providers.py @@ -684,11 +684,18 @@ async def _stream_response( raise ProviderError(self.name, resp.status_code, error_text.decode()[:500]) first_chunk = True - async for line in resp.aiter_lines(): - if first_chunk: - self.health.record_success((time.time() - t0) * 1000) - first_chunk = False - yield (line + "\n").encode() + try: + async for line in resp.aiter_lines(): + if first_chunk: + self.health.record_success((time.time() - t0) * 1000) + first_chunk = False + yield (line + "\n").encode() + except httpx.HTTPError as e: + self.health.record_failure(f"Stream error: {e}") + raise ProviderError(self.name, 0, f"Stream error: {e}") from e + except Exception as e: + self.health.record_failure(f"Stream error: {e}") + raise ProviderError(self.name, 0, f"Stream error: {e}") from e # ── Google GenAI path ────────────────────────────────────── diff --git a/faigate/vendor/uPlot.min.css b/faigate/vendor/uPlot.min.css new file mode 100644 index 0000000..a030d63 --- /dev/null +++ b/faigate/vendor/uPlot.min.css @@ -0,0 +1 @@ +.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;} \ No newline at end of file diff --git a/faigate/vendor/uplot.iife.min.js b/faigate/vendor/uplot.iife.min.js new file mode 100644 index 0000000..047f29b --- /dev/null +++ b/faigate/vendor/uplot.iife.min.js @@ -0,0 +1,2 @@ +/*! https://github.com/leeoniya/uPlot (v1.6.32) */ +var uPlot=function(){"use strict";const l="u-off",e="u-label",t="width",n="height",i="top",o="bottom",s="left",r="right",u="#000",a=u+"0",f="mousemove",c="mousedown",h="mouseup",d="mouseenter",p="mouseleave",m="dblclick",g="change",x="dppxchange",w="--",_="undefined"!=typeof window,b=_?document:null,v=_?window:null,k=_?navigator:null;let y,M;function S(l,e){if(null!=e){let t=l.classList;!t.contains(e)&&t.add(e)}}function T(l,e){let t=l.classList;t.contains(e)&&t.remove(e)}function E(l,e,t){l.style[e]=t+"px"}function z(l,e,t,n){let i=b.createElement(l);return null!=e&&S(i,e),null!=t&&t.insertBefore(i,n),i}function D(l,e){return z("div",l,e)}const P=new WeakMap;function A(e,t,n,i,o){let s="translate("+t+"px,"+n+"px)";s!=P.get(e)&&(e.style.transform=s,P.set(e,s),0>t||0>n||t>i||n>o?S(e,l):T(e,l))}const W=new WeakMap;function Y(l,e,t){let n=e+t;n!=W.get(l)&&(W.set(l,n),l.style.background=e,l.style.borderColor=t)}const C=new WeakMap;function H(l,e,t,n){let i=e+""+t;i!=C.get(l)&&(C.set(l,i),l.style.height=t+"px",l.style.width=e+"px",l.style.marginLeft=n?-e/2+"px":0,l.style.marginTop=n?-t/2+"px":0)}const F={passive:!0},R={...F,capture:!0};function G(l,e,t,n){e.addEventListener(l,t,n?R:F)}function I(l,e,t){e.removeEventListener(l,t,F)}function L(l,e,t,n){let i;t=t||0;let o=2147483647>=(n=n||e.length-1);for(;n-t>1;)i=o?t+n>>1:sl((t+n)/2),l>e[i]?t=i:n=i;return l-e[t]>e[n]-l?n:t}function O(l){return(e,t,n)=>{let i=-1,o=-1;for(let o=t;n>=o;o++)if(l(e[o])){i=o;break}for(let i=n;i>=t;i--)if(l(e[i])){o=i;break}return[i,o]}}_&&function l(){let e=devicePixelRatio;y!=e&&(y=e,M&&I(g,M,l),M=matchMedia(`(min-resolution: ${y-.001}dppx) and (max-resolution: ${y+.001}dppx)`),G(g,M,l),v.dispatchEvent(new CustomEvent(x)))}();const N=l=>null!=l,j=l=>null!=l&&l>0,U=O(N),V=O(j);function B(l,e,t,n){let i=hl(l),o=hl(e);l==e&&(-1==i?(l*=t,e/=t):(l/=t,e*=t));let s=10==t?dl:pl,r=1==o?ul:sl,u=(1==i?sl:ul)(s(ol(l))),a=r(s(ol(e))),f=cl(t,u),c=cl(t,a);return 10==t&&(0>u&&(f=Al(f,-u)),0>a&&(c=Al(c,-a))),n||2==t?(l=f*i,e=c*o):(l=Pl(l,f),e=Dl(e,c)),[l,e]}function $(l,e,t,n){let i=B(l,e,t,n);return 0==l&&(i[0]=0),0==e&&(i[1]=0),i}const J=.1,q={mode:3,pad:J},K={pad:0,soft:null,mode:0},X={min:K,max:K};function Z(l,e,t,n){return Ol(t)?ll(l,e,t):(K.pad=t,K.soft=n?0:null,K.mode=n?3:0,ll(l,e,X))}function Q(l,e){return null==l?e:l}function ll(l,e,t){let n=t.min,i=t.max,o=Q(n.pad,0),s=Q(i.pad,0),r=Q(n.hard,-gl),u=Q(i.hard,gl),a=Q(n.soft,gl),f=Q(i.soft,-gl),c=Q(n.mode,0),h=Q(i.mode,0),d=e-l,p=dl(d),m=fl(ol(l),ol(e)),g=dl(m),x=ol(g-p);(1e-24>d||x>10)&&(d=0,0!=l&&0!=e||(d=1e-24,2==c&&a!=gl&&(o=0),2==h&&f!=-gl&&(s=0)));let w=d||m||1e3,_=dl(w),b=cl(10,sl(_)),v=Al(Pl(l-w*(0==d?0==l?.1:1:o),b/10),24),k=a>l||1!=c&&(3!=c||v>a)&&(2!=c||a>v)?gl:a,y=fl(r,k>v&&l>=k?k:al(k,v)),M=Al(Dl(e+w*(0==d?0==e?.1:1:s),b/10),24),S=e>f||1!=h&&(3!=h||f>M)&&(2!=h||M>f)?-gl:f,T=al(u,M>S&&S>=e?S:fl(S,M));return y==T&&0==y&&(T=100),[y,T]}const el=new Intl.NumberFormat(_?k.language:"en-US"),tl=l=>el.format(l),nl=Math,il=nl.PI,ol=nl.abs,sl=nl.floor,rl=nl.round,ul=nl.ceil,al=nl.min,fl=nl.max,cl=nl.pow,hl=nl.sign,dl=nl.log10,pl=nl.log2,ml=(l,e=1)=>nl.asinh(l/e),gl=1/0;function xl(l){return 1+(0|dl((l^l>>31)-(l>>31)))}function wl(l,e,t){return al(fl(l,e),t)}function _l(l){return"function"==typeof l}function bl(l){return _l(l)?l:()=>l}const vl=l=>l,kl=(l,e)=>e,yl=()=>null,Ml=()=>!0,Sl=(l,e)=>l==e,Tl=/\.\d*?(?=9{6,}|0{6,})/gm,El=l=>{if(Il(l)||Wl.has(l))return l;const e=""+l,t=e.match(Tl);if(null==t)return l;let n=t[0].length-1;if(-1!=e.indexOf("e-")){let[l,t]=e.split("e");return+`${El(l)}e${t}`}return Al(l,n)};function zl(l,e){return El(Al(El(l/e))*e)}function Dl(l,e){return El(ul(El(l/e))*e)}function Pl(l,e){return El(sl(El(l/e))*e)}function Al(l,e=0){if(Il(l))return l;let t=10**e;return rl(l*t*(1+Number.EPSILON))/t}const Wl=new Map;function Yl(l){return((""+l).split(".")[1]||"").length}function Cl(l,e,t,n){let i=[],o=n.map(Yl);for(let s=e;t>s;s++){let e=ol(s),t=Al(cl(l,s),e);for(let r=0;n.length>r;r++){let u=10==l?+`${n[r]}e${s}`:n[r]*t,a=(0>s?e:0)+(o[r]>s?o[r]:0),f=10==l?u:Al(u,a);i.push(f),Wl.set(f,a)}}return i}const Hl={},Fl=[],Rl=[null,null],Gl=Array.isArray,Il=Number.isInteger;function Ll(l){return"string"==typeof l}function Ol(l){let e=!1;if(null!=l){let t=l.constructor;e=null==t||t==Object}return e}function Nl(l){return null!=l&&"object"==typeof l}const jl=Object.getPrototypeOf(Uint8Array),Ul="__proto__";function Vl(l,e=Ol){let t;if(Gl(l)){let n=l.find((l=>null!=l));if(Gl(n)||e(n)){t=Array(l.length);for(let n=0;l.length>n;n++)t[n]=Vl(l[n],e)}else t=l.slice()}else if(l instanceof jl)t=l.slice();else if(e(l)){t={};for(let n in l)n!=Ul&&(t[n]=Vl(l[n],e))}else t=l;return t}function Bl(l){let e=arguments;for(let t=1;e.length>t;t++){let n=e[t];for(let e in n)e!=Ul&&(Ol(l[e])?Bl(l[e],Vl(n[e])):l[e]=Vl(n[e]))}return l}function $l(l,e,t){for(let n,i=0,o=-1;e.length>i;i++){let s=e[i];if(s>o){for(n=s-1;n>=0&&null==l[n];)l[n--]=null;for(n=s+1;t>n&&null==l[n];)l[o=n++]=null}}}const Jl="undefined"==typeof queueMicrotask?l=>Promise.resolve().then(l):queueMicrotask,ql=["January","February","March","April","May","June","July","August","September","October","November","December"],Kl=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];function Xl(l){return l.slice(0,3)}const Zl=Kl.map(Xl),Ql=ql.map(Xl),le={MMMM:ql,MMM:Ql,WWWW:Kl,WWW:Zl};function ee(l){return(10>l?"0":"")+l}const te={YYYY:l=>l.getFullYear(),YY:l=>(l.getFullYear()+"").slice(2),MMMM:(l,e)=>e.MMMM[l.getMonth()],MMM:(l,e)=>e.MMM[l.getMonth()],MM:l=>ee(l.getMonth()+1),M:l=>l.getMonth()+1,DD:l=>ee(l.getDate()),D:l=>l.getDate(),WWWW:(l,e)=>e.WWWW[l.getDay()],WWW:(l,e)=>e.WWW[l.getDay()],HH:l=>ee(l.getHours()),H:l=>l.getHours(),h:l=>{let e=l.getHours();return 0==e?12:e>12?e-12:e},AA:l=>12>l.getHours()?"AM":"PM",aa:l=>12>l.getHours()?"am":"pm",a:l=>12>l.getHours()?"a":"p",mm:l=>ee(l.getMinutes()),m:l=>l.getMinutes(),ss:l=>ee(l.getSeconds()),s:l=>l.getSeconds(),fff:l=>function(l){return(10>l?"00":100>l?"0":"")+l}(l.getMilliseconds())};function ne(l,e){e=e||le;let t,n=[],i=/\{([a-z]+)\}|[^{]+/gi;for(;t=i.exec(l);)n.push("{"==t[0][0]?te[t[1]]:t[0]);return l=>{let t="";for(let i=0;n.length>i;i++)t+="string"==typeof n[i]?n[i]:n[i](l,e);return t}}const ie=(new Intl.DateTimeFormat).resolvedOptions().timeZone,oe=l=>l%1==0,se=[1,2,2.5,5],re=Cl(10,-32,0,se),ue=Cl(10,0,32,se),ae=ue.filter(oe),fe=re.concat(ue),ce="{YYYY}",he="\n"+ce,de="{M}/{D}",pe="\n"+de,me=pe+"/{YY}",ge="{aa}",xe="{h}:{mm}"+ge,we="\n"+xe,_e=":{ss}",be=null;function ve(l){let e=1e3*l,t=60*e,n=60*t,i=24*n,o=30*i,s=365*i;return[(1==l?Cl(10,0,3,se).filter(oe):Cl(10,-3,0,se)).concat([e,5*e,10*e,15*e,30*e,t,5*t,10*t,15*t,30*t,n,2*n,3*n,4*n,6*n,8*n,12*n,i,2*i,3*i,4*i,5*i,6*i,7*i,8*i,9*i,10*i,15*i,o,2*o,3*o,4*o,6*o,s,2*s,5*s,10*s,25*s,50*s,100*s]),[[s,ce,be,be,be,be,be,be,1],[28*i,"{MMM}",he,be,be,be,be,be,1],[i,de,he,be,be,be,be,be,1],[n,"{h}"+ge,me,be,pe,be,be,be,1],[t,xe,me,be,pe,be,be,be,1],[e,_e,me+" "+xe,be,pe+" "+xe,be,we,be,1],[l,_e+".{fff}",me+" "+xe,be,pe+" "+xe,be,we,be,1]],function(e){return(r,u,a,f,c,h)=>{let d=[],p=c>=s,m=c>=o&&s>c,g=e(a),x=Al(g*l,3),w=Pe(g.getFullYear(),p?0:g.getMonth(),m||p?1:g.getDate()),_=Al(w*l,3);if(m||p){let t=m?c/o:0,n=p?c/s:0,i=x==_?x:Al(Pe(w.getFullYear()+n,w.getMonth()+t,1)*l,3),r=new Date(rl(i/l)),u=r.getFullYear(),a=r.getMonth();for(let o=0;f>=i;o++){let s=Pe(u+n*o,a+t*o,1),r=s-e(Al(s*l,3));i=Al((+s+r)*l,3),i>f||d.push(i)}}else{let o=i>c?c:i,s=_+(sl(a)-sl(x))+Dl(x-_,o);d.push(s);let p=e(s),m=p.getHours()+p.getMinutes()/t+p.getSeconds()/n,g=c/n,w=h/r.axes[u]._space;for(;s=Al(s+c,1==l?0:3),f>=s;)if(g>1){let l=sl(Al(m+g,6))%24,t=e(s).getHours()-l;t>1&&(t=-1),s-=t*n,m=(m+g)%24,.7>Al((s-d[d.length-1])/c,3)*w||d.push(s)}else d.push(s)}return d}}]}const[ke,ye,Me]=ve(1),[Se,Te,Ee]=ve(.001);function ze(l,e){return l.map((l=>l.map(((t,n)=>0==n||8==n||null==t?t:e(1==n||0==l[8]?t:l[1]+t)))))}function De(l,e){return(t,n,i,o,s)=>{let r,u,a,f,c,h,d=e.find((l=>s>=l[0]))||e[e.length-1];return n.map((e=>{let t=l(e),n=t.getFullYear(),i=t.getMonth(),o=t.getDate(),s=t.getHours(),p=t.getMinutes(),m=t.getSeconds(),g=n!=r&&d[2]||i!=u&&d[3]||o!=a&&d[4]||s!=f&&d[5]||p!=c&&d[6]||m!=h&&d[7]||d[1];return r=n,u=i,a=o,f=s,c=p,h=m,g(t)}))}}function Pe(l,e,t){return new Date(l,e,t)}function Ae(l,e){return e(l)}function We(l,e){return(t,n,i,o)=>null==o?w:e(l(n))}Cl(2,-53,53,[1]);const Ye={show:!0,live:!0,isolate:!1,mount:()=>{},markers:{show:!0,width:2,stroke:function(l,e){let t=l.series[e];return t.width?t.stroke(l,e):t.points.width?t.points.stroke(l,e):null},fill:function(l,e){return l.series[e].fill(l,e)},dash:"solid"},idx:null,idxs:null,values:[]},Ce=[0,0];function He(l,e,t,n=!0){return l=>{0==l.button&&(!n||l.target==e)&&t(l)}}function Fe(l,e,t,n=!0){return l=>{(!n||l.target==e)&&t(l)}}const Re={show:!0,x:!0,y:!0,lock:!1,move:function(l,e,t){return Ce[0]=e,Ce[1]=t,Ce},points:{one:!1,show:function(l,e){let i=l.cursor.points,o=D(),s=i.size(l,e);E(o,t,s),E(o,n,s);let r=s/-2;E(o,"marginLeft",r),E(o,"marginTop",r);let u=i.width(l,e,s);return u&&E(o,"borderWidth",u),o},size:function(l,e){return l.series[e].points.size},width:0,stroke:function(l,e){let t=l.series[e].points;return t._stroke||t._fill},fill:function(l,e){let t=l.series[e].points;return t._fill||t._stroke}},bind:{mousedown:He,mouseup:He,click:He,dblclick:He,mousemove:Fe,mouseleave:Fe,mouseenter:Fe},drag:{setScale:!0,x:!0,y:!1,dist:0,uni:null,click:(l,e)=>{e.stopPropagation(),e.stopImmediatePropagation()},_x:!1,_y:!1},focus:{dist:(l,e,t,n,i)=>n-i,prox:-1,bias:0},hover:{skip:[void 0],prox:null,bias:0},left:-10,top:-10,idx:null,dataIdx:null,idxs:null,event:null},Ge={show:!0,stroke:"rgba(0,0,0,0.07)",width:2},Ie=Bl({},Ge,{filter:kl}),Le=Bl({},Ie,{size:10}),Oe=Bl({},Ge,{show:!1}),Ne='12px system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',je="bold "+Ne,Ue={show:!0,scale:"x",stroke:u,space:50,gap:5,alignTo:1,size:50,labelGap:0,labelSize:30,labelFont:je,side:2,grid:Ie,ticks:Le,border:Oe,font:Ne,lineGap:1.5,rotate:0},Ve={show:!0,scale:"x",auto:!1,sorted:1,min:gl,max:-gl,idxs:[]};function Be(l,e){return e.map((l=>null==l?"":tl(l)))}function $e(l,e,t,n,i,o,s){let r=[],u=Wl.get(i)||0;for(let l=t=s?t:Al(Dl(t,i),u);n>=l;l=Al(l+i,u))r.push(Object.is(l,-0)?0:l);return r}function Je(l,e,t,n,i){const o=[],s=l.scales[l.axes[e].scale].log,r=sl((10==s?dl:pl)(t));i=cl(s,r),10==s&&(i=fe[L(i,fe)]);let u=t,a=i*s;10==s&&(a=fe[L(a,fe)]);do{o.push(u),u+=i,10!=s||Wl.has(u)||(u=Al(u,Wl.get(i))),a>u||(a=(i=u)*s,10==s&&(a=fe[L(a,fe)]))}while(n>=u);return o}function qe(l,e,t,n,i){let o=l.scales[l.axes[e].scale].asinh,s=n>o?Je(l,e,fl(o,t),n,i):[o],r=0>n||t>0?[]:[0];return(-o>t?Je(l,e,fl(o,-n),-t,i):[o]).reverse().map((l=>-l)).concat(r,s)}const Ke=/./,Xe=/[12357]/,Ze=/[125]/,Qe=/1/,lt=(l,e,t,n)=>l.map(((l,i)=>4==e&&0==l||i%n==0&&t.test(l.toExponential()[0>l?1:0])?l:null));function et(l,e,t){let n=l.axes[t],i=n.scale,o=l.scales[i],s=l.valToPos,r=n._space,u=s(10,i),a=s(9,i)-ul)return lt(e.slice().reverse(),o.distr,a,ul(r/l)).reverse()}return lt(e,o.distr,a,1)}function tt(l,e,t){let n=l.axes[t],i=n.scale,o=n._space,s=l.valToPos,r=ol(s(1,i)-s(2,i));return o>r?lt(e.slice().reverse(),3,Ke,ul(o/r)).reverse():e}function nt(l,e,t,n){return null==n?w:null==e?"":tl(e)}const it={show:!0,scale:"y",stroke:u,space:30,gap:5,alignTo:1,size:50,labelGap:0,labelSize:30,labelFont:je,side:3,grid:Ie,ticks:Le,border:Oe,font:Ne,lineGap:1.5,rotate:0},ot={scale:null,auto:!0,sorted:0,min:gl,max:-gl},st=(l,e,t,n,i)=>i,rt={show:!0,auto:!0,sorted:0,gaps:st,alpha:1,facets:[Bl({},ot,{scale:"x"}),Bl({},ot,{scale:"y"})]},ut={scale:"y",auto:!0,sorted:0,show:!0,spanGaps:!1,gaps:st,alpha:1,points:{show:function(l,e){let{scale:t,idxs:n}=l.series[0],i=l._data[0],o=l.valToPos(i[n[0]],t,!0),s=l.valToPos(i[n[1]],t,!0);return ol(s-o)/(l.series[e].points.space*y)>=n[1]-n[0]},filter:null},values:null,min:gl,max:-gl,idxs:[],path:null,clip:null};function at(l,e,t){return t/10}const ft={time:!0,auto:!0,distr:1,log:10,asinh:1,min:null,max:null,dir:1,ori:0},ct=Bl({},ft,{time:!1,ori:1}),ht={};function dt(l){let e=ht[l];return e||(e={key:l,plots:[],sub(l){e.plots.push(l)},unsub(l){e.plots=e.plots.filter((e=>e!=l))},pub(l,t,n,i,o,s,r){for(let u=0;e.plots.length>u;u++)e.plots[u]!=t&&e.plots[u].pub(l,t,n,i,o,s,r)}},null!=l&&(ht[l]=e)),e}function pt(l,e,t){const n=l.mode,i=l.series[e],o=2==n?l._data[e]:l._data,s=l.scales,r=l.bbox;let u=o[0],a=2==n?o[1]:o[e],f=2==n?s[i.facets[0].scale]:s[l.series[0].scale],c=2==n?s[i.facets[1].scale]:s[i.scale],h=r.left,d=r.top,p=r.width,m=r.height,g=l.valToPosH,x=l.valToPosV;return 0==f.ori?t(i,u,a,f,c,g,x,h,d,p,m,kt,Mt,Tt,zt,Pt):t(i,u,a,f,c,x,g,d,h,m,p,yt,St,Et,Dt,At)}function mt(l,e){let t=0,n=0,i=Q(l.bands,Fl);for(let l=0;i.length>l;l++){let o=i[l];o.series[0]==e?t=o.dir:o.series[1]==e&&(n|=1==o.dir?1:2)}return[t,1==n?-1:2==n?1:3==n?2:0]}function gt(l,e,t,n,i){let o=l.series[e],s=l.scales[2==l.mode?o.facets[1].scale:o.scale];return-1==i?s.min:1==i?s.max:3==s.distr?1==s.dir?s.min:s.max:0}function xt(l,e,t,n,i,o){return pt(l,e,((l,e,s,r,u,a,f,c,h,d,p)=>{let m=l.pxRound;const g=0==r.ori?Mt:St;let x,w;1==r.dir*(0==r.ori?1:-1)?(x=t,w=n):(x=n,w=t);let _=m(a(e[x],r,d,c)),b=m(f(s[x],u,p,h)),v=m(a(e[w],r,d,c)),k=m(f(1==o?u.max:u.min,u,p,h)),y=new Path2D(i);return g(y,v,k),g(y,_,k),g(y,_,b),y}))}function wt(l,e,t,n,i,o){let s=null;if(l.length>0){s=new Path2D;const r=0==e?Tt:Et;let u=t;for(let e=0;l.length>e;e++){let t=l[e];if(t[1]>t[0]){let l=t[0]-u;l>0&&r(s,u,n,l,n+o),u=t[1]}}let a=t+i-u,f=10;a>0&&r(s,u,n-f/2,a,n+o+f)}return s}function _t(l,e,t,n,i,o,s){let r=[],u=l.length;for(let a=1==i?t:n;a>=t&&n>=a;a+=i)if(null===e[a]){let f=a,c=a;if(1==i)for(;++a<=n&&null===e[a];)c=a;else for(;--a>=t&&null===e[a];)c=a;let h=o(l[f]),d=c==f?h:o(l[c]),p=f-i;h=s>0||0>p||p>=u?h:o(l[p]);let m=c+i;d=0>s||0>m||m>=u?d:o(l[m]),h>d||r.push([h,d])}return r}function bt(l){return 0==l?vl:1==l?rl:e=>zl(e,l)}function vt(l){let e=0==l?kt:yt,t=0==l?(l,e,t,n,i,o)=>{l.arcTo(e,t,n,i,o)}:(l,e,t,n,i,o)=>{l.arcTo(t,e,i,n,o)},n=0==l?(l,e,t,n,i)=>{l.rect(e,t,n,i)}:(l,e,t,n,i)=>{l.rect(t,e,i,n)};return(l,i,o,s,r,u=0,a=0)=>{0==u&&0==a?n(l,i,o,s,r):(u=al(u,s/2,r/2),a=al(a,s/2,r/2),e(l,i+u,o),t(l,i+s,o,i+s,o+r,u),t(l,i+s,o+r,i,o+r,a),t(l,i,o+r,i,o,a),t(l,i,o,i+s,o,u),l.closePath())}}const kt=(l,e,t)=>{l.moveTo(e,t)},yt=(l,e,t)=>{l.moveTo(t,e)},Mt=(l,e,t)=>{l.lineTo(e,t)},St=(l,e,t)=>{l.lineTo(t,e)},Tt=vt(0),Et=vt(1),zt=(l,e,t,n,i,o)=>{l.arc(e,t,n,i,o)},Dt=(l,e,t,n,i,o)=>{l.arc(t,e,n,i,o)},Pt=(l,e,t,n,i,o,s)=>{l.bezierCurveTo(e,t,n,i,o,s)},At=(l,e,t,n,i,o,s)=>{l.bezierCurveTo(t,e,i,n,s,o)};function Wt(){return(l,e,t,n,i)=>pt(l,e,((e,o,s,r,u,a,f,c,h,d,p)=>{let m,g,{pxRound:x,points:w}=e;0==r.ori?(m=kt,g=zt):(m=yt,g=Dt);const _=Al(w.width*y,3);let b=(w.size-w.width)/2*y,v=Al(2*b,3),k=new Path2D,M=new Path2D,{left:S,top:T,width:E,height:z}=l.bbox;Tt(M,S-v,T-v,E+2*v,z+2*v);const D=l=>{if(null!=s[l]){let e=x(a(o[l],r,d,c)),t=x(f(s[l],u,p,h));m(k,e+b,t),g(k,e,t,b,0,2*il)}};if(i)i.forEach(D);else for(let l=t;n>=l;l++)D(l);return{stroke:_>0?k:null,fill:k,clip:M,flags:3}}))}function Yt(l){return(e,t,n,i,o,s)=>{n!=i&&(o!=n&&s!=n&&l(e,t,n),o!=i&&s!=i&&l(e,t,i),l(e,t,s))}}const Ct=Yt(Mt),Ht=Yt(St);function Ft(l){const e=Q(l?.alignGaps,0);return(l,t,n,i)=>pt(l,t,((o,s,r,u,a,f,c,h,d,p,m)=>{[n,i]=U(r,n,i);let g,x,w=o.pxRound,_=l=>w(f(l,u,p,h)),b=l=>w(c(l,a,m,d));0==u.ori?(g=Mt,x=Ct):(g=St,x=Ht);const v=u.dir*(0==u.ori?1:-1),k={stroke:new Path2D,fill:null,clip:null,band:null,gaps:null,flags:1},y=k.stroke;let M=!1;if(i-n<4*p)for(let l=1==v?n:i;l>=n&&i>=l;l+=v){let e=r[l];null===e?M=!0:null!=e&&g(y,_(s[l]),b(e))}else{let e,t,o,a=e=>l.posToVal(e,u.key,!0),f=null,c=null,h=_(s[1==v?n:i]),d=_(s[n]),p=_(s[i]),m=a(1==v?d+1:p-1);for(let l=1==v?n:i;l>=n&&i>=l;l+=v){let n=s[l],i=(1==v?m>n:n>m)?h:_(n),o=r[l];i==h?null!=o?(t=o,null==f?(g(y,i,b(t)),e=f=c=t):f>t?f=t:t>c&&(c=t)):null===o&&(M=!0):(null!=f&&x(y,h,b(f),b(c),b(e),b(t)),null!=o?(t=o,g(y,i,b(t)),f=c=e=t):(f=c=null,null===o&&(M=!0)),h=i,m=a(h+v))}null!=f&&f!=c&&o!=h&&x(y,h,b(f),b(c),b(e),b(t))}let[S,T]=mt(l,t);if(null!=o.fill||0!=S){let e=k.fill=new Path2D(y),r=b(o.fillTo(l,t,o.min,o.max,S)),u=_(s[n]),a=_(s[i]);-1==v&&([a,u]=[u,a]),g(e,a,r),g(e,u,r)}if(!o.spanGaps){let a=[];M&&a.push(..._t(s,r,n,i,v,_,e)),k.gaps=a=o.gaps(l,t,n,i,a),k.clip=wt(a,u.ori,h,d,p,m)}return 0!=T&&(k.band=2==T?[xt(l,t,n,i,y,-1),xt(l,t,n,i,y,1)]:xt(l,t,n,i,y,T)),k}))}function Rt(l,e,t,n,i,o,s=gl){if(l.length>1){let r=null;for(let u=0,a=1/0;l.length>u;u++)if(void 0!==e[u]){if(null!=r){let e=ol(l[u]-l[r]);a>e&&(a=e,s=ol(t(l[u],n,i,o)-t(l[r],n,i,o)))}r=u}}return s}function Gt(l,e,t,n,i){const o=l.length;if(2>o)return null;const s=new Path2D;if(t(s,l[0],e[0]),2==o)n(s,l[1],e[1]);else{let t=Array(o),n=Array(o-1),r=Array(o-1),u=Array(o-1);for(let t=0;o-1>t;t++)r[t]=e[t+1]-e[t],u[t]=l[t+1]-l[t],n[t]=r[t]/u[t];t[0]=n[0];for(let l=1;o-1>l;l++)0===n[l]||0===n[l-1]||n[l-1]>0!=n[l]>0?t[l]=0:(t[l]=3*(u[l-1]+u[l])/((2*u[l]+u[l-1])/n[l-1]+(u[l]+2*u[l-1])/n[l]),isFinite(t[l])||(t[l]=0));t[o-1]=n[o-2];for(let n=0;o-1>n;n++)i(s,l[n]+u[n]/3,e[n]+t[n]*u[n]/3,l[n+1]-u[n]/3,e[n+1]-t[n+1]*u[n]/3,l[n+1],e[n+1])}return s}const It=new Set;function Lt(){for(let l of It)l.syncRect(!0)}_&&(G("resize",v,Lt),G("scroll",v,Lt,!0),G(x,v,(()=>{en.pxRatio=y})));const Ot=Ft(),Nt=Wt();function jt(l,e,t,n){return(n?[l[0],l[1]].concat(l.slice(2)):[l[0]].concat(l.slice(1))).map(((l,n)=>Ut(l,n,e,t)))}function Ut(l,e,t,n){return Bl({},0==e?t:n,l)}function Vt(l,e,t){return null==e?Rl:[e,t]}const Bt=Vt;function $t(l,e,t){return null==e?Rl:Z(e,t,J,!0)}function Jt(l,e,t,n){return null==e?Rl:B(e,t,l.scales[n].log,!1)}const qt=Jt;function Kt(l,e,t,n){return null==e?Rl:$(e,t,l.scales[n].log,!1)}const Xt=Kt;function Zt(l,e,t,n,i){let o=fl(xl(l),xl(e)),s=e-l,r=L(i/n*s,t);do{let l=t[r],e=n*l/s;if(e>=i&&17>=o+(5>l?Wl.get(l):0))return[l,e]}while(++r(e=rl((t=+n)*y))+"px")),e,t]}function ln(l){l.show&&[l.font,l.labelFont].forEach((l=>{let e=Al(l[2]*y,1);l[0]=l[0].replace(/[0-9.]+px/,e+"px"),l[1]=e}))}function en(u,g,_){const k={mode:Q(u.mode,1)},M=k.mode;function P(l,e,t,n){let i=e.valToPct(l);return n+t*(-1==e.dir?1-i:i)}function W(l,e,t,n){let i=e.valToPct(l);return n+t*(-1==e.dir?i:1-i)}function C(l,e,t,n){return 0==e.ori?P(l,e,t,n):W(l,e,t,n)}k.valToPosH=P,k.valToPosV=W;let F=!1;k.status=0;const R=k.root=D("uplot");null!=u.id&&(R.id=u.id),S(R,u.class),u.title&&(D("u-title",R).textContent=u.title);const O=z("canvas"),K=k.ctx=O.getContext("2d"),X=D("u-wrap",R);G("click",X,(l=>{l.target===el&&(Nn!=Gn||jn!=In)&&Zn.click(k,l)}),!0);const ll=k.under=D("u-under",X);X.appendChild(O);const el=k.over=D("u-over",X),tl=+Q((u=Vl(u)).pxAlign,1),sl=bt(tl);(u.plugins||[]).forEach((l=>{l.opts&&(u=l.opts(k,u)||u)}));const hl=u.ms||.001,pl=k.series=1==M?jt(u.series||[],Ve,ut,!1):function(l,e){return l.map(((l,t)=>0==t?{}:Bl({},e,l)))}(u.series||[null],rt),xl=k.axes=jt(u.axes||[],Ue,it,!0),vl=k.scales={},Tl=k.bands=u.bands||[];Tl.forEach((l=>{l.fill=bl(l.fill||null),l.dir=Q(l.dir,-1)}));const El=2==M?pl[1].facets[0].scale:pl[0].scale,Dl={axes:function(){for(let l=0;xl.length>l;l++){let e=xl[l];if(!e.show||!e._show)continue;let t,n,u=e.side,a=u%2,f=e.stroke(k,l),c=0==u||3==u?-1:1,[h,d]=e._found;if(null!=e.label){let s=rl((e._lpos+e.labelGap*c)*y);_n(e.labelFont[0],f,"center",2==u?i:o),K.save(),1==a?(t=n=0,K.translate(s,rl(lt+st/2)),K.rotate((3==u?-il:il)/2)):(t=rl(Qe+ot/2),n=s);let r=_l(e.label)?e.label(k,l,h,d):e.label;K.fillText(r,t,n),K.restore()}if(0==d)continue;let p=vl[e.scale],m=0==a?ot:st,g=0==a?Qe:lt,x=e._splits,w=2==p.distr?x.map((l=>pn[l])):x,_=2==p.distr?pn[x[1]]-pn[x[0]]:h,b=e.ticks,v=e.border,M=b.show?b.size:0,S=rl(M*y),T=rl((2==e.alignTo?e._size-M-e.gap:e.gap)*y),E=e._rotate*-il/180,z=sl(e._pos*y),D=z+(S+T)*c;n=0==a?D:0,t=1==a?D:0,_n(e.font[0],f,1==e.align?s:2==e.align?r:E>0?s:0>E?r:0==a?"center":3==u?r:s,E||1==a?"middle":2==u?i:o);let P=e.font[1]*e.lineGap,A=x.map((l=>sl(C(l,p,m,g)))),W=e._values;for(let l=0;W.length>l;l++){let e=W[l];if(null!=e){0==a?t=A[l]:n=A[l],e=""+e;let i=-1==e.indexOf("\n")?[e]:e.split(/\n/gm);for(let l=0;i.length>l;l++){let e=i[l];E?(K.save(),K.translate(t,n+l*P),K.rotate(E),K.fillText(e,0,0),K.restore()):K.fillText(e,t,n+l*P)}}}b.show&&zn(A,b.filter(k,w,l,d,_),a,u,z,S,Al(b.width*y,3),b.stroke(k,l),b.dash,b.cap);let Y=e.grid;Y.show&&zn(A,Y.filter(k,w,l,d,_),a,0==a?2:1,0==a?lt:Qe,0==a?st:ot,Al(Y.width*y,3),Y.stroke(k,l),Y.dash,Y.cap),v.show&&zn([z],[1],0==a?1:0,0==a?1:2,1==a?lt:Qe,1==a?st:ot,Al(v.width*y,3),v.stroke(k,l),v.dash,v.cap)}Ci("drawAxes")},series:function(){if(Gt>0){let l=pl.some((l=>l._focus))&&dn!=Tt.alpha;l&&(K.globalAlpha=dn=Tt.alpha),pl.forEach(((l,e)=>{if(e>0&&l.show&&(kn(e,!1),kn(e,!0),null==l._paths)){let t=dn;dn!=l.alpha&&(K.globalAlpha=dn=l.alpha);let n=2==M?[0,g[e][0].length-1]:function(l){let e=wl(Lt-1,0,Gt-1),t=wl(en+1,0,Gt-1);for(;null==l[e]&&e>0;)e--;for(;null==l[t]&&Gt-1>t;)t++;return[e,t]}(g[e]);l._paths=l.paths(k,e,n[0],n[1]),dn!=t&&(K.globalAlpha=dn=t)}})),pl.forEach(((l,e)=>{if(e>0&&l.show){let t=dn;dn!=l.alpha&&(K.globalAlpha=dn=l.alpha),null!=l._paths&&yn(e,!1);{let t=null!=l._paths?l._paths.gaps:null,n=l.points.show(k,e,Lt,en,t),i=l.points.filter(k,e,n,t);(n||i)&&(l.points._paths=l.points.paths(k,e,Lt,en,i),yn(e,!0))}dn!=t&&(K.globalAlpha=dn=t),Ci("drawSeries",e)}})),l&&(K.globalAlpha=dn=1)}}},Pl=(u.drawOrder||["axes","series"]).map((l=>Dl[l]));function Cl(l){const e=3==l.distr?e=>dl(e>0?e:l.clamp(k,e,l.min,l.max,l.key)):4==l.distr?e=>ml(e,l.asinh):100==l.distr?e=>l.fwd(e):l=>l;return t=>{let n=e(t),{_min:i,_max:o}=l;return(n-i)/(o-i)}}function Il(l){let e=vl[l];if(null==e){let t=(u.scales||Hl)[l]||Hl;if(null!=t.from){Il(t.from);let e=Bl({},vl[t.from],t,{key:l});e.valToPct=Cl(e),vl[l]=e}else{e=vl[l]=Bl({},l==El?ft:ct,t),e.key=l;let n=e.time,i=e.range,o=Gl(i);if((l!=El||2==M&&!n)&&(!o||null!=i[0]&&null!=i[1]||(i={min:null==i[0]?q:{mode:1,hard:i[0],soft:i[0]},max:null==i[1]?q:{mode:1,hard:i[1],soft:i[1]}},o=!1),!o&&Ol(i))){let l=i;i=(e,t,n)=>null==t?Rl:Z(t,n,l)}e.range=bl(i||(n?Bt:l==El?3==e.distr?qt:4==e.distr?Xt:Vt:3==e.distr?Jt:4==e.distr?Kt:$t)),e.auto=bl(!o&&e.auto),e.clamp=bl(e.clamp||at),e._min=e._max=null,e.valToPct=Cl(e)}}}Il("x"),Il("y"),1==M&&pl.forEach((l=>{Il(l.scale)})),xl.forEach((l=>{Il(l.scale)}));for(let l in u.scales)Il(l);const jl=vl[El],Ul=jl.distr;let $l,ql;0==jl.ori?(S(R,"u-hz"),$l=P,ql=W):(S(R,"u-vt"),$l=W,ql=P);const Kl={};for(let l in vl){let e=vl[l];null==e.min&&null==e.max||(Kl[l]={min:e.min,max:e.max},e.min=e.max=null)}const Xl=u.tzDate||(l=>new Date(rl(l/hl))),Zl=u.fmtDate||ne,Ql=1==hl?Me(Xl):Ee(Xl),le=De(Xl,ze(1==hl?ye:Te,Zl)),ee=We(Xl,Ae("{YYYY}-{MM}-{DD} {h}:{mm}{aa}",Zl)),te=[],ie=k.legend=Bl({},Ye,u.legend),oe=k.cursor=Bl({},Re,{drag:{y:2==M}},u.cursor),se=ie.show,re=oe.show,ue=ie.markers;let ce,he,de;ie.idxs=te,ue.width=bl(ue.width),ue.dash=bl(ue.dash),ue.stroke=bl(ue.stroke),ue.fill=bl(ue.fill);let pe,me=[],ge=[],xe=!1,we={};if(ie.live){const l=pl[1]?pl[1].values:null;xe=null!=l,pe=xe?l(k,1,0):{_:0};for(let l in pe)we[l]=w}if(se)if(ce=z("table","u-legend",R),de=z("tbody",null,ce),ie.mount(k,ce),xe){he=z("thead",null,ce,de);let l=z("tr",null,he);for(var _e in z("th",null,l),pe)z("th",e,l).textContent=_e}else S(ce,"u-inline"),ie.live&&S(ce,"u-live");const be={show:!0},ve={show:!1},Pe=new Map;function Ce(l,e,t,n=!0){const i=Pe.get(e)||{},o=oe.bind[l](k,e,t,n);o&&(G(l,e,i[l]=o),Pe.set(e,i))}function He(l,e){const t=Pe.get(e)||{};for(let n in t)null!=l&&n!=l||(I(n,e,t[n]),delete t[n]);null==l&&Pe.delete(e)}let Fe=0,Ge=0,Ie=0,Le=0,Oe=0,Ne=0,je=Oe,Ke=Ne,Xe=Ie,Ze=Le,Qe=0,lt=0,ot=0,st=0;k.bbox={};let ht=!1,pt=!1,mt=!1,xt=!1,wt=!1,_t=!1;function vt(l,e,t){(t||l!=k.width||e!=k.height)&&kt(l,e),An(!1),mt=!0,pt=!0,Jn()}function kt(l,e){k.width=Fe=Ie=l,k.height=Ge=Le=e,Oe=Ne=0,function(){let l=!1,e=!1,t=!1,n=!1;xl.forEach((i=>{if(i.show&&i._show){let{side:o,_size:s}=i,r=s+(null!=i.label?i.labelSize:0);r>0&&(o%2?(Ie-=r,3==o?(Oe+=r,n=!0):t=!0):(Le-=r,0==o?(Ne+=r,l=!0):e=!0))}})),Ct[0]=l,Ct[1]=t,Ct[2]=e,Ct[3]=n,Ie-=Rt[1]+Rt[3],Oe+=Rt[3],Le-=Rt[2]+Rt[0],Ne+=Rt[0]}(),function(){let l=Oe+Ie,e=Ne+Le,t=Oe,n=Ne;function i(i,o){switch(i){case 1:return l+=o,l-o;case 2:return e+=o,e-o;case 3:return t-=o,t+o;case 0:return n-=o,n+o}}xl.forEach((l=>{if(l.show&&l._show){let e=l.side;l._pos=i(e,l._size),null!=l.label&&(l._lpos=i(e,l.labelSize))}}))}();let t=k.bbox;Qe=t.left=zl(Oe*y,.5),lt=t.top=zl(Ne*y,.5),ot=t.width=zl(Ie*y,.5),st=t.height=zl(Le*y,.5)}const yt=3;if(k.setSize=function({width:l,height:e}){vt(l,e)},null==oe.dataIdx){let l=oe.hover,e=l.skip=new Set(l.skip??[]);e.add(void 0);let t=l.prox=bl(l.prox),n=l.bias??=0;oe.dataIdx=(l,i,o,s)=>{if(0==i)return o;let r=o,u=t(l,i,o,s)??gl,a=u>=0&&gl>u,f=0==jl.ori?Ie:Le,c=oe.left,h=g[0],d=g[i];if(e.has(d[o])){r=null;let l,t=null,i=null;if(0==n||-1==n)for(l=o;null==t&&l-- >0;)e.has(d[l])||(t=l);if(0==n||1==n)for(l=o;null==i&&l++e?e>u||(r=i):l>u||(r=t)}else r=null==i?t:null==t||o-t>i-o?i:t}else a&&ol(c-$l(h[o],jl,f,0))>u&&(r=null);return r}}const Mt=l=>{oe.event=l};oe.idxs=te,oe._lock=!1;let St=oe.points;St.show=bl(St.show),St.size=bl(St.size),St.stroke=bl(St.stroke),St.width=bl(St.width),St.fill=bl(St.fill);const Tt=k.focus=Bl({},u.focus||{alpha:.3},oe.focus),Et=Tt.prox>=0,zt=Et&&St.one;let Dt=[],Pt=[],At=[];function Wt(l,e){let t=St.show(k,e);if(t instanceof HTMLElement)return S(t,"u-cursor-pt"),S(t,l.class),A(t,-10,-10,Ie,Le),el.insertBefore(t,Dt[e]),t}function Yt(t,n){if(1==M||n>0){let l=1==M&&vl[t.scale].time,e=t.value;t.value=l?Ll(e)?We(Xl,Ae(e,Zl)):e||ee:e||nt,t.label=t.label||(l?"Time":"Value")}if(zt||n>0){t.width=null==t.width?1:t.width,t.paths=t.paths||Ot||yl,t.fillTo=bl(t.fillTo||gt),t.pxAlign=+Q(t.pxAlign,tl),t.pxRound=bt(t.pxAlign),t.stroke=bl(t.stroke||null),t.fill=bl(t.fill||null),t._stroke=t._fill=t._paths=t._focus=null;let l=function(l){return Al(1*(3+2*(l||1)),3)}(fl(1,t.width)),e=t.points=Bl({},{size:l,width:fl(1,.2*l),stroke:t.stroke,space:2*l,paths:Nt,_stroke:null,_fill:null},t.points);e.show=bl(e.show),e.filter=bl(e.filter),e.fill=bl(e.fill),e.stroke=bl(e.stroke),e.paths=bl(e.paths),e.pxAlign=t.pxAlign}if(se){let i=function(t,n){if(0==n&&(xe||!ie.live||2==M))return Rl;let i=[],o=z("tr","u-series",de,de.childNodes[n]);S(o,t.class),t.show||S(o,l);let s=z("th",null,o);if(ue.show){let l=D("u-marker",s);if(n>0){let e=ue.width(k,n);e&&(l.style.border=e+"px "+ue.dash(k,n)+" "+ue.stroke(k,n)),l.style.background=ue.fill(k,n)}}let r=D(e,s);for(var u in t.label instanceof HTMLElement?r.appendChild(t.label):r.textContent=t.label,n>0&&(ue.show||(r.style.color=t.width>0?ue.stroke(k,n):ue.fill(k,n)),Ce("click",s,(l=>{if(oe._lock)return;Mt(l);let e=pl.indexOf(t);if((l.ctrlKey||l.metaKey)!=ie.isolate){let l=pl.some(((l,t)=>t>0&&t!=e&&l.show));pl.forEach(((t,n)=>{n>0&&oi(n,l?n==e?be:ve:be,!0,Fi.setSeries)}))}else oi(e,{show:!t.show},!0,Fi.setSeries)}),!1),Et&&Ce(d,s,(l=>{oe._lock||(Mt(l),oi(pl.indexOf(t),ai,!0,Fi.setSeries))}),!1)),pe){let l=z("td","u-value",o);l.textContent="--",i.push(l)}return[o,i]}(t,n);me.splice(n,0,i[0]),ge.splice(n,0,i[1]),ie.values.push(null)}if(re){te.splice(n,0,null);let l=null;zt?0==n&&(l=Wt(t,n)):n>0&&(l=Wt(t,n)),Dt.splice(n,0,l),Pt.splice(n,0,0),At.splice(n,0,0)}Ci("addSeries",n)}k.addSeries=function(l,e){e=null==e?pl.length:e,l=1==M?Ut(l,e,Ve,ut):Ut(l,e,{},rt),pl.splice(e,0,l),Yt(pl[e],e)},k.delSeries=function(l){if(pl.splice(l,1),se){ie.values.splice(l,1),ge.splice(l,1);let e=me.splice(l,1)[0];He(null,e.firstChild),e.remove()}re&&(te.splice(l,1),Dt.splice(l,1)[0].remove(),Pt.splice(l,1),At.splice(l,1)),Ci("delSeries",l)};const Ct=[!1,!1,!1,!1];function Ht(l,e,t){let[n,i,o,s]=t,r=e%2,u=0;return 0==r&&(s||i)&&(u=0==e&&!n||2==e&&!o?rl(Ue.size/3):0),1==r&&(n||o)&&(u=1==e&&!i||3==e&&!s?rl(it.size/2):0),u}const Ft=k.padding=(u.padding||[Ht,Ht,Ht,Ht]).map((l=>bl(Q(l,Ht)))),Rt=k._padding=Ft.map(((l,e)=>l(k,e,Ct,0)));let Gt,Lt=null,en=null;const tn=1==M?pl[0].idxs:null;let nn,on,sn,rn,un,an,fn,cn,hn,dn,pn=null,mn=!1;function gn(l,e){if(k.data=k._data=g=null==l?[]:l,2==M){Gt=0;for(let l=1;pl.length>l;l++)Gt+=g[l][0].length}else{0==g.length&&(k.data=k._data=g=[[]]),pn=g[0],Gt=pn.length;let l=g;if(2==Ul){l=g.slice();let e=l[0]=Array(Gt);for(let l=0;Gt>l;l++)e[l]=l}k._data=g=l}if(An(!0),Ci("setData"),2==Ul&&(mt=!0),!1!==e){let l=jl;l.auto(k,mn)?xn():ii(El,l.min,l.max),xt=xt||oe.left>=0,_t=!0,Jn()}}function xn(){let l,e;mn=!0,1==M&&(Gt>0?(Lt=tn[0]=0,en=tn[1]=Gt-1,l=g[0][Lt],e=g[0][en],2==Ul?(l=Lt,e=en):l==e&&(3==Ul?[l,e]=B(l,l,jl.log,!1):4==Ul?[l,e]=$(l,l,jl.log,!1):jl.time?e=l+rl(86400/hl):[l,e]=Z(l,e,J,!0))):(Lt=tn[0]=l=null,en=tn[1]=e=null)),ii(El,l,e)}function wn(l,e,t,n,i,o){l??=a,t??=Fl,n??="butt",i??=a,o??="round",l!=nn&&(K.strokeStyle=nn=l),i!=on&&(K.fillStyle=on=i),e!=sn&&(K.lineWidth=sn=e),o!=un&&(K.lineJoin=un=o),n!=an&&(K.lineCap=an=n),t!=rn&&K.setLineDash(rn=t)}function _n(l,e,t,n){e!=on&&(K.fillStyle=on=e),l!=fn&&(K.font=fn=l),t!=cn&&(K.textAlign=cn=t),n!=hn&&(K.textBaseline=hn=n)}function bn(l,e,t,n,i=0){if(n.length>0&&l.auto(k,mn)&&(null==e||null==e.min)){let e=Q(Lt,0),o=Q(en,n.length-1),s=null==t.min?function(l,e,t,n=0,i=!1){let o=i?V:U,s=i?j:N;[e,t]=o(l,e,t);let r=l[e],u=l[e];if(e>-1)if(1==n)r=l[e],u=l[t];else if(-1==n)r=l[t],u=l[e];else for(let n=e;t>=n;n++){let e=l[n];s(e)&&(r>e?r=e:e>u&&(u=e))}return[r??gl,u??-gl]}(n,e,o,i,3==l.distr):[t.min,t.max];l.min=al(l.min,t.min=s[0]),l.max=fl(l.max,t.max=s[1])}}k.setData=gn;const vn={min:null,max:null};function kn(l,e){let t=e?pl[l].points:pl[l];t._stroke=t.stroke(k,l),t._fill=t.fill(k,l)}function yn(l,e){let t=e?pl[l].points:pl[l],{stroke:n,fill:i,clip:o,flags:s,_stroke:r=t._stroke,_fill:u=t._fill,_width:a=t.width}=t._paths;a=Al(a*y,3);let f=null,c=a%2/2;e&&null==u&&(u=a>0?"#fff":r);let h=1==t.pxAlign&&c>0;if(h&&K.translate(c,c),!e){let l=Qe-a/2,e=lt-a/2,t=ot+a,n=st+a;f=new Path2D,f.rect(l,e,t,n)}e?Sn(r,a,t.dash,t.cap,u,n,i,s,o):function(l,e,t,n,i,o,s,r,u,a,f){let c=!1;0!=u&&Tl.forEach(((h,d)=>{if(h.series[0]==l){let l,p=pl[h.series[1]],m=g[h.series[1]],x=(p._paths||Hl).band;Gl(x)&&(x=1==h.dir?x[0]:x[1]);let w=null;p.show&&x&&function(l,e,t){for(e=Q(e,0),t=Q(t,l.length-1);t>=e;){if(null!=l[e])return!0;e++}return!1}(m,Lt,en)?(w=h.fill(k,d)||o,l=p._paths.clip):x=null,Sn(e,t,n,i,w,s,r,u,a,f,l,x),c=!0}})),c||Sn(e,t,n,i,o,s,r,u,a,f)}(l,r,a,t.dash,t.cap,u,n,i,s,f,o),h&&K.translate(-c,-c)}const Mn=3;function Sn(l,e,t,n,i,o,s,r,u,a,f,c){wn(l,e,t,n,i),(u||a||c)&&(K.save(),u&&K.clip(u),a&&K.clip(a)),c?(r&Mn)==Mn?(K.clip(c),f&&K.clip(f),En(i,s),Tn(l,o,e)):2&r?(En(i,s),K.clip(c),Tn(l,o,e)):1&r&&(K.save(),K.clip(c),f&&K.clip(f),En(i,s),K.restore(),Tn(l,o,e)):(En(i,s),Tn(l,o,e)),(u||a||c)&&K.restore()}function Tn(l,e,t){t>0&&(e instanceof Map?e.forEach(((l,e)=>{K.strokeStyle=nn=e,K.stroke(l)})):null!=e&&l&&K.stroke(e))}function En(l,e){e instanceof Map?e.forEach(((l,e)=>{K.fillStyle=on=e,K.fill(l)})):null!=e&&l&&K.fill(e)}function zn(l,e,t,n,i,o,s,r,u,a){let f=s%2/2;1==tl&&K.translate(f,f),wn(r,s,u,a,r),K.beginPath();let c,h,d,p,m=i+(0==n||3==n?-o:o);0==t?(h=i,p=m):(c=i,d=m);for(let n=0;l.length>n;n++)null!=e[n]&&(0==t?c=d=l[n]:h=p=l[n],K.moveTo(c,h),K.lineTo(d,p));K.stroke(),1==tl&&K.translate(-f,-f)}function Dn(l){let e=!0;return xl.forEach(((t,n)=>{if(!t.show)return;let i=vl[t.scale];if(null==i.min)return void(t._show&&(e=!1,t._show=!1,An(!1)));t._show||(e=!1,t._show=!0,An(!1));let o=t.side,s=o%2,{min:r,max:u}=i,[a,f]=function(l,e,t,n){let i,o=xl[l];if(n>0){let s=o._space=o.space(k,l,e,t,n);i=Zt(e,t,o._incrs=o.incrs(k,l,e,t,n,s),n,s)}else i=[0,0];return o._found=i}(n,r,u,0==s?Ie:Le);if(0==f)return;let c=t._splits=t.splits(k,n,r,u,a,f,2==i.distr),h=2==i.distr?c.map((l=>pn[l])):c,d=2==i.distr?pn[c[1]]-pn[c[0]]:a,p=t._values=t.values(k,t.filter(k,h,n,f,d),n,f,d);t._rotate=2==o?t.rotate(k,p,n,f):0;let m=t._size;t._size=ul(t.size(k,p,n,l)),null!=m&&t._size!=m&&(e=!1)})),e}function Pn(l){let e=!0;return Ft.forEach(((t,n)=>{let i=t(k,n,Ct,l);i!=Rt[n]&&(e=!1),Rt[n]=i})),e}function An(l){pl.forEach(((e,t)=>{t>0&&(e._paths=null,l&&(1==M?(e.min=null,e.max=null):e.facets.forEach((l=>{l.min=null,l.max=null}))))}))}let Wn,Yn,Cn,Hn,Fn,Rn,Gn,In,Ln,On,Nn,jn,Un=!1,Vn=!1,Bn=[];function $n(){Vn=!1;for(let l=0;Bn.length>l;l++)Ci(...Bn[l]);Bn.length=0}function Jn(){Un||(Jl(qn),Un=!0)}function qn(){if(ht&&(function(){for(let l in vl){let e=vl[l];null==Kl[l]&&(null==e.min||null!=Kl[El]&&e.auto(k,mn))&&(Kl[l]=vn)}for(let l in vl){let e=vl[l];null==Kl[l]&&null!=e.from&&null!=Kl[e.from]&&(Kl[l]=vn)}null!=Kl[El]&&An(!0);let l={};for(let e in Kl){let t=Kl[e];if(null!=t){let n=l[e]=Vl(vl[e],Nl);if(null!=t.min)Bl(n,t);else if(e!=El||2==M)if(0==Gt&&null==n.from){let l=n.range(k,null,null,e);n.min=l[0],n.max=l[1]}else n.min=gl,n.max=-gl}}if(Gt>0){pl.forEach(((e,t)=>{if(1==M){let n=e.scale,i=Kl[n];if(null==i)return;let o=l[n];if(0==t){let l=o.range(k,o.min,o.max,n);o.min=l[0],o.max=l[1],Lt=L(o.min,g[0]),en=L(o.max,g[0]),en-Lt>1&&(o.min>g[0][Lt]&&Lt++,g[0][en]>o.max&&en--),e.min=pn[Lt],e.max=pn[en]}else e.show&&e.auto&&bn(o,i,e,g[t],e.sorted);e.idxs[0]=Lt,e.idxs[1]=en}else if(t>0&&e.show&&e.auto){let[n,i]=e.facets,o=n.scale,s=i.scale,[r,u]=g[t],a=l[o],f=l[s];null!=a&&bn(a,Kl[o],n,r,n.sorted),null!=f&&bn(f,Kl[s],i,u,i.sorted),e.min=i.min,e.max=i.max}}));for(let e in l){let t=l[e],n=Kl[e];if(null==t.from&&(null==n||null==n.min)){let l=t.range(k,t.min==gl?null:t.min,t.max==-gl?null:t.max,e);t.min=l[0],t.max=l[1]}}}for(let e in l){let t=l[e];if(null!=t.from){let n=l[t.from];if(null==n.min)t.min=t.max=null;else{let l=t.range(k,n.min,n.max,e);t.min=l[0],t.max=l[1]}}}let e={},t=!1;for(let n in l){let i=l[n],o=vl[n];if(o.min!=i.min||o.max!=i.max){o.min=i.min,o.max=i.max;let l=o.distr;o._min=3==l?dl(o.min):4==l?ml(o.min,o.asinh):100==l?o.fwd(o.min):o.min,o._max=3==l?dl(o.max):4==l?ml(o.max,o.asinh):100==l?o.fwd(o.max):o.max,e[n]=t=!0}}if(t){pl.forEach(((l,t)=>{2==M?t>0&&e.y&&(l._paths=null):e[l.scale]&&(l._paths=null)}));for(let l in e)mt=!0,Ci("setScale",l);re&&oe.left>=0&&(xt=_t=!0)}for(let l in Kl)Kl[l]=null}(),ht=!1),mt&&(function(){let l=!1,e=0;for(;!l;){e++;let t=Dn(e),n=Pn(e);l=e==yt||t&&n,l||(kt(k.width,k.height),pt=!0)}}(),mt=!1),pt){if(E(ll,s,Oe),E(ll,i,Ne),E(ll,t,Ie),E(ll,n,Le),E(el,s,Oe),E(el,i,Ne),E(el,t,Ie),E(el,n,Le),E(X,t,Fe),E(X,n,Ge),O.width=rl(Fe*y),O.height=rl(Ge*y),xl.forEach((({_el:e,_show:t,_size:n,_pos:i,side:o})=>{if(null!=e)if(t){let t=o%2==1;E(e,t?"left":"top",i-(3===o||0===o?n:0)),E(e,t?"width":"height",n),E(e,t?"top":"left",t?Ne:Oe),E(e,t?"height":"width",t?Le:Ie),T(e,l)}else S(e,l)})),nn=on=sn=un=an=fn=cn=hn=rn=null,dn=1,_i(!0),Oe!=je||Ne!=Ke||Ie!=Xe||Le!=Ze){An(!1);let l=Ie/Xe,e=Le/Ze;if(re&&!xt&&oe.left>=0){oe.left*=l,oe.top*=e,Cn&&A(Cn,rl(oe.left),0,Ie,Le),Hn&&A(Hn,0,rl(oe.top),Ie,Le);for(let t=0;Dt.length>t;t++){let n=Dt[t];null!=n&&(Pt[t]*=l,At[t]*=e,A(n,ul(Pt[t]),ul(At[t]),Ie,Le))}}if(ei.show&&!wt&&ei.left>=0&&ei.width>0){ei.left*=l,ei.width*=l,ei.top*=e,ei.height*=e;for(let l in ki)E(ti,l,ei[l])}je=Oe,Ke=Ne,Xe=Ie,Ze=Le}Ci("setSize"),pt=!1}Fe>0&&Ge>0&&(K.clearRect(0,0,O.width,O.height),Ci("drawClear"),Pl.forEach((l=>l())),Ci("draw")),ei.show&&wt&&(ni(ei),wt=!1),re&&xt&&(xi(null,!0,!1),xt=!1),ie.show&&ie.live&&_t&&(mi(),_t=!1),F||(F=!0,k.status=1,Ci("ready")),mn=!1,Un=!1}function Kn(l,e){let t=vl[l];if(null==t.from){if(0==Gt){let n=t.range(k,e.min,e.max,l);e.min=n[0],e.max=n[1]}if(e.min>e.max){let l=e.min;e.min=e.max,e.max=l}if(Gt>1&&null!=e.min&&null!=e.max&&1e-16>e.max-e.min)return;l==El&&2==t.distr&&Gt>0&&(e.min=L(e.min,g[0]),e.max=L(e.max,g[0]),e.min==e.max&&e.max++),Kl[l]=e,ht=!0,Jn()}}k.batch=function(l,e=!1){Un=!0,Vn=e,l(k),qn(),e&&Bn.length>0&&queueMicrotask($n)},k.redraw=(l,e)=>{mt=e||!1,!1!==l?ii(El,jl.min,jl.max):Jn()},k.setScale=Kn;let Xn=!1;const Zn=oe.drag;let Qn=Zn.x,li=Zn.y;re&&(oe.x&&(Wn=D("u-cursor-x",el)),oe.y&&(Yn=D("u-cursor-y",el)),0==jl.ori?(Cn=Wn,Hn=Yn):(Cn=Yn,Hn=Wn),Nn=oe.left,jn=oe.top);const ei=k.select=Bl({show:!0,over:!0,left:0,width:0,top:0,height:0},u.select),ti=ei.show?D("u-select",ei.over?el:ll):null;function ni(l,e){if(ei.show){for(let e in l)ei[e]=l[e],e in ki&&E(ti,e,l[e]);!1!==e&&Ci("setSelect")}}function ii(l,e,t){Kn(l,{min:e,max:t})}function oi(e,t,n,i){null!=t.focus&&function(l){if(l!=ui){let e=null==l,t=1!=Tt.alpha;pl.forEach(((n,i)=>{if(1==M||i>0){let o=e||0==i||i==l;n._focus=e?null:o,t&&function(l,e){pl[l].alpha=e,re&&null!=Dt[l]&&(Dt[l].style.opacity=e),se&&me[l]&&(me[l].style.opacity=e)}(i,o?1:Tt.alpha)}})),ui=l,t&&Jn()}}(e),null!=t.show&&pl.forEach(((n,i)=>{0>=i||e!=i&&null!=e||(n.show=t.show,function(e){if(pl[e].show)se&&T(me[e],l);else if(se&&S(me[e],l),re){let l=zt?Dt[0]:Dt[e];null!=l&&A(l,-10,-10,Ie,Le)}}(i),2==M?(ii(n.facets[0].scale,null,null),ii(n.facets[1].scale,null,null)):ii(n.scale,null,null),Jn())})),!1!==n&&Ci("setSeries",e,t),i&&Ii("setSeries",k,e,t)}let si,ri,ui;k.setSelect=ni,k.setSeries=oi,k.addBand=function(l,e){l.fill=bl(l.fill||null),l.dir=Q(l.dir,-1),Tl.splice(e=null==e?Tl.length:e,0,l)},k.setBand=function(l,e){Bl(Tl[l],e)},k.delBand=function(l){null==l?Tl.length=0:Tl.splice(l,1)};const ai={focus:!0};function fi(l,e,t){let n=vl[e];t&&(l=l/y-(1==n.ori?Ne:Oe));let i=Ie;1==n.ori&&(i=Le,l=i-l),-1==n.dir&&(l=i-l);let o=n._min,s=o+l/i*(n._max-o),r=n.distr;return 3==r?cl(10,s):4==r?((l,e=1)=>nl.sinh(l)*e)(s,n.asinh):100==r?n.bwd(s):s}function ci(l,e){E(ti,s,ei.left=l),E(ti,t,ei.width=e)}function hi(l,e){E(ti,i,ei.top=l),E(ti,n,ei.height=e)}se&&Et&&Ce(p,ce,(l=>{oe._lock||(Mt(l),null!=ui&&oi(null,ai,!0,Fi.setSeries))})),k.valToIdx=l=>L(l,g[0]),k.posToIdx=function(l,e){return L(fi(l,El,e),g[0],Lt,en)},k.posToVal=fi,k.valToPos=(l,e,t)=>0==vl[e].ori?P(l,vl[e],t?ot:Ie,t?Qe:0):W(l,vl[e],t?st:Le,t?lt:0),k.setCursor=(l,e,t)=>{Nn=l.left,jn=l.top,xi(null,e,t)};let di=0==jl.ori?ci:hi,pi=1==jl.ori?ci:hi;function mi(l,e){if(null!=l&&(l.idxs?l.idxs.forEach(((l,e)=>{te[e]=l})):(l=>void 0===l)(l.idx)||te.fill(l.idx),ie.idx=te[0]),se&&ie.live){for(let l=0;pl.length>l;l++)(l>0||1==M&&!xe)&&gi(l,te[l]);!function(){if(se&&ie.live)for(let l=2==M?1:0;pl.length>l;l++){if(0==l&&xe)continue;let e=ie.values[l],t=0;for(let n in e)ge[l][t++].firstChild.nodeValue=e[n]}}()}_t=!1,!1!==e&&Ci("setLegend")}function gi(l,e){let t,n=pl[l],i=0==l&&2==Ul?pn:g[l];xe?t=n.values(k,l,e)??we:(t=n.value(k,null==e?null:i[e],l,e),t=null==t?we:{_:t}),ie.values[l]=t}function xi(l,e,t){let n;Ln=Nn,On=jn,[Nn,jn]=oe.move(k,Nn,jn),oe.left=Nn,oe.top=jn,re&&(Cn&&A(Cn,rl(Nn),0,Ie,Le),Hn&&A(Hn,0,rl(jn),Ie,Le)),si=gl,ri=null;let i=0==jl.ori?Ie:Le,o=1==jl.ori?Ie:Le;if(0>Nn||0==Gt||Lt>en){n=oe.idx=null;for(let l=0;pl.length>l;l++){let e=Dt[l];null!=e&&A(e,-10,-10,Ie,Le)}Et&&oi(null,ai,!0,null==l&&Fi.setSeries),ie.live&&(te.fill(n),_t=!0)}else{let l,e,t;1==M&&(l=0==jl.ori?Nn:jn,e=fi(l,El),n=oe.idx=L(e,g[0],Lt,en),t=$l(g[0][n],jl,i,0));let s=-10,r=-10,u=0,a=0,f=!0,c="",h="";for(let l=2==M?1:0;pl.length>l;l++){let d=pl[l],p=te[l],m=null==p?null:1==M?g[l][p]:g[l][1][p],x=oe.dataIdx(k,l,n,e),w=null==x?null:1==M?g[l][x]:g[l][1][x];if(_t=_t||w!=m||x!=p,te[l]=x,l>0&&d.show){let e=null==x?-10:x==n?t:$l(1==M?g[0][x]:g[l][0][x],jl,i,0),p=null==w?-10:ql(w,1==M?vl[d.scale]:vl[d.facets[1].scale],o,0);if(Et&&null!=w){let e=1==jl.ori?Nn:jn,t=ol(Tt.dist(k,l,x,p,e));if(si>t){let n=Tt.bias;if(0!=n){let i=fi(e,d.scale),o=0>i?-1:1;o!=(0>w?-1:1)||(1==o?1==n?i>w:w>i:1==n?w>i:i>w)||(si=t,ri=l)}else si=t,ri=l}}if(_t||zt){let t,n;0==jl.ori?(t=e,n=p):(t=p,n=e);let i,o,d,m,g,x,w=!0,_=St.bbox;if(null!=_){w=!1;let e=_(k,l);d=e.left,m=e.top,i=e.width,o=e.height}else d=t,m=n,i=o=St.size(k,l);if(x=St.fill(k,l),g=St.stroke(k,l),zt)l!=ri||si>Tt.prox||(s=d,r=m,u=i,a=o,f=w,c=x,h=g);else{let e=Dt[l];null!=e&&(Pt[l]=d,At[l]=m,H(e,i,o,w),Y(e,x,g),A(e,ul(d),ul(m),Ie,Le))}}}}if(zt){let l=Tt.prox;if(_t||(null==ui?l>=si:si>l||ri!=ui)){let l=Dt[0];null!=l&&(Pt[0]=s,At[0]=r,H(l,u,a,f),Y(l,c,h),A(l,ul(s),ul(r),Ie,Le))}}}if(ei.show&&Xn)if(null!=l){let[e,t]=Fi.scales,[n,s]=Fi.match,[r,u]=l.cursor.sync.scales,a=l.cursor.drag;if(Qn=a._x,li=a._y,Qn||li){let a,f,c,h,d,{left:p,top:m,width:g,height:x}=l.select,w=l.scales[r].ori,_=l.posToVal,b=null!=e&&n(e,r),v=null!=t&&s(t,u);b&&Qn?(0==w?(a=p,f=g):(a=m,f=x),c=vl[e],h=$l(_(a,r),c,i,0),d=$l(_(a+f,r),c,i,0),di(al(h,d),ol(d-h))):di(0,i),v&&li?(1==w?(a=p,f=g):(a=m,f=x),c=vl[t],h=ql(_(a,u),c,o,0),d=ql(_(a+f,u),c,o,0),pi(al(h,d),ol(d-h))):pi(0,o)}else yi()}else{let l=ol(Ln-Fn),e=ol(On-Rn);if(1==jl.ori){let t=l;l=e,e=t}Qn=Zn.x&&l>=Zn.dist,li=Zn.y&&e>=Zn.dist;let t,n,s=Zn.uni;null!=s?Qn&&li&&(Qn=l>=s,li=e>=s,Qn||li||(e>l?li=!0:Qn=!0)):Zn.x&&Zn.y&&(Qn||li)&&(Qn=li=!0),Qn&&(0==jl.ori?(t=Gn,n=Nn):(t=In,n=jn),di(al(t,n),ol(n-t)),li||pi(0,o)),li&&(1==jl.ori?(t=Gn,n=Nn):(t=In,n=jn),pi(al(t,n),ol(n-t)),Qn||di(0,i)),Qn||li||(di(0,0),pi(0,0))}if(Zn._x=Qn,Zn._y=li,null==l){if(t){if(null!=Ri){let[l,e]=Fi.scales;Fi.values[0]=null!=l?fi(0==jl.ori?Nn:jn,l):null,Fi.values[1]=null!=e?fi(1==jl.ori?Nn:jn,e):null}Ii(f,k,Nn,jn,Ie,Le,n)}if(Et){let l=t&&Fi.setSeries,e=Tt.prox;null==ui?si>e||oi(ri,ai,!0,l):si>e?oi(null,ai,!0,l):ri!=ui&&oi(ri,ai,!0,l)}}_t&&(ie.idx=n,mi()),!1!==e&&Ci("setCursor")}k.setLegend=mi;let wi=null;function _i(l=!1){l?wi=null:(wi=el.getBoundingClientRect(),Ci("syncRect",wi))}function bi(l,e,t,n,i,o){oe._lock||Xn&&null!=l&&0==l.movementX&&0==l.movementY||(vi(l,e,t,n,i,o,0,!1,null!=l),null!=l?xi(null,!0,!0):xi(e,!0,!1))}function vi(l,e,t,n,i,o,s,r,u){if(null==wi&&_i(!1),Mt(l),null!=l)t=l.clientX-wi.left,n=l.clientY-wi.top;else{if(0>t||0>n)return Nn=-10,void(jn=-10);let[l,s]=Fi.scales,r=e.cursor.sync,[u,a]=r.values,[f,c]=r.scales,[h,d]=Fi.match,p=e.axes[0].side%2==1,m=0==jl.ori?Ie:Le,g=1==jl.ori?Ie:Le,x=p?o:i,w=p?i:o,_=p?n:t,b=p?t:n;if(t=null!=f?h(l,f)?C(u,vl[l],m,0):-10:m*(_/x),n=null!=c?d(s,c)?C(a,vl[s],g,0):-10:g*(b/w),1==jl.ori){let l=t;t=n,n=l}}!u||null!=e&&e.cursor.event.type!=f||(t>1&&Ie-1>t||(t=zl(t,Ie)),n>1&&Le-1>n||(n=zl(n,Le))),r?(Fn=t,Rn=n,[Gn,In]=oe.move(k,t,n)):(Nn=t,jn=n)}Object.defineProperty(k,"rect",{get:()=>(null==wi&&_i(!1),wi)});const ki={width:0,height:0,left:0,top:0};function yi(){ni(ki,!1)}let Mi,Si,Ti,Ei;function zi(l,e,t,n,i,o){Xn=!0,Qn=li=Zn._x=Zn._y=!1,vi(l,e,t,n,i,o,0,!0,!1),null!=l&&(Ce(h,b,Di,!1),Ii(c,k,Gn,In,Ie,Le,null));let{left:s,top:r,width:u,height:a}=ei;Mi=s,Si=r,Ti=u,Ei=a}function Di(l,e,t,n,i,o){Xn=Zn._x=Zn._y=!1,vi(l,e,t,n,i,o,0,!1,!0);let{left:s,top:r,width:u,height:a}=ei,f=u>0||a>0,c=Mi!=s||Si!=r||Ti!=u||Ei!=a;if(f&&c&&ni(ei),Zn.setScale&&f&&c){let l=s,e=u,t=r,n=a;if(1==jl.ori&&(l=r,e=a,t=s,n=u),Qn&&ii(El,fi(l,El),fi(l+e,El)),li)for(let l in vl){let e=vl[l];l!=El&&null==e.from&&e.min!=gl&&ii(l,fi(t+n,l),fi(t,l))}yi()}else oe.lock&&(oe._lock=!oe._lock,xi(e,!0,null!=l));null!=l&&(He(h,b),Ii(h,k,Nn,jn,Ie,Le,null))}function Pi(l){oe._lock||(Mt(l),xn(),yi(),null!=l&&Ii(m,k,Nn,jn,Ie,Le,null))}function Ai(){xl.forEach(ln),vt(k.width,k.height,!0)}G(x,v,Ai);const Wi={};Wi.mousedown=zi,Wi.mousemove=bi,Wi.mouseup=Di,Wi.dblclick=Pi,Wi.setSeries=(l,e,t,n)=>{-1!=(t=(0,Fi.match[2])(k,e,t))&&oi(t,n,!0,!1)},re&&(Ce(c,el,zi),Ce(f,el,bi),Ce(d,el,(l=>{Mt(l),_i(!1)})),Ce(p,el,(function(l){if(oe._lock)return;Mt(l);let e=Xn;if(Xn){let l,e,t=!0,n=!0,i=10;0==jl.ori?(l=Qn,e=li):(l=li,e=Qn),l&&e&&(t=i>=Nn||Nn>=Ie-i,n=i>=jn||jn>=Le-i),l&&t&&(Nn=Gn>Nn?0:Ie),e&&n&&(jn=In>jn?0:Le),xi(null,!0,!0),Xn=!1}Nn=-10,jn=-10,te.fill(null),xi(null,!0,!0),e&&(Xn=e)})),Ce(m,el,Pi),It.add(k),k.syncRect=_i);const Yi=k.hooks=u.hooks||{};function Ci(l,e,t){Vn?Bn.push([l,e,t]):l in Yi&&Yi[l].forEach((l=>{l.call(null,k,e,t)}))}(u.plugins||[]).forEach((l=>{for(let e in l.hooks)Yi[e]=(Yi[e]||[]).concat(l.hooks[e])}));const Hi=(l,e,t)=>t,Fi=Bl({key:null,setSeries:!1,filters:{pub:Ml,sub:Ml},scales:[El,pl[1]?pl[1].scale:null],match:[Sl,Sl,Hi],values:[null,null]},oe.sync);2==Fi.match.length&&Fi.match.push(Hi),oe.sync=Fi;const Ri=Fi.key,Gi=dt(Ri);function Ii(l,e,t,n,i,o,s){Fi.filters.pub(l,e,t,n,i,o,s)&&Gi.pub(l,e,t,n,i,o,s)}function Li(){Ci("init",u,g),gn(g||u.data,!1),Kl[El]?Kn(El,Kl[El]):xn(),wt=ei.show&&(ei.width>0||ei.height>0),xt=_t=!0,vt(u.width,u.height)}return Gi.sub(k),k.pub=function(l,e,t,n,i,o,s){Fi.filters.sub(l,e,t,n,i,o,s)&&Wi[l](null,e,t,n,i,o,s)},k.destroy=function(){Gi.unsub(k),It.delete(k),Pe.clear(),I(x,v,Ai),R.remove(),ce?.remove(),Ci("destroy")},pl.forEach(Yt),xl.forEach((function(l,e){if(l._show=l.show,l.show){let t=vl[l.scale];null==t&&(l.scale=l.side%2?pl[1].scale:El,t=vl[l.scale]);let n=t.time;l.size=bl(l.size),l.space=bl(l.space),l.rotate=bl(l.rotate),Gl(l.incrs)&&l.incrs.forEach((l=>{!Wl.has(l)&&Wl.set(l,Yl(l))})),l.incrs=bl(l.incrs||(2==t.distr?ae:n?1==hl?ke:Se:fe)),l.splits=bl(l.splits||(n&&1==t.distr?Ql:3==t.distr?Je:4==t.distr?qe:$e)),l.stroke=bl(l.stroke),l.grid.stroke=bl(l.grid.stroke),l.ticks.stroke=bl(l.ticks.stroke),l.border.stroke=bl(l.border.stroke);let i=l.values;l.values=Gl(i)&&!Gl(i[0])?bl(i):n?Gl(i)?De(Xl,ze(i,Zl)):Ll(i)?function(l,e){let t=ne(e);return(e,n)=>n.map((e=>t(l(e))))}(Xl,i):i||le:i||Be,l.filter=bl(l.filter||(3>t.distr||10!=t.log?3==t.distr&&2==t.log?tt:kl:et)),l.font=Qt(l.font),l.labelFont=Qt(l.labelFont),l._size=l.size(k,null,e,0),l._space=l._rotate=l._incrs=l._found=l._splits=l._values=null,l._size>0&&(Ct[e]=!0,l._el=D("u-axis",X))}})),_?_ instanceof HTMLElement?(_.appendChild(R),Li()):_(k,Li):Li(),k}en.assign=Bl,en.fmtNum=tl,en.rangeNum=Z,en.rangeLog=B,en.rangeAsinh=$,en.orient=pt,en.pxRatio=y,en.join=function(l,e){if(function(l){let e=l[0][0],t=e.length;for(let n=1;l.length>n;n++){let i=l[n][0];if(i.length!=t)return!1;if(i!=e)for(let l=0;t>l;l++)if(i[l]!=e[l])return!1}return!0}(l)){let e=l[0].slice();for(let t=1;l.length>t;t++)e.push(...l[t].slice(1));return function(l,e=100){const t=l.length;if(1>=t)return!0;let n=0,i=t-1;for(;i>=n&&null==l[n];)n++;for(;i>=n&&null==l[i];)i--;if(n>=i)return!0;const o=fl(1,sl((i-n+1)/e));for(let e=l[n],t=n+o;i>=t;t+=o){const n=l[t];if(null!=n){if(e>=n)return!1;e=n}}return!0}(e[0])||(e=function(l){let e=l[0],t=e.length,n=Array(t);for(let l=0;n.length>l;l++)n[l]=l;n.sort(((l,t)=>e[l]-e[t]));let i=[];for(let e=0;l.length>e;e++){let o=l[e],s=Array(t);for(let l=0;t>l;l++)s[l]=o[n[l]];i.push(s)}return i}(e)),e}let t=new Set;for(let e=0;l.length>e;e++){let n=l[e][0],i=n.length;for(let l=0;i>l;l++)t.add(n[l])}let n=[Array.from(t).sort(((l,e)=>l-e))],i=n[0].length,o=new Map;for(let l=0;i>l;l++)o.set(n[0][l],l);for(let t=0;l.length>t;t++){let s=l[t],r=s[0];for(let l=1;s.length>l;l++){let u=s[l],a=Array(i).fill(void 0),f=e?e[t][l]:1,c=[];for(let l=0;u.length>l;l++){let e=u[l],t=o.get(r[l]);null===e?0!=f&&(a[t]=e,2==f&&c.push(t)):a[t]=e}$l(a,c,i),n.push(a)}}return n},en.fmtDate=ne,en.tzDate=function(l,e){let t;return"UTC"==e||"Etc/UTC"==e?t=new Date(+l+6e4*l.getTimezoneOffset()):e==ie?t=l:(t=new Date(l.toLocaleString("en-US",{timeZone:e})),t.setMilliseconds(l.getMilliseconds())),t},en.sync=dt;{en.addGap=function(l,e,t){let n=l[l.length-1];n&&n[0]==e?n[1]=t:l.push([e,t])},en.clipGaps=wt;let l=en.paths={points:Wt};l.linear=Ft,l.stepped=function(l){const e=Q(l.align,1),t=Q(l.ascDesc,!1),n=Q(l.alignGaps,0),i=Q(l.extend,!1);return(l,o,s,r)=>pt(l,o,((u,a,f,c,h,d,p,m,g,x,w)=>{[s,r]=U(f,s,r);let _=u.pxRound,{left:b,width:v}=l.bbox,k=l=>_(d(l,c,x,m)),M=l=>_(p(l,h,w,g)),S=0==c.ori?Mt:St;const T={stroke:new Path2D,fill:null,clip:null,band:null,gaps:null,flags:1},E=T.stroke,z=c.dir*(0==c.ori?1:-1);let D=M(f[1==z?s:r]),P=k(a[1==z?s:r]),A=P,W=P;i&&-1==e&&(W=b,S(E,W,D)),S(E,P,D);for(let l=1==z?s:r;l>=s&&r>=l;l+=z){let t=f[l];if(null==t)continue;let n=k(a[l]),i=M(t);1==e?S(E,n,D):S(E,A,i),S(E,n,i),D=i,A=n}let Y=A;i&&1==e&&(Y=b+v,S(E,Y,D));let[C,H]=mt(l,o);if(null!=u.fill||0!=C){let e=T.fill=new Path2D(E),t=M(u.fillTo(l,o,u.min,u.max,C));S(e,Y,t),S(e,W,t)}if(!u.spanGaps){let i=[];i.push(..._t(a,f,s,r,z,k,n));let h=u.width*y/2,d=t||1==e?h:-h,p=t||-1==e?-h:h;i.forEach((l=>{l[0]+=d,l[1]+=p})),T.gaps=i=u.gaps(l,o,s,r,i),T.clip=wt(i,c.ori,m,g,x,w)}return 0!=H&&(T.band=2==H?[xt(l,o,s,r,E,-1),xt(l,o,s,r,E,1)]:xt(l,o,s,r,E,H)),T}))},l.bars=function(l){const e=Q((l=l||Hl).size,[.6,gl,1]),t=l.align||0,n=l.gap||0;let i=l.radius;i=null==i?[0,0]:"number"==typeof i?[i,0]:i;const o=bl(i),s=1-e[0],r=Q(e[1],gl),u=Q(e[2],1),a=Q(l.disp,Hl),f=Q(l.each,(()=>{})),{fill:c,stroke:h}=a;return(l,e,i,d)=>pt(l,e,((p,m,g,x,w,_,b,v,k,M,S)=>{let T,E,z=p.pxRound,D=t,P=n*y,A=r*y,W=u*y;0==x.ori?[T,E]=o(l,e):[E,T]=o(l,e);const Y=x.dir*(0==x.ori?1:-1);let C,H,F,R=0==x.ori?Tt:Et,G=0==x.ori?f:(l,e,t,n,i,o,s)=>{f(l,e,t,i,n,s,o)},I=Q(l.bands,Fl).find((l=>l.series[0]==e)),L=p.fillTo(l,e,p.min,p.max,null!=I?I.dir:0),O=z(b(L,w,S,k)),N=M,j=z(p.width*y),U=!1,V=null,B=null,$=null,J=null;null==c||0!=j&&null==h||(U=!0,V=c.values(l,e,i,d),B=new Map,new Set(V).forEach((l=>{null!=l&&B.set(l,new Path2D)})),j>0&&($=h.values(l,e,i,d),J=new Map,new Set($).forEach((l=>{null!=l&&J.set(l,new Path2D)}))));let{x0:q,size:K}=a;if(null!=q&&null!=K){D=1,m=q.values(l,e,i,d),2==q.unit&&(m=m.map((e=>l.posToVal(v+e*M,x.key,!0))));let t=K.values(l,e,i,d);H=2==K.unit?t[0]*M:_(t[0],x,M,v)-_(0,x,M,v),N=Rt(m,g,_,x,M,v,N),F=N-H+P}else N=Rt(m,g,_,x,M,v,N),F=N*s+P,H=N-F;1>F&&(F=0),H/2>j||(j=0),5>F&&(z=vl);let X=F>0;H=z(wl(N-F-(X?j:0),W,A)),C=(0==D?H/2:D==Y?0:H)-D*Y*((0==D?P/2:0)+(X?j/2:0));const Z={stroke:null,fill:null,clip:null,band:null,gaps:null,flags:0},ll=U?null:new Path2D;let el=null;if(null!=I)el=l.data[I.series[1]];else{let{y0:t,y1:n}=a;null!=t&&null!=n&&(g=n.values(l,e,i,d),el=t.values(l,e,i,d))}let tl=T*H,nl=E*H;for(let t=1==Y?i:d;t>=i&&d>=t;t+=Y){let n=g[t];if(null==n)continue;if(null!=el){let l=el[t]??0;if(n-l==0)continue;O=b(l,w,S,k)}let i=_(2!=x.distr||null!=a?m[t]:t,x,M,v),o=b(Q(n,L),w,S,k),s=z(i-C),r=z(fl(o,O)),u=z(al(o,O)),f=r-u;if(null!=n){let i=0>n?nl:tl,o=0>n?tl:nl;U?(j>0&&null!=$[t]&&R(J.get($[t]),s,u+sl(j/2),H,fl(0,f-j),i,o),null!=V[t]&&R(B.get(V[t]),s,u+sl(j/2),H,fl(0,f-j),i,o)):R(ll,s,u+sl(j/2),H,fl(0,f-j),i,o),G(l,e,t,s-j/2,u,H+j,f)}}return j>0?Z.stroke=U?J:ll:U||(Z._fill=0==p.width?p._fill:p._stroke??p._fill,Z.width=0),Z.fill=U?B:ll,Z}))},l.spline=function(l){return function(l,e){const t=Q(e?.alignGaps,0);return(e,n,i,o)=>pt(e,n,((s,r,u,a,f,c,h,d,p,m,g)=>{[i,o]=U(u,i,o);let x,w,_,b=s.pxRound,v=l=>b(c(l,a,m,d)),k=l=>b(h(l,f,g,p));0==a.ori?(x=kt,_=Mt,w=Pt):(x=yt,_=St,w=At);const y=a.dir*(0==a.ori?1:-1);let M=v(r[1==y?i:o]),S=M,T=[],E=[];for(let l=1==y?i:o;l>=i&&o>=l;l+=y)if(null!=u[l]){let e=v(r[l]);T.push(S=e),E.push(k(u[l]))}const z={stroke:l(T,E,x,_,w,b),fill:null,clip:null,band:null,gaps:null,flags:1},D=z.stroke;let[P,A]=mt(e,n);if(null!=s.fill||0!=P){let l=z.fill=new Path2D(D),t=k(s.fillTo(e,n,s.min,s.max,P));_(l,S,t),_(l,M,t)}if(!s.spanGaps){let l=[];l.push(..._t(r,u,i,o,y,v,t)),z.gaps=l=s.gaps(e,n,i,o,l),z.clip=wt(l,a.ori,d,p,m,g)}return 0!=A&&(z.band=2==A?[xt(e,n,i,o,D,-1),xt(e,n,i,o,D,1)]:xt(e,n,i,o,D,A)),z}))}(Gt,l)}}return en}(); diff --git a/faigate/wizard.py b/faigate/wizard.py index 6d0f44f..2c61567 100644 --- a/faigate/wizard.py +++ b/faigate/wizard.py @@ -585,6 +585,58 @@ def _extract_env_reference(value: str) -> str: return "" +def _describe_quota_domain( + *, + backend: str, + route_type: str, + compatibility: str, + billing_mode: str, + quota_group: str, + quota_isolated: bool, + api_key_env: str, +) -> tuple[str, str]: + summary_bits: list[str] = [] + note = "" + + if billing_mode: + summary_bits.append(f"billing={billing_mode}") + elif backend == "anthropic-compat": + summary_bits.append("billing=direct-api") + elif route_type == "aggregator" or compatibility == "aggregator": + summary_bits.append("billing=aggregator-unspecified") + + if quota_group: + summary_bits.append(f"group={quota_group}") + if quota_isolated: + summary_bits.append("isolated=yes") + elif quota_group: + summary_bits.append("isolated=no") + + if backend == "anthropic-compat" and api_key_env == "ANTHROPIC_API_KEY": + note = ( + "uses ANTHROPIC_API_KEY through the Anthropic API; this path is separate from " + "Claude app subscription meters." + ) + elif quota_group and not quota_isolated: + note = ( + f"shares quota domain '{quota_group}' with sibling routes; a 429 here can also " + "hold other routes in the same group." + ) + elif route_type == "aggregator" or compatibility == "aggregator": + if quota_isolated: + note = ( + "marked as quota-isolated; this route can stay in rotation when a separate " + "Anthropic API path is rate-limited." + ) + else: + note = ( + "aggregator route is not marked quota-isolated; do not assume it escapes the " + "same Anthropic account limits unless its billing path is independent." + ) + + return " | ".join(summary_bits), note + + _ENV_REF_RE = re.compile(r"\$\{([^}]+)}") @@ -1232,6 +1284,7 @@ def build_provider_probe_report( backend=str(provider.get("backend", "openai-compat") or "openai-compat"), contract=str(provider.get("contract", "generic") or "generic"), ) + backend = str(provider.get("backend", "openai-compat") or "openai-compat") lane_binding = get_provider_lane_binding(name) api_key = str(provider.get("api_key", "") or "").strip() env_name = _extract_env_reference(api_key) @@ -1351,6 +1404,24 @@ def build_provider_probe_report( or transport_defaults.get("probe_strategy") or "" ), + "billing_mode": str( + request_readiness.get("billing_mode") + or (provider.get("transport") or {}).get("billing_mode") + or "" + ), + "quota_group": str( + request_readiness.get("quota_group") + or (provider.get("transport") or {}).get("quota_group") + or "" + ), + "quota_isolated": bool( + request_readiness.get("quota_isolated") + or (provider.get("transport") or {}).get("quota_isolated") + or False + ), + "route_type": str(lane.get("route_type") or ""), + "backend": backend, + "api_key_env": env_name, "probe_payload": str(request_readiness.get("probe_payload") or ""), "verified_via": str(request_readiness.get("verified_via") or ""), "operator_hint": operator_hint, @@ -1613,6 +1684,19 @@ def render_provider_probe_text(report: dict[str, Any]) -> str: ) + (f" | strategy: {row.get('probe_strategy')}" if row.get("probe_strategy") else "") ) + quota_summary, quota_note = _describe_quota_domain( + backend=str(row.get("backend") or ""), + route_type=str(row.get("route_type") or ""), + compatibility=str(row.get("transport_compatibility") or ""), + billing_mode=str(row.get("billing_mode") or ""), + quota_group=str(row.get("quota_group") or ""), + quota_isolated=bool(row.get("quota_isolated")), + api_key_env=str(row.get("api_key_env") or ""), + ) + if quota_summary: + lines.append(" " + f"quota domain: {quota_summary}") + if quota_note: + lines.append(" " + f"quota note: {quota_note}") if row.get("verified_via"): lines.append(" " + f"verified via: {row['verified_via']}") if row.get("probe_payload"): diff --git a/pyproject.toml b/pyproject.toml index a728297..894ed38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,15 @@ Documentation = "https://github.com/fusionAIze/faigate#readme" [tool.setuptools.packages.find] include = ["faigate*"] +[tool.setuptools.package-data] +faigate = [ + "vendor/*.js", + "vendor/*.css", + "assets/brand/*.svg", + "assets/fonts/Montserrat/*.ttf", + "assets/fonts/Open-Sans/*.ttf", +] + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/scripts/faigate-doctor b/scripts/faigate-doctor index da6b4c1..156b264 100755 --- a/scripts/faigate-doctor +++ b/scripts/faigate-doctor @@ -227,6 +227,55 @@ if store is not None and health_raw: return "route" return "inspect" + def describe_quota_domain( + *, + backend: str, + route_type: str, + compatibility: str, + billing_mode: str, + quota_group: str, + quota_isolated: bool, + api_key_env: str, + ) -> tuple[str, str]: + summary_bits = [] + note = "" + if billing_mode: + summary_bits.append(f"billing={billing_mode}") + elif backend == "anthropic-compat": + summary_bits.append("billing=direct-api") + elif route_type == "aggregator" or compatibility == "aggregator": + summary_bits.append("billing=aggregator-unspecified") + if quota_group: + summary_bits.append(f"group={quota_group}") + if quota_isolated: + summary_bits.append("isolated=yes") + elif quota_group: + summary_bits.append("isolated=no") + + if backend == "anthropic-compat" and api_key_env == "ANTHROPIC_API_KEY": + note = ( + "uses ANTHROPIC_API_KEY through the Anthropic API; this path is separate from " + "Claude app subscription meters." + ) + elif quota_group and not quota_isolated: + note = ( + f"shares quota domain '{quota_group}' with sibling routes; a 429 here can also " + "hold other routes in the same group." + ) + elif route_type == "aggregator" or compatibility == "aggregator": + if quota_isolated: + note = ( + "marked as quota-isolated; this route can stay in rotation when a separate " + "Anthropic API path is rate-limited." + ) + else: + note = ( + "aggregator route is not marked quota-isolated; do not assume it escapes " + "the same Anthropic account limits unless its billing path is independent." + ) + + return " | ".join(summary_bits), note + for provider_name, payload in sorted(providers.items()): total += 1 request_readiness = payload.get("request_readiness") or {} @@ -237,6 +286,9 @@ if store is not None and health_raw: profile = str(request_readiness.get("profile") or "") compatibility = str(request_readiness.get("compatibility") or "") confidence = str(request_readiness.get("probe_confidence") or "") + billing_mode = str(request_readiness.get("billing_mode") or "") + quota_group = str(request_readiness.get("quota_group") or "") + quota_isolated = bool(request_readiness.get("quota_isolated")) verified_via = str(request_readiness.get("verified_via") or "") probe_payload = str(request_readiness.get("probe_payload") or "") operator_hint = str(request_readiness.get("operator_hint") or "") @@ -275,6 +327,12 @@ if store is not None and health_raw: mirror_gap_routes += 1 lane_cluster = str(lane.get("cluster") or "") degrade_to = [str(item) for item in (lane.get("degrade_to") or []) if str(item)] + route_type = str(lane.get("route_type") or "") + backend = str(payload.get("backend") or "") + api_key_env = "" + api_key = str(payload.get("api_key") or "").strip() + if api_key.startswith("${") and api_key.endswith("}"): + api_key_env = api_key[2:-1].split(":-", 1)[0].split(":", 1)[0] add_recommendations = get_route_add_recommendations( configured_provider_names=configured_provider_names, canonical_model=canonical_model, @@ -300,6 +358,12 @@ if store is not None and health_raw: "profile": profile, "compatibility": compatibility, "confidence": confidence, + "billing_mode": billing_mode, + "quota_group": quota_group, + "quota_isolated": quota_isolated, + "route_type": route_type, + "backend": backend, + "api_key_env": api_key_env, "verified_via": verified_via, "probe_payload": probe_payload, "operator_hint": operator_hint, @@ -381,6 +445,19 @@ if store is not None and health_raw: ) if row["probe_payload"]: print(f"[ok] request-ready payload: {row['provider']} -> {row['probe_payload']}") + quota_summary, quota_note = describe_quota_domain( + backend=row["backend"], + route_type=row["route_type"], + compatibility=row["compatibility"], + billing_mode=row["billing_mode"], + quota_group=row["quota_group"], + quota_isolated=row["quota_isolated"], + api_key_env=row["api_key_env"], + ) + if quota_summary: + print(f"[ok] request-ready quota: {row['provider']} -> {quota_summary}") + if quota_note: + print(f"[ok] request-ready quota note: {row['provider']} -> {quota_note}") if row["operator_hint"]: print(f"[ok] request-ready next step: {row['provider']} -> {row['operator_hint']}") if row["runtime_penalty"] or row["runtime_issue_type"]: diff --git a/tests/test_anthropic_api.py b/tests/test_anthropic_api.py index d5f94a5..0b74857 100644 --- a/tests/test_anthropic_api.py +++ b/tests/test_anthropic_api.py @@ -5,6 +5,7 @@ import importlib import sys import types +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path @@ -21,6 +22,7 @@ sys.modules.pop("faigate.main", None) import faigate.main as main_module # noqa: E402 +from faigate.bridges.anthropic import openai_sse_to_anthropic # noqa: E402 from faigate.config import load_config # noqa: E402 from faigate.providers import ProviderError # noqa: E402 from faigate.router import Router # noqa: E402 @@ -35,13 +37,23 @@ def _write_config(tmp_path: Path, body: str) -> Path: class _CapturingProviderStub: - def __init__(self, name: str = "cloud-default", *, transport: dict[str, object] | None = None): + def __init__( + self, + name: str = "cloud-default", + *, + transport: dict[str, object] | None = None, + ): self.name = name self.model = "chat-model" self.backend_type = "openai-compat" self.contract = "generic" self.tier = "default" - self.capabilities = {"chat": True, "local": False, "cloud": True, "network_zone": "public"} + self.capabilities = { + "chat": True, + "local": False, + "cloud": True, + "network_zone": "public", + } self.context_window = 128000 self.limits = {"max_input_tokens": 128000, "max_output_tokens": 4096} self.cache = {"mode": "none", "read_discount": False} @@ -116,6 +128,30 @@ async def complete(self, messages, **kwargs): raise ProviderError(self.name, self.status, self.detail) +class _StreamingProviderStub(_CapturingProviderStub): + async def complete(self, messages, **kwargs): + self.calls.append({"messages": messages, **kwargs}) + + async def _iter() -> AsyncIterator[bytes]: + yield ( + b'data: {"id":"chatcmpl-stream","object":"chat.completion.chunk",' + b'"model":"chat-model","choices":[{"index":0,"delta":{"role":"assistant",' + b'"content":"Hello"},"finish_reason":null}]}\n' + ) + yield b"\n" + yield ( + b'data: {"id":"chatcmpl-stream","object":"chat.completion.chunk",' + b'"model":"chat-model","choices":[{"index":0,"delta":{"content":" world"},' + b'"finish_reason":"stop"}],"usage":{"prompt_tokens":11,' + b'"completion_tokens":2,"total_tokens":13}}\n' + ) + yield b"\n" + yield b"data: [DONE]\n" + yield b"\n" + + return _iter() + + @pytest.fixture def anthropic_api_client(tmp_path, monkeypatch): cfg = load_config( @@ -155,9 +191,19 @@ async def _noop_lifespan(_app): monkeypatch.setattr(main_module, "_config", cfg, raising=False) monkeypatch.setattr(main_module, "_router", Router(cfg), raising=False) - monkeypatch.setattr(main_module, "_providers", {"cloud-default": provider}, raising=False) + monkeypatch.setattr( + main_module, + "_providers", + {"cloud-default": provider}, + raising=False, + ) monkeypatch.setattr(main_module, "_metrics", _MetricsStub(), raising=False) - monkeypatch.setattr(main_module.app.router, "lifespan_context", _noop_lifespan, raising=False) + monkeypatch.setattr( + main_module.app.router, + "lifespan_context", + _noop_lifespan, + raising=False, + ) with TestClient(main_module.app) as client: yield client, provider @@ -181,12 +227,41 @@ def test_anthropic_messages_returns_bridge_response(anthropic_api_client): assert body["content"][0]["type"] == "text" assert body["content"][0]["text"] == "anthropic ok" assert provider.calls[0]["extra_body"]["metadata"]["source"] == "claude-code" - assert provider.calls[0]["messages"][0] == {"role": "system", "content": "Use markdown"} + assert provider.calls[0]["messages"][0] == { + "role": "system", + "content": "Use markdown", + } assert response.headers["x-faigate-bridge-surface"] == "anthropic-messages" assert response.headers["x-faigate-bridge-source"] == "claude-code" assert response.headers["x-faigate-bridge-model-requested"] == "claude-sonnet" +def test_anthropic_messages_accept_system_text_blocks(anthropic_api_client): + client, provider = anthropic_api_client + + response = client.post( + "/v1/messages", + json={ + "model": "claude-sonnet", + "system": [ + {"type": "text", "text": "Use markdown"}, + {"type": "text", "text": "Prefer concise patches"}, + ], + "messages": [{"role": "user", "content": "Summarize this"}], + }, + ) + + assert response.status_code == 200 + assert provider.calls[0]["messages"][0] == { + "role": "system", + "content": "Use markdown", + } + assert provider.calls[0]["messages"][1] == { + "role": "system", + "content": "Prefer concise patches", + } + + def test_anthropic_messages_applies_model_aliases(anthropic_api_client): client, provider = anthropic_api_client @@ -211,6 +286,58 @@ def test_anthropic_messages_applies_model_aliases(anthropic_api_client): assert response.headers["x-faigate-bridge-model-resolved"] == "premium" +def test_anthropic_messages_applies_builtin_claude_code_model_aliases( + anthropic_api_client, +): + client, provider = anthropic_api_client + + response = client.post( + "/v1/messages", + json={ + "model": "claude-sonnet-4-6[1m]", + "messages": [ + { + "role": "user", + "content": "Use the Claude Code model name directly", + } + ], + }, + ) + + assert response.status_code == 200 + metadata = provider.calls[0]["extra_body"]["metadata"] + assert metadata["requested_model_original"] == "claude-sonnet-4-6[1m]" + assert metadata["requested_model_resolved"] == "auto" + assert response.headers["x-faigate-bridge-model-requested"] == "claude-sonnet-4-6-1m" + assert response.headers["x-faigate-bridge-model-resolved"] == "auto" + + +def test_anthropic_messages_can_redirect_claude_code_model_ids_to_gateway_routes( + anthropic_api_client, +): + client, provider = anthropic_api_client + main_module._config.anthropic_bridge["model_aliases"]["claude-sonnet-4-6[1m]"] = "kilo-sonnet" + + response = client.post( + "/v1/messages", + json={ + "model": "claude-sonnet-4-6[1m]", + "messages": [ + { + "role": "user", + "content": "Prefer the gateway-managed sonnet lane", + } + ], + }, + ) + + assert response.status_code == 200 + metadata = provider.calls[0]["extra_body"]["metadata"] + assert metadata["requested_model_original"] == "claude-sonnet-4-6[1m]" + assert metadata["requested_model_resolved"] == "kilo-sonnet" + assert response.headers["x-faigate-bridge-model-resolved"] == "kilo-sonnet" + + def test_anthropic_messages_preserve_version_headers(anthropic_api_client): client, provider = anthropic_api_client @@ -238,7 +365,9 @@ def test_anthropic_messages_preserve_version_headers(anthropic_api_client): assert response.headers["x-faigate-bridge-anthropic-beta"] == "tools-2024-04-04" -def test_anthropic_messages_forward_tool_use_and_tool_result_blocks(anthropic_api_client): +def test_anthropic_messages_forward_tool_use_and_tool_result_blocks( + anthropic_api_client, +): client, provider = anthropic_api_client response = client.post( @@ -283,6 +412,100 @@ def test_anthropic_messages_forward_tool_use_and_tool_result_blocks(anthropic_ap } +def test_anthropic_messages_tolerate_tool_result_without_id(anthropic_api_client): + client, provider = anthropic_api_client + + response = client.post( + "/v1/messages", + json={ + "model": "claude-sonnet", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "content": "Detached tool result text", + } + ], + } + ], + }, + ) + + assert response.status_code == 200 + forwarded_messages = provider.calls[0]["messages"] + assert forwarded_messages == [ + { + "role": "user", + "content": "Detached tool result text", + } + ] + + +def test_anthropic_messages_keep_tool_result_adjacent_before_user_text(anthropic_api_client): + client, provider = anthropic_api_client + + response = client.post( + "/v1/messages", + json={ + "model": "claude-sonnet", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_lookup", + "name": "lookup_doc", + "input": {"id": "design-note"}, + } + ], + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "Use the most relevant snippet"}, + { + "type": "tool_result", + "tool_use_id": "toolu_lookup", + "content": "Design note loaded", + }, + ], + }, + ], + }, + ) + + assert response.status_code == 200 + forwarded_messages = provider.calls[0]["messages"] + assert forwarded_messages == [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "toolu_lookup", + "type": "function", + "function": { + "name": "lookup_doc", + "arguments": '{"id":"design-note"}', + }, + } + ], + }, + { + "role": "tool", + "content": "Design note loaded", + "tool_call_id": "toolu_lookup", + }, + { + "role": "user", + "content": "Use the most relevant snippet", + }, + ] + + def test_anthropic_messages_rejects_non_text_blocks(anthropic_api_client): client, _provider = anthropic_api_client @@ -306,6 +529,75 @@ def test_anthropic_messages_rejects_non_text_blocks(anthropic_api_client): assert "text and tool_result blocks" in body["error"]["message"] +def test_anthropic_messages_support_streaming(tmp_path, monkeypatch): + cfg = load_config( + _write_config( + tmp_path, + """ +server: + host: "127.0.0.1" + port: 8090 +providers: + cloud-default: + backend: openai-compat + base_url: "https://api.example.com/v1" + api_key: "secret" + model: "chat-model" +anthropic_bridge: + enabled: true +fallback_chain: + - cloud-default +metrics: + enabled: false +""", + ) + ) + + @asynccontextmanager + async def _noop_lifespan(_app): + yield + + provider = _StreamingProviderStub() + monkeypatch.setattr(main_module, "_config", cfg, raising=False) + monkeypatch.setattr(main_module, "_router", Router(cfg), raising=False) + monkeypatch.setattr( + main_module, + "_providers", + {"cloud-default": provider}, + raising=False, + ) + monkeypatch.setattr(main_module, "_metrics", _MetricsStub(), raising=False) + monkeypatch.setattr( + main_module.app.router, + "lifespan_context", + _noop_lifespan, + raising=False, + ) + + with TestClient(main_module.app) as client: + with client.stream( + "POST", + "/v1/messages", + json={ + "model": "claude-sonnet", + "stream": True, + "messages": [{"role": "user", "content": "hello"}], + }, + ) as response: + body = b"".join(response.iter_bytes()).decode("utf-8") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + assert response.headers["x-faigate-bridge-surface"] == "anthropic-messages" + assert "event: message_start" in body + assert "event: content_block_start" in body + assert '"type":"text_delta","text":"Hello"' in body + assert '"type":"text_delta","text":" world"' in body + assert '"stop_reason":"end_turn"' in body + assert "event: message_stop" in body + assert provider.calls[0]["stream"] is True + + def test_anthropic_count_tokens_returns_estimate_with_headers(anthropic_api_client): client, _provider = anthropic_api_client @@ -319,7 +611,10 @@ def test_anthropic_count_tokens_returns_estimate_with_headers(anthropic_api_clie { "name": "lookup_doc", "description": "Load one doc", - "input_schema": {"type": "object", "properties": {"id": {"type": "string"}}}, + "input_schema": { + "type": "object", + "properties": {"id": {"type": "string"}}, + }, } ], }, @@ -412,7 +707,12 @@ async def _noop_lifespan(_app): raising=False, ) monkeypatch.setattr(main_module, "_metrics", _MetricsStub(), raising=False) - monkeypatch.setattr(main_module.app.router, "lifespan_context", _noop_lifespan, raising=False) + monkeypatch.setattr( + main_module.app.router, + "lifespan_context", + _noop_lifespan, + raising=False, + ) with TestClient(main_module.app) as client: response = client.post( @@ -483,7 +783,10 @@ async def _noop_lifespan(_app): assert body["error"]["type"] == "rate_limit_error" -def test_anthropic_messages_skip_shared_quota_group_after_quota_failure(tmp_path, monkeypatch): +def test_anthropic_messages_skip_shared_quota_group_after_quota_failure( + tmp_path, + monkeypatch, +): cfg = load_config( _write_config( tmp_path, @@ -533,7 +836,10 @@ async def _noop_lifespan(_app): detail="insufficient_quota on upstream account", transport={"quota_group": "anthropic-main"}, ) - mirror = _CapturingProviderStub("kilo-mirror", transport={"quota_group": "anthropic-main"}) + mirror = _CapturingProviderStub( + "kilo-mirror", + transport={"quota_group": "anthropic-main"}, + ) local = _CapturingProviderStub("local-worker") monkeypatch.setattr(main_module, "_config", cfg, raising=False) @@ -549,7 +855,12 @@ async def _noop_lifespan(_app): raising=False, ) monkeypatch.setattr(main_module, "_metrics", _MetricsStub(), raising=False) - monkeypatch.setattr(main_module.app.router, "lifespan_context", _noop_lifespan, raising=False) + monkeypatch.setattr( + main_module.app.router, + "lifespan_context", + _noop_lifespan, + raising=False, + ) with TestClient(main_module.app) as client: response = client.post( @@ -565,3 +876,91 @@ async def _noop_lifespan(_app): assert mirror.calls == [] assert len(local.calls) == 1 assert response.headers["x-faigate-provider"] == "local-worker" + + +@pytest.mark.asyncio +async def test_openai_sse_to_anthropic_maps_tool_call_deltas(): + async def _iter() -> AsyncIterator[bytes]: + yield ( + b'data: {"id":"chatcmpl-stream","object":"chat.completion.chunk",' + b'"model":"chat-model","choices":[{"index":0,"delta":{"tool_calls":[{' + b'"index":0,"id":"call_1","type":"function","function":{"name":"lookup_doc",' + b'"arguments":"{\\"id\\":"}}]},"finish_reason":null}]}\n' + ) + yield b"\n" + yield ( + b'data: {"id":"chatcmpl-stream","object":"chat.completion.chunk",' + b'"model":"chat-model","choices":[{"index":0,"delta":{"tool_calls":[{' + b'"index":0,"function":{"arguments":"\\"design-note\\"}"}}]},' + b'"finish_reason":"tool_calls"}]}\n' + ) + yield b"\n" + yield b"data: [DONE]\n" + yield b"\n" + + chunks: list[str] = [] + async for chunk in openai_sse_to_anthropic( + _iter(), + requested_model="claude-code", + resolved_model="premium", + ): + chunks.append(chunk.decode("utf-8")) + + body = "".join(chunks) + assert "event: content_block_start" in body + assert '"type":"tool_use","id":"call_1","name":"lookup_doc","input":{}' in body + assert '"type":"input_json_delta","partial_json":"{\\"id\\":' in body + assert '"type":"input_json_delta","partial_json":"\\"design-note\\"}"' in body + assert '"stop_reason":"tool_use"' in body + + +@pytest.mark.asyncio +async def test_openai_sse_to_anthropic_closes_open_text_block_before_error(): + async def _iter() -> AsyncIterator[bytes]: + yield ( + b'data: {"id":"chatcmpl-stream","object":"chat.completion.chunk",' + b'"model":"chat-model","choices":[{"index":0,"delta":{"role":"assistant",' + b'"content":"Hello"},"finish_reason":null}]}\n' + ) + yield b"\n" + yield (b'data: {"error":{"type":"api_error","message":"upstream broke"}}\n') + yield b"\n" + + chunks: list[str] = [] + async for chunk in openai_sse_to_anthropic( + _iter(), + requested_model="claude-code", + resolved_model="premium", + ): + chunks.append(chunk.decode("utf-8")) + + body = "".join(chunks) + assert "event: content_block_start" in body + assert '"type":"text_delta","text":"Hello"' in body + assert "event: content_block_stop" in body + assert "event: error" in body + assert body.index("event: content_block_stop") < body.index("event: error") + assert '"message":"upstream broke"' in body + + +@pytest.mark.asyncio +async def test_safe_openai_sse_stream_emits_error_frame_and_done(): + async def _iter() -> AsyncIterator[bytes]: + yield b'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n' + raise ProviderError("kilo-sonnet", 429, "rate limited mid-stream") + + chunks: list[bytes] = [] + async for chunk in main_module._safe_openai_sse_stream( + _iter(), + provider_name="kilo-sonnet", + trace_id="trace-stream-1", + ): + chunks.append(chunk) + + body = b"".join(chunks).decode("utf-8") + assert 'data: {"choices":[{"delta":{"content":"Hello"}}]}' in body + assert '"message":"rate limited mid-stream"' in body + assert '"type":"rate-limited"' in body + assert '"provider":"kilo-sonnet"' in body + assert '"trace_id":"trace-stream-1"' in body + assert body.rstrip().endswith("data: [DONE]") diff --git a/tests/test_anthropic_bridge.py b/tests/test_anthropic_bridge.py index 853b0aa..242bb2e 100644 --- a/tests/test_anthropic_bridge.py +++ b/tests/test_anthropic_bridge.py @@ -48,6 +48,24 @@ def test_parse_anthropic_messages_request_accepts_string_content(): assert request.messages[0].content[0].text == "hello" +def test_parse_anthropic_messages_request_accepts_text_block_system_prompt(): + request = parse_anthropic_messages_request( + { + "model": "claude-sonnet", + "system": [ + {"type": "text", "text": "You are a coding assistant."}, + {"type": "text", "text": "Prefer concise diffs."}, + ], + "messages": [{"role": "user", "content": "hello"}], + } + ) + + assert request.system == [ + "You are a coding assistant.", + "Prefer concise diffs.", + ] + + def test_anthropic_request_maps_to_canonical_and_openai_body(): wire_request = parse_anthropic_messages_request( { @@ -127,6 +145,102 @@ def test_anthropic_request_maps_tool_use_and_tool_result_blocks(): } +def test_anthropic_request_degrades_tool_result_without_id_to_user_text(): + wire_request = parse_anthropic_messages_request( + { + "model": "claude-sonnet", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "content": "Result text without a stable tool id", + } + ], + } + ], + } + ) + + canonical = anthropic_request_to_canonical( + wire_request, + headers={"x-faigate-client": "claude-code"}, + ) + openai_body = canonical.to_openai_body() + + assert openai_body["messages"] == [ + { + "role": "user", + "content": "Result text without a stable tool id", + } + ] + + +def test_anthropic_request_keeps_tool_results_adjacent_to_tool_calls(): + wire_request = parse_anthropic_messages_request( + { + "model": "claude-sonnet", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_lookup", + "name": "lookup_doc", + "input": {"id": "spec"}, + } + ], + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "Here is the context you asked for"}, + { + "type": "tool_result", + "tool_use_id": "toolu_lookup", + "content": "Spec body", + }, + ], + }, + ], + } + ) + + canonical = anthropic_request_to_canonical( + wire_request, + headers={"x-faigate-client": "claude-code"}, + ) + openai_body = canonical.to_openai_body() + + assert openai_body["messages"] == [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "toolu_lookup", + "type": "function", + "function": { + "name": "lookup_doc", + "arguments": '{"id":"spec"}', + }, + } + ], + }, + { + "role": "tool", + "content": "Spec body", + "tool_call_id": "toolu_lookup", + }, + { + "role": "user", + "content": "Here is the context you asked for", + }, + ] + + def test_detached_router_runs_bridge_dispatch(): executor = _FakeExecutor() response = TestClient(_build_test_app(executor)).post( diff --git a/tests/test_api_hardening.py b/tests/test_api_hardening.py index 71eddde..ff72eac 100644 --- a/tests/test_api_hardening.py +++ b/tests/test_api_hardening.py @@ -267,10 +267,19 @@ def test_dashboard_sets_security_headers(api_client): assert response.headers["referrer-policy"] == "no-referrer" csp = response.headers["content-security-policy"] assert "frame-ancestors 'none'" in csp + assert "font-src 'self'" in csp assert "'unsafe-inline'" not in csp assert "sha256-" in csp +def test_dashboard_serves_packaged_font_asset(api_client): + response = api_client.get("/dashboard/assets/fonts/Montserrat/Montserrat-VariableFont_wght.ttf") + + assert response.status_code == 200 + assert response.headers["content-type"] in {"font/ttf", "application/x-font-ttf"} + assert len(response.content) > 1000 + + def test_stats_includes_client_highlights(api_client): response = api_client.get("/api/stats") diff --git a/tests/test_config.py b/tests/test_config.py index 71a02fb..a294c00 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -411,6 +411,14 @@ def test_anthropic_bridge_defaults_are_exposed(): "claude-code": "auto", "claude-code-fast": "eco", "claude-code-premium": "premium", + "claude-sonnet-4-6": "auto", + "claude-sonnet-4-6-20251001": "auto", + "claude-sonnet-4-6[1m]": "auto", + "claude-opus-4-6": "premium", + "claude-opus-4-6-20251001": "premium", + "claude-opus-4-6[1m]": "premium", + "claude-haiku-4-5": "eco", + "claude-haiku-4-5-20251001": "eco", }, } diff --git a/tests/test_menu_helpers.py b/tests/test_menu_helpers.py index 6e898da..01e3c6b 100644 --- a/tests/test_menu_helpers.py +++ b/tests/test_menu_helpers.py @@ -2431,6 +2431,93 @@ def test_faigate_provider_probe_surfaces_catalog_alert_actions(tmp_path: Path): assert "Action summary: fix-now=1 | review-now=1 | inspect=0" in result.stdout +def test_faigate_provider_probe_surfaces_quota_domain_notes(tmp_path: Path): + config_file = tmp_path / "config.yaml" + env_file = tmp_path / "faigate.env" + config_file.write_text( + """ +server: + host: "127.0.0.1" + port: 8090 +providers: + anthropic-sonnet: + backend: anthropic-compat + api_key: ${ANTHROPIC_API_KEY} + base_url: https://api.anthropic.com/v1 + model: claude-sonnet-4-6 + kilo-sonnet: + backend: openai-compat + api_key: ${KILOCODE_API_KEY} + base_url: https://api.kilo.ai/api/gateway + model: anthropic/claude-sonnet-4.6 +fallback_chain: [] +metrics: + enabled: false + db_path: ":memory:" +""".strip(), + encoding="utf-8", + ) + env_file.write_text( + "ANTHROPIC_API_KEY=test-ant\nKILOCODE_API_KEY=test-kilo\n", + encoding="utf-8", + ) + + fake_bin = _write_fake_curl( + tmp_path, + { + "/health": json.dumps( + { + "providers": { + "anthropic-sonnet": { + "healthy": False, + "request_readiness": { + "ready": False, + "status": "rate-limited", + "reason": "429 rate limited upstream", + "profile": "anthropic-native", + "compatibility": "native", + "probe_confidence": "high", + }, + }, + "kilo-sonnet": { + "healthy": True, + "request_readiness": { + "ready": True, + "status": "ready-compat", + "reason": "route looks request-ready", + "profile": "kilo-openai-compat", + "compatibility": "aggregator", + "probe_confidence": "medium", + }, + }, + } + } + ) + }, + ) + + env = os.environ.copy() + env["PATH"] = f"{fake_bin}:{env['PATH']}" + env["FAIGATE_CONFIG_FILE"] = str(config_file) + env["FAIGATE_ENV_FILE"] = str(env_file) + env["FAIGATE_PYTHON"] = sys.executable + env["PYTHONPATH"] = str(REPO_ROOT) + + result = subprocess.run( + ["bash", "scripts/faigate-provider-probe"], + cwd=REPO_ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + + assert "quota domain: billing=direct-api" in result.stdout + assert "separate from Claude app subscription meters" in result.stdout + assert "quota domain: billing=aggregator-unspecified" in result.stdout + assert "not marked quota-isolated" in result.stdout + + def test_faigate_doctor_prefers_same_lane_route_before_cluster_degrade(tmp_path: Path): config_file = tmp_path / "config.yaml" env_file = tmp_path / "faigate.env" @@ -2533,6 +2620,113 @@ def test_faigate_doctor_prefers_same_lane_route_before_cluster_degrade(tmp_path: assert "request-ready fallback guidance: same-lane=1 | cluster=0 | family=0" in result.stdout +def test_faigate_doctor_surfaces_quota_domain_notes(tmp_path: Path): + config_file = tmp_path / "config.yaml" + env_file = tmp_path / "faigate.env" + config_file.write_text( + """ +server: {} +providers: + anthropic-sonnet: + backend: anthropic-compat + api_key: ${ANTHROPIC_API_KEY} + base_url: https://api.anthropic.com/v1 + model: claude-sonnet-4-6 + kilo-sonnet: + backend: openai-compat + api_key: ${KILOCODE_API_KEY} + base_url: https://api.kilo.ai/api/gateway + model: anthropic/claude-sonnet-4.6 +""".strip(), + encoding="utf-8", + ) + env_file.write_text( + "ANTHROPIC_API_KEY=test-ant\nKILOCODE_API_KEY=test-kilo\n", + encoding="utf-8", + ) + + fake_bin = _write_fake_curl( + tmp_path, + { + "/health": json.dumps( + { + "status": "ok", + "summary": { + "providers_total": 2, + "providers_healthy": 1, + "providers_unhealthy": 1, + }, + "request_readiness": { + "providers_total": 2, + "providers_ready": 1, + "providers_not_ready": 1, + }, + "providers": { + "anthropic-sonnet": { + "backend": "anthropic-compat", + "api_key": "${ANTHROPIC_API_KEY}", + "healthy": False, + "lane": { + "family": "anthropic", + "canonical_model": "anthropic/sonnet-4.6", + "route_type": "direct", + }, + "request_readiness": { + "ready": False, + "status": "rate-limited", + "reason": "429 rate limited upstream", + "profile": "anthropic-native", + "compatibility": "native", + "probe_confidence": "high", + }, + }, + "kilo-sonnet": { + "backend": "openai-compat", + "api_key": "${KILOCODE_API_KEY}", + "healthy": True, + "lane": { + "family": "kilo", + "canonical_model": "anthropic/claude-sonnet-4.6", + "route_type": "aggregator", + }, + "request_readiness": { + "ready": True, + "status": "ready-compat", + "reason": "route looks request-ready", + "profile": "kilo-openai-compat", + "compatibility": "aggregator", + "probe_confidence": "medium", + }, + }, + }, + } + ), + "/v1/models": json.dumps({"data": []}), + }, + ) + + env = os.environ.copy() + env["PATH"] = f"{fake_bin}:{env['PATH']}" + env["FAIGATE_CONFIG_FILE"] = str(config_file) + env["FAIGATE_ENV_FILE"] = str(env_file) + env["FAIGATE_PYTHON"] = sys.executable + env["PYTHONPATH"] = str(REPO_ROOT) + + result = subprocess.run( + ["bash", "scripts/faigate-doctor"], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + check=True, + ) + + assert "request-ready quota: anthropic-sonnet -> billing=direct-api" in result.stdout + assert "separate from Claude app subscription meters" in result.stdout + assert "request-ready quota: kilo-sonnet -> billing=aggregator-unspecified" in result.stdout + assert "not marked quota-isolated" in result.stdout + + def test_faigate_doctor_surfaces_provider_source_priority_actions(tmp_path: Path): config_file = tmp_path / "config.yaml" db_path = tmp_path / "faigate.db"