mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-05 14:05:30 +02:00
feat: enhance AI chat functionality and API integration
- Updated the AI chat panel to support multiple service types, including applications, databases, and more, improving context handling. - Implemented local storage for chat messages, allowing users to retain their chat history. - Enhanced API integration by adding new endpoints for reading deployment build logs and creating tools from OpenAPI specifications. - Improved error handling and user experience in the chat interface, ensuring smoother interactions. These changes significantly enhance the AI chat capabilities and overall user experience within the Dokploy platform.
This commit is contained in:
398
packages/server/src/utils/ai/api-tool.ts
Normal file
398
packages/server/src/utils/ai/api-tool.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { dynamicTool } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { ChatContext } from "./chat-tools";
|
||||
|
||||
interface OpenApiSpec {
|
||||
paths: Record<
|
||||
string,
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
operationId?: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
deprecated?: boolean;
|
||||
parameters?: {
|
||||
name: string;
|
||||
in: string;
|
||||
required?: boolean;
|
||||
schema?: { type?: string };
|
||||
}[];
|
||||
requestBody?: {
|
||||
content: Record<
|
||||
string,
|
||||
{ schema: { properties?: Record<string, any>; required?: string[] } }
|
||||
>;
|
||||
};
|
||||
}
|
||||
>
|
||||
>;
|
||||
}
|
||||
|
||||
interface ToolConfig {
|
||||
baseUrl: string;
|
||||
cookie: string;
|
||||
}
|
||||
|
||||
interface EndpointInfo {
|
||||
method: string;
|
||||
path: string;
|
||||
operationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compact one-line-per-endpoint catalog for the system prompt.
|
||||
* Example line: "application-deploy (POST, applicationId*, title?, description?) — Deploy an application"
|
||||
*/
|
||||
const EXCLUDED_TAGS = new Set([
|
||||
"notification",
|
||||
"sso",
|
||||
"stripe",
|
||||
"auditLog",
|
||||
"ai",
|
||||
"customRole",
|
||||
"whitelabeling",
|
||||
]);
|
||||
|
||||
/** Minimal shared tags — only project/environment for navigation */
|
||||
const SHARED_TAGS = ["project", "environment"];
|
||||
|
||||
/** Tags allowed per context type (on top of SHARED_TAGS) */
|
||||
const CONTEXT_TAGS: Record<ChatContext["type"], string[]> = {
|
||||
application: [
|
||||
"application",
|
||||
"deployment",
|
||||
"domain",
|
||||
"docker",
|
||||
"mounts",
|
||||
"port",
|
||||
"security",
|
||||
"redirects",
|
||||
"registry",
|
||||
"sshKey",
|
||||
"backup",
|
||||
"volumeBackups",
|
||||
"rollback",
|
||||
"schedule",
|
||||
"patch",
|
||||
"previewDeployment",
|
||||
"bitbucket",
|
||||
"gitea",
|
||||
"github",
|
||||
"gitlab",
|
||||
"gitProvider",
|
||||
"destination",
|
||||
"tag",
|
||||
],
|
||||
compose: [
|
||||
"compose",
|
||||
"deployment",
|
||||
"domain",
|
||||
"docker",
|
||||
"backup",
|
||||
"patch",
|
||||
"sshKey",
|
||||
"bitbucket",
|
||||
"gitea",
|
||||
"github",
|
||||
"gitlab",
|
||||
"gitProvider",
|
||||
"tag",
|
||||
],
|
||||
postgres: ["postgres", "backup", "docker", "destination"],
|
||||
mysql: ["mysql", "backup", "docker", "destination"],
|
||||
redis: ["redis", "docker"],
|
||||
mongo: ["mongo", "backup", "docker", "destination"],
|
||||
mariadb: ["mariadb", "backup", "docker", "destination"],
|
||||
libsql: ["libsql", "docker"],
|
||||
project: [
|
||||
"application",
|
||||
"compose",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"redis",
|
||||
"mongo",
|
||||
"mariadb",
|
||||
"libsql",
|
||||
"domain",
|
||||
"deployment",
|
||||
"docker",
|
||||
"tag",
|
||||
],
|
||||
server: [
|
||||
"server",
|
||||
"docker",
|
||||
"cluster",
|
||||
"swarm",
|
||||
"certificates",
|
||||
"registry",
|
||||
"settings",
|
||||
],
|
||||
general: [], // empty = allow all non-excluded tags
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the set of allowed tags for a given context type.
|
||||
* Returns null for "general" context (no filtering, allow all).
|
||||
*/
|
||||
function getAllowedTags(contextType: ChatContext["type"]): Set<string> | null {
|
||||
if (contextType === "general") return null;
|
||||
const contextSpecific = CONTEXT_TAGS[contextType];
|
||||
return new Set([...SHARED_TAGS, ...contextSpecific]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract enum values from a JSON Schema property (handles anyOf wrappers).
|
||||
*/
|
||||
function extractEnum(prop: any): string[] | null {
|
||||
if (prop?.enum) return prop.enum;
|
||||
if (Array.isArray(prop?.anyOf)) {
|
||||
for (const variant of prop.anyOf) {
|
||||
if (variant?.enum) return variant.enum;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Human-readable description for each tag group in the catalog */
|
||||
const TAG_DESCRIPTIONS: Record<string, string> = {
|
||||
application: "Manage application services — create, update, deploy, start, stop, and configure applications",
|
||||
compose: "Manage Docker Compose/Stack services — create, update, deploy, and configure compose files",
|
||||
postgres: "Manage PostgreSQL database services",
|
||||
mysql: "Manage MySQL database services",
|
||||
redis: "Manage Redis database services",
|
||||
mongo: "Manage MongoDB database services",
|
||||
mariadb: "Manage MariaDB database services",
|
||||
libsql: "Manage LibSQL database services",
|
||||
deployment: "View deployment history, build logs, and manage deployment lifecycle",
|
||||
domain: "Manage domains, SSL certificates, and routing for services",
|
||||
docker: "Interact with Docker containers — inspect, restart, remove, and view logs",
|
||||
backup: "Create and manage database backups, run manual backups, and restore from backups",
|
||||
patch: "Browse and modify source code files in a service's cloned repository — read directories, read files, and create file patches",
|
||||
mounts: "Manage persistent volume mounts for services",
|
||||
port: "Manage exposed port mappings for services",
|
||||
security: "Manage HTTP basic auth security rules for services",
|
||||
redirects: "Manage HTTP redirect rules for domains",
|
||||
registry: "Manage Docker registries for pulling private images",
|
||||
sshKey: "Manage SSH keys for Git repository access",
|
||||
rollback: "Rollback a service to a previous deployment",
|
||||
schedule: "Create and manage scheduled tasks (cron jobs) for services",
|
||||
previewDeployment: "Manage preview deployments for pull requests",
|
||||
volumeBackups: "Create and manage volume-level backups and restores",
|
||||
project: "Manage projects — create, update, delete, and list projects",
|
||||
environment: "Manage environments within projects — create, duplicate, and configure",
|
||||
server: "Manage servers — configure, monitor, and connect remote servers",
|
||||
settings: "View and update global Dokploy settings",
|
||||
destination: "Manage S3/storage destinations for backups",
|
||||
tag: "Manage tags for organizing and labeling services",
|
||||
cluster: "Manage Docker Swarm cluster nodes",
|
||||
swarm: "Manage Docker Swarm settings and configuration",
|
||||
certificates: "Manage SSL/TLS certificates",
|
||||
gitProvider: "Manage Git provider integrations",
|
||||
github: "Manage GitHub provider connections and repositories",
|
||||
gitlab: "Manage GitLab provider connections and repositories",
|
||||
bitbucket: "Manage Bitbucket provider connections and repositories",
|
||||
gitea: "Manage Gitea provider connections and repositories",
|
||||
user: "Manage user accounts and permissions",
|
||||
};
|
||||
|
||||
export interface CatalogResult {
|
||||
catalog: string;
|
||||
count: number;
|
||||
operationIds: Set<string>;
|
||||
}
|
||||
|
||||
export function buildEndpointCatalog(
|
||||
spec: OpenApiSpec,
|
||||
contextType: ChatContext["type"] = "general",
|
||||
): CatalogResult {
|
||||
const operationIds = new Set<string>();
|
||||
const allowedTags = getAllowedTags(contextType);
|
||||
const groups = new Map<string, string[]>();
|
||||
|
||||
for (const methods of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(methods)) {
|
||||
if (!op.operationId || op.deprecated) continue;
|
||||
if (op.tags?.some((t) => EXCLUDED_TAGS.has(t))) continue;
|
||||
if (allowedTags && !op.tags?.some((t) => allowedTags.has(t))) continue;
|
||||
|
||||
operationIds.add(op.operationId);
|
||||
|
||||
const requiredParams: string[] = [];
|
||||
const optionalParams: string[] = [];
|
||||
|
||||
if (op.parameters) {
|
||||
for (const p of op.parameters) {
|
||||
if (p.in === "header") continue;
|
||||
if (p.required) {
|
||||
requiredParams.push(`${p.name}*`);
|
||||
} else {
|
||||
optionalParams.push(`${p.name}?`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (op.requestBody?.content?.["application/json"]?.schema) {
|
||||
const schema = op.requestBody.content["application/json"].schema;
|
||||
const requiredSet = new Set(schema.required ?? []);
|
||||
if (schema.properties) {
|
||||
for (const [key, prop] of Object.entries(
|
||||
schema.properties as Record<string, any>,
|
||||
)) {
|
||||
const enumVals = extractEnum(prop);
|
||||
const suffix = enumVals
|
||||
? `[${enumVals.join("|")}]`
|
||||
: "";
|
||||
if (requiredSet.has(key)) {
|
||||
requiredParams.push(`${key}*${suffix}`);
|
||||
} else {
|
||||
optionalParams.push(`${key}?${suffix}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allParams = [...requiredParams, ...optionalParams];
|
||||
const paramStr =
|
||||
allParams.length > 0 ? `(${allParams.join(", ")})` : "";
|
||||
const summary = op.summary ? ` — ${op.summary}` : "";
|
||||
const desc = op.description ? `\n ${op.description}` : "";
|
||||
const line = `${op.operationId}${paramStr}${summary}${desc}`;
|
||||
|
||||
const tag = op.tags?.[0] ?? "other";
|
||||
if (!groups.has(tag)) groups.set(tag, []);
|
||||
groups.get(tag)!.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Order sections: context-specific tags first (in CONTEXT_TAGS order), then shared, then rest
|
||||
const contextOrder = CONTEXT_TAGS[contextType];
|
||||
const sharedOrder = SHARED_TAGS;
|
||||
const orderedTags: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const t of contextOrder) {
|
||||
if (groups.has(t) && !seen.has(t)) { orderedTags.push(t); seen.add(t); }
|
||||
}
|
||||
for (const t of sharedOrder) {
|
||||
if (groups.has(t) && !seen.has(t)) { orderedTags.push(t); seen.add(t); }
|
||||
}
|
||||
for (const t of groups.keys()) {
|
||||
if (!seen.has(t)) { orderedTags.push(t); seen.add(t); }
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
for (const tag of orderedTags) {
|
||||
const lines = groups.get(tag)!;
|
||||
const tagDesc = TAG_DESCRIPTIONS[tag];
|
||||
const header = tagDesc ? `## ${tag} — ${tagDesc}` : `## ${tag}`;
|
||||
sections.push(`${header}\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
return {
|
||||
catalog: sections.join("\n\n"),
|
||||
count: operationIds.size,
|
||||
operationIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lookup map from operationId to endpoint info for execution.
|
||||
*/
|
||||
function buildEndpointMap(
|
||||
spec: OpenApiSpec,
|
||||
): Map<string, EndpointInfo> {
|
||||
const map = new Map<string, EndpointInfo>();
|
||||
for (const [path, methods] of Object.entries(spec.paths)) {
|
||||
for (const [method, op] of Object.entries(methods)) {
|
||||
if (!op.operationId) continue;
|
||||
map.set(op.operationId, { method, path, operationId: op.operationId });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single "call_api" tool that only allows endpoints present in allowedOperationIds.
|
||||
*/
|
||||
export function createApiTool(
|
||||
spec: OpenApiSpec,
|
||||
config: ToolConfig,
|
||||
allowedOperationIds?: Set<string>,
|
||||
maxResponseSize = 4000,
|
||||
) {
|
||||
const endpointMap = buildEndpointMap(spec);
|
||||
|
||||
return {
|
||||
call_api: dynamicTool({
|
||||
description:
|
||||
"Call a Dokploy API endpoint. Use the operationId from the endpoint catalog and pass the required parameters.",
|
||||
inputSchema: z.object({
|
||||
operationId: z
|
||||
.string()
|
||||
.describe("The operationId from the endpoint catalog"),
|
||||
params: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.describe("Parameters for the endpoint (* = required)"),
|
||||
}),
|
||||
execute: async (rawInput: unknown) => {
|
||||
const { operationId, params } = rawInput as {
|
||||
operationId: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (allowedOperationIds && !allowedOperationIds.has(operationId)) {
|
||||
return `Error: "${operationId}" is not available in the current context. Only use operationIds from the ENDPOINT CATALOG.`;
|
||||
}
|
||||
|
||||
const endpoint = endpointMap.get(operationId);
|
||||
if (!endpoint) {
|
||||
return `Error: Unknown endpoint "${operationId}". Check the endpoint catalog for valid operationIds.`;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: config.cookie,
|
||||
};
|
||||
|
||||
let url = `${config.baseUrl}${endpoint.path}`;
|
||||
|
||||
if (endpoint.method === "get" && params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
const qs = searchParams.toString();
|
||||
if (qs) url += `?${qs}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: endpoint.method.toUpperCase(),
|
||||
headers,
|
||||
...(endpoint.method !== "get" && params
|
||||
? { body: JSON.stringify(params) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return `API error (${response.status}): ${errorText.slice(0, 500)}`;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(await response.json(), null, 2);
|
||||
if (json.length > maxResponseSize) {
|
||||
return `${json.slice(0, maxResponseSize)}\n\n... [Truncated — ${json.length} chars total]`;
|
||||
}
|
||||
return json;
|
||||
} catch (error) {
|
||||
return `Error: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,22 @@ import { dynamicTool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { findDeploymentById } from "../../services/deployment";
|
||||
|
||||
export type ServiceType =
|
||||
| "application"
|
||||
| "compose"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "mongo"
|
||||
| "mariadb"
|
||||
| "libsql";
|
||||
|
||||
export interface ChatContext {
|
||||
type: "application" | "compose" | "project" | "server" | "general";
|
||||
type: ServiceType | "project" | "server" | "general";
|
||||
id: string;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
interface ToolConfig {
|
||||
|
||||
317
packages/server/src/utils/ai/openapi-tools.ts
Normal file
317
packages/server/src/utils/ai/openapi-tools.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { dynamicTool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Converts an OpenAPI spec into AI SDK tool definitions automatically.
|
||||
*
|
||||
* Each endpoint becomes a tool that the agent can call. The tool name
|
||||
* is the operationId, the description comes from the endpoint's
|
||||
* summary/description, and the input schema is derived from the
|
||||
* request body or query parameters.
|
||||
*/
|
||||
|
||||
interface OpenApiSpec {
|
||||
paths: Record<string, Record<string, OpenApiOperation>>;
|
||||
}
|
||||
|
||||
interface OpenApiOperation {
|
||||
operationId: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
deprecated?: boolean;
|
||||
parameters?: OpenApiParameter[];
|
||||
requestBody?: {
|
||||
required?: boolean;
|
||||
content: Record<
|
||||
string,
|
||||
{
|
||||
schema: JsonSchema;
|
||||
}
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenApiParameter {
|
||||
name: string;
|
||||
in: "query" | "path" | "header";
|
||||
required?: boolean;
|
||||
schema: JsonSchema;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface JsonSchema {
|
||||
type?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
items?: JsonSchema;
|
||||
enum?: unknown[];
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
nullable?: boolean;
|
||||
anyOf?: JsonSchema[];
|
||||
oneOf?: JsonSchema[];
|
||||
allOf?: JsonSchema[];
|
||||
format?: string;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
interface ToolConfig {
|
||||
baseUrl: string;
|
||||
cookie: string;
|
||||
}
|
||||
|
||||
interface GenerateToolsOptions {
|
||||
/** Only include tools whose tag matches one of these */
|
||||
tags?: string[];
|
||||
/** Only include these specific operationIds */
|
||||
operationIds?: string[];
|
||||
/** Exclude these operationIds */
|
||||
exclude?: string[];
|
||||
/** Max response size in chars before truncating (default: 15000) */
|
||||
maxResponseSize?: number;
|
||||
}
|
||||
|
||||
// ─── JSON Schema → Zod conversion ──────────────────────────────
|
||||
|
||||
function jsonSchemaToZod(schema: JsonSchema): z.ZodTypeAny {
|
||||
if (!schema || !schema.type) {
|
||||
// anyOf / oneOf / allOf — just accept anything
|
||||
if (schema?.anyOf || schema?.oneOf || schema?.allOf) {
|
||||
return z.any();
|
||||
}
|
||||
return z.any();
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "string": {
|
||||
let s = z.string();
|
||||
if (schema.enum) {
|
||||
return z.enum(schema.enum as [string, ...string[]]);
|
||||
}
|
||||
if (schema.minLength) s = s.min(schema.minLength);
|
||||
if (schema.maxLength) s = s.max(schema.maxLength);
|
||||
if (schema.description) s = s.describe(schema.description);
|
||||
return s;
|
||||
}
|
||||
case "number":
|
||||
case "integer": {
|
||||
let n = z.number();
|
||||
if (schema.minimum !== undefined) n = n.min(schema.minimum);
|
||||
if (schema.maximum !== undefined) n = n.max(schema.maximum);
|
||||
if (schema.description) n = n.describe(schema.description);
|
||||
return n;
|
||||
}
|
||||
case "boolean":
|
||||
return z.boolean();
|
||||
case "array": {
|
||||
const itemSchema = schema.items
|
||||
? jsonSchemaToZod(schema.items)
|
||||
: z.any();
|
||||
return z.array(itemSchema);
|
||||
}
|
||||
case "object": {
|
||||
if (!schema.properties) {
|
||||
return z.object({});
|
||||
}
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
const required = new Set(schema.required ?? []);
|
||||
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
||||
const zodProp = jsonSchemaToZod(propSchema);
|
||||
shape[key] = required.has(key) ? zodProp : zodProp.optional();
|
||||
}
|
||||
return z.object(shape);
|
||||
}
|
||||
default:
|
||||
return z.any();
|
||||
}
|
||||
}
|
||||
|
||||
function buildInputSchema(
|
||||
operation: OpenApiOperation,
|
||||
): z.ZodObject<z.ZodRawShape> {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
|
||||
// Query/path parameters → flat keys
|
||||
if (operation.parameters) {
|
||||
for (const param of operation.parameters) {
|
||||
if (param.in === "header") continue;
|
||||
const zodParam = jsonSchemaToZod(param.schema);
|
||||
const described = param.description
|
||||
? zodParam.describe(param.description)
|
||||
: zodParam;
|
||||
shape[param.name] = param.required ? described : described.optional();
|
||||
}
|
||||
}
|
||||
|
||||
// Request body → merge properties into the same object
|
||||
if (operation.requestBody) {
|
||||
const content = operation.requestBody.content;
|
||||
const jsonContent = content["application/json"];
|
||||
if (jsonContent?.schema) {
|
||||
const bodySchema = jsonContent.schema;
|
||||
if (bodySchema.properties) {
|
||||
const required = new Set(bodySchema.required ?? []);
|
||||
for (const [key, propSchema] of Object.entries(
|
||||
bodySchema.properties,
|
||||
)) {
|
||||
const zodProp = jsonSchemaToZod(propSchema);
|
||||
shape[key] = required.has(key) ? zodProp : zodProp.optional();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return z.object(shape);
|
||||
}
|
||||
|
||||
// ─── API caller ─────────────────────────────────────────────────
|
||||
|
||||
async function callApi(
|
||||
config: ToolConfig,
|
||||
method: string,
|
||||
path: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
maxResponseSize: number,
|
||||
): Promise<string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: config.cookie,
|
||||
};
|
||||
|
||||
let url = `${config.baseUrl}${path}`;
|
||||
|
||||
if (method === "get" && params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
const qs = searchParams.toString();
|
||||
if (qs) url += `?${qs}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
headers,
|
||||
...(method !== "get" && params
|
||||
? { body: JSON.stringify(params) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`API error (${response.status}): ${errorText.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = JSON.stringify(await response.json(), null, 2);
|
||||
if (json.length > maxResponseSize) {
|
||||
return `${json.slice(0, maxResponseSize)}\n\n... [Truncated — ${json.length} chars total]`;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
// ─── Main conversion ────────────────────────────────────────────
|
||||
|
||||
export function openApiToTools(
|
||||
spec: OpenApiSpec,
|
||||
config: ToolConfig,
|
||||
options: GenerateToolsOptions = {},
|
||||
) {
|
||||
const {
|
||||
tags,
|
||||
operationIds,
|
||||
exclude,
|
||||
maxResponseSize = 15000,
|
||||
} = options;
|
||||
|
||||
const tagSet = tags ? new Set(tags) : null;
|
||||
const idSet = operationIds ? new Set(operationIds) : null;
|
||||
const excludeSet = exclude ? new Set(exclude) : null;
|
||||
const tools: Record<string, ReturnType<typeof dynamicTool>> = {};
|
||||
|
||||
for (const [path, methods] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(methods)) {
|
||||
const opId = operation.operationId;
|
||||
if (!opId) continue;
|
||||
if (operation.deprecated) continue;
|
||||
|
||||
// Filtering
|
||||
if (excludeSet?.has(opId)) continue;
|
||||
if (idSet && !idSet.has(opId)) continue;
|
||||
if (tagSet && !operation.tags?.some((t) => tagSet.has(t))) continue;
|
||||
|
||||
const description = [operation.summary, operation.description]
|
||||
.filter(Boolean)
|
||||
.join(". ");
|
||||
|
||||
const inputSchema = buildInputSchema(operation);
|
||||
|
||||
const isWriteAction = method !== "get";
|
||||
|
||||
tools[opId] = dynamicTool({
|
||||
description: description || `Call ${method.toUpperCase()} ${path}`,
|
||||
inputSchema,
|
||||
needsApproval: isWriteAction,
|
||||
execute: async (rawInput: unknown) => {
|
||||
const input = (rawInput ?? {}) as Record<string, unknown>;
|
||||
const params =
|
||||
Object.keys(input).length > 0 ? input : undefined;
|
||||
return callApi(config, method, path, params, maxResponseSize);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a summary of all available tools (name + description).
|
||||
* Useful for debugging or for the system prompt.
|
||||
*/
|
||||
export function getToolsSummary(
|
||||
spec: OpenApiSpec,
|
||||
options: GenerateToolsOptions = {},
|
||||
): { name: string; description: string; tag: string; method: string }[] {
|
||||
const { tags, operationIds, exclude } = options;
|
||||
|
||||
const tagSet = tags ? new Set(tags) : null;
|
||||
const idSet = operationIds ? new Set(operationIds) : null;
|
||||
const excludeSet = exclude ? new Set(exclude) : null;
|
||||
const summary: {
|
||||
name: string;
|
||||
description: string;
|
||||
tag: string;
|
||||
method: string;
|
||||
}[] = [];
|
||||
|
||||
for (const [_path, methods] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(methods)) {
|
||||
const opId = operation.operationId;
|
||||
if (!opId) continue;
|
||||
if (operation.deprecated) continue;
|
||||
if (excludeSet?.has(opId)) continue;
|
||||
if (idSet && !idSet.has(opId)) continue;
|
||||
if (tagSet && !operation.tags?.some((t) => tagSet.has(t))) continue;
|
||||
|
||||
summary.push({
|
||||
name: opId,
|
||||
description:
|
||||
[operation.summary, operation.description]
|
||||
.filter(Boolean)
|
||||
.join(". ") || "",
|
||||
tag: operation.tags?.[0] ?? "default",
|
||||
method: method.toUpperCase(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
Reference in New Issue
Block a user