diff --git a/.gitignore b/.gitignore index d0545ec..856a25d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ dist/ coverage/ .nyc_output/ + +# Environment +.env diff --git a/example/package.json b/example/package.json deleted file mode 100644 index 8f3ac1b..0000000 --- a/example/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "edgee-sdk-example", - "version": "1.0.0", - "type": "module", - "scripts": { - "test": "npx tsx test.ts" - }, - "dependencies": { - "edgee": "file:.." - }, - "devDependencies": { - "tsx": "^4.7.0" - } -} - diff --git a/example/test.ts b/example/test.ts deleted file mode 100644 index 77a29e1..0000000 --- a/example/test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import Edgee from "edgee"; - -const edgee = new Edgee(process.env.EDGEE_API_KEY || "test-key"); - -// Test 1: Simple string input -console.log("Test 1: Simple string input"); -const response1 = await edgee.send({ - model: "gpt-4o", - input: "What is the capital of France?", -}); -console.log("Content:", response1.choices[0].message.content); -console.log("Usage:", response1.usage); -console.log(); - -// Test 2: Full input object with messages -console.log("Test 2: Full input object with messages"); -const response2 = await edgee.send({ - model: "gpt-4o", - input: { - messages: [ - { role: "system", content: "You are a helpful assistant." }, - { role: "user", content: "Say hello!" }, - ], - }, -}); -console.log("Content:", response2.choices[0].message.content); -console.log(); - -// Test 3: With tools -console.log("Test 3: With tools"); -const response3 = await edgee.send({ - model: "gpt-4o", - input: { - messages: [{ role: "user", content: "What is the weather in Paris?" }], - tools: [ - { - type: "function", - function: { - name: "get_weather", - description: "Get the current weather for a location", - parameters: { - type: "object", - properties: { - location: { type: "string", description: "City name" }, - }, - required: ["location"], - }, - }, - }, - ], - tool_choice: "auto", - }, -}); -console.log("Content:", response3.choices[0].message.content); -console.log( - "Tool calls:", - JSON.stringify(response3.choices[0].message.tool_calls, null, 2) -); diff --git a/example/tsconfig.json b/example/tsconfig.json deleted file mode 100644 index 2cfd8cc..0000000 --- a/example/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "moduleResolution": "bundler", - "module": "ESNext", - "target": "ES2022", - "types": ["node"] - }, - "include": ["test.ts"] -} - diff --git a/examples/.env.example b/examples/.env.example new file mode 100644 index 0000000..c7878ee --- /dev/null +++ b/examples/.env.example @@ -0,0 +1,2 @@ +EDGEE_API_KEY=your_api_key_here +EDGEE_BASE_URL=https://api.edgee.ai diff --git a/examples/messages.ts b/examples/messages.ts new file mode 100644 index 0000000..57e8ee4 --- /dev/null +++ b/examples/messages.ts @@ -0,0 +1,25 @@ +/** + * Full input object with messages + * + * Use the messages array for multi-turn conversations + * and system prompts. + */ +import "dotenv/config"; +import Edgee from "edgee"; + +const edgee = new Edgee({ + apiKey: process.env.EDGEE_API_KEY, + baseUrl: process.env.EDGEE_BASE_URL, +}); + +const response = await edgee.send({ + model: "devstral2", + input: { + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "Say hello!" }, + ], + }, +}); + +console.log("Content:", response.text); diff --git a/example/package-lock.json b/examples/package-lock.json similarity index 95% rename from example/package-lock.json rename to examples/package-lock.json index ec7ae6c..f18cc83 100644 --- a/example/package-lock.json +++ b/examples/package-lock.json @@ -1,13 +1,14 @@ { - "name": "edgee-sdk-example", + "name": "edgee-sdk-examples", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "edgee-sdk-example", + "name": "edgee-sdk-examples", "version": "1.0.0", "dependencies": { + "dotenv": "^17.2.3", "edgee": "file:.." }, "devDependencies": { @@ -17,6 +18,9 @@ "..": { "name": "edgee", "version": "0.1.1", + "dependencies": { + "zod-to-json-schema": "^3.24.5" + }, "devDependencies": { "@eslint/js": "^9.39.2", "@types/node": "^25.0.3", @@ -26,7 +30,16 @@ "eslint": "^9.39.2", "typescript": "^5.7.2", "typescript-eslint": "^8.51.0", - "vitest": "^4.0.16" + "vitest": "^4.0.16", + "zod": "^3.25.67" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } } }, "node_modules/@esbuild/aix-ppc64": { @@ -471,6 +484,18 @@ "node": ">=18" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/edgee": { "resolved": "..", "link": true diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 0000000..a8acfa5 --- /dev/null +++ b/examples/package.json @@ -0,0 +1,22 @@ +{ + "name": "edgee-sdk-examples", + "version": "1.0.0", + "type": "module", + "scripts": { + "simple": "npx tsx simple.ts", + "messages": "npx tsx messages.ts", + "tools:manual": "npx tsx tools_manual.ts", + "tools:auto": "npx tsx tools_auto.ts", + "tools:multiple": "npx tsx tools_multiple.ts", + "stream": "npx tsx stream.ts", + "stream:tools": "npx tsx stream_tools.ts", + "all": "npm run simple && npm run messages && npm run tools:manual && npm run tools:auto && npm run tools:multiple && npm run stream && npm run stream:tools" + }, + "dependencies": { + "dotenv": "^17.2.3", + "edgee": "file:.." + }, + "devDependencies": { + "tsx": "^4.7.0" + } +} diff --git a/examples/simple.ts b/examples/simple.ts new file mode 100644 index 0000000..ea71be0 --- /dev/null +++ b/examples/simple.ts @@ -0,0 +1,20 @@ +/** + * Simple string input example + * + * The most basic way to use the SDK - just pass a string prompt. + */ +import "dotenv/config"; +import Edgee from "edgee"; + +const edgee = new Edgee({ + apiKey: process.env.EDGEE_API_KEY, + baseUrl: process.env.EDGEE_BASE_URL, +}); + +const response = await edgee.send({ + model: "devstral2", + input: "What is the capital of France?", +}); + +console.log("Content:", response.text); +console.log("Usage:", response.usage); diff --git a/examples/stream.ts b/examples/stream.ts new file mode 100644 index 0000000..a387cd3 --- /dev/null +++ b/examples/stream.ts @@ -0,0 +1,20 @@ +/** + * Simple streaming without tools + * + * Stream responses token by token for real-time output. + */ +import "dotenv/config"; +import Edgee from "edgee"; + +const edgee = new Edgee({ + apiKey: process.env.EDGEE_API_KEY, + baseUrl: process.env.EDGEE_BASE_URL, +}); + +process.stdout.write("Response: "); + +for await (const chunk of edgee.stream("devstral2", "Say hello in 10 words!")) { + process.stdout.write(chunk.text ?? ""); +} + +console.log(); diff --git a/examples/stream_tools.ts b/examples/stream_tools.ts new file mode 100644 index 0000000..78bdfb4 --- /dev/null +++ b/examples/stream_tools.ts @@ -0,0 +1,99 @@ +/** + * Streaming with automatic tool execution + * + * Combine streaming with auto tool execution. + * You receive events for chunks, tool starts, and tool results. + */ +import "dotenv/config"; +import Edgee, { StreamEvent, Tool, z } from "edgee"; + +const edgee = new Edgee({ + apiKey: process.env.EDGEE_API_KEY, + baseUrl: process.env.EDGEE_BASE_URL, +}); + +// Weather tool +const weatherTool = new Tool({ + name: "get_weather", + description: "Get the current weather for a location", + schema: z.object({ + location: z.string().describe("The city name"), + }), + handler: async (args) => { + const weatherData: Record< + string, + { temperature: number; condition: string } + > = { + Paris: { temperature: 18, condition: "partly cloudy" }, + London: { temperature: 12, condition: "rainy" }, + "New York": { temperature: 22, condition: "sunny" }, + }; + const data = weatherData[args.location] || { + temperature: 20, + condition: "unknown", + }; + return { + location: args.location, + temperature: data.temperature, + condition: data.condition, + }; + }, +}); + +// Calculator tool +const calculatorTool = new Tool({ + name: "calculate", + description: "Perform basic arithmetic operations", + schema: z.object({ + operation: z.enum(["add", "subtract", "multiply", "divide"]), + a: z.number(), + b: z.number(), + }), + handler: async ({ operation, a, b }) => { + const ops = { + add: a + b, + subtract: a - b, + multiply: a * b, + divide: b !== 0 ? a / b : "Error: division by zero", + }; + return { operation, a, b, result: ops[operation] }; + }, +}); + +console.log("Streaming with tools...\n"); +process.stdout.write("Response: "); + +const stream: AsyncGenerator = edgee.stream({ + model: "devstral2", + input: "What's 15 multiplied by 7, and what's the weather in Paris?", + tools: [weatherTool, calculatorTool], +}); + +for await (const event of stream) { + switch (event.type) { + case "chunk": + // Stream content as it arrives + process.stdout.write(event.chunk.text ?? ""); + break; + + case "tool_start": + // Tool is about to be executed + console.log(`\n [Tool starting: ${event.toolCall.function.name}]`); + break; + + case "tool_result": + // Tool finished executing + console.log( + ` [Tool result: ${event.toolName} -> ${JSON.stringify(event.result)}]` + ); + process.stdout.write("Response: "); + break; + + case "iteration_complete": + // One iteration of the tool loop completed + console.log(` [Iteration ${event.iteration} complete, continuing...]`); + break; + } +} + +console.log(); diff --git a/examples/tools_auto.ts b/examples/tools_auto.ts new file mode 100644 index 0000000..99bd454 --- /dev/null +++ b/examples/tools_auto.ts @@ -0,0 +1,52 @@ +/** + * Automatic tool execution (Simple mode) + * + * Define tools with Zod schemas and handlers. + * The SDK automatically executes tools and loops + * until the model produces a final response. + */ +import "dotenv/config"; +import Edgee, { Tool, z } from "edgee"; + +const edgee = new Edgee({ + apiKey: process.env.EDGEE_API_KEY, + baseUrl: process.env.EDGEE_BASE_URL, +}); + +// Define a weather tool with Zod schema and handler +const weatherTool = new Tool({ + name: "get_weather", + description: "Get the current weather for a location", + schema: z.object({ + location: z.string().describe("The city name"), + }), + handler: async (args) => { + // Simulated weather API response + const weatherData: Record< + string, + { temperature: number; condition: string } + > = { + Paris: { temperature: 18, condition: "partly cloudy" }, + London: { temperature: 12, condition: "rainy" }, + "New York": { temperature: 22, condition: "sunny" }, + }; + const data = weatherData[args.location] || { + temperature: 20, + condition: "unknown", + }; + return { + location: args.location, + temperature: data.temperature, + condition: data.condition, + }; + }, +}); + +const response = await edgee.send({ + model: "devstral2", + input: "What's the weather like in Paris?", + tools: [weatherTool], +}); + +console.log("Content:", response.text); +console.log("Total usage:", response.usage); diff --git a/examples/tools_manual.ts b/examples/tools_manual.ts new file mode 100644 index 0000000..d16f93a --- /dev/null +++ b/examples/tools_manual.ts @@ -0,0 +1,45 @@ +/** + * Manual tool handling (Advanced mode) + * + * Define tools using raw OpenAI format and handle + * tool calls manually in your code. + */ +import "dotenv/config"; +import Edgee from "edgee"; + +const edgee = new Edgee({ + apiKey: process.env.EDGEE_API_KEY, + baseUrl: process.env.EDGEE_BASE_URL, +}); + +const response = await edgee.send({ + model: "devstral2", + input: { + messages: [{ role: "user", content: "What is the weather in Paris?" }], + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the current weather for a location", + parameters: { + type: "object", + properties: { + location: { type: "string", description: "City name" }, + }, + required: ["location"], + }, + }, + }, + ], + tool_choice: "auto", + }, +}); + +console.log("Content:", response.text); +console.log("Tool calls:", JSON.stringify(response.toolCalls, null, 2)); + +// In manual mode, you would: +// 1. Check if response.toolCalls exists +// 2. Execute the tool yourself +// 3. Send another request with the tool result diff --git a/examples/tools_multiple.ts b/examples/tools_multiple.ts new file mode 100644 index 0000000..3467d11 --- /dev/null +++ b/examples/tools_multiple.ts @@ -0,0 +1,76 @@ +/** + * Multiple tools with automatic execution + * + * Pass multiple tools and the model will decide + * which ones to call based on the user's request. + */ +import "dotenv/config"; +import Edgee, { Tool, z } from "edgee"; + +const edgee = new Edgee({ + apiKey: process.env.EDGEE_API_KEY, + baseUrl: process.env.EDGEE_BASE_URL, +}); + +// Weather tool +const weatherTool = new Tool({ + name: "get_weather", + description: "Get the current weather for a location", + schema: z.object({ + location: z.string().describe("The city name"), + }), + handler: async (args) => { + const weatherData: Record< + string, + { temperature: number; condition: string } + > = { + Paris: { temperature: 18, condition: "partly cloudy" }, + London: { temperature: 12, condition: "rainy" }, + "New York": { temperature: 22, condition: "sunny" }, + }; + const data = weatherData[args.location] || { + temperature: 20, + condition: "unknown", + }; + return { + location: args.location, + temperature: data.temperature, + condition: data.condition, + }; + }, +}); + +// Calculator tool +const calculatorTool = new Tool({ + name: "calculate", + description: + "Perform basic arithmetic operations (add, subtract, multiply, divide)", + schema: z.object({ + operation: z.enum(["add", "subtract", "multiply", "divide"]), + a: z.number(), + b: z.number(), + }), + handler: async ({ operation, a, b }) => { + const operations = { + add: a + b, + subtract: a - b, + multiply: a * b, + divide: b !== 0 ? a / b : "Error: division by zero", + }; + return { + operation, + a, + b, + result: operations[operation], + }; + }, +}); + +const response = await edgee.send({ + model: "devstral2", + input: "What's 25 multiplied by 4, and then what's the weather in London?", + tools: [weatherTool, calculatorTool], +}); + +console.log("Content:", response.text); +console.log("Total usage:", response.usage); diff --git a/package-lock.json b/package-lock.json index 5ebe474..3f77232 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "edgee", "version": "0.1.2", + "dependencies": { + "zod-to-json-schema": "^3.24.5" + }, "devDependencies": { "@eslint/js": "^9.39.2", "@types/node": "^25.0.3", @@ -16,7 +19,16 @@ "eslint": "^9.39.2", "typescript": "^5.7.2", "typescript-eslint": "^8.51.0", - "vitest": "^4.0.16" + "vitest": "^4.0.16", + "zod": "^3.25.67" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } } }, "node_modules/@esbuild/aix-ppc64": { @@ -1069,7 +1081,6 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1109,7 +1120,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -1411,7 +1421,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -1448,7 +1457,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1705,7 +1713,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2413,7 +2420,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2716,7 +2722,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2772,7 +2777,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -2848,7 +2852,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -2976,6 +2979,24 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/package.json b/package.json index 5a9aa8d..7cabe45 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,19 @@ "test:watch": "vitest", "test:ui": "vitest --ui" }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + }, + "dependencies": { + "zod-to-json-schema": "^3.24.5" + }, "devDependencies": { + "zod": "^3.25.67", "@eslint/js": "^9.39.2", "@types/node": "^25.0.3", "@typescript-eslint/eslint-plugin": "^8.51.0", diff --git a/src/index.ts b/src/index.ts index 0df5b68..31c27a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,14 @@ -// Tool types +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; + +// OpenAI API tool types export interface FunctionDefinition { name: string; description?: string; parameters?: Record; } -export interface Tool { +export interface OpenAITool { type: "function"; function: FunctionDefinition; } @@ -28,6 +31,56 @@ export interface ToolCall { }; } +// Tool class for easy declaration +export interface ToolConfig { + name: string; + description?: string; + schema: T; + handler: (args: z.infer) => unknown | Promise; +} + +export class Tool { + readonly name: string; + readonly description?: string; + readonly schema: T; + readonly handler: (args: z.infer) => unknown | Promise; + + constructor(config: ToolConfig) { + this.name = config.name; + this.description = config.description; + this.schema = config.schema; + this.handler = config.handler; + } + + // Convert to JSON format for API + toJSON(): OpenAITool { + return { + type: "function", + function: { + name: this.name, + description: this.description, + parameters: zodToJsonSchema(this.schema, { + $refStrategy: "none", + target: "openAi", + }) as Record, + }, + }; + } + + // Execute the tool with validation + async execute(args: Record): Promise { + const parsedArgs = this.schema.parse(args); + return this.handler(parsedArgs); + } +} + +// Helper function to create a tool (alternative to class) +export function createTool( + config: ToolConfig +): Tool { + return new Tool(config); +} + // Message types export interface Message { role: "system" | "user" | "assistant" | "tool"; @@ -37,18 +90,31 @@ export interface Message { tool_call_id?: string; } -// Full input object +// Full input object (for advanced/manual mode) export interface InputObject { messages: Message[]; - tools?: Tool[]; + tools?: OpenAITool[]; tool_choice?: ToolChoice; } -export interface SendOptions { +// Simple mode: string input with optional auto-handled tools +export interface SimpleSendOptions { model: string; - input: string | InputObject; + input: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools?: Tool[]; + maxToolIterations?: number; } +// Advanced mode: full InputObject (tools defined inside, manually handled) +export interface AdvancedSendOptions { + model: string; + input: InputObject; +} + +// Union type - one or the other, never mixed +export type SendOptions = SimpleSendOptions | AdvancedSendOptions; + export interface Choice { index: number; message: { @@ -59,22 +125,27 @@ export interface Choice { finish_reason: string | null; } +export interface InputTokenDetails { + cached_tokens: number; +} + +export interface OutputTokenDetails { + reasoning_tokens: number; +} + +export interface Usage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + input_tokens_details: InputTokenDetails; + output_tokens_details: OutputTokenDetails; +} + export class SendResponse { choices: Choice[]; - usage?: { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - }; + usage?: Usage; - constructor( - choices: Choice[], - usage?: { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - } - ) { + constructor(choices: Choice[], usage?: Usage) { this.choices = choices; this.usage = usage; } @@ -103,7 +174,18 @@ export class SendResponse { export interface StreamDelta { role?: string; content?: string; - tool_calls?: ToolCall[]; + tool_calls?: StreamToolCallDelta[]; +} + +// Tool call delta (partial tool call in stream) +export interface StreamToolCallDelta { + index: number; + id?: string; + type?: "function"; + function?: { + name?: string; + arguments?: string; + }; } export interface StreamChoice { @@ -139,8 +221,37 @@ export class StreamChunk { } return null; } + + get toolCallDeltas(): StreamToolCallDelta[] | null { + return this.choices[0]?.delta?.tool_calls ?? null; + } +} + +// Stream events for tool-enabled streaming +export type StreamEvent = + | { type: "chunk"; chunk: StreamChunk } + | { type: "tool_start"; toolCall: ToolCall } + | { type: "tool_result"; toolCallId: string; toolName: string; result: unknown } + | { type: "iteration_complete"; iteration: number }; + +// Simple stream options (auto-handled tools) +export interface SimpleStreamOptions { + model: string; + input: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools?: Tool[]; + maxToolIterations?: number; +} + +// Advanced stream options (manual control) +export interface AdvancedStreamOptions { + model: string; + input: InputObject; } +// Union type for stream options +export type StreamOptions = SimpleStreamOptions | AdvancedStreamOptions; + export interface EdgeeConfig { apiKey?: string; baseUrl?: string; @@ -168,24 +279,128 @@ export default class Edgee { throw new Error("EDGEE_API_KEY is not set"); } - this.baseUrl = baseUrl || process.env.EDGEE_BASE_URL || "https://api.edgee.ai"; + this.baseUrl = + baseUrl || process.env.EDGEE_BASE_URL || "https://api.edgee.ai"; } async send(options: SendOptions): Promise { - const { input } = options; + // String input = Simple mode (auto-handled tools) + // Object input = Advanced mode (manual control) + if (typeof options.input === "string") { + return this.sendSimple(options as SimpleSendOptions); + } else { + return this.sendAdvanced(options as AdvancedSendOptions); + } + } + + private async sendSimple(options: SimpleSendOptions): Promise { + const { model, input, tools, maxToolIterations = 10 } = options; + + // Build initial messages + const messages: Message[] = [{ role: "user", content: input }]; + + // Convert Tool instances to JSON format + const openAiTools: OpenAITool[] | undefined = tools?.map((tool) => + tool.toJSON() + ); + + // Create a map for quick tool lookup by name + const toolMap = new Map( + tools?.map((tool) => [tool.name, tool]) + ); + + let iterations = 0; + let totalUsage: SendResponse["usage"] | undefined; + + // The agentic loop + while (iterations < maxToolIterations) { + iterations++; + + // Call the API + const response = await this.callApi(model, messages, openAiTools); + const choice = response.choices[0]; + + // Accumulate usage + if (response.usage) { + if (!totalUsage) { + totalUsage = structuredClone(response.usage); + } else { + totalUsage.prompt_tokens += response.usage.prompt_tokens; + totalUsage.completion_tokens += response.usage.completion_tokens; + totalUsage.total_tokens += response.usage.total_tokens; + totalUsage.input_tokens_details.cached_tokens += + response.usage.input_tokens_details.cached_tokens; + totalUsage.output_tokens_details.reasoning_tokens += + response.usage.output_tokens_details.reasoning_tokens; + } + } + + // No choice or no tool calls? We're done - return final response + if ( + !choice?.message?.tool_calls || + choice.message.tool_calls.length === 0 + ) { + return new SendResponse(response.choices, totalUsage); + } + + // Add assistant's response (with tool_calls) to messages + messages.push({ + role: "assistant", + content: choice.message.content ?? undefined, + tool_calls: choice.message.tool_calls, + }); + + // Execute each tool call and add results + for (const toolCall of choice.message.tool_calls) { + const toolName = toolCall.function.name; + const tool = toolMap.get(toolName); + + let result: unknown; + if (tool) { + try { + // Parse arguments and execute with validation + const rawArgs = JSON.parse(toolCall.function.arguments); + result = await tool.execute(rawArgs); + } catch (err) { + if (err instanceof z.ZodError) { + result = { error: `Invalid arguments: ${err.message}` }; + } else if (err instanceof Error) { + result = { error: `Tool execution failed: ${err.message}` }; + } else { + result = { error: "Tool execution failed" }; + } + } + } else { + result = { error: `Unknown tool: ${toolName}` }; + } + + // Add tool result to messages + messages.push({ + role: "tool", + tool_call_id: toolCall.id, + content: typeof result === "string" ? result : JSON.stringify(result), + }); + } + + // Loop continues - model will process tool results + } + + // Max iterations reached + throw new Error(`Max tool iterations (${maxToolIterations}) reached`); + } + + private async sendAdvanced( + options: AdvancedSendOptions + ): Promise { + const { model, input } = options; const body: Record = { - model: options.model, - messages: - typeof input === "string" - ? [{ role: "user", content: input }] - : input.messages, + model, + messages: input.messages, }; - if (typeof input !== "string") { - if (input.tools) body.tools = input.tools; - if (input.tool_choice) body.tool_choice = input.tool_choice; - } + if (input.tools) body.tools = input.tools; + if (input.tool_choice) body.tool_choice = input.tool_choice; const res = await fetch(`${this.baseUrl}/v1/chat/completions`, { method: "POST", @@ -201,13 +416,9 @@ export default class Edgee { throw new Error(`API error ${res.status}: ${errorBody}`); } - const data = await res.json() as { + const data = (await res.json()) as { choices: Choice[]; - usage?: { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - } + usage?: Usage; }; return new SendResponse(data.choices, data.usage); @@ -269,7 +480,60 @@ export default class Edgee { } } + /** + * Stream a response from the API. + * + * Simple mode (string input): Optionally pass tools for auto-execution. + * Advanced mode (InputObject): Manual tool handling. + * + * @example Simple streaming without tools + * ```ts + * for await (const chunk of client.stream("gpt-4o", "Hello!")) { + * process.stdout.write(chunk.text ?? ""); + * } + * ``` + * + * @example Simple streaming with auto-executed tools + * ```ts + * for await (const event of client.stream({ + * model: "gpt-4o", + * input: "What's the weather in Paris?", + * tools: [weatherTool], + * })) { + * if (event.type === "chunk") { + * process.stdout.write(event.chunk.text ?? ""); + * } else if (event.type === "tool_result") { + * console.log(`Tool ${event.toolName}: ${JSON.stringify(event.result)}`); + * } + * } + * ``` + */ + stream(model: string, input: string): AsyncGenerator; + stream(model: string, input: InputObject): AsyncGenerator; + stream(options: SimpleStreamOptions): AsyncGenerator; + stream(options: AdvancedStreamOptions): AsyncGenerator; async *stream( + modelOrOptions: string | StreamOptions, + input?: string | InputObject + ): AsyncGenerator { + // Overload 1 & 2: stream(model, input) - backward compatible + if (typeof modelOrOptions === "string") { + yield* this._streamAdvanced(modelOrOptions, input!); + return; + } + + // Overload 3 & 4: stream(options) + const options = modelOrOptions; + if (typeof options.input === "string") { + // Simple mode with tools + yield* this._streamSimple(options as SimpleStreamOptions); + } else { + // Advanced mode + yield* this._streamAdvanced(options.model, options.input); + } + } + + private async *_streamAdvanced( model: string, input: string | InputObject ): AsyncGenerator { @@ -292,4 +556,179 @@ export default class Edgee { body ); } + + private async *_streamSimple( + options: SimpleStreamOptions + ): AsyncGenerator { + const { model, input, tools, maxToolIterations = 10 } = options; + + // Build initial messages + const messages: Message[] = [{ role: "user", content: input }]; + + // Convert Tool instances to JSON format + const openAiTools: OpenAITool[] | undefined = tools?.map((tool) => + tool.toJSON() + ); + + // Create a map for quick tool lookup by name + const toolMap = new Map( + tools?.map((tool) => [tool.name, tool]) + ); + + let iterations = 0; + + // The agentic loop + while (iterations < maxToolIterations) { + iterations++; + + // Accumulate the full response from stream + let role: string | undefined; + let content = ""; + const toolCallsAccumulator: Map = new Map(); + + // Stream the response + const body: Record = { + model, + messages, + stream: true, + }; + if (openAiTools) body.tools = openAiTools; + + for await (const chunk of this._handleStreamingResponse( + `${this.baseUrl}/v1/chat/completions`, + body + )) { + // Yield the chunk as an event + yield { type: "chunk", chunk }; + + // Accumulate role + if (chunk.role) { + role = chunk.role; + } + + // Accumulate content + if (chunk.text) { + content += chunk.text; + } + + // Accumulate tool calls from deltas + const toolCallDeltas = chunk.toolCallDeltas; + if (toolCallDeltas) { + for (const delta of toolCallDeltas) { + const existing = toolCallsAccumulator.get(delta.index); + if (existing) { + // Append to existing tool call + if (delta.function?.arguments) { + existing.function.arguments += delta.function.arguments; + } + } else { + // Start new tool call + toolCallsAccumulator.set(delta.index, { + id: delta.id || "", + type: "function", + function: { + name: delta.function?.name || "", + arguments: delta.function?.arguments || "", + }, + }); + } + } + } + } + + // Convert accumulated tool calls to array + const toolCalls = Array.from(toolCallsAccumulator.values()); + + // No tool calls? We're done + if (toolCalls.length === 0) { + return; + } + + // Add assistant's response (with tool_calls) to messages + messages.push({ + role: (role as Message["role"]) || "assistant", + content: content || undefined, + tool_calls: toolCalls, + }); + + // Execute each tool call and add results + for (const toolCall of toolCalls) { + const toolName = toolCall.function.name; + const tool = toolMap.get(toolName); + + // Yield tool_start event + yield { type: "tool_start", toolCall }; + + let result: unknown; + if (tool) { + try { + // Parse arguments and execute with validation + const rawArgs = JSON.parse(toolCall.function.arguments); + result = await tool.execute(rawArgs); + } catch (err) { + if (err instanceof z.ZodError) { + result = { error: `Invalid arguments: ${err.message}` }; + } else if (err instanceof Error) { + result = { error: `Tool execution failed: ${err.message}` }; + } else { + result = { error: "Tool execution failed" }; + } + } + } else { + result = { error: `Unknown tool: ${toolName}` }; + } + + // Yield tool_result event + yield { type: "tool_result", toolCallId: toolCall.id, toolName, result }; + + // Add tool result to messages + messages.push({ + role: "tool", + tool_call_id: toolCall.id, + content: typeof result === "string" ? result : JSON.stringify(result), + }); + } + + // Yield iteration complete event + yield { type: "iteration_complete", iteration: iterations }; + + // Loop continues - model will process tool results + } + + // Max iterations reached - throw error + throw new Error(`Max tool iterations (${maxToolIterations}) reached`); + } + + private async callApi( + model: string, + messages: Message[], + tools?: OpenAITool[] + ): Promise { + const body: Record = { model, messages }; + if (tools) body.tools = tools; + + const res = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorBody = await res.text(); + throw new Error(`API error ${res.status}: ${errorBody}`); + } + + const data = (await res.json()) as { + choices: Choice[]; + usage?: Usage; + }; + + return new SendResponse(data.choices, data.usage); + } } + +// Re-export zod for convenience +export { z } from "zod"; diff --git a/tests/index.test.ts b/tests/index.test.ts index 3b1e646..61a6a3e 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -644,9 +644,12 @@ describe('Edgee', () => { json: async () => mockResponse, }); + // Use InputObject to trigger sendAdvanced mode (no auto tool execution) const result = await client.send({ model: 'gpt-4', - input: 'What is the weather?', + input: { + messages: [{ role: 'user', content: 'What is the weather?' }], + }, }); expect(result.toolCalls).toEqual(toolCalls); @@ -662,9 +665,12 @@ describe('Edgee', () => { json: async () => mockResponse, }); + // Use InputObject to trigger sendAdvanced mode const result = await client.send({ model: 'gpt-4', - input: 'Hello', + input: { + messages: [{ role: 'user', content: 'Hello' }], + }, }); expect(result.text).toBeNull();