| import { DuckAI } from "./duckai"; |
| import { ToolService } from "./tool-service"; |
| import type { |
| ChatCompletionRequest, |
| ChatCompletionResponse, |
| ChatCompletionStreamResponse, |
| ChatCompletionMessage, |
| ModelsResponse, |
| Model, |
| DuckAIRequest, |
| ToolDefinition, |
| ToolCall, |
| } from "./types"; |
|
|
| export class OpenAIService { |
| private duckAI: DuckAI; |
| private toolService: ToolService; |
| private availableFunctions: Record<string, Function>; |
|
|
| constructor() { |
| this.duckAI = new DuckAI(); |
| this.toolService = new ToolService(); |
| this.availableFunctions = this.initializeBuiltInFunctions(); |
| } |
|
|
| private initializeBuiltInFunctions(): Record<string, Function> { |
| return { |
| |
| get_current_time: () => new Date().toISOString(), |
| calculate: (args: { expression: string }) => { |
| try { |
| |
| const result = Function( |
| `"use strict"; return (${args.expression})` |
| )(); |
| return { result }; |
| } catch (error) { |
| return { error: "Invalid expression" }; |
| } |
| }, |
| get_weather: (args: { location: string }) => { |
| |
| return { |
| location: args.location, |
| temperature: Math.floor(Math.random() * 30) + 10, |
| condition: ["sunny", "cloudy", "rainy"][ |
| Math.floor(Math.random() * 3) |
| ], |
| note: "This is a mock weather function for demonstration", |
| }; |
| }, |
| }; |
| } |
|
|
| registerFunction(name: string, func: Function): void { |
| this.availableFunctions[name] = func; |
| } |
|
|
| private generateId(): string { |
| return `chatcmpl-${Math.random().toString(36).substring(2, 15)}`; |
| } |
|
|
| private getCurrentTimestamp(): number { |
| return Math.floor(Date.now() / 1000); |
| } |
|
|
| private estimateTokens(text: string): number { |
| |
| return Math.ceil(text.length / 4); |
| } |
|
|
| private transformToDuckAIRequest( |
| request: ChatCompletionRequest, |
| vqd?: string |
| ): DuckAIRequest { |
| |
| |
| const transformedMessages = []; |
| let systemContent = ""; |
| let firstUserMessageProcessed = false; |
|
|
| for (const message of request.messages) { |
| if (message.role === "system") { |
| systemContent += (systemContent ? "\n" : "") + message.content; |
| } else if (message.role === "user") { |
| |
| const userContent = !firstUserMessageProcessed && systemContent |
| ? systemContent + "\n\n" + message.content |
| : message.content; |
| |
| transformedMessages.push({ |
| role: "user" as const, |
| content: userContent, |
| }); |
| firstUserMessageProcessed = true; |
| } else if (message.role === "assistant") { |
| |
| transformedMessages.push({ |
| role: "assistant" as const, |
| content: message.content || "", |
| }); |
| } |
| } |
|
|
| |
| if (!firstUserMessageProcessed && systemContent) { |
| transformedMessages.push({ |
| role: "user" as const, |
| content: systemContent, |
| }); |
| } |
|
|
| |
| const model = request.model || "gpt-4o-mini"; |
| |
| return { |
| model, |
| messages: transformedMessages, |
| vqd |
| }; |
| } |
|
|
| async createChatCompletion( |
| request: ChatCompletionRequest, |
| vqd?: string |
| ): Promise<{ completion: ChatCompletionResponse, vqd: string | null }> { |
| |
| if ( |
| this.toolService.shouldUseFunctionCalling( |
| request.tools, |
| request.tool_choice |
| ) |
| ) { |
| |
| const result = await this.createChatCompletionWithTools(request, vqd); |
| return result; |
| } |
|
|
| const duckAIRequest = this.transformToDuckAIRequest(request, vqd); |
| const response = await this.duckAI.chat(duckAIRequest); |
|
|
| const id = this.generateId(); |
| const created = this.getCurrentTimestamp(); |
|
|
| |
| const promptText = request.messages.map((m) => m.content || "").join(" "); |
| const promptTokens = this.estimateTokens(promptText); |
| const completionTokens = this.estimateTokens(response.message); |
|
|
| return { |
| completion: { |
| id, |
| object: "chat.completion", |
| created, |
| model: request.model, |
| choices: [ |
| { |
| index: 0, |
| message: { |
| role: "assistant", |
| content: response.message, |
| }, |
| finish_reason: "stop", |
| }, |
| ], |
| usage: { |
| prompt_tokens: promptTokens, |
| completion_tokens: completionTokens, |
| total_tokens: promptTokens + completionTokens, |
| }, |
| }, |
| vqd: response.vqd |
| }; |
| } |
|
|
| private async createChatCompletionWithTools( |
| request: ChatCompletionRequest, |
| vqd?: string |
| ): Promise<{ completion: ChatCompletionResponse, vqd: string | null }> { |
| const id = this.generateId(); |
| const created = this.getCurrentTimestamp(); |
|
|
| |
| if (request.tools) { |
| const validation = this.toolService.validateTools(request.tools); |
| if (!validation.valid) { |
| throw new Error(`Invalid tools: ${validation.errors.join(", ")}`); |
| } |
| } |
|
|
| |
| const modifiedMessages = [...request.messages]; |
|
|
| |
| if (request.tools && request.tools.length > 0) { |
| const toolPrompt = this.toolService.generateToolSystemPrompt( |
| request.tools, |
| request.tool_choice |
| ); |
| modifiedMessages.unshift({ |
| role: "user", |
| content: `[SYSTEM INSTRUCTIONS] ${toolPrompt} |
| |
| Please follow these instructions when responding to the following user message.`, |
| }); |
| } |
|
|
| const duckAIRequest = this.transformToDuckAIRequest({ |
| ...request, |
| messages: modifiedMessages, |
| }, vqd); |
|
|
| const response = await this.duckAI.chat(duckAIRequest); |
| const content = response.message; |
|
|
| |
| if (this.toolService.detectFunctionCalls(content)) { |
| const toolCalls = this.toolService.extractFunctionCalls(content); |
|
|
| if (toolCalls.length > 0) { |
| |
| const promptText = modifiedMessages |
| .map((m) => m.content || "") |
| .join(" "); |
| const promptTokens = this.estimateTokens(promptText); |
| const completionTokens = this.estimateTokens(content); |
|
|
| return { |
| completion: { |
| id, |
| object: "chat.completion", |
| created, |
| model: request.model, |
| choices: [ |
| { |
| index: 0, |
| message: { |
| role: "assistant", |
| content: null, |
| tool_calls: toolCalls, |
| }, |
| finish_reason: "tool_calls", |
| }, |
| ], |
| usage: { |
| prompt_tokens: promptTokens, |
| completion_tokens: completionTokens, |
| total_tokens: promptTokens + completionTokens, |
| }, |
| }, |
| vqd: response.vqd |
| }; |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| const promptText = modifiedMessages.map((m) => m.content || "").join(" "); |
| const promptTokens = this.estimateTokens(promptText); |
| const completionTokens = this.estimateTokens(content); |
|
|
| return { |
| completion: { |
| id, |
| object: "chat.completion", |
| created, |
| model: request.model, |
| choices: [ |
| { |
| index: 0, |
| message: { |
| role: "assistant", |
| content: content, |
| }, |
| finish_reason: "stop", |
| }, |
| ], |
| usage: { |
| prompt_tokens: promptTokens, |
| completion_tokens: completionTokens, |
| total_tokens: promptTokens + completionTokens, |
| }, |
| }, |
| vqd: response.vqd |
| }; |
| } |
|
|
| async createChatCompletionStream( |
| request: ChatCompletionRequest, |
| vqd?: string |
| ): Promise<{ stream: ReadableStream<Uint8Array>, vqd: string | null }> { |
| |
| if ( |
| this.toolService.shouldUseFunctionCalling( |
| request.tools, |
| request.tool_choice |
| ) |
| ) { |
| |
| const result = await this.createChatCompletionWithTools(request, vqd); |
| |
| |
| } |
|
|
| const duckAIRequest = this.transformToDuckAIRequest(request, vqd); |
| const response = await this.duckAI.chatStream(duckAIRequest); |
|
|
| const id = this.generateId(); |
| const created = this.getCurrentTimestamp(); |
|
|
| const stream = new ReadableStream({ |
| start(controller) { |
| const reader = response.stream.getReader(); |
| let isFirst = true; |
|
|
| function pump(): Promise<void> { |
| return reader.read().then(({ done, value }) => { |
| if (done) { |
| |
| const finalChunk: ChatCompletionStreamResponse = { |
| id, |
| object: "chat.completion.chunk", |
| created, |
| model: request.model, |
| choices: [ |
| { |
| index: 0, |
| delta: {}, |
| finish_reason: "stop", |
| }, |
| ], |
| }; |
|
|
| const finalData = `data: ${JSON.stringify(finalChunk)}\n\n`; |
| const finalDone = `data: [DONE]\n\n`; |
|
|
| controller.enqueue(new TextEncoder().encode(finalData)); |
| controller.enqueue(new TextEncoder().encode(finalDone)); |
| controller.close(); |
| return; |
| } |
|
|
| const chunk: ChatCompletionStreamResponse = { |
| id, |
| object: "chat.completion.chunk", |
| created, |
| model: request.model, |
| choices: [ |
| { |
| index: 0, |
| delta: isFirst |
| ? { role: "assistant", content: value } |
| : { content: value }, |
| finish_reason: null, |
| }, |
| ], |
| }; |
|
|
| isFirst = false; |
| const data = `data: ${JSON.stringify(chunk)}\n\n`; |
| controller.enqueue(new TextEncoder().encode(data)); |
|
|
| return pump(); |
| }); |
| } |
|
|
| return pump(); |
| }, |
| }); |
|
|
| return { stream, vqd: response.vqd }; |
| } |
|
|
| private async createChatCompletionStreamWithTools( |
| request: ChatCompletionRequest |
| ): Promise<ReadableStream<Uint8Array>> { |
| |
| |
| const completion = await this.createChatCompletionWithTools(request); |
|
|
| const id = completion.id; |
| const created = completion.created; |
|
|
| return new ReadableStream({ |
| start(controller) { |
| const choice = completion.choices[0]; |
|
|
| if (choice.message.tool_calls) { |
| |
| const toolCallsChunk: ChatCompletionStreamResponse = { |
| id, |
| object: "chat.completion.chunk", |
| created, |
| model: request.model, |
| choices: [ |
| { |
| index: 0, |
| delta: { |
| role: "assistant", |
| tool_calls: choice.message.tool_calls, |
| }, |
| finish_reason: null, |
| }, |
| ], |
| }; |
|
|
| const toolCallsData = `data: ${JSON.stringify(toolCallsChunk)}\n\n`; |
| controller.enqueue(new TextEncoder().encode(toolCallsData)); |
|
|
| |
| const finalChunk: ChatCompletionStreamResponse = { |
| id, |
| object: "chat.completion.chunk", |
| created, |
| model: request.model, |
| choices: [ |
| { |
| index: 0, |
| delta: {}, |
| finish_reason: "tool_calls", |
| }, |
| ], |
| }; |
|
|
| const finalData = `data: ${JSON.stringify(finalChunk)}\n\n`; |
| const finalDone = `data: [DONE]\n\n`; |
|
|
| controller.enqueue(new TextEncoder().encode(finalData)); |
| controller.enqueue(new TextEncoder().encode(finalDone)); |
| } else { |
| |
| const content = choice.message.content || ""; |
|
|
| |
| const roleChunk: ChatCompletionStreamResponse = { |
| id, |
| object: "chat.completion.chunk", |
| created, |
| model: request.model, |
| choices: [ |
| { |
| index: 0, |
| delta: { role: "assistant" }, |
| finish_reason: null, |
| }, |
| ], |
| }; |
|
|
| const roleData = `data: ${JSON.stringify(roleChunk)}\n\n`; |
| controller.enqueue(new TextEncoder().encode(roleData)); |
|
|
| |
| const chunkSize = 10; |
| for (let i = 0; i < content.length; i += chunkSize) { |
| const contentChunk = content.slice(i, i + chunkSize); |
|
|
| const chunk: ChatCompletionStreamResponse = { |
| id, |
| object: "chat.completion.chunk", |
| created, |
| model: request.model, |
| choices: [ |
| { |
| index: 0, |
| delta: { content: contentChunk }, |
| finish_reason: null, |
| }, |
| ], |
| }; |
|
|
| const data = `data: ${JSON.stringify(chunk)}\n\n`; |
| controller.enqueue(new TextEncoder().encode(data)); |
| } |
|
|
| |
| const finalChunk: ChatCompletionStreamResponse = { |
| id, |
| object: "chat.completion.chunk", |
| created, |
| model: request.model, |
| choices: [ |
| { |
| index: 0, |
| delta: {}, |
| finish_reason: "stop", |
| }, |
| ], |
| }; |
|
|
| const finalData = `data: ${JSON.stringify(finalChunk)}\n\n`; |
| const finalDone = `data: [DONE]\n\n`; |
|
|
| controller.enqueue(new TextEncoder().encode(finalData)); |
| controller.enqueue(new TextEncoder().encode(finalDone)); |
| } |
|
|
| controller.close(); |
| }, |
| }); |
| } |
|
|
| getModels(): ModelsResponse { |
| const models = this.duckAI.getAvailableModels(); |
| const created = this.getCurrentTimestamp(); |
|
|
| const modelData: Model[] = models.map((modelId) => ({ |
| id: modelId, |
| object: "model", |
| created, |
| owned_by: "duckai", |
| })); |
|
|
| return { |
| object: "list", |
| data: modelData, |
| }; |
| } |
|
|
| validateRequest(request: any): ChatCompletionRequest { |
| if (!request.messages || !Array.isArray(request.messages)) { |
| throw new Error("messages field is required and must be an array"); |
| } |
|
|
| if (request.messages.length === 0) { |
| throw new Error("messages array cannot be empty"); |
| } |
|
|
| for (const message of request.messages) { |
| if ( |
| !message.role || |
| !["system", "user", "assistant", "tool"].includes(message.role) |
| ) { |
| throw new Error( |
| "Each message must have a valid role (system, user, assistant, or tool)" |
| ); |
| } |
|
|
| |
| if (message.role === "tool") { |
| if (!message.tool_call_id) { |
| throw new Error("Tool messages must have a tool_call_id"); |
| } |
| if (typeof message.content !== "string") { |
| throw new Error("Tool messages must have content as a string"); |
| } |
| } else { |
| |
| if ( |
| message.content === undefined || |
| (message.content !== null && typeof message.content !== "string") |
| ) { |
| throw new Error("Each message must have content as a string or null"); |
| } |
| } |
| } |
|
|
| |
| if (request.tools) { |
| const validation = this.toolService.validateTools(request.tools); |
| if (!validation.valid) { |
| throw new Error(`Invalid tools: ${validation.errors.join(", ")}`); |
| } |
| } |
|
|
| return { |
| model: request.model || "mistralai/Mistral-Small-24B-Instruct-2501", |
| messages: request.messages, |
| temperature: request.temperature, |
| max_tokens: request.max_tokens, |
| stream: request.stream || false, |
| top_p: request.top_p, |
| frequency_penalty: request.frequency_penalty, |
| presence_penalty: request.presence_penalty, |
| stop: request.stop, |
| tools: request.tools, |
| tool_choice: request.tool_choice, |
| }; |
| } |
|
|
| async executeToolCall(toolCall: ToolCall): Promise<string> { |
| return this.toolService.executeFunctionCall( |
| toolCall, |
| this.availableFunctions |
| ); |
| } |
|
|
| |
| |
| |
| getRateLimitStatus() { |
| return this.duckAI.getRateLimitStatus(); |
| } |
| } |
|
|