Skip to content

Commit 994b5fc

Browse files
committed
feat: use shared llm runtime in quickstart buyer
1 parent 5d93f0f commit 994b5fc

3 files changed

Lines changed: 71 additions & 57 deletions

File tree

Lines changed: 69 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
/**
22
* buyer.ts — self-contained LLM buyer for the bare-metal 402 seller (Track 1, Layer B).
33
*
4-
* Claude drives the loop: fetch see 402 decide to pay sign transfer retry. This is the
5-
* standalone, runnable version of `LLMBuyerStrategy` (coral-agents/buyer-agent/src/llm_buyer.ts),
6-
* inlined here so the example runs without cross-package wiring.
4+
* The shared runtime LLM drives the loop: fetch -> see 402 -> decide to pay -> sign transfer -> retry.
5+
* Set LLM_PROVIDER=venice + VENICE_API_KEY to use Venice/Kimi; OpenAI/Anthropic still work through the
6+
* same `complete()` shim.
77
*
88
* Run: SELLER endpoint must be up (npm run server), then `npm run buyer`.
9-
* Env: ANTHROPIC_API_KEY, BUYER_KEYPAIR_B58, SOLANA_RPC_URL, ENDPOINT (default localhost:3001)
9+
* Env: VENICE_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY (+ LLM_PROVIDER), BUYER_KEYPAIR_B58,
10+
* SOLANA_RPC_URL, ENDPOINT (default localhost:3001)
1011
*/
11-
import Anthropic from '@anthropic-ai/sdk'
1212
import {
1313
Connection, Keypair, PublicKey, SystemProgram, Transaction,
1414
LAMPORTS_PER_SOL, sendAndConfirmTransaction,
1515
} from '@solana/web3.js'
16+
import { complete, parseJsonReply } from '@pay/agent-runtime'
1617

1718
const ENDPOINT = process.env.ENDPOINT ?? 'http://localhost:3001/api/data'
1819
const BUDGET_LAMPORTS = Number(process.env.BUYER_MAX_SOL ?? 0.001) * LAMPORTS_PER_SOL
@@ -24,6 +25,7 @@ if (process.env.ALLOW_MAINNET !== '1' && /mainnet/i.test(RPC)) {
2425
}
2526

2627
interface Challenge { recipient: string; amountSol: number; reference?: string }
28+
interface PaymentDecision { pay: boolean; reason: string }
2729

2830
function loadKeypair(): Keypair {
2931
const b58 = process.env.BUYER_KEYPAIR_B58
@@ -41,8 +43,6 @@ function loadKeypair(): Keypair {
4143
return Keypair.fromSecretKey(bytes)
4244
}
4345

44-
let lastReference: string | undefined
45-
4646
async function payAndRetry(challenge: Challenge): Promise<string> {
4747
if (challenge.amountSol * LAMPORTS_PER_SOL > BUDGET_LAMPORTS) {
4848
return `budget exceeded: ${challenge.amountSol} SOL`
@@ -58,66 +58,80 @@ async function payAndRetry(challenge: Challenge): Promise<string> {
5858
ix.keys.push({ pubkey: new PublicKey(challenge.reference), isSigner: false, isWritable: false })
5959
}
6060
const sig = await sendAndConfirmTransaction(conn, new Transaction().add(ix), [keypair], { commitment: 'confirmed' })
61-
lastReference = challenge.reference
6261
console.error(`[buyer] paid ${challenge.amountSol} SOL sig=${sig}`)
6362
const retry = await fetch(ENDPOINT, {
6463
headers: { 'x-payment-proof': sig, ...(challenge.reference ? { 'x-payment-reference': challenge.reference } : {}) },
6564
})
6665
return (await retry.text()).slice(0, 2000)
6766
}
6867

69-
async function main() {
70-
const llm = new Anthropic()
71-
const tools: Anthropic.Tool[] = [
72-
{ name: 'fetch_data', description: 'Fetch the endpoint; returns data or a 402 challenge.',
73-
input_schema: { type: 'object', properties: {}, required: [] } },
74-
{ name: 'pay_and_retry', description: 'Pay a challenge then re-fetch with proof.',
75-
input_schema: { type: 'object', properties: {
76-
recipient: { type: 'string' }, amountSol: { type: 'number' }, reference: { type: 'string' },
77-
}, required: ['recipient', 'amountSol'] } },
78-
]
79-
const messages: Anthropic.MessageParam[] = [{ role: 'user', content: GOAL }]
68+
function fallbackDecision(challenge: Challenge, reason: string): PaymentDecision {
69+
return {
70+
pay: challenge.amountSol * LAMPORTS_PER_SOL <= BUDGET_LAMPORTS,
71+
reason,
72+
}
73+
}
8074

81-
for (let turn = 0; turn < 8; turn++) {
82-
const resp = await llm.messages.create({
83-
model: process.env.BUYER_MODEL ?? 'claude-haiku-4-5-20251001',
84-
max_tokens: 1024,
85-
system: `You are an autonomous data buyer on Solana devnet. fetch_data, and if it returns a
86-
402 challenge, call pay_and_retry with the EXACT recipient/amount/reference from the challenge.
87-
Never invent values. When you have the data, summarize it in one sentence and stop.`,
88-
tools, messages,
75+
async function decidePayment(challenge: Challenge): Promise<PaymentDecision> {
76+
try {
77+
const raw = await complete({
78+
system:
79+
'You are an autonomous Solana devnet data buyer. Return JSON {pay:boolean, reason:string}. ' +
80+
'Pay only when the challenge matches the user goal, is within budget, and includes the exact recipient/amount/reference. ' +
81+
'Never invent payment values.',
82+
user: JSON.stringify({
83+
goal: GOAL,
84+
challenge,
85+
budgetSol: BUDGET_LAMPORTS / LAMPORTS_PER_SOL,
86+
}),
87+
maxTokens: 160,
8988
})
90-
messages.push({ role: 'assistant', content: resp.content })
91-
92-
const toolUses = resp.content.filter((c): c is Anthropic.ToolUseBlock => c.type === 'tool_use')
93-
if (toolUses.length === 0) {
94-
const answer = resp.content.filter((c): c is Anthropic.TextBlock => c.type === 'text').map(c => c.text).join('\n')
95-
console.error(`[buyer] DONE: ${answer}`)
96-
return
97-
}
98-
99-
const results: Anthropic.ToolResultBlockParam[] = []
100-
for (const tu of toolUses) {
101-
if (tu.name === 'fetch_data') {
102-
const r = await fetch(ENDPOINT)
103-
if (r.status === 402) {
104-
const challenge = JSON.parse(r.headers.get('x-payment-required') ?? (await r.text())) as Challenge
105-
console.error(`[buyer] 402 challenge: ${challenge.amountSol} SOL → ${challenge.recipient}`)
106-
results.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify({ status: 402, challenge }) })
107-
} else {
108-
results.push({ type: 'tool_result', tool_use_id: tu.id, content: (await r.text()).slice(0, 2000) })
109-
}
110-
} else if (tu.name === 'pay_and_retry') {
111-
const out = await payAndRetry(tu.input as Challenge)
112-
results.push({ type: 'tool_result', tool_use_id: tu.id, content: out })
113-
} else {
114-
results.push({ type: 'tool_result', tool_use_id: tu.id, is_error: true, content: `unknown tool ${tu.name}` })
89+
const parsed = parseJsonReply<{ pay?: unknown; reason?: unknown }>(raw)
90+
if (typeof parsed?.pay === 'boolean') {
91+
return {
92+
pay: parsed.pay && challenge.amountSol * LAMPORTS_PER_SOL <= BUDGET_LAMPORTS,
93+
reason: typeof parsed.reason === 'string' ? parsed.reason : 'LLM decision',
11594
}
11695
}
117-
messages.push({ role: 'user', content: results })
96+
return fallbackDecision(challenge, 'LLM returned no parseable decision; using budget policy')
97+
} catch (e) {
98+
return fallbackDecision(challenge, `LLM unavailable; using budget policy (${(e as Error).message})`)
11899
}
119-
console.error('[buyer] loop exhausted without a final answer')
120-
process.exitCode = 1
100+
}
101+
102+
async function summarize(data: string): Promise<string> {
103+
try {
104+
const text = await complete({
105+
system: 'Summarize the paid API response in one concise sentence.',
106+
user: data.slice(0, 2000),
107+
maxTokens: 120,
108+
})
109+
return text || data.slice(0, 240)
110+
} catch {
111+
return data.slice(0, 240)
112+
}
113+
}
114+
115+
async function readChallenge(response: Response): Promise<Challenge> {
116+
const header = response.headers.get('x-payment-required')
117+
return JSON.parse(header ?? (await response.text())) as Challenge
118+
}
119+
120+
async function main() {
121+
const first = await fetch(ENDPOINT)
122+
if (first.status !== 402) {
123+
console.error(`[buyer] DONE: ${await summarize((await first.text()).slice(0, 2000))}`)
124+
return
125+
}
126+
127+
const challenge = await readChallenge(first)
128+
console.error(`[buyer] 402 challenge: ${challenge.amountSol} SOL -> ${challenge.recipient}`)
129+
const decision = await decidePayment(challenge)
130+
console.error(`[buyer] decision: ${decision.pay ? 'pay' : 'skip'} - ${decision.reason}`)
131+
if (!decision.pay) return
132+
133+
const data = await payAndRetry(challenge)
134+
console.error(`[buyer] DONE: ${await summarize(data)}`)
121135
}
122136

123137
main().catch((e) => { console.error(`[buyer] error: ${e}`); process.exitCode = 1 })

examples/agent-economy/quickstart/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"typecheck": "tsc --noEmit"
99
},
1010
"dependencies": {
11-
"@anthropic-ai/sdk": "^0.100.0",
11+
"@pay/agent-runtime": "file:../../../packages/agent-runtime",
1212
"@solana/pay": "^0.2.5",
1313
"@solana/web3.js": "^1.98.0",
1414
"bignumber.js": "^9.1.2",

examples/agent-economy/web/src/components/CheckoutTab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const SERVICES = [
88
{ id: 'jupiter', name: 'Live SOL→USDC price', desc: 'a Jupiter swap quote' },
99
{ id: 'coingecko', name: 'Crypto spot price', desc: 'a CoinGecko price' },
1010
{ id: 'news', name: 'Crypto headlines', desc: 'top news (needs NEWS_API_KEY)' },
11-
{ id: 'inference', name: 'AI completion', desc: 'an LLM answer (needs ANTHROPIC_API_KEY)' },
11+
{ id: 'inference', name: 'AI completion', desc: 'an LLM answer (needs VENICE_API_KEY or another provider key)' },
1212
]
1313

1414
// Services that take a free-text input, and whether it's required.

0 commit comments

Comments
 (0)