diff --git a/a2a-examples/observability-a2a-agent b/a2a-examples/observability-a2a-agent new file mode 160000 index 0000000..88ad367 --- /dev/null +++ b/a2a-examples/observability-a2a-agent @@ -0,0 +1 @@ +Subproject commit 88ad367854c1027b55bcbe628c9f40df8ca4f6d5 diff --git a/agent-examples/observability-gpt-agent b/agent-examples/observability-gpt-agent new file mode 160000 index 0000000..8451d83 --- /dev/null +++ b/agent-examples/observability-gpt-agent @@ -0,0 +1 @@ +Subproject commit 8451d83316e9aa7cffc4a4a1b0ae305bca8ad704 diff --git a/financial-agent/.env.example b/financial-agent/.env.example index 6213bba..59d4f10 100644 --- a/financial-agent/.env.example +++ b/financial-agent/.env.example @@ -3,7 +3,7 @@ ############################################ OPENAI_API_KEY=sk-your-openai-key PORT=3000 -NVM_ENV=sandbox # or live +NVM_ENVIRONMENT=sandbox # or live BUILDER_NVM_API_KEY=your-builder-api-key NVM_AGENT_ID=your-agent-id NVM_AGENT_HOST=http://localhost:3000 # public URL in production @@ -12,7 +12,9 @@ NVM_AGENT_HOST=http://localhost:3000 # public URL in production # Client ############################################ AGENT_URL=http://localhost:3000 -NVM_ENV=sandbox # or live +NVM_ENVIRONMENT=sandbox # or live SUBSCRIBER_NVM_API_KEY=your-subscriber-api-key NVM_PLAN_ID=your-plan-id -NVM_AGENT_ID=your-agent-id \ No newline at end of file +NVM_AGENT_ID=your-agent-id + +HELICONE_URL=https://helicone.nevermined.dev \ No newline at end of file diff --git a/financial-agent/.gitignore b/financial-agent/.gitignore index 319e5c4..7b65050 100644 --- a/financial-agent/.gitignore +++ b/financial-agent/.gitignore @@ -3,4 +3,8 @@ dist/ .env .DS_Store *.log -.vscode \ No newline at end of file +.vscode + +.env.local +.env.staging +.env.production \ No newline at end of file diff --git a/financial-agent/README.md b/financial-agent/README.md index 217e241..8204ede 100644 --- a/financial-agent/README.md +++ b/financial-agent/README.md @@ -33,7 +33,7 @@ agent/ client/ index_unprotected.ts # Simple client hitting the free agent index_nevermined.ts # Client that buys/uses plan to call protected agent -.env # Environment variables (not committed) +.env # Environment variables ``` --- @@ -116,7 +116,7 @@ For the protected agent (server): ``` OPENAI_API_KEY=sk-... PORT=3000 -NVM_ENV=sandbox +NVM_ENVIRONMENT=sandbox BUILDER_NVM_API_KEY=your-builder-api-key # server-side key NVM_AGENT_ID=your-agent-id # agent registered in Nevermined NVM_AGENT_HOST=http://localhost:3000 # public URL in production @@ -125,7 +125,7 @@ NVM_AGENT_HOST=http://localhost:3000 # public URL in production For the protected client: ``` AGENT_URL=http://localhost:3000 -NVM_ENV=sandbox +NVM_ENVIRONMENT=sandbox SUBSCRIBER_NVM_API_KEY=your-subscriber-api-key # client/subscriber key NVM_PLAN_ID=your-plan-id # plan linked to the agent NVM_AGENT_ID=your-agent-id @@ -140,8 +140,8 @@ Key additions in `agent/index_nevermined.ts`: import { Payments, EnvironmentName } from "@nevermined-io/payments"; const NVM_API_KEY = process.env.BUILDER_NVM_API_KEY ?? ""; -const NVM_ENV = (process.env.NVM_ENV || "sandbox") as EnvironmentName; // or "live" for prod environment -const payments = Payments.getInstance({ nvmApiKey: NVM_API_KEY, environment: NVM_ENV }); +const NVM_ENVIRONMENT = (process.env.NVM_ENVIRONMENT || "sandbox") as EnvironmentName; // or "live" for prod environment +const payments = Payments.getInstance({ nvmApiKey: NVM_API_KEY, environment: NVM_ENVIRONMENT }); ``` 2) Extract the `Authorization` header and the HTTP request context. We will pass these to Nevermined to verify that the token is valid for this `agent` and endpoint. @@ -255,7 +255,7 @@ app.post("/ask", async (req: Request, res: Response) => { Run the protected server: ``` PORT=3000 OPENAI_API_KEY=sk-... \ -BUILDER_NVM_API_KEY=... NVM_ENV=sandbox NVM_AGENT_ID=... \ +BUILDER_NVM_API_KEY=... NVM_ENVIRONMENT=sandbox NVM_AGENT_ID=... \ npm run dev:agent ``` Health check: @@ -323,7 +323,7 @@ const baseUrl = process.env.AGENT_URL || "http://localhost:3000"; const planId = process.env.NVM_PLAN_ID as string; const agentId = process.env.NVM_AGENT_ID as string; const nvmApiKey = process.env.SUBSCRIBER_NVM_API_KEY as string; -const nvmEnv = (process.env.NVM_ENV || "sandbox") as EnvironmentName; +const nvmEnv = (process.env.NVM_ENVIRONMENT || "sandbox") as EnvironmentName; const bearer = await getOrBuyAccessToken({ planId, agentId, nvmApiKey, nvmEnv }); @@ -338,7 +338,7 @@ for (const input of questions) { Run it (in a separate terminal): ``` AGENT_URL=http://localhost:3000 \ -SUBSCRIBER_NVM_API_KEY=... NVM_ENV=sandbox \ +SUBSCRIBER_NVM_API_KEY=... NVM_ENVIRONMENT=sandbox \ NVM_PLAN_ID=... NVM_AGENT_ID=... \ npm run dev:client ``` @@ -360,12 +360,12 @@ Protected flow: ``` # Terminal A (agent) PORT=3000 OPENAI_API_KEY=sk-... \ -BUILDER_NVM_API_KEY=... NVM_ENV=sandbox NVM_AGENT_ID=... \ +BUILDER_NVM_API_KEY=... NVM_ENVIRONMENT=sandbox NVM_AGENT_ID=... \ npm run dev:agent # Terminal B (client) AGENT_URL=http://localhost:3000 \ -SUBSCRIBER_NVM_API_KEY=... NVM_ENV=sandbox \ +SUBSCRIBER_NVM_API_KEY=... NVM_ENVIRONMENT=sandbox \ NVM_PLAN_ID=... NVM_AGENT_ID=... \ npm run dev:client ``` @@ -392,7 +392,7 @@ import { Payments, EnvironmentName } from "@nevermined-io/payments"; - Add Nevermined configuration (after OpenAI checks): ```ts const NVM_API_KEY = process.env.BUILDER_NVM_API_KEY ?? ""; -const NVM_ENV = (process.env.NVM_ENV || "sandbox") as EnvironmentName; // or "live" +const NVM_ENVIRONMENT = (process.env.NVM_ENVIRONMENT || "sandbox") as EnvironmentName; // or "live" const NVM_AGENT_ID = process.env.NVM_AGENT_ID ?? ""; const NVM_AGENT_HOST = process.env.NVM_AGENT_HOST || `http://localhost:${PORT}`; if (!NVM_API_KEY || !NVM_AGENT_ID) { @@ -403,7 +403,7 @@ if (!NVM_API_KEY || !NVM_AGENT_ID) { - Create a singleton `payments` client (near other singletons like `model`): ```ts -const payments = Payments.getInstance({ nvmApiKey: NVM_API_KEY, environment: NVM_ENV }); +const payments = Payments.getInstance({ nvmApiKey: NVM_API_KEY, environment: NVM_ENVIRONMENT }); ``` - Introduce an authorization helper (before route handlers): @@ -448,7 +448,7 @@ Notes: ## Migration checklist (unprotected → protected) - Add `@nevermined-io/payments` import in the agent -- Add env vars: `BUILDER_NVM_API_KEY`, `NVM_ENV`, `NVM_AGENT_ID`, `NVM_AGENT_HOST` +- Add env vars: `BUILDER_NVM_API_KEY`, `NVM_ENVIRONMENT`, `NVM_AGENT_ID`, `NVM_AGENT_HOST` - Instantiate `payments = Payments.getInstance(...)` - Add `ensureAuthorized(req)` and call it at the start of protected handlers - On success, call `payments.requests.redeemCreditsFromRequest(agentRequestId, requestAccessToken, 1n)` @@ -472,7 +472,7 @@ Notes: - Wrong `requestedUrl` - Ensure `NVM_AGENT_HOST` matches the externally reachable host used by clients - Sandbox vs live - - `NVM_ENV` must match keys and assets created in that environment + - `NVM_ENVIRONMENT` must match keys and assets created in that environment --- @@ -484,14 +484,14 @@ Copy to `.env` and adjust values: # --- Server --- OPENAI_API_KEY=sk-your-openai-key PORT=3000 -NVM_ENV=sandbox # or live +NVM_ENVIRONMENT=sandbox # or live BUILDER_NVM_API_KEY=your-builder-api-key NVM_AGENT_ID=your-agent-id NVM_AGENT_HOST=http://localhost:3000 # public URL in production # --- Client --- AGENT_URL=http://localhost:3000 -NVM_ENV=sandbox # or live +NVM_ENVIRONMENT=sandbox # or live SUBSCRIBER_NVM_API_KEY=your-subscriber-api-key NVM_PLAN_ID=your-plan-id NVM_AGENT_ID=your-agent-id diff --git a/financial-agent/agent/index_nevermined.ts b/financial-agent/agent/index_nevermined.ts index 5d261d3..2d8e2e5 100644 --- a/financial-agent/agent/index_nevermined.ts +++ b/financial-agent/agent/index_nevermined.ts @@ -1,238 +1,184 @@ /** - * @fileoverview HTTP server for a financial-advice agent using LangChain and OpenAI. - * Exposes a `/ask` endpoint with per-session conversational memory. + * @fileoverview HTTP server for a financial-advice agent using OpenAI. + * Exposes a `/ask` endpoint with per-session conversational memory and Nevermined protection. */ import "dotenv/config"; import express, { Request, Response } from "express"; -import { ChatOpenAI } from "@langchain/openai"; -import { - ChatPromptTemplate, - MessagesPlaceholder, -} from "@langchain/core/prompts"; -import { RunnableWithMessageHistory } from "@langchain/core/runnables"; -import { InMemoryChatMessageHistory } from "@langchain/core/chat_history"; +import OpenAI from "openai"; import crypto from "crypto"; -import { - Payments, - EnvironmentName, - StartAgentRequest, -} from "@nevermined-io/payments"; - -/** - * In-memory session message store. - */ -class SessionStore { - private sessions: Map = new Map(); - - /** - * Get or create the message history for a session id. - * @param {string} sessionId - Session identifier - * @returns {InMemoryChatMessageHistory} The chat message history for the session - */ - getHistory(sessionId: string): InMemoryChatMessageHistory { - let history = this.sessions.get(sessionId); - if (!history) { - history = new InMemoryChatMessageHistory(); - this.sessions.set(sessionId, history); - } - return history; - } -} - -/** - * Build the financial advisor prompt template. - * @returns {ChatPromptTemplate} The composed chat prompt template - */ -function buildFinancialPrompt(): ChatPromptTemplate { - const systemText = `You are FinGuide, a professional financial advisor and market analyst specializing in cryptocurrency and traditional markets. -Your role is to provide: - -1. Real-time market data: current prices of cryptocurrencies, stock market performance, and key market indicators. -2. Investment analysis: monthly returns of major companies, market trends, and investment opportunities. -3. Financial advice: recommendations on whether to invest in specific assets based on current market conditions in a generic way. -4. Educational content: explain financial concepts, market dynamics, and investment strategies in simple terms. - -Response requirements: -- Be accurate and rely on current market data where possible. -- Include specific numbers and percentages when relevant. -- Provide balanced advice considering both opportunities and risks. -- Be educational and explain the reasoning behind recommendations. -- Always include appropriate risk warnings. -- Maintain a professional but accessible tone for both beginners and experienced investors. - -When providing investment advice: -- Consider the user's risk tolerance (no need to ask if not specified). -- Mention both potential gains and potential losses. -- Include time horizon recommendations. -- Suggest diversification strategies. -- Always remind: past performance does not guarantee future results. - -Formatting: -- Use clear headings and bullet points. -- Display current market data prominently. -- Highlight risk warnings in bold. -- Provide actionable recommendations when appropriate. - -Important constraints: -- You provide financial information and general advice, not personalized financial planning. -- Recommend consulting with a qualified financial advisor for personalized decisions. -- Avoid collecting personally identifiable information. -- Ask clarifying questions when the user intent or constraints (budget, risk tolerance, time horizon) are unclear. -`; - return ChatPromptTemplate.fromMessages([ - ["system", systemText], - new MessagesPlaceholder("history"), - ["human", "{input}"], - ]); -} - -/** - * Create LangChain pipeline with per-session memory. - * @param {ChatOpenAI} model - Chat model instance - * @returns {RunnableWithMessageHistory} Runnable with history - */ -function createRunnable(model: ChatOpenAI) { - const prompt = buildFinancialPrompt(); - const chain = prompt.pipe(model); - const runnable = new RunnableWithMessageHistory({ - runnable: chain, - getMessageHistory: async (sessionId: string) => - sessionStore.getHistory(sessionId), - inputMessagesKey: "input", - historyMessagesKey: "history", - }); - return runnable; -} +import { Payments, EnvironmentName, StartAgentRequest } from "@nevermined-io/payments"; const app = express(); app.use(express.json()); const PORT = process.env.PORT ? Number(process.env.PORT) : 3000; const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""; +const NVM_API_KEY = process.env.BUILDER_NVM_API_KEY ?? ""; +const NVM_ENVIRONMENT = (process.env.NVM_ENVIRONMENT || "staging_sandbox") as EnvironmentName; +const NVM_AGENT_ID = process.env.NVM_AGENT_ID ?? ""; +const NVM_AGENT_HOST = process.env.NVM_AGENT_HOST || `http://localhost:${PORT}`; + if (!OPENAI_API_KEY) { - // eslint-disable-next-line no-console console.error("OPENAI_API_KEY is required to run the agent."); process.exit(1); } -// Nevermined required configuration -const NVM_API_KEY = process.env.BUILDER_NVM_API_KEY ?? ""; -const NVM_ENV = (process.env.NVM_ENV || "staging_sandbox") as EnvironmentName; -const NVM_AGENT_ID = process.env.NVM_AGENT_ID ?? ""; -const NVM_AGENT_HOST = process.env.NVM_AGENT_HOST || `http://localhost:${PORT}`; -const NVM_PLAN_ID = process.env.NVM_PLAN_ID ?? ""; - if (!NVM_API_KEY || !NVM_AGENT_ID) { - // eslint-disable-next-line no-console - console.error( - "Nevermined environment is required: set NVM_API_KEY and NVM_AGENT_ID in .env" - ); + console.error("Nevermined environment is required: set NVM_API_KEY and NVM_AGENT_ID in .env"); process.exit(1); } -/** - * Build a singleton Payments client for Nevermined. - */ +// Initialize Nevermined Payments SDK for access control and observability const payments = Payments.getInstance({ nvmApiKey: NVM_API_KEY, - environment: NVM_ENV, + environment: NVM_ENVIRONMENT, }); -const sessionStore = new SessionStore(); +// Define the AI's role and behavior +function getSystemPrompt(maxTokens: number): string { + return `You are FinGuide, a friendly financial education AI designed to help people learn about investing, personal finance, and market concepts. -/** - * Create a model with dynamic sessionId and custom properties for each request - * @param {string} sessionId - The session ID for this request - * @param {Record} customProperties - Additional custom properties to include as headers - * @returns {ChatOpenAI} Configured ChatOpenAI model - */ -function createModelWithSessionId( - agentRequest: StartAgentRequest, - customProperties: Record = {} -): ChatOpenAI { - return new ChatOpenAI( - payments.observability.withHeliconeLangchain( - "gpt-4o-mini", - OPENAI_API_KEY, - agentRequest, - customProperties - ) - ); +Your role is to provide: + +1. Financial education: Explain investing concepts, terminology, and strategies in simple, beginner-friendly language. +2. General market insights: Discuss historical trends, market principles, and how different asset classes typically behave. +3. Investment fundamentals: Teach about diversification, risk management, dollar-cost averaging, and long-term investing principles. +4. Personal finance guidance: Help with budgeting basics, emergency funds, debt management, and retirement planning concepts. + +Response style: +Write in a natural, conversational tone as if you're chatting with a friend over coffee. Be encouraging and educational rather +than giving specific investment advice. Use analogies and everyday examples to explain complex concepts in a way that feels +relatable. Always focus on teaching principles rather than recommending specific investments. Be honest about not having access +to real-time market data, and naturally encourage users to do their own research and consult professionals for personalized +advice. Avoid using bullet points or formal lists - instead, weave information into flowing, natural sentences that feel +like genuine conversation. Adjust your response length based on the complexity of the question - for simple questions, +keep responses concise (50-100 words), but for complex topics that need thorough explanation, feel free to use +up to ${maxTokens} tokens to provide comprehensive educational value. + +Important disclaimers: +Remember to naturally work into your conversations that you're an educational AI guide, not a licensed financial advisor. +You don't have access to real-time market data or current prices. All the information you share is for educational purposes only, +not personalized financial advice. Always encourage users to consult with qualified financial professionals for actual +investment decisions. Naturally remind them that past performance never guarantees future results and all investments +carry risk, including potential loss of principal. + +When discussing investments: +Focus on general principles and educational concepts while explaining both potential benefits and risks in a conversational way. +Naturally emphasize the importance of diversification and long-term thinking. Gently remind users to only invest what they can +afford to lose and suggest they research thoroughly while considering their personal financial situation. +Make these important points feel like natural parts of the conversation rather than formal warnings.`; } -/** - * Ensure the incoming request is authorized via Nevermined and return request data for redemption. - * @param {Request} req - Express request object - * @returns {{ agentRequestId: string, requestAccessToken: string }} identifiers to redeem credits later - * @throws Error with statusCode 402 when not authorized - */ -async function ensureAuthorized( - req: Request -): Promise<{ agentRequest: StartAgentRequest; requestAccessToken: string }> { - const authHeader = (req.headers["authorization"] || "") as string; - const requestedUrl = `${NVM_AGENT_HOST}${req.url}`; - const httpVerb = req.method; - const result = await payments.requests.startProcessingRequest( - NVM_AGENT_ID, - authHeader, - requestedUrl, - httpVerb - ); - if (!result.balance.isSubscriber || result.balance.balance < 1n) { - const error: any = new Error("Payment Required"); - error.statusCode = 402; - throw error; - } - const requestAccessToken = authHeader.replace(/^Bearer\s+/i, ""); - return { agentRequest: result, requestAccessToken }; +// Calculate dynamic credit amount based on token usage +function calculateCreditAmount(tokensUsed: number, maxTokens: number): number { + // Formula: 10 * (actual_tokens / max_tokens) + // This rewards shorter responses with lower costs + const tokenUtilization = Math.min(tokensUsed / maxTokens, 1); // Cap at 1 + const baseCreditAmount = 10 * tokenUtilization; + const creditAmount = Math.max(Math.ceil(baseCreditAmount), 1); // Minimum 1 credit + + console.log(`Token usage: ${tokensUsed}/${maxTokens} (${(tokenUtilization * 100).toFixed(1)}%) - Credits: ${creditAmount}`); + + return creditAmount; } -/** - * POST /ask - * Body: { input: string, sessionId?: string } - * Returns: { output: string, sessionId: string } - */ -/** - * Handle medical question requests. - * Creates a session when one is not provided and reuses memory across calls. - */ +// Store conversation history for each session +const sessions = new Map(); + +// Handle financial advice requests with Nevermined payment protection and observability app.post("/ask", async (req: Request, res: Response) => { try { - const { agentRequest, requestAccessToken } = await ensureAuthorized(req); - console.log("agentRequestId", agentRequest.agentRequestId); - console.log("requestAccessToken", requestAccessToken); + // Extract authorization details from request headers + const authHeader = (req.headers["authorization"] || "") as string; + const requestedUrl = `${NVM_AGENT_HOST}${req.url}`; + const httpVerb = req.method; + + // Check if user is authorized and has sufficient balance + const agentRequest = await payments.requests.startProcessingRequest( + NVM_AGENT_ID, + authHeader, + requestedUrl, + httpVerb + ); + + // Reject request if user doesn't have credits or subscription + if (!agentRequest.balance.isSubscriber || agentRequest.balance.balance < 1n) { + return res.status(402).json({ error: "Payment Required" }); + } + + // Extract access token for credit redemption + const requestAccessToken = authHeader.replace(/^Bearer\s+/i, ""); + + // Extract and validate the user's input const input = String(req.body?.input_query ?? "").trim(); if (!input) return res.status(400).json({ error: "Missing input" }); + // Get or create a session ID for conversation continuity let { sessionId } = req.body as { sessionId?: string }; if (!sessionId) sessionId = crypto.randomUUID(); - // Create model and runnable with the dynamic sessionId - const model = createModelWithSessionId(agentRequest); - const runnable = createRunnable(model); + // Define the maximum number of tokens for the completion response + const maxTokens = 250; - const result = await runnable.invoke( - { input }, - { configurable: { sessionId } } - ); - const text = - result?.content ?? - (Array.isArray(result) - ? result.map((m: any) => m.content).join("\n") - : String(result)); + // Retrieve existing conversation history or start fresh + let messages = sessions.get(sessionId) || []; + + // Add system prompt if this is a new conversation + if (messages.length === 0) { + messages.push({ + role: "system", + content: getSystemPrompt(maxTokens) + }); + } + + // Add the user's question to the conversation + messages.push({ role: "user", content: input }); + + // Set up observability metadata for tracking this operation + const customProperties = { + agentid: NVM_AGENT_ID, + sessionid: sessionId, + operation: "financial_advice", + }; + + // Create OpenAI client with Helicone observability integration + const openai = new OpenAI(payments.observability.withHeliconeOpenAI( + OPENAI_API_KEY, + agentRequest, + customProperties + )); + + // Call OpenAI API to generate response + const completion = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: messages, + temperature: 0.3, + max_tokens: maxTokens, + }); - // After successful processing, redeem 1 credit for this request + // Extract the AI's response and token usage + const response = completion.choices[0]?.message?.content || "No response generated"; + const tokensUsed = completion.usage?.completion_tokens || 0; + + // Save the AI's response to conversation history + messages.push({ role: "assistant", content: response }); + sessions.set(sessionId, messages); + + // Calculate dynamic credit amount based on token usage + const creditAmount = calculateCreditAmount(tokensUsed, maxTokens); + + // Initialize redemption result let redemptionResult: any; + + // Redeem credits after successful API call try { redemptionResult = await payments.requests.redeemCreditsFromRequest( agentRequest.agentRequestId, requestAccessToken, - 1n + BigInt(creditAmount) ); - redemptionResult.creditsRedeemed = 1; - console.log("redemptionResult", redemptionResult); + redemptionResult.creditsRedeemed = creditAmount; } catch (redeemErr) { - // eslint-disable-next-line no-console console.error("Failed to redeem credits:", redeemErr); redemptionResult = { creditsRedeemed: 0, @@ -240,9 +186,9 @@ app.post("/ask", async (req: Request, res: Response) => { }; } - res.json({ output: text, sessionId, redemptionResult }); + // Return response with session info and payment details + res.json({ output: response, sessionId, redemptionResult }); } catch (error: any) { - // eslint-disable-next-line no-console console.error("Error handling /ask", error); const status = error?.statusCode === 402 ? 402 : 500; res.status(status).json({ @@ -251,11 +197,17 @@ app.post("/ask", async (req: Request, res: Response) => { } }); +// Health check endpoint app.get("/health", (_req: Request, res: Response) => { res.json({ status: "ok" }); }); +// Start the server app.listen(PORT, () => { // eslint-disable-next-line no-console console.log(`Agent listening on http://localhost:${PORT}`); + console.log("NVM_API_KEY", process.env.BUILDER_NVM_API_KEY); + console.log("NVM_ENVIRONMENT", process.env.NVM_ENVIRONMENT); + console.log("NVM_AGENT_ID", process.env.NVM_AGENT_ID); + console.log("NVM_PLAN_ID", process.env.NVM_PLAN_ID); }); diff --git a/financial-agent/agent/index_unprotected.ts b/financial-agent/agent/index_unprotected.ts index aa3c610..25843b0 100644 --- a/financial-agent/agent/index_unprotected.ts +++ b/financial-agent/agent/index_unprotected.ts @@ -1,111 +1,121 @@ /** - * @fileoverview Free-access HTTP server for the medical-advice agent (no Nevermined protection). + * @fileoverview Free-access HTTP server for the financial-advice agent (no Nevermined protection). * Provides a `/ask` endpoint with per-session conversational memory. */ import "dotenv/config"; import express, { Request, Response } from "express"; -import { ChatOpenAI } from "@langchain/openai"; -import { - ChatPromptTemplate, - MessagesPlaceholder, -} from "@langchain/core/prompts"; -import { RunnableWithMessageHistory } from "@langchain/core/runnables"; -import { InMemoryChatMessageHistory } from "@langchain/core/chat_history"; +import OpenAI from "openai"; import crypto from "crypto"; -class SessionStore { - private sessions: Map = new Map(); - getHistory(sessionId: string): InMemoryChatMessageHistory { - let history = this.sessions.get(sessionId); - if (!history) { - history = new InMemoryChatMessageHistory(); - this.sessions.set(sessionId, history); - } - return history; - } -} - -function buildMedicalPrompt(): ChatPromptTemplate { - const systemText = `You are MedGuide, a board-certified medical expert assistant. -Provide accurate, evidence-based, and empathetic medical guidance. -Constraints and behavior: -- You are not a substitute for a licensed physician or emergency services. -- If symptoms are severe, sudden, or life-threatening, advise calling emergency services immediately. -- Be concise but thorough. Use plain language, avoid jargon, and explain reasoning. -- Always ask clarifying questions when the information is insufficient. -- Provide differential considerations when appropriate and list red flags. -- Include lifestyle guidance and self-care measures when relevant. -- When medication is discussed, include typical adult dosage ranges where safe and general, and warn to consult a clinician for personalized dosing, interactions, or contraindications. -- Suggest when to seek in-person evaluation and what tests a clinician might order. -- Never provide definitive diagnoses. Use probabilistic language (e.g., likely, possible). -- Respect privacy and avoid collecting personally identifiable information. -- If the request is outside medical scope, politely decline or redirect.`; - return ChatPromptTemplate.fromMessages([ - ["system", systemText], - new MessagesPlaceholder("history"), - ["human", "{input}"], - ]); -} - -function createRunnable(model: ChatOpenAI) { - const prompt = buildMedicalPrompt(); - const chain = prompt.pipe(model); - return new RunnableWithMessageHistory({ - runnable: chain, - getMessageHistory: async (sessionId: string) => - sessionStore.getHistory(sessionId), - inputMessagesKey: "input", - historyMessagesKey: "history", - }); -} - const app = express(); app.use(express.json()); const PORT = process.env.PORT ? Number(process.env.PORT) : 3001; const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""; + if (!OPENAI_API_KEY) { - // eslint-disable-next-line no-console - console.error("OPENAI_API_KEY is required to run the free agent."); + console.error("OPENAI_API_KEY is required to run the agent."); process.exit(1); } -const sessionStore = new SessionStore(); -const model = new ChatOpenAI({ - model: "gpt-4o-mini", - temperature: 0.3, - apiKey: OPENAI_API_KEY, -}); -const runnable = createRunnable(model); +// Initialize OpenAI client with API key +const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); + +// Define the AI's role and behavior +function getSystemPrompt(maxTokens: number): string { + return `You are FinGuide, a friendly financial education AI designed to help people learn about investing, personal finance, and market concepts. + +Your role is to provide: + +1. Financial education: Explain investing concepts, terminology, and strategies in simple, beginner-friendly language. +2. General market insights: Discuss historical trends, market principles, and how different asset classes typically behave. +3. Investment fundamentals: Teach about diversification, risk management, dollar-cost averaging, and long-term investing principles. +4. Personal finance guidance: Help with budgeting basics, emergency funds, debt management, and retirement planning concepts. + +Response style: +Write in a natural, conversational tone as if you're chatting with a friend over coffee. Be encouraging and educational rather +than giving specific investment advice. Use analogies and everyday examples to explain complex concepts in a way that feels +relatable. Always focus on teaching principles rather than recommending specific investments. Be honest about not having access +to real-time market data, and naturally encourage users to do their own research and consult professionals for personalized +advice. Avoid using bullet points or formal lists - instead, weave information into flowing, natural sentences that feel +like genuine conversation. Adjust your response length based on the complexity of the question - for simple questions, +keep responses concise (50-100 words), but for complex topics that need thorough explanation, feel free to use +up to ${maxTokens} tokens to provide comprehensive educational value. +Important disclaimers: +Remember to naturally work into your conversations that you're an educational AI guide, not a licensed financial advisor. +You don't have access to real-time market data or current prices. All the information you share is for educational purposes only, +not personalized financial advice. Always encourage users to consult with qualified financial professionals for actual +investment decisions. Naturally remind them that past performance never guarantees future results and all investments +carry risk, including potential loss of principal. + +When discussing investments: +Focus on general principles and educational concepts while explaining both potential benefits and risks in a conversational way. +Naturally emphasize the importance of diversification and long-term thinking. Gently remind users to only invest what they can +afford to lose and suggest they research thoroughly while considering their personal financial situation. +Make these important points feel like natural parts of the conversation rather than formal warnings.`; +} + +// Store conversation history for each session +const sessions = new Map(); + +// Handle financial advice requests with session-based conversation memory app.post("/ask", async (req: Request, res: Response) => { try { + // Extract and validate the user's input const input = String(req.body?.input_query ?? "").trim(); if (!input) return res.status(400).json({ error: "Missing input" }); + + // Get or create a session ID for conversation continuity let { sessionId } = req.body as { sessionId?: string }; if (!sessionId) sessionId = crypto.randomUUID(); - const result = await runnable.invoke( - { input }, - { configurable: { sessionId } } - ); - const text = - result?.content ?? - (Array.isArray(result) - ? result.map((m: any) => m.content).join("\n") - : String(result)); - res.json({ output: text, sessionId }); + + // Define the maximum number of tokens for the completion response + const maxTokens = 250; + + // Retrieve existing conversation history or start fresh + let messages = sessions.get(sessionId) || []; + + // Add system prompt if this is a new conversation + if (messages.length === 0) { + messages.push({ + role: "system", + content: getSystemPrompt(maxTokens) + }); + } + + // Add the user's question to the conversation + messages.push({ role: "user", content: input }); + + // Call OpenAI API to generate response + const completion = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: messages, + temperature: 0.3, + max_tokens: maxTokens, + }); + + // Extract the AI's response + const response = completion.choices[0]?.message?.content || "No response generated"; + + // Save the AI's response to conversation history + messages.push({ role: "assistant", content: response }); + sessions.set(sessionId, messages); + + // Return response to the client + res.json({ output: response, sessionId }); } catch (error: any) { - // eslint-disable-next-line no-console - console.error("Free agent /ask error:", error); + console.error("Agent /ask error:", error); res.status(500).json({ error: "Internal server error" }); } }); +// Health check endpoint app.get("/health", (_req: Request, res: Response) => { res.json({ status: "ok" }); }); +// Start the server app.listen(PORT, () => { - // eslint-disable-next-line no-console - console.log(`Free Agent listening on http://localhost:${PORT}`); + console.log(`Financial Agent listening on http://localhost:${PORT}`); }); diff --git a/financial-agent/client/index_nevermined.ts b/financial-agent/client/index_nevermined.ts index 06e5e78..67016c6 100644 --- a/financial-agent/client/index_nevermined.ts +++ b/financial-agent/client/index_nevermined.ts @@ -5,122 +5,170 @@ import "dotenv/config"; import { Payments, EnvironmentName } from "@nevermined-io/payments"; -/** - * Run the protected demo client. - * Sends predefined financial questions to the agent with Authorization and reuses sessionId to preserve context. - * @returns {Promise} Resolves when the run finishes - */ -async function main(): Promise { - const baseUrl = process.env.AGENT_URL || "http://localhost:3000"; +// Configuration: Load environment variables with defaults +const AGENT_URL = process.env.AGENT_URL || "http://localhost:3000"; +const PLAN_ID = process.env.NVM_PLAN_ID as string; +const AGENT_ID = process.env.NVM_AGENT_ID as string; +const SUBSCRIBER_API_KEY = process.env.SUBSCRIBER_NVM_API_KEY as string; +const NVM_ENVIRONMENT = (process.env.NVM_ENVIRONMENT || "staging_sandbox") as EnvironmentName; - // Predefined questions for the demo client. The client is intentionally dumb. - const questions: string[] = [ - "What is your market outlook for Bitcoin over the next month?", - "How are major stock indices performing today and what trends are notable?", - "What risks should I consider before increasing exposure to tech stocks?", - ]; +// Define demo conversation to show chatbot-style interaction +const DEMO_CONVERSATION_QUESTIONS = [ + "Hi there! I'm new to investing and keep hearing about diversification. Can you explain what that means in simple terms?", + "That makes sense! So if I want to start investing but only have $100 a month, what should I focus on first?", + "I've been thinking about cryptocurrency. What should a beginner like me know before investing in crypto?", +]; - let sessionId: string | undefined; - let bearer: string | undefined; - - const planId = process.env.NVM_PLAN_ID as string; - const agentId = process.env.NVM_AGENT_ID as string; - const nvmApiKey = process.env.SUBSCRIBER_NVM_API_KEY as string; - const nvmEnv = (process.env.NVM_ENV || "sandbox") as EnvironmentName; - if (!planId || !agentId) { - throw new Error("NVM_PLAN_ID and NVM_AGENT_ID are required in client env"); +// Validate required environment variables +function validateEnvironment(): void { + if (!PLAN_ID || !AGENT_ID) { + throw new Error("NVM_PLAN_ID and NVM_AGENT_ID are required in environment"); } - if (!nvmApiKey) { - throw new Error("SUBSCRIBER_NVM_API_KEY is required in client env"); + if (!SUBSCRIBER_API_KEY) { + throw new Error("SUBSCRIBER_NVM_API_KEY is required in environment"); } - bearer = await getOrBuyAccessToken({ - planId, - agentId, - nvmApiKey, - nvmEnv, +} + +// Get or purchase access token for protected agent +async function getorPurchaseAccessToken(): Promise { + console.log("🔐 Setting up Nevermined access..."); + + // Initialize Nevermined Payments SDK + const payments = Payments.getInstance({ + nvmApiKey: SUBSCRIBER_API_KEY, + environment: NVM_ENVIRONMENT, }); - for (let i = 0; i < questions.length; i += 1) { - const input = questions[i]; - // eslint-disable-next-line no-console - console.log(`\n[CLIENT] Sending question ${i + 1}: ${input}`); - const response = await askAgent(baseUrl, input, sessionId, bearer); - sessionId = response.sessionId; - // eslint-disable-next-line no-console - console.log(`[AGENT] (sessionId=${sessionId})\n${response.output}`); + // Check current plan balance and subscription status + const balanceInfo: any = await payments.plans.getPlanBalance(PLAN_ID); + const hasCredits = Number(balanceInfo?.balance ?? 0) > 0; + const isSubscriber = balanceInfo?.isSubscriber === true; + + // Purchase plan if not subscribed and no credits + if (!isSubscriber && !hasCredits) { + console.log("💳 No subscription or credits found. Purchasing plan..."); + await payments.plans.orderPlan(PLAN_ID); + } + + // Get access token for the agent + const credentials = await payments.agents.getAgentAccessToken(PLAN_ID, AGENT_ID); + + if (!credentials?.accessToken) { + throw new Error("Failed to obtain access token"); } + + console.log("✅ Access token obtained successfully"); + return credentials.accessToken; } -/** - * Perform a POST /ask to the protected agent. - * @param {string} baseUrl - Base URL of the agent service - * @param {string} input - User question text - * @param {string} [sessionId] - Optional existing session id - * @param {string} [bearer] - Authorization token to access protected endpoint - * @returns {Promise<{ output: string; sessionId: string }>} The JSON response containing output and sessionId - */ -async function askAgent( - baseUrl: string, - input: string, - sessionId?: string, - bearer?: string -): Promise<{ output: string; sessionId: string }> { - const res = await fetch(`${baseUrl}/ask`, { - method: "POST", - headers: { +// Simple loading animation for terminal +function startLoadingAnimation(): () => void { + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let i = 0; + const interval = setInterval(() => { + process.stdout.write(`\r${frames[i]} FinGuide is thinking...`); + i = (i + 1) % frames.length; + }, 100); + + return () => { + clearInterval(interval); + process.stdout.write('\r'); + }; +} + +// Send a question to the protected financial agent +async function askAgent(input: string, accessToken: string, sessionId?: string): Promise<{ output: string; sessionId: string; redemptionResult?: any }> { + // Start loading animation + const stopLoading = startLoadingAnimation(); + + try { + // Prepare request payload + const requestBody = { + input_query: input, + sessionId: sessionId + }; + + // Prepare headers with authorization + const headers = { "Content-Type": "application/json", - ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), - }, - body: JSON.stringify({ input_query: input, sessionId }), - }); - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `Agent request failed: ${res.status} ${res.statusText} ${errorText}` - ); + "Authorization": `Bearer ${accessToken}` + }; + + // Make HTTP request to protected agent + const response = await fetch(`${AGENT_URL}/ask`, { + method: "POST", + headers: headers, + body: JSON.stringify(requestBody), + }); + + // Handle HTTP errors (including payment required) + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + if (response.status === 402) { + throw new Error("Payment Required - insufficient credits or subscription"); + } + throw new Error(`Agent request failed: ${response.status} ${response.statusText} ${errorText}`); + } + + // Parse and return JSON response + return await response.json() as { output: string; sessionId: string; redemptionResult?: any }; + } finally { + // Stop loading animation + stopLoading(); } - const data = (await res.json()) as { output: string; sessionId: string }; - return data; } /** - * Get a valid access token by checking plan balance/subscription first. - * If not subscribed or no credits, purchase the plan and then fetch the token. - * @param {Object} opts - Options object - * @param {string} opts.planId - Plan identifier - * @param {string} opts.agentId - Agent identifier - * @param {string} opts.nvmApiKey - Nevermined API Key (subscriber) - * @param {EnvironmentName} opts.nvmEnv - Nevermined environment - * @returns {Promise} The access token string + * Run the protected demo client. + * Sends predefined financial questions to the agent with Authorization and reuses sessionId to preserve context. + * @returns {Promise} Resolves when the run finishes */ -async function getOrBuyAccessToken(opts: { - planId: string; - agentId: string; - nvmApiKey: string; - nvmEnv: EnvironmentName; -}): Promise { - const payments = Payments.getInstance({ - nvmApiKey: opts.nvmApiKey, - environment: opts.nvmEnv, - }); - const balanceInfo: any = await payments.plans.getPlanBalance(opts.planId); - const hasCredits = Number(balanceInfo?.balance ?? 0) > 0; - const isSubscriber = balanceInfo?.isSubscriber === true; - if (!isSubscriber && !hasCredits) { - console.log("Ordering plan with key: ", opts.nvmApiKey); - await payments.plans.orderPlan(opts.planId); +async function runDemo(): Promise { + console.log("🚀 Starting Financial Agent Demo (Protected with Nevermined)\n"); + + // Validate environment setup + validateEnvironment(); + + // Obtain access token for protected agent + const accessToken = await getorPurchaseAccessToken(); + + // Track session across multiple questions + let sessionId: string | undefined; + + // Send each demo question and maintain conversation context + for (let i = 0; i < DEMO_CONVERSATION_QUESTIONS.length; i++) { + const question = DEMO_CONVERSATION_QUESTIONS[i]; + + console.log(`📝 Question ${i + 1}: ${question}`); + + try { + // Send question to protected agent (reusing sessionId for context) + const result = await askAgent(question, accessToken, sessionId); + + // Update sessionId for next question + sessionId = result.sessionId; + + // Display agent response and payment info + console.log(`🤖 FinGuide (Session: ${sessionId}):`); + console.log(result.output); + + if (result.redemptionResult) { + console.log(`💰 Credits redeemed: ${result.redemptionResult.creditsRedeemed || 0}`); + } + + console.log("\n" + "=".repeat(80) + "\n"); + + } catch (error) { + console.error(`❌ Error processing question ${i + 1}:`, error); + break; + } } - const creds = await payments.agents.getAgentAccessToken( - opts.planId, - opts.agentId - ); - if (!creds?.accessToken) throw new Error("Access token unavailable"); - return creds.accessToken; + + console.log("✅ Demo completed!"); } -// Run the client -main().catch((err) => { - // eslint-disable-next-line no-console - console.error("[CLIENT] Error:", err); +// Run the demo and handle any errors +runDemo().catch((error) => { + console.error("💥 Demo failed:", error); process.exit(1); }); diff --git a/financial-agent/client/index_unprotected.ts b/financial-agent/client/index_unprotected.ts index 4db566f..1a6d0eb 100644 --- a/financial-agent/client/index_unprotected.ts +++ b/financial-agent/client/index_unprotected.ts @@ -4,61 +4,104 @@ */ import "dotenv/config"; +// Configuration: Agent URL from environment or default +const AGENT_URL = process.env.AGENT_URL || "http://localhost:3001"; + +// Define demo conversation to show chatbot-style interaction +const DEMO_CONVERSATION_QUESTIONS = [ + "Hi there! I'm new to investing and keep hearing about diversification. Can you explain what that means in simple terms?", + "That makes sense! So if I want to start investing but only have $100 a month, what should I focus on first?", + "I've been thinking about cryptocurrency. What should a beginner like me know before investing in crypto?", +]; + +// Simple loading animation for terminal +function startLoadingAnimation(): () => void { + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let i = 0; + const interval = setInterval(() => { + process.stdout.write(`\r${frames[i]} FinGuide is thinking...`); + i = (i + 1) % frames.length; + }, 100); + + return () => { + clearInterval(interval); + process.stdout.write('\r'); + }; +} + +// Send a question to the financial agent +async function askAgent(input: string, sessionId?: string): Promise<{ output: string; sessionId: string }> { + // Start loading animation + const stopLoading = startLoadingAnimation(); + + try { + // Prepare request payload + const requestBody = { + input_query: input, + sessionId: sessionId + }; + + // Make HTTP request to agent + const response = await fetch(`${AGENT_URL}/ask`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + }); + + // Handle HTTP errors + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new Error(`Agent request failed: ${response.status} ${response.statusText} ${errorText}`); + } + + // Parse and return JSON response + return await response.json() as { output: string; sessionId: string }; + } finally { + // Stop loading animation + stopLoading(); + } +} + /** * Run the unprotected demo client. * Sends predefined financial questions to the agent and reuses sessionId to preserve context. * @returns {Promise} Resolves when the run finishes */ -async function main(): Promise { - const baseUrl = process.env.AGENT_URL || "http://localhost:3001"; - - const questions: string[] = [ - "What is your market outlook for Bitcoin over the next month?", - "How are major stock indices performing today and what trends are notable?", - "What risks should I consider before increasing exposure to tech stocks?", - ]; +async function runDemo(): Promise { + console.log("🚀 Starting Financial Agent Demo (Unprotected)\n"); + // Track session across multiple questions let sessionId: string | undefined; - for (let i = 0; i < questions.length; i += 1) { - const input = questions[i]; - // eslint-disable-next-line no-console - console.log(`\n[FREE CLIENT] Sending question ${i + 1}: ${input}`); - const response = await askAgent(baseUrl, input, sessionId); - sessionId = response.sessionId; - // eslint-disable-next-line no-console - console.log(`[FREE AGENT] (sessionId=${sessionId})\n${response.output}`); - } -} + // Send each demo question and maintain conversation context + for (let i = 0; i < DEMO_CONVERSATION_QUESTIONS.length; i++) { + const question = DEMO_CONVERSATION_QUESTIONS[i]; -/** - * Perform a POST /ask to the free agent. - * @param {string} baseUrl - Base URL of the agent service - * @param {string} input - User question text - * @param {string} [sessionId] - Optional existing session id to keep context - * @returns {Promise<{ output: string; sessionId: string }>} Response with model output and session id - */ -async function askAgent( - baseUrl: string, - input: string, - sessionId?: string -): Promise<{ output: string; sessionId: string }> { - const res = await fetch(`${baseUrl}/ask`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ input_query: input, sessionId }), - }); - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `Free agent request failed: ${res.status} ${res.statusText} ${errorText}` - ); + console.log(`📝 Question ${i + 1}: ${question}`); + + try { + // Send question to agent (reusing sessionId for context) + const result = await askAgent(question, sessionId); + + // Update sessionId for next question + sessionId = result.sessionId; + + // Display agent response + console.log(`🤖 FinGuide (Session: ${sessionId}):`); + console.log(result.output); + console.log("\n" + "=".repeat(80) + "\n"); + + } catch (error) { + console.error(`❌ Error processing question ${i + 1}:`, error); + break; + } } - return (await res.json()) as { output: string; sessionId: string }; + + console.log("✅ Demo completed!"); } -main().catch((err) => { - // eslint-disable-next-line no-console - console.error("[FREE CLIENT] Error:", err); +// Run the demo and handle any errors +runDemo().catch((error) => { + console.error("💥 Demo failed:", error); process.exit(1); }); diff --git a/financial-agent/package-lock.json b/financial-agent/package-lock.json index 86c0cdb..cd47853 100644 --- a/financial-agent/package-lock.json +++ b/financial-agent/package-lock.json @@ -22,46 +22,178 @@ "typescript": "^5.6.2" } }, - "../../payments": { - "name": "@nevermined-io/payments", - "version": "1.0.0-rc14", - "license": "Apache-2.0", + "node_modules/@a2a-js/sdk": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.2.5.tgz", + "integrity": "sha512-VTDuRS5V0ATbJ/LkaQlisMnTAeYKXAK6scMguVBstf+KIBQ7HIuKhiXLv+G/hvejkV+THoXzoNifInAkU81P1g==", "dependencies": { - "@a2a-js/sdk": "^0.2.5", - "@helicone/helpers": "^1.6.0", - "axios": "^1.7.7", - "express": "4.21.2", - "jose": "^5.2.4", - "js-file-download": "^0.4.12", - "uuid": "^10.0.0", - "zod": "^4.0.17" + "@types/cors": "^2.8.17", + "@types/express": "^4.17.23", + "body-parser": "^2.2.0", + "cors": "^2.8.5", + "express": "^4.21.2", + "uuid": "^11.1.0" }, - "devDependencies": { - "@babel/core": "^7.27.4", - "@babel/preset-env": "^7.27.2", - "@modelcontextprotocol/sdk": "^1.17.2", - "@types/express": "4.17.23", - "@types/jest": "^29.5.13", - "@types/node": "^20.11.19", - "@types/uuid": "^10.0.0", - "@types/ws": "^8.0.3", - "@typescript-eslint/eslint-plugin": "^5.62.0", - "@typescript-eslint/parser": "^5.62.0", - "babel-jest": "^30.0.2", - "eslint": "^8.56.0", - "eslint-config-nevermined": "^0.3.0", - "eslint-config-next": "^15.1.5", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-tsdoc": "^0.2.17", - "jest": "^29.7.0", - "prettier": "^3.2.5", - "source-map-support": "^0.5.21", - "ts-jest": "^29.2.5", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "tslib": "^2.6.2", - "typedoc": "0.25.13", - "typescript": "^5.3.3" + "engines": { + "node": ">=18" + } + }, + "node_modules/@a2a-js/sdk/node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@a2a-js/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@a2a-js/sdk/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@a2a-js/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@a2a-js/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@a2a-js/sdk/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@a2a-js/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@a2a-js/sdk/node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@a2a-js/sdk/node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@a2a-js/sdk/node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@a2a-js/sdk/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@a2a-js/sdk/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" } }, "node_modules/@cfworker/json-schema": { @@ -613,14 +745,65 @@ } }, "node_modules/@nevermined-io/payments": { - "resolved": "../../payments", - "link": true + "version": "1.0.0-rc14", + "resolved": "https://registry.npmjs.org/@nevermined-io/payments/-/payments-1.0.0-rc14.tgz", + "integrity": "sha512-BB7zes4tc6RpHJ4R205/1fztMDuSUAeBmACQrj+U+qFFlnXo/LFCKZe1hygWLYp0itK630o67u6ptaYH9G1IzQ==", + "license": "Apache-2.0", + "dependencies": { + "@a2a-js/sdk": "^0.2.5", + "@helicone/helpers": "^1.6.0", + "axios": "^1.7.7", + "express": "4.21.2", + "jose": "^5.2.4", + "js-file-download": "^0.4.12", + "uuid": "^10.0.0", + "zod": "^4.0.17" + } + }, + "node_modules/@nevermined-io/payments/node_modules/@helicone/helpers": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@helicone/helpers/-/helpers-1.7.1.tgz", + "integrity": "sha512-vXZ7vGs9WLl/PUYLcDFOv09jZjClsPbhnCqjqLX120GseOXnf1h5fo1BvCwGg/2A5Ty3XiwO9iFwtXeZfbUbzw==", + "license": "Apache-2.0", + "peerDependencies": { + "openai": "^5.10.2" + } + }, + "node_modules/@nevermined-io/payments/node_modules/openai": { + "version": "5.20.3", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.20.3.tgz", + "integrity": "sha512-8V0KgAcPFppDIP8uMBOkhRrhDBuxNQYQxb9IovP4NN4VyaYGISAzYexyYYuAwVul2HB75Wpib0xDboYJqRMNow==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@nevermined-io/payments/node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -631,7 +814,15 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "license": "MIT", "dependencies": { "@types/node": "*" @@ -641,7 +832,6 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -654,7 +844,6 @@ "version": "4.19.6", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -667,14 +856,12 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -700,14 +887,12 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/retry": { @@ -720,7 +905,6 @@ "version": "0.17.5", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -731,7 +915,6 @@ "version": "1.15.8", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -817,8 +1000,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -1051,6 +1232,19 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1330,8 +1524,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=4.0" }, @@ -1581,6 +1773,21 @@ "node": ">= 0.10" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-file-download": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz", + "integrity": "sha512-rML+NkoD08p5Dllpjo0ffy4jRHeY6Zsapvr/W86N7E0yuzAO6qa5X9+xog6zQNlH102J7IXljNY2FtS6Lj3ucg==", + "license": "MIT" + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -2100,6 +2307,15 @@ } } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2257,9 +2473,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/qs": { "version": "6.13.0",