/** * DirectTransport, the 0G Compute serving-broker completion transport. * * Upstream `@0glabs/0g-serving-broker` requires an ethers Wallet/Signer, so * this file (with `brain/ledger.ts` and `storage/og.ts`) is one of the few * places anima imports ethers. Callers pass a raw privkey hex + RPC URL; the * transport builds the ethers Wallet internally so the rest of the brain stays * viem-first. This was extracted verbatim from `og-compute.ts` so the Direct * path is byte-for-byte unchanged: same broker init, same per-request EIP-712 * voucher headers, same endpoint, same ledger-insufficient error mapping. */ import { createZGComputeNetworkBroker } from '@0glabs/0g-serving-broker' import { JsonRpcProvider, Wallet } from 'ethers' import type { BrokerLedgerLike } from '../economy/auto-topup' import type { CompletionTransport, ListedService } from './completion-transport' export type Broker = Awaited> export interface DirectTransportOpts { privkeyHex: `0x${string}` rpcUrl: string providerAddress: string } export class DirectTransport implements CompletionTransport { private broker: Broker | null = null private endpoint: string | null = null private model: string | null = null private readonly wallet: Wallet private readonly providerAddress: string constructor(opts: DirectTransportOpts) { const provider = new JsonRpcProvider(opts.rpcUrl) this.wallet = new Wallet(opts.privkeyHex, provider) this.providerAddress = opts.providerAddress } async init(): Promise { if (this.broker) return // biome-ignore lint/suspicious/noExplicitAny: broker signer shape mismatch is upstream this.broker = await createZGComputeNetworkBroker(this.wallet as any) const meta = await this.broker.inference.getServiceMetadata(this.providerAddress) this.endpoint = meta.endpoint this.model = meta.model try { await this.broker.inference.acknowledgeProviderSigner(this.providerAddress) } catch { // Already acknowledged, broker throws on repeat. Safe to ignore. } } get baseUrl(): string { if (this.endpoint == null) { throw new Error('DirectTransport not initialized; call init() first.') } return this.endpoint } get modelId(): string { if (this.model == null) { throw new Error('DirectTransport not initialized; call init() first.') } return this.model } async authHeaders(lastContent: string): Promise> { if (!this.broker) { throw new Error('DirectTransport not initialized; call init() first.') } // ServingRequestHeaders is a string→string map but lacks an index // signature; it's only ever spread into the fetch headers, so the cast is // safe (matches the original spread in og-compute.ts). const headers = await this.broker.inference.getRequestHeaders(this.providerAddress, lastContent) return headers as unknown as Record } mapHttpError(_status: number, body: string): Error | null { // Translate the 0G provider's "insufficient balance" HTTP 400 into a typed // error so dispatchers (TUI + TG) can render an actionable message // ("anima topup --compute N") instead of the raw HTTP body. return parseLedgerInsufficientError(body, this.providerAddress) } getLedger(): BrokerLedgerLike | null { return this.broker?.ledger ?? null } /** Live ledger balance in 0G (the prepaid compute credit). null on failure. */ async getLedgerBalance(): Promise { if (!this.broker) await this.init() try { const ledger = await this.broker!.ledger.getLedger() // broker returns BigNumber-like; convert via formatEther fallback const totalRaw = (ledger as { totalBalance?: bigint | string }).totalBalance const lockedRaw = (ledger as { locked?: bigint | string }).locked if (totalRaw == null) return null const toBigInt = (v: bigint | string): bigint => typeof v === 'bigint' ? v : BigInt(v.toString()) const total = toBigInt(totalRaw) const locked = lockedRaw != null ? toBigInt(lockedRaw) : 0n const available = total - locked // available is in wei (18 decimals) return Number(available) / 1e18 } catch { return null } } async listServices(): Promise { if (!this.broker) await this.init() return (await this.broker!.inference.listService()) as unknown as ListedService[] } /** Fetch live catalog without requiring a specific provider to be pre-set. */ static async listServicesFor(args: { privkeyHex: `0x${string}` rpcUrl: string }): Promise { const provider = new JsonRpcProvider(args.rpcUrl) const wallet = new Wallet(args.privkeyHex, provider) // biome-ignore lint/suspicious/noExplicitAny: broker signer shape mismatch is upstream const broker = await createZGComputeNetworkBroker(wallet as any) return (await broker.inference.listService()) as unknown as ListedService[] } } /** * Thrown when the 0G provider rejects the request with HTTP 400 because the * agent's compute ledger sub-account for that provider is below the minimum * locked balance. Caught by TUI + TG dispatchers to render a topup hint * instead of the raw HTTP body. * * The provider error message pattern (May 2026): * "Provider proxy: handle proxied service, validate request: * insufficient balance: your locked balance is X 0G, but the required * minimum is Y 0G (breakdown: minimum reserve A 0G + unsettled fees * B 0G + current request fee C 0G). Please add more" */ export class LedgerInsufficientError extends Error { /** Locked balance in the provider sub-account, in 0G (string for precision). */ readonly availableOg: string /** Required minimum, in 0G. */ readonly requiredOg: string /** required − available, in 0G. */ readonly shortfallOg: string /** Provider EOA the agent's brain is configured to use. */ readonly providerAddress: string constructor(opts: { availableOg: string requiredOg: string shortfallOg: string providerAddress: string }) { super( `Compute ledger sub-account short by ${opts.shortfallOg} 0G (provider ${opts.providerAddress.slice(0, 10)}…, locked ${opts.availableOg} of ${opts.requiredOg} required). Topup with: anima topup --compute 2`, ) this.name = 'LedgerInsufficientError' this.availableOg = opts.availableOg this.requiredOg = opts.requiredOg this.shortfallOg = opts.shortfallOg this.providerAddress = opts.providerAddress } } const LEDGER_ERROR_RE = /insufficient balance: your locked balance is ([\d.]+)\s*0G, but the required minimum is ([\d.]+)\s*0G/i export function parseLedgerInsufficientError( body: string, providerAddress: string, ): LedgerInsufficientError | null { if (!body) return null const m = body.match(LEDGER_ERROR_RE) if (!m || !m[1] || !m[2]) return null const available = Number.parseFloat(m[1]) const required = Number.parseFloat(m[2]) if (!Number.isFinite(available) || !Number.isFinite(required)) return null const shortfall = Math.max(0, required - available) return new LedgerInsufficientError({ availableOg: m[1], requiredOg: m[2], shortfallOg: shortfall.toFixed(6), providerAddress, }) }