feat(alexandria): provider discovery through Search and billed execution through Scrape - #4621
feat(alexandria): provider discovery through Search and billed execution through Scrape#4621developersdigest wants to merge 9 commits into
Conversation
Build from main using Exchange tool contracts, Autumn credit holds, and one BullMQ execution job. Preserve team-scoped retries, provider agreements, budgets, and fail-closed handling of uncertain paid outcomes without a new database migration or billing batch ledger.
…billing rails Replace the dedicated BullMQ queue, QueueEvents wait, eight-phase state machine, worker-side semaphore and index-worker consumer with an inline request path guarded by a Redis idempotency record keyed by team and x-request-id (the charge id). The record is written before the provider call, so a crash is found as an unresolved request rather than re-executed; a completed request replays its response; a different payload under the same id answers 409 duplicate_request; a running one answers 409 request_in_flight. Billing now follows the monitor runner and scrape worker: lockCredits with a deterministic lock id, finalizeCreditsLock for the actual receipt (through the service, which already routes firebill settles), the ledger write through the existing billing queue with a deterministic job id instead of a direct bill_team_7 call, and a fire-and-forget usage report to Exchange with the existing billing-report retry semantics. Pre-execution refusals (authorization, quote, hold denied or skipped, Exchange 4xx) release the record so the same id can retry; only an outcome the Exchange may have executed is held for reconciliation. Provider authorization reads the Exchange catalog terms and the organization's access flags, the same gate the URL-routed Exchange scrape uses, instead of a raw query against an undeclared table and a terms digest nothing writes. The Exchange side has no /v1/provider-terms/requirements route. Controller: relay a failed single legacy call with the Exchange's status and code instead of success:true; accept boostConcurrency in __agentInterop and prefer its request id; log one requests row per execution with the External-Request-Id attribution; let wrap() format Zod errors; drop the redundant endpoint-restriction check. Search: one tools-only predicate shared by the credit middleware and the keyless projection (developer category still bills); www-normalised domain matching; combined tools capped at limit; developer result URLs included and image URLs excluded from domain matching; dead status/level fields removed. Tests cover replay without re-execution, duplicate-payload 409, denied and skipped holds releasing the claim, transient quote failures not cached, ambiguous Exchange outcomes held without settling or re-executing, definitive refusals releasing the hold, over-budget receipts, an unsettled confirm returning the answer without a ledger write, non-billable runs, in-flight versus abandoned records, and the legacy single-call error relay.
…findings - Authorize through Exchange /v1/provider-terms/requirements, which answers 404 for any unknown or hidden provider, so an unlisted provider never reaches the quote or /v1/retrieve. Agreements compare against the organization flags the URL-routed Exchange path already uses. Drops the /v1/providers catalog lookup, which lists only URL-routed providers, and restores lib/exchange.ts apart from the shared billing-report transport. - Reject a blank EXCHANGE_INTERNAL_SECRET, matching FIREBILL_SECRET. - Bound relayed provider error statuses (schema 100-999, controller 400-599, else 502) so res.status can never throw. - Log a requests row only when a provider execution was attempted, and keep the original scrape_id on replays. - Mark the ledger enqueue refundable only on the direct Autumn route; a firebill-routed charge stands pending reconciliation. - Live smoke (17 checks) passes against the local Exchange snapshot.
There was a problem hiding this comment.
All reported issues were addressed across 20 files
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
…queue failure, doc fixes - Compare the set of providers the Exchange answered for against the set requested, so duplicate entries cannot mask a missing provider. - When the ledger enqueue fails after retries on the direct Autumn route, refund the confirmed charge (same compensation as the scrape worker); on the firebill route the durable charge still stands pending reconciliation. - README: forced-ZDR refusal happens before any record exists; a release that does not land leaves an expiring hold, so a retry may briefly hold twice. - Tests: one table case per fail-closed refusal (authorization, quote outage, malformed quote, billing unconfigured, denied and skipped holds) and the enqueue-failure refund.
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
`domainTools: true` (default off) on an ordinary /v2/scrape attaches `data.tools` matched to the scraped page's domain, in the same shape Search returns, for teams with Exchange access and outside ZDR. Discovery is free and never fails the scrape. Also drops the remaining inline comments in the Alexandria files.
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
…ils; 403 for domainTools without access - Decide the refund route once, before the credit hold, and carry it to the ledger write instead of looking it up again after settlement. - recordLedgerUsage reports whether the enqueue landed; on failure the Exchange usage is left pending for reconciliation instead of confirmed, and the direct-Autumn charge is refunded (refundCredits logs its own failures; there is nothing further to catch). - URL scrape with domainTools now returns the same 403 Search does for a team without exchangeRetrieve or under ZDR, instead of silently omitting tools.
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
…alexandria POST /v2/scrape now takes `alexandria` (one call or 1..10) and returns `data.alexandria`; the old `exchange` key is an unrecognized-key 400. Search and scrape 403 messages say "alexandria source". Legacy /exchange/retrieve is unchanged.
Summary
Alexandria provider tools on the existing Search and Scrape surfaces, following the frozen Firecrawl/Exchange interface.
POST /v2/searchwithsources: ["alexandria"](optionallydomainTools) returns tool contracts indata.tools, up tolimitper discovery source. Discovery is free and never counts towardcreditsUsedor the keyless reservation; web results bill as before.POST /v2/scrapewith aurlacceptsdomainTools: true(default off) and addsdata.toolsmatched to the scraped page's domain, same shape as Search, free, only for teams with Exchange access.POST /v2/scrapewith analexandriabody executes one to ten provider calls and returnsdata.alexandriawithdata.creditsCost./exchange/retrieveshares the same path; its single-call shape relays a provider error with the Exchange's status andcode.Billing
Execution runs inline in the request on existing rails: authorize through Exchange
/v1/provider-terms/requirements(404 for unknown providers, so nothing unlisted reaches a quote or execution) against the organization'sorganizationDataSourceAccessflags, quote, reserve with Autumn (lockCredits, lockalexandria_<chargeId>), execute/v1/retrievewith budget and deadline headers, settle the receipt withfinalizeCreditsLock, enqueue the ledger write on the billing queue with a deterministic job id, and report to/v1/usage-events/billing. Paid requests fail closed when authorization, quote, reservation, orUSE_DB_AUTHENTICATIONis unavailable. No new queue, worker, or migration.The charge id is
sha256(teamId, x-request-id). One Redis record per charge id (7 days) gives: replay of a completed request with its originalscrape_idand no second execution, 409duplicate_requestfor a different payload, 409request_in_flightwhile running, release of the record on any pre-execution refusal so the id can retry, and 503request_unresolvedheld for reconciliation when the Exchange may have executed (5xx, timeout, malformed or over-budget receipt, crash after the point of no return). The Exchange has no request-id idempotency, so unresolved requests are never re-executed automatically. A failed ledger commit is refunded at Autumn on the direct route; on the firebill route the durable charge stands pending reconciliation.Details in
apps/api/src/services/alexandria/README.md.Verification
services/alexandria(retrieve, access), the controller, andsearch.domainTools: truereturns 21 World Bank tools with domain provenance and none without the flag, including one usage row per execution,confirmedbilling status on the Exchange, same-id and cross-route replay with a stablescrape_id, concurrent same-id dedupe, 409 codes, unknown-provider refusal before any quote, seeded unresolved and in-flight records, and fail-closed paid execution without Autumn.Not yet validated
Autumn is not configured locally, so every paid path stops at the reservation. The confirm, the release after an Exchange 4xx, and the ledger enqueue are mock-tested only. One paid call against the Autumn sandbox is the remaining check before merge.
Exchange dependencies
/v1/retrieve/quote,x-exchange-max-credits, and/v1/skills/resolveare on Exchangecodex/contextual-provider-tools;/v1/provider-terms/requirementsis in the Exchangeprovider-termsworktree (uncommitted there as of 2026-09-11). Neither is on Exchangemain./v1/usage-events/billingis on main and needs a non-blankEXCHANGE_INTERNAL_SECRET.Summary by cubic
Adds Alexandria provider tools to Search and Scrape: the
alexandriasource /domainToolson Search, anddomainToolson URL scrape, attach free tool contracts todata.tools, while Scrape'salexandriabody executes 1–10 paid provider calls and returnsdata.alexandria. All are gated by theexchangeRetrieveflag;domainToolswithout access (or under ZDR) returns 403 on both surfaces, and the legacy/exchange/retrieveroute shares the Scrape handler. The previously usedexchangebody key is rejected as unrecognized.Billing and idempotency
sha256(teamId, x-request-id), so sendx-request-idon every paid request or a retry is a new charge.USE_DB_AUTHENTICATIONis unavailable.Rollout
codex/contextual-provider-toolsandprovider-termsworktrees, not onmain.EXCHANGE_INTERNAL_SECRET(non-blank) for usage-event billing reports.Written for commit 47ef264. Summary will update on new commits.