UNPKG

genaiscript

Version:

A CLI for GenAIScript, a generative AI scripting framework.

417 lines 18.3 kB
// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import { CORE_VERSION, OPENAPI_SERVER_PORT, deleteUndefinedValues, ensureDotGenaiscriptPath, ensureHeadSlash, errorMessage, genaiscriptDebug, logError, logVerbose, logWarn, nodeTryReadPackage, toStrictJSONSchema, trimTrailingSlash, } from "@genaiscript/core"; import { run } from "@genaiscript/api"; import { applyRemoteOptions } from "./remote.js"; import { findOpenPort } from "./port.js"; import { startProjectWatcher } from "./watch.js"; import { uniq } from "es-toolkit"; const dbg = genaiscriptDebug("openapi"); const dbgError = dbg.extend("error"); const dbgHandlers = dbg.extend("handlers"); export async function startOpenAPIServer(options) { logVerbose(`web api server: starting...`); await ensureDotGenaiscriptPath(); await applyRemoteOptions(options); const { startup, cors, network, ...runOptions } = options || {}; const serverHost = network ? "0.0.0.0" : "127.0.0.1"; const route = ensureHeadSlash(trimTrailingSlash(options?.route || "/api")); const docsRoute = `${route}/docs`; dbg(`route: %s`, route); dbg(`server host: %s`, serverHost); dbg(`run options: %O`, runOptions); const port = await findOpenPort(OPENAPI_SERVER_PORT, options); const watcher = await startProjectWatcher(options); logVerbose(`openapi server: watching ${watcher.cwd}`); const createFastify = (await import("fastify")).default; const swagger = (await import("@fastify/swagger")).default; const swaggerUi = (await import("@fastify/swagger-ui")).default; const swaggerCors = cors ? (await import("@fastify/cors")).default : undefined; let fastifyController; let fastify; const stopServer = async () => { const s = fastifyController; const f = fastify; fastifyController = undefined; fastify = undefined; if (s) { try { logVerbose(`stopping watcher...`); s.abort(); } catch (e) { dbg(e); } } if (f) { try { logVerbose(`stopping server...`); await f.close(); } catch (e) { dbg(e); } } }; const startServer = async () => { await stopServer(); logVerbose(`starting server...`); const tools = (await watcher.scripts()).sort((l, r) => l.id.localeCompare(r.id)); fastifyController = new AbortController(); fastify = createFastify({ logger: false }); if (cors) fastify.register(swaggerCors, { origin: cors, methods: ["GET", "POST"], allowedHeaders: ["Content-Type"], }); // infer server metadata from package.json const { name, description = "GenAIScript OpenAPI Server", version = "0.0.0", author, license, homepage, displayName, } = (await nodeTryReadPackage()) || {}; const operationPrefix = ""; // Register the OpenAPI documentation plugin (Swagger for OpenAPI 3.x) await fastify.register(swagger, { openapi: { openapi: "3.1.1", info: deleteUndefinedValues({ title: displayName || name, description, version, contact: author ? { name: author } : undefined, license: license ? { name: license, } : undefined, }), externalDocs: homepage ? { url: homepage, description: "Homepage", } : undefined, servers: [ { url: `http://127.0.0.1:${port}`, description: "GenAIScript server", }, { url: `http://localhost:${port}`, description: "GenAIScript server", }, { url: `http://${serverHost}:${port}`, description: "GenAIScript server", }, ], tags: uniq(["default", ...tools.map(({ group }) => group).filter(Boolean)]).map((name) => ({ name, })), }, }); // Dynamically create a POST route for each tool in the tools list const routes = new Set([docsRoute]); for (const tool of tools) { const { id, accept, inputSchema, title: summary, description, group } = tool; const scriptSchema = inputSchema?.properties.script || { type: "object", properties: {}, }; const bodySchema = { type: "object", properties: deleteUndefinedValues({ ...(scriptSchema?.properties || {}), // Model parameters that can override script defaults model: { type: "string", description: "Override the main model (e.g., 'github:openai/gpt-4', 'anthropic:claude-3-sonnet')", }, smallModel: { type: "string", description: "Override the small model alias", }, visionModel: { type: "string", description: "Override the vision model alias", }, embeddingsModel: { type: "string", description: "Override the embeddings model alias", }, provider: { type: "string", description: "Override the LLM provider (e.g., 'openai', 'anthropic', 'azure')", }, temperature: { type: "number", description: "Override model temperature (0-2)", minimum: 0, maximum: 2, }, reasoningEffort: { type: "string", enum: ["high", "medium", "low"], description: "Override reasoning effort for o* models", }, topP: { type: "number", description: "Override top-p sampling parameter (0-1)", minimum: 0, maximum: 1, }, maxTokens: { type: "number", description: "Override maximum tokens to generate", minimum: 1, }, maxToolCalls: { type: "number", description: "Override maximum tool calls allowed", minimum: 0, }, seed: { type: "number", description: "Override random seed for reproducible results", }, modelAlias: { type: "array", items: { type: "string", }, description: "Override model aliases as name=modelid pairs", }, toolChoice: { type: "string", description: "Override tool choice strategy", }, files: accept !== "none" ? { type: "array", items: { type: "object", properties: { filename: { type: "string", description: `Filename of the file. Accepts ${accept || "*"}.`, }, content: { type: "string", description: "Content of the file. Use 'base64' encoding for binary files.", }, encoding: { type: "string", description: "Encoding of the file. Binary files should use 'base64'.", enum: ["base64"], }, type: { type: "string", description: "MIME type of the file", }, }, required: ["filename", "content"], }, } : undefined, }), required: scriptSchema?.required || [], }; if (!description) logWarn(`${id}: operation must have a description`); if (!group) logWarn(`${id}: operation must have a group`); const operationId = `${operationPrefix}${id}`; // Query parameters schema - same model parameters as body const querySchema = { type: "object", properties: { model: { type: "string", description: "Override the main model (e.g., 'gpt-4', 'claude-3-sonnet')", }, smallModel: { type: "string", description: "Override the small model alias", }, visionModel: { type: "string", description: "Override the vision model alias", }, embeddingsModel: { type: "string", description: "Override the embeddings model alias", }, provider: { type: "string", description: "Override the LLM provider (e.g., 'openai', 'anthropic', 'azure')", }, temperature: { type: "number", description: "Override model temperature (0-2)", }, reasoningEffort: { type: "string", enum: ["high", "medium", "low"], description: "Override reasoning effort for o* models", }, topP: { type: "number", description: "Override top-p sampling parameter (0-1)", }, maxTokens: { type: "number", description: "Override maximum tokens to generate", }, maxToolCalls: { type: "number", description: "Override maximum tool calls allowed", }, seed: { type: "number", description: "Override random seed for reproducible results", }, }, }; const schema = deleteUndefinedValues({ operationId, summary, description, tags: [tool.group || "default"].filter(Boolean), querystring: toStrictJSONSchema(querySchema, { defaultOptional: true }), body: toStrictJSONSchema(bodySchema, { defaultOptional: true }), response: { 200: toStrictJSONSchema({ type: "object", properties: deleteUndefinedValues({ error: { type: "string", description: "Error message", }, text: { type: "string", description: "Output text", }, data: tool.responseSchema ? toStrictJSONSchema(tool.responseSchema, { defaultOptional: true, }) : undefined, uncertainty: { type: "number", description: "Uncertainty of the response, between 0 and 1", }, perplexity: { type: "number", description: "Perplexity of the response, lower is better", }, }), }, { defaultOptional: true }), }, 400: { type: "object", properties: { error: { type: "string", description: "Error message", }, }, }, 500: { type: "object", properties: { error: { type: "string", description: "Error message", }, }, }, }); const toolPath = id.replace(/[^a-z\-_]+/gi, "_").replace(/_+$/, ""); const url = `${route}/${toolPath}`; if (routes.has(url)) { logError(`duplicate route: ${url} for tool ${id}, skipping`); continue; } dbg(`script %s: %s\n%O`, id, url, schema); routes.add(url); const handler = async (request) => { const { files = [], ...bodyRest } = (request.body || {}); dbgHandlers(`query: %O`, request.query); dbgHandlers(`body: %O`, bodyRest); const allParams = { ...(request.query || {}), ...bodyRest }; dbgHandlers(`params: %O`, allParams); // Extract model parameters from HTTP request const { model, smallModel, visionModel, embeddingsModel, modelAlias, provider, temperature, reasoningEffort, topP, maxTokens, maxToolCalls, seed, toolChoice, ...vars } = allParams; const finalRunOptions = { ...runOptions, workspaceFiles: files || [], vars: vars, runTrace: false, outputTrace: false, // Pass model parameters as direct options ...(model && { model }), ...(smallModel && { smallModel }), ...(visionModel && { visionModel }), ...(embeddingsModel && { embeddingsModel }), ...(modelAlias && { modelAlias }), ...(provider && { provider }), ...(temperature && { temperature }), ...(reasoningEffort && { reasoningEffort }), ...(topP && { topP }), ...(maxTokens && { maxTokens }), ...(maxToolCalls && { maxToolCalls }), ...(seed && { seed }), ...(toolChoice && { toolChoice }), }; dbg(`options: %O`, finalRunOptions); const res = await run(tool.id, [], finalRunOptions); if (!res) throw new Error("Internal Server Error"); dbgHandlers(`res: %s`, res.status); if (res.error) { dbgHandlers(`error: %O`, res.error); throw new Error(errorMessage(res.error)); } return deleteUndefinedValues({ ...res, }); }; fastify.post(url, { schema }, async (request) => { dbgHandlers(`post %s %O`, tool.id, request.body); return await handler(request); }); } await fastify.register(swaggerUi, { routePrefix: docsRoute, }); // Global error handler for uncaught errors and validation issues fastify.setErrorHandler((error, request, reply) => { dbgError(`%s %s %O`, request.method, request.url, error); if (error.validation) { reply.status(400).send({ error: error.message, }); } else { reply.status(error.statusCode ?? 500).send({ error: `Internal Server Error - ${error.message ?? "An unexpected error occurred"}`, }); } }); console.log(`GenAIScript OpenAPI v${CORE_VERSION}`); console.log(`│ API http://localhost:${port}${route}/`); console.log(`| Console UI: http://localhost:${port}${route}/docs`); console.log(`| OpenAPI Spec: http://localhost:${port}${route}/docs/json`); await fastify.listen({ port, host: serverHost, signal: fastifyController.signal, }); }; if (startup) { logVerbose(`startup script: ${startup}`); await run(startup, [], {}); } // start watcher watcher.addEventListener("change", startServer); await startServer(); } //# sourceMappingURL=openapi.js.map