Skip to content

fix(adapter): pass through hybrid-pricing metadata in /capabilities#326

Open
seanhanca wants to merge 44 commits into
mainfrom
feat/adapter-capabilities-pricing-passthrough
Open

fix(adapter): pass through hybrid-pricing metadata in /capabilities#326
seanhanca wants to merge 44 commits into
mainfrom
feat/adapter-capabilities-pricing-passthrough

Conversation

@seanhanca
Copy link
Copy Markdown
Contributor

@seanhanca seanhanca commented May 16, 2026

What

The inference-adapter's `/capabilities` response only emitted 5 fields (`name`, `model_id`, `capacity`, `price_per_unit`, `price_scaling`). The `CAPABILITIES_JSON` env input already carries the hybrid-pricing display metadata (`display_price_usd`, `display_unit`, `unit_kind`, `pixels_per_unit`, `meter`) but:

  1. `CapabilityConfig` dataclass didn't have those fields → dropped at load time
  2. `_handle_list_capabilities` only projected the 5 base fields → dropped at response time

Why this matters

Consumer chain: adapter `/capabilities` → SDK `/capabilities` → storyboard MCP `estimateCost()`. With `display_price_usd` missing at the source, every storyboard `create_media` job persisted `cost_usd_estimated: null`, which made `get_cost_report` total $0 even when real GPU money was spent.

Diagnosed during a real marketer brief — agent reported "74/74 jobs missing cost metadata" and blamed the backend. Actually the adapter was the root cause; the SDK was a secondary leak.

The fix

Three small patches in `livepeer-inference-adapter`:

  1. `config.py CapabilityConfig` — add 4 Optional pricing fields
  2. `config.py` JSON loader (env + saved-file paths) — plumb the new fields when parsing
  3. `proxy.py _handle_list_capabilities` — emit them when non-null (compact response for older caps)

Verification (live, post hot-patch)

```
curl http://8.229.77.130:9090/capabilities → 53/53 caps now carry display_price_usd
flux-dev sample: display_price_usd 0.02625, unit_kind "megapixel", pixels_per_unit 1
```

Then via the SDK (with its own companion fix applied):
```
storyboard create_media flux-schnell → cost_usd_estimated: 0.0032 ✓
```

Companion PR

SDK strips the fields again at `CapabilityItem`, fix in simple-infra `feat/sdk-capabilities-pricing-passthrough`. Both layers need merging.

Deploy state

  • Hot-patched into running byoc-staging-1 `byoc-adapter` container
  • Image bake pending — same AR push perm gap; documented for next ops cycle

Test plan

  • Existing adapter tests still pass (pricing fields are Optional → no breakage for older configs)
  • Companion simple-infra PR merged before image bake

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added Orchestrator Leaderboard plugin for discovering and ranking orchestrators with customizable discovery plans
    • Added training support with submit and status polling capabilities
    • Added configurable data sources with refresh audit logging and admin settings panel
    • Added metering system for calculating billable units in inference workloads
    • Added admin UI for managing leaderboard data sources and refresh intervals
  • New APIs

    • Added REST endpoints for orchestrator ranking, discovery plans, dataset management, and source configuration
    • Added training endpoints for job submission and status tracking
  • Documentation

    • Added comprehensive API reference, how-to guides, and OpenAPI specification for leaderboard plugin

Review Change Stack

seanhanca and others added 30 commits April 13, 2026 15:47
…support

Wire `upstreamStaticBody` through the connector template pipeline (loader
interface, JSON schema, admin template route, seed script) so endpoints
can send a pre-configured request body to the upstream service.

Add the `clickhouse-query` connector template with four endpoints:
  - /network_prices  — static SQL for orchestrator pricing data
  - /query           — dynamic SELECT-only queries with regex + blacklist
  - /ping            — ClickHouse health check
  - /tables          — list tables via SHOW TABLES

Include a how-to guide with dashboard visualization example and update
the connector catalog.

Made-with: Cursor
…ded with basic auth

Updated the buildUpstreamRequest and buildUpstreamHeaders functions to ensure that when the connector's authType is set to 'basic', the consumer's Authorization header is not forwarded to the upstream service. This change enhances security by preventing potential credential leaks. Added corresponding tests to verify this behavior.
- Use ?? null instead of || null for upstreamStaticBody to preserve
  empty strings and match the seed script's nullish coalescing
- Remove hardcoded allowedHosts from clickhouse-query.json so host
  validation derives from upstreamBaseUrl at runtime, avoiding
  conflicts when users override the base URL
- Remove envKey from clickhouse-query.json — a single env var cannot
  map to two separate Basic-auth secrets (username + password);
  users configure credentials via the Settings tab UI instead
- Add JSDoc docstrings to all exported interfaces and functions in
  the connector template loader and route handlers

Made-with: Cursor
Add a new plugin that provides real-time orchestrator rankings based on
latency, stability (swap ratio), and price. Queries ClickHouse via the
service gateway connector and supports custom SLA-weighted re-ranking.

- REST API: POST /rank (filtered + ranked results), GET /filters (capabilities)
- Server-side in-memory cache (10s TTL) to reduce ClickHouse load
- SLA scoring engine with user-defined latency/swapRate/price weights
- Modern dark-theme UI with capability pills, stat cards, and metric badges
- Fallback capabilities for local dev without ClickHouse
- API tests, unit tests (cache, query builder, ranking), and E2E specs
- SDK client examples (curl + TypeScript) and API reference docs

Made-with: Cursor
- Validate null/non-object JSON body before accessing properties (rank route)
- Change Cache-Control from public to private on authenticated routes
- Derive filters route origin from request.url instead of hardcoded localhost
- Exclude rows with null metrics when max filters are applied (ranking)
- Clamp negative SLA weights to zero before normalization
- Skip FIFO cache eviction when refreshing an existing capability key
- Increase MAX_QUERY_ROWS to 1000 to match validateTopN upper bound
- Fix ClickHouse JSON response parsing for raw array responses
- Use != null checks in hasActiveFilters to preserve zero-valued filters
- Add accessible labels (htmlFor/id via useId) to FilterInput and WeightSlider
- Prevent all-zero SLA weights by resetting changed key to 1
- Restore focus-visible outline on range inputs for keyboard accessibility
- Stabilize useLeaderboard effect deps with useMemo for filters/slaWeights
- Use fake timers in cache TTL test for deterministic behavior
- Add consumer Auth header forwarding test for basic auth strategy
- Update test assertions for null-exclusion filter behavior
- Add Playwright config and @playwright/test dependency for E2E tests
- Replace waitForTimeout with waitForResponse in E2E specs
- Remove duplicate build:umd script from frontend package.json
- Remove inline password from ClickHouse curl example (security)
- Fix connector catalog numbering gap (19 -> 18)
- Document fromFallback field in /filters API response
- Add res.ok check in example client and curl version note
- Remove platform-specific @rollup/rollup-darwin-arm64 optional dep (CI fix)

Made-with: Cursor
Introduce an `"experimental": true` flag in plugin.json that causes new
plugins to auto-register as hidden (visibleToUsers: false) during sync.
Admins can then add specific preview testers via the admin API, keeping
the plugin invisible to all other users. Works identically on local dev
and Vercel preview deployments (separate Neon DB branches).

- plugin-discovery.ts: read experimental flag, pass through to package data
- sync-plugin-registry.ts: set visibleToUsers=false only on first create
- bin/preview-plugin.sh: helper script to configure any experimental plugin
- docs/experimental-plugin-preview.md: full workflow documentation

Made-with: Cursor
…E tests

- Remove NEXT_PUBLIC_APP_URL/NEXTAUTH_URL from committed .env (use .env.local)
- Patch start.sh to sync NEXT_PUBLIC_APP_URL with SHELL_PORT on every start
- Remove hardcoded localhost:3000 from CORS allowlist in next.config.js
- Consolidate 7 inline localhost fallbacks to use shared appUrl from lib/env
- Fix layout.tsx stale localhost:3001 fallback
- Use request.url origin for ClickHouse gateway URL resolution
- Add auth headers + credentials to plugin frontend API calls
- Make Playwright webServer.url respect PLAYWRIGHT_BASE_URL
- Add orchestrator-leaderboard E2E spec (stub + live modes)
- Add resolveClickhouseGatewayQueryUrl unit tests

Made-with: Cursor
Add persistent DiscoveryPlan model to store parameterized discovery
queries for external consumers (signers, SDKs). Uses a dedicated
plugin_orchestrator_leaderboard Postgres schema with indexes on
teamId and ownerUserId for scoped access.

Made-with: Cursor
Extend types.ts with DiscoveryPlan, PlanResults, PlanSortBy types and
Zod schemas for create/update validation. Capabilities are validated
against the existing alphanumeric pattern.

Made-with: Cursor
Create plans.ts with create, list, get, update, delete functions for
DiscoveryPlan records. Plans are scoped by teamId or ownerUserId.
Includes listEnabledPlans() for the cron refresh path.

Made-with: Cursor
Add evaluatePlan() to ranking.ts that applies the full pipeline:
filter -> SLA score -> min-gate -> custom sort -> topN. Supports
all PlanSortBy options (slaScore, latency, price, swapRate, avail).

Made-with: Cursor
…upport

Create refresh.ts with:
- evaluateAndCache(): lazy evaluation with stale-while-revalidate
- refreshAllPlans(): bulk refresh for Vercel Cron warming
- startLocalRefreshLoop(): optional setInterval for local dev
- In-memory plan result cache with configurable TTL

Made-with: Cursor
Create API routes for the Discovery Plan system:
- GET/POST /plans — list and create plans
- GET/PUT/DELETE /plans/:id — get, update, delete a plan
- GET /plans/:id/results — lazy-evaluated cached results
- POST /plans/refresh — cron-triggered bulk refresh

Made-with: Cursor
Schedule plan refresh every 1 minute so most reads are cache hits.
Uses CRON_SECRET auth (same pattern as bff-warm).

Made-with: Cursor
Add 3 new test files (29 tests total):
- evaluate-plan.test.ts: evaluatePlan() pipeline (filter, SLA, gate, sort, topN)
- types-validation.test.ts: Zod schema validation (CreatePlan, UpdatePlan)
- refresh.test.ts: evaluateAndCache(), plan cache behavior

Made-with: Cursor
The function is synchronous so await import() is invalid. Switch to
a top-level static import to fix the Vercel build.

Made-with: Cursor
…d webhook guide

- Add TabNav component for Leaderboard / Plans tab switching
- Add PlansOverviewPage with summary stats, plan cards, endpoint info
- Add PlanDetailPage with live-editing config (SLA weights, min score,
  topN, sort, filters) and "Apply Changes" to see results update
- Add EndpointGuide component showing API endpoint URL, copy button,
  and 3-step webhook setup guide for signer ORCHESTRATOR_DISCOVERY_URL
- Add usePlans/usePlanDetail hooks for data fetching and state management
- Add frontend API functions: fetchPlans, fetchPlanResults, updatePlan, seedDemoPlans
- Add backend POST /plans/seed route for dev-only demo data (4 plans)

Made-with: Cursor
The results endpoint returns success({ data: results }) which wraps the
response as { success, data: { data: PlanResults } }. The frontend was
accessing json.data directly, causing "Cannot read properties of
undefined (reading 'totalOrchestrators')" at runtime.

Made-with: Cursor
Migrate orchestrator-leaderboard plugin from hardcoded Tailwind gray/blue
classes to NaaP's CSS-variable-based design tokens. Rewrites tailwind.config.js
with semantic color mappings, globals.css with :root/.dark CSS variables and
.glass-card, and all 7 TSX component files with token-backed utility classes.

Also fixes a bug where updating a plan's configuration (e.g. SLA min score)
did not refresh the orchestrator results — the PUT handler now invalidates the
in-memory planCache entry so the next GET /results forces a fresh evaluation.

Made-with: Cursor
… preview

Create bin/seed-gateway-connector.ts that seeds the clickhouse-query
ServiceConnector, its endpoints, and encrypted upstream credentials
(username/password) into SecretVault using Prisma directly — no running
app or base-svc required.

Add step 3.5 to vercel-build.sh that runs this seed after prisma db push
when CLICKHOUSE_QUERY_USERNAME and CLICKHOUSE_QUERY_PASSWORD are set.
This enables the orchestrator-leaderboard plugin to query ClickHouse on
Vercel preview deployments, matching the local dev experience.

Made-with: Cursor
…ookup

The User model has no direct `role` column — roles are in a separate
UserRole relation table. Use findFirst with orderBy createdAt instead.

Made-with: Cursor
Server-to-server calls from leaderboard routes to the gateway endpoint
go through the external URL, which gets intercepted by Vercel's
deployment protection SSO on preview deployments.

Forward the incoming request's cookie header (contains _vercel_jwt) and
also support VERCEL_AUTOMATION_BYPASS_SECRET for cron-initiated calls.

Made-with: Cursor
Adds bin/seed-discovery-plans.ts (idempotent) that creates 4 demo plans
during Vercel preview/production builds so the leaderboard UI has data
to display without manual API calls. Runs as step 3.6 in vercel-build.sh
after schema push and gateway connector seed.

Made-with: Cursor
… scoping

- Fix auth redirect loop: always clear httpOnly cookie via logout endpoint
  before redirecting to /login, preventing middleware loop when DB is
  unreachable or session is invalid
- Fix plan visibility: use OR logic in scopeWhere so plans match by either
  teamId or ownerUserId (build-time seed sets ownerUserId only)
- Fix seed button hidden on Vercel: remove isLocalhost gate so all
  authenticated users can seed demo data
- Fix seed API blocked on Vercel: remove NODE_ENV=production check that
  blocked preview deployments
- Fix per-user seeding: use user-scoped billingPlanIds (demo-{userId}-slug)
  so each user gets their own isolated set of demo plans without unique
  constraint collisions
- Set teamId in build-time seed for proper scope matching

Made-with: Cursor
…dmin-configurable refresh

- Make orchestrator-leaderboard a core plugin (isCore: true, remove experimental)
- Add LeaderboardConfig Prisma model (singleton, refreshIntervalHours, lastRefreshedAt)
- Add global dataset in-memory cache with full-replace strategy and configurable TTL
- Add time-gated cron endpoint (hourly Vercel cron, skips if interval not elapsed)
- Wire plan evaluation to read from global dataset cache (zero ClickHouse calls when populated)
- Add GET /dataset, GET/PUT /dataset/config, POST /dataset/refresh API routes
- Add AdminSettings panel in plugin frontend (1h/4h/8h/12h selector, refresh now, admin-only)
- Add description field to DiscoveryPlan and plan metadata in results response
- Add unit tests for config, global dataset cache, plan evaluation with global dataset
- Add e2e tests for admin settings panel and plan results metadata

Made-with: Cursor
… error handling

- Fix TOCTOU race in updatePlan: use updateMany with scoped where clause
- Fix TOCTOU race in deletePlan: use deleteMany with scoped where clause
- Consistent nullable field handling (remove ?? undefined for slaWeights/filters)
- Add .catch() to fire-and-forget refreshSingle in stale-while-revalidate path
- Add .catch() to local dev refresh loop to surface errors
- Log per-capability evaluation failures instead of silently swallowing

Made-with: Cursor
… guide

Adds machine-readable contract and integration playbook so external SDKs,
gateways, and signers can build, push, and consume discovery plans without
reading the route handlers.

- docs/openapi.yaml: OpenAPI 3.1 covering all 10 plugin routes (plans CRUD,
  plan results, ad-hoc rank, capabilities, dataset, dataset config/refresh,
  bulk plans refresh, demo seed) with full schemas, error envelope, and
  three security schemes (JWT, gw_ API key, CRON_SECRET). Validated with
  @redocly/cli.
- docs/how-to-guide.md: end-to-end playbook — get an API key, discover
  capabilities, build a plan field-by-field, push it, pull executed
  results, and wire the URLs into a runtime. Includes a full TypeScript
  example with an upsert helper and Livepeer signer wiring notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sean Han and others added 11 commits May 1, 2026 10:29
…ources

Playwright E2E spec covering:
- API auth guard tests (sources + audits endpoints deny unauthenticated access)
- Stub-mode UI tests (plugin shell loads, stubbed sources/audits APIs return
  correct data via page.evaluate)
- Live-mode tests for Vercel preview: source list validation, audit record
  shape, admin auth enforcement, refresh endpoint reachability
- Build & seed verification: app health check, plugin page load, default
  source seeding with correct priority order

7 tests pass locally (stub + API guard), 9 skip (live-mode, need credentials)

Co-authored-by: Cursor <cursoragent@cursor.com>
…derboard

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	package-lock.json
PR-3 of byoc-payment-fleet-2026-05 plan. Closes the missing surface
identified in the design doc § 2: the BYOC adapter calls
serverless-proxy:8080/train/submit for training jobs, but the proxy
never grew that route. This PR adds it.

Changes:
- server.py: POST /train/submit + GET /train/status/{request_id} routes
  that delegate to a provider's train_submit() / train_status() methods.
  _resolve_training_provider() picks the fal-ai provider (today the only
  one with training support — RunPodProvider has methods too but routes
  are fal-only by convention until RunPod training is enabled in
  CAPABILITIES_JSON).
- providers/fal_ai.py: train_submit() translates SDK body
  ({model_id, params}) → fal queue body ({images_data_url, trigger_word,
  steps}); returns {request_id, model_id, status_url}. train_status()
  polls fal queue status; on COMPLETED fetches full result envelope so
  diffusers_lora_file.url is available.

TRAINING_MODELS table is the SOT for base → fal training model mapping.
Today: flux-dev/flux-schnell → fal-ai/flux-lora-fast-training.

Tests (14, all pass):
- 5 fal_ai provider tests (body translation, status pass-through, completion fetch)
- 5 route tests (P4 shape, 502 on provider error, 400 on bad JSON, 400 on missing model_id, status dispatch)
- 2 inference regression tests (existing /inference paths unchanged)
- 2 table tests

Cross-repo dependencies:
- Required by: livepeer/simple-infra PR-8 (SDK consumes orch path)
- Coordinates with: livepeer/go-livepeer feat/remote-signer-byoc-v2
  (orch already calls adapter /train; adapter calls this /train/submit)
…url, add prefix-stripping regression

Addresses three review findings on PR #315:

Imp1 — train_submit was hard-coded to forward only images_data_url,
trigger_word, steps, create_masks. Caller-provided fal params like
learning_rate, is_style, data_archive_format, resume_from_checkpoint
were silently dropped. Now spreads params first and applies setdefault
for required keys so callers can pass any fal-supported input.

Imp2 — status_url in train_submit's return value could be None when
fal's envelope omitted it. Now constructs the canonical
queue.fal.run/{model}/requests/{id}/status URL as fallback. Adapter
doesn't use the field today but the contract advertises it.

Imp3 — added test_inference_model_path_strips_provider_prefix to
TestInferenceRegression. The two pre-existing tests didn't exercise
the extra-provider routing branch in server.py (line 122-126), which
is the only inference-path branch that shares state with the new
training routing. Now covered: ensures gemini/X model_id routes to
GeminiProvider with the prefix stripped.

15/15 proxy tests pass.
Addresses CodeRabbit's one in-scope comment on PR #315. Mirrors the
string-coercion handling already applied to `steps` a few lines above.

Background: `bool("false")` returns True in Python because any non-
empty string is truthy. Without explicit string handling, a caller
passing create_masks="false" would have it flipped to True. The fix
maps common string boolean forms ("1"/"true"/"yes"/"on", case-
insensitive) to True and everything else to False; non-strings fall
back to native bool().

All 15 proxy tests still pass.

Note: CodeRabbit posted 12 other comments on this PR that flag pre-
existing issues in apps/web-next, plugins/orchestrator-leaderboard, etc.
Those files are not touched by PR-3 — CodeRabbit reviewed the whole
diff vs main and flagged pre-existing code. Out of scope for this PR.
PR-7 of byoc-payment-fleet-2026-05 plan. Adds opt-in persistence to
the BYOC inference-adapter's TrainingJob tracking (design §3.D).

Same deviation rationale as go-livepeer PR-6: design specified Redis;
filesystem JSON checkpoint gives equivalent recovery for single-
instance adapter without a new container dependency.

New module: livepeer_adapter/training_store.py
- TrainingJobStore wraps a dict with optional filesystem persistence.
- Dict-like interface preserves all existing __setitem__/__getitem__/
  __contains__/values()/items() callsites in proxy.py.
- Atomic disk write on __setitem__ via tempfile.mkstemp + os.replace.
- Background snapshot loop (5s default) catches attribute mutations
  on jobs (status/progress/result/error updates happen via direct
  attribute assignment in proxy._run_training_job — too many sites
  to refactor).
- sweep_on_startup() loads existing checkpoints, marks in-flight
  jobs as failed_adapter_restart, rewrites their checkpoints.
- TTL cleanup deletes terminal checkpoints alongside in-memory entries.
- Corrupt JSON files logged + skipped, not fatal.

Wiring:
- TRAINING_CHECKPOINT_DIR env var enables persistence.
  Unset = pure in-memory (backward compat).
- TrainingJob gained from_dict() classmethod (inverse of to_dict).
- ProxyServer.start() kicks off snapshot loop;
  ProxyServer.stop() cancels it cleanly.

Tests (7, all pass):
- test_inmemory_mode_no_persistence_dir (backward compat)
- test_disk_write_on_insert
- test_sweep_marks_inflight_as_failed (I8 invariant)
- test_sweep_skips_corrupt_json
- test_sweep_no_checkpoint_dir_is_noop
- test_snapshot_loop_persists_attribute_mutations
- test_atomic_write_no_leftover_tmp

Cross-repo: pairs with go-livepeer PR-6 — orch + adapter both
checkpoint to their respective directories. Production deploy will
mount tmpfs or hostPath volumes per design §3.D.
…tart tests

Addresses three review findings on PR-7 (#316):

Q4 — fs I/O on the request thread blocks the event loop. Added
TrainingJobStore.aset() async setter that offloads _persist_one to
asyncio.to_thread. proxy._handle_train_async now uses aset() on the
hot path. Subsequent mutations inside _run_training_job continue to
rely on the periodic snapshot loop (no per-mutation overhead).

Q3 — TrainingJob.from_dict could silently drop fields added later
to to_dict. New test test_training_job_roundtrip_matches exercises
the REAL production class (not the test fake) — asserts that
from_dict(to_dict(j)).to_dict() == j.to_dict() for a job with all
optional fields populated.

Q7 — I8 invariant not tested across multiple restarts. New test
test_sweep_marker_survives_second_restart creates 3 stores in
sequence against the same directory, verifies failed_adapter_restart
status is durable across all three sweeps.

All 9 tests pass.
PR-B of pricing-metering-design.md.

Adds a 14-function extractor registry that computes the units a job
consumed (megapixels output, video seconds output, characters input,
etc.) and emits them as X-Livepeer-Units-Consumed + X-Livepeer-Units-Kind
on the adapter's response. The orch (PR-A) reads these headers to
debit in domain units instead of wall-clock seconds.

Behavior change: zero for caps without `meter` field in CAPABILITIES_JSON.
Header absent → orch falls back to seconds (current behavior preserved
bit-for-bit). Per-cap opt-in is PR-C.

Files added:
- livepeer_adapter/metering/__init__.py — compute_units(meter, req,
  resp, elapsed) → (units, kind). Clamps + fallback handling.
- livepeer_adapter/metering/extractors.py — 14 stock extractors:
  - output.image_megapixels, .image_count, .video_seconds,
    .audio_seconds, .mesh_count, .text_kilo_tokens
  - input.text_kilo_chars, .text_chars, .image_megapixels,
    .video_seconds, .video_seconds_total
  - special: llm.tokens_io, time.seconds, flat.1
- tests/metering/test_extractors.py — 24 tests covering each extractor,
  the registry contract, clamps, and empty-input invariants.

Files modified:
- config.py — CapabilityConfig.meter: Optional[dict]. Parses `meter`
  field from CAPABILITIES_JSON. Plumbed through load_saved_capabilities.
- proxy.py — _handle_inference resolves meter from cap config, calls
  compute_units, sets headers when units > 0 + kind non-empty.

24/24 pytest pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lities

The /capabilities response only emitted 5 fields (name, model_id,
capacity, price_per_unit, price_scaling). The CAPABILITIES_JSON env
input already carries display_price_usd / display_unit / unit_kind /
pixels_per_unit / meter — but CapabilityConfig dropped them on load
AND the /capabilities handler projected them out again.

Consumer chain: adapter /capabilities → SDK /capabilities → storyboard
estimateCost(). With display_price_usd missing at the source, every
storyboard create_media job persisted cost_usd_estimated:null, which
made get_cost_report total \$0 even when real money was spent.

Fix in three spots:
1. CapabilityConfig: add display_price_usd / display_unit / unit_kind /
   pixels_per_unit as Optional fields
2. load_config (env path) + load_saved_capabilities (file path): plumb
   the new fields when parsing JSON
3. _handle_list_capabilities: emit them when non-null (kept compact for
   older capabilities that don't have pricing metadata yet)

Verified live:
  curl http://8.229.77.130:9090/capabilities
    → 53/53 caps now carry display_price_usd
  curl https://sdk.daydream.monster/capabilities
    → 53/64 priced (the 11 unpriced are from a separate orch)
  storyboard create_media flux-schnell
    → cost_usd_estimated: 0.0032

Companion fix in simple-infra feat/sdk-capabilities-pricing-passthrough
(the SDK's CapabilityItem also stripped the fields before this).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
naap-platform Ready Ready Preview, Comment May 16, 2026 3:16am

Request Review

@github-actions github-actions Bot added the size/XL Extra large PR (500+ lines) label May 16, 2026
@github-actions
Copy link
Copy Markdown

⚠️ This PR is very large (19652 lines changed). Please split it into smaller, focused PRs if possible.

@github-actions github-actions Bot added the has-migration Includes database migration label May 16, 2026
@github-actions
Copy link
Copy Markdown

🗃️ Database Migration Detected

This PR includes changes to Prisma schema files. Please ensure:

  • Migration is backward-compatible or a rollback plan exists
  • Data migration scripts are included if needed
  • Schema changes will be auto-applied to the preview database (Neon preview branch) during the Vercel preview deployment
  • Verify the preview deployment works correctly with the new schema
  • On merge to main, schema changes will auto-promote to production via prisma db push

Preview DB: This PR's Vercel preview deployment uses an isolated Neon database branch. Schema changes are applied automatically via prisma db push during the preview build. The preview branch is reset after each production deploy.

Requesting review from the core team: @livepeer/core

@github-actions github-actions Bot added scope/shell Shell app changes scope/packages Shared package changes scope/infra Infrastructure changes labels May 16, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 16, 2026

📝 Walkthrough

Walkthrough

Adds a full orchestrator-leaderboard plugin: DB schema, backend routes, query/ranking/cache, resolver and source adapters, global dataset refresh with cron, discovery plans CRUD/results, admin sources/config/audits, frontend plugin UI, extensive tests, docs/OpenAPI, seeding/build scripts, and related env/proxy/middleware updates.

Changes

Orchestrator Leaderboard — End-to-end stack

Layer / File(s) Summary
Feature implementation and integration
apps/web-next/src/app/api/v1/orchestrator-leaderboard/*, apps/web-next/src/lib/orchestrator-leaderboard/**/*, packages/database/prisma/schema.prisma, plugins/orchestrator-leaderboard/**/*, plugins/service-gateway/connectors/*, bin/*, apps/web-next/tests/*, containers/*, docs/*, apps/web-next/*
Implements DB models, adapters/resolver/cache, dataset refresh/cron, plans CRUD/results, rank/filters/sources/audits/config APIs, frontend plugin/UI/hooks, tests, docs/OpenAPI, seeding/build scripts, and ancillary config/env/middleware updates.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • livepeer/naap#213 — Earlier work around the Orchestrator Leaderboard rank/filters and shared modules.
  • livepeer/naap#324 — Adjusts resolver/dataset behavior (e.g., excluding empty capabilities) in the same modules.
  • livepeer/naap#239 — Related Playwright baseURL/webServer condition changes in the same config file.

Suggested labels

scope/packages, size/M

Suggested reviewers

  • eliteprox
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/adapter-capabilities-pricing-passthrough
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/adapter-capabilities-pricing-passthrough

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (25)
apps/web-next/src/__tests__/api/orchestrator-leaderboard.test.ts-72-77 (1)

72-77: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore global.fetch after each test to avoid cross-suite leakage.

Lines 72 and 226 overwrite global.fetch; vi.restoreAllMocks() won’t restore this direct assignment, so later tests can run against a stale mock.

Proposed fix
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 ...
+const originalFetch = global.fetch;
+
+afterEach(() => {
+  global.fetch = originalFetch;
+});

Also applies to: 226-230

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/__tests__/api/orchestrator-leaderboard.test.ts` around
lines 72 - 77, Tests directly reassign global.fetch (in the
orchestrator-leaderboard.test suite) which isn't undone by vi.restoreAllMocks(),
causing cross-suite leakage; capture the original global.fetch into a variable
before mocking and restore it in an afterEach/afterAll, or replace direct
assignment with vi.spyOn(global, "fetch")/mockResolvedValue and ensure
vi.restoreAllMocks() is called in afterEach; update the mock setup around the
existing global.fetch assignment (lines using FIXTURE_CH_RESPONSE) and the
cleanup near line 226-230 accordingly so the original fetch is reinstated after
each test.
apps/web-next/src/app/api/v1/gw/admin/templates/route.ts-22-23 (1)

22-23: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Add authentication to GET handler.

The GET handler exposes admin connector templates (including secretRefs, upstreamBaseUrl, and endpoint configurations) without authentication. Every other admin route under /gw/admin/ requires authentication via getAdminContext(request); this endpoint should too.

If templates are meant to be publicly accessible, move the endpoint outside the /admin/ path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/app/api/v1/gw/admin/templates/route.ts` around lines 22 -
23, The GET handler currently returns admin connector templates without
authentication; update the export async function GET to accept the incoming
Request, call getAdminContext(request) at the start to enforce authentication
(and handle failures by returning the appropriate unauthorized response), then
proceed to call loadConnectorTemplates() and return the templates; if these
templates should remain public instead, move this route out of the /gw/admin/
namespace instead of adding auth. Ensure you reference GET,
getAdminContext(request), and loadConnectorTemplates() when making the change.
apps/web-next/src/app/api/v1/orchestrator-leaderboard/plans/seed/route.ts-70-72 (1)

70-72: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid collision-prone billingPlanId derivation.

Using only the first 8 chars of callerId can collide across users and break the “scoped per user” guarantee.

Proposed fix
 function userPlanId(userId: string, slug: string): string {
-  return `demo-${userId.slice(0, 8)}-${slug}`;
+  const stable = Buffer.from(userId).toString('base64url').slice(0, 16);
+  return `demo-${stable}-${slug}`;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/app/api/v1/orchestrator-leaderboard/plans/seed/route.ts`
around lines 70 - 72, The userPlanId function is collision-prone because it
slices callerId to 8 chars; update userPlanId to derive a collision-safe plan id
by either using the full userId or a deterministic hash of the userId (e.g.,
SHA-256 or other stable hex digest) combined with the slug, ensuring the result
remains URL-safe and within any length limits; adjust any callers that expect
the old short format if necessary and preserve the `demo-...-slug` pattern in
the new identifier.
apps/web-next/src/app/api/v1/orchestrator-leaderboard/plans/seed/route.ts-100-105 (1)

100-105: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not suppress non-duplicate creation failures.

The blanket catch {} masks operational/data errors and can return success: true even when seeding partially failed.

Proposed fix
     try {
       await createPlan(input, scope);
       created++;
-    } catch {
-      // skip duplicates (race or constraint)
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : '';
+      if (msg.includes('Unique constraint')) continue; // duplicate race
+      throw err;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/app/api/v1/orchestrator-leaderboard/plans/seed/route.ts`
around lines 100 - 105, The current blanket catch around createPlan(input,
scope) hides real failures; change it to catch (err) { if
(isDuplicateError(err)) { /* skip duplicate */ } else { throw err; } } so only
unique-constraint/race duplicates are swallowed and all other errors are
rethrown; implement an isDuplicateError helper that checks common indicators
(Prisma P2002, Postgres 23505, or error message substrings like "duplicate" /
"already exists") and reference createPlan, input, scope and the created counter
so created only increments on successful creation.
apps/web-next/src/app/api/v1/orchestrator-leaderboard/plans/route.ts-49-53 (1)

49-53: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use direct Prisma error code check for duplicate-plan handling.

Matching "Unique constraint" in error text is brittle and can break on message changes/localization, returning the wrong status for duplicates. Check the error code directly instead, following the pattern used elsewhere in the codebase:

   } catch (err) {
     const msg = err instanceof Error ? err.message : 'Failed to create plan';
-    if (msg.includes('Unique constraint')) {
+    if ((err as { code?: string })?.code === 'P2002') {
       return errors.badRequest('A plan with this billingPlanId already exists');
     }
     return errors.internal(msg);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/app/api/v1/orchestrator-leaderboard/plans/route.ts` around
lines 49 - 53, Replace the brittle text-match in the catch block with a direct
Prisma error-code check: import Prisma (or reference Prisma) and change the
condition to detect duplicate-key errors by checking if err is an instance of
Prisma.PrismaClientKnownRequestError and err.code === 'P2002', then return
errors.badRequest('A plan with this billingPlanId already exists'); keep the
existing fallback path for other errors and preserve the err message extraction
for the final handling.
apps/web-next/src/lib/orchestrator-leaderboard/cache.ts-20-33 (1)

20-33: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against accidental mutation of cached data across requests.

Both getCached() (line 32) and setCached() (line 50) leak mutable references. getCached() returns the internal cached array directly, and setCached() stores the caller's array without copying. While current code doesn't mutate these rows in place, the pattern allows accidental mutations that could corrupt shared cached state. A shallow copy prevents this footgun.

Suggested patch
 export function getCached(capability: string): { rows: ClickHouseLeaderboardRow[]; cachedAt: number } | null {
   stats.hits++;
-  return { rows: entry.data, cachedAt: entry.cachedAt };
+  return {
+    rows: entry.data.map((row) => ({ ...row })),
+    cachedAt: entry.cachedAt,
+  };
 }

   const now = Date.now();
   LEADERBOARD_CACHE.set(capability, {
-    data: rows,
+    data: rows.map((row) => ({ ...row })),
     cachedAt: now,
     expiresAt: now + ttlMs,
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/lib/orchestrator-leaderboard/cache.ts` around lines 20 -
33, getCached and setCached leak mutable references to the cached rows
(returning entry.data directly and storing the caller's array), so prevent
accidental cross-request mutation by storing and returning shallow copies: in
setCached (the function that writes into LEADERBOARD_CACHE) save a copy of the
provided rows (e.g., [...rows] or rows.slice()) into entry.data, and in
getCached return a copy of entry.data instead of the internal array; reference
getCached, setCached, LEADERBOARD_CACHE and entry.data when making these
changes.
apps/web-next/src/lib/orchestrator-leaderboard/plans.ts-20-27 (1)

20-27: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce non-empty scope before any scoped plan CRUD.

The current scope builder returns an empty filter when both teamId and ownerUserId are missing, which turns scoped reads/updates/deletes into unscoped operations.

🔒 Proposed fix
 type PlanScope = { teamId?: string; ownerUserId?: string };

+function assertScoped(scope: PlanScope): void {
+  if (!scope.teamId && !scope.ownerUserId) {
+    throw new Error('Plan scope requires teamId or ownerUserId');
+  }
+}
+
 function scopeWhere(scope: PlanScope) {
+  assertScoped(scope);
   const conditions: Record<string, string>[] = [];
   if (scope.teamId) conditions.push({ teamId: scope.teamId });
   if (scope.ownerUserId) conditions.push({ ownerUserId: scope.ownerUserId });
   if (conditions.length === 0) return {};
   if (conditions.length === 1) return conditions[0];
   return { OR: conditions };
 }

 export async function createPlan(
   input: CreatePlanInput,
   scope: PlanScope,
 ): Promise<DiscoveryPlan> {
+  assertScoped(scope);
   const row = await prisma.discoveryPlan.create({

Also applies to: 49-67, 71-119

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/lib/orchestrator-leaderboard/plans.ts` around lines 20 -
27, scopeWhere currently returns an empty filter when PlanScope has neither
teamId nor ownerUserId, allowing unscoped CRUD; change scopeWhere (and any
scoped CRUD callers) to enforce a non-empty scope by throwing a clear exception
(e.g., "Invalid PlanScope: missing teamId and ownerUserId") when
conditions.length === 0 so no query runs without scope; update any functions
that perform scoped reads/updates/deletes to call scopeWhere and
propagate/handle that error rather than permitting an empty filter.
apps/web-next/src/lib/orchestrator-leaderboard/refresh.ts-116-127 (1)

116-127: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Deduplicate in-flight refreshes per plan to prevent refresh stampedes.

Stale cache hits can launch multiple concurrent refreshSingle() calls for the same plan.id, which creates avoidable duplicate upstream queries.

⚙️ Proposed fix
 const planCache = new Map<string, PlanCacheEntry>();
+const inFlightRefreshes = new Map<string, Promise<PlanResults>>();

 async function refreshSingle(
   plan: DiscoveryPlan,
   authToken: string,
   requestUrl?: string,
   cookieHeader?: string | null,
 ): Promise<PlanResults> {
-  const results = await evaluate(plan, authToken, requestUrl, cookieHeader);
-  const now = Date.now();
-  planCache.set(plan.id, {
-    results,
-    cachedAt: now,
-    expiresAt: now + CACHE_TTL_MS,
-  });
-  return results;
+  const existing = inFlightRefreshes.get(plan.id);
+  if (existing) return existing;
+
+  const p = (async () => {
+    const results = await evaluate(plan, authToken, requestUrl, cookieHeader);
+    const now = Date.now();
+    planCache.set(plan.id, {
+      results,
+      cachedAt: now,
+      expiresAt: now + CACHE_TTL_MS,
+    });
+    return results;
+  })();
+
+  inFlightRefreshes.set(plan.id, p);
+  try {
+    return await p;
+  } finally {
+    inFlightRefreshes.delete(plan.id);
+  }
 }

Also applies to: 129-143

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/lib/orchestrator-leaderboard/refresh.ts` around lines 116 -
127, Stale cache paths can start concurrent refreshSingle(plan, ...) calls for
the same plan.id; add a module-level Map (e.g., inFlightRefreshes: Map<string,
Promise<any>>) and use it to deduplicate: before calling refreshSingle check if
inFlightRefreshes.has(plan.id) and reuse that promise (return or await it as
appropriate), otherwise create the promise = refreshSingle(plan, authToken,
requestUrl, cookieHeader), store it with inFlightRefreshes.set(plan.id, promise)
and ensure promise.finally(() => inFlightRefreshes.delete(plan.id)) to remove
the entry when settled; apply the same dedupe logic for both the background
fire-and-forget branch and the synchronous return branch so multiple requests
for the same plan.id reuse the in-flight promise instead of launching duplicate
upstream work.
apps/web-next/src/lib/orchestrator-leaderboard/resolver.ts-296-301 (1)

296-301: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Per-capability metrics are being flattened to one arbitrary source row.

The resolver picks rows[0] during field selection, then reuses that single merged metric set for every capability. That drops capability-specific values (e.g., price/latency/capacity) and can produce incorrect rankings.

You’ll need to merge at capability granularity (or preserve per-cap rows from the winning source) instead of merging once per orchestrator and cloning the same metrics across all capabilities.

Also applies to: 366-378

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/lib/orchestrator-leaderboard/resolver.ts` around lines 296
- 301, The code in resolver.ts iterates sources and uses rows[0] (the variable
first) to pick metric values, which flattens per-capability metrics and reuses a
single metric set across all capabilities; update the logic in the
field-selection loop (the block that reads sourceRows.get(src), const first =
rows[0], const val = first[field]) to merge or select metrics at capability
granularity: either iterate each capability-specific row in rows and merge them
into the result per-capability, or retain the winning source's per-capability
rows and copy capability-specific fields from the corresponding row rather than
using rows[0]; apply the same fix to the analogous block around the 366-378
section so capability-specific price/latency/capacity values are preserved per
capability instead of being cloned across all capabilities.
apps/web-next/src/lib/orchestrator-leaderboard/sources/naap-discover.ts-78-86 (1)

78-86: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unchecked capabilities access can fail the whole fetch on malformed upstream rows.

Line 79 assumes r.capabilities is always an array. If upstream returns one malformed record, .map throws and the entire source fetch fails instead of skipping bad rows.

Proposed fix
-    for (const r of rawRows) {
-      const shortCaps = r.capabilities.map(extractCapabilityName);
+    for (const r of rawRows) {
+      const caps = Array.isArray((r as { capabilities?: unknown }).capabilities)
+        ? (r as { capabilities: string[] }).capabilities
+        : [];
+      const shortCaps = caps.map(extractCapabilityName);
       rows.push({
         orchUri: r.address,
         capabilities: shortCaps,
         score: r.score,
         recentWork: r.recent_work,
         lastSeenMs: r.last_seen_ms,
       });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/lib/orchestrator-leaderboard/sources/naap-discover.ts`
around lines 78 - 86, The loop over rawRows assumes each record's r.capabilities
is an array and calling r.capabilities.map(extractCapabilityName) can throw if
capabilities is missing or malformed; update the iteration (the for (const r of
rawRows) block that builds rows and calls extractCapabilityName) to defensively
check Array.isArray(r.capabilities) before mapping, skip or default to an empty
array for bad entries, and only push rows when required fields (e.g., r.address
and a safe capabilities list) are valid so a single malformed upstream row
doesn't abort the whole fetch.
apps/web-next/src/lib/orchestrator-leaderboard/sources/naap-pricing.ts-67-75 (1)

67-75: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Row normalization can crash on invalid orchAddress.

Line 68 calls toLowerCase() unconditionally. A null/undefined/non-string value from upstream will throw and fail the full pricing source fetch.

Proposed fix
-    const rows: NormalizedOrch[] = rawRows.map((r) => ({
-      ethAddress: r.orchAddress.toLowerCase(),
-      orchUri: undefined,
-      pricePerUnit: r.priceWeiPerUnit,
-      pipeline: r.pipeline,
-      model: r.model,
-      isWarm: r.isWarm,
-      capabilities: [`${r.model}`],
-    }));
+    const rows: NormalizedOrch[] = rawRows.flatMap((r) => {
+      if (typeof r.orchAddress !== 'string' || r.orchAddress.length === 0) return [];
+      return [{
+        ethAddress: r.orchAddress.toLowerCase(),
+        orchUri: undefined,
+        pricePerUnit: r.priceWeiPerUnit,
+        pipeline: r.pipeline,
+        model: r.model,
+        isWarm: r.isWarm,
+        capabilities: [`${r.model}`],
+      }];
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/lib/orchestrator-leaderboard/sources/naap-pricing.ts`
around lines 67 - 75, The mapping that builds rows (rawRows.map ->
NormalizedOrch) calls r.orchAddress.toLowerCase() unguarded which will throw on
null/undefined/non-string; update the normalization to defensively handle
non-string orchAddress by checking typeof r.orchAddress === 'string' (or using
String(r.orchAddress) safely) before calling toLowerCase, and set ethAddress to
a safe fallback (e.g., undefined or '' ) when invalid; modify the rows
construction where ethAddress is assigned so other fields (orchUri,
pricePerUnit, pipeline, model, isWarm, capabilities) remain unchanged and the
normalization does not crash the pricing source fetch.
apps/web-next/src/lib/orchestrator-leaderboard/sources/clickhouse.ts-50-53 (1)

50-53: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Capability parsing drops valid ClickHouse results.

On Line 51, when the response is the standard ClickHouse JSON shape ({ data: [...] }), json.data is already the row array, but the code then reads chData.data, which yields undefined. This silently returns an empty capabilities list and prevents downstream per-capability queries.

Proposed fix
-    const json = await res.json();
-    const chData = (json.data ?? json) as { data?: Array<{ capability_name: string }> };
-    return (chData.data ?? []).map((row: { capability_name: string }) => row.capability_name);
+    const json = await res.json();
+    const rows = Array.isArray((json as { data?: unknown }).data)
+      ? ((json as { data: Array<{ capability_name?: string }> }).data)
+      : Array.isArray(json)
+        ? (json as Array<{ capability_name?: string }>)
+        : [];
+    return rows
+      .map((row) => row.capability_name)
+      .filter((cap): cap is string => typeof cap === 'string' && cap.length > 0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/lib/orchestrator-leaderboard/sources/clickhouse.ts` around
lines 50 - 53, The current parsing creates chData from json and then reads
chData.data which drops the case where json.data is already the row array;
change the logic in the try block (where json, chData and the final .map are
used) to normalize rows first: detect if json.data is an Array (use that), else
if json itself is an Array use json, else if chData.data is an Array use
chData.data, otherwise default to empty array; then map over that rows array and
return row.capability_name. Update the variables used in the mapping (json,
chData, capability_name) accordingly so the standard ClickHouse `{ data: [...]
}` shape is handled correctly.
apps/web-next/src/lib/orchestrator-leaderboard/sources/clickhouse.ts-96-118 (1)

96-118: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Per-capability fetches are fully sequential and can timeout under larger capability sets.

Lines 96-118 execute one network request at a time. With dozens of capabilities, this can exceed API/runtime latency budgets and degrade reliability. Use bounded concurrency (batching/pool) to keep wall-clock time predictable.

Proposed direction (bounded concurrency)
-    for (const cap of capabilities) {
-      try {
-        const sql = buildLeaderboardSQL(cap, MAX_QUERY_ROWS);
-        const res = await fetch(url, {
-          method: 'POST',
-          headers,
-          body: sql,
-          signal: AbortSignal.timeout(15_000),
-        });
-        if (!res.ok) {
-          rawCaps[cap] = [];
-          continue;
-        }
-        const json = await res.json();
-        const rows = parseChRows(json);
-        rawCaps[cap] = rows;
-        for (const r of rows) {
-          allRows.push(chRowToNormalized(r, cap));
-        }
-      } catch {
-        rawCaps[cap] = [];
-      }
-    }
+    const BATCH_SIZE = 5;
+    for (let i = 0; i < capabilities.length; i += BATCH_SIZE) {
+      const batch = capabilities.slice(i, i + BATCH_SIZE);
+      const settled = await Promise.allSettled(
+        batch.map(async (cap) => {
+          const sql = buildLeaderboardSQL(cap, MAX_QUERY_ROWS);
+          const res = await fetch(url, {
+            method: 'POST',
+            headers,
+            body: sql,
+            signal: AbortSignal.timeout(15_000),
+          });
+          if (!res.ok) return { cap, rows: [] as ClickHouseLeaderboardRow[] };
+          const json = await res.json();
+          return { cap, rows: parseChRows(json) };
+        }),
+      );
+
+      for (const s of settled) {
+        if (s.status === 'fulfilled') {
+          rawCaps[s.value.cap] = s.value.rows;
+          for (const r of s.value.rows) allRows.push(chRowToNormalized(r, s.value.cap));
+        }
+      }
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/lib/orchestrator-leaderboard/sources/clickhouse.ts` around
lines 96 - 118, The loop over capabilities in the function that calls
buildLeaderboardSQL is performing fetches sequentially (for...of over
capabilities) which causes long wall-clock time; change it to use bounded
concurrency (e.g., a fixed-size worker pool or batching) so multiple fetches run
in parallel but limited (suggest concurrency=6-8), preserving the existing
behavior: for each cap call fetch with the same options (including
AbortSignal.timeout), on non-ok or errors assign rawCaps[cap] = [] and continue,
on success parse json -> parseChRows, push rows into rawCaps[cap] and push
chRowToNormalized(r, cap) into allRows; implement this by mapping capabilities
to async tasks and executing them in controlled batches or using a small p-limit
style helper to run N promises concurrently.
containers/livepeer-inference-adapter/src/livepeer_adapter/config.py-132-136 (1)

132-136: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

New pricing metadata is loaded but not persisted back to disk.

Lines 132-136 and 178-182 correctly hydrate meter/display_*, but save_capabilities() still writes only base fields, so these values are lost after runtime updates and restart.

Suggested persistence fix
-        caps = [{"name": c.name, "model_id": c.model_id, "capacity": c.capacity,
-                 "price_per_unit": c.price_per_unit, "price_scaling": c.price_scaling}
+        caps = [{"name": c.name, "model_id": c.model_id, "capacity": c.capacity,
+                 "price_per_unit": c.price_per_unit, "price_scaling": c.price_scaling,
+                 "meter": c.meter,
+                 "display_price_usd": c.display_price_usd,
+                 "display_unit": c.display_unit,
+                 "unit_kind": c.unit_kind,
+                 "pixels_per_unit": c.pixels_per_unit}
                 for c in self.get_capabilities()]

Also applies to: 178-182

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@containers/livepeer-inference-adapter/src/livepeer_adapter/config.py` around
lines 132 - 136, The new pricing metadata fields (meter, display_price_usd,
display_unit, unit_kind, pixels_per_unit) are hydrated from config but not
persisted because save_capabilities() only writes base fields; modify
save_capabilities() to include these keys when serializing/writing capabilities
(mirror how you read them in the hydrate block), so the in-memory values for
meter/display_*/unit_kind/pixels_per_unit are saved to disk and survive restarts
(also apply same change for the other hydrate block around lines 178-182 where
these fields are set).
containers/livepeer-inference-adapter/src/livepeer_adapter/proxy.py-372-401 (1)

372-401: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Runtime capability upserts still drop new pricing metadata.

/capabilities GET now emits display_* and meter, but /capabilities POST (Line 415-421) does not map those fields into CapabilityConfig, so runtime-added entries can’t persist or round-trip this metadata.

Suggested add-handler patch
         cap = CapabilityConfig(
             name=name,
             model_id=model_id,
             capacity=body.get("capacity", 1),
             price_per_unit=body.get("price_per_unit", 0),
             price_scaling=body.get("price_scaling", 1_000_000),
+            meter=body.get("meter"),
+            display_price_usd=body.get("display_price_usd"),
+            display_unit=body.get("display_unit"),
+            unit_kind=body.get("unit_kind"),
+            pixels_per_unit=body.get("pixels_per_unit"),
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@containers/livepeer-inference-adapter/src/livepeer_adapter/proxy.py` around
lines 372 - 401, The POST /capabilities handler is not persisting the new
pricing/display/meter fields, so extend the request-to-CapabilityConfig mapping
to include display_price_usd, display_unit, unit_kind, pixels_per_unit, and
meter before calling the config upsert/creation method (where CapabilityConfig
is constructed/updated in the POST handler); ensure you read these optional keys
from the incoming JSON and set them on the CapabilityConfig instance (or
kwargs/dict) so runtime-added capabilities round-trip the same metadata emitted
by the GET handler in proxy.py.
containers/livepeer-inference-adapter/Dockerfile-1-6 (1)

1-6: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Run the adapter as a non-root user.

Line 1-6 leaves the container running as root, which is a preventable privilege-escalation risk.

Suggested hardening patch
 FROM python:3.11-slim
 WORKDIR /app
 RUN pip install --no-cache-dir aiohttp
 COPY src/ /app/
+RUN addgroup --system app && adduser --system --ingroup app app \
+    && chown -R app:app /app
+USER app
 ENV PYTHONUNBUFFERED=1
 CMD ["python", "-m", "livepeer_adapter.main"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@containers/livepeer-inference-adapter/Dockerfile` around lines 1 - 6, The
Dockerfile currently runs the image as root; change it to create and switch to a
non-root user before the final CMD to reduce privilege escalation risk: add
commands to create a dedicated user/group (e.g., appuser), set appropriate
ownership for WORKDIR (/app) and any runtime files (chown -R appuser:appuser
/app), set a minimal HOME if needed, and add USER appuser before the existing
CMD ["python", "-m", "livepeer_adapter.main"]; keep the existing WORKDIR and CMD
but ensure all pip installs and file copies that require root happen before
switching to the non-root user.
containers/livepeer-inference-adapter/src/livepeer_adapter/metering/__init__.py-48-64 (1)

48-64: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard numeric config parsing in compute_units.

float(meter.get("fallback"...)) / clamp casts can raise on malformed values and bubble into request handling. A bad meter config should degrade to defaults, not fail metering (or the response path).

Suggested defensive parse
-    fallback = float(meter.get("fallback", 1.0))
+    try:
+        fallback = float(meter.get("fallback", 1.0))
+    except (TypeError, ValueError):
+        fallback = 1.0
@@
-    if min_units is not None:
-        units = max(units, float(min_units))
-    if max_units is not None:
-        units = min(units, float(max_units))
+    if min_units is not None:
+        try:
+            units = max(units, float(min_units))
+        except (TypeError, ValueError):
+            pass
+    if max_units is not None:
+        try:
+            units = min(units, float(max_units))
+        except (TypeError, ValueError):
+            pass
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@containers/livepeer-inference-adapter/src/livepeer_adapter/metering/__init__.py`
around lines 48 - 64, The numeric parsing in compute_units is unguarded: calls
like float(meter.get("fallback", 1.0)) and float(min_units)/float(max_units) can
raise on malformed config and should degrade to safe defaults; update
compute_units to parse fallback, min_units, and max_units defensively (e.g.,
try/except or a small safe_float helper) so invalid values revert to fallback or
None and do not raise, and ensure extractor_fn result casting to float is also
caught and falls back to the fallback value.
containers/livepeer-serverless-proxy/Dockerfile-1-6 (1)

1-6: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Run the container as a non-root user.

The image currently runs as root, which increases blast radius if the process is compromised.

🔒 Proposed hardening patch
 FROM python:3.11-slim
 WORKDIR /app
 RUN pip install --no-cache-dir aiohttp
 COPY src/ /app/
+RUN addgroup --system app && adduser --system --ingroup app app \
+    && chown -R app:app /app
+USER app
 ENV PYTHONUNBUFFERED=1
 CMD ["python", "-c", "\nimport asyncio\nfrom serverless_proxy.config import load_config\nfrom serverless_proxy.server import ProxyServer, create_provider, create_extra_providers\ncfg = load_config()\nprovider = create_provider(cfg)\nextra = create_extra_providers(cfg)\nserver = ProxyServer(cfg, provider, extra_providers=extra)\nloop = asyncio.new_event_loop()\nloop.run_until_complete(server.start())\nloop.run_forever()\n"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@containers/livepeer-serverless-proxy/Dockerfile` around lines 1 - 6, The
Dockerfile currently runs the Python process as root; update it to create and
switch to a non-root user: keep pip install and other root-required steps (RUN
pip install --no-cache-dir aiohttp) before creating a system user (e.g.,
adduser/appuser), chown the application directory (WORKDIR /app and COPY src/
/app/) to that user, then add a USER appuser (and set HOME if needed) before the
CMD so ProxyServer (started by the existing CMD invocation) runs unprivileged;
ensure file ownership and permissions allow the new user to run the process and
write logs/state if required.
packages/database/src/plugin-discovery.ts-122-123 (1)

122-123: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Honor experimental when setting package visibility.

Line 283 always sets visibleToUsers: true, which bypasses the intended experimental default described in Line 122 and parsed in Line 174.

Proposed fix
-    visibleToUsers: true,
+    visibleToUsers: plugin.experimental ? false : true,

Also applies to: 174-174, 283-284

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/database/src/plugin-discovery.ts` around lines 122 - 123, The code
unconditionally sets visibleToUsers: true during package/plug-in registration,
ignoring the experimental flag; update the registration logic that currently
assigns visibleToUsers: true so it instead respects the parsed experimental
property (use something like visibleToUsers: pkg.experimental ? false : true or
visibleToUsers: !pkg.experimental), ensuring the experimental?: boolean field is
honored when creating/updating the package record.
plugins/orchestrator-leaderboard/frontend/src/hooks/useDatasetConfig.ts-62-79 (1)

62-79: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard refreshNow against concurrent execution.

refreshNow can be invoked multiple times before the first call completes, which can trigger overlapping backend refreshes and race UI state.

🛡️ Proposed fix
-  const refreshNow = useCallback(async () => {
+  const refreshNow = useCallback(async () => {
+    if (isRefreshing) return;
     setIsRefreshing(true);
     try {
       const result = await triggerDatasetRefresh();
@@
     } finally {
       setIsRefreshing(false);
     }
-  }, []);
+  }, [isRefreshing]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/orchestrator-leaderboard/frontend/src/hooks/useDatasetConfig.ts`
around lines 62 - 79, refreshNow can run concurrently because its closure
doesn't prevent re-entry; guard it with a reentrancy flag (e.g., a mutable ref
like isRefreshingRef) so subsequent calls return early while a refresh is in
progress. In the refreshNow implementation (inside useDatasetConfig), check the
ref at the top and return if true, set the ref and setIsRefreshing(true) before
awaiting work, and clear both the ref and setIsRefreshing(false) in finally;
update any hook dependencies accordingly to avoid stale closures. Use the
existing symbols refreshNow, setIsRefreshing, and
fetchDatasetConfig/triggerDatasetRefresh to locate and apply the change.
plugins/orchestrator-leaderboard/frontend/src/globals.css-1-3 (1)

1-3: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix Tailwind at-rule lint failures.

Stylelint is currently rejecting @tailwind on these lines, which will fail linting/CI until the directives are allowed.

🔧 Localized fix option in this file
+/* stylelint-disable-next-line scss/at-rule-no-unknown */
 `@tailwind` base;
+/* stylelint-disable-next-line scss/at-rule-no-unknown */
 `@tailwind` components;
+/* stylelint-disable-next-line scss/at-rule-no-unknown */
 `@tailwind` utilities;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/orchestrator-leaderboard/frontend/src/globals.css` around lines 1 -
3, Stylelint is flagging the `@tailwind` at-rules (`@tailwind` base, `@tailwind`
components, `@tailwind` utilities); fix by either updating Stylelint config to
recognize Tailwind by adding these to the at-rule ignore list (e.g., add
"tailwind", "apply", "variants", "responsive", "layer" to ignoreAtRules in your
stylelint config) or add a targeted inline disable comment immediately above the
rules (disable at-rule-no-unknown) so the three `@tailwind` directives are allowed
during linting; apply the change near the `@tailwind` base/components/utilities
lines.
plugins/orchestrator-leaderboard/frontend/src/hooks/usePlanDetail.ts-80-82 (1)

80-82: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear stale results when plan is disabled.

The early return on disabled plans keeps prior results in state, so the page can display outdated results for a now-disabled plan.

🧩 Proposed fix
  useEffect(() => {
-    if (!plan?.enabled) return;
+    if (!plan?.enabled) {
+      setResults(null);
+      setResultsLoading(false);
+      return;
+    }
     let cancelled = false;
     setResultsLoading(true);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/orchestrator-leaderboard/frontend/src/hooks/usePlanDetail.ts` around
lines 80 - 82, The effect in usePlanDetail that early-returns when plan?.enabled
is false leaves prior results in state; update the useEffect (the one starting
with "useEffect(() => { if (!plan?.enabled) return; let cancelled = false;") so
that when the plan is disabled you explicitly clear stale results (e.g., call
the state setter used for results such as setResults([]) or setResults(null))
before returning, preserving the existing cancelled/cleanup logic.
plugins/orchestrator-leaderboard/frontend/src/pages/PlanDetailPage.tsx-49-56 (1)

49-56: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden updateFilter numeric parsing to prevent invalid filter state.

At Line 54, invalid/partial numeric input can be stored as NaN, then persisted in draft.filters and propagated in plan updates.

💡 Proposed fix
+const parseOptionalNumber = (raw: string): number | undefined => {
+  if (raw.trim() === '') return undefined;
+  const n = Number(raw);
+  return Number.isFinite(n) ? n : undefined;
+};
+
 const updateFilter = (key: keyof LeaderboardFilters, value: string) => {
   const current = draft.filters ?? {};
   setDraft({
     filters: {
       ...current,
-      [key]: value !== '' ? Number(value) : undefined,
+      [key]: parseOptionalNumber(value),
     },
   });
 };

Also applies to: 257-266

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/orchestrator-leaderboard/frontend/src/pages/PlanDetailPage.tsx`
around lines 49 - 56, updateFilter currently converts string input with
Number(value) which can produce NaN and persist invalid filter values; modify
updateFilter (and the similar handler around lines 257-266) to parse the numeric
input safely (e.g. use Number or parseFloat then check with Number.isFinite or
isNaN) and only set the filter key when the parsed value is a valid number,
otherwise set undefined; also ensure you merge into the existing draft object
instead of replacing other draft fields by spreading the previous draft and
updating draft.filters so other properties are preserved.
plugins/orchestrator-leaderboard/frontend/src/pages/PlansOverviewPage.tsx-145-149 (1)

145-149: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make plan cards keyboard-accessible and prevent nested action hijacking.

At Line 145, navigation is mouse-only because the container is a plain <div> with onClick. Also, nested controls in the endpoint section can unintentionally trigger card navigation.

💡 Proposed fix
-<div
+<div
   key={plan.id}
   className="plan-card group"
+  role="button"
+  tabIndex={0}
   onClick={() => navigate(`/plans/${plan.id}`)}
+  onKeyDown={(e) => {
+    if (e.key === 'Enter' || e.key === ' ') {
+      e.preventDefault();
+      navigate(`/plans/${plan.id}`);
+    }
+  }}
 >
...
-  <div className="border-t border-[var(--border-color)] pt-3">
+  <div
+    className="border-t border-[var(--border-color)] pt-3"
+    onClick={(e) => e.stopPropagation()}
+    onKeyDown={(e) => e.stopPropagation()}
+  >
     <EndpointGuide planId={plan.id} compact />
   </div>
 </div>

Also applies to: 225-227

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/orchestrator-leaderboard/frontend/src/pages/PlansOverviewPage.tsx`
around lines 145 - 149, The plan card currently uses a plain div with onClick
(the element rendering className "plan-card group" and calling
navigate(`/plans/${plan.id}`)), making it inaccessible to keyboard users and
causing nested controls (the endpoint controls inside the card) to inadvertently
trigger card navigation; replace the clickable div with a semantic focusable
element (e.g., a <button> or <a> equivalent component) or add tabIndex and key
handlers so Enter/Space trigger the same navigate(`/plans/${plan.id}`) behavior,
and ensure nested interactive elements inside the endpoint section stop
propagation (or check event.target to ignore clicks from interactive
descendants) so their clicks don’t call the card navigation; apply the same
changes to the other occurrence around lines 225–227.
plugins/orchestrator-leaderboard/frontend/src/pages/LeaderboardPage.tsx-224-238 (1)

224-238: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard numeric filter parsing to avoid NaN in API payloads.

At Line 225, Line 228, Line 231, Line 234, and Line 237, invalid numeric input can become NaN, which is then treated as an active filter and sent downstream.

💡 Proposed fix
+const parseOptionalNumber = (raw: string): number | undefined => {
+  if (raw.trim() === '') return undefined;
+  const n = Number(raw);
+  return Number.isFinite(n) ? n : undefined;
+};
...
-<FilterInput label="Min GPU RAM (GB)" value={filters.gpuRamGbMin ?? ''}
-  onChange={(v) => setFilters({ ...filters, gpuRamGbMin: v !== '' ? Number(v) : undefined })}
+<FilterInput label="Min GPU RAM (GB)" value={filters.gpuRamGbMin ?? ''}
+  onChange={(v) => setFilters({ ...filters, gpuRamGbMin: parseOptionalNumber(v) })}
   type="number" min={0} step={1} />
-<FilterInput label="Max GPU RAM (GB)" value={filters.gpuRamGbMax ?? ''}
-  onChange={(v) => setFilters({ ...filters, gpuRamGbMax: v !== '' ? Number(v) : undefined })}
+<FilterInput label="Max GPU RAM (GB)" value={filters.gpuRamGbMax ?? ''}
+  onChange={(v) => setFilters({ ...filters, gpuRamGbMax: parseOptionalNumber(v) })}
   type="number" min={0} step={1} />
-<FilterInput label="Max Price" value={filters.priceMax ?? ''}
-  onChange={(v) => setFilters({ ...filters, priceMax: v !== '' ? Number(v) : undefined })}
+<FilterInput label="Max Price" value={filters.priceMax ?? ''}
+  onChange={(v) => setFilters({ ...filters, priceMax: parseOptionalNumber(v) })}
   type="number" min={0} step={0.001} />
-<FilterInput label="Max Avg Latency (ms)" value={filters.maxAvgLatencyMs ?? ''}
-  onChange={(v) => setFilters({ ...filters, maxAvgLatencyMs: v !== '' ? Number(v) : undefined })}
+<FilterInput label="Max Avg Latency (ms)" value={filters.maxAvgLatencyMs ?? ''}
+  onChange={(v) => setFilters({ ...filters, maxAvgLatencyMs: parseOptionalNumber(v) })}
   type="number" min={0} />
-<FilterInput label="Max Swap Ratio" value={filters.maxSwapRatio ?? ''}
-  onChange={(v) => setFilters({ ...filters, maxSwapRatio: v !== '' ? Number(v) : undefined })}
+<FilterInput label="Max Swap Ratio" value={filters.maxSwapRatio ?? ''}
+  onChange={(v) => setFilters({ ...filters, maxSwapRatio: parseOptionalNumber(v) })}
   type="number" min={0} max={1} step={0.01} />
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/orchestrator-leaderboard/frontend/src/pages/LeaderboardPage.tsx`
around lines 224 - 238, The numeric FilterInput onChange handlers (e.g., for
filters.gpuRamGbMin, gpuRamGbMax, priceMax, maxAvgLatencyMs, maxSwapRatio)
currently call Number(v) which can produce NaN and thus send an active invalid
filter; update each onChange in LeaderboardPage.tsx to parse the value safely
(use parseFloat/Number and then guard with !isNaN or Number.isFinite) and only
set the filter key when the parsed value is a valid number, otherwise set it to
undefined (preserving the existing empty-string -> undefined behavior); keep
using setFilters({...filters, KEY: valid ? parsedValue : undefined}) for the
corresponding keys so NaN is never included in the API payload.
🧹 Nitpick comments (3)
apps/web-next/src/app/api/v1/gw/mcp/route.ts (1)

91-91: 💤 Low value

Consider static import for appUrl.

Using await import('@/lib/env') inside the request handler adds overhead on every tools/call invocation. Since appUrl is a constant export, a top-level static import would be more efficient.

♻️ Suggested refactor

At the top of the file (after line 14):

+import { appUrl } from '`@/lib/env`';

Then at line 91-93:

-      const { appUrl: selfOrigin } = await import('`@/lib/env`');
-      const proxyPath = `/api/v1/gw/${encodeURIComponent(connectorSlug)}${path}`;
-      let url = `${selfOrigin}${proxyPath}`;
+      const proxyPath = `/api/v1/gw/${encodeURIComponent(connectorSlug)}${path}`;
+      let url = `${appUrl}${proxyPath}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/app/api/v1/gw/mcp/route.ts` at line 91, The handler
currently does a dynamic import to read appUrl (const { appUrl: selfOrigin } =
await import('`@/lib/env`')), which wastes time on every tools/call; replace that
dynamic import with a top-level static import of appUrl (import { appUrl } from
'`@/lib/env`') and update usages to use appUrl (or rename to selfOrigin where
used) so the await/import is removed from the request path; search for the
symbol appUrl and the local const selfOrigin in route.ts to locate and update
the code.
apps/web-next/src/app/api/v1/orchestrator-leaderboard/filters/route.ts (1)

72-74: 💤 Low value

Clarify the ClickHouse response structure handling.

The nested json.data ?? json followed by chData.data ?? [] suggests uncertainty about the response shape. Consider documenting the expected formats or simplifying if only one format is actually returned.

📝 Add a comment explaining the response formats
     const json = await res.json();
+    // ClickHouse gateway may wrap data in { data: [...] } or return it directly
     const chData = (json.data ?? json) as { data?: Array<{ capability_name: string }> };
     capabilities = (chData.data ?? []).map((row: { capability_name: string }) => row.capability_name);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/app/api/v1/orchestrator-leaderboard/filters/route.ts`
around lines 72 - 74, The handling of the ClickHouse response in route.ts is
ambiguous: you first assign const json = await res.json(); then wrap it as const
chData = (json.data ?? json) and later read (chData.data ?? []).map(...) —
clarify and simplify by documenting the expected response shapes (e.g., whether
the API returns { data: [...] } or [...] directly) and updating the logic
accordingly; either remove the double-layer fallback if only one shape is valid,
or add a short comment above these lines explaining both supported formats and
why you use json → chData → chData.data, referencing the variables json, chData
and the capabilities assignment so reviewers can find and adjust the parsing
behavior.
apps/web-next/src/app/api/v1/orchestrator-leaderboard/sources/route.ts (1)

136-139: ⚡ Quick win

Use issues instead of errors for Zod validation errors—recommended for forward compatibility.

While .errors works in Zod 3.23 (the currently installed version), it's deprecated in favor of .issues and will be removed in Zod 4. Adopting .issues now aligns with Zod's standard API and ensures forward compatibility.

Proposed improvement
   } catch (err) {
     if (err instanceof z.ZodError) {
+      const message = err.issues[0]?.message ?? 'Invalid request body';
       return NextResponse.json(
-        { success: false, error: { code: 'VALIDATION_ERROR', message: err.errors[0].message } },
+        { success: false, error: { code: 'VALIDATION_ERROR', message } },
         { status: 400 },
       );
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web-next/src/app/api/v1/orchestrator-leaderboard/sources/route.ts`
around lines 136 - 139, Replace usage of the deprecated ZodError.errors with
ZodError.issues in the validation error handling: in the block checking "if (err
instanceof z.ZodError)" update the payload to read from err.issues (e.g., use
err.issues[0].message or map issues for full details) before calling
NextResponse.json so the response uses the forward-compatible .issues API.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 09b3fd54-6165-4b57-b203-3fcd7cabc179

📥 Commits

Reviewing files that changed from the base of the PR and between 3d95a5d and 586abe5.

⛔ Files ignored due to path filters (10)
  • containers/livepeer-serverless-proxy/src/serverless_proxy/__pycache__/config.cpython-312.pyc is excluded by !**/*.pyc
  • containers/livepeer-serverless-proxy/src/serverless_proxy/__pycache__/server.cpython-312.pyc is excluded by !**/*.pyc
  • containers/livepeer-serverless-proxy/src/serverless_proxy/providers/__pycache__/__init__.cpython-312.pyc is excluded by !**/*.pyc
  • containers/livepeer-serverless-proxy/src/serverless_proxy/providers/__pycache__/base.cpython-312.pyc is excluded by !**/*.pyc
  • containers/livepeer-serverless-proxy/src/serverless_proxy/providers/__pycache__/custom.cpython-312.pyc is excluded by !**/*.pyc
  • containers/livepeer-serverless-proxy/src/serverless_proxy/providers/__pycache__/fal_ai.cpython-312.pyc is excluded by !**/*.pyc
  • containers/livepeer-serverless-proxy/src/serverless_proxy/providers/__pycache__/gemini.cpython-312.pyc is excluded by !**/*.pyc
  • containers/livepeer-serverless-proxy/src/serverless_proxy/providers/__pycache__/replicate.cpython-312.pyc is excluded by !**/*.pyc
  • containers/livepeer-serverless-proxy/src/serverless_proxy/providers/__pycache__/runpod.cpython-312.pyc is excluded by !**/*.pyc
  • package-lock.json is excluded by !**/package-lock.json, !package-lock.json
📒 Files selected for processing (121)
  • .gitignore
  • apps/web-next/.env.local.example
  • apps/web-next/next.config.js
  • apps/web-next/playwright.config.ts
  • apps/web-next/playwright/.auth/admin.json
  • apps/web-next/playwright/.auth/user.json
  • apps/web-next/src/__tests__/api/orchestrator-leaderboard-global-dataset.test.ts
  • apps/web-next/src/__tests__/api/orchestrator-leaderboard.test.ts
  • apps/web-next/src/app/api/internal/bff-warm/route.ts
  • apps/web-next/src/app/api/v1/auth/providers/[providerSlug]/start/route.ts
  • apps/web-next/src/app/api/v1/gw/admin/templates/route.ts
  • apps/web-next/src/app/api/v1/gw/mcp/route.ts
  • apps/web-next/src/app/api/v1/orchestrator-leaderboard/audits/route.ts
  • apps/web-next/src/app/api/v1/orchestrator-leaderboard/dataset/config/route.ts
  • apps/web-next/src/app/api/v1/orchestrator-leaderboard/dataset/refresh/route.ts
  • apps/web-next/src/app/api/v1/orchestrator-leaderboard/dataset/route.ts
  • apps/web-next/src/app/api/v1/orchestrator-leaderboard/filters/route.ts
  • apps/web-next/src/app/api/v1/orchestrator-leaderboard/plans/[id]/results/route.ts
  • apps/web-next/src/app/api/v1/orchestrator-leaderboard/plans/[id]/route.ts
  • apps/web-next/src/app/api/v1/orchestrator-leaderboard/plans/refresh/route.ts
  • apps/web-next/src/app/api/v1/orchestrator-leaderboard/plans/route.ts
  • apps/web-next/src/app/api/v1/orchestrator-leaderboard/plans/seed/route.ts
  • apps/web-next/src/app/api/v1/orchestrator-leaderboard/rank/route.ts
  • apps/web-next/src/app/api/v1/orchestrator-leaderboard/sources/route.ts
  • apps/web-next/src/app/api/v1/plugin-publisher/upload/route.ts
  • apps/web-next/src/app/layout.tsx
  • apps/web-next/src/contexts/auth-context.tsx
  • apps/web-next/src/lib/api/auth.ts
  • apps/web-next/src/lib/email.ts
  • apps/web-next/src/lib/gateway/__tests__/transform.test.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/__tests__/cache.test.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/__tests__/evaluate-plan.test.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/__tests__/query.test.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/__tests__/ranking.test.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/__tests__/refresh.test.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/__tests__/resolver.test.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/__tests__/sources/naap-discover.test.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/__tests__/sources/naap-pricing.test.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/__tests__/sources/subgraph.test.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/__tests__/types-validation.test.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/cache.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/config.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/global-dataset.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/global-refresh.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/plans.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/query.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/ranking.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/refresh.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/resolver.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/sources/clickhouse.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/sources/index.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/sources/naap-discover.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/sources/naap-pricing.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/sources/subgraph.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/sources/types.ts
  • apps/web-next/src/lib/orchestrator-leaderboard/types.ts
  • apps/web-next/src/middleware.ts
  • apps/web-next/tests/orchestrator-leaderboard-sources.spec.ts
  • apps/web-next/tests/orchestrator-leaderboard.spec.ts
  • apps/web-next/vercel.json
  • bin/preview-plugin.sh
  • bin/seed-discovery-plans.ts
  • bin/seed-gateway-connector.ts
  • bin/start.sh
  • bin/sync-plugin-registry.ts
  • bin/vercel-build.sh
  • containers/livepeer-inference-adapter/Dockerfile
  • containers/livepeer-inference-adapter/src/livepeer_adapter/config.py
  • containers/livepeer-inference-adapter/src/livepeer_adapter/metering/__init__.py
  • containers/livepeer-inference-adapter/src/livepeer_adapter/metering/extractors.py
  • containers/livepeer-inference-adapter/src/livepeer_adapter/proxy.py
  • containers/livepeer-inference-adapter/src/livepeer_adapter/training_store.py
  • containers/livepeer-inference-adapter/tests/__init__.py
  • containers/livepeer-inference-adapter/tests/metering/__init__.py
  • containers/livepeer-inference-adapter/tests/metering/test_extractors.py
  • containers/livepeer-inference-adapter/tests/test_training_store.py
  • containers/livepeer-serverless-proxy/Dockerfile
  • containers/livepeer-serverless-proxy/src/serverless_proxy/providers/fal_ai.py
  • containers/livepeer-serverless-proxy/src/serverless_proxy/server.py
  • containers/livepeer-serverless-proxy/tests/__init__.py
  • containers/livepeer-serverless-proxy/tests/test_training.py
  • docker/init-schemas.sql
  • docs/experimental-plugin-preview.md
  • package.json
  • packages/database/prisma/schema.prisma
  • packages/database/src/plugin-discovery.ts
  • plugins/orchestrator-leaderboard/docs/api-reference.md
  • plugins/orchestrator-leaderboard/docs/data-sources.md
  • plugins/orchestrator-leaderboard/docs/for-ai.md
  • plugins/orchestrator-leaderboard/docs/how-to-guide.md
  • plugins/orchestrator-leaderboard/docs/openapi.yaml
  • plugins/orchestrator-leaderboard/examples/client-test.sh
  • plugins/orchestrator-leaderboard/examples/client-test.ts
  • plugins/orchestrator-leaderboard/frontend/e2e/leaderboard.spec.ts
  • plugins/orchestrator-leaderboard/frontend/package.json
  • plugins/orchestrator-leaderboard/frontend/playwright.config.ts
  • plugins/orchestrator-leaderboard/frontend/src/App.tsx
  • plugins/orchestrator-leaderboard/frontend/src/components/AdminSettings.tsx
  • plugins/orchestrator-leaderboard/frontend/src/components/EndpointGuide.tsx
  • plugins/orchestrator-leaderboard/frontend/src/components/TabNav.tsx
  • plugins/orchestrator-leaderboard/frontend/src/globals.css
  • plugins/orchestrator-leaderboard/frontend/src/hooks/useCapabilities.ts
  • plugins/orchestrator-leaderboard/frontend/src/hooks/useDatasetConfig.ts
  • plugins/orchestrator-leaderboard/frontend/src/hooks/useLeaderboard.ts
  • plugins/orchestrator-leaderboard/frontend/src/hooks/usePlanDetail.ts
  • plugins/orchestrator-leaderboard/frontend/src/hooks/usePlans.ts
  • plugins/orchestrator-leaderboard/frontend/src/lib/api.ts
  • plugins/orchestrator-leaderboard/frontend/src/mount.tsx
  • plugins/orchestrator-leaderboard/frontend/src/pages/LeaderboardPage.tsx
  • plugins/orchestrator-leaderboard/frontend/src/pages/PlanDetailPage.tsx
  • plugins/orchestrator-leaderboard/frontend/src/pages/PlansOverviewPage.tsx
  • plugins/orchestrator-leaderboard/frontend/tailwind.config.js
  • plugins/orchestrator-leaderboard/frontend/tsconfig.json
  • plugins/orchestrator-leaderboard/frontend/vite.config.ts
  • plugins/orchestrator-leaderboard/plugin.json
  • plugins/service-gateway/connectors/clickhouse-query.json
  • plugins/service-gateway/connectors/livepeer-subgraph.json
  • plugins/service-gateway/connectors/naap-discover.json
  • plugins/service-gateway/connectors/naap-pricing.json
  • plugins/service-gateway/docs/clickhouse-query-connector.md
  • plugins/service-gateway/docs/connector-catalog.md
💤 Files with no reviewable changes (1)
  • apps/web-next/next.config.js

data:
type: array
items: { $ref: "#/components/schemas/LeaderboardSource" }
"400": { $ref: "#/components/responses/ValidationError" }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
from pathlib import Path
import re

p = Path("plugins/orchestrator-leaderboard/docs/openapi.yaml")
text = p.read_text()

refs = set(re.findall(r'\$ref:\s*"`#/components/responses/`([^"]+)"', text))

components_block = re.search(r'components:\n(.*?)(?:\n  schemas:)', text, re.S)
defs = set(re.findall(r'^\s{4}([A-Za-z0-9_]+):\s*$', components_block.group(1), re.M)) if components_block else set()

missing = sorted(refs - defs)
print("Referenced responses:", sorted(refs))
print("Defined responses:", sorted(defs))
print("Missing response refs:", missing)

if missing:
    raise SystemExit(1)
PY

Repository: livepeer/naap

Length of output: 303


Fix unresolved $ref to ValidationError in updateSources 400 response.

Line 575 references #/components/responses/ValidationError, which is not defined in components.responses. The OpenAPI spec will fail validation.

Proposed fix
   responses:
         "200":
           description: Updated source list
           content:
             application/json:
               schema:
                 allOf:
                   - $ref: "`#/components/schemas/SuccessEnvelope`"
                   - type: object
                     properties:
                       data:
                         type: array
                         items: { $ref: "`#/components/schemas/LeaderboardSource`" }
-        "400": { $ref: "`#/components/responses/ValidationError`" }
+        "400":
+          description: Validation failure
+          content:
+            application/json:
+              schema: { $ref: "`#/components/schemas/ErrorEnvelope`" }
         "401": { $ref: "`#/components/responses/Unauthorized`" }
         "403": { $ref: "`#/components/responses/Forbidden`" }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/orchestrator-leaderboard/docs/openapi.yaml` at line 575, The 400
response for updateSources references a missing component
("`#/components/responses/ValidationError`"); add a proper response object named
ValidationError under components.responses (or change the $ref to an existing
response) so the OpenAPI spec validates; ensure the new
components.responses.ValidationError contains a descriptive description and a
JSON schema for the validation error payload consistent with other response
definitions.

@github-actions github-actions Bot added the needs-rebase Has merge conflicts label May 16, 2026
@github-actions
Copy link
Copy Markdown

⚠️ Merge conflict detected

This PR has conflicts with the base branch. Please rebase to resolve them:

git fetch origin
git rebase origin/main
# resolve conflicts, then:
git push --force-with-lease

The needs-rebase label will be removed automatically once the conflicts are resolved.

@github-actions github-actions Bot added needs-rebase Has merge conflicts and removed needs-rebase Has merge conflicts labels May 21, 2026
@github-actions github-actions Bot added needs-rebase Has merge conflicts and removed needs-rebase Has merge conflicts labels May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

has-migration Includes database migration needs-rebase Has merge conflicts scope/infra Infrastructure changes scope/packages Shared package changes scope/shell Shell app changes size/XL Extra large PR (500+ lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants