Files
pmc-funder-tool/netlify/functions/agent-proxy.js
T
2026-05-12 21:56:51 -04:00

158 lines
4.7 KiB
JavaScript

import Anthropic from '@anthropic-ai/sdk'
/**
* Netlify Functions v2 handler.
*
* Proxies a request from the React frontend to the Anthropic Messages API,
* configured for a Managed Agent workflow. We pipe `content_block_delta`
* events directly to the client through a ReadableStream so we never hit
* Netlify's 26-second synchronous execution limit.
*/
// ---------------------------------------------------------------------------
// Managed Agent configuration
// ---------------------------------------------------------------------------
// The Managed Agent ID is supplied via the ANTHROPIC_AGENT_ID env var. The
// agent's system prompt and model are defined in the Claude Console and used
// by default. ANTHROPIC_MODEL and ANTHROPIC_SYSTEM_PROMPT may optionally
// override them.
// ---------------------------------------------------------------------------
// Anthropic client (reused across warm Lambda invocations)
// ---------------------------------------------------------------------------
let _client
function getClient() {
if (_client) return _client
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error('Server is missing ANTHROPIC_API_KEY')
}
_client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
return _client
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
export default async (req /*, context */) => {
if (req.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 })
}
let payload
try {
payload = await req.json()
} catch {
return new Response('Invalid JSON body', { status: 400 })
}
const {
stationName = '',
stationLocation = '',
stationWebsite = '',
} = payload || {}
// Server-side URL validation (mirrors the client-side regex).
const URL_REGEX =
/^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/[\w\-._~:/?#[\]@!$&'()*+,;=%]*)?$/i
if (!URL_REGEX.test(stationWebsite)) {
return new Response('Invalid station website URL', { status: 400 })
}
let client
try {
client = getClient()
} catch (err) {
return new Response(
`Failed to initialize agent: ${err.message || err}`,
{ status: 500 },
)
}
const userMessage = `Here is a new public media station intake:
- Station Name: ${stationName}
- Station Location: ${stationLocation}
- Station Website: ${stationWebsite}
Please follow your instructions to produce the funding outlook brief.`
// ----- Build the streaming request -----
// The Managed Agent's system prompt and model (configured in the Claude
// Console) are used by default. Optional env vars can override them.
const streamArgs = {
model: process.env.ANTHROPIC_MODEL || 'claude-opus-4-6',
max_tokens: 20000,
messages: [
{
role: 'user',
content: [{ type: 'text', text: userMessage }],
},
],
}
if (process.env.ANTHROPIC_AGENT_ID) {
streamArgs.agent_id = process.env.ANTHROPIC_AGENT_ID
}
if (process.env.ANTHROPIC_SYSTEM_PROMPT) {
streamArgs.system = process.env.ANTHROPIC_SYSTEM_PROMPT
}
const upstream = client.messages.stream(streamArgs)
// ----- Pipe content_block_delta events straight to the client -----
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
try {
for await (const event of upstream) {
if (
event.type === 'content_block_delta' &&
event.delta?.type === 'text_delta' &&
event.delta.text
) {
controller.enqueue(encoder.encode(event.delta.text))
}
// Surface an explicit message if the agent decides to call a tool.
// (Managed Agents may emit `input_json_delta` or `tool_use` blocks.)
if (
event.type === 'content_block_delta' &&
event.delta?.type === 'input_json_delta' &&
event.delta.partial_json
) {
controller.enqueue(
encoder.encode(
`\n\n_[agent requested tool input: ${event.delta.partial_json}]_\n\n`,
),
)
}
}
controller.close()
} catch (err) {
controller.enqueue(
encoder.encode(`\n\n[stream error: ${err.message || err}]`),
)
controller.close()
}
},
cancel() {
// If the client disconnects, abort the upstream Anthropic request.
upstream.controller?.abort?.()
},
})
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
})
}
export const config = {
path: '/.netlify/functions/agent-proxy',
}