@paytaca/opencode-plugin 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
- export declare const MCP_SERVER_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca MCP Server\n *\n * Registers Paytaca tools with opencode so the assistant can work with real\n * data instead of guessing. Two groups:\n * - Paytaca AI account (backend): credits, models, plan pricing\n * - Paytaca wallet (paytaca CLI): balance, transactions, receiving address,\n * token holdings, and sending funds\n *\n * The send tool moves real funds \u2014 opencode is configured (via the plugin's\n * config hook) to require explicit user approval before it runs.\n *\n * Loaded automatically via the plugin's config hook (cfg.mcp['paytaca']).\n * Uses only Node.js built-in modules.\n */\n\nconst http = require('http');\nconst https = require('https');\nconst { spawn } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\nconst os = require('os');\n\nconst CONFIG_DIR = process.env.PAYTACA_CONFIG_DIR || path.join(os.homedir(), '.opencode-paytaca');\nconst PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';\nconst DEFAULT_BACKEND = process.env.PAYTACA_BACKEND_URL || 'https://api.paytaca.ai';\n\nconst PROTOCOL_VERSION = '2025-06-18';\n\n// Logging setup\nconst LOG_FILE = path.join(CONFIG_DIR, 'mcp.log');\nconst logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });\nfunction log(message) {\n const timestamp = new Date().toISOString();\n logStream.write(timestamp + ' [MCP] ' + message + '\\n');\n}\n\n// Load fresh config on every call so walletHash/backendUrl never go stale\nfunction loadConfig() {\n try {\n return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'config.json'), 'utf8'));\n } catch (e) {\n return {};\n }\n}\n\nlet BACKEND_URL = DEFAULT_BACKEND;\nlet WALLET_HASH = '';\nfunction refreshConfig() {\n const cfg = loadConfig();\n BACKEND_URL = process.env.PAYTACA_BACKEND_URL || cfg.backendUrl || DEFAULT_BACKEND;\n WALLET_HASH = cfg.walletHash || '';\n}\n\n// Fetch a JSON payload over HTTP(S)\nfunction getJson(url, headers) {\n return new Promise((resolve, reject) => {\n let u;\n try {\n u = new URL(url);\n } catch (e) {\n return reject(new Error('Invalid URL: ' + url));\n }\n const requester = u.protocol === 'https:' ? https : http;\n const req = requester.get({\n hostname: u.hostname,\n port: u.port || (u.protocol === 'https:' ? 443 : 80),\n path: u.pathname + u.search,\n headers: headers || {},\n }, (res) => {\n let data = '';\n res.on('data', (chunk) => { data += chunk; });\n res.on('end', () => {\n if (res.statusCode >= 400) {\n reject(new Error('HTTP ' + res.statusCode + ': ' + data.substring(0, 200)));\n return;\n }\n try {\n resolve(JSON.parse(data));\n } catch (e) {\n reject(new Error('Invalid JSON response'));\n }\n });\n });\n req.on('error', reject);\n req.setTimeout(15000, () => {\n req.destroy();\n reject(new Error('Request timed out'));\n });\n });\n}\n\n// Run a shell command with a timeout\nfunction runCommand(cmd, args, timeoutMs) {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { shell: false });\n let stdout = '';\n let stderr = '';\n let settled = false;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try { child.kill(); } catch (e) {}\n reject(new Error('Command timed out'));\n }, timeoutMs || 15000);\n child.stdout.on('data', (d) => { stdout += d.toString(); });\n child.stderr.on('data', (d) => { stderr += d.toString(); });\n child.on('close', (code) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n if (code === 0) resolve(stdout.trim());\n else reject(new Error(stderr.trim() || 'Command exited with code ' + code));\n });\n child.on('error', (err) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n reject(err);\n });\n });\n}\n\n// Format seconds as MM:SS or HH:MM:SS\nfunction formatDuration(totalSeconds) {\n const hours = Math.floor(totalSeconds / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const secs = totalSeconds % 60;\n if (hours > 0) {\n return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0');\n }\n return minutes + ':' + String(secs).padStart(2, '0');\n}\n\n// Remaining time credits per model session\nasync function getCredits() {\n const data = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(data.sessions) ? data.sessions : [];\n const active = sessions.filter((s) => s.time_remaining_seconds > 0 && s.model_active);\n const inactive = sessions.filter((s) => s.time_remaining_seconds > 0 && !s.model_active);\n const parts = [];\n if (active.length > 0) {\n parts.push('Active time credits:');\n for (const s of active) {\n const total = formatDuration(s.time_credits_seconds);\n const remaining = formatDuration(s.time_remaining_seconds);\n const used = formatDuration(s.time_used_seconds);\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + remaining + ' remaining of ' + total + ' (' + used + ' used)');\n }\n }\n if (inactive.length > 0) {\n parts.push('');\n parts.push('Inactive models (credits but session not active):');\n for (const s of inactive) {\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + formatDuration(s.time_remaining_seconds) + ' remaining');\n }\n }\n if (parts.length === 0) {\n return 'No active time credits.';\n }\n return parts.join('\\n');\n}\n\n// Wallet BCH balance via the paytaca CLI\nasync function getBalance() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = out.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (match) {\n return 'Wallet balance: ' + match[1] + ' BCH.';\n }\n return 'Could not parse balance. Raw output:\\n' + out.split('\\n').slice(0, 5).join('\\n');\n}\n\n// List all available models\nasync function getModels() {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const lines = ['Available models:'];\n for (const m of models) {\n let line = m.id || 'unknown';\n if (m.display_name && m.display_name !== m.id) line += ' (' + m.display_name + ')';\n if (m.tier) line += ' [' + m.tier + ']';\n lines.push('- ' + line);\n }\n return lines.join('\\n');\n}\n\n// Plan pricing grouped by tier, optionally filtered to one model\nasync function getPlans(filterModel) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n let models = Array.isArray(data.models) ? data.models : [];\n if (filterModel) {\n const f = String(filterModel).toLowerCase();\n models = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n }\n const groups = { budget: [], premium: [], frontier: [], other: [] };\n for (const m of models) {\n const key = String(m.tier || '').toLowerCase();\n const groupKey = (key === 'budget' || key === 'premium' || key === 'frontier') ? key : 'other';\n groups[groupKey].push(m);\n }\n const lines = ['Paytaca AI \u2014 Model Pricing'];\n const order = [\n { key: 'budget', label: 'Budget' },\n { key: 'premium', label: 'Premium' },\n { key: 'frontier', label: 'Frontier' },\n { key: 'other', label: 'Other' },\n ];\n let any = false;\n for (const g of order) {\n const group = groups[g.key];\n if (group.length === 0) continue;\n any = true;\n lines.push('');\n lines.push(g.label);\n for (const m of group) {\n lines.push('');\n const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];\n if (tiers.length === 0) {\n lines.push('- ' + (m.display_name || m.id) + ': no pricing configured');\n continue;\n }\n const sorted = tiers.slice().sort((a, b) => (a.minutes || 0) - (b.minutes || 0));\n lines.push((m.display_name || m.id) + ':');\n for (const t of sorted) {\n const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;\n const bch = (sats / 100000000).toFixed(8);\n const usd = typeof t.price_usd === 'number' ? t.price_usd.toFixed(4) : '?.??';\n lines.push(' ' + (t.minutes || 0) + ' minutes \u2014 USD ' + usd + ' (' + bch + ' BCH)');\n }\n }\n }\n if (!any) {\n lines.push('No models available.');\n }\n return lines.join('\\n');\n}\n\n// Resolve a model (by id or display name) and its price tier by minutes.\n// Uses the same /v1/config data as get_plans.\nasync function resolvePlan(modelFilter, minutes) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const f = String(modelFilter || '').toLowerCase();\n const matches = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n if (matches.length === 0) {\n const ids = models.map((m) => m.id).join(', ');\n throw new Error('Model not found: ' + modelFilter + '. Available models: ' + ids);\n }\n const model = matches[0];\n const tiers = Array.isArray(model.price_tiers) ? model.price_tiers : [];\n const want = Number(minutes);\n const tier = tiers.find((t) => Number(t.minutes) === want);\n if (!tier) {\n const avail = tiers.map((t) => t.minutes).join(', ');\n throw new Error('No ' + minutes + '-minute plan for ' + (model.display_name || model.id) + '. Available minutes: ' + avail);\n }\n return { model, tier };\n}\n\n// Wallet balance in sats (mirrors the proxy's pre-payment check)\nasync function getBalanceSats() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info'], 20000);\n const match = out.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (!match) return null;\n return Math.floor(parseFloat(match[1]) * 100000000);\n}\n\n// Purchase time credits for a specific model and plan duration. Reuses the\n// same payment wrapper the proxy runs on a 402 (x402 payment + retry with the\n// PAYMENT-SIGNATURE header), but works for ANY model/tier the user picks \u2014\n// not just the model active in the current session. Spends real BCH.\nasync function buyPlan(args) {\n const modelFilter = String(args.model || '').trim();\n const minutes = Number(args.minutes);\n if (!modelFilter) {\n throw new Error('Missing model. Pass the model id or display name (e.g. deepseek/deepseek-v4-flash).');\n }\n if (isNaN(minutes) || minutes <= 0) {\n throw new Error('Missing or invalid minutes. Pass the plan duration, e.g. 30 for the 30-minute plan.');\n }\n\n const { model, tier } = await resolvePlan(modelFilter, minutes);\n const priceSats = Number(tier.price_sats) || 0;\n\n // Fail fast on insufficient balance instead of sending a txn that cannot\n // fund the plan (mirrors the proxy's 402 flow).\n const balanceSats = await getBalanceSats();\n if (balanceSats !== null && balanceSats < priceSats) {\n const addr = await getReceivingAddress({});\n const shortfall = (priceSats - balanceSats) / 100000000;\n throw new Error('Insufficient balance: ' + (balanceSats / 100000000).toFixed(8) + ' BCH available but the ' + minutes + '-minute plan costs ' + (priceSats / 100000000).toFixed(8) + ' BCH. Top up at least ' + shortfall.toFixed(8) + ' BCH to: ' + addr);\n }\n\n const wrapper = path.join(CONFIG_DIR, 'paytaca-pay-wrapper.mjs');\n if (!fs.existsSync(wrapper)) {\n throw new Error('Payment wrapper not found at ' + wrapper + '. Restart opencode so the plugin writes it.');\n }\n\n // Minimal chat request for the target model; the payment flow only needs a\n // valid request that triggers the x402 PaymentRequired for that model.\n const body = JSON.stringify({\n model: model.id,\n messages: [{ role: 'user', content: 'Purchase plan' }],\n stream: false,\n });\n\n const url = BACKEND_URL + '/v1/chat/completions?wallet_hash=' + encodeURIComponent(WALLET_HASH || '');\n const extraHeaders = {\n 'X-Model-Id': model.id,\n 'X-Duration-Minutes': String(tier.minutes),\n };\n\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-buy-plan-'));\n const bodyFile = path.join(tmpDir, 'body.json');\n const configFile = path.join(tmpDir, 'config.json');\n try {\n fs.writeFileSync(bodyFile, body, 'utf8');\n fs.writeFileSync(configFile, JSON.stringify({\n url: url,\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders),\n bodyFile: bodyFile,\n confirmed: true,\n }), 'utf8');\n } catch (e) {\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n throw new Error('Failed to write payment files: ' + e.message);\n }\n\n log('Buy plan requested: ' + model.id + ' ' + tier.minutes + ' min');\n let stdout;\n try {\n stdout = await runCommand('node', [wrapper, configFile], 250000);\n } finally {\n try { fs.unlinkSync(bodyFile); } catch (e2) {}\n try { fs.unlinkSync(configFile); } catch (e2) {}\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n }\n\n let result;\n try {\n result = JSON.parse(stdout);\n } catch (e) {\n throw new Error('Could not parse payment result: ' + stdout.substring(0, 200));\n }\n\n if (result.timeout) {\n return 'Payment was processed but the response timed out. Check your credits with get_credits.';\n }\n if (!result.success) {\n throw new Error(result.error || 'Payment failed (status ' + result.status + ').');\n }\n\n const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes.'];\n if (result.payment && result.payment.txid) {\n lines.push('Transaction: ' + result.payment.txid);\n }\n try {\n const status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(status.sessions) ? status.sessions : [];\n const found = sessions.find((s) => s.ai_model === model.id);\n if (found && found.time_remaining_seconds > 0) {\n lines.push('Credits: ' + formatDuration(found.time_remaining_seconds) + ' remaining.');\n }\n } catch (e) {}\n return lines.join('\\n');\n}\n\n// Recent wallet transactions via the paytaca CLI\nasync function getTransactions(args) {\n const cmdArgs = ['history'];\n if (args.type === 'incoming' || args.type === 'outgoing') {\n cmdArgs.push('--type', args.type);\n }\n const page = parseInt(args.page, 10);\n if (!isNaN(page) && page > 0) {\n cmdArgs.push('--page', String(page));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out || 'No transactions found.';\n}\n\n// Receiving address via the paytaca CLI (QR art suppressed)\nasync function getReceivingAddress(args) {\n const cmdArgs = ['receive', '--no-qr'];\n const amount = parseFloat(args.amount);\n if (!isNaN(amount) && amount > 0) {\n cmdArgs.push('--amount', String(amount));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || 'Could not get receiving address.';\n}\n\n// CashToken holdings via the paytaca CLI (all tokens, or one category)\nasync function getTokens(args) {\n const category = args.category ? String(args.category).trim() : '';\n const cmdArgs = category ? ['token', 'info', category] : ['token', 'list'];\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || (category ? 'Token not found: ' + category : 'No tokens found.');\n}\n\n// Send BCH or CashTokens. This spends real funds \u2014 opencode requires manual\n// user approval for this tool (permission 'paytaca_send' set to 'ask' by the\n// plugin's config hook), so it must never be called without the user asking\n// for the send.\nasync function sendFunds(args) {\n const address = String(args.address || '').trim();\n const amount = String(args.amount || '').trim();\n const unit = args.unit === 'sats' ? 'sats' : 'bch';\n const tokenCategory = args.token_category ? String(args.token_category).trim() : '';\n if (!address) {\n throw new Error('Missing recipient address.');\n }\n if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {\n throw new Error('Missing or invalid amount.');\n }\n const cmdArgs = tokenCategory\n ? ['token', 'send', address, amount, '--token', tokenCategory]\n : ['send', address, amount];\n if (!tokenCategory && unit === 'sats') {\n cmdArgs.push('--unit', 'sats');\n }\n log('Send requested: ' + cmdArgs.join(' '));\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 90000);\n log('Send completed');\n return 'Transaction sent.\\n\\n' + out;\n}\n\n// Tool schemas (concise descriptions so MCP tool context stays small)\nconst TOOLS = [\n {\n name: 'get_credits',\n description: 'Get remaining Paytaca AI time credits (active model sessions, time left). Use when the user asks about credits, remaining time, session status, or how much usage they have left.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_balance',\n description: 'Get the BCH balance of the user\\'s Paytaca wallet. Use when the user asks about wallet balance or funds.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_models',\n description: 'List the AI models available on Paytaca AI (id, display name, tier). Use when the user asks which models are available.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_plans',\n description: 'Get Paytaca AI plan pricing: time tiers in minutes with USD and BCH prices, grouped by tier. Optionally pass a model id/name to filter one model. Use when the user asks about plans, pricing, costs, or how much a model costs.',\n inputSchema: {\n type: 'object',\n properties: {\n model: { type: 'string', description: 'Optional model id or display name to filter pricing to a single model.' },\n },\n },\n },\n {\n name: 'buy_plan',\n description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS BCH FROM THE WALLET \u2014 only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session.',\n inputSchema: {\n type: 'object',\n required: ['model', 'minutes'],\n properties: {\n model: { type: 'string', description: 'Model id or display name, e.g. deepseek/deepseek-v4-flash or DeepSeek V4 Flash.' },\n minutes: { type: 'number', description: 'Plan duration in minutes, e.g. 30 for the 30-minute tier.' },\n },\n },\n },\n {\n name: 'get_transactions',\n description: 'Get recent Paytaca wallet transactions (sent/received BCH). Use when the user asks about transaction history or latest transactions.',\n inputSchema: {\n type: 'object',\n properties: {\n type: { type: 'string', enum: ['incoming', 'outgoing'], description: 'Optional direction filter.' },\n page: { type: 'number', description: 'Optional 1-based page number for older history.' },\n },\n },\n },\n {\n name: 'get_receiving_address',\n description: 'Get a Paytaca wallet receiving address for depositing BCH, optionally as a BIP21 URI with an amount. Use when the user wants to fund the wallet or needs their address.',\n inputSchema: {\n type: 'object',\n properties: {\n amount: { type: 'number', description: 'Optional BCH amount to embed in a BIP21 payment URI.' },\n },\n },\n },\n {\n name: 'get_tokens',\n description: 'List CashToken holdings of the Paytaca wallet, or get details (name, symbol, balance, NFTs) for one token category. Use when the user asks about tokens or NFTs.',\n inputSchema: {\n type: 'object',\n properties: {\n category: { type: 'string', description: 'Optional token category id for details of a single token.' },\n },\n },\n },\n {\n name: 'send',\n description: 'Send BCH or CashTokens from the Paytaca wallet to an address. SPENDS REAL FUNDS \u2014 only call when the user explicitly asks to send; opencode will prompt the user for approval and that prompt must never be bypassed. Token amounts are in base units; recipients of tokens should use token-aware (z-prefix) addresses.',\n inputSchema: {\n type: 'object',\n required: ['address', 'amount'],\n properties: {\n address: { type: 'string', description: 'Recipient CashAddr (e.g. bitcoincash:qp...).' },\n amount: { type: 'string', description: 'Amount to send.' },\n unit: { type: 'string', enum: ['bch', 'sats'], description: 'Amount unit, default bch. Ignored for token sends.' },\n token_category: { type: 'string', description: 'Token category id to send CashTokens instead of BCH.' },\n },\n },\n },\n];\n\n// JSON-RPC over stdio (newline-delimited)\nlet buffer = '';\nfunction handleMessage(msg) {\n if (msg.method === 'initialize') {\n const requestedVersion = msg.params && msg.params.protocolVersion;\n send(msg.id, {\n protocolVersion: requestedVersion || PROTOCOL_VERSION,\n capabilities: { tools: {} },\n serverInfo: { name: 'paytaca', version: '1.2.0' },\n });\n return;\n }\n if (msg.method === 'notifications/initialized') {\n return;\n }\n if (msg.method === 'ping') {\n send(msg.id, {});\n return;\n }\n if (msg.method === 'tools/list') {\n send(msg.id, { tools: TOOLS });\n return;\n }\n if (msg.method === 'tools/call') {\n const name = msg.params && msg.params.name;\n const args = (msg.params && msg.params.arguments) || {};\n refreshConfig();\n (async () => {\n let text;\n try {\n switch (name) {\n case 'get_credits': text = await getCredits(); break;\n case 'get_balance': text = await getBalance(); break;\n case 'get_models': text = await getModels(); break;\n case 'get_plans': text = await getPlans(args.model); break;\n case 'buy_plan': text = await buyPlan(args); break;\n case 'get_transactions': text = await getTransactions(args); break;\n case 'get_receiving_address': text = await getReceivingAddress(args); break;\n case 'get_tokens': text = await getTokens(args); break;\n case 'send': text = await sendFunds(args); break;\n default: throw new Error('Unknown tool: ' + name);\n }\n send(msg.id, { content: [{ type: 'text', text: text }] });\n } catch (e) {\n log('Tool ' + name + ' failed: ' + e.message);\n send(msg.id, { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true });\n }\n })();\n return;\n }\n // Unknown request \u2014 respond with an empty result so the client never hangs\n if (typeof msg.id !== 'undefined' && msg.id !== null) {\n send(msg.id, {});\n }\n}\n\nfunction send(id, result) {\n const payload = { jsonrpc: '2.0', id, result };\n process.stdout.write(JSON.stringify(payload) + '\\n');\n}\n\nprocess.stdin.on('data', (chunk) => {\n buffer += chunk.toString();\n let idx;\n while ((idx = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.substring(0, idx).trim();\n buffer = buffer.substring(idx + 1);\n if (!line) continue;\n let msg;\n try {\n msg = JSON.parse(line);\n } catch (e) {\n continue;\n }\n try {\n handleMessage(msg);\n } catch (e) {\n log('handleMessage error: ' + e.message);\n }\n }\n});\n\nrefreshConfig();\nlog('MCP server started (backend=' + BACKEND_URL + ')');\n";
1
+ export declare const MCP_SERVER_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca MCP Server\n *\n * Registers Paytaca tools with opencode so the assistant can work with real\n * data instead of guessing. Two groups:\n * - Paytaca AI account (backend): credits, models, plan pricing\n * - Paytaca wallet (paytaca CLI): balance, transactions, receiving address,\n * token holdings, and sending funds\n *\n * The send tool moves real funds \u2014 opencode is configured (via the plugin's\n * config hook) to require explicit user approval before it runs.\n *\n * Loaded automatically via the plugin's config hook (cfg.mcp['paytaca']).\n * Uses only Node.js built-in modules.\n */\n\nconst http = require('http');\nconst https = require('https');\nconst { spawn } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\nconst os = require('os');\n\nconst CONFIG_DIR = process.env.PAYTACA_CONFIG_DIR || path.join(os.homedir(), '.opencode-paytaca');\nconst PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';\nconst DEFAULT_BACKEND = process.env.PAYTACA_BACKEND_URL || 'https://api.paytaca.ai';\n\n// LIFT is the Paytaca token users can pay AI plans with (sold via Cauldron).\n// The same token id is used by the payment wrapper's payWithLift().\nconst LIFT_TOKEN_ID = '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';\n\nconst PROTOCOL_VERSION = '2025-06-18';\n\n// Logging setup\nconst LOG_FILE = path.join(CONFIG_DIR, 'mcp.log');\nconst logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });\nfunction log(message) {\n const timestamp = new Date().toISOString();\n logStream.write(timestamp + ' [MCP] ' + message + '\\n');\n}\n\n// Load fresh config on every call so walletHash/backendUrl never go stale\nfunction loadConfig() {\n try {\n return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'config.json'), 'utf8'));\n } catch (e) {\n return {};\n }\n}\n\nlet BACKEND_URL = DEFAULT_BACKEND;\nlet WALLET_HASH = '';\nfunction refreshConfig() {\n const cfg = loadConfig();\n BACKEND_URL = process.env.PAYTACA_BACKEND_URL || cfg.backendUrl || DEFAULT_BACKEND;\n WALLET_HASH = cfg.walletHash || '';\n}\n\n// Fetch a JSON payload over HTTP(S)\nfunction getJson(url, headers) {\n return new Promise((resolve, reject) => {\n let u;\n try {\n u = new URL(url);\n } catch (e) {\n return reject(new Error('Invalid URL: ' + url));\n }\n const requester = u.protocol === 'https:' ? https : http;\n const req = requester.get({\n hostname: u.hostname,\n port: u.port || (u.protocol === 'https:' ? 443 : 80),\n path: u.pathname + u.search,\n headers: headers || {},\n }, (res) => {\n let data = '';\n res.on('data', (chunk) => { data += chunk; });\n res.on('end', () => {\n if (res.statusCode >= 400) {\n reject(new Error('HTTP ' + res.statusCode + ': ' + data.substring(0, 200)));\n return;\n }\n try {\n resolve(JSON.parse(data));\n } catch (e) {\n reject(new Error('Invalid JSON response'));\n }\n });\n });\n req.on('error', reject);\n req.setTimeout(15000, () => {\n req.destroy();\n reject(new Error('Request timed out'));\n });\n });\n}\n\n// Run a shell command with a timeout\nfunction runCommand(cmd, args, timeoutMs) {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { shell: false });\n let stdout = '';\n let stderr = '';\n let settled = false;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try { child.kill(); } catch (e) {}\n reject(new Error('Command timed out'));\n }, timeoutMs || 15000);\n child.stdout.on('data', (d) => { stdout += d.toString(); });\n child.stderr.on('data', (d) => { stderr += d.toString(); });\n child.on('close', (code) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n if (code === 0) resolve(stdout.trim());\n else reject(new Error(stderr.trim() || 'Command exited with code ' + code));\n });\n child.on('error', (err) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n reject(err);\n });\n });\n}\n\n// Format seconds as MM:SS or HH:MM:SS\nfunction formatDuration(totalSeconds) {\n const hours = Math.floor(totalSeconds / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const secs = totalSeconds % 60;\n if (hours > 0) {\n return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0');\n }\n return minutes + ':' + String(secs).padStart(2, '0');\n}\n\n// Remaining time credits per model session\nasync function getCredits() {\n const data = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(data.sessions) ? data.sessions : [];\n const active = sessions.filter((s) => s.time_remaining_seconds > 0 && s.model_active);\n const inactive = sessions.filter((s) => s.time_remaining_seconds > 0 && !s.model_active);\n const parts = [];\n if (active.length > 0) {\n parts.push('Active time credits:');\n for (const s of active) {\n const total = formatDuration(s.time_credits_seconds);\n const remaining = formatDuration(s.time_remaining_seconds);\n const used = formatDuration(s.time_used_seconds);\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + remaining + ' remaining of ' + total + ' (' + used + ' used)');\n }\n }\n if (inactive.length > 0) {\n parts.push('');\n parts.push('Inactive models (credits but session not active):');\n for (const s of inactive) {\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + formatDuration(s.time_remaining_seconds) + ' remaining');\n }\n }\n if (parts.length === 0) {\n return 'No active time credits.';\n }\n return parts.join('\\n');\n}\n\n// Wallet BCH balance via the paytaca CLI\nasync function getBalance() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = out.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (match) {\n return 'Wallet balance: ' + match[1] + ' BCH.';\n }\n return 'Could not parse balance. Raw output:\\n' + out.split('\\n').slice(0, 5).join('\\n');\n}\n\n// List all available models\nasync function getModels() {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const lines = ['Available models:'];\n for (const m of models) {\n let line = m.id || 'unknown';\n if (m.display_name && m.display_name !== m.id) line += ' (' + m.display_name + ')';\n if (m.tier) line += ' [' + m.tier + ']';\n lines.push('- ' + line);\n }\n return lines.join('\\n');\n}\n\n// Plan pricing as a markdown table per model, optionally filtered to one model\nasync function getPlans(filterModel) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n let models = Array.isArray(data.models) ? data.models : [];\n if (filterModel) {\n const f = String(filterModel).toLowerCase();\n models = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n }\n if (models.length === 0) {\n return 'No models available.';\n }\n const lines = [];\n for (const m of models) {\n const name = m.display_name || m.id;\n const tier = m.tier ? String(m.tier).charAt(0).toUpperCase() + String(m.tier).slice(1) : '';\n const tierInName = tier && name.toLowerCase().indexOf(tier.toLowerCase()) !== -1;\n if (lines.length > 0) lines.push('');\n lines.push('**' + name + (tier && !tierInName ? ' (' + tier + ')' : '') + '**');\n lines.push('');\n const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];\n if (tiers.length === 0) {\n lines.push('No pricing configured.');\n continue;\n }\n lines.push('| Duration | USD | BCH | Sats |');\n lines.push('|---|---|---|---|');\n const sorted = tiers.slice().sort((a, b) => (a.minutes || 0) - (b.minutes || 0));\n for (const t of sorted) {\n const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;\n const bch = (sats / 100000000).toFixed(8);\n const usd = typeof t.price_usd === 'number' ? '$' + t.price_usd.toFixed(2) : '?.??';\n const satsStr = Number(sats).toLocaleString('en-US');\n lines.push('| ' + (t.minutes || 0) + ' min | ' + usd + ' | ' + bch + ' | ' + satsStr + ' |');\n }\n }\n return lines.join('\\n');\n}\n\n// Resolve a model (by id or display name) and its price tier by minutes.\n// Uses the same /v1/config data as get_plans.\nasync function resolvePlan(modelFilter, minutes) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const f = String(modelFilter || '').toLowerCase();\n const matches = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n if (matches.length === 0) {\n const ids = models.map((m) => m.id).join(', ');\n throw new Error('Model not found: ' + modelFilter + '. Available models: ' + ids);\n }\n const model = matches[0];\n const tiers = Array.isArray(model.price_tiers) ? model.price_tiers : [];\n const want = Number(minutes);\n const tier = tiers.find((t) => Number(t.minutes) === want);\n if (!tier) {\n const avail = tiers.map((t) => t.minutes).join(', ');\n throw new Error('No ' + minutes + '-minute plan for ' + (model.display_name || model.id) + '. Available minutes: ' + avail);\n }\n return { model, tier };\n}\n\n// Wallet balance in sats (mirrors the proxy's pre-payment check)\nasync function getBalanceSats() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info'], 20000);\n const match = out.match(/Balance:s*([d.]+)s*BCH/i);\n if (!match) return null;\n return Math.floor(parseFloat(match[1]) * 100000000);\n}\n\n// LIFT token balance in base units (2 decimals), null when the CLI output\n// cannot be parsed.\nasync function getLiftBalanceUnits() {\n const out = await runCommand(PAYTACA_CMD, ['token', 'info', LIFT_TOKEN_ID], 20000);\n const match = out.match(/Balance:s*([d.]+)s*LIFT/i);\n if (!match) return null;\n return BigInt(Math.round(parseFloat(match[1]) * 100));\n}\n\n// Purchase time credits for a specific model and plan duration. Reuses the\n// same payment wrapper the proxy runs on a 402 (x402 payment + retry with the\n// PAYMENT-SIGNATURE header), but works for ANY model/tier the user picks \u2014\n// not just the model active in the current session. Spends real BCH.\nasync function buyPlan(args) {\n const modelFilter = String(args.model || '').trim();\n const minutes = Number(args.minutes);\n const paymentMethod = args.payment_method === 'lift' ? 'lift' : 'bch';\n if (!modelFilter) {\n throw new Error('Missing model. Pass the model id or display name (e.g. deepseek/deepseek-v4-flash).');\n }\n if (isNaN(minutes) || minutes <= 0) {\n throw new Error('Missing or invalid minutes. Pass the plan duration, e.g. 30 for the 30-minute plan.');\n }\n\n const { model, tier } = await resolvePlan(modelFilter, minutes);\n const priceSats = Number(tier.price_sats) || 0;\n\n // Fail fast on insufficient balance instead of sending a txn that cannot\n // fund the plan (mirrors the proxy's 402 flow). Only applies to BCH \u2014 the\n // LIFT path funds the plan by selling tokens, so no BCH balance is required.\n if (paymentMethod === 'bch') {\n const balanceSats = await getBalanceSats();\n if (balanceSats !== null && balanceSats < priceSats) {\n const addr = await getReceivingAddress({});\n const shortfall = (priceSats - balanceSats) / 100000000;\n throw new Error('Insufficient balance: ' + (balanceSats / 100000000).toFixed(8) + ' BCH available but the ' + minutes + '-minute plan costs ' + (priceSats / 100000000).toFixed(8) + ' BCH. Top up at least ' + shortfall.toFixed(8) + ' BCH to: ' + addr);\n }\n } else {\n // LIFT path: fail fast if the wallet holds no LIFT, instead of prompting\n // for approval and then failing inside the wrapper.\n const liftBalanceUnits = await getLiftBalanceUnits();\n if (liftBalanceUnits !== null && liftBalanceUnits <= 0n) {\n throw new Error('No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or call again with payment_method \"bch\".');\n }\n }\n\n const wrapper = path.join(CONFIG_DIR, 'paytaca-pay-wrapper.mjs');\n if (!fs.existsSync(wrapper)) {\n throw new Error('Payment wrapper not found at ' + wrapper + '. Restart opencode so the plugin writes it.');\n }\n\n // Minimal chat request for the target model; the payment flow only needs a\n // valid request that triggers the x402 PaymentRequired for that model.\n const body = JSON.stringify({\n model: model.id,\n messages: [{ role: 'user', content: 'Purchase plan' }],\n stream: false,\n });\n\n const url = BACKEND_URL + '/v1/chat/completions?wallet_hash=' + encodeURIComponent(WALLET_HASH || '');\n const extraHeaders = {\n 'X-Model-Id': model.id,\n 'X-Duration-Minutes': String(tier.minutes),\n };\n\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-buy-plan-'));\n const bodyFile = path.join(tmpDir, 'body.json');\n const configFile = path.join(tmpDir, 'config.json');\n try {\n fs.writeFileSync(bodyFile, body, 'utf8');\n const config = {\n url: url,\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders),\n bodyFile: bodyFile,\n confirmed: true,\n };\n if (paymentMethod === 'lift') {\n config.paymentMethod = 'lift';\n }\n fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');\n } catch (e) {\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n throw new Error('Failed to write payment files: ' + e.message);\n }\n\n log('Buy plan requested: ' + model.id + ' ' + tier.minutes + ' min (' + paymentMethod + ')');\n let stdout;\n try {\n stdout = await runCommand('node', [wrapper, configFile], 250000);\n } finally {\n try { fs.unlinkSync(bodyFile); } catch (e2) {}\n try { fs.unlinkSync(configFile); } catch (e2) {}\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n }\n\n let result;\n try {\n result = JSON.parse(stdout);\n } catch (e) {\n throw new Error('Could not parse payment result: ' + stdout.substring(0, 200));\n }\n\n if (result.timeout) {\n return 'Payment was processed but the response timed out. Check your credits with get_credits.';\n }\n if (!result.success) {\n throw new Error(result.error || 'Payment failed (status ' + result.status + ').');\n }\n\n const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes' + (paymentMethod === 'lift' ? ' (paid with LIFT).' : '.')];\n if (result.payment && result.payment.txid) {\n lines.push('Transaction: ' + result.payment.txid);\n }\n try {\n const status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(status.sessions) ? status.sessions : [];\n const found = sessions.find((s) => s.ai_model === model.id);\n if (found && found.time_remaining_seconds > 0) {\n lines.push('Credits: ' + formatDuration(found.time_remaining_seconds) + ' remaining.');\n }\n } catch (e) {}\n return lines.join('\\n');\n}\n\n// Recent wallet transactions via the paytaca CLI\nasync function getTransactions(args) {\n const cmdArgs = ['history'];\n if (args.type === 'incoming' || args.type === 'outgoing') {\n cmdArgs.push('--type', args.type);\n }\n const page = parseInt(args.page, 10);\n if (!isNaN(page) && page > 0) {\n cmdArgs.push('--page', String(page));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out || 'No transactions found.';\n}\n\n// Receiving address via the paytaca CLI (QR art suppressed)\nasync function getReceivingAddress(args) {\n const cmdArgs = ['receive', '--no-qr'];\n const amount = parseFloat(args.amount);\n if (!isNaN(amount) && amount > 0) {\n cmdArgs.push('--amount', String(amount));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || 'Could not get receiving address.';\n}\n\n// CashToken holdings via the paytaca CLI (all tokens, or one category)\nasync function getTokens(args) {\n const category = args.category ? String(args.category).trim() : '';\n const cmdArgs = category ? ['token', 'info', category] : ['token', 'list'];\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || (category ? 'Token not found: ' + category : 'No tokens found.');\n}\n\n// Send BCH or CashTokens. This spends real funds \u2014 opencode requires manual\n// user approval for this tool (permission 'paytaca_send' set to 'ask' by the\n// plugin's config hook), so it must never be called without the user asking\n// for the send.\nasync function sendFunds(args) {\n const address = String(args.address || '').trim();\n const amount = String(args.amount || '').trim();\n const unit = args.unit === 'sats' ? 'sats' : 'bch';\n const tokenCategory = args.token_category ? String(args.token_category).trim() : '';\n if (!address) {\n throw new Error('Missing recipient address.');\n }\n if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {\n throw new Error('Missing or invalid amount.');\n }\n const cmdArgs = tokenCategory\n ? ['token', 'send', address, amount, '--token', tokenCategory]\n : ['send', address, amount];\n if (!tokenCategory && unit === 'sats') {\n cmdArgs.push('--unit', 'sats');\n }\n log('Send requested: ' + cmdArgs.join(' '));\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 90000);\n log('Send completed');\n return 'Transaction sent.\\n\\n' + out;\n}\n\n// Tool schemas (concise descriptions so MCP tool context stays small)\nconst TOOLS = [\n {\n name: 'get_credits',\n description: 'Get remaining Paytaca AI time credits (active model sessions, time left). Use when the user asks about credits, remaining time, session status, or how much usage they have left.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_balance',\n description: 'Get the BCH balance of the user\\'s Paytaca wallet. Use when the user asks about wallet balance or funds.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_models',\n description: 'List the AI models available on Paytaca AI (id, display name, tier). Use when the user asks which models are available.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_plans',\n description: 'Get Paytaca AI plan pricing: time tiers in minutes with USD and BCH prices, grouped by tier. Optionally pass a model id/name to filter one model. Use when the user asks about plans, pricing, costs, or how much a model costs.',\n inputSchema: {\n type: 'object',\n properties: {\n model: { type: 'string', description: 'Optional model id or display name to filter pricing to a single model.' },\n },\n },\n },\n {\n name: 'buy_plan',\n description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS FUNDS FROM THE WALLET (BCH, or LIFT tokens sold via Cauldron when payment_method=lift) \u2014 only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session. IMPORTANT \u2014 LIFT phrasing: when the user says \"pay with LIFT\", \"pay with LIFT tokens\", \"use LIFT\", or mentions paying a plan with their LIFT token balance, set payment_method to \"lift\". LIFT is the Paytaca token users hold to pay for AI plans; \"pay with LIFT\" is NOT asking to buy a plan called LIFT. Default to \"bch\" unless the user explicitly mentions LIFT/tokens.',\n inputSchema: {\n type: 'object',\n required: ['model', 'minutes'],\n properties: {\n model: { type: 'string', description: 'Model id or display name, e.g. deepseek/deepseek-v4-flash or DeepSeek V4 Flash.' },\n minutes: { type: 'number', description: 'Plan duration in minutes, e.g. 30 for the 30-minute tier.' },\n payment_method: { type: 'string', enum: ['bch', 'lift'], description: 'Payment method. bch (default) pays from the BCH balance. Set to lift when the user says \"pay with LIFT\" or \"pay with LIFT tokens\" \u2014 this sells the wallet LIFT token balance via Cauldron to fund the plan. Use lift when the wallet lacks BCH but holds LIFT, or when the user explicitly asks to pay with LIFT.' },\n },\n },\n },\n {\n name: 'get_transactions',\n description: 'Get recent Paytaca wallet transactions (sent/received BCH). Use when the user asks about transaction history or latest transactions.',\n inputSchema: {\n type: 'object',\n properties: {\n type: { type: 'string', enum: ['incoming', 'outgoing'], description: 'Optional direction filter.' },\n page: { type: 'number', description: 'Optional 1-based page number for older history.' },\n },\n },\n },\n {\n name: 'get_receiving_address',\n description: 'Get a Paytaca wallet receiving address for depositing BCH, optionally as a BIP21 URI with an amount. Use when the user wants to fund the wallet or needs their address.',\n inputSchema: {\n type: 'object',\n properties: {\n amount: { type: 'number', description: 'Optional BCH amount to embed in a BIP21 payment URI.' },\n },\n },\n },\n {\n name: 'get_tokens',\n description: 'List CashToken holdings of the Paytaca wallet, or get details (name, symbol, balance, NFTs) for one token category. Use when the user asks about tokens or NFTs.',\n inputSchema: {\n type: 'object',\n properties: {\n category: { type: 'string', description: 'Optional token category id for details of a single token.' },\n },\n },\n },\n {\n name: 'send',\n description: 'Send BCH or CashTokens from the Paytaca wallet to an address. SPENDS REAL FUNDS \u2014 only call when the user explicitly asks to send; opencode will prompt the user for approval and that prompt must never be bypassed. Token amounts are in base units; recipients of tokens should use token-aware (z-prefix) addresses.',\n inputSchema: {\n type: 'object',\n required: ['address', 'amount'],\n properties: {\n address: { type: 'string', description: 'Recipient CashAddr (e.g. bitcoincash:qp...).' },\n amount: { type: 'string', description: 'Amount to send.' },\n unit: { type: 'string', enum: ['bch', 'sats'], description: 'Amount unit, default bch. Ignored for token sends.' },\n token_category: { type: 'string', description: 'Token category id to send CashTokens instead of BCH.' },\n },\n },\n },\n];\n\n// JSON-RPC over stdio (newline-delimited)\nlet buffer = '';\nfunction handleMessage(msg) {\n if (msg.method === 'initialize') {\n const requestedVersion = msg.params && msg.params.protocolVersion;\n send(msg.id, {\n protocolVersion: requestedVersion || PROTOCOL_VERSION,\n capabilities: { tools: {} },\n serverInfo: { name: 'paytaca', version: '1.2.0' },\n });\n return;\n }\n if (msg.method === 'notifications/initialized') {\n return;\n }\n if (msg.method === 'ping') {\n send(msg.id, {});\n return;\n }\n if (msg.method === 'tools/list') {\n send(msg.id, { tools: TOOLS });\n return;\n }\n if (msg.method === 'tools/call') {\n const name = msg.params && msg.params.name;\n const args = (msg.params && msg.params.arguments) || {};\n refreshConfig();\n (async () => {\n let text;\n try {\n switch (name) {\n case 'get_credits': text = await getCredits(); break;\n case 'get_balance': text = await getBalance(); break;\n case 'get_models': text = await getModels(); break;\n case 'get_plans': text = await getPlans(args.model); break;\n case 'buy_plan': text = await buyPlan(args); break;\n case 'get_transactions': text = await getTransactions(args); break;\n case 'get_receiving_address': text = await getReceivingAddress(args); break;\n case 'get_tokens': text = await getTokens(args); break;\n case 'send': text = await sendFunds(args); break;\n default: throw new Error('Unknown tool: ' + name);\n }\n send(msg.id, { content: [{ type: 'text', text: text }] });\n } catch (e) {\n log('Tool ' + name + ' failed: ' + e.message);\n send(msg.id, { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true });\n }\n })();\n return;\n }\n // Unknown request \u2014 respond with an empty result so the client never hangs\n if (typeof msg.id !== 'undefined' && msg.id !== null) {\n send(msg.id, {});\n }\n}\n\nfunction send(id, result) {\n const payload = { jsonrpc: '2.0', id, result };\n process.stdout.write(JSON.stringify(payload) + '\\n');\n}\n\nprocess.stdin.on('data', (chunk) => {\n buffer += chunk.toString();\n let idx;\n while ((idx = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.substring(0, idx).trim();\n buffer = buffer.substring(idx + 1);\n if (!line) continue;\n let msg;\n try {\n msg = JSON.parse(line);\n } catch (e) {\n continue;\n }\n try {\n handleMessage(msg);\n } catch (e) {\n log('handleMessage error: ' + e.message);\n }\n }\n});\n\nrefreshConfig();\nlog('MCP server started (backend=' + BACKEND_URL + ')');\n";
2
2
  //# sourceMappingURL=mcp.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,kBAAkB,q7uBAgmB9B,CAAC"}
1
+ {"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,kBAAkB,25yBAgnB9B,CAAC"}
@@ -31,6 +31,10 @@ const CONFIG_DIR = process.env.PAYTACA_CONFIG_DIR || path.join(os.homedir(), '.o
31
31
  const PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';
32
32
  const DEFAULT_BACKEND = process.env.PAYTACA_BACKEND_URL || 'https://api.paytaca.ai';
33
33
 
34
+ // LIFT is the Paytaca token users can pay AI plans with (sold via Cauldron).
35
+ // The same token id is used by the payment wrapper's payWithLift().
36
+ const LIFT_TOKEN_ID = '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';
37
+
34
38
  const PROTOCOL_VERSION = '2025-06-18';
35
39
 
36
40
  // Logging setup
@@ -191,7 +195,7 @@ async function getModels() {
191
195
  return lines.join('\\n');
192
196
  }
193
197
 
194
- // Plan pricing grouped by tier, optionally filtered to one model
198
+ // Plan pricing as a markdown table per model, optionally filtered to one model
195
199
  async function getPlans(filterModel) {
196
200
  const data = await getJson(BACKEND_URL + '/v1/config', {});
197
201
  let models = Array.isArray(data.models) ? data.models : [];
@@ -203,45 +207,32 @@ async function getPlans(filterModel) {
203
207
  return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;
204
208
  });
205
209
  }
206
- const groups = { budget: [], premium: [], frontier: [], other: [] };
210
+ if (models.length === 0) {
211
+ return 'No models available.';
212
+ }
213
+ const lines = [];
207
214
  for (const m of models) {
208
- const key = String(m.tier || '').toLowerCase();
209
- const groupKey = (key === 'budget' || key === 'premium' || key === 'frontier') ? key : 'other';
210
- groups[groupKey].push(m);
211
- }
212
- const lines = ['Paytaca AI Model Pricing'];
213
- const order = [
214
- { key: 'budget', label: 'Budget' },
215
- { key: 'premium', label: 'Premium' },
216
- { key: 'frontier', label: 'Frontier' },
217
- { key: 'other', label: 'Other' },
218
- ];
219
- let any = false;
220
- for (const g of order) {
221
- const group = groups[g.key];
222
- if (group.length === 0) continue;
223
- any = true;
215
+ const name = m.display_name || m.id;
216
+ const tier = m.tier ? String(m.tier).charAt(0).toUpperCase() + String(m.tier).slice(1) : '';
217
+ const tierInName = tier && name.toLowerCase().indexOf(tier.toLowerCase()) !== -1;
218
+ if (lines.length > 0) lines.push('');
219
+ lines.push('**' + name + (tier && !tierInName ? ' (' + tier + ')' : '') + '**');
224
220
  lines.push('');
225
- lines.push(g.label);
226
- for (const m of group) {
227
- lines.push('');
228
- const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];
229
- if (tiers.length === 0) {
230
- lines.push('- ' + (m.display_name || m.id) + ': no pricing configured');
231
- continue;
232
- }
233
- const sorted = tiers.slice().sort((a, b) => (a.minutes || 0) - (b.minutes || 0));
234
- lines.push((m.display_name || m.id) + ':');
235
- for (const t of sorted) {
236
- const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;
237
- const bch = (sats / 100000000).toFixed(8);
238
- const usd = typeof t.price_usd === 'number' ? t.price_usd.toFixed(4) : '?.??';
239
- lines.push(' ' + (t.minutes || 0) + ' minutes — USD ' + usd + ' (' + bch + ' BCH)');
240
- }
221
+ const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];
222
+ if (tiers.length === 0) {
223
+ lines.push('No pricing configured.');
224
+ continue;
225
+ }
226
+ lines.push('| Duration | USD | BCH | Sats |');
227
+ lines.push('|---|---|---|---|');
228
+ const sorted = tiers.slice().sort((a, b) => (a.minutes || 0) - (b.minutes || 0));
229
+ for (const t of sorted) {
230
+ const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;
231
+ const bch = (sats / 100000000).toFixed(8);
232
+ const usd = typeof t.price_usd === 'number' ? '$' + t.price_usd.toFixed(2) : '?.??';
233
+ const satsStr = Number(sats).toLocaleString('en-US');
234
+ lines.push('| ' + (t.minutes || 0) + ' min | ' + usd + ' | ' + bch + ' | ' + satsStr + ' |');
241
235
  }
242
- }
243
- if (!any) {
244
- lines.push('No models available.');
245
236
  }
246
237
  return lines.join('\\n');
247
238
  }
@@ -275,11 +266,20 @@ async function resolvePlan(modelFilter, minutes) {
275
266
  // Wallet balance in sats (mirrors the proxy's pre-payment check)
276
267
  async function getBalanceSats() {
277
268
  const out = await runCommand(PAYTACA_CMD, ['wallet', 'info'], 20000);
278
- const match = out.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);
269
+ const match = out.match(/Balance:\s*([\d.]+)\s*BCH/i);
279
270
  if (!match) return null;
280
271
  return Math.floor(parseFloat(match[1]) * 100000000);
281
272
  }
282
273
 
274
+ // LIFT token balance in base units (2 decimals), null when the CLI output
275
+ // cannot be parsed.
276
+ async function getLiftBalanceUnits() {
277
+ const out = await runCommand(PAYTACA_CMD, ['token', 'info', LIFT_TOKEN_ID], 20000);
278
+ const match = out.match(/Balance:\s*([\d.]+)\s*LIFT/i);
279
+ if (!match) return null;
280
+ return BigInt(Math.round(parseFloat(match[1]) * 100));
281
+ }
282
+
283
283
  // Purchase time credits for a specific model and plan duration. Reuses the
284
284
  // same payment wrapper the proxy runs on a 402 (x402 payment + retry with the
285
285
  // PAYMENT-SIGNATURE header), but works for ANY model/tier the user picks —
@@ -287,6 +287,7 @@ async function getBalanceSats() {
287
287
  async function buyPlan(args) {
288
288
  const modelFilter = String(args.model || '').trim();
289
289
  const minutes = Number(args.minutes);
290
+ const paymentMethod = args.payment_method === 'lift' ? 'lift' : 'bch';
290
291
  if (!modelFilter) {
291
292
  throw new Error('Missing model. Pass the model id or display name (e.g. deepseek/deepseek-v4-flash).');
292
293
  }
@@ -298,12 +299,22 @@ async function buyPlan(args) {
298
299
  const priceSats = Number(tier.price_sats) || 0;
299
300
 
300
301
  // Fail fast on insufficient balance instead of sending a txn that cannot
301
- // fund the plan (mirrors the proxy's 402 flow).
302
- const balanceSats = await getBalanceSats();
303
- if (balanceSats !== null && balanceSats < priceSats) {
304
- const addr = await getReceivingAddress({});
305
- const shortfall = (priceSats - balanceSats) / 100000000;
306
- throw new Error('Insufficient balance: ' + (balanceSats / 100000000).toFixed(8) + ' BCH available but the ' + minutes + '-minute plan costs ' + (priceSats / 100000000).toFixed(8) + ' BCH. Top up at least ' + shortfall.toFixed(8) + ' BCH to: ' + addr);
302
+ // fund the plan (mirrors the proxy's 402 flow). Only applies to BCH — the
303
+ // LIFT path funds the plan by selling tokens, so no BCH balance is required.
304
+ if (paymentMethod === 'bch') {
305
+ const balanceSats = await getBalanceSats();
306
+ if (balanceSats !== null && balanceSats < priceSats) {
307
+ const addr = await getReceivingAddress({});
308
+ const shortfall = (priceSats - balanceSats) / 100000000;
309
+ throw new Error('Insufficient balance: ' + (balanceSats / 100000000).toFixed(8) + ' BCH available but the ' + minutes + '-minute plan costs ' + (priceSats / 100000000).toFixed(8) + ' BCH. Top up at least ' + shortfall.toFixed(8) + ' BCH to: ' + addr);
310
+ }
311
+ } else {
312
+ // LIFT path: fail fast if the wallet holds no LIFT, instead of prompting
313
+ // for approval and then failing inside the wrapper.
314
+ const liftBalanceUnits = await getLiftBalanceUnits();
315
+ if (liftBalanceUnits !== null && liftBalanceUnits <= 0n) {
316
+ throw new Error('No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or call again with payment_method "bch".');
317
+ }
307
318
  }
308
319
 
309
320
  const wrapper = path.join(CONFIG_DIR, 'paytaca-pay-wrapper.mjs');
@@ -330,19 +341,23 @@ async function buyPlan(args) {
330
341
  const configFile = path.join(tmpDir, 'config.json');
331
342
  try {
332
343
  fs.writeFileSync(bodyFile, body, 'utf8');
333
- fs.writeFileSync(configFile, JSON.stringify({
344
+ const config = {
334
345
  url: url,
335
346
  method: 'POST',
336
347
  headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders),
337
348
  bodyFile: bodyFile,
338
349
  confirmed: true,
339
- }), 'utf8');
350
+ };
351
+ if (paymentMethod === 'lift') {
352
+ config.paymentMethod = 'lift';
353
+ }
354
+ fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');
340
355
  } catch (e) {
341
356
  try { fs.rmdirSync(tmpDir); } catch (e2) {}
342
357
  throw new Error('Failed to write payment files: ' + e.message);
343
358
  }
344
359
 
345
- log('Buy plan requested: ' + model.id + ' ' + tier.minutes + ' min');
360
+ log('Buy plan requested: ' + model.id + ' ' + tier.minutes + ' min (' + paymentMethod + ')');
346
361
  let stdout;
347
362
  try {
348
363
  stdout = await runCommand('node', [wrapper, configFile], 250000);
@@ -366,7 +381,7 @@ async function buyPlan(args) {
366
381
  throw new Error(result.error || 'Payment failed (status ' + result.status + ').');
367
382
  }
368
383
 
369
- const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes.'];
384
+ const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes' + (paymentMethod === 'lift' ? ' (paid with LIFT).' : '.')];
370
385
  if (result.payment && result.payment.txid) {
371
386
  lines.push('Transaction: ' + result.payment.txid);
372
387
  }
@@ -470,13 +485,14 @@ const TOOLS = [
470
485
  },
471
486
  {
472
487
  name: 'buy_plan',
473
- description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS BCH FROM THE WALLET — only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session.',
488
+ description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS FUNDS FROM THE WALLET (BCH, or LIFT tokens sold via Cauldron when payment_method=lift) — only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session. IMPORTANT — LIFT phrasing: when the user says "pay with LIFT", "pay with LIFT tokens", "use LIFT", or mentions paying a plan with their LIFT token balance, set payment_method to "lift". LIFT is the Paytaca token users hold to pay for AI plans; "pay with LIFT" is NOT asking to buy a plan called LIFT. Default to "bch" unless the user explicitly mentions LIFT/tokens.',
474
489
  inputSchema: {
475
490
  type: 'object',
476
491
  required: ['model', 'minutes'],
477
492
  properties: {
478
493
  model: { type: 'string', description: 'Model id or display name, e.g. deepseek/deepseek-v4-flash or DeepSeek V4 Flash.' },
479
494
  minutes: { type: 'number', description: 'Plan duration in minutes, e.g. 30 for the 30-minute tier.' },
495
+ payment_method: { type: 'string', enum: ['bch', 'lift'], description: 'Payment method. bch (default) pays from the BCH balance. Set to lift when the user says "pay with LIFT" or "pay with LIFT tokens" — this sells the wallet LIFT token balance via Cauldron to fund the plan. Use lift when the wallet lacks BCH but holds LIFT, or when the user explicitly asks to pay with LIFT.' },
480
496
  },
481
497
  },
482
498
  },
@@ -1 +1 @@
1
- {"version":3,"file":"mcp.js","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":";AAAA,+DAA+D;AAC/D,kEAAkE;;;AAErD,QAAA,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgmBjC,CAAC"}
1
+ {"version":3,"file":"mcp.js","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":";AAAA,+DAA+D;AAC/D,kEAAkE;;;AAErD,QAAA,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgnBjC,CAAC"}
@@ -1,2 +1,2 @@
1
- export declare const PROXY_SCRIPT_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca AI Proxy\n * \n * Sits between OpenCode and the Django backend.\n * - Auto-starts by OpenCode plugin\n * - On 402, returns SSE typewriter loading sequence + synthetic payment prompt\n * - Stores pending payments; handles \"yes\"/\"no\" approval internally\n * - Uses only Node.js built-in modules\n * \n * Usage: node proxy.js [backend_url] [proxy_port]\n * Example: node proxy.js https://api.paytaca.ai 8001\n */\n\nconst http = require('http');\nconst https = require('https');\nconst { spawn } = require('child_process');\nconst { Transform } = require('stream');\nconst fs = require('fs');\nconst path = require('path');\nconst os = require('os');\n\nconst PROXY_PORT = parseInt(process.argv[3]) || 8001;\nconst BACKEND_URL = process.argv[2] || 'https://api.paytaca.ai';\nconst parsedUrl = new URL(BACKEND_URL);\nconst DJANGO_HOST = parsedUrl.hostname;\nconst DJANGO_PORT = parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80);\nconst REQUester = parsedUrl.protocol === 'https:' ? https : http;\n\n// Logging setup: write to file instead of console\nconst LOG_DIR = path.join(os.homedir(), '.opencode-paytaca');\nif (!fs.existsSync(LOG_DIR)) {\n fs.mkdirSync(LOG_DIR, { recursive: true });\n}\nconst LOG_FILE = path.join(LOG_DIR, 'proxy.log');\nconst logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });\n\nfunction log(message) {\n const timestamp = new Date().toISOString();\n logStream.write(timestamp + ' [Proxy] ' + message + '\\n');\n}\n\n// Store pending payment requests per wallet hash\n// Each entry: { body, modelId, displayName, durationMinutes, tiers[], step }\n// step: 'tier_select' (user must pick a tier) or 'approval' (yes/no)\nconst pendingPayments = new Map();\n\n// Track the last model used per wallet so we can detect model switches and\n// make sure a switched-to model never hits a stale payment prompt.\nconst lastModelPerWallet = new Map();\n\n// Monotonic id per incoming request. A response may only clear the pending\n// payment created by its own request \u2014 concurrent requests from opencode share\n// the wallet hash, and a plain 200 finishing mid-payment must not clobber the\n// pending entry another request just created (that made tier selections\n// \"2\"/\"3\" fall through to a fresh 402 and re-show the prompt forever).\nlet requestCounter = 0;\n\n// Utility: run shell command and return output\nfunction runCommand(cmd, args = []) {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { shell: false });\n let stdout = '';\n let stderr = '';\n \n child.stdout.on('data', (data) => { stdout += data.toString(); });\n child.stderr.on('data', (data) => { stderr += data.toString(); });\n \n child.on('close', (code) => {\n if (code === 0) resolve(stdout.trim());\n else reject(new Error(stderr.trim() || 'Command exited with code ' + code));\n });\n \n child.on('error', (err) => reject(err));\n });\n}\n\n// Get paytaca command from environment or default to 'paytaca'\nconst PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';\n\n// Utility: check if paytaca CLI exists\nasync function checkPaytacaCli() {\n try {\n // Try to run version check\n await runCommand(PAYTACA_CMD, ['--version']);\n return true;\n } catch {\n return false;\n }\n}\n\n// Utility: get wallet balance in sats\nasync function getWalletBalance() {\n try {\n const output = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = output.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (match) {\n const bch = parseFloat(match[1]);\n return Math.floor(bch * 100000000);\n }\n return null;\n } catch (err) {\n log('Failed to get wallet balance: ' + err.message);\n return null;\n }\n}\n\n// Utility: get receiving address\nasync function getReceivingAddress() {\n try {\n const output = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = output.match(/Address:\\s*(bitcoincash:[a-zA-Z0-9]+)/i);\n return match ? match[1] : null;\n } catch {\n return null;\n }\n}\n\n// Utility: check if wallet exists\nasync function checkWallet() {\n try {\n await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n return true;\n } catch {\n return false;\n }\n}\n\n// Format seconds as MM:SS or HH:MM:SS\nfunction formatDuration(totalSeconds) {\n const hours = Math.floor(totalSeconds / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const secs = totalSeconds % 60;\n if (hours > 0) {\n return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0');\n }\n return minutes + ':' + String(secs).padStart(2, '0');\n}\n\n// SSE helper: write a data line\nfunction sseLine(res, data) {\n res.write('data: ' + JSON.stringify(data) + '\\n\\n');\n}\n\n// SSE helper: write [DONE]\nfunction sseDone(res) {\n res.write('data: [DONE]\\n\\n');\n}\n\n// Zero-width marker prepended to every synthetic proxy message (tier\n// prompts, credits/plans output, payment notices). The opencode plugin\n// strips marker-carrying assistant messages from LLM context \u2014 proxy chatter\n// is not relevant to the coding session \u2014 while the user still sees them in\n// the UI (zero-width characters don't render).\nconst PROXY_MARKER = String.fromCharCode(0x200b, 0x200b, 0x200b, 0x200b);\n\n// Stream the tier-selection prompt body (SSE lines) into an in-progress response.\n// When includeRole is false the leading role delta is skipped, so the body can be\n// appended to a stream that already emitted content (e.g. after a payment failure).\nasync function streamTierSelectionBody(res, walletHash, modelName, tiers, includeRole, otherModels) {\n if (includeRole !== false) {\n sseLine(res, {\n id: 'tier-1',\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: modelName,\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n }\n\n // Loading sequence\n sseLine(res, {\n id: 'tier-2',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: PROXY_MARKER + '\u23F3 Initializing Paytaca AI provider...\\n' }, finish_reason: null }],\n });\n\n const hasCli = await checkPaytacaCli();\n sseLine(res, {\n id: 'tier-3',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Checking Paytaca CLI... ' }, finish_reason: null }],\n });\n sseLine(res, {\n id: 'tier-4',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: hasCli ? '\u2705\\n' : '\u274C Not found\\n' }, finish_reason: null }],\n });\n\n const hasWallet = hasCli ? await checkWallet() : false;\n sseLine(res, {\n id: 'tier-5',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Checking wallet... ' }, finish_reason: null }],\n });\n sseLine(res, {\n id: 'tier-6',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: hasWallet ? '\u2705\\n' : '\u274C Not found\\n' }, finish_reason: null }],\n });\n\n const balanceSats = hasWallet ? await getWalletBalance() : null;\n sseLine(res, {\n id: 'tier-7',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Fetching balance... ' }, finish_reason: null }],\n });\n\n let balanceStr;\n if (balanceSats !== null) {\n const bch = (balanceSats / 100000000).toFixed(8);\n balanceStr = bch + ' BCH';\n sseLine(res, {\n id: 'tier-8',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u2705 \u2014 ' + balanceStr + '\\n\\n' }, finish_reason: null }],\n });\n } else {\n balanceStr = 'Unable to check';\n sseLine(res, {\n id: 'tier-8',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\\n' + balanceStr + '\\n\\n' }, finish_reason: null }],\n });\n }\n\n // Tier selection\n sseLine(res, {\n id: 'tier-9',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\uD83D\uDCB3 Select a plan for **' + (modelName || 'AI Model') + '**\\n\\n' }, finish_reason: null }],\n });\n\n // If other models still have paid credits, tell the user they can switch\n // instead of buying a new plan (only when there is something to suggest).\n if (otherModels && otherModels.length > 0) {\n sseLine(res, {\n id: 'tier-9b',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: otherModelsHint(otherModels) }, finish_reason: null }],\n });\n }\n\n // Build all tier lines into one string so backtick markdown renders\n // consistently (same as the 'plans' command).\n let tiersContent = '';\n for (let i = 0; i < tiers.length; i++) {\n const tier = tiers[i];\n const bchAmount = (tier.price_sats / 100000000).toFixed(8);\n const label = '`(' + String(i + 1) + ')` ';\n // Display USD price if available, fall back to PHP for legacy backends\n const priceDisplay = tier.price_usd !== undefined && tier.price_usd !== null\n ? 'USD ' + tier.price_usd.toFixed(4)\n : 'PHP ' + (tier.price_php ? tier.price_php.toFixed(2) : '?.??');\n tiersContent += label + tier.minutes + ' minutes \u2014 ' + priceDisplay + ' (' + bchAmount + ' BCH)\\n';\n }\n sseLine(res, {\n id: 'tier-10',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: tiersContent }, finish_reason: null }],\n });\n\n sseLine(res, {\n id: 'tier-11',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\\nEnter a number (1-' + tiers.length + '), e.g. type ' + tiers[0].minutes + ':' }, finish_reason: 'stop' }],\n });\n\n sseLine(res, {\n id: 'tier-12',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n}\n\n// Build and stream a full tier-selection prompt (headers + body + [DONE]) to the client.\nasync function streamTierSelectionPrompt(res, walletHash, modelName, tiers, otherModels) {\n if (!res.headersSent) {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'X-Payment-Required': 'true',\n 'Connection': 'keep-alive',\n });\n }\n await streamTierSelectionBody(res, walletHash, modelName, tiers, true, otherModels);\n sseDone(res);\n res.end();\n}\n\n// Build and stream SSE loading sequence + payment prompt\n// Stream SSE notice when the upstream (OpenRouter) account lacks balance to fund\n// the request. Replaces the old single-tier yes/no approval prompt.\nasync function streamLowBalanceNotice(res, modelName, otherModels) {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'X-Payment-Required': 'true',\n 'Connection': 'keep-alive',\n });\n\n sseLine(res, {\n id: 'lb-1',\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: modelName || 'AI Model',\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n\n // Include the other-models hint (when available) so the user knows they can\n // switch to a model that still has credits instead of being stuck.\n const hint = otherModelsHint(otherModels);\n sseLine(res, {\n id: 'lb-2',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: PROXY_MARKER + '\u26A0\uFE0F OpenRouter balance is low \u2014 please top up before continuing.\\n' + hint }, finish_reason: 'stop' }],\n });\n\n sseLine(res, {\n id: 'lb-3',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n\n sseDone(res);\n res.end();\n}\n\n// Forward request to Django and return response (buffered, for non-streaming)\nfunction forwardToDjango(req, body, callback) {\n const options = {\n hostname: DJANGO_HOST,\n port: DJANGO_PORT,\n path: req.url,\n method: req.method,\n headers: {\n 'Content-Type': req.headers['content-type'] || 'application/json',\n 'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n\n const startTime = Date.now();\n log('forwardToDjango -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);\n\n let timeoutCleared = false;\n const djangoReq = REQUester.request(options, (djangoRes) => {\n // Response started; clear the connect/first-byte timeout so slow streams aren't killed.\n if (!timeoutCleared) {\n djangoReq.clearTimeout();\n timeoutCleared = true;\n }\n\n let responseBody = '';\n djangoRes.on('data', chunk => { responseBody += chunk; });\n djangoRes.on('end', () => {\n const elapsed = Date.now() - startTime;\n log('Django responded in ' + elapsed + 'ms: status=' + djangoRes.statusCode + ', bodyLen=' + responseBody.length);\n callback(null, djangoRes.statusCode, djangoRes.headers, responseBody);\n });\n });\n\n djangoReq.setTimeout(300000, () => {\n djangoReq.destroy();\n callback(new Error('Django request timed out after 300s'));\n });\n\n djangoReq.on('error', (err) => {\n if (!timeoutCleared) {\n djangoReq.clearTimeout();\n timeoutCleared = true;\n }\n log('Django request error: ' + err.message);\n callback(err);\n });\n\n djangoReq.write(body);\n djangoReq.end();\n}\n\n// Forward streaming request to Django\nfunction forwardStreaming(req, res, body, callback) {\n const options = {\n hostname: DJANGO_HOST,\n port: DJANGO_PORT,\n path: req.url,\n method: req.method,\n headers: {\n 'Content-Type': req.headers['content-type'] || 'application/json',\n 'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n\n const startTime = Date.now();\n log('forwardStreaming -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);\n\n let timeoutCleared = false;\n const djangoReq = REQUester.request(options, (djangoRes) => {\n // Response started; clear the connect/first-byte timeout so slow streams aren't killed.\n if (!timeoutCleared) {\n djangoReq.clearTimeout();\n timeoutCleared = true;\n }\n\n const elapsed = Date.now() - startTime;\n log('Django response started in ' + elapsed + 'ms: status=' + djangoRes.statusCode);\n\n if (djangoRes.statusCode === 402) {\n let responseBody = '';\n djangoRes.on('data', chunk => { responseBody += chunk; });\n djangoRes.on('end', () => {\n callback(null, 402, djangoRes.headers, responseBody);\n });\n return;\n }\n\n res.writeHead(djangoRes.statusCode, {\n 'Content-Type': djangoRes.headers['content-type'] || 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n });\n\n if (res.socket) {\n res.socket.setNoDelay(true);\n }\n\n // Buffer SSE data at event boundaries and inject keepalive between events.\n let sseBuffer = '';\n let lastActivity = Date.now();\n let streamingDone = false;\n let doneForwarded = false;\n\n // Watchdog: inject keepalive only when buffer is empty (between complete events)\n const keepaliveTimer = setInterval(() => {\n if (streamingDone || res.writableEnded || res.destroyed) {\n clearInterval(keepaliveTimer);\n return;\n }\n const now = Date.now();\n if (now - lastActivity >= 2000 && sseBuffer.length === 0) {\n try {\n res.write(': keepalive\\n\\n');\n lastActivity = now;\n } catch (err) {\n log('Keepalive write error: ' + err.message);\n clearInterval(keepaliveTimer);\n }\n }\n }, 500);\n\n const cleanup = () => {\n streamingDone = true;\n clearInterval(keepaliveTimer);\n };\n\n var diagCounter = 0;\n djangoRes.on('data', (chunk) => {\n var chunkStr = chunk.toString();\n var chunkIdx = ++diagCounter;\n sseBuffer += chunkStr;\n lastActivity = Date.now();\n\n var okCount = (sseBuffer.match(/:ok/g) || []).length;\n if (okCount > 0) {\n log('CHUNK#' + chunkIdx + ': ' + okCount + ' :ok in buffer (len=' + sseBuffer.length + ')');\n }\n\n // Strip upstream SSE \":ok\" keepalive comments from anywhere in the buffer.\n sseBuffer = sseBuffer.replace(/:ok(?:\\n)?/g, '');\n if (okCount > 0) {\n log('AFTER: stripped ' + okCount + ' :ok, buffer len=' + sseBuffer.length);\n }\n\n var extractedCount = 0;\n let idx;\n while ((idx = sseBuffer.indexOf('\\n\\n')) !== -1) {\n const event = sseBuffer.substring(0, idx + 2);\n sseBuffer = sseBuffer.substring(idx + 2);\n const lines = event.split('\\n').filter(l => !/^:/.test(l) && l.length > 0);\n if (lines.length === 0) continue;\n const cleanEvent = lines.join('\\n') + '\\n\\n';\n extractedCount++;\n var dataContent = lines.map(function(l) { return l.replace(/^data: ?/, ''); }).join('');\n if (dataContent === '[DONE]') { doneForwarded = true; }\n var lastChar = dataContent.slice(-1);\n if (dataContent !== '[DONE]' && lastChar !== '}' && lastChar !== ']') {\n log('FLUSH: truncated event #' + extractedCount + ' (len=' + dataContent.length + ', end=' + JSON.stringify(dataContent.slice(-30)) + ')');\n }\n try {\n res.write(cleanEvent);\n } catch (err) {\n cleanup();\n log('Write error: ' + err.message);\n return;\n }\n }\n if (extractedCount > 0) {\n log('EXTRACT: forwarded ' + extractedCount + ' events in chunk#' + chunkIdx + ', buffer remaining len=' + sseBuffer.length);\n }\n });\n\n djangoRes.on('end', () => {\n if (streamingDone) {\n return;\n }\n sseBuffer = sseBuffer.replace(/:ok(?:\\n)?/g, '');\n if (sseBuffer) {\n // Ensure the final written data ends with \\n\\n so the client recognizes the event boundary\n if (sseBuffer.length < 2 || sseBuffer.substring(sseBuffer.length - 2) !== '\\n\\n') {\n sseBuffer += '\\n\\n';\n }\n log('END: writing remaining buffer len=' + sseBuffer.length + ' start=' + JSON.stringify(sseBuffer.substring(0, 80)));\n try { res.write(sseBuffer); } catch (e) {}\n }\n if (!doneForwarded) {\n log('Injecting [DONE] \u2014 upstream closed without sending it');\n try { res.write('data: [DONE]\\n\\n'); } catch (e) {}\n }\n cleanup();\n try { res.end(); } catch (e) {}\n log('Streaming response completed' + (doneForwarded ? '' : ' (injected [DONE])'));\n callback(null, djangoRes.statusCode, {}, '');\n });\n\n djangoRes.on('error', (err) => {\n log('Django stream error: ' + err.message);\n if (!streamingDone) {\n cleanup();\n }\n if (!res.writableEnded) {\n try {\n res.end();\n } catch (e) {}\n }\n callback(null, 200, {}, '');\n });\n\n res.on('close', () => {\n cleanup();\n log('Client connection closed');\n });\n\n res.on('error', (err) => {\n cleanup();\n log('Client connection error: ' + err.message);\n });\n });\n\n djangoReq.setTimeout(300000, () => {\n djangoReq.destroy();\n callback(new Error('Django streaming request timed out after 300s'));\n });\n\n djangoReq.on('error', (err) => {\n if (!timeoutCleared) {\n djangoReq.clearTimeout();\n timeoutCleared = true;\n }\n log('Django streaming request error: ' + err.message);\n callback(err);\n });\n\n djangoReq.write(body);\n djangoReq.end();\n}\n\n// Force stream=false in body because paytaca pay reads the response as text\nfunction forceNonStreaming(body) {\n try {\n const data = JSON.parse(body);\n data.stream = false;\n return JSON.stringify(data);\n } catch {\n return body;\n }\n}\n\n// Convert a chat.completion JSON object to SSE format\nfunction jsonToSse(res, chatCompletion, opts) {\n opts = opts || {};\n if (res.destroyed || res.writableEnded) {\n log('jsonToSse: response already destroyed/ended, cannot send SSE');\n return;\n }\n const message = chatCompletion.choices?.[0]?.message || {};\n const content = message.content || '';\n const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : null;\n const model = chatCompletion.model || chatCompletion.model_id || 'deepseek/deepseek-v4-flash';\n const created = chatCompletion.created || Math.floor(Date.now() / 1000);\n\n if (!res.headersSent) {\n try {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n });\n } catch (e) {\n log('jsonToSse writeHead failed: ' + e.message);\n return;\n }\n }\n\n try {\n sseLine(res, {\n id: 'chatcmpl-1',\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n } catch (e) {\n log('jsonToSse: failed to write role delta: ' + e.message);\n }\n\n const allContent = (opts.prependContent || '') + content;\n const chunkSize = 20;\n let chunksWritten = 0;\n for (let i = 0; i < allContent.length; i += chunkSize) {\n try {\n sseLine(res, {\n id: 'chatcmpl-' + (i + 2),\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: { content: allContent.slice(i, i + chunkSize) }, finish_reason: null }],\n });\n chunksWritten++;\n } catch (e) {\n log('jsonToSse: failed to write content chunk ' + (i / chunkSize) + ': ' + e.message);\n break;\n }\n }\n\n let finishReason = 'stop';\n if (toolCalls && toolCalls.length > 0) {\n const toolCallDeltas = [];\n for (let i = 0; i < toolCalls.length; i++) {\n const tc = toolCalls[i] || {};\n const fn = tc.function || {};\n let args = fn.arguments;\n if (args !== undefined && typeof args !== 'string') {\n try { args = JSON.stringify(args); } catch (e) { args = String(args); }\n }\n toolCallDeltas.push({\n index: i,\n id: tc.id || ('call_' + i),\n type: 'function',\n function: {\n name: fn.name || '',\n arguments: args === undefined || args === null ? '' : String(args),\n },\n });\n }\n try {\n sseLine(res, {\n id: 'chatcmpl-tools',\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: { tool_calls: toolCallDeltas }, finish_reason: null }],\n });\n } catch (e) {\n log('jsonToSse: failed to write tool_calls: ' + e.message);\n }\n finishReason = 'tool_calls';\n }\n\n try {\n sseLine(res, {\n id: 'chatcmpl-done',\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: {}, finish_reason: finishReason }],\n usage: chatCompletion.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n } catch (e) {\n log('jsonToSse: failed to write final delta: ' + e.message);\n }\n\n try {\n sseDone(res);\n } catch (e) {\n log('jsonToSse: failed to write [DONE]: ' + e.message);\n }\n\n try {\n res.end();\n } catch (e) {\n log('jsonToSse: res.end() failed: ' + e.message);\n }\n}\n\n// Stream a payment-failure message, then re-show the tier-selection prompt so the\n// user can retry the same or a different plan without sending another message.\n// The pending payment is restored to the tier-select step so the next tier pick is\n// handled by the proxy instead of being forwarded fresh to Django.\nasync function streamPaymentFailureAndRetry(res, walletHash, pendingPayload, message) {\n try {\n sseLine(res, {\n id: 'pay-err',\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: 'deepseek/deepseek-v4-flash',\n choices: [{ index: 0, delta: { content: PROXY_MARKER + message }, finish_reason: 'stop' }],\n });\n } catch (e) {\n }\n pendingPayload.step = 'tier_select';\n pendingPayload.durationMinutes = null;\n pendingPayments.set(walletHash, pendingPayload);\n try {\n const tiers = Array.isArray(pendingPayload.tiers) ? pendingPayload.tiers : [];\n if (tiers.length > 0) {\n await streamTierSelectionBody(res, walletHash, pendingPayload.displayName || pendingPayload.modelId || 'AI Model', tiers, false);\n }\n sseDone(res);\n res.end();\n } catch (e) {\n try { res.end(); } catch (e2) {}\n }\n}\n\n// Run paytaca pay internally and return the response\nfunction runPaytacaPay(djangoUrl, body, walletHash, extraHeaders, callback) {\n const url = djangoUrl + '/chat/completions?wallet_hash=' + encodeURIComponent(walletHash || '');\n const payBody = forceNonStreaming(body);\n\n // Write body to a temp file to avoid CLI arg length limits\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-pay-'));\n const bodyFile = path.join(tmpDir, 'body.json');\n const configFile = path.join(tmpDir, 'config.json');\n\n try {\n fs.writeFileSync(bodyFile, payBody, 'utf8');\n } catch (err) {\n return callback(new Error('Failed to write temp body file: ' + err.message));\n }\n\n const config = {\n url,\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders || {}),\n bodyFile,\n confirmed: true,\n };\n\n try {\n fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');\n } catch (err) {\n return callback(new Error('Failed to write temp config file: ' + err.message));\n }\n\n // Path to the wrapper script\n const wrapperScript = path.join(LOG_DIR, 'paytaca-pay-wrapper.mjs');\n log('Running paytaca pay via wrapper script...');\n\n const child = spawn('node', [wrapperScript, configFile], { shell: false });\n let stdout = '';\n let stderr = '';\n\n child.stdout.on('data', (data) => { \n stdout += data.toString(); \n });\n child.stderr.on('data', (data) => { \n stderr += data.toString(); \n });\n\n child.on('close', (code) => {\n // Clean up temp files\n try {\n fs.unlinkSync(bodyFile);\n fs.unlinkSync(configFile);\n fs.rmdirSync(tmpDir);\n } catch {}\n\n if (code === 0) {\n try {\n const responseJson = JSON.parse(stdout.trim());\n callback(null, responseJson);\n } catch (err) {\n callback(new Error('Could not parse paytaca pay response: ' + err.message));\n }\n } else {\n // Try to extract error from stdout (wrapper writes JSON errors to stdout, not stderr)\n let wrapperErr = stderr.trim();\n if (!wrapperErr) {\n try {\n const parsed = JSON.parse(stdout.trim());\n wrapperErr = parsed.error || 'Unknown error';\n } catch {\n wrapperErr = stdout.trim() || 'paytaca pay wrapper exited with code ' + code;\n }\n }\n callback(new Error(wrapperErr));\n }\n });\n\n child.on('error', (err) => {\n // Clean up temp files on error\n try {\n fs.unlinkSync(bodyFile);\n fs.unlinkSync(configFile);\n fs.rmdirSync(tmpDir);\n } catch {}\n callback(new Error('Failed to run paytaca pay wrapper: ' + err.message));\n });\n}\n\n// Extract the last user message content from a chat payload\nfunction getLastUserMessageContent(body) {\n try {\n const data = JSON.parse(body);\n const messages = data.messages || [];\n for (let i = messages.length - 1; i >= 0; i--) {\n if (messages[i].role === 'user') {\n const content = messages[i].content;\n if (Array.isArray(content)) {\n const parts = [];\n for (const part of content) {\n if (part && typeof part === 'object' && part.type === 'text') {\n parts.push(part.text || '');\n } else if (typeof part === 'string') {\n parts.push(part);\n } else {\n parts.push(JSON.stringify(part));\n }\n }\n return parts.join('').trim().toLowerCase();\n }\n return String(content || '').trim().toLowerCase();\n }\n }\n return '';\n } catch {\n return '';\n }\n}\n\n// Fetch wallet status and return other models that still have remaining time\n// credits, excluding the model currently being requested. Returns an array of\n// { modelId, displayName, remainingSeconds } or [] when nothing qualifies\n// (or the status endpoint is unreachable). This powers the \"you can switch to\n// another model\" hint on 402 responses.\nasync function getOtherModelsWithCredits(walletHash, excludeModelId) {\n try {\n const statusRes = await fetch(BACKEND_URL + '/v1/wallet/status', {\n headers: { 'X-Wallet-Hash': walletHash }\n });\n if (!statusRes.ok) {\n return [];\n }\n const statusData = await statusRes.json();\n const sessions = Array.isArray(statusData.sessions) ? statusData.sessions : [];\n const others = [];\n for (const s of sessions) {\n const modelId = s.ai_model || s.model_id || '';\n if (excludeModelId && modelId && modelId === excludeModelId) {\n continue;\n }\n const remaining = Number(s.time_remaining_seconds) || 0;\n if (remaining > 0) {\n others.push({\n modelId: modelId,\n displayName: s.display_name || modelId || 'Unknown model',\n remainingSeconds: remaining,\n });\n }\n }\n return others;\n } catch (err) {\n log('Failed to check other models with credits: ' + err.message);\n return [];\n }\n}\n\n// Build a hint listing other models that still have remaining credits, so the\n// user knows they can switch instead of buying a new plan. Returns '' when\n// there is nothing worth suggesting.\nfunction otherModelsHint(otherModels) {\n if (!otherModels || otherModels.length === 0) {\n return '';\n }\n let hint = '\\n\uD83D\uDCA1 You have remaining credits on other models:\\n';\n for (const m of otherModels) {\n hint += ' - **' + m.displayName + '** \u2014 ' + formatDuration(m.remainingSeconds) + ' remaining\\n';\n }\n hint += 'Switch to one of these models to keep chatting without a new purchase.\\n\\n';\n return hint;\n}\n\nasync function handleTimeCreditsCommand(res, walletHash) {\n log('Time command for wallet ' + walletHash?.substring(0, 16) + '...');\n const statusUrl = BACKEND_URL + '/v1/wallet/status';\n const statusRes = await fetch(statusUrl, {\n headers: { 'X-Wallet-Hash': walletHash }\n });\n let content;\n if (statusRes.ok) {\n const statusData = await statusRes.json();\n const sessions = statusData.sessions || [];\n const activeSessions = sessions.filter(s => s.time_remaining_seconds > 0 && s.model_active);\n const inactiveSessions = sessions.filter(s => s.time_remaining_seconds > 0 && !s.model_active);\n const parts = [];\n if (activeSessions.length > 0) {\n parts.push('**\u23F1\uFE0F Active Time Credits:**');\n activeSessions.forEach(s => {\n const total = formatDuration(s.time_credits_seconds);\n const remaining = formatDuration(s.time_remaining_seconds);\n const used = formatDuration(s.time_used_seconds);\n parts.push(' - **' + (s.display_name || s.ai_model) + '** \u2014 ' + remaining + ' remaining of ' + total + ' (' + used + ' used)');\n });\n }\n if (inactiveSessions.length > 0) {\n parts.push('\\n**\u26A0\uFE0F Inactive Model:**');\n inactiveSessions.forEach(s => {\n const remaining = formatDuration(s.time_remaining_seconds);\n parts.push(' - **' + (s.display_name || s.ai_model) + ' (Inactive)** \u2014 ' + remaining + ' remaining');\n });\n }\n content = parts.length > 0 ? parts.join('\\n') : '\u23F1\uFE0F No active time credits.';\n } else {\n content = '\u23F1\uFE0F Unable to check time credits.';\n }\n\n sseLine(res, {\n id: 'time-1',\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: 'deepseek/deepseek-v4-flash',\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n sseLine(res, {\n id: 'time-2',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: PROXY_MARKER + content + '\\n' }, finish_reason: 'stop' }],\n });\n sseLine(res, {\n id: 'time-3',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n sseDone(res);\n res.end();\n}\n\nconst isTimeCmd = (s) => s === 'credits';\nconst isPricingCmd = (s) => s === 'plans';\n\n// List all models grouped by tier (Budget / Premium / Frontier / Other) with prices\nasync function handlePricingCommand(res) {\n log('Pricing command requested');\n let content;\n try {\n const configRes = await fetch(BACKEND_URL + '/v1/config');\n if (!configRes.ok) {\n throw new Error('config status ' + configRes.status);\n }\n const config = await configRes.json();\n const models = Array.isArray(config.models) ? config.models : [];\n const groups = { budget: [], premium: [], frontier: [], other: [] };\n for (const m of models) {\n const key = String(m.tier || '').toLowerCase();\n const groupKey = (key === 'budget' || key === 'premium' || key === 'frontier') ? key : 'other';\n groups[groupKey].push(m);\n }\n const lines = ['\uD83D\uDCCB Paytaca AI \u2014 Model Pricing'];\n const order = [\n { key: 'budget', label: 'Budget' },\n { key: 'premium', label: 'Premium' },\n { key: 'frontier', label: 'Frontier' },\n { key: 'other', label: 'Other' },\n ];\n let any = false;\n for (const g of order) {\n if (groups[g.key].length === 0) continue;\n any = true;\n lines.push('');\n lines.push(g.label);\n for (const m of groups[g.key]) {\n lines.push('');\n const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];\n if (tiers.length === 0) {\n lines.push('- **' + (m.display_name || m.id) + '**: \u2014 no pricing configured');\n continue;\n }\n const sorted = tiers.slice().sort((a, b) => (a.minutes || 0) - (b.minutes || 0));\n lines.push('**' + (m.display_name || m.id) + '**:');\n sorted.forEach((t, i) => {\n const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;\n const bch = (sats / 100000000).toFixed(8);\n const usd = typeof t.price_usd === 'number' ? t.price_usd.toFixed(4) : '?.??';\n lines.push(' `(' + String(i + 1) + ')` ' + (t.minutes || 0) + ' minutes \u2014 USD ' + usd + ' (' + bch + ' BCH)');\n });\n }\n }\n if (!any) {\n lines.push('');\n lines.push('No models available.');\n }\n content = lines.join('\\n');\n } catch (err) {\n log('Pricing command failed: ' + err.message);\n content = '\uD83D\uDCCB Unable to fetch pricing.';\n }\n\n sseLine(res, {\n id: 'price-1',\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: 'deepseek/deepseek-v4-flash',\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n sseLine(res, {\n id: 'price-2',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: PROXY_MARKER + content + '\\n' }, finish_reason: 'stop' }],\n });\n sseLine(res, {\n id: 'price-3',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n sseDone(res);\n res.end();\n}\n\n// Main proxy server\nconst server = http.createServer(async (req, res) => {\n // Enable CORS\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Wallet-Hash, X-Model-Id, X-Duration-Minutes, Payment-Signature, Authorization');\n \n if (req.method === 'OPTIONS') {\n res.writeHead(200);\n res.end();\n return;\n }\n \n // Discovery endpoint - fetch from backend to get actual config\n if (req.url === '/v1/config' && req.method === 'GET') {\n try {\n const backendConfig = await fetch(BACKEND_URL + '/v1/config');\n if (backendConfig.ok) {\n const config = await backendConfig.json();\n // Add proxy-specific info\n config.proxy_url = 'http://localhost:' + PROXY_PORT + '/v1';\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(config));\n return;\n }\n } catch (err) {\n log('Failed to fetch backend config: ' + err.message);\n }\n \n // Fallback to static values if backend unavailable\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({\n proxy_url: 'http://localhost:' + PROXY_PORT + '/v1',\n django_url: BACKEND_URL + '/v1',\n payment_address: '',\n default_model: 'deepseek/deepseek-v4-flash',\n default_duration_minutes: 30,\n models: [\n {\n id: 'deepseek/deepseek-v4-flash',\n object: 'model',\n display_name: 'DeepSeek V4 Flash',\n provider: 'openrouter',\n price_tiers: [\n { minutes: 10, price_php: 5.0, price_sats: 45000 },\n { minutes: 30, price_php: 12.0, price_sats: 108000 },\n { minutes: 60, price_php: 20.0, price_sats: 180000 },\n ],\n },\n ],\n context_retention_hours: 2,\n }));\n return;\n }\n \n // All other endpoints \u2014 read body and forward to Django\n let body = '';\n req.on('data', chunk => { body += chunk; });\n req.on('end', async () => {\n try {\n const walletHash = req.headers['x-wallet-hash'];\n const proxyReqId = ++requestCounter;\n const lastContent = getLastUserMessageContent(body);\n \n log('Request received: wallet=' + (walletHash?.substring(0, 16) || 'none') + '..., bodyLen=' + body.length + ', pending=' + pendingPayments.has(walletHash));\n \n const stripSysRem = (s) => { let r = (s || ''), a = '<system-reminder>', b = '</system-reminder>', i = r.indexOf(a); while (i !== -1) { let j = r.indexOf(b, i); if (j === -1) break; r = r.substring(0, i) + r.substring(j + b.length); i = r.indexOf(a); } return r.trim(); };\n \n // Guard: wallet hash is required for payment flow\n if (!walletHash) {\n const redactedHeaders = {};\n for (const [k, v] of Object.entries(req.headers)) {\n const lk = k.toLowerCase();\n redactedHeaders[k] = /authorization|payment-signature|api-?key|secret|token/i.test(lk)\n ? '<redacted>'\n : v;\n }\n log('MISSING X-Wallet-Hash. Received headers: ' + JSON.stringify(redactedHeaders));\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({\n error: 'X-Wallet-Hash header missing',\n message: 'The X-Wallet-Hash header was not sent by the client. It is injected by the paytaca opencode plugin (provider options.headers / chat.headers). Reinstall or restart the plugin, or run paytaca wallet info and verify the plugin loaded.',\n }));\n return;\n }\n \n // Check if there's a pending payment for this wallet\n var pendingPayload = pendingPayments.get(walletHash);\n\n // Parse the model requested by this call \u2014 used for switch detection\n // and for clearing stale pending payments tied to a previous model.\n var reqModel = '';\n try { reqModel = JSON.parse(body).model || ''; } catch (e) {}\n \n // If there's a pending payment for a different model, clear it so the\n // new request can be forwarded fresh to Django. This prevents the\n // proxy from re-showing a stale payment prompt when the user switches\n // to a different model mid-conversation.\n if (pendingPayload) {\n if (reqModel && pendingPayload.modelId && reqModel !== pendingPayload.modelId) {\n pendingPayments.delete(walletHash);\n pendingPayload = null;\n }\n }\n\n // Model-switch detection: remember which model this wallet last used.\n // When a switch is detected, log it \u2014 opencode carries the full\n // conversation history on the next message, so the last prompt is\n // effectively re-sent to the new model. If that model has no credits,\n // the standard 402 flow shows the buy-plan prompt for it.\n const prevModel = lastModelPerWallet.get(walletHash) || '';\n if (reqModel && prevModel && reqModel !== prevModel) {\n log('Model switch detected for wallet ' + (walletHash?.substring(0, 16) || 'none') + ': ' + prevModel + ' -> ' + reqModel);\n }\n if (reqModel) {\n lastModelPerWallet.set(walletHash, reqModel);\n }\n \n if (pendingPayload) {\n // Check for tier selection first\n if (pendingPayload.step === 'tier_select' && pendingPayload.tiers && pendingPayload.tiers.length > 0) {\n const userInput = stripSysRem(lastContent);\n const timeCmd = userInput?.trim().toLowerCase();\n if (isTimeCmd(timeCmd)) {\n await handleTimeCreditsCommand(res, walletHash);\n return;\n }\n if (isPricingCmd(timeCmd)) {\n await handlePricingCommand(res);\n return;\n }\n let selectedIndex = -1;\n \n // Try to parse user input as a number (1-based)\n const num = parseInt(userInput, 10);\n if (!isNaN(num) && num >= 1 && num <= pendingPayload.tiers.length) {\n selectedIndex = num - 1;\n } else {\n // Try to match by duration minutes\n for (let i = 0; i < pendingPayload.tiers.length; i++) {\n if (userInput === String(pendingPayload.tiers[i].minutes) ||\n userInput === pendingPayload.tiers[i].minutes + ' minutes' ||\n userInput === pendingPayload.tiers[i].minutes + ' min') {\n selectedIndex = i;\n break;\n }\n }\n }\n \n if (selectedIndex >= 0) {\n const selectedTier = pendingPayload.tiers[selectedIndex];\n pendingPayload.durationMinutes = selectedTier.minutes;\n pendingPayload.step = 'processing';\n \n log('Tier selected: ' + selectedTier.minutes + ' min for wallet ' + walletHash?.substring(0, 16) + '...');\n \n // Build extra headers for payment wrapper\n const extraHeaders = {};\n if (pendingPayload.modelId) {\n extraHeaders['X-Model-Id'] = pendingPayload.modelId;\n }\n extraHeaders['X-Duration-Minutes'] = String(selectedTier.minutes);\n \n // Check wallet balance before attempting payment\n const currentBalanceSats = await getWalletBalance();\n if (currentBalanceSats !== null && selectedTier.price_sats && currentBalanceSats < selectedTier.price_sats) {\n log('Insufficient balance for wallet ' + walletHash?.substring(0, 16) + '...: ' + currentBalanceSats + ' sats < ' + selectedTier.price_sats + ' sats needed');\n pendingPayments.delete(walletHash);\n const addr = await getReceivingAddress();\n const neededBch = (selectedTier.price_sats - currentBalanceSats) / 100000000;\n const neededLine = addr ? '\\n\\n\uD83D\uDCE5 **Fund your wallet:** \\`' + addr + '\\`\\nOr run: paytaca receive (in another terminal) for QR code' : '';\n sseLine(res, {\n id: 'balance-err',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: PROXY_MARKER + '\\n\\n\u274C **Insufficient balance** \u2014 You have **' + (currentBalanceSats / 100000000).toFixed(8) + ' BCH** but need **' + (selectedTier.price_sats / 100000000).toFixed(8) + ' BCH** for this plan. Top up at least **' + neededBch.toFixed(8) + ' BCH** more.' + neededLine + '\\n\\nType \\`balance\\` to re-check or try a different plan:' }, finish_reason: 'stop' }],\n });\n sseLine(res, {\n id: 'balance-err-done',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n });\n sseDone(res);\n res.end();\n return;\n }\n \n // Keepalive during payment processing\n if (!res.headersSent) {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n 'X-Payment-Processing': 'true',\n });\n }\n const keepalive = setInterval(() => {\n if (res.destroyed || res.writableEnded) { clearInterval(keepalive); return; }\n res.write(': keepalive\\n\\n');\n }, 2000);\n\n runPaytacaPay(BACKEND_URL + '/v1', pendingPayload.body, walletHash, extraHeaders, async (err, responseJson) => {\n pendingPayments.delete(walletHash);\n clearInterval(keepalive);\n\n if (err) {\n log('paytaca pay failed: ' + err.message);\n if (res.headersSent && !res.destroyed && !res.writableEnded) {\n await streamPaymentFailureAndRetry(res, walletHash, pendingPayload, '\\n\\n\u274C Payment failed: ' + err.message + '\\n\\n');\n } else if (!res.headersSent) {\n try {\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Payment failed', message: err.message, details: 'Please check your wallet balance and try again.' }));\n } catch (e) { log('Failed to send payment error response: ' + e.message); }\n } else {\n log('Cannot send payment failure \u2014 response already ended or destroyed');\n }\n return;\n }\n \n if (!responseJson.success) {\n const isTimeout = responseJson.timeout;\n const sseContent = isTimeout ? '\\n\\n\u23F1\uFE0F Response timed out. Your payment was processed \\u2014 check credits with \\'credits\\' and try again' : '\\n\\n\u274C Payment failed: ' + (responseJson.error || 'Unknown error') + '\\n\\n';\n const errLabel = isTimeout ? 'Response timeout' : 'Payment failed';\n const errMsg = isTimeout ? 'Response timed out. Payment was processed.' : responseJson.error;\n const errDetails = isTimeout ? 'Try again or check credits with \\'credits\\'.' : 'Please check your balance and try again.';\n if (res.headersSent && !res.destroyed && !res.writableEnded) {\n if (isTimeout) {\n try {\n sseLine(res, { id: 'pay-err', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: 'deepseek/deepseek-v4-flash', choices: [{ index: 0, delta: { content: PROXY_MARKER + sseContent }, finish_reason: 'stop' }] });\n sseLine(res, { id: 'pay-err-done', object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });\n sseDone(res);\n res.end();\n } catch (e) { log('Failed to send timeout error via SSE: ' + e.message); }\n } else {\n await streamPaymentFailureAndRetry(res, walletHash, pendingPayload, sseContent);\n }\n } else if (!res.headersSent) {\n try {\n res.writeHead(responseJson.status || 500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: errLabel, message: errMsg, details: errDetails }));\n } catch (e) { log('Failed to send payment error JSON: ' + e.message); }\n } else {\n log('Cannot send payment error \u2014 response already ended or destroyed');\n }\n return;\n }\n \n const chatCompletion = responseJson?.data || responseJson;\n \n let wasStreaming = false;\n try {\n wasStreaming = JSON.parse(pendingPayload.body).stream === true;\n } catch {}\n \n log('paytaca pay succeeded. Returning chat response.');\n \n if (res.destroyed || res.writableEnded) {\n log('Payment succeeded but response connection is gone \u2014 cannot deliver chat response');\n return;\n }\n\n if (wasStreaming) {\n try {\n jsonToSse(res, chatCompletion, { prependContent: '\\n\uD83D\uDCB3 Payment successful \u2014 generating your response...\\n\\n' });\n } catch (e) { log('jsonToSse threw: ' + e.message); }\n } else {\n try {\n if (!res.headersSent) {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n }\n res.end(JSON.stringify(chatCompletion));\n } catch (e) { log('Failed to send non-streaming response: ' + e.message); }\n }\n });\n return;\n } else {\n // Invalid selection \u2014 reshow the prompt\n log('Invalid tier selection for wallet ' + walletHash?.substring(0, 16) + '...');\n await streamTierSelectionPrompt(res, walletHash, pendingPayload.displayName || pendingPayload.modelId || 'AI Model', pendingPayload.tiers);\n return;\n }\n }\n \n // Old flow: user responded to a yes/no payment prompt\n if (stripSysRem(lastContent) === 'yes') {\n log('Payment approved by wallet ' + walletHash?.substring(0, 16) + '...');\n pendingPayments.delete(walletHash);\n \n const extraHeaders = {};\n if (pendingPayload.modelId) {\n extraHeaders['X-Model-Id'] = pendingPayload.modelId;\n }\n if (pendingPayload.durationMinutes) {\n extraHeaders['X-Duration-Minutes'] = String(pendingPayload.durationMinutes);\n }\n \n if (!res.headersSent) {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n 'X-Payment-Processing': 'true',\n });\n }\n const keepalive = setInterval(() => {\n if (res.destroyed || res.writableEnded) { clearInterval(keepalive); return; }\n res.write(': keepalive\\n\\n');\n }, 2000);\n\n runPaytacaPay(BACKEND_URL + '/v1', pendingPayload.body, walletHash, extraHeaders, (err, responseJson) => {\n clearInterval(keepalive);\n if (err) {\n log('paytaca pay failed: ' + err.message);\n if (res.headersSent && !res.destroyed && !res.writableEnded) {\n try {\n sseLine(res, { id: 'pay-err', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: 'deepseek/deepseek-v4-flash', choices: [{ index: 0, delta: { content: PROXY_MARKER + '\\n\\n\u274C Payment failed: ' + err.message }, finish_reason: 'stop' }] });\n sseLine(res, { id: 'pay-err-done', object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });\n sseDone(res);\n res.end();\n } catch (e) { log('Failed to send payment error via SSE: ' + e.message); }\n } else if (!res.headersSent) {\n try {\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Payment failed', message: err.message, details: 'Please check your wallet balance and try again.' }));\n } catch (e) { log('Failed to send payment error JSON: ' + e.message); }\n } else {\n log('Cannot send payment failure \u2014 response already ended or destroyed');\n }\n return;\n }\n \n if (!responseJson.success) {\n const isTimeout = responseJson.timeout;\n const sseContent = isTimeout ? '\\n\\n\u23F1\uFE0F Response timed out. Your payment was processed \\u2014 check credits with \\'credits\\' and try again' : '\\n\\n\u274C Payment failed: ' + (responseJson.error || 'Unknown error');\n const errLabel = isTimeout ? 'Response timeout' : 'Payment failed';\n const errMsg = isTimeout ? 'Response timed out. Payment was processed.' : responseJson.error;\n const errDetails = isTimeout ? 'Try again or check credits with \\'credits\\'.' : 'Please check your balance and try again.';\n if (res.headersSent && !res.destroyed && !res.writableEnded) {\n try {\n sseLine(res, { id: 'pay-err', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: 'deepseek/deepseek-v4-flash', choices: [{ index: 0, delta: { content: PROXY_MARKER + sseContent }, finish_reason: 'stop' }] });\n sseLine(res, { id: 'pay-err-done', object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });\n sseDone(res);\n res.end();\n } catch (e) { log('Failed to send error via SSE: ' + e.message); }\n } else if (!res.headersSent) {\n try {\n res.writeHead(responseJson.status || 500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: errLabel, message: errMsg, details: errDetails }));\n } catch (e) { log('Failed to send error JSON: ' + e.message); }\n } else {\n log('Cannot send payment error \u2014 response already ended or destroyed');\n }\n return;\n }\n \n const chatCompletion = responseJson?.data || responseJson;\n \n let wasStreaming = false;\n try {\n wasStreaming = JSON.parse(pendingPayload.body).stream === true;\n } catch {}\n \n log('paytaca pay succeeded. Returning chat response.');\n\n if (res.destroyed || res.writableEnded) {\n log('Payment succeeded but response connection is gone \u2014 cannot deliver chat response');\n return;\n }\n \n if (wasStreaming) {\n try {\n jsonToSse(res, chatCompletion, { prependContent: '\\n\uD83D\uDCB3 Payment successful \u2014 generating your response...\\n\\n' });\n } catch (e) { log('jsonToSse threw: ' + e.message); }\n } else {\n try {\n if (!res.headersSent) {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n }\n res.end(JSON.stringify(chatCompletion));\n } catch (e) { log('Failed to send non-streaming response: ' + e.message); }\n }\n });\n return;\n \n } else if (stripSysRem(lastContent) === 'no') {\n log('Payment declined by wallet ' + walletHash?.substring(0, 16) + '...');\n pendingPayments.delete(walletHash);\n\n const addr = await getReceivingAddress();\n const fundMsg = addr\n ? 'Fund your wallet: ' + addr\n : 'You can fund your wallet by running: paytaca receive';\n\n const declineCompletion = {\n id: 'payment-declined',\n object: 'chat.completion',\n created: Math.floor(Date.now() / 1000),\n model: pendingPayload.modelId || 'deepseek/deepseek-v4-flash',\n choices: [{\n index: 0,\n message: {\n role: 'assistant',\n content: PROXY_MARKER + 'Payment declined. Chat cannot continue without funding.\\n\\n' + fundMsg,\n },\n finish_reason: 'stop',\n }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n };\n jsonToSse(res, declineCompletion);\n return;\n \n } else {\n const innerCmd = stripSysRem(lastContent?.trim().toLowerCase());\n if (isTimeCmd(innerCmd)) {\n await handleTimeCreditsCommand(res, walletHash);\n return;\n }\n if (isPricingCmd(innerCmd)) {\n await handlePricingCommand(res);\n return;\n }\n log('New message while payment pending for wallet ' + walletHash?.substring(0, 16) + '...');\n }\n }\n \n // Handle credits command \u2014 show remaining time credits\n const cmd = stripSysRem(lastContent?.trim().toLowerCase());\n if (isTimeCmd(cmd)) {\n await handleTimeCreditsCommand(res, walletHash);\n return;\n }\n // Handle pricing command \u2014 show all models grouped by tier\n if (isPricingCmd(cmd)) {\n await handlePricingCommand(res);\n return;\n }\n \n let isStreaming = true;\n try { isStreaming = JSON.parse(body).stream !== false; } catch {}\n\n const handleResponse = async (err, statusCode, headers, responseBody) => {\n if (err) {\n if (!res.headersSent) {\n log('Django connection error: ' + err.message);\n res.writeHead(502, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Backend unavailable', details: err.message }));\n }\n return;\n }\n\n if (statusCode === 402) {\n let requestModel = null;\n try { requestModel = JSON.parse(body).model || null; } catch (e) {}\n log('402 intercepted for wallet ' + walletHash?.substring(0, 16)\n + ' x-model-id=' + (req.headers['x-model-id'] || 'null')\n + ' body.model=' + (requestModel || 'null'));\n \n // Parse 402 response for model_id and price_tiers\n let modelId = null;\n let displayName = null;\n let tiers = null;\n try {\n const parsed = JSON.parse(responseBody);\n modelId = parsed.model_id || null;\n displayName = parsed.display_name || null;\n tiers = parsed.price_tiers || null;\n log('402 body: model=' + (modelId || 'null')\n + ' display=' + (displayName || 'null')\n + ' tiers=' + (Array.isArray(tiers) ? tiers.length : String(tiers))\n + ' reason=' + (parsed.reason || 'n/a')\n + ' bodyPrefix=' + responseBody.substring(0, 160).replace(/\\n/g, ' '));\n } catch (e) {\n log('Could not parse 402 body: ' + e.message);\n }\n \n pendingPayments.set(walletHash, {\n reqId: proxyReqId,\n body: body,\n modelId: modelId,\n displayName: displayName,\n durationMinutes: null,\n tiers: tiers,\n step: tiers ? 'tier_select' : 'approval'\n });\n \n if (tiers && tiers.length > 0) {\n // New flow: show tier selection prompt. Also tell the user about\n // other models that still have paid credits, so they can switch\n // instead of buying a plan for the currently selected model.\n const otherModels = await getOtherModelsWithCredits(walletHash, modelId || requestModel);\n await streamTierSelectionPrompt(res, walletHash, displayName || modelId || 'AI Model', tiers, otherModels);\n return;\n }\n \n // Check session status to determine if this is a renewal\n let isRenewal = false;\n let tokensUsed = 0;\n let tokenLimit = 50000;\n let timeRemainingSeconds = 0;\n \n let statusModelId = modelId;\n let statusSnapshot = null;\n try {\n // Extract model from the original request body if not in 402\n if (!statusModelId) {\n try {\n const bodyParsed = JSON.parse(body);\n statusModelId = bodyParsed.model || null;\n } catch (e) {}\n }\n \n const statusPath = '/v1/wallet/status' + (statusModelId ? '?model_id=' + encodeURIComponent(statusModelId) : '');\n const statusResponse = await new Promise((resolve, reject) => {\n const statusReq = REQUester.get({\n hostname: DJANGO_HOST,\n port: DJANGO_PORT,\n path: statusPath,\n headers: { 'X-Wallet-Hash': walletHash }\n }, (res) => {\n let data = '';\n res.on('data', chunk => data += chunk);\n res.on('end', () => {\n try {\n resolve(JSON.parse(data));\n } catch {\n resolve({});\n }\n });\n });\n statusReq.on('error', reject);\n statusReq.setTimeout(5000, () => reject(new Error('timeout')));\n });\n \n if (statusResponse) {\n statusSnapshot = statusResponse;\n tokensUsed = statusResponse.tokens_used || 0;\n tokenLimit = statusResponse.token_limit || 50000;\n timeRemainingSeconds = statusResponse.time_remaining_seconds || 0;\n \n // Renewal if session has been used (tokens > 0 or time > 0) but is now exhausted\n isRenewal = (tokensUsed > 0 || statusResponse.time_used_seconds > 0) &&\n (!statusResponse.session_active || timeRemainingSeconds <= 0);\n }\n } catch (err) {\n log('Failed to check session status: ' + err.message);\n }\n \n log('402 status model=' + (statusModelId || 'null')\n + ' snapshot=' + JSON.stringify(statusSnapshot)\n + ' isRenewal=' + isRenewal\n + ' timeRemaining=' + timeRemainingSeconds\n + ' tokensUsed=' + tokensUsed\n + ' tokenLimit=' + tokenLimit);\n \n const lowBalanceOtherModels = await getOtherModelsWithCredits(walletHash, statusModelId || modelId);\n await streamLowBalanceNotice(res, displayName || statusModelId || modelId || 'AI Model', lowBalanceOtherModels);\n } else {\n if (res.headersSent) {\n log('Streaming response completed and already sent');\n const settled = pendingPayments.get(walletHash);\n if (settled && settled.reqId === proxyReqId) {\n pendingPayments.delete(walletHash);\n }\n return;\n }\n\n log('Forwarding normal response to OpenCode: status=' + statusCode + ', bodyLen=' + responseBody.length);\n const settled = pendingPayments.get(walletHash);\n if (settled && settled.reqId === proxyReqId) {\n pendingPayments.delete(walletHash);\n }\n res.writeHead(statusCode, {\n 'Content-Type': headers['content-type'] || 'application/json',\n });\n res.end(responseBody);\n }\n };\n\n if (isStreaming) {\n forwardStreaming(req, res, body, handleResponse);\n } else {\n forwardToDjango(req, body, handleResponse);\n }\n \n } catch (err) {\n log('Error: ' + err.message);\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Internal proxy error' }));\n }\n });\n});\n\nserver.on('error', (err) => {\n if (err.code === 'EADDRINUSE') {\n log('Port ' + PROXY_PORT + ' is already in use. Another proxy instance may be running.');\n log('Exiting cleanly (code 0) so the plugin can detect the existing proxy.');\n process.exit(0);\n }\n log('Server error: ' + err.message);\n process.exit(1);\n});\n\nserver.listen(PROXY_PORT, () => {\n log('Paytaca AI Proxy running on http://localhost:' + PROXY_PORT);\n log('Forwarding to ' + BACKEND_URL);\n log('Discovery: http://localhost:' + PROXY_PORT + '/v1/config');\n log('Managed by OpenCode plugin');\n});\n\n// Graceful shutdown\nprocess.on('SIGTERM', () => {\n log('Shutting down proxy...');\n server.close(() => process.exit(0));\n});\n\nprocess.on('SIGINT', () => {\n log('Shutting down proxy...');\n server.close(() => process.exit(0));\n});\n";
1
+ export declare const PROXY_SCRIPT_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca AI Proxy\n * \n * Sits between OpenCode and the Django backend.\n * - Auto-starts by OpenCode plugin\n * - On 402, returns SSE typewriter loading sequence + synthetic payment prompt\n * - Stores pending payments; handles \"yes\"/\"no\" approval internally\n * - Uses only Node.js built-in modules\n * \n * Usage: node proxy.js [backend_url] [proxy_port]\n * Example: node proxy.js https://api.paytaca.ai 8001\n */\n\nconst http = require('http');\nconst https = require('https');\nconst { spawn } = require('child_process');\nconst { Transform } = require('stream');\nconst fs = require('fs');\nconst path = require('path');\nconst os = require('os');\n\nconst PROXY_PORT = parseInt(process.argv[3]) || 8001;\nconst BACKEND_URL = process.argv[2] || 'https://api.paytaca.ai';\nconst parsedUrl = new URL(BACKEND_URL);\nconst DJANGO_HOST = parsedUrl.hostname;\nconst DJANGO_PORT = parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80);\nconst REQUester = parsedUrl.protocol === 'https:' ? https : http;\n\n// Logging setup: write to file instead of console\nconst LOG_DIR = path.join(os.homedir(), '.opencode-paytaca');\nif (!fs.existsSync(LOG_DIR)) {\n fs.mkdirSync(LOG_DIR, { recursive: true });\n}\nconst LOG_FILE = path.join(LOG_DIR, 'proxy.log');\nconst logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });\n\nfunction log(message) {\n const timestamp = new Date().toISOString();\n logStream.write(timestamp + ' [Proxy] ' + message + '\\n');\n}\n\n// Store pending payment requests per wallet hash\n// Each entry: { body, modelId, displayName, durationMinutes, tiers[], step }\n// step: 'tier_select' (user must pick a tier) or 'approval' (yes/no)\nconst pendingPayments = new Map();\n\n// Track the last model used per wallet so we can detect model switches and\n// make sure a switched-to model never hits a stale payment prompt.\nconst lastModelPerWallet = new Map();\n\n// Monotonic id per incoming request. A response may only clear the pending\n// payment created by its own request \u2014 concurrent requests from opencode share\n// the wallet hash, and a plain 200 finishing mid-payment must not clobber the\n// pending entry another request just created (that made tier selections\n// \"2\"/\"3\" fall through to a fresh 402 and re-show the prompt forever).\nlet requestCounter = 0;\n\n// Utility: run shell command and return output\nfunction runCommand(cmd, args = []) {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { shell: false });\n let stdout = '';\n let stderr = '';\n \n child.stdout.on('data', (data) => { stdout += data.toString(); });\n child.stderr.on('data', (data) => { stderr += data.toString(); });\n \n child.on('close', (code) => {\n if (code === 0) resolve(stdout.trim());\n else reject(new Error(stderr.trim() || 'Command exited with code ' + code));\n });\n \n child.on('error', (err) => reject(err));\n });\n}\n\n// Get paytaca command from environment or default to 'paytaca'\nconst PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';\n\n// Utility: check if paytaca CLI exists\nasync function checkPaytacaCli() {\n try {\n // Try to run version check\n await runCommand(PAYTACA_CMD, ['--version']);\n return true;\n } catch {\n return false;\n }\n}\n\n// Utility: get wallet balance in sats\nasync function getWalletBalance() {\n try {\n const output = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = output.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (match) {\n const bch = parseFloat(match[1]);\n return Math.floor(bch * 100000000);\n }\n return null;\n } catch (err) {\n log('Failed to get wallet balance: ' + err.message);\n return null;\n }\n}\n\n// Utility: get receiving address\nasync function getReceivingAddress() {\n try {\n const output = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = output.match(/Address:\\s*(bitcoincash:[a-zA-Z0-9]+)/i);\n return match ? match[1] : null;\n } catch {\n return null;\n }\n}\n\n// Utility: check if wallet exists\nasync function checkWallet() {\n try {\n await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n return true;\n } catch {\n return false;\n }\n}\n\n// Format seconds as MM:SS or HH:MM:SS\nfunction formatDuration(totalSeconds) {\n const hours = Math.floor(totalSeconds / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const secs = totalSeconds % 60;\n if (hours > 0) {\n return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0');\n }\n return minutes + ':' + String(secs).padStart(2, '0');\n}\n\n// SSE helper: write a data line\nfunction sseLine(res, data) {\n res.write('data: ' + JSON.stringify(data) + '\\n\\n');\n}\n\n// SSE helper: write [DONE]\nfunction sseDone(res) {\n res.write('data: [DONE]\\n\\n');\n}\n\n// Zero-width marker prepended to every synthetic proxy message (tier\n// prompts, credits/plans output, payment notices). The opencode plugin\n// strips marker-carrying assistant messages from LLM context \u2014 proxy chatter\n// is not relevant to the coding session \u2014 while the user still sees them in\n// the UI (zero-width characters don't render).\nconst PROXY_MARKER = String.fromCharCode(0x200b, 0x200b, 0x200b, 0x200b);\n\n// Stream the tier-selection prompt body (SSE lines) into an in-progress response.\n// When includeRole is false the leading role delta is skipped, so the body can be\n// appended to a stream that already emitted content (e.g. after a payment failure).\nasync function streamTierSelectionBody(res, walletHash, modelName, tiers, includeRole, otherModels) {\n if (includeRole !== false) {\n sseLine(res, {\n id: 'tier-1',\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: modelName,\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n }\n\n // Loading sequence\n sseLine(res, {\n id: 'tier-2',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: PROXY_MARKER + '\u23F3 Initializing Paytaca AI provider...\\n' }, finish_reason: null }],\n });\n\n const hasCli = await checkPaytacaCli();\n sseLine(res, {\n id: 'tier-3',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Checking Paytaca CLI... ' }, finish_reason: null }],\n });\n sseLine(res, {\n id: 'tier-4',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: hasCli ? '\u2705\\n' : '\u274C Not found\\n' }, finish_reason: null }],\n });\n\n const hasWallet = hasCli ? await checkWallet() : false;\n sseLine(res, {\n id: 'tier-5',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Checking wallet... ' }, finish_reason: null }],\n });\n sseLine(res, {\n id: 'tier-6',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: hasWallet ? '\u2705\\n' : '\u274C Not found\\n' }, finish_reason: null }],\n });\n\n const balanceSats = hasWallet ? await getWalletBalance() : null;\n sseLine(res, {\n id: 'tier-7',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: 'Fetching balance... ' }, finish_reason: null }],\n });\n\n let balanceStr;\n if (balanceSats !== null) {\n const bch = (balanceSats / 100000000).toFixed(8);\n balanceStr = bch + ' BCH';\n sseLine(res, {\n id: 'tier-8',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u2705 \u2014 ' + balanceStr + '\\n\\n' }, finish_reason: null }],\n });\n } else {\n balanceStr = 'Unable to check';\n sseLine(res, {\n id: 'tier-8',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\\n' + balanceStr + '\\n\\n' }, finish_reason: null }],\n });\n }\n\n // Tier selection\n sseLine(res, {\n id: 'tier-9',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\uD83D\uDCB3 Select a plan for **' + (modelName || 'AI Model') + '**\\n\\n' }, finish_reason: null }],\n });\n\n // Build all tier lines into one string so backtick markdown renders\n // consistently (same as the 'plans' command).\n let tiersContent = '';\n for (let i = 0; i < tiers.length; i++) {\n const tier = tiers[i];\n const bchAmount = (tier.price_sats / 100000000).toFixed(8);\n const label = '`(' + String(i + 1) + ')` ';\n // Display USD price if available, fall back to PHP for legacy backends\n const priceDisplay = tier.price_usd !== undefined && tier.price_usd !== null\n ? 'USD ' + tier.price_usd.toFixed(4)\n : 'PHP ' + (tier.price_php ? tier.price_php.toFixed(2) : '?.??');\n tiersContent += label + tier.minutes + ' minutes \u2014 ' + priceDisplay + ' (' + bchAmount + ' BCH)\\n';\n }\n sseLine(res, {\n id: 'tier-10',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: tiersContent }, finish_reason: null }],\n });\n\n sseLine(res, {\n id: 'tier-11',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\\nEnter a number (1-' + tiers.length + '), e.g. type ' + tiers[0].minutes + ':\\n' }, finish_reason: 'stop' }],\n });\n\n // If other models still have paid credits, tell the user they can switch\n // instead of buying a new plan (only when there is something to suggest).\n if (otherModels && otherModels.length > 0) {\n sseLine(res, {\n id: 'tier-9b',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: otherModelsHint(otherModels) }, finish_reason: null }],\n });\n }\n\n sseLine(res, {\n id: 'tier-12',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n}\n\n// Build and stream a full tier-selection prompt (headers + body + [DONE]) to the client.\nasync function streamTierSelectionPrompt(res, walletHash, modelName, tiers, otherModels) {\n if (!res.headersSent) {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'X-Payment-Required': 'true',\n 'Connection': 'keep-alive',\n });\n }\n await streamTierSelectionBody(res, walletHash, modelName, tiers, true, otherModels);\n sseDone(res);\n res.end();\n}\n\n// Build and stream SSE loading sequence + payment prompt\n// Stream SSE notice when the upstream (OpenRouter) account lacks balance to fund\n// the request. Replaces the old single-tier yes/no approval prompt.\nasync function streamLowBalanceNotice(res, modelName, otherModels) {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'X-Payment-Required': 'true',\n 'Connection': 'keep-alive',\n });\n\n sseLine(res, {\n id: 'lb-1',\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: modelName || 'AI Model',\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n\n // Include the other-models hint (when available) so the user knows they can\n // switch to a model that still has credits instead of being stuck.\n const hint = otherModelsHint(otherModels);\n sseLine(res, {\n id: 'lb-2',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: PROXY_MARKER + '\u26A0\uFE0F OpenRouter balance is low \u2014 please top up before continuing.\\n' + hint }, finish_reason: 'stop' }],\n });\n\n sseLine(res, {\n id: 'lb-3',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n\n sseDone(res);\n res.end();\n}\n\n// Forward request to Django and return response (buffered, for non-streaming)\nfunction forwardToDjango(req, body, callback) {\n const options = {\n hostname: DJANGO_HOST,\n port: DJANGO_PORT,\n path: req.url,\n method: req.method,\n headers: {\n 'Content-Type': req.headers['content-type'] || 'application/json',\n 'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n\n const startTime = Date.now();\n log('forwardToDjango -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);\n\n let timeoutCleared = false;\n const djangoReq = REQUester.request(options, (djangoRes) => {\n // Response started; clear the connect/first-byte timeout so slow streams aren't killed.\n if (!timeoutCleared) {\n djangoReq.clearTimeout();\n timeoutCleared = true;\n }\n\n let responseBody = '';\n djangoRes.on('data', chunk => { responseBody += chunk; });\n djangoRes.on('end', () => {\n const elapsed = Date.now() - startTime;\n log('Django responded in ' + elapsed + 'ms: status=' + djangoRes.statusCode + ', bodyLen=' + responseBody.length);\n callback(null, djangoRes.statusCode, djangoRes.headers, responseBody);\n });\n });\n\n djangoReq.setTimeout(300000, () => {\n djangoReq.destroy();\n callback(new Error('Django request timed out after 300s'));\n });\n\n djangoReq.on('error', (err) => {\n if (!timeoutCleared) {\n djangoReq.clearTimeout();\n timeoutCleared = true;\n }\n log('Django request error: ' + err.message);\n callback(err);\n });\n\n djangoReq.write(body);\n djangoReq.end();\n}\n\n// Forward streaming request to Django\nfunction forwardStreaming(req, res, body, callback) {\n const options = {\n hostname: DJANGO_HOST,\n port: DJANGO_PORT,\n path: req.url,\n method: req.method,\n headers: {\n 'Content-Type': req.headers['content-type'] || 'application/json',\n 'X-Wallet-Hash': req.headers['x-wallet-hash'] || '',\n 'Content-Length': Buffer.byteLength(body),\n },\n };\n\n const startTime = Date.now();\n log('forwardStreaming -> ' + options.method + ' ' + options.hostname + ':' + options.port + options.path);\n\n let timeoutCleared = false;\n const djangoReq = REQUester.request(options, (djangoRes) => {\n // Response started; clear the connect/first-byte timeout so slow streams aren't killed.\n if (!timeoutCleared) {\n djangoReq.clearTimeout();\n timeoutCleared = true;\n }\n\n const elapsed = Date.now() - startTime;\n log('Django response started in ' + elapsed + 'ms: status=' + djangoRes.statusCode);\n\n if (djangoRes.statusCode === 402) {\n let responseBody = '';\n djangoRes.on('data', chunk => { responseBody += chunk; });\n djangoRes.on('end', () => {\n callback(null, 402, djangoRes.headers, responseBody);\n });\n return;\n }\n\n res.writeHead(djangoRes.statusCode, {\n 'Content-Type': djangoRes.headers['content-type'] || 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n });\n\n if (res.socket) {\n res.socket.setNoDelay(true);\n }\n\n // Buffer SSE data at event boundaries and inject keepalive between events.\n let sseBuffer = '';\n let lastActivity = Date.now();\n let streamingDone = false;\n let doneForwarded = false;\n\n // Watchdog: inject keepalive only when buffer is empty (between complete events)\n const keepaliveTimer = setInterval(() => {\n if (streamingDone || res.writableEnded || res.destroyed) {\n clearInterval(keepaliveTimer);\n return;\n }\n const now = Date.now();\n if (now - lastActivity >= 2000 && sseBuffer.length === 0) {\n try {\n res.write(': keepalive\\n\\n');\n lastActivity = now;\n } catch (err) {\n log('Keepalive write error: ' + err.message);\n clearInterval(keepaliveTimer);\n }\n }\n }, 500);\n\n const cleanup = () => {\n streamingDone = true;\n clearInterval(keepaliveTimer);\n };\n\n var diagCounter = 0;\n djangoRes.on('data', (chunk) => {\n var chunkStr = chunk.toString();\n var chunkIdx = ++diagCounter;\n sseBuffer += chunkStr;\n lastActivity = Date.now();\n\n var okCount = (sseBuffer.match(/:ok/g) || []).length;\n if (okCount > 0) {\n log('CHUNK#' + chunkIdx + ': ' + okCount + ' :ok in buffer (len=' + sseBuffer.length + ')');\n }\n\n // Strip upstream SSE \":ok\" keepalive comments from anywhere in the buffer.\n sseBuffer = sseBuffer.replace(/:ok(?:\\n)?/g, '');\n if (okCount > 0) {\n log('AFTER: stripped ' + okCount + ' :ok, buffer len=' + sseBuffer.length);\n }\n\n var extractedCount = 0;\n let idx;\n while ((idx = sseBuffer.indexOf('\\n\\n')) !== -1) {\n const event = sseBuffer.substring(0, idx + 2);\n sseBuffer = sseBuffer.substring(idx + 2);\n const lines = event.split('\\n').filter(l => !/^:/.test(l) && l.length > 0);\n if (lines.length === 0) continue;\n const cleanEvent = lines.join('\\n') + '\\n\\n';\n extractedCount++;\n var dataContent = lines.map(function(l) { return l.replace(/^data: ?/, ''); }).join('');\n if (dataContent === '[DONE]') { doneForwarded = true; }\n var lastChar = dataContent.slice(-1);\n if (dataContent !== '[DONE]' && lastChar !== '}' && lastChar !== ']') {\n log('FLUSH: truncated event #' + extractedCount + ' (len=' + dataContent.length + ', end=' + JSON.stringify(dataContent.slice(-30)) + ')');\n }\n try {\n res.write(cleanEvent);\n } catch (err) {\n cleanup();\n log('Write error: ' + err.message);\n return;\n }\n }\n if (extractedCount > 0) {\n log('EXTRACT: forwarded ' + extractedCount + ' events in chunk#' + chunkIdx + ', buffer remaining len=' + sseBuffer.length);\n }\n });\n\n djangoRes.on('end', () => {\n if (streamingDone) {\n return;\n }\n sseBuffer = sseBuffer.replace(/:ok(?:\\n)?/g, '');\n if (sseBuffer) {\n // Ensure the final written data ends with \\n\\n so the client recognizes the event boundary\n if (sseBuffer.length < 2 || sseBuffer.substring(sseBuffer.length - 2) !== '\\n\\n') {\n sseBuffer += '\\n\\n';\n }\n log('END: writing remaining buffer len=' + sseBuffer.length + ' start=' + JSON.stringify(sseBuffer.substring(0, 80)));\n try { res.write(sseBuffer); } catch (e) {}\n }\n if (!doneForwarded) {\n log('Injecting [DONE] \u2014 upstream closed without sending it');\n try { res.write('data: [DONE]\\n\\n'); } catch (e) {}\n }\n cleanup();\n try { res.end(); } catch (e) {}\n log('Streaming response completed' + (doneForwarded ? '' : ' (injected [DONE])'));\n callback(null, djangoRes.statusCode, {}, '');\n });\n\n djangoRes.on('error', (err) => {\n log('Django stream error: ' + err.message);\n if (!streamingDone) {\n cleanup();\n }\n if (!res.writableEnded) {\n try {\n res.end();\n } catch (e) {}\n }\n callback(null, 200, {}, '');\n });\n\n res.on('close', () => {\n cleanup();\n log('Client connection closed');\n });\n\n res.on('error', (err) => {\n cleanup();\n log('Client connection error: ' + err.message);\n });\n });\n\n djangoReq.setTimeout(300000, () => {\n djangoReq.destroy();\n callback(new Error('Django streaming request timed out after 300s'));\n });\n\n djangoReq.on('error', (err) => {\n if (!timeoutCleared) {\n djangoReq.clearTimeout();\n timeoutCleared = true;\n }\n log('Django streaming request error: ' + err.message);\n callback(err);\n });\n\n djangoReq.write(body);\n djangoReq.end();\n}\n\n// Force stream=false in body because paytaca pay reads the response as text\nfunction forceNonStreaming(body) {\n try {\n const data = JSON.parse(body);\n data.stream = false;\n return JSON.stringify(data);\n } catch {\n return body;\n }\n}\n\n// Convert a chat.completion JSON object to SSE format\nfunction jsonToSse(res, chatCompletion, opts) {\n opts = opts || {};\n if (res.destroyed || res.writableEnded) {\n log('jsonToSse: response already destroyed/ended, cannot send SSE');\n return;\n }\n const message = chatCompletion.choices?.[0]?.message || {};\n const content = message.content || '';\n const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : null;\n const model = chatCompletion.model || chatCompletion.model_id || 'deepseek/deepseek-v4-flash';\n const created = chatCompletion.created || Math.floor(Date.now() / 1000);\n\n if (!res.headersSent) {\n try {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n });\n } catch (e) {\n log('jsonToSse writeHead failed: ' + e.message);\n return;\n }\n }\n\n try {\n sseLine(res, {\n id: 'chatcmpl-1',\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n } catch (e) {\n log('jsonToSse: failed to write role delta: ' + e.message);\n }\n\n const allContent = (opts.prependContent || '') + content;\n const chunkSize = 20;\n let chunksWritten = 0;\n for (let i = 0; i < allContent.length; i += chunkSize) {\n try {\n sseLine(res, {\n id: 'chatcmpl-' + (i + 2),\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: { content: allContent.slice(i, i + chunkSize) }, finish_reason: null }],\n });\n chunksWritten++;\n } catch (e) {\n log('jsonToSse: failed to write content chunk ' + (i / chunkSize) + ': ' + e.message);\n break;\n }\n }\n\n let finishReason = 'stop';\n if (toolCalls && toolCalls.length > 0) {\n const toolCallDeltas = [];\n for (let i = 0; i < toolCalls.length; i++) {\n const tc = toolCalls[i] || {};\n const fn = tc.function || {};\n let args = fn.arguments;\n if (args !== undefined && typeof args !== 'string') {\n try { args = JSON.stringify(args); } catch (e) { args = String(args); }\n }\n toolCallDeltas.push({\n index: i,\n id: tc.id || ('call_' + i),\n type: 'function',\n function: {\n name: fn.name || '',\n arguments: args === undefined || args === null ? '' : String(args),\n },\n });\n }\n try {\n sseLine(res, {\n id: 'chatcmpl-tools',\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: { tool_calls: toolCallDeltas }, finish_reason: null }],\n });\n } catch (e) {\n log('jsonToSse: failed to write tool_calls: ' + e.message);\n }\n finishReason = 'tool_calls';\n }\n\n try {\n sseLine(res, {\n id: 'chatcmpl-done',\n object: 'chat.completion.chunk',\n created,\n model,\n choices: [{ index: 0, delta: {}, finish_reason: finishReason }],\n usage: chatCompletion.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n } catch (e) {\n log('jsonToSse: failed to write final delta: ' + e.message);\n }\n\n try {\n sseDone(res);\n } catch (e) {\n log('jsonToSse: failed to write [DONE]: ' + e.message);\n }\n\n try {\n res.end();\n } catch (e) {\n log('jsonToSse: res.end() failed: ' + e.message);\n }\n}\n\n// Stream a payment-failure message, then re-show the tier-selection prompt so the\n// user can retry the same or a different plan without sending another message.\n// The pending payment is restored to the tier-select step so the next tier pick is\n// handled by the proxy instead of being forwarded fresh to Django.\nasync function streamPaymentFailureAndRetry(res, walletHash, pendingPayload, message) {\n try {\n sseLine(res, {\n id: 'pay-err',\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: 'deepseek/deepseek-v4-flash',\n choices: [{ index: 0, delta: { content: PROXY_MARKER + message }, finish_reason: 'stop' }],\n });\n } catch (e) {\n }\n pendingPayload.step = 'tier_select';\n pendingPayload.durationMinutes = null;\n pendingPayments.set(walletHash, pendingPayload);\n try {\n const tiers = Array.isArray(pendingPayload.tiers) ? pendingPayload.tiers : [];\n if (tiers.length > 0) {\n await streamTierSelectionBody(res, walletHash, pendingPayload.displayName || pendingPayload.modelId || 'AI Model', tiers, false);\n }\n sseDone(res);\n res.end();\n } catch (e) {\n try { res.end(); } catch (e2) {}\n }\n}\n\n// Run paytaca pay internally and return the response\nfunction runPaytacaPay(djangoUrl, body, walletHash, extraHeaders, callback) {\n const url = djangoUrl + '/chat/completions?wallet_hash=' + encodeURIComponent(walletHash || '');\n const payBody = forceNonStreaming(body);\n\n // Write body to a temp file to avoid CLI arg length limits\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-pay-'));\n const bodyFile = path.join(tmpDir, 'body.json');\n const configFile = path.join(tmpDir, 'config.json');\n\n try {\n fs.writeFileSync(bodyFile, payBody, 'utf8');\n } catch (err) {\n return callback(new Error('Failed to write temp body file: ' + err.message));\n }\n\n const config = {\n url,\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders || {}),\n bodyFile,\n confirmed: true,\n };\n\n try {\n fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');\n } catch (err) {\n return callback(new Error('Failed to write temp config file: ' + err.message));\n }\n\n // Path to the wrapper script\n const wrapperScript = path.join(LOG_DIR, 'paytaca-pay-wrapper.mjs');\n log('Running paytaca pay via wrapper script...');\n\n const child = spawn('node', [wrapperScript, configFile], { shell: false });\n let stdout = '';\n let stderr = '';\n\n child.stdout.on('data', (data) => { \n stdout += data.toString(); \n });\n child.stderr.on('data', (data) => { \n stderr += data.toString(); \n });\n\n child.on('close', (code) => {\n // Clean up temp files\n try {\n fs.unlinkSync(bodyFile);\n fs.unlinkSync(configFile);\n fs.rmdirSync(tmpDir);\n } catch {}\n\n if (code === 0) {\n try {\n const responseJson = JSON.parse(stdout.trim());\n callback(null, responseJson);\n } catch (err) {\n callback(new Error('Could not parse paytaca pay response: ' + err.message));\n }\n } else {\n // Try to extract error from stdout (wrapper writes JSON errors to stdout, not stderr)\n let wrapperErr = stderr.trim();\n if (!wrapperErr) {\n try {\n const parsed = JSON.parse(stdout.trim());\n wrapperErr = parsed.error || 'Unknown error';\n } catch {\n wrapperErr = stdout.trim() || 'paytaca pay wrapper exited with code ' + code;\n }\n }\n callback(new Error(wrapperErr));\n }\n });\n\n child.on('error', (err) => {\n // Clean up temp files on error\n try {\n fs.unlinkSync(bodyFile);\n fs.unlinkSync(configFile);\n fs.rmdirSync(tmpDir);\n } catch {}\n callback(new Error('Failed to run paytaca pay wrapper: ' + err.message));\n });\n}\n\n// Extract the last user message content from a chat payload\nfunction getLastUserMessageContent(body) {\n try {\n const data = JSON.parse(body);\n const messages = data.messages || [];\n for (let i = messages.length - 1; i >= 0; i--) {\n if (messages[i].role === 'user') {\n const content = messages[i].content;\n if (Array.isArray(content)) {\n const parts = [];\n for (const part of content) {\n if (part && typeof part === 'object' && part.type === 'text') {\n parts.push(part.text || '');\n } else if (typeof part === 'string') {\n parts.push(part);\n } else {\n parts.push(JSON.stringify(part));\n }\n }\n return parts.join('').trim().toLowerCase();\n }\n return String(content || '').trim().toLowerCase();\n }\n }\n return '';\n } catch {\n return '';\n }\n}\n\n// Fetch wallet status and return other models that still have remaining time\n// credits, excluding the model currently being requested. Returns an array of\n// { modelId, displayName, remainingSeconds } or [] when nothing qualifies\n// (or the status endpoint is unreachable). This powers the \"you can switch to\n// another model\" hint on 402 responses.\nasync function getOtherModelsWithCredits(walletHash, excludeModelId) {\n try {\n const statusRes = await fetch(BACKEND_URL + '/v1/wallet/status', {\n headers: { 'X-Wallet-Hash': walletHash }\n });\n if (!statusRes.ok) {\n return [];\n }\n const statusData = await statusRes.json();\n const sessions = Array.isArray(statusData.sessions) ? statusData.sessions : [];\n const others = [];\n for (const s of sessions) {\n const modelId = s.ai_model || s.model_id || '';\n if (excludeModelId && modelId && modelId === excludeModelId) {\n continue;\n }\n const remaining = Number(s.time_remaining_seconds) || 0;\n if (remaining > 0) {\n others.push({\n modelId: modelId,\n displayName: s.display_name || modelId || 'Unknown model',\n remainingSeconds: remaining,\n });\n }\n }\n return others;\n } catch (err) {\n log('Failed to check other models with credits: ' + err.message);\n return [];\n }\n}\n\n// Build a hint listing other models that still have remaining credits, so the\n// user knows they can switch instead of buying a new plan. Returns '' when\n// there is nothing worth suggesting.\nfunction otherModelsHint(otherModels) {\n if (!otherModels || otherModels.length === 0) {\n return '';\n }\n let hint = '\\n\uD83D\uDCA1 You have remaining credits on other models:\\n';\n for (const m of otherModels) {\n hint += ' - **' + m.displayName + '** \u2014 ' + formatDuration(m.remainingSeconds) + ' remaining\\n';\n }\n hint += 'Switch to one of these models to keep chatting without a new purchase.\\n\\n';\n return hint;\n}\n\nasync function handleTimeCreditsCommand(res, walletHash) {\n log('Time command for wallet ' + walletHash?.substring(0, 16) + '...');\n const statusUrl = BACKEND_URL + '/v1/wallet/status';\n const statusRes = await fetch(statusUrl, {\n headers: { 'X-Wallet-Hash': walletHash }\n });\n let content;\n if (statusRes.ok) {\n const statusData = await statusRes.json();\n const sessions = statusData.sessions || [];\n const activeSessions = sessions.filter(s => s.time_remaining_seconds > 0 && s.model_active);\n const inactiveSessions = sessions.filter(s => s.time_remaining_seconds > 0 && !s.model_active);\n const parts = [];\n if (activeSessions.length > 0) {\n parts.push('**\u23F1\uFE0F Active Time Credits:**');\n activeSessions.forEach(s => {\n const total = formatDuration(s.time_credits_seconds);\n const remaining = formatDuration(s.time_remaining_seconds);\n const used = formatDuration(s.time_used_seconds);\n parts.push(' - **' + (s.display_name || s.ai_model) + '** \u2014 ' + remaining + ' remaining of ' + total + ' (' + used + ' used)');\n });\n }\n if (inactiveSessions.length > 0) {\n parts.push('\\n**\u26A0\uFE0F Inactive Model:**');\n inactiveSessions.forEach(s => {\n const remaining = formatDuration(s.time_remaining_seconds);\n parts.push(' - **' + (s.display_name || s.ai_model) + ' (Inactive)** \u2014 ' + remaining + ' remaining');\n });\n }\n content = parts.length > 0 ? parts.join('\\n') : '\u23F1\uFE0F No active time credits.';\n } else {\n content = '\u23F1\uFE0F Unable to check time credits.';\n }\n\n sseLine(res, {\n id: 'time-1',\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: 'deepseek/deepseek-v4-flash',\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n sseLine(res, {\n id: 'time-2',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: PROXY_MARKER + content + '\\n' }, finish_reason: 'stop' }],\n });\n sseLine(res, {\n id: 'time-3',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n sseDone(res);\n res.end();\n}\n\nconst isTimeCmd = (s) => s === 'credits';\nconst isPricingCmd = (s) => s === 'plans';\n\n// List all models grouped by tier (Budget / Premium / Frontier / Other) with prices\nasync function handlePricingCommand(res) {\n log('Pricing command requested');\n let content;\n try {\n const configRes = await fetch(BACKEND_URL + '/v1/config');\n if (!configRes.ok) {\n throw new Error('config status ' + configRes.status);\n }\n const config = await configRes.json();\n const models = Array.isArray(config.models) ? config.models : [];\n const groups = { budget: [], premium: [], frontier: [], other: [] };\n for (const m of models) {\n const key = String(m.tier || '').toLowerCase();\n const groupKey = (key === 'budget' || key === 'premium' || key === 'frontier') ? key : 'other';\n groups[groupKey].push(m);\n }\n const lines = ['\uD83D\uDCCB Paytaca AI \u2014 Model Pricing'];\n const order = [\n { key: 'budget', label: 'Budget' },\n { key: 'premium', label: 'Premium' },\n { key: 'frontier', label: 'Frontier' },\n { key: 'other', label: 'Other' },\n ];\n let any = false;\n for (const g of order) {\n if (groups[g.key].length === 0) continue;\n any = true;\n lines.push('');\n lines.push(g.label);\n for (const m of groups[g.key]) {\n lines.push('');\n const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];\n if (tiers.length === 0) {\n lines.push('- **' + (m.display_name || m.id) + '**: \u2014 no pricing configured');\n continue;\n }\n const sorted = tiers.slice().sort((a, b) => (a.minutes || 0) - (b.minutes || 0));\n lines.push('**' + (m.display_name || m.id) + '**:');\n sorted.forEach((t, i) => {\n const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;\n const bch = (sats / 100000000).toFixed(8);\n const usd = typeof t.price_usd === 'number' ? t.price_usd.toFixed(4) : '?.??';\n lines.push(' `(' + String(i + 1) + ')` ' + (t.minutes || 0) + ' minutes \u2014 USD ' + usd + ' (' + bch + ' BCH)');\n });\n }\n }\n if (!any) {\n lines.push('');\n lines.push('No models available.');\n }\n content = lines.join('\\n');\n } catch (err) {\n log('Pricing command failed: ' + err.message);\n content = '\uD83D\uDCCB Unable to fetch pricing.';\n }\n\n sseLine(res, {\n id: 'price-1',\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: 'deepseek/deepseek-v4-flash',\n choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],\n });\n sseLine(res, {\n id: 'price-2',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: PROXY_MARKER + content + '\\n' }, finish_reason: 'stop' }],\n });\n sseLine(res, {\n id: 'price-3',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n });\n sseDone(res);\n res.end();\n}\n\n// Main proxy server\nconst server = http.createServer(async (req, res) => {\n // Enable CORS\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Wallet-Hash, X-Model-Id, X-Duration-Minutes, Payment-Signature, Authorization');\n \n if (req.method === 'OPTIONS') {\n res.writeHead(200);\n res.end();\n return;\n }\n \n // Discovery endpoint - fetch from backend to get actual config\n if (req.url === '/v1/config' && req.method === 'GET') {\n try {\n const backendConfig = await fetch(BACKEND_URL + '/v1/config');\n if (backendConfig.ok) {\n const config = await backendConfig.json();\n // Add proxy-specific info\n config.proxy_url = 'http://localhost:' + PROXY_PORT + '/v1';\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify(config));\n return;\n }\n } catch (err) {\n log('Failed to fetch backend config: ' + err.message);\n }\n \n // Fallback to static values if backend unavailable\n res.writeHead(200, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({\n proxy_url: 'http://localhost:' + PROXY_PORT + '/v1',\n django_url: BACKEND_URL + '/v1',\n payment_address: '',\n default_model: 'deepseek/deepseek-v4-flash',\n default_duration_minutes: 30,\n models: [\n {\n id: 'deepseek/deepseek-v4-flash',\n object: 'model',\n display_name: 'DeepSeek V4 Flash',\n provider: 'openrouter',\n price_tiers: [\n { minutes: 10, price_php: 5.0, price_sats: 45000 },\n { minutes: 30, price_php: 12.0, price_sats: 108000 },\n { minutes: 60, price_php: 20.0, price_sats: 180000 },\n ],\n },\n ],\n context_retention_hours: 2,\n }));\n return;\n }\n \n // All other endpoints \u2014 read body and forward to Django\n let body = '';\n req.on('data', chunk => { body += chunk; });\n req.on('end', async () => {\n try {\n const walletHash = req.headers['x-wallet-hash'];\n const proxyReqId = ++requestCounter;\n const lastContent = getLastUserMessageContent(body);\n \n log('Request received: wallet=' + (walletHash?.substring(0, 16) || 'none') + '..., bodyLen=' + body.length + ', pending=' + pendingPayments.has(walletHash));\n \n const stripSysRem = (s) => { let r = (s || ''), a = '<system-reminder>', b = '</system-reminder>', i = r.indexOf(a); while (i !== -1) { let j = r.indexOf(b, i); if (j === -1) break; r = r.substring(0, i) + r.substring(j + b.length); i = r.indexOf(a); } return r.trim(); };\n \n // Guard: wallet hash is required for payment flow\n if (!walletHash) {\n const redactedHeaders = {};\n for (const [k, v] of Object.entries(req.headers)) {\n const lk = k.toLowerCase();\n redactedHeaders[k] = /authorization|payment-signature|api-?key|secret|token/i.test(lk)\n ? '<redacted>'\n : v;\n }\n log('MISSING X-Wallet-Hash. Received headers: ' + JSON.stringify(redactedHeaders));\n res.writeHead(400, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({\n error: 'X-Wallet-Hash header missing',\n message: 'The X-Wallet-Hash header was not sent by the client. It is injected by the paytaca opencode plugin (provider options.headers / chat.headers). Reinstall or restart the plugin, or run paytaca wallet info and verify the plugin loaded.',\n }));\n return;\n }\n \n // Check if there's a pending payment for this wallet\n var pendingPayload = pendingPayments.get(walletHash);\n\n // Parse the model requested by this call \u2014 used for switch detection\n // and for clearing stale pending payments tied to a previous model.\n var reqModel = '';\n try { reqModel = JSON.parse(body).model || ''; } catch (e) {}\n \n // If there's a pending payment for a different model, clear it so the\n // new request can be forwarded fresh to Django. This prevents the\n // proxy from re-showing a stale payment prompt when the user switches\n // to a different model mid-conversation.\n if (pendingPayload) {\n if (reqModel && pendingPayload.modelId && reqModel !== pendingPayload.modelId) {\n pendingPayments.delete(walletHash);\n pendingPayload = null;\n }\n }\n\n // Model-switch detection: remember which model this wallet last used.\n // When a switch is detected, log it \u2014 opencode carries the full\n // conversation history on the next message, so the last prompt is\n // effectively re-sent to the new model. If that model has no credits,\n // the standard 402 flow shows the buy-plan prompt for it.\n const prevModel = lastModelPerWallet.get(walletHash) || '';\n if (reqModel && prevModel && reqModel !== prevModel) {\n log('Model switch detected for wallet ' + (walletHash?.substring(0, 16) || 'none') + ': ' + prevModel + ' -> ' + reqModel);\n }\n if (reqModel) {\n lastModelPerWallet.set(walletHash, reqModel);\n }\n \n if (pendingPayload) {\n // Check for tier selection first\n if (pendingPayload.step === 'tier_select' && pendingPayload.tiers && pendingPayload.tiers.length > 0) {\n const userInput = stripSysRem(lastContent);\n const timeCmd = userInput?.trim().toLowerCase();\n if (isTimeCmd(timeCmd)) {\n await handleTimeCreditsCommand(res, walletHash);\n return;\n }\n if (isPricingCmd(timeCmd)) {\n await handlePricingCommand(res);\n return;\n }\n let selectedIndex = -1;\n \n // Try to parse user input as a number (1-based)\n const num = parseInt(userInput, 10);\n if (!isNaN(num) && num >= 1 && num <= pendingPayload.tiers.length) {\n selectedIndex = num - 1;\n } else {\n // Try to match by duration minutes\n for (let i = 0; i < pendingPayload.tiers.length; i++) {\n if (userInput === String(pendingPayload.tiers[i].minutes) ||\n userInput === pendingPayload.tiers[i].minutes + ' minutes' ||\n userInput === pendingPayload.tiers[i].minutes + ' min') {\n selectedIndex = i;\n break;\n }\n }\n }\n \n if (selectedIndex >= 0) {\n const selectedTier = pendingPayload.tiers[selectedIndex];\n pendingPayload.durationMinutes = selectedTier.minutes;\n pendingPayload.step = 'processing';\n \n log('Tier selected: ' + selectedTier.minutes + ' min for wallet ' + walletHash?.substring(0, 16) + '...');\n \n // Build extra headers for payment wrapper\n const extraHeaders = {};\n if (pendingPayload.modelId) {\n extraHeaders['X-Model-Id'] = pendingPayload.modelId;\n }\n extraHeaders['X-Duration-Minutes'] = String(selectedTier.minutes);\n \n // Check wallet balance before attempting payment\n const currentBalanceSats = await getWalletBalance();\n if (currentBalanceSats !== null && selectedTier.price_sats && currentBalanceSats < selectedTier.price_sats) {\n log('Insufficient balance for wallet ' + walletHash?.substring(0, 16) + '...: ' + currentBalanceSats + ' sats < ' + selectedTier.price_sats + ' sats needed');\n pendingPayments.delete(walletHash);\n const addr = await getReceivingAddress();\n const neededBch = (selectedTier.price_sats - currentBalanceSats) / 100000000;\n const neededLine = addr ? '\\n\\n\uD83D\uDCE5 **Fund your wallet:** \\`' + addr + '\\`\\nOr run: paytaca receive (in another terminal) for QR code' : '';\n sseLine(res, {\n id: 'balance-err',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: PROXY_MARKER + '\\n\\n\u274C **Insufficient balance** \u2014 You have **' + (currentBalanceSats / 100000000).toFixed(8) + ' BCH** but need **' + (selectedTier.price_sats / 100000000).toFixed(8) + ' BCH** for this plan. Top up at least **' + neededBch.toFixed(8) + ' BCH** more.' + neededLine + '\\n\\nType \\`balance\\` to re-check or try a different plan:' }, finish_reason: 'stop' }],\n });\n sseLine(res, {\n id: 'balance-err-done',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n });\n sseDone(res);\n res.end();\n return;\n }\n \n // Keepalive during payment processing\n if (!res.headersSent) {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n 'X-Payment-Processing': 'true',\n });\n }\n const keepalive = setInterval(() => {\n if (res.destroyed || res.writableEnded) { clearInterval(keepalive); return; }\n res.write(': keepalive\\n\\n');\n }, 2000);\n\n runPaytacaPay(BACKEND_URL + '/v1', pendingPayload.body, walletHash, extraHeaders, async (err, responseJson) => {\n pendingPayments.delete(walletHash);\n clearInterval(keepalive);\n\n if (err) {\n log('paytaca pay failed: ' + err.message);\n if (res.headersSent && !res.destroyed && !res.writableEnded) {\n await streamPaymentFailureAndRetry(res, walletHash, pendingPayload, '\\n\\n\u274C Payment failed: ' + err.message + '\\n\\n');\n } else if (!res.headersSent) {\n try {\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Payment failed', message: err.message, details: 'Please check your wallet balance and try again.' }));\n } catch (e) { log('Failed to send payment error response: ' + e.message); }\n } else {\n log('Cannot send payment failure \u2014 response already ended or destroyed');\n }\n return;\n }\n \n if (!responseJson.success) {\n const isTimeout = responseJson.timeout;\n const sseContent = isTimeout ? '\\n\\n\u23F1\uFE0F Response timed out. Your payment was processed \\u2014 check credits with \\'credits\\' and try again' : '\\n\\n\u274C Payment failed: ' + (responseJson.error || 'Unknown error') + '\\n\\n';\n const errLabel = isTimeout ? 'Response timeout' : 'Payment failed';\n const errMsg = isTimeout ? 'Response timed out. Payment was processed.' : responseJson.error;\n const errDetails = isTimeout ? 'Try again or check credits with \\'credits\\'.' : 'Please check your balance and try again.';\n if (res.headersSent && !res.destroyed && !res.writableEnded) {\n if (isTimeout) {\n try {\n sseLine(res, { id: 'pay-err', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: 'deepseek/deepseek-v4-flash', choices: [{ index: 0, delta: { content: PROXY_MARKER + sseContent }, finish_reason: 'stop' }] });\n sseLine(res, { id: 'pay-err-done', object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });\n sseDone(res);\n res.end();\n } catch (e) { log('Failed to send timeout error via SSE: ' + e.message); }\n } else {\n await streamPaymentFailureAndRetry(res, walletHash, pendingPayload, sseContent);\n }\n } else if (!res.headersSent) {\n try {\n res.writeHead(responseJson.status || 500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: errLabel, message: errMsg, details: errDetails }));\n } catch (e) { log('Failed to send payment error JSON: ' + e.message); }\n } else {\n log('Cannot send payment error \u2014 response already ended or destroyed');\n }\n return;\n }\n \n const chatCompletion = responseJson?.data || responseJson;\n \n let wasStreaming = false;\n try {\n wasStreaming = JSON.parse(pendingPayload.body).stream === true;\n } catch {}\n \n log('paytaca pay succeeded. Returning chat response.');\n \n if (res.destroyed || res.writableEnded) {\n log('Payment succeeded but response connection is gone \u2014 cannot deliver chat response');\n return;\n }\n\n if (wasStreaming) {\n try {\n jsonToSse(res, chatCompletion, { prependContent: '\\n\uD83D\uDCB3 Payment successful \u2014 generating your response...\\n\\n' });\n } catch (e) { log('jsonToSse threw: ' + e.message); }\n } else {\n try {\n if (!res.headersSent) {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n }\n res.end(JSON.stringify(chatCompletion));\n } catch (e) { log('Failed to send non-streaming response: ' + e.message); }\n }\n });\n return;\n } else {\n // Invalid selection \u2014 reshow the prompt\n log('Invalid tier selection for wallet ' + walletHash?.substring(0, 16) + '...');\n await streamTierSelectionPrompt(res, walletHash, pendingPayload.displayName || pendingPayload.modelId || 'AI Model', pendingPayload.tiers);\n return;\n }\n }\n \n // Old flow: user responded to a yes/no payment prompt\n if (stripSysRem(lastContent) === 'yes') {\n log('Payment approved by wallet ' + walletHash?.substring(0, 16) + '...');\n pendingPayments.delete(walletHash);\n \n const extraHeaders = {};\n if (pendingPayload.modelId) {\n extraHeaders['X-Model-Id'] = pendingPayload.modelId;\n }\n if (pendingPayload.durationMinutes) {\n extraHeaders['X-Duration-Minutes'] = String(pendingPayload.durationMinutes);\n }\n \n if (!res.headersSent) {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n 'X-Payment-Processing': 'true',\n });\n }\n const keepalive = setInterval(() => {\n if (res.destroyed || res.writableEnded) { clearInterval(keepalive); return; }\n res.write(': keepalive\\n\\n');\n }, 2000);\n\n runPaytacaPay(BACKEND_URL + '/v1', pendingPayload.body, walletHash, extraHeaders, (err, responseJson) => {\n clearInterval(keepalive);\n if (err) {\n log('paytaca pay failed: ' + err.message);\n if (res.headersSent && !res.destroyed && !res.writableEnded) {\n try {\n sseLine(res, { id: 'pay-err', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: 'deepseek/deepseek-v4-flash', choices: [{ index: 0, delta: { content: PROXY_MARKER + '\\n\\n\u274C Payment failed: ' + err.message }, finish_reason: 'stop' }] });\n sseLine(res, { id: 'pay-err-done', object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });\n sseDone(res);\n res.end();\n } catch (e) { log('Failed to send payment error via SSE: ' + e.message); }\n } else if (!res.headersSent) {\n try {\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Payment failed', message: err.message, details: 'Please check your wallet balance and try again.' }));\n } catch (e) { log('Failed to send payment error JSON: ' + e.message); }\n } else {\n log('Cannot send payment failure \u2014 response already ended or destroyed');\n }\n return;\n }\n \n if (!responseJson.success) {\n const isTimeout = responseJson.timeout;\n const sseContent = isTimeout ? '\\n\\n\u23F1\uFE0F Response timed out. Your payment was processed \\u2014 check credits with \\'credits\\' and try again' : '\\n\\n\u274C Payment failed: ' + (responseJson.error || 'Unknown error');\n const errLabel = isTimeout ? 'Response timeout' : 'Payment failed';\n const errMsg = isTimeout ? 'Response timed out. Payment was processed.' : responseJson.error;\n const errDetails = isTimeout ? 'Try again or check credits with \\'credits\\'.' : 'Please check your balance and try again.';\n if (res.headersSent && !res.destroyed && !res.writableEnded) {\n try {\n sseLine(res, { id: 'pay-err', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: 'deepseek/deepseek-v4-flash', choices: [{ index: 0, delta: { content: PROXY_MARKER + sseContent }, finish_reason: 'stop' }] });\n sseLine(res, { id: 'pay-err-done', object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });\n sseDone(res);\n res.end();\n } catch (e) { log('Failed to send error via SSE: ' + e.message); }\n } else if (!res.headersSent) {\n try {\n res.writeHead(responseJson.status || 500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: errLabel, message: errMsg, details: errDetails }));\n } catch (e) { log('Failed to send error JSON: ' + e.message); }\n } else {\n log('Cannot send payment error \u2014 response already ended or destroyed');\n }\n return;\n }\n \n const chatCompletion = responseJson?.data || responseJson;\n \n let wasStreaming = false;\n try {\n wasStreaming = JSON.parse(pendingPayload.body).stream === true;\n } catch {}\n \n log('paytaca pay succeeded. Returning chat response.');\n\n if (res.destroyed || res.writableEnded) {\n log('Payment succeeded but response connection is gone \u2014 cannot deliver chat response');\n return;\n }\n \n if (wasStreaming) {\n try {\n jsonToSse(res, chatCompletion, { prependContent: '\\n\uD83D\uDCB3 Payment successful \u2014 generating your response...\\n\\n' });\n } catch (e) { log('jsonToSse threw: ' + e.message); }\n } else {\n try {\n if (!res.headersSent) {\n res.writeHead(200, { 'Content-Type': 'application/json' });\n }\n res.end(JSON.stringify(chatCompletion));\n } catch (e) { log('Failed to send non-streaming response: ' + e.message); }\n }\n });\n return;\n \n } else if (stripSysRem(lastContent) === 'no') {\n log('Payment declined by wallet ' + walletHash?.substring(0, 16) + '...');\n pendingPayments.delete(walletHash);\n\n const addr = await getReceivingAddress();\n const fundMsg = addr\n ? 'Fund your wallet: ' + addr\n : 'You can fund your wallet by running: paytaca receive';\n\n const declineCompletion = {\n id: 'payment-declined',\n object: 'chat.completion',\n created: Math.floor(Date.now() / 1000),\n model: pendingPayload.modelId || 'deepseek/deepseek-v4-flash',\n choices: [{\n index: 0,\n message: {\n role: 'assistant',\n content: PROXY_MARKER + 'Payment declined. Chat cannot continue without funding.\\n\\n' + fundMsg,\n },\n finish_reason: 'stop',\n }],\n usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n };\n jsonToSse(res, declineCompletion);\n return;\n \n } else {\n const innerCmd = stripSysRem(lastContent?.trim().toLowerCase());\n if (isTimeCmd(innerCmd)) {\n await handleTimeCreditsCommand(res, walletHash);\n return;\n }\n if (isPricingCmd(innerCmd)) {\n await handlePricingCommand(res);\n return;\n }\n log('New message while payment pending for wallet ' + walletHash?.substring(0, 16) + '...');\n }\n }\n \n // Handle credits command \u2014 show remaining time credits\n const cmd = stripSysRem(lastContent?.trim().toLowerCase());\n if (isTimeCmd(cmd)) {\n await handleTimeCreditsCommand(res, walletHash);\n return;\n }\n // Handle pricing command \u2014 show all models grouped by tier\n if (isPricingCmd(cmd)) {\n await handlePricingCommand(res);\n return;\n }\n \n let isStreaming = true;\n try { isStreaming = JSON.parse(body).stream !== false; } catch {}\n\n const handleResponse = async (err, statusCode, headers, responseBody) => {\n if (err) {\n if (!res.headersSent) {\n log('Django connection error: ' + err.message);\n res.writeHead(502, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Backend unavailable', details: err.message }));\n }\n return;\n }\n\n if (statusCode === 402) {\n let requestModel = null;\n try { requestModel = JSON.parse(body).model || null; } catch (e) {}\n log('402 intercepted for wallet ' + walletHash?.substring(0, 16)\n + ' x-model-id=' + (req.headers['x-model-id'] || 'null')\n + ' body.model=' + (requestModel || 'null'));\n \n // Parse 402 response for model_id and price_tiers\n let modelId = null;\n let displayName = null;\n let tiers = null;\n try {\n const parsed = JSON.parse(responseBody);\n modelId = parsed.model_id || null;\n displayName = parsed.display_name || null;\n tiers = parsed.price_tiers || null;\n log('402 body: model=' + (modelId || 'null')\n + ' display=' + (displayName || 'null')\n + ' tiers=' + (Array.isArray(tiers) ? tiers.length : String(tiers))\n + ' reason=' + (parsed.reason || 'n/a')\n + ' bodyPrefix=' + responseBody.substring(0, 160).replace(/\\n/g, ' '));\n } catch (e) {\n log('Could not parse 402 body: ' + e.message);\n }\n \n pendingPayments.set(walletHash, {\n reqId: proxyReqId,\n body: body,\n modelId: modelId,\n displayName: displayName,\n durationMinutes: null,\n tiers: tiers,\n step: tiers ? 'tier_select' : 'approval'\n });\n \n if (tiers && tiers.length > 0) {\n // New flow: show tier selection prompt. Also tell the user about\n // other models that still have paid credits, so they can switch\n // instead of buying a plan for the currently selected model.\n const otherModels = await getOtherModelsWithCredits(walletHash, modelId || requestModel);\n await streamTierSelectionPrompt(res, walletHash, displayName || modelId || 'AI Model', tiers, otherModels);\n return;\n }\n \n // Check session status to determine if this is a renewal\n let isRenewal = false;\n let tokensUsed = 0;\n let tokenLimit = 50000;\n let timeRemainingSeconds = 0;\n \n let statusModelId = modelId;\n let statusSnapshot = null;\n try {\n // Extract model from the original request body if not in 402\n if (!statusModelId) {\n try {\n const bodyParsed = JSON.parse(body);\n statusModelId = bodyParsed.model || null;\n } catch (e) {}\n }\n \n const statusPath = '/v1/wallet/status' + (statusModelId ? '?model_id=' + encodeURIComponent(statusModelId) : '');\n const statusResponse = await new Promise((resolve, reject) => {\n const statusReq = REQUester.get({\n hostname: DJANGO_HOST,\n port: DJANGO_PORT,\n path: statusPath,\n headers: { 'X-Wallet-Hash': walletHash }\n }, (res) => {\n let data = '';\n res.on('data', chunk => data += chunk);\n res.on('end', () => {\n try {\n resolve(JSON.parse(data));\n } catch {\n resolve({});\n }\n });\n });\n statusReq.on('error', reject);\n statusReq.setTimeout(5000, () => reject(new Error('timeout')));\n });\n \n if (statusResponse) {\n statusSnapshot = statusResponse;\n tokensUsed = statusResponse.tokens_used || 0;\n tokenLimit = statusResponse.token_limit || 50000;\n timeRemainingSeconds = statusResponse.time_remaining_seconds || 0;\n \n // Renewal if session has been used (tokens > 0 or time > 0) but is now exhausted\n isRenewal = (tokensUsed > 0 || statusResponse.time_used_seconds > 0) &&\n (!statusResponse.session_active || timeRemainingSeconds <= 0);\n }\n } catch (err) {\n log('Failed to check session status: ' + err.message);\n }\n \n log('402 status model=' + (statusModelId || 'null')\n + ' snapshot=' + JSON.stringify(statusSnapshot)\n + ' isRenewal=' + isRenewal\n + ' timeRemaining=' + timeRemainingSeconds\n + ' tokensUsed=' + tokensUsed\n + ' tokenLimit=' + tokenLimit);\n \n const lowBalanceOtherModels = await getOtherModelsWithCredits(walletHash, statusModelId || modelId);\n await streamLowBalanceNotice(res, displayName || statusModelId || modelId || 'AI Model', lowBalanceOtherModels);\n } else {\n if (res.headersSent) {\n log('Streaming response completed and already sent');\n const settled = pendingPayments.get(walletHash);\n if (settled && settled.reqId === proxyReqId) {\n pendingPayments.delete(walletHash);\n }\n return;\n }\n\n log('Forwarding normal response to OpenCode: status=' + statusCode + ', bodyLen=' + responseBody.length);\n const settled = pendingPayments.get(walletHash);\n if (settled && settled.reqId === proxyReqId) {\n pendingPayments.delete(walletHash);\n }\n res.writeHead(statusCode, {\n 'Content-Type': headers['content-type'] || 'application/json',\n });\n res.end(responseBody);\n }\n };\n\n if (isStreaming) {\n forwardStreaming(req, res, body, handleResponse);\n } else {\n forwardToDjango(req, body, handleResponse);\n }\n \n } catch (err) {\n log('Error: ' + err.message);\n res.writeHead(500, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Internal proxy error' }));\n }\n });\n});\n\nserver.on('error', (err) => {\n if (err.code === 'EADDRINUSE') {\n log('Port ' + PROXY_PORT + ' is already in use. Another proxy instance may be running.');\n log('Exiting cleanly (code 0) so the plugin can detect the existing proxy.');\n process.exit(0);\n }\n log('Server error: ' + err.message);\n process.exit(1);\n});\n\nserver.listen(PROXY_PORT, () => {\n log('Paytaca AI Proxy running on http://localhost:' + PROXY_PORT);\n log('Forwarding to ' + BACKEND_URL);\n log('Discovery: http://localhost:' + PROXY_PORT + '/v1/config');\n log('Managed by OpenCode plugin');\n});\n\n// Graceful shutdown\nprocess.on('SIGTERM', () => {\n log('Shutting down proxy...');\n server.close(() => process.exit(0));\n});\n\nprocess.on('SIGINT', () => {\n log('Shutting down proxy...');\n server.close(() => process.exit(0));\n});\n";
2
2
  //# sourceMappingURL=proxy.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,oBAAoB,ougEAonDhC,CAAC"}
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,oBAAoB,uugEAonDhC,CAAC"}
@@ -236,16 +236,6 @@ async function streamTierSelectionBody(res, walletHash, modelName, tiers, includ
236
236
  choices: [{ index: 0, delta: { content: '💳 Select a plan for **' + (modelName || 'AI Model') + '**\\n\\n' }, finish_reason: null }],
237
237
  });
238
238
 
239
- // If other models still have paid credits, tell the user they can switch
240
- // instead of buying a new plan (only when there is something to suggest).
241
- if (otherModels && otherModels.length > 0) {
242
- sseLine(res, {
243
- id: 'tier-9b',
244
- object: 'chat.completion.chunk',
245
- choices: [{ index: 0, delta: { content: otherModelsHint(otherModels) }, finish_reason: null }],
246
- });
247
- }
248
-
249
239
  // Build all tier lines into one string so backtick markdown renders
250
240
  // consistently (same as the 'plans' command).
251
241
  let tiersContent = '';
@@ -268,9 +258,19 @@ async function streamTierSelectionBody(res, walletHash, modelName, tiers, includ
268
258
  sseLine(res, {
269
259
  id: 'tier-11',
270
260
  object: 'chat.completion.chunk',
271
- choices: [{ index: 0, delta: { content: '\\nEnter a number (1-' + tiers.length + '), e.g. type ' + tiers[0].minutes + ':' }, finish_reason: 'stop' }],
261
+ choices: [{ index: 0, delta: { content: '\\nEnter a number (1-' + tiers.length + '), e.g. type ' + tiers[0].minutes + ':\\n' }, finish_reason: 'stop' }],
272
262
  });
273
263
 
264
+ // If other models still have paid credits, tell the user they can switch
265
+ // instead of buying a new plan (only when there is something to suggest).
266
+ if (otherModels && otherModels.length > 0) {
267
+ sseLine(res, {
268
+ id: 'tier-9b',
269
+ object: 'chat.completion.chunk',
270
+ choices: [{ index: 0, delta: { content: otherModelsHint(otherModels) }, finish_reason: null }],
271
+ });
272
+ }
273
+
274
274
  sseLine(res, {
275
275
  id: 'tier-12',
276
276
  object: 'chat.completion.chunk',
@@ -1,2 +1,2 @@
1
- export declare const WRAPPER_SCRIPT_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca Pay Wrapper \u2014 handles large request bodies by reading from a file.\n * Imports paytaca-cli modules directly (avoids CLI argument size limits).\n */\n\nimport { readFileSync } from 'fs';\nimport { execSync } from 'child_process';\nimport { fileURLToPath } from 'url';\nimport { dirname, join } from 'path';\n\n// How long to wait for the server to respond before treating the payment as timed out.\n// Default 240s so heavy non-streaming generations (large context / long output) can\n// complete; override with PAYTACA_PAY_TIMEOUT_MS.\nconst PAY_TIMEOUT_MS = Number(process.env.PAYTACA_PAY_TIMEOUT_MS || 240000);\n\n// Find paytaca-cli installation\nfunction findPaytacaCliPath() {\n const possiblePaths = [];\n \n // Try to get global npm root\n try {\n const globalPath = execSync('npm root -g', { encoding: 'utf8' }).trim();\n possiblePaths.push(\n join(globalPath, 'paytaca-cli'),\n join(globalPath, 'opencode-plugin', 'node_modules', 'paytaca-cli'),\n );\n } catch {}\n \n // Common global locations\n possiblePaths.push(\n '/usr/lib/node_modules/paytaca-cli',\n '/usr/local/lib/node_modules/paytaca-cli',\n '/opt/homebrew/lib/node_modules/paytaca-cli',\n );\n \n // Try current file's node_modules (for bundled installs)\n try {\n const currentFile = fileURLToPath(import.meta.url);\n const currentDir = dirname(currentFile);\n possiblePaths.push(\n join(currentDir, '..', 'node_modules', 'paytaca-cli'),\n join(currentDir, '..', '..', 'node_modules', 'paytaca-cli'),\n );\n } catch {}\n \n // Find first valid path\n for (const basePath of possiblePaths) {\n try {\n const walletPath = join(basePath, 'dist', 'wallet', 'index.js');\n readFileSync(walletPath);\n return basePath;\n } catch {}\n }\n \n throw new Error('paytaca-cli not found. Try reinstalling opencode-plugin: npm install @paytaca/opencode-plugin');\n}\n\n// Load paytaca-cli modules\nlet loadMnemonic, loadWallet, LibauthHDWallet, X402Payer, parsePaymentRequiredJson, selectBchPaymentRequirements, BCH_DERIVATION_PATH;\n\ntry {\n const basePath = findPaytacaCliPath();\n \n ({ loadMnemonic, loadWallet } = await import(join(basePath, 'dist', 'wallet', 'index.js')));\n ({ LibauthHDWallet } = await import(join(basePath, 'dist', 'wallet', 'keys.js')));\n ({ X402Payer } = await import(join(basePath, 'dist', 'wallet', 'x402.js')));\n ({ parsePaymentRequiredJson, selectBchPaymentRequirements } = await import(join(basePath, 'dist', 'utils', 'x402.js')));\n ({ BCH_DERIVATION_PATH } = await import(join(basePath, 'dist', 'utils', 'network.js')));\n} catch (err) {\n console.log(JSON.stringify({ success: false, error: 'Failed to load paytaca-cli: ' + err.message }));\n process.exit(1);\n}\n\nasync function main() {\n const configPath = process.argv[2];\n if (!configPath) {\n console.log(JSON.stringify({ success: false, error: 'Usage: node paytaca-pay-wrapper.mjs <config.json>' }));\n process.exit(1);\n }\n\n const config = JSON.parse(readFileSync(configPath, 'utf8'));\n const { url, method, headers, bodyFile, chipnet, confirmed } = config;\n\n const body = readFileSync(bodyFile, 'utf8');\n\n const data = loadMnemonic();\n if (!data) {\n console.log(JSON.stringify({ success: false, error: 'No wallet found. Run paytaca wallet create first.' }));\n process.exit(1);\n }\n\n const wallet = loadWallet();\n const isChipnet = Boolean(chipnet);\n const bchWallet = wallet.forNetwork(isChipnet);\n const hdWallet = new LibauthHDWallet(data.mnemonic, BCH_DERIVATION_PATH, isChipnet ? 'chipnet' : 'mainnet');\n const x402Payer = new X402Payer({ hdWallet, addressIndex: 0 });\n\n try {\n const result = await executePay(url, method, headers, body, bchWallet, x402Payer, isChipnet, confirmed);\n console.log(JSON.stringify(result, null, 2));\n } catch (err) {\n console.log(JSON.stringify({ success: false, error: err.message || String(err) }, null, 2));\n process.exit(1);\n }\n}\n\nasync function executePay(url, method, headers, body, bchWallet, x402Payer, isChipnet, confirmed) {\n const response = await fetch(url, {\n method,\n headers,\n body: ['POST', 'PUT', 'PATCH'].includes(method) ? body : undefined,\n signal: AbortSignal.timeout(PAY_TIMEOUT_MS),\n });\n\n const responseHeaders = {};\n response.headers.forEach((value, key) => { responseHeaders[key] = value; });\n const responseText = await response.text();\n let responseData;\n try { responseData = JSON.parse(responseText); } catch { responseData = responseText; }\n\n if (response.status === 402) {\n const paymentRequired = parsePaymentRequiredJson(responseData);\n if (!paymentRequired) {\n return { success: false, status: 402, error: 'Could not parse PaymentRequired from 402 response body' };\n }\n const requirements = selectBchPaymentRequirements(paymentRequired, isChipnet ? 'chipnet' : 'mainnet');\n if (!requirements) {\n return {\n success: false, status: 402, error: 'Server does not accept BCH payment',\n data: { acceptedSchemes: paymentRequired.accepts.map(a => ({ scheme: a.scheme, network: a.network })) },\n };\n }\n\n const payerAddress = x402Payer.getPayerAddress();\n const address = requirements.payTo;\n const amountBch = Number(requirements.amount) / 1e8;\n const changeAddressSet = bchWallet.getAddressSetAt(0);\n const changeAddress = changeAddressSet.change;\n\n if (!confirmed) {\n return {\n success: false, status: 402, error: 'Payment not confirmed.',\n payment: { required: true, amount: requirements.amount, payTo: address },\n };\n }\n\n const sendResult = await bchWallet.sendBch(amountBch, address, changeAddress);\n if (!sendResult.success) {\n return { success: false, status: 402, payment: { required: true, error: sendResult.error }, error: sendResult.error };\n }\n\n const txid = sendResult.txid;\n const paymentPayload = await x402Payer.createPaymentPayload(requirements, paymentRequired.resource.url, txid, 0, requirements.amount);\n headers['PAYMENT-SIGNATURE'] = JSON.stringify(paymentPayload);\n\n let retryResponse;\n try {\n retryResponse = await fetch(url, {\n method,\n headers,\n body: ['POST', 'PUT', 'PATCH'].includes(method) ? body : undefined,\n signal: AbortSignal.timeout(PAY_TIMEOUT_MS),\n });\n } catch (e) {\n if (e.name === 'AbortError') {\n return { success: false, timeout: true, error: 'Response timed out from server.' };\n }\n throw e;\n }\n const retryResponseHeaders = {};\n retryResponse.headers.forEach((value, key) => { retryResponseHeaders[key] = value; });\n const retryResponseText = await retryResponse.text();\n let retryResponseData;\n try { retryResponseData = JSON.parse(retryResponseText); } catch { retryResponseData = retryResponseText; }\n\n return {\n success: retryResponse.ok,\n status: retryResponse.status,\n statusText: retryResponse.statusText,\n headers: retryResponseHeaders,\n data: retryResponseData,\n payment: { required: true, txid, recipientAddress: address },\n };\n }\n\n return {\n success: response.ok,\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders,\n data: responseData,\n payment: { required: false },\n };\n}\n\nmain();\n";
1
+ export declare const WRAPPER_SCRIPT_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca Pay Wrapper \u2014 handles large request bodies by reading from a file.\n * Imports paytaca-cli modules directly (avoids CLI argument size limits).\n */\n\nimport { readFileSync } from 'fs';\nimport { execSync } from 'child_process';\nimport { fileURLToPath } from 'url';\nimport { dirname, join } from 'path';\n\n// How long to wait for the server to respond before treating the payment as timed out.\n// Default 240s so heavy non-streaming generations (large context / long output) can\n// complete; override with PAYTACA_PAY_TIMEOUT_MS.\nconst PAY_TIMEOUT_MS = Number(process.env.PAYTACA_PAY_TIMEOUT_MS || 240000);\n\n// Find paytaca-cli installation\nfunction findPaytacaCliPath() {\n const possiblePaths = [];\n \n // Try to get global npm root\n try {\n const globalPath = execSync('npm root -g', { encoding: 'utf8' }).trim();\n possiblePaths.push(\n join(globalPath, 'paytaca-cli'),\n join(globalPath, 'opencode-plugin', 'node_modules', 'paytaca-cli'),\n );\n } catch {}\n \n // Common global locations\n possiblePaths.push(\n '/usr/lib/node_modules/paytaca-cli',\n '/usr/local/lib/node_modules/paytaca-cli',\n '/opt/homebrew/lib/node_modules/paytaca-cli',\n );\n \n // Try current file's node_modules (for bundled installs)\n try {\n const currentFile = fileURLToPath(import.meta.url);\n const currentDir = dirname(currentFile);\n possiblePaths.push(\n join(currentDir, '..', 'node_modules', 'paytaca-cli'),\n join(currentDir, '..', '..', 'node_modules', 'paytaca-cli'),\n );\n } catch {}\n \n // Find first valid path\n for (const basePath of possiblePaths) {\n try {\n const walletPath = join(basePath, 'dist', 'wallet', 'index.js');\n readFileSync(walletPath);\n return basePath;\n } catch {}\n }\n \n throw new Error('paytaca-cli not found. Try reinstalling opencode-plugin: npm install @paytaca/opencode-plugin');\n}\n\n// Load paytaca-cli modules\nlet loadMnemonic, loadWallet, LibauthHDWallet, X402Payer, parsePaymentRequiredJson, selectBchPaymentRequirements, BCH_DERIVATION_PATH;\n\ntry {\n const basePath = findPaytacaCliPath();\n \n ({ loadMnemonic, loadWallet } = await import(join(basePath, 'dist', 'wallet', 'index.js')));\n ({ LibauthHDWallet } = await import(join(basePath, 'dist', 'wallet', 'keys.js')));\n ({ X402Payer } = await import(join(basePath, 'dist', 'wallet', 'x402.js')));\n ({ parsePaymentRequiredJson, selectBchPaymentRequirements } = await import(join(basePath, 'dist', 'utils', 'x402.js')));\n ({ BCH_DERIVATION_PATH } = await import(join(basePath, 'dist', 'utils', 'network.js')));\n} catch (err) {\n console.log(JSON.stringify({ success: false, error: 'Failed to load paytaca-cli: ' + err.message }));\n process.exit(1);\n}\n\n// Cauldron payment support (opt-in via config.paymentMethod === 'lift').\n// The LIFT token is sold in a single swap transaction whose output pays the\n// x402 payTo address directly. Uses the same machinery as paytaca-cli's\n// \"paytaca swap\" command, imported via absolute paths because the wrapper runs\n// outside any node_modules tree.\nconst LIFT_TOKEN_ID = process.env.PAYTACA_PAYMENT_TOKEN_ID || '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';\nlet cauldronLoaded = false;\nlet fetchPoolsForToken, apiPoolToMicroPool, microPoolToPoolV0, attemptTrade, watchtowerUtxosToSpendableCoins, ExchangeLab, PayoutAmountRuleType, cashAddressToLockingBytecode, binToHex;\ntry {\n const basePath = findPaytacaCliPath();\n const cauldronDir = join(basePath, 'dist', 'wallet', 'cauldron');\n const cashlabDir = join(basePath, 'node_modules', '@cashlab');\n ({ fetchPoolsForToken } = await import(join(cauldronDir, 'api.js')));\n ({ apiPoolToMicroPool, microPoolToPoolV0 } = await import(join(cauldronDir, 'pools.js')));\n ({ attemptTrade, watchtowerUtxosToSpendableCoins } = await import(join(cauldronDir, 'transact.js')));\n ({ default: ExchangeLab } = await import(join(cashlabDir, 'cauldron', 'out', 'exchange-lab.js')));\n ({ PayoutAmountRuleType } = await import(join(cashlabDir, 'common', 'out', 'constants.js')));\n ({ cashAddressToLockingBytecode, binToHex } = await import(join(cashlabDir, 'common', 'out', 'libauth.js')));\n cauldronLoaded = true;\n} catch (err) {\n // Cauldron modules are only needed for LIFT payments; BCH payments still work.\n cauldronLoaded = false;\n}\n\nasync function main() {\n const configPath = process.argv[2];\n if (!configPath) {\n console.log(JSON.stringify({ success: false, error: 'Usage: node paytaca-pay-wrapper.mjs <config.json>' }));\n process.exit(1);\n }\n\n const config = JSON.parse(readFileSync(configPath, 'utf8'));\n const { url, method, headers, bodyFile, chipnet, confirmed, paymentMethod } = config;\n\n const body = readFileSync(bodyFile, 'utf8');\n\n const data = loadMnemonic();\n if (!data) {\n console.log(JSON.stringify({ success: false, error: 'No wallet found. Run paytaca wallet create first.' }));\n process.exit(1);\n }\n\n const wallet = loadWallet();\n const isChipnet = Boolean(chipnet);\n const bchWallet = wallet.forNetwork(isChipnet);\n const hdWallet = new LibauthHDWallet(data.mnemonic, BCH_DERIVATION_PATH, isChipnet ? 'chipnet' : 'mainnet');\n const x402Payer = new X402Payer({ hdWallet, addressIndex: 0 });\n\n try {\n const result = await executePay(url, method, headers, body, bchWallet, hdWallet, x402Payer, isChipnet, confirmed, paymentMethod);\n console.log(JSON.stringify(result, null, 2));\n } catch (err) {\n console.log(JSON.stringify({ success: false, error: err.message || String(err) }, null, 2));\n process.exit(1);\n }\n}\n\n// Sell LIFT tokens via Cauldron in a single swap transaction that pays the\n// x402 payTo address directly. Returns { txid, vout } for the payment payload.\nasync function payWithLift(bchWallet, hdWallet, requirements, changeAddress) {\n if (!cauldronLoaded) {\n throw new Error('Cauldron payment modules unavailable. Update paytaca-cli to 0.5.0+ to pay with LIFT.');\n }\n const tokenId = LIFT_TOKEN_ID;\n const amountSats = BigInt(requirements.amount);\n\n const [apiPools, allUtxos, tokenUtxos] = await Promise.all([\n fetchPoolsForToken(tokenId),\n bchWallet.getUtxos(),\n bchWallet.getUtxos({ category: tokenId }),\n ]);\n if (!apiPools || apiPools.length === 0) {\n throw new Error('No active Cauldron pools for the payment token.');\n }\n const pools = apiPools.map(apiPoolToMicroPool).map(microPoolToPoolV0);\n\n const tokenBalance = (tokenUtxos || []).reduce((sum, u) => sum + BigInt(u.amount || 0), 0n);\n if (tokenBalance <= 0n) {\n throw new Error('No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or pay with BCH.');\n }\n\n const bchUtxos = allUtxos.filter((utxo) => !utxo.is_cashtoken);\n const spendableCoins = watchtowerUtxosToSpendableCoins({\n utxos: [...bchUtxos, ...(tokenUtxos || [])],\n wallet: hdWallet,\n });\n if (spendableCoins.length === 0) {\n throw new Error('No spendable UTXOs available.');\n }\n\n const payToDecoded = cashAddressToLockingBytecode(requirements.payTo);\n if (!payToDecoded || typeof payToDecoded === 'string' || !payToDecoded.bytecode) {\n throw new Error('Invalid payment address: ' + requirements.payTo);\n }\n const changeDecoded = cashAddressToLockingBytecode(changeAddress);\n if (!changeDecoded || typeof changeDecoded === 'string' || !changeDecoded.bytecode) {\n throw new Error('Invalid change address: ' + changeAddress);\n }\n\n const exlab = new ExchangeLab();\n const payoutRules = [\n { type: PayoutAmountRuleType.FIXED, locking_bytecode: payToDecoded.bytecode, amount: amountSats },\n { type: PayoutAmountRuleType.CHANGE, locking_bytecode: changeDecoded.bytecode, allow_mixing_native_and_token: false, allow_mixing_native_and_token_when_bch_change_is_dust: false, add_change_to_txfee_when_bch_change_is_dust: true },\n ];\n\n // Back-compute the token supply for a demand target slightly above the plan\n // cost so the received BCH covers the fixed payout plus fees (excess becomes\n // change). Retry with a bigger buffer if the first target leaves no change.\n let trade = null;\n let tradeTx = null;\n let lastError = null;\n for (const buffer of [2000n, 20000n, 100000n]) {\n try {\n trade = attemptTrade({ pools, isBuyingToken: false, supply: undefined, demand: amountSats + buffer });\n tradeTx = exlab.createTradeTx(trade.entries, spendableCoins, payoutRules, null, 1n);\n exlab.verifyTradeTx(tradeTx);\n break;\n } catch (e) {\n lastError = e;\n }\n }\n if (!tradeTx) {\n const supply = trade?.summary?.supply;\n if (supply && tokenBalance < supply) {\n throw new Error('Insufficient LIFT balance: this payment needs ' + supply + ' base units but the wallet has ' + tokenBalance + '.');\n }\n throw new Error('Could not fund the payment by selling LIFT: ' + (lastError?.message || 'unknown error'));\n }\n\n const tx = tradeTx.libauth_generated_transaction;\n const payToHex = binToHex(payToDecoded.bytecode);\n const vout = tx.outputs.findIndex((o) => binToHex(o.lockingBytecode) === payToHex);\n if (vout === -1) {\n throw new Error('Payment output missing from built transaction.');\n }\n\n const txHex = binToHex(tradeTx.txbin);\n const broadcastResponse = await bchWallet.watchtower.BCH._api.post('broadcast/', { transaction: txHex });\n const data = broadcastResponse.data;\n if (data?.result) {\n data[data.success ? 'txid' : 'error'] = data.result;\n delete data.result;\n }\n if (!data?.success || !data?.txid) {\n throw new Error(data?.error || 'Broadcast failed');\n }\n return { txid: data.txid, vout };\n}\n\nasync function executePay(url, method, headers, body, bchWallet, hdWallet, x402Payer, isChipnet, confirmed, paymentMethod) {\n const response = await fetch(url, {\n method,\n headers,\n body: ['POST', 'PUT', 'PATCH'].includes(method) ? body : undefined,\n signal: AbortSignal.timeout(PAY_TIMEOUT_MS),\n });\n\n const responseHeaders = {};\n response.headers.forEach((value, key) => { responseHeaders[key] = value; });\n const responseText = await response.text();\n let responseData;\n try { responseData = JSON.parse(responseText); } catch { responseData = responseText; }\n\n if (response.status === 402) {\n const paymentRequired = parsePaymentRequiredJson(responseData);\n if (!paymentRequired) {\n return { success: false, status: 402, error: 'Could not parse PaymentRequired from 402 response body' };\n }\n const requirements = selectBchPaymentRequirements(paymentRequired, isChipnet ? 'chipnet' : 'mainnet');\n if (!requirements) {\n return {\n success: false, status: 402, error: 'Server does not accept BCH payment',\n data: { acceptedSchemes: paymentRequired.accepts.map(a => ({ scheme: a.scheme, network: a.network })) },\n };\n }\n\n const payerAddress = x402Payer.getPayerAddress();\n const address = requirements.payTo;\n const amountBch = Number(requirements.amount) / 1e8;\n const changeAddressSet = bchWallet.getAddressSetAt(0);\n const changeAddress = changeAddressSet.change;\n\n if (!confirmed) {\n return {\n success: false, status: 402, error: 'Payment not confirmed.',\n payment: { required: true, amount: requirements.amount, payTo: address },\n };\n }\n\n let txid, vout = 0;\n if (paymentMethod === 'lift') {\n // Sell LIFT via Cauldron; the swap transaction pays the plan directly.\n const liftPayment = await payWithLift(bchWallet, hdWallet, requirements, changeAddress);\n txid = liftPayment.txid;\n vout = liftPayment.vout;\n } else {\n const sendResult = await bchWallet.sendBch(amountBch, address, changeAddress);\n if (!sendResult.success) {\n return { success: false, status: 402, payment: { required: true, error: sendResult.error }, error: sendResult.error };\n }\n txid = sendResult.txid;\n }\n\n const paymentPayload = await x402Payer.createPaymentPayload(requirements, paymentRequired.resource.url, txid, vout, requirements.amount);\n headers['PAYMENT-SIGNATURE'] = JSON.stringify(paymentPayload);\n\n let retryResponse;\n try {\n retryResponse = await fetch(url, {\n method,\n headers,\n body: ['POST', 'PUT', 'PATCH'].includes(method) ? body : undefined,\n signal: AbortSignal.timeout(PAY_TIMEOUT_MS),\n });\n } catch (e) {\n if (e.name === 'AbortError') {\n return { success: false, timeout: true, error: 'Response timed out from server.' };\n }\n throw e;\n }\n const retryResponseHeaders = {};\n retryResponse.headers.forEach((value, key) => { retryResponseHeaders[key] = value; });\n const retryResponseText = await retryResponse.text();\n let retryResponseData;\n try { retryResponseData = JSON.parse(retryResponseText); } catch { retryResponseData = retryResponseText; }\n\n return {\n success: retryResponse.ok,\n status: retryResponse.status,\n statusText: retryResponse.statusText,\n headers: retryResponseHeaders,\n data: retryResponseData,\n payment: { required: true, txid, recipientAddress: address, method: paymentMethod === 'lift' ? 'lift' : 'bch' },\n };\n }\n\n return {\n success: response.ok,\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders,\n data: responseData,\n payment: { required: false },\n };\n}\n\nmain();\n";
2
2
  //# sourceMappingURL=wrapper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"wrapper.d.ts","sourceRoot":"","sources":["../../src/bundled/wrapper.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,ozOAqMlC,CAAC"}
1
+ {"version":3,"file":"wrapper.d.ts","sourceRoot":"","sources":["../../src/bundled/wrapper.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,oyaAiUlC,CAAC"}
@@ -78,6 +78,30 @@ try {
78
78
  process.exit(1);
79
79
  }
80
80
 
81
+ // Cauldron payment support (opt-in via config.paymentMethod === 'lift').
82
+ // The LIFT token is sold in a single swap transaction whose output pays the
83
+ // x402 payTo address directly. Uses the same machinery as paytaca-cli's
84
+ // "paytaca swap" command, imported via absolute paths because the wrapper runs
85
+ // outside any node_modules tree.
86
+ const LIFT_TOKEN_ID = process.env.PAYTACA_PAYMENT_TOKEN_ID || '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';
87
+ let cauldronLoaded = false;
88
+ let fetchPoolsForToken, apiPoolToMicroPool, microPoolToPoolV0, attemptTrade, watchtowerUtxosToSpendableCoins, ExchangeLab, PayoutAmountRuleType, cashAddressToLockingBytecode, binToHex;
89
+ try {
90
+ const basePath = findPaytacaCliPath();
91
+ const cauldronDir = join(basePath, 'dist', 'wallet', 'cauldron');
92
+ const cashlabDir = join(basePath, 'node_modules', '@cashlab');
93
+ ({ fetchPoolsForToken } = await import(join(cauldronDir, 'api.js')));
94
+ ({ apiPoolToMicroPool, microPoolToPoolV0 } = await import(join(cauldronDir, 'pools.js')));
95
+ ({ attemptTrade, watchtowerUtxosToSpendableCoins } = await import(join(cauldronDir, 'transact.js')));
96
+ ({ default: ExchangeLab } = await import(join(cashlabDir, 'cauldron', 'out', 'exchange-lab.js')));
97
+ ({ PayoutAmountRuleType } = await import(join(cashlabDir, 'common', 'out', 'constants.js')));
98
+ ({ cashAddressToLockingBytecode, binToHex } = await import(join(cashlabDir, 'common', 'out', 'libauth.js')));
99
+ cauldronLoaded = true;
100
+ } catch (err) {
101
+ // Cauldron modules are only needed for LIFT payments; BCH payments still work.
102
+ cauldronLoaded = false;
103
+ }
104
+
81
105
  async function main() {
82
106
  const configPath = process.argv[2];
83
107
  if (!configPath) {
@@ -86,7 +110,7 @@ async function main() {
86
110
  }
87
111
 
88
112
  const config = JSON.parse(readFileSync(configPath, 'utf8'));
89
- const { url, method, headers, bodyFile, chipnet, confirmed } = config;
113
+ const { url, method, headers, bodyFile, chipnet, confirmed, paymentMethod } = config;
90
114
 
91
115
  const body = readFileSync(bodyFile, 'utf8');
92
116
 
@@ -103,7 +127,7 @@ async function main() {
103
127
  const x402Payer = new X402Payer({ hdWallet, addressIndex: 0 });
104
128
 
105
129
  try {
106
- const result = await executePay(url, method, headers, body, bchWallet, x402Payer, isChipnet, confirmed);
130
+ const result = await executePay(url, method, headers, body, bchWallet, hdWallet, x402Payer, isChipnet, confirmed, paymentMethod);
107
131
  console.log(JSON.stringify(result, null, 2));
108
132
  } catch (err) {
109
133
  console.log(JSON.stringify({ success: false, error: err.message || String(err) }, null, 2));
@@ -111,7 +135,99 @@ async function main() {
111
135
  }
112
136
  }
113
137
 
114
- async function executePay(url, method, headers, body, bchWallet, x402Payer, isChipnet, confirmed) {
138
+ // Sell LIFT tokens via Cauldron in a single swap transaction that pays the
139
+ // x402 payTo address directly. Returns { txid, vout } for the payment payload.
140
+ async function payWithLift(bchWallet, hdWallet, requirements, changeAddress) {
141
+ if (!cauldronLoaded) {
142
+ throw new Error('Cauldron payment modules unavailable. Update paytaca-cli to 0.5.0+ to pay with LIFT.');
143
+ }
144
+ const tokenId = LIFT_TOKEN_ID;
145
+ const amountSats = BigInt(requirements.amount);
146
+
147
+ const [apiPools, allUtxos, tokenUtxos] = await Promise.all([
148
+ fetchPoolsForToken(tokenId),
149
+ bchWallet.getUtxos(),
150
+ bchWallet.getUtxos({ category: tokenId }),
151
+ ]);
152
+ if (!apiPools || apiPools.length === 0) {
153
+ throw new Error('No active Cauldron pools for the payment token.');
154
+ }
155
+ const pools = apiPools.map(apiPoolToMicroPool).map(microPoolToPoolV0);
156
+
157
+ const tokenBalance = (tokenUtxos || []).reduce((sum, u) => sum + BigInt(u.amount || 0), 0n);
158
+ if (tokenBalance <= 0n) {
159
+ throw new Error('No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or pay with BCH.');
160
+ }
161
+
162
+ const bchUtxos = allUtxos.filter((utxo) => !utxo.is_cashtoken);
163
+ const spendableCoins = watchtowerUtxosToSpendableCoins({
164
+ utxos: [...bchUtxos, ...(tokenUtxos || [])],
165
+ wallet: hdWallet,
166
+ });
167
+ if (spendableCoins.length === 0) {
168
+ throw new Error('No spendable UTXOs available.');
169
+ }
170
+
171
+ const payToDecoded = cashAddressToLockingBytecode(requirements.payTo);
172
+ if (!payToDecoded || typeof payToDecoded === 'string' || !payToDecoded.bytecode) {
173
+ throw new Error('Invalid payment address: ' + requirements.payTo);
174
+ }
175
+ const changeDecoded = cashAddressToLockingBytecode(changeAddress);
176
+ if (!changeDecoded || typeof changeDecoded === 'string' || !changeDecoded.bytecode) {
177
+ throw new Error('Invalid change address: ' + changeAddress);
178
+ }
179
+
180
+ const exlab = new ExchangeLab();
181
+ const payoutRules = [
182
+ { type: PayoutAmountRuleType.FIXED, locking_bytecode: payToDecoded.bytecode, amount: amountSats },
183
+ { type: PayoutAmountRuleType.CHANGE, locking_bytecode: changeDecoded.bytecode, allow_mixing_native_and_token: false, allow_mixing_native_and_token_when_bch_change_is_dust: false, add_change_to_txfee_when_bch_change_is_dust: true },
184
+ ];
185
+
186
+ // Back-compute the token supply for a demand target slightly above the plan
187
+ // cost so the received BCH covers the fixed payout plus fees (excess becomes
188
+ // change). Retry with a bigger buffer if the first target leaves no change.
189
+ let trade = null;
190
+ let tradeTx = null;
191
+ let lastError = null;
192
+ for (const buffer of [2000n, 20000n, 100000n]) {
193
+ try {
194
+ trade = attemptTrade({ pools, isBuyingToken: false, supply: undefined, demand: amountSats + buffer });
195
+ tradeTx = exlab.createTradeTx(trade.entries, spendableCoins, payoutRules, null, 1n);
196
+ exlab.verifyTradeTx(tradeTx);
197
+ break;
198
+ } catch (e) {
199
+ lastError = e;
200
+ }
201
+ }
202
+ if (!tradeTx) {
203
+ const supply = trade?.summary?.supply;
204
+ if (supply && tokenBalance < supply) {
205
+ throw new Error('Insufficient LIFT balance: this payment needs ' + supply + ' base units but the wallet has ' + tokenBalance + '.');
206
+ }
207
+ throw new Error('Could not fund the payment by selling LIFT: ' + (lastError?.message || 'unknown error'));
208
+ }
209
+
210
+ const tx = tradeTx.libauth_generated_transaction;
211
+ const payToHex = binToHex(payToDecoded.bytecode);
212
+ const vout = tx.outputs.findIndex((o) => binToHex(o.lockingBytecode) === payToHex);
213
+ if (vout === -1) {
214
+ throw new Error('Payment output missing from built transaction.');
215
+ }
216
+
217
+ const txHex = binToHex(tradeTx.txbin);
218
+ const broadcastResponse = await bchWallet.watchtower.BCH._api.post('broadcast/', { transaction: txHex });
219
+ const data = broadcastResponse.data;
220
+ if (data?.result) {
221
+ data[data.success ? 'txid' : 'error'] = data.result;
222
+ delete data.result;
223
+ }
224
+ if (!data?.success || !data?.txid) {
225
+ throw new Error(data?.error || 'Broadcast failed');
226
+ }
227
+ return { txid: data.txid, vout };
228
+ }
229
+
230
+ async function executePay(url, method, headers, body, bchWallet, hdWallet, x402Payer, isChipnet, confirmed, paymentMethod) {
115
231
  const response = await fetch(url, {
116
232
  method,
117
233
  headers,
@@ -151,13 +267,21 @@ async function executePay(url, method, headers, body, bchWallet, x402Payer, isCh
151
267
  };
152
268
  }
153
269
 
154
- const sendResult = await bchWallet.sendBch(amountBch, address, changeAddress);
155
- if (!sendResult.success) {
156
- return { success: false, status: 402, payment: { required: true, error: sendResult.error }, error: sendResult.error };
270
+ let txid, vout = 0;
271
+ if (paymentMethod === 'lift') {
272
+ // Sell LIFT via Cauldron; the swap transaction pays the plan directly.
273
+ const liftPayment = await payWithLift(bchWallet, hdWallet, requirements, changeAddress);
274
+ txid = liftPayment.txid;
275
+ vout = liftPayment.vout;
276
+ } else {
277
+ const sendResult = await bchWallet.sendBch(amountBch, address, changeAddress);
278
+ if (!sendResult.success) {
279
+ return { success: false, status: 402, payment: { required: true, error: sendResult.error }, error: sendResult.error };
280
+ }
281
+ txid = sendResult.txid;
157
282
  }
158
283
 
159
- const txid = sendResult.txid;
160
- const paymentPayload = await x402Payer.createPaymentPayload(requirements, paymentRequired.resource.url, txid, 0, requirements.amount);
284
+ const paymentPayload = await x402Payer.createPaymentPayload(requirements, paymentRequired.resource.url, txid, vout, requirements.amount);
161
285
  headers['PAYMENT-SIGNATURE'] = JSON.stringify(paymentPayload);
162
286
 
163
287
  let retryResponse;
@@ -186,7 +310,7 @@ async function executePay(url, method, headers, body, bchWallet, x402Payer, isCh
186
310
  statusText: retryResponse.statusText,
187
311
  headers: retryResponseHeaders,
188
312
  data: retryResponseData,
189
- payment: { required: true, txid, recipientAddress: address },
313
+ payment: { required: true, txid, recipientAddress: address, method: paymentMethod === 'lift' ? 'lift' : 'bch' },
190
314
  };
191
315
  }
192
316
 
@@ -1 +1 @@
1
- {"version":3,"file":"wrapper.js","sourceRoot":"","sources":["../../src/bundled/wrapper.ts"],"names":[],"mappings":";AAAA,oEAAoE;AACpE,4EAA4E;AAC5E,mFAAmF;;;AAEtE,QAAA,sBAAsB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqMrC,CAAC"}
1
+ {"version":3,"file":"wrapper.js","sourceRoot":"","sources":["../../src/bundled/wrapper.ts"],"names":[],"mappings":";AAAA,oEAAoE;AACpE,4EAA4E;AAC5E,mFAAmF;;;AAEtE,QAAA,sBAAsB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiUrC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paytaca/opencode-plugin",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "OpenCode plugin for Paytaca AI - AI inference provider powered by Bitcoin Cash micropayments",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -33,7 +33,7 @@
33
33
  "license": "MIT",
34
34
  "dependencies": {
35
35
  "@opencode-ai/plugin": "^1.17.8",
36
- "paytaca-cli": "^0.4.1"
36
+ "paytaca-cli": "^0.5.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "^20.0.0",