/** * RouterTransport, the 0G Router (OpenAI-compatible gateway) completion * transport. The opt-in second "door" beside DirectTransport. * * Auth is a `Bearer sk-` header (content-independent, unlike Direct's * per-request EIP-712 voucher). The sk- is minted headlessly from the agent * privkey via {@link headlessMint} (router-auth.ts), passed in pre-minted for * testability, or lazily minted on {@link init} via the injected `mintApiKey` * closure. There is no serving-broker ledger ({@link getLedger} returns null); * Router billing is a prepaid Payment Layer credit funded separately (Phase 5). */ import type { Address } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { type AnimaNetwork, NETWORK_CHAIN_ID } from '../config' import type { BrokerLedgerLike } from '../economy/auto-topup' import { getRouterCreditBalance } from '../economy/router-payment' import type { CompletionTransport, ListedService } from './completion-transport' import { DEFAULT_ROUTER_BASE_URL, RouterAuthError, headlessMint } from './router-auth' export interface RouterTransportOpts { /** Router model id sent in the request body (e.g. `minimax-m3`). */ modelId: string /** OpenAI-compatible base URL (no trailing slash needed). Default router-api.0g.ai/v1. */ baseUrl?: string /** Pre-minted inference key (`sk-...`). When absent, {@link init} calls {@link mintApiKey}. */ apiKey?: string /** * Minter, invoked in {@link init} when no `apiKey` was supplied, AND in * {@link reprovision} to re-mint after an auth failure (even when an apiKey * was injected, that becomes the fast-path, the minter the recovery path). */ mintApiKey?: () => Promise /** Clears any process-level mint memo so {@link reprovision} mints fresh. */ resetMint?: () => void /** Network + account for {@link getLedgerBalance} (reads Payment Layer credit). */ network?: AnimaNetwork account?: Address /** Payment Layer override (defaults per network). */ paymentLayer?: Address } /** Router rejected the completion because the wallet JWT / key is expired or invalid (401/403). */ export class RouterAuthExpiredError extends Error { constructor(body: string) { super( `Router rejected the request (auth expired or invalid). Re-mint with a fresh SIWE sign-in. Detail: ${body.slice(0, 200)}`, ) this.name = 'RouterAuthExpiredError' } } /** Router rejected the completion for insufficient prepaid credit (402). */ export class RouterCreditError extends Error { constructor(body: string) { super( `Router credit exhausted. Top up the Payment Layer with: anima topup --router 1. Detail: ${body.slice(0, 200)}`, ) this.name = 'RouterCreditError' } } export class RouterTransport implements CompletionTransport { private sk: string | null private readonly _baseUrl: string private readonly _modelId: string private readonly minter?: () => Promise private readonly resetMint?: () => void private readonly network?: AnimaNetwork private readonly account?: Address private readonly paymentLayer?: Address /** MiniMax-M3 (and other Router models) emit CoT inline in content. */ readonly stripInlineThink = true constructor(opts: RouterTransportOpts) { this._baseUrl = (opts.baseUrl ?? DEFAULT_ROUTER_BASE_URL).replace(/\/+$/, '') this._modelId = opts.modelId this.sk = opts.apiKey ?? null this.minter = opts.mintApiKey this.resetMint = opts.resetMint this.network = opts.network this.account = opts.account this.paymentLayer = opts.paymentLayer } async init(): Promise { if (this.sk) return if (!this.minter) { throw new RouterAuthError('init', 'no api key supplied and no minter configured') } this.sk = await this.minter() } /** * Called by the brain on an HTTP 401/403: the current sk- was rejected * (expired/revoked, or a stale disk-cached key). Drop it, bust the process * mint memo, and re-mint a fresh sk- via SIWE. Returns true if a new key was * minted (so the brain retries once), false if there is no minter to fall * back to (a bare injected apiKey with no privkey). */ async reprovision(): Promise { if (!this.minter) return false this.sk = null this.resetMint?.() try { this.sk = await this.minter() return true } catch { return false } } get baseUrl(): string { return this._baseUrl } get modelId(): string { return this._modelId } async authHeaders(_lastContent: string): Promise> { if (!this.sk) { throw new Error('RouterTransport not initialized; call init() first.') } // Bearer auth is content-independent, `_lastContent` is ignored (it only // matters for Direct's per-request EIP-712 voucher binding). return { Authorization: `Bearer ${this.sk}` } } mapHttpError(status: number, body: string): Error | null { if (status === 401 || status === 403) return new RouterAuthExpiredError(body) // Credit exhaustion: a 402, or a 4xx billing response whose body explicitly // says the balance/credit is insufficient. The body match is scoped to 4xx // (and the alternatives tightened to `insufficient[_\s-]*(balance|credit)`) // so a transient 5xx outage body that incidentally contains these words is // NOT misclassified as a permanent credit error, which would wrongly tell // the operator to deposit NON-recoverable credit to fix a server outage. const insufficient = /insufficient[_\s-]*balance|insufficient[_\s-]*credit/i.test(body) if (status === 402 || (status >= 400 && status < 500 && insufficient)) { return new RouterCreditError(body) } return null } getLedger(): BrokerLedgerLike | null { // No serving-broker ledger; Router billing is the Payment Layer credit // vault, funded out-of-band (Phase 5). Returning null keeps the Direct // AutoTopupManager from being wired for a Router agent (build-runtime gate). return null } async getLedgerBalance(): Promise { // The Router analog of the compute-ledger balance: the prepaid Payment // Layer credit (balanceOf the agent account), in 0G. Requires network + // account to have been provided; null otherwise. if (!this.network || !this.account) return null return getRouterCreditBalance({ network: this.network, account: this.account, paymentLayer: this.paymentLayer, }) } async listServices(): Promise { // Public catalog, no auth required. try { const res = await fetch(`${this._baseUrl}/models`) if (!res.ok) return [] const json = (await res.json()) as { data?: Array<{ id?: string; [k: string]: unknown }> } const data = Array.isArray(json.data) ? json.data : [] return data .filter((m): m is { id: string } => typeof m.id === 'string') .map(m => ({ provider: 'router', model: m.id, serviceType: 'chatbot', ...m })) } catch { return [] } } } /** * Process-level memo of headless-mint results, keyed by `${privkey}:${baseUrl}`. * The main brain and every delegate sub-brain on the same agent share ONE sk-, * so a single process never re-runs the SIWE handshake per construction (which * would burn latency and risk the Router's per-account rate limit). In-memory * only, a daemon restart re-mints once. Stores the promise so concurrent * constructions await the same in-flight mint. */ const mintMemo = new Map>() /** Test-only: clear the process mint memo so each case starts cold. */ export function __clearRouterMintMemo(): void { mintMemo.clear() } /** * Construct a RouterTransport from config + the agent privkey. The single * factory used by every brain-construction site (chat.tsx main + delegate, * build-runtime main + delegate) so the providerKind branch is identical * everywhere. * * When `apiKey` is supplied (the CLI resolves a disk-sealed sk- via * `router-secrets`), it is used directly. Otherwise `init()` mints a fresh sk- * headlessly via SIWE on the agent privkey, memoized per process so delegates * and restarts-within-a-process don't re-handshake. `model` is required. */ export function buildRouterTransport(opts: { network: AnimaNetwork privkeyHex: `0x${string}` model: string | null router?: { baseUrl?: string } /** Pre-resolved inference key (`sk-...`). When absent, init() mints (memoized). */ apiKey?: string /** Label prefix for minted keys (e.g. the agent subname). */ keyName?: string }): RouterTransport { if (!opts.model) { throw new Error('Router brain requires brain.model (the Router model id, e.g. minimax-m3).') } const baseUrl = opts.router?.baseUrl ?? DEFAULT_ROUTER_BASE_URL const chainId = NETWORK_CHAIN_ID[opts.network] const memoKey = `${opts.privkeyHex}:${baseUrl}` // The minter is ALWAYS wired (memoized), even when an apiKey fast-path is // supplied, so reprovision() can re-mint a fresh sk- on a 401/403 (e.g. a // stale disk-cached key). apiKey just lets init() skip the first handshake. const mint = (): Promise => { let pending = mintMemo.get(memoKey) if (!pending) { pending = headlessMint({ baseUrl, privkeyHex: opts.privkeyHex, chainId, keyName: opts.keyName, }) .then(r => r.sk) .catch(err => { // Don't cache a failed mint, let the next call retry. mintMemo.delete(memoKey) throw err }) mintMemo.set(memoKey, pending) } return pending } return new RouterTransport({ modelId: opts.model, baseUrl, network: opts.network, account: privateKeyToAccount(opts.privkeyHex).address, apiKey: opts.apiKey, mintApiKey: mint, resetMint: () => mintMemo.delete(memoKey), }) }