diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 664faed7..f9259233 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,14 +19,15 @@ jobs: with: python-version: "3.11" enable-cache: true + cache-dependency-glob: "**/requirements-docs.txt" - - name: Install all dependencies - run: uv pip install -r requirements-docs.txt + - name: Install Python dependencies + run: uv pip install --system -r requirements-docs.txt - name: Install bun uses: oven-sh/setup-bun@v2 - - name: Install dependencies + - name: Install Node dependencies run: bun install - name: Build documentation diff --git a/README.md b/README.md index fcba8323..c2b46182 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ const toolset = new StackOneToolSet(); const tools = toolset.getTools('hris_*', 'your-account-id'); // Convert to OpenAI functions -const openAIFunctions = tools.toOpenAIFunctions(); +const openAITools = tools.toOpenAI(); // Use with OpenAI const openai = new OpenAI({ @@ -102,7 +102,7 @@ const response = await openai.chat.completions.create({ { role: 'system', content: 'You are a helpful assistant.' }, { role: 'user', content: 'List all employees' }, ], - tools: openAIFunctions, + tools: openAITools, }); ``` diff --git a/requirements-docs.txt b/requirements-docs.txt index 66dd0e7d..6e5b62eb 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,3 +1,4 @@ +mkdocs mkdocs-terminal pygments pymdown-extensions \ No newline at end of file diff --git a/src/models.ts b/src/models.ts index dfee0324..34a90420 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,10 +1,17 @@ /// -import { jsonSchema, tool } from 'ai'; +import { type Schema, type Tool, type ToolExecutionOptions, jsonSchema, tool } from 'ai'; +// Import OpenAPI and JSON Schema types +import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'; +import type { ChatCompletionTool } from 'openai/resources/chat/completions'; // Type aliases for common types export type JsonDict = Record; export type Headers = Record; +// JSON Schema related types +export type JsonSchemaProperties = Record; +export type JsonSchemaType = JSONSchema7['type']; + /** * Base exception for StackOne errors */ @@ -58,7 +65,7 @@ export interface ExecuteConfig { */ export interface ToolParameters { type: string; - properties: JsonDict; + properties: JsonSchemaProperties; } /** @@ -213,7 +220,12 @@ export class StackOneTool { } else if (bodyType === 'multipart') { const formData = new FormData(); for (const [key, value] of Object.entries(bodyParams)) { - formData.append(key, value); + // Convert value to string or Blob as required by FormData.append + if (value instanceof Blob) { + formData.append(key, value); + } else { + formData.append(key, String(value)); + } } fetchOptions.body = formData; // Content-Type is automatically set by the browser for FormData @@ -225,7 +237,7 @@ export class StackOneTool { // Handle errors if (!response.ok) { - let responseBody = null; + let responseBody: string | JsonDict = ''; try { responseBody = await response.json(); } catch (_e) { @@ -241,7 +253,7 @@ export class StackOneTool { // Parse response const result = await response.json(); - return typeof result === 'object' ? result : { result }; + return typeof result === 'object' && result !== null ? result : { result }; } catch (error) { if (error instanceof SyntaxError) { throw new Error(`Invalid JSON in arguments: ${error.message}`); @@ -254,15 +266,16 @@ export class StackOneTool { * Convert this tool to OpenAI's tool format * @returns Tool definition in OpenAI tool format */ - toOpenAI(): JsonDict { + toOpenAI(): ChatCompletionTool { // Clean properties and handle special types - const properties: JsonDict = {}; + const properties: Record = {}; const required: string[] = []; - for (const [name, prop] of Object.entries(this.parameters.properties)) { - if (typeof prop === 'object') { + for (const [name, propValue] of Object.entries(this.parameters.properties)) { + if (typeof propValue === 'object' && propValue !== null) { + const prop = propValue as JSONSchema7; // Only keep standard JSON Schema properties - const cleanedProp: JsonDict = {}; + const cleanedProp: JSONSchema7 = {}; // Copy basic properties if ('type' in prop) { @@ -278,12 +291,11 @@ export class StackOneTool { // Handle array types if (cleanedProp.type === 'array') { // Ensure all arrays have an items property - if ('items' in prop && typeof prop.items === 'object') { + if ('items' in prop && typeof prop.items === 'object' && prop.items !== null) { + const itemsObj = prop.items as JSONSchema7; cleanedProp.items = Object.fromEntries( - Object.entries(prop.items).filter(([k]) => - ['type', 'description', 'enum'].includes(k) - ) - ); + Object.entries(itemsObj).filter(([k]) => ['type', 'description', 'enum'].includes(k)) + ) as JSONSchema7; } else { // Default to string items if not specified cleanedProp.items = { type: 'string' }; @@ -292,9 +304,10 @@ export class StackOneTool { // Handle object types if (cleanedProp.type === 'object' && 'properties' in prop) { + const propProperties = prop.properties as Record; cleanedProp.properties = Object.fromEntries( - Object.entries(prop.properties).map(([k, v]) => { - const propValue = v as JsonDict; + Object.entries(propProperties).map(([k, v]) => { + const propValue = v as JSONSchema7; // Recursively ensure arrays in nested objects have items if (propValue.type === 'array' && !('items' in propValue)) { return [k, { ...propValue, items: { type: 'string' } }]; @@ -305,10 +318,10 @@ export class StackOneTool { Object.entries(propValue).filter(([sk]) => ['type', 'description', 'enum', 'items'].includes(sk) ) - ), + ) as JSONSchema7, ]; }) - ); + ) as Record; } properties[name] = cleanedProp; @@ -336,11 +349,11 @@ export class StackOneTool { toAISDKTool() { // Create a wrapper function that will handle the execution const executeWrapper = async ( - args: JsonDict, - _options: { toolCallId: string; messages: JsonDict[]; abortSignal?: AbortSignal } - ) => { + args: unknown, + _options: ToolExecutionOptions + ): Promise => { try { - return await this.execute(args); + return await this.execute(args as JsonDict); } catch (error) { if (error instanceof StackOneError) { throw new Error(`StackOne Error: ${error.message}`); @@ -353,7 +366,7 @@ export class StackOneTool { const openAIFormat = this.toOpenAI(); // Use the OpenAI function parameters as our JSON schema - const schema = jsonSchema(openAIFormat.function.parameters); + const schema = jsonSchema(openAIFormat.function.parameters as JSONSchema7); // Return the AI SDK tool return tool({ @@ -394,7 +407,7 @@ export class Tools { * Convert all tools to OpenAI format * @returns Array of tools in the format expected by OpenAI's API */ - toOpenAI(): JsonDict[] { + toOpenAI(): ChatCompletionTool[] { return this.tools.map((tool) => tool.toOpenAI()); } @@ -402,8 +415,8 @@ export class Tools { * Convert all tools to AI SDK tools * @returns Object with tool names as keys and AI SDK tools as values */ - toAISDKTools() { - const result: Record = {}; + toAISDKTools(): Record, JsonDict>> { + const result: Record, JsonDict>> = {}; for (const stackOneTool of this.tools) { result[stackOneTool.name] = stackOneTool.toAISDKTool(); @@ -424,7 +437,7 @@ export class Tools { if (index < tools.length) { return { value: tools[index++], done: false }; } - return { value: undefined as any, done: true }; + return { value: undefined, done: true }; }, }; } diff --git a/src/tests/fetch-specs.spec.ts b/src/tests/fetch-specs.spec.ts index 1d2c421f..67b97ff8 100644 --- a/src/tests/fetch-specs.spec.ts +++ b/src/tests/fetch-specs.spec.ts @@ -122,7 +122,7 @@ describe('fetch-specs script', () => { // Test fetchSpec function const hrisSpec = await fetchSpec('hris'); - expect(hrisSpec.info.title).toBe('HRIS API'); + expect((hrisSpec.info as { title: string }).title).toBe('HRIS API'); expect(mockFetch).toHaveBeenCalledTimes(1); // Reset mock call count diff --git a/src/tests/models.spec.ts b/src/tests/models.spec.ts index a58f444a..e675d3a6 100644 --- a/src/tests/models.spec.ts +++ b/src/tests/models.spec.ts @@ -26,8 +26,10 @@ describe('StackOneTool', () => { expect(tool.name).toBe('test_tool'); expect(tool.description).toBe('Test tool'); - expect(tool.parameters.type).toBe('object'); - expect(tool.parameters.properties.id.type).toBe('string'); + expect((tool.parameters as { type: string }).type).toBe('object'); + expect( + (tool.parameters as unknown as { properties: { id: { type: string } } }).properties.id.type + ).toBe('string'); }); it('should execute with parameters', async () => { @@ -111,8 +113,11 @@ describe('StackOneTool', () => { expect(openAIFormat.type).toBe('function'); expect(openAIFormat.function.name).toBe('test_tool'); expect(openAIFormat.function.description).toBe('Test tool'); - expect(openAIFormat.function.parameters.type).toBe('object'); - expect(openAIFormat.function.parameters.properties.id.type).toBe('string'); + expect(openAIFormat.function.parameters?.type).toBe('object'); + expect( + (openAIFormat.function.parameters as { properties: { id: { type: string } } }).properties.id + .type + ).toBe('string'); }); it('should convert to AI SDK tool format', () => { @@ -132,7 +137,10 @@ describe('StackOneTool', () => { expect(aiSdkTool.parameters.jsonSchema.type).toBe('object'); // Use type assertions to handle possibly undefined properties - const properties = aiSdkTool.parameters.jsonSchema.properties as Record; + const properties = aiSdkTool.parameters.jsonSchema.properties as Record< + string, + { type: string } + >; expect(properties).toBeDefined(); expect(properties.id).toBeDefined(); expect(properties.id.type).toBe('string'); @@ -180,7 +188,7 @@ describe('StackOneTool', () => { expect(schema.type).toBe('object'); // Use type assertions to handle possibly undefined properties - const properties = schema.properties as Record; + const properties = schema.properties as Record; expect(properties.stringParam.type).toBe('string'); expect(properties.numberParam.type).toBe('number'); expect(properties.booleanParam.type).toBe('boolean'); @@ -249,8 +257,11 @@ describe('Tools', () => { expect(openAITools[0].type).toBe('function'); expect(openAITools[0].function.name).toBe('test_tool'); expect(openAITools[0].function.description).toBe('Test tool'); - expect(openAITools[0].function.parameters.type).toBe('object'); - expect(openAITools[0].function.parameters.properties.id.type).toBe('string'); + expect(openAITools[0].function.parameters?.type).toBe('object'); + expect( + (openAITools[0].function.parameters as { properties: { id: { type: string } } }).properties.id + .type + ).toBe('string'); }); it('should convert all tools to AI SDK tools', () => { diff --git a/src/tests/openapi-loader.spec.ts b/src/tests/openapi-loader.spec.ts index 868b600c..0031e6e9 100644 --- a/src/tests/openapi-loader.spec.ts +++ b/src/tests/openapi-loader.spec.ts @@ -61,7 +61,7 @@ describe('Loader', () => { // Mock fs.readdirSync to return test files const mockReadDirSync = spyOn(fs, 'readdirSync'); - (mockReadDirSync.mockImplementation as (callback: () => string[]) => void)(() => [ + (mockReadDirSync.mockImplementation as unknown as (callback: () => string[]) => void)(() => [ 'hris.json', 'ats.json', 'not-json.txt', @@ -85,7 +85,7 @@ describe('Loader', () => { return Buffer.from('{}'); } - ) as typeof fs.readFileSync; + ) as unknown as typeof fs.readFileSync; const specs = loadSpecs(); diff --git a/src/tests/schema-validation.spec.ts b/src/tests/schema-validation.spec.ts index de800a88..f4e9c09d 100644 --- a/src/tests/schema-validation.spec.ts +++ b/src/tests/schema-validation.spec.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'bun:test'; import { jsonSchema } from 'ai'; +import type { JSONSchema7 } from 'json-schema'; import { StackOneTool } from '../models'; // Helper function to validate array items in a schema -const validateArrayItems = (obj: any, path = ''): string[] => { +const validateArrayItems = (obj: Record, path = ''): string[] => { const errors: string[] = []; if (typeof obj !== 'object' || obj === null) { @@ -18,16 +19,16 @@ const validateArrayItems = (obj: any, path = ''): string[] => { } // Recursively check properties - if (obj.properties) { + if (obj.properties && typeof obj.properties === 'object') { for (const [key, value] of Object.entries(obj.properties)) { const nestedPath = path ? `${path}.${key}` : key; - errors.push(...validateArrayItems(value, nestedPath)); + errors.push(...validateArrayItems(value as Record, nestedPath)); } } // Check items of arrays if (obj.items && typeof obj.items === 'object') { - errors.push(...validateArrayItems(obj.items, `${path}.items`)); + errors.push(...validateArrayItems(obj.items as Record, `${path}.items`)); } return errors; @@ -135,54 +136,90 @@ describe('Schema Validation', () => { const tool = createArrayTestTool(); const openAIFormat = tool.toOpenAI(); - const errors = validateArrayItems(openAIFormat.function.parameters); + const parameters = openAIFormat.function.parameters; + if (!parameters) { + throw new Error('Parameters should be defined'); + } + + const errors = validateArrayItems(parameters as Record); expect(errors.length).toBe(0); }); it('should handle simple arrays without items', () => { const tool = createArrayTestTool(); const openAIFormat = tool.toOpenAI(); + const parameters = openAIFormat.function.parameters; + + if (!parameters || !parameters.properties) { + throw new Error('Parameters or properties should be defined'); + } - // Check that simpleArray has items - const simpleArray = openAIFormat.function.parameters.properties.simpleArray; + // TypeScript doesn't know the structure of properties, so we need to cast + const properties = parameters.properties as Record; + const simpleArray = properties.simpleArray; expect(simpleArray.items).toBeDefined(); - expect(simpleArray.items.type).toBe('string'); + expect((simpleArray.items as JSONSchema7).type).toBe('string'); }); it('should preserve existing array items', () => { const tool = createArrayTestTool(); const openAIFormat = tool.toOpenAI(); + const parameters = openAIFormat.function.parameters; + + if (!parameters || !parameters.properties) { + throw new Error('Parameters or properties should be defined'); + } - // Check that arrayWithItems preserved its items - const arrayWithItems = openAIFormat.function.parameters.properties.arrayWithItems; + // TypeScript doesn't know the structure of properties, so we need to cast + const properties = parameters.properties as Record; + const arrayWithItems = properties.arrayWithItems; expect(arrayWithItems.items).toBeDefined(); - expect(arrayWithItems.items.type).toBe('string'); + expect((arrayWithItems.items as JSONSchema7).type).toBe('string'); }); it('should handle nested arrays in objects', () => { const tool = createArrayTestTool(); const openAIFormat = tool.toOpenAI(); + const parameters = openAIFormat.function.parameters; + + if (!parameters || !parameters.properties) { + throw new Error('Parameters or properties should be defined'); + } + + // TypeScript doesn't know the structure of properties, so we need to cast + const properties = parameters.properties as Record; + const nestedObject = properties.nestedObject; + if (!nestedObject.properties) { + throw new Error('Nested object properties should be defined'); + } - // Check that nestedArray has items - const nestedArray = - openAIFormat.function.parameters.properties.nestedObject.properties.nestedArray; + const nestedArray = nestedObject.properties.nestedArray as JSONSchema7; expect(nestedArray.items).toBeDefined(); }); it('should handle deeply nested arrays', () => { const tool = createArrayTestTool(); const openAIFormat = tool.toOpenAI(); + const parameters = openAIFormat.function.parameters; - // The structure is simplified in the OpenAI format - // Just verify that level1 exists and is an object - const deeplyNestedProperties = - openAIFormat.function.parameters.properties.deeplyNested.properties; - expect(deeplyNestedProperties.level1).toBeDefined(); - expect(deeplyNestedProperties.level1.type).toBe('object'); + if (!parameters || !parameters.properties) { + throw new Error('Parameters or properties should be defined'); + } + + // TypeScript doesn't know the structure of properties, so we need to cast + const properties = parameters.properties as Record; + const deeplyNested = properties.deeplyNested; + if (!deeplyNested.properties) { + throw new Error('Deeply nested properties should be defined'); + } + + const level1 = deeplyNested.properties.level1 as JSONSchema7; + expect(level1).toBeDefined(); + expect(level1.type).toBe('object'); // Since we can't directly test the deeply nested array (it's simplified in the output), // we'll verify our validation function doesn't find any errors - const errors = validateArrayItems(openAIFormat.function.parameters); + const errors = validateArrayItems(parameters as Record); expect(errors.length).toBe(0); }); }); @@ -199,14 +236,24 @@ describe('Schema Validation', () => { it('should handle the problematic nested array case', () => { const tool = createNestedArrayTestTool(); const openAIFormat = tool.toOpenAI(); + const parameters = openAIFormat.function.parameters; + + if (!parameters || !parameters.properties) { + throw new Error('Parameters or properties should be defined'); + } + + // TypeScript doesn't know the structure of properties, so we need to cast + const properties = parameters.properties as Record; + const filter = properties.filter; + if (!filter.properties) { + throw new Error('Filter properties should be defined'); + } - // Check if the nested array has items - const typeIds = openAIFormat.function.parameters.properties.filter.properties.type_ids; + const typeIds = filter.properties.type_ids as JSONSchema7; expect(typeIds.items).toBeDefined(); // Verify that the schema can be used with jsonSchema - const schema = openAIFormat.function.parameters; - const aiSchema = jsonSchema(schema); + const aiSchema = jsonSchema(parameters); expect(aiSchema).toBeDefined(); }); }); diff --git a/src/tests/toolset.spec.ts b/src/tests/toolset.spec.ts index 78918c8e..717dac09 100644 --- a/src/tests/toolset.spec.ts +++ b/src/tests/toolset.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, spyOn } from 'bun:test'; import { env } from 'bun'; -import { ParameterLocation, StackOneTool } from '../models'; +import { ParameterLocation, StackOneTool, type Tools } from '../models'; import { OpenAPIParser } from '../openapi/parser'; import { StackOneToolSet } from '../toolset'; @@ -69,7 +69,9 @@ describe('StackOneToolSet', () => { [Symbol.iterator]: function* () { yield tool; }, - } as Tools; + toOpenAI: () => [tool.toOpenAI()], + toAISDKTools: () => ({ [tool.name]: tool.toAISDKTool() }), + } as unknown as Tools; } // Return empty tools collection for non-matching filter @@ -77,7 +79,9 @@ describe('StackOneToolSet', () => { length: 0, getTool: () => undefined, [Symbol.iterator]: function* () {}, - } as Tools; + toOpenAI: () => [], + toAISDKTools: () => ({}), + } as unknown as Tools; }; try { @@ -108,7 +112,9 @@ describe('StackOneToolSet', () => { length: 0, getTool: () => undefined, [Symbol.iterator]: function* () {}, - } as Tools; + toOpenAI: () => [], + toAISDKTools: () => ({}), + } as unknown as Tools; }; try { @@ -142,7 +148,7 @@ describe('StackOneToolSet', () => { execute: { headers: {}, method: 'GET', - url: `${(this as { baseUrl: string }).baseUrl}/test/{id}`, + url: `${(this as unknown as { baseUrl: string }).baseUrl}/test/{id}`, name: 'test_tool', parameterLocations: { id: ParameterLocation.PATH }, }, @@ -201,7 +207,7 @@ describe('StackOneToolSet', () => { execute: { headers: {}, method: 'GET', - url: `${(this as { baseUrl: string }).baseUrl}/hris/employees/{id}`, + url: `${(this as unknown as { baseUrl: string }).baseUrl}/hris/employees/{id}`, name: 'hris_get_employee', parameterLocations: { id: ParameterLocation.PATH }, },