askexperts
Version:
AskExperts SDK: build and use AI experts - ask them questions and pay with bitcoin on an open protocol
278 lines • 10.8 kB
JavaScript
import express from "express";
import cors from "cors";
import { z } from "zod";
import { LightningPaymentManager } from "../payments/LightningPaymentManager.js";
import { debugClient, debugError } from "../common/debug.js";
import { OpenaiAskExperts } from "../openai/OpenaiAskExperts.js";
import { SimplePool } from "nostr-tools";
/**
* OpenAI Chat Completions API request schema
*/
const OpenAIChatCompletionsRequestSchema = z.object({
model: z
.string()
.min(1)
.describe("Expert's pubkey, optionally followed by ?max_amount_sats=N to limit payment amount"),
messages: z
.array(z.object({
role: z.string(),
content: z.string(),
name: z.string().optional(),
}))
.min(1),
temperature: z.number().optional(),
top_p: z.number().optional(),
n: z.number().optional(),
stream: z.boolean().optional(),
max_tokens: z.number().optional(),
presence_penalty: z.number().optional(),
frequency_penalty: z.number().optional(),
logit_bias: z.record(z.string(), z.number()).optional(),
user: z.string().optional(),
});
/**
* OpenAI Chat Completions API response schema
*/
const OpenAIChatCompletionsResponseSchema = z.object({
id: z.string(),
object: z.string(),
created: z.number(),
model: z.string(),
choices: z.array(z.object({
index: z.number(),
message: z.object({
role: z.string(),
content: z.string(),
}),
finish_reason: z.string(),
})),
usage: z.object({
prompt_tokens: z.number(),
completion_tokens: z.number(),
total_tokens: z.number(),
}),
});
/**
* OpenaiProxy class that provides an OpenAI-compatible API for NIP-174
*/
export class OpenaiProxy {
/**
* Creates a new OpenAIProxy instance
*
* @param port - Port number to listen on
* @param basePath - Base path for the API (e.g., '/v1')
* @param discoveryRelays - Optional array of discovery relay URLs
*/
constructor(port, basePath, discoveryRelays) {
this.stopped = true;
this.pool = new SimplePool();
this.port = port;
this.basePath = basePath
? basePath.startsWith("/")
? basePath
: `/${basePath}`
: "/";
this.discoveryRelays = discoveryRelays;
// Create the Express app
this.app = express();
// Configure middleware
this.app.use(cors());
this.app.use(express.json({ limit: '1mb' }));
// Set up routes
this.setupRoutes();
}
/**
* Sets up the API routes
* @private
*/
setupRoutes() {
// Health check endpoint
this.app.get(`${this.basePath}health`, (req, res) => {
if (this.stopped)
res.status(503).json({ error: "Service unavailable" });
else
res.status(200).json({ status: "ok" });
});
// OpenAI Chat Completions API endpoint
this.app.post(`${this.basePath}chat/completions`, this.handleChatCompletions.bind(this));
}
/**
* Handles requests to the chat completions endpoint
*
* @param req - Express request object
* @param res - Express response object
* @private
*/
async handleChatCompletions(req, res) {
if (this.stopped) {
res.status(503).json({ error: "Service unavailable" });
return;
}
try {
// Extract the Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res
.status(401)
.json({ error: "Missing or invalid Authorization header" });
return;
}
// Extract the NWC string
const nwcString = authHeader.substring(7); // Remove 'Bearer ' prefix
// Validate the request body
const parseResult = OpenAIChatCompletionsRequestSchema.safeParse(req.body);
if (!parseResult.success) {
const errorMessage = parseResult.error.errors
.map((err) => `${err.path.join(".")}: ${err.message}`)
.join(", ");
res.status(400).json({
error: "Invalid request format",
details: errorMessage,
});
return;
}
const requestBody = parseResult.data;
// Extract the expert pubkey and query parameters from the model field
let expertPubkey = requestBody.model;
let maxAmountSats;
// Check if the model field contains query parameters
const queryParamIndex = expertPubkey.indexOf("?");
if (queryParamIndex !== -1) {
const queryString = expertPubkey.substring(queryParamIndex + 1);
expertPubkey = expertPubkey.substring(0, queryParamIndex);
// Parse query parameters
const params = new URLSearchParams(queryString);
const maxAmountParam = params.get("max_amount_sats");
if (maxAmountParam) {
maxAmountSats = parseInt(maxAmountParam, 10);
if (isNaN(maxAmountSats)) {
maxAmountSats = undefined;
}
}
}
// Create a LightningPaymentManager for this request
const paymentManager = new LightningPaymentManager(nwcString);
// Create the client
const client = new OpenaiAskExperts(paymentManager, {
pool: this.pool,
discoveryRelays: this.discoveryRelays,
});
try {
const { quoteId, amountSats } = await client.getQuote(expertPubkey, requestBody);
if (maxAmountSats && amountSats > maxAmountSats) {
res.status(402).json({
error: "Payment failed",
message: "Quoted price too high",
});
return;
}
const reply = await client.execute(quoteId);
// Check if streaming is requested
if (!requestBody.stream) {
// Send the response as JSON
res.status(200).json(reply);
}
else {
// Handle streaming response
// Set appropriate headers for streaming
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Transfer-Encoding', 'chunked');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Cache-Control', 'no-cache');
// Send each chunk as it arrives
try {
// reply is an AsyncIterable<ChatCompletionChunk> when streaming is true
const streamingReply = reply;
for await (const chunk of streamingReply) {
if (!chunk)
continue;
// Each chunk needs to be formatted according to SSE protocol
// Format: data: {JSON_DATA}\n\n
const chunkStr = `data: ${JSON.stringify(chunk)}\n\n`;
// Write the chunk to the response
res.write(chunkStr);
// Ensure the chunk is sent immediately
// The flush method might not be available on all Response objects
// Use as any to bypass TypeScript error
if (res.flush) {
res.flush();
}
}
// End the stream with a "data: [DONE]" message
res.write('data: [DONE]\n\n');
res.end();
}
catch (error) {
debugError("Error streaming response:", error);
// If we encounter an error during streaming, try to end the response
// if it hasn't been ended already
try {
res.end();
}
catch (e) {
// Ignore errors when ending an already-ended response
}
}
}
}
finally {
// Clean up resources
paymentManager[Symbol.dispose]();
client[Symbol.dispose]();
}
}
catch (error) {
debugError("Error handling chat completions request:", error);
const message = error instanceof Error ? error.message : String(error);
res.status(500).json({
error: message,
message: message,
});
}
}
/**
* Starts the proxy server
*
* @returns Promise that resolves when the server is started
*/
async start() {
if (this.server)
throw new Error("Already started");
this.stopped = false;
this.server = this.app.listen(this.port);
debugClient(`OpenAI Proxy server running at http://localhost:${this.port}${this.basePath}`);
}
/**
* Stops the proxy server
*
* @returns Promise that resolves when the server is stopped
*/
stop() {
return new Promise(async (resolve) => {
// Mark as stopped
this.stopped = false;
if (!this.server) {
resolve();
return;
}
debugError("Server stopping...");
// Stop accepting new connections
const closePromise = new Promise((ok) => this.server.close(ok));
// FIXME: if clients have active requests, we
// risk losing their money by cutting them off,
// ideally we would stop accepting new requests,
// and wait for a while until existing requests are over,
// and start terminating only after that.
// Wait until all connections are closed with timeout
let to;
await Promise.race([
closePromise,
new Promise((ok) => to = setTimeout(ok, 5000)),
]);
clearTimeout(to);
debugError("Server stopped");
resolve();
});
}
}
//# sourceMappingURL=OpenaiProxy.js.map