@paytaca/opencode-plugin 0.1.16 → 0.2.0

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 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// 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// 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) {\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: '\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 + ':' }, 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) {\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);\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) {\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 sseLine(res, {\n id: 'lb-2',\n object: 'chat.completion.chunk',\n choices: [{ index: 0, delta: { content: '\u26A0\uFE0F OpenRouter balance is low \u2014 please top up before continuing.\\n' }, 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: 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\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: 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: 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 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 // 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 var reqModel = '';\n try { reqModel = JSON.parse(body).model || ''; } catch (e) {}\n if (reqModel && pendingPayload.modelId && reqModel !== pendingPayload.modelId) {\n pendingPayments.delete(walletHash);\n pendingPayload = null;\n }\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: '\\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: 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: '\\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: 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: '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 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\n await streamTierSelectionPrompt(res, walletHash, displayName || modelId || 'AI Model', tiers);\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 await streamLowBalanceNotice(res, displayName || statusModelId || modelId || 'AI Model');\n } else {\n if (res.headersSent) {\n log('Streaming response completed and already sent');\n pendingPayments.delete(walletHash);\n return;\n }\n\n log('Forwarding normal response to OpenCode: status=' + statusCode + ', bodyLen=' + responseBody.length);\n pendingPayments.delete(walletHash);\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// 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) {\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 + ':' }, 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) {\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);\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) {\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 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' }, 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\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 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 // 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 var reqModel = '';\n try { reqModel = JSON.parse(body).model || ''; } catch (e) {}\n if (reqModel && pendingPayload.modelId && reqModel !== pendingPayload.modelId) {\n pendingPayments.delete(walletHash);\n pendingPayload = null;\n }\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 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\n await streamTierSelectionPrompt(res, walletHash, displayName || modelId || 'AI Model', tiers);\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 await streamLowBalanceNotice(res, displayName || statusModelId || modelId || 'AI Model');\n } else {\n if (res.headersSent) {\n log('Streaming response completed and already sent');\n pendingPayments.delete(walletHash);\n return;\n }\n\n log('Forwarding normal response to OpenCode: status=' + statusCode + ', bodyLen=' + responseBody.length);\n pendingPayments.delete(walletHash);\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,kz1DAqgDhC,CAAC"}
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,oBAAoB,632DA4gDhC,CAAC"}
@@ -141,6 +141,13 @@ function sseDone(res) {
141
141
  res.write('data: [DONE]\\n\\n');
142
142
  }
143
143
 
144
+ // Zero-width marker prepended to every synthetic proxy message (tier
145
+ // prompts, credits/plans output, payment notices). The opencode plugin
146
+ // strips marker-carrying assistant messages from LLM context — proxy chatter
147
+ // is not relevant to the coding session — while the user still sees them in
148
+ // the UI (zero-width characters don't render).
149
+ const PROXY_MARKER = String.fromCharCode(0x200b, 0x200b, 0x200b, 0x200b);
150
+
144
151
  // Stream the tier-selection prompt body (SSE lines) into an in-progress response.
145
152
  // When includeRole is false the leading role delta is skipped, so the body can be
146
153
  // appended to a stream that already emitted content (e.g. after a payment failure).
@@ -159,7 +166,7 @@ async function streamTierSelectionBody(res, walletHash, modelName, tiers, includ
159
166
  sseLine(res, {
160
167
  id: 'tier-2',
161
168
  object: 'chat.completion.chunk',
162
- choices: [{ index: 0, delta: { content: '⏳ Initializing Paytaca AI provider...\\n' }, finish_reason: null }],
169
+ choices: [{ index: 0, delta: { content: PROXY_MARKER + '⏳ Initializing Paytaca AI provider...\\n' }, finish_reason: null }],
163
170
  });
164
171
 
165
172
  const hasCli = await checkPaytacaCli();
@@ -288,7 +295,7 @@ async function streamLowBalanceNotice(res, modelName) {
288
295
  sseLine(res, {
289
296
  id: 'lb-2',
290
297
  object: 'chat.completion.chunk',
291
- choices: [{ index: 0, delta: { content: '⚠️ OpenRouter balance is low — please top up before continuing.\\n' }, finish_reason: 'stop' }],
298
+ choices: [{ index: 0, delta: { content: PROXY_MARKER + '⚠️ OpenRouter balance is low — please top up before continuing.\\n' }, finish_reason: 'stop' }],
292
299
  });
293
300
 
294
301
  sseLine(res, {
@@ -680,7 +687,7 @@ async function streamPaymentFailureAndRetry(res, walletHash, pendingPayload, mes
680
687
  object: 'chat.completion.chunk',
681
688
  created: Math.floor(Date.now() / 1000),
682
689
  model: 'deepseek/deepseek-v4-flash',
683
- choices: [{ index: 0, delta: { content: message }, finish_reason: 'stop' }],
690
+ choices: [{ index: 0, delta: { content: PROXY_MARKER + message }, finish_reason: 'stop' }],
684
691
  });
685
692
  } catch (e) {
686
693
  }
@@ -859,7 +866,7 @@ async function handleTimeCreditsCommand(res, walletHash) {
859
866
  sseLine(res, {
860
867
  id: 'time-2',
861
868
  object: 'chat.completion.chunk',
862
- choices: [{ index: 0, delta: { content: content + '\\n' }, finish_reason: 'stop' }],
869
+ choices: [{ index: 0, delta: { content: PROXY_MARKER + content + '\\n' }, finish_reason: 'stop' }],
863
870
  });
864
871
  sseLine(res, {
865
872
  id: 'time-3',
@@ -941,7 +948,7 @@ async function handlePricingCommand(res) {
941
948
  sseLine(res, {
942
949
  id: 'price-2',
943
950
  object: 'chat.completion.chunk',
944
- choices: [{ index: 0, delta: { content: content + '\\n' }, finish_reason: 'stop' }],
951
+ choices: [{ index: 0, delta: { content: PROXY_MARKER + content + '\\n' }, finish_reason: 'stop' }],
945
952
  });
946
953
  sseLine(res, {
947
954
  id: 'price-3',
@@ -1110,7 +1117,7 @@ const server = http.createServer(async (req, res) => {
1110
1117
  sseLine(res, {
1111
1118
  id: 'balance-err',
1112
1119
  object: 'chat.completion.chunk',
1113
- choices: [{ index: 0, delta: { content: '\\n\\n❌ **Insufficient balance** — 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' }],
1120
+ choices: [{ index: 0, delta: { content: PROXY_MARKER + '\\n\\n❌ **Insufficient balance** — 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' }],
1114
1121
  });
1115
1122
  sseLine(res, {
1116
1123
  id: 'balance-err-done',
@@ -1164,7 +1171,7 @@ const server = http.createServer(async (req, res) => {
1164
1171
  if (res.headersSent && !res.destroyed && !res.writableEnded) {
1165
1172
  if (isTimeout) {
1166
1173
  try {
1167
- 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: sseContent }, finish_reason: 'stop' }] });
1174
+ 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' }] });
1168
1175
  sseLine(res, { id: 'pay-err-done', object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
1169
1176
  sseDone(res);
1170
1177
  res.end();
@@ -1251,7 +1258,7 @@ const server = http.createServer(async (req, res) => {
1251
1258
  log('paytaca pay failed: ' + err.message);
1252
1259
  if (res.headersSent && !res.destroyed && !res.writableEnded) {
1253
1260
  try {
1254
- 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: '\\n\\n❌ Payment failed: ' + err.message }, finish_reason: 'stop' }] });
1261
+ 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❌ Payment failed: ' + err.message }, finish_reason: 'stop' }] });
1255
1262
  sseLine(res, { id: 'pay-err-done', object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
1256
1263
  sseDone(res);
1257
1264
  res.end();
@@ -1275,7 +1282,7 @@ const server = http.createServer(async (req, res) => {
1275
1282
  const errDetails = isTimeout ? 'Try again or check credits with \\'credits\\'.' : 'Please check your balance and try again.';
1276
1283
  if (res.headersSent && !res.destroyed && !res.writableEnded) {
1277
1284
  try {
1278
- 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: sseContent }, finish_reason: 'stop' }] });
1285
+ 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' }] });
1279
1286
  sseLine(res, { id: 'pay-err-done', object: 'chat.completion.chunk', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
1280
1287
  sseDone(res);
1281
1288
  res.end();
@@ -1338,7 +1345,7 @@ const server = http.createServer(async (req, res) => {
1338
1345
  index: 0,
1339
1346
  message: {
1340
1347
  role: 'assistant',
1341
- content: 'Payment declined. Chat cannot continue without funding.\\n\\n' + fundMsg,
1348
+ content: PROXY_MARKER + 'Payment declined. Chat cannot continue without funding.\\n\\n' + fundMsg,
1342
1349
  },
1343
1350
  finish_reason: 'stop',
1344
1351
  }],
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqgDnC,CAAC"}
1
+ {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/bundled/proxy.ts"],"names":[],"mappings":";AAAA,0DAA0D;AAC1D,6DAA6D;;;AAEhD,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4gDnC,CAAC"}
package/dist/config.d.ts CHANGED
@@ -10,4 +10,5 @@ export declare function getPidFile(configDir: string): string;
10
10
  export declare function getProxyScript(configDir: string): string;
11
11
  export declare function getLogFile(configDir: string): string;
12
12
  export declare function getWrapperScript(configDir: string): string;
13
+ export declare function getMcpScript(configDir: string): string;
13
14
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAGjC,eAAO,MAAM,mBAAmB,2BAA2B,CAAC;AAC5D,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAGvC,wBAAgB,YAAY,IAAI,MAAM,CAGrC;AAED,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAIvD;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAkCpD;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAIlE;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAE1D"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAGjC,eAAO,MAAM,mBAAmB,2BAA2B,CAAC;AAC5D,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAGvC,wBAAgB,YAAY,IAAI,MAAM,CAGrC;AAED,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAIvD;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAkCpD;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAIlE;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEtD"}
package/dist/config.js CHANGED
@@ -43,6 +43,7 @@ exports.getPidFile = getPidFile;
43
43
  exports.getProxyScript = getProxyScript;
44
44
  exports.getLogFile = getLogFile;
45
45
  exports.getWrapperScript = getWrapperScript;
46
+ exports.getMcpScript = getMcpScript;
46
47
  const fs = __importStar(require("fs"));
47
48
  const path = __importStar(require("path"));
48
49
  // Default production backend
@@ -111,4 +112,7 @@ function getLogFile(configDir) {
111
112
  function getWrapperScript(configDir) {
112
113
  return path.join(configDir, 'paytaca-pay-wrapper.mjs');
113
114
  }
115
+ function getMcpScript(configDir) {
116
+ return path.join(configDir, 'mcp-server.js');
117
+ }
114
118
  //# sourceMappingURL=config.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export declare const PROXY_MARKER: string;
2
+ export declare const PAYMENT_SUCCESS_LINE = "\uD83D\uDCB3 Payment successful \u2014 generating your response...";
3
+ export declare function filterProxyChatter(messages: any[]): any[];
4
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,YAAY,QAAsD,CAAC;AAKhF,eAAO,MAAM,oBAAoB,uEAAwD,CAAC;AAgE1F,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,GAAG,EAAE,GAAG,GAAG,EAAE,CAqBzD"}
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PAYMENT_SUCCESS_LINE = exports.PROXY_MARKER = void 0;
4
+ exports.filterProxyChatter = filterProxyChatter;
5
+ // Zero-width marker the proxy prepends to every synthetic message it streams
6
+ // (tier-selection prompts, credits/plans output, payment notices). The
7
+ // messages stay visible in the opencode UI — zero-width characters don't
8
+ // render — but carrying the marker lets this plugin strip them from the
9
+ // context passed to the LLM, since proxy/payment chatter is not relevant to
10
+ // the coding session.
11
+ exports.PROXY_MARKER = String.fromCharCode(0x200b, 0x200b, 0x200b, 0x200b);
12
+ // The success line the proxy prepends to a real model response after a
13
+ // payment completes. Useful during streaming UX, but it is proxy chatter —
14
+ // stripped from assistant text before the LLM sees it.
15
+ exports.PAYMENT_SUCCESS_LINE = '💳 Payment successful — generating your response...';
16
+ // Replies that are part of a proxy interactive flow (tier pick, approval,
17
+ // credits/plans shortcuts). Only removed when they immediately follow a
18
+ // marked proxy message, so genuine user messages are never dropped.
19
+ const SELECTION_RE = /^\s*(?:\d{1,3}|yes|no|credits|plans|balance)\s*$/i;
20
+ function stripSystemReminders(text) {
21
+ let r = text || '';
22
+ const open = '<system-reminder>';
23
+ const close = '</system-reminder>';
24
+ let i = r.indexOf(open);
25
+ while (i !== -1) {
26
+ const j = r.indexOf(close, i);
27
+ if (j === -1)
28
+ break;
29
+ r = r.substring(0, i) + r.substring(j + close.length);
30
+ i = r.indexOf(open);
31
+ }
32
+ return r;
33
+ }
34
+ function textOf(msg) {
35
+ const parts = msg && msg.parts;
36
+ if (!Array.isArray(parts))
37
+ return '';
38
+ let out = '';
39
+ for (const part of parts) {
40
+ if (part && part.type === 'text' && typeof part.text === 'string') {
41
+ out += part.text;
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ function isMarkedProxyMessage(msg) {
47
+ return !!(msg && msg.info && msg.info.role === 'assistant' && textOf(msg).indexOf(exports.PROXY_MARKER) !== -1);
48
+ }
49
+ function isSelectionReply(msg) {
50
+ if (!msg || !msg.info || msg.info.role !== 'user')
51
+ return false;
52
+ return SELECTION_RE.test(stripSystemReminders(textOf(msg)).trim());
53
+ }
54
+ // Remove the payment-success preamble from an assistant message without
55
+ // mutating opencode's stored objects — returns a shallow-cloned wrapper when
56
+ // a change is made, or null when the message is clean.
57
+ function withoutPaymentSuccessLine(msg) {
58
+ const parts = msg && msg.parts;
59
+ if (!Array.isArray(parts))
60
+ return null;
61
+ for (let i = 0; i < parts.length; i++) {
62
+ const part = parts[i];
63
+ if (part && part.type === 'text' && typeof part.text === 'string' && part.text.indexOf(exports.PAYMENT_SUCCESS_LINE) !== -1) {
64
+ const cleaned = part.text.split(exports.PAYMENT_SUCCESS_LINE).join('').replace(/\n{3,}/g, '\n\n');
65
+ const newParts = parts.slice();
66
+ newParts[i] = { ...part, text: cleaned };
67
+ return { ...msg, parts: newParts };
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+ // Drop proxy-generated assistant messages (and the interactive selection
73
+ // replies directly following them) from the LLM context. The final message —
74
+ // the turn currently being answered — is always kept, so the proxy can still
75
+ // detect tier selections and approval replies in the live request.
76
+ function filterProxyChatter(messages) {
77
+ if (!Array.isArray(messages) || messages.length === 0)
78
+ return messages;
79
+ const kept = [];
80
+ for (let i = 0; i < messages.length; i++) {
81
+ const msg = messages[i];
82
+ const isLast = i === messages.length - 1;
83
+ if (!isLast && isMarkedProxyMessage(msg)) {
84
+ const next = messages[i + 1];
85
+ if (next && (i + 1) < messages.length - 1 && isSelectionReply(next)) {
86
+ i++;
87
+ }
88
+ continue;
89
+ }
90
+ if (msg && msg.info && msg.info.role === 'assistant') {
91
+ const cleaned = withoutPaymentSuccessLine(msg);
92
+ kept.push(cleaned || msg);
93
+ continue;
94
+ }
95
+ kept.push(msg);
96
+ }
97
+ return kept;
98
+ }
99
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"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"}