Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 31 additions & 31 deletions packages/bundler/src/client-api.test.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,33 @@
import { describe, expect, it } from "vitest";
import { buildClientToolMap } from "./client-api.js";
import type { Tool } from "@utcp/sdk";
import type { DiscoveredOperation } from "./openapi-discovery.js";
import type { OpenAPIV3 } from "openapi-types";


function createTool(name: string, httpMethod: string, url: string, contentType = "application/json"): Tool {
/**
* `path` must match the key used in `document.paths` for the test.
* For documents with a server base path (e.g. `/v1`), pass the OpenAPI path
* key WITHOUT the base (e.g. `"/responses"` not `"/v1/responses"`).
*/
function createOp(
name: string,
method: string,
path: string,
contentType = "application/json",
): DiscoveredOperation {
return {
name,
method: method.toUpperCase(),
path,
routeTemplate: path,
contentType,
description: name,
tags: [],
tool_call_template: {
call_template_type: "http",
http_method: httpMethod,
url,
content_type: contentType,
},
inputs: {
type: "object",
properties: {},
},
outputs: {
type: "object",
properties: {},
},
} as unknown as Tool;
};
}

describe("buildClientToolMap", () => {
it("renames duplicate nested method leaves to call", () => {
const tool = createTool("example.CreateLogsMetric/CreateLogsMetric", "POST", "https://api.example.com/v1/logs/metrics");
const tool = createOp("example.CreateLogsMetric/CreateLogsMetric", "POST", "/v1/logs/metrics");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "Example", version: "1.0.0" },
Expand Down Expand Up @@ -71,7 +70,7 @@ describe("buildClientToolMap", () => {
});

it("keeps body fields in input and moves conflicting transport fields to overrides", () => {
const tool = createTool("example.fooBar", "POST", "https://api.example.com/v1/{id}/example");
const tool = createOp("example.fooBar", "POST", "/v1/{id}/example");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "Example", version: "1.0.0" },
Expand Down Expand Up @@ -122,7 +121,8 @@ describe("buildClientToolMap", () => {
});

it("matches operations when the server url contributes a base path", () => {
const tool = createTool("openai.createResponse", "POST", "https://api.openai.com/v1/responses");
// path = "/responses" matches directly in document.paths (server adds /v1 at runtime)
const tool = createOp("openai.createResponse", "POST", "/responses");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "OpenAI", version: "1.0.0" },
Expand Down Expand Up @@ -171,7 +171,7 @@ describe("buildClientToolMap", () => {
});

it("strips the full provider prefix for dotted provider names", () => {
const tool = createTool("google.books.volumes/list", "GET", "https://www.googleapis.com/books/v1/volumes");
const tool = createOp("google.books.volumes/list", "GET", "/books/v1/volumes");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "Google Books", version: "1.0.0" },
Expand Down Expand Up @@ -201,7 +201,7 @@ describe("buildClientToolMap", () => {
});

it("keeps primitive request bodies as raw body input", () => {
const tool = createTool("spotify.uploadPlaylistCover", "PUT", "https://api.spotify.com/playlists/{playlist_id}/images", "image/jpeg");
const tool = createOp("spotify.uploadPlaylistCover", "PUT", "/playlists/{playlist_id}/images", "image/jpeg");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "Spotify", version: "1.0.0" },
Expand Down Expand Up @@ -237,7 +237,7 @@ describe("buildClientToolMap", () => {
});

it("promotes object bodies with additionalProperties to top-level input", () => {
const tool = createTool("spotify.createPlaylist", "POST", "https://api.spotify.com/users/{user_id}/playlists");
const tool = createOp("spotify.createPlaylist", "POST", "/users/{user_id}/playlists");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "Spotify", version: "1.0.0" },
Expand Down Expand Up @@ -282,7 +282,7 @@ describe("buildClientToolMap", () => {
});

it("populates description from operation summary", () => {
const tool = createTool("example.getFoo", "GET", "https://api.example.com/v1/foo");
const tool = createOp("example.getFoo", "GET", "/v1/foo");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "Example", version: "1.0.0" },
Expand All @@ -304,7 +304,7 @@ describe("buildClientToolMap", () => {
});

it("falls back to operation description when summary is absent", () => {
const tool = createTool("example.getFoo", "GET", "https://api.example.com/v1/foo");
const tool = createOp("example.getFoo", "GET", "/v1/foo");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "Example", version: "1.0.0" },
Expand All @@ -326,7 +326,7 @@ describe("buildClientToolMap", () => {
});

it("falls back to tool description when operation has neither summary nor description", () => {
const tool = createTool("example.getFoo", "GET", "https://api.example.com/v1/foo");
const tool = createOp("example.getFoo", "GET", "/v1/foo");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "Example", version: "1.0.0" },
Expand All @@ -347,7 +347,7 @@ describe("buildClientToolMap", () => {
});

it("populates parameterDescriptions from operation parameters", () => {
const tool = createTool("example.getFoo", "GET", "https://api.example.com/v1/foo");
const tool = createOp("example.getFoo", "GET", "/v1/foo");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "Example", version: "1.0.0" },
Expand Down Expand Up @@ -390,7 +390,7 @@ describe("buildClientToolMap", () => {
});

it("leaves description and parameterDescriptions undefined when absent", () => {
const tool = createTool("example.getBar", "GET", "https://api.example.com/v1/bar");
const tool = createOp("example.getBar", "GET", "/v1/bar");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "Example", version: "1.0.0" },
Expand All @@ -412,7 +412,7 @@ describe("buildClientToolMap", () => {
});

it("includes parameter descriptions in inputSchema property schemas", () => {
const tool = createTool("example.getRepo", "GET", "https://api.example.com/v1/{owner}/{repo}");
const tool = createOp("example.getRepo", "GET", "/v1/{owner}/{repo}");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "Example", version: "1.0.0" },
Expand Down Expand Up @@ -466,7 +466,7 @@ describe("buildClientToolMap", () => {
});

it("omits inputSchema when tool has no input properties", () => {
const tool = createTool("example.ping", "GET", "https://api.example.com/v1/ping");
const tool = createOp("example.ping", "GET", "/v1/ping");
const openApiDocument = {
openapi: "3.0.0",
info: { title: "Example", version: "1.0.0" },
Expand Down
58 changes: 19 additions & 39 deletions packages/bundler/src/client-api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { getDocumentPathItem } from "./openapi-path.js";
import { stripProviderToolName, type RegistryProvider } from "./provider.js";
import { schemaToTypeScriptType } from "./schema.js";
import type { Tool } from "@utcp/sdk";
import type { DiscoveredOperation } from "./openapi-discovery.js";
import type { OpenAPIV3 } from "openapi-types";

export type ToolRuntimeMetadata = {
Expand Down Expand Up @@ -139,20 +138,10 @@ function resolveSchema(

function getOperationContext(
document: OpenAPIV3.Document,
tool: Tool,
op: DiscoveredOperation,
): { operation?: OpenAPIV3.OperationObject; pathItem?: OpenAPIV3.PathItemObject } {
const method =
tool.tool_call_template && typeof tool.tool_call_template.http_method === "string"
? tool.tool_call_template.http_method.toLowerCase()
: undefined;
const rawUrl =
tool.tool_call_template && typeof tool.tool_call_template.url === "string" ? tool.tool_call_template.url : undefined;

if (!method || !rawUrl) {
return {};
}

const { pathItem } = getDocumentPathItem(document, rawUrl);
const method = op.method.toLowerCase();
const pathItem = document.paths?.[op.path];

if (!pathItem || !(method in pathItem)) {
return {};
Expand Down Expand Up @@ -286,8 +275,8 @@ function createUniqueAccessPath(rawToolName: string, usedPaths: Set<string>): st
return accessPath;
}

function buildRequestDefinition(document: OpenAPIV3.Document, tool: Tool): RequestDefinition {
const { operation, pathItem } = getOperationContext(document, tool);
function buildRequestDefinition(document: OpenAPIV3.Document, op: DiscoveredOperation): RequestDefinition {
const { operation, pathItem } = getOperationContext(document, op);
const parameterMap = new Map<string, ParameterDefinition>();
const parameterValues = [...(pathItem?.parameters ?? []), ...(operation?.parameters ?? [])];

Expand Down Expand Up @@ -521,9 +510,9 @@ function buildOptionsSchema(request: RequestDefinition, runtimeMetadata: ToolRun

function collectParameterDescriptions(
document: OpenAPIV3.Document,
tool: Tool,
op: DiscoveredOperation,
): Record<string, string> {
const { operation } = getOperationContext(document, tool);
const { operation } = getOperationContext(document, op);
const descriptions: Record<string, string> = {};
const parameterValues = operation?.parameters ?? [];

Expand All @@ -540,24 +529,24 @@ function collectParameterDescriptions(

export function buildClientToolMap(
document: OpenAPIV3.Document,
tools: Tool[],
operations: DiscoveredOperation[],
provider: Pick<RegistryProvider, "name" | "options">,
): Map<string, ClientToolDefinition> {
const usedPaths = new Set<string>();

return new Map(
tools.map((tool) => {
const rawToolName = stripProviderToolName(tool.name, provider);
const request = buildRequestDefinition(document, tool);
operations.map((op) => {
const rawToolName = stripProviderToolName(op.name, provider);
const request = buildRequestDefinition(document, op);
const { hasInput, schema: inputSchema, runtimeMetadata } = buildInputSchema(request);
const { schema: optionsSchema, optional: optionsOptional, hasOptions } = buildOptionsSchema(request, runtimeMetadata);
const accessPath = createUniqueAccessPath(rawToolName, usedPaths);
const parameterDescriptions = collectParameterDescriptions(document, tool);
const { operation } = getOperationContext(document, tool);
const operationDescription = operation?.summary ?? operation?.description ?? tool.description;
const parameterDescriptions = collectParameterDescriptions(document, op);
const { operation } = getOperationContext(document, op);
const operationDescription = operation?.summary ?? operation?.description ?? op.description;

return [
tool.name,
op.name,
{
accessPath,
hasInput,
Expand All @@ -569,20 +558,11 @@ export function buildClientToolMap(
runtimeMetadata: {
...runtimeMetadata,
accessPath,
contentType:
tool.tool_call_template && typeof tool.tool_call_template.content_type === "string"
? tool.tool_call_template.content_type
: undefined,
contentType: op.contentType,
description: operationDescription || undefined,
method:
tool.tool_call_template && typeof tool.tool_call_template.http_method === "string"
? tool.tool_call_template.http_method
: "GET",
method: op.method,
parameterDescriptions: Object.keys(parameterDescriptions).length > 0 ? parameterDescriptions : undefined,
routeTemplate:
tool.tool_call_template && typeof tool.tool_call_template.url === "string"
? tool.tool_call_template.url.replace(/^https?:\/\/[^/]+/u, "")
: "/",
routeTemplate: op.routeTemplate,
},
},
];
Expand Down
23 changes: 13 additions & 10 deletions packages/bundler/src/docs/augment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,24 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import { resolveProviderDocsIndexPath, resolveProviderDocsManifestPath, resolveProviderOutputDir } from "../provider.js";
import { stripProviderToolName, type RegistryProvider } from "../provider.js";
import { schemaToTypeScriptType } from "../schema.js";
import { groupOpenApiOperations } from "./grouping.js";
import { getDocsStaleCheckResult } from "./hash.js";
import { readDocsManifest } from "./manifest.js";
import { loadDocsPromptAssets } from "./prompt.js";
import { DocsPipelineError, type ProviderPackageDocsMetadata } from "./types.js";
import type { ClientToolDefinition } from "../client-api.js";
import type { Tool } from "@utcp/sdk";
import type { DiscoveredOperation } from "../openapi-discovery.js";
import type { OpenAPIV3 } from "openapi-types";

type PublicTypeMap = Map<string, { inputType: string; outputType: string }>;

export type AugmentProviderDocsOptions = {
provider: string;
providerOptions?: RegistryProvider["options"];
openApiDocument: OpenAPIV3.Document;
tools?: Tool[];
operations?: DiscoveredOperation[];
clientToolMap?: Map<string, ClientToolDefinition>;
publicTypeMap?: PublicTypeMap;
docsCacheRoot: string;
outputRoot: string;
overwriteDocs?: boolean;
Expand Down Expand Up @@ -120,35 +122,36 @@ function isParameterObject(
function buildOperationLookup(
provider: string,
providerOptions: RegistryProvider["options"] | undefined,
tools: Tool[] | undefined,
operations: DiscoveredOperation[] | undefined,
clientToolMap: Map<string, ClientToolDefinition> | undefined,
publicTypeMap: PublicTypeMap | undefined,
): {
byMethodAndPath: Map<string, AugmentedOperation>;
byOperationId: Map<string, AugmentedOperation>;
} {
const byMethodAndPath = new Map<string, AugmentedOperation>();
const byOperationId = new Map<string, AugmentedOperation>();

for (const tool of tools ?? []) {
const mapped = clientToolMap?.get(tool.name);
for (const op of operations ?? []) {
const mapped = clientToolMap?.get(op.name);

if (!mapped) {
continue;
}

const strippedToolName = stripProviderToolName(tool.name, {
const strippedToolName = stripProviderToolName(op.name, {
name: provider,
options: providerOptions,
});
const operation: AugmentedOperation = {
accessPath: mapped.accessPath,
description: tool.description,
description: op.description,
hasInput: mapped.hasInput,
hasOptions: mapped.hasOptions,
inputType: mapped.inputType,
optionsOptional: mapped.optionsOptional,
optionsType: mapped.optionsType,
outputType: schemaToTypeScriptType(tool.outputs),
outputType: publicTypeMap?.get(op.name)?.outputType ?? "{ [key: string]: unknown }",
toolName: strippedToolName,
};

Expand Down Expand Up @@ -407,7 +410,7 @@ export async function augmentProviderDocs(options: AugmentProviderDocsOptions):
}

const groups = groupOpenApiOperations(options.openApiDocument);
const operationLookup = buildOperationLookup(options.provider, options.providerOptions, options.tools, options.clientToolMap);
const operationLookup = buildOperationLookup(options.provider, options.providerOptions, options.operations, options.clientToolMap, options.publicTypeMap);
const packageSpecifier = getClientPackageSpecifier(options.provider);
const clientVariable = toCamelCase(options.provider.split(/[./]/u).at(-1) ?? options.provider);
const groupDocOpts: GroupDocOptions = {
Expand Down
Loading