/** * OpenAI Client implementation */ import { InvokeError, InvokeErrorType } from './errors' import type { InvokeOptions, InvokeResult, LLMClient, LLMConfig, Message, Tool } from './types' import { modelPatch, zodToOpenAITool } from './utils' /** * Client for OpenAI compatible APIs */ export class OpenAIClient implements LLMClient { config: Required private fetch: typeof globalThis.fetch constructor(config: Required) { this.config = config this.fetch = config.customFetch } async invoke( messages: Message[], tools: Record, abortSignal?: AbortSignal, options?: InvokeOptions ): Promise { // 1. Convert tools to OpenAI format const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t)) // Build request body const requestBody: Record = { model: this.config.model, temperature: this.config.temperature, messages, tools: openaiTools, parallel_tool_calls: false, // Require tool call: specific tool if provided, otherwise any tool tool_choice: options?.toolChoiceName ? { type: 'function', function: { name: options.toolChoiceName } } : 'required', } // 2. Call API let response: Response try { response = await this.fetch(`${this.config.baseURL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.config.apiKey}`, }, body: JSON.stringify(modelPatch(requestBody)), signal: abortSignal, }) } catch (error: unknown) { console.error(error) throw new InvokeError(InvokeErrorType.NETWORK_ERROR, 'Network request failed', error) } // 3. Handle HTTP errors if (!response.ok) { const errorData = await response.json().catch() const errorMessage = (errorData as { error?: { message?: string } }).error?.message || response.statusText if (response.status === 401 || response.status === 403) { throw new InvokeError( InvokeErrorType.AUTH_ERROR, `Authentication failed: ${errorMessage}`, errorData ) } if (response.status === 429) { throw new InvokeError( InvokeErrorType.RATE_LIMIT, `Rate limit exceeded: ${errorMessage}`, errorData ) } if (response.status >= 500) { throw new InvokeError( InvokeErrorType.SERVER_ERROR, `Server error: ${errorMessage}`, errorData ) } throw new InvokeError( InvokeErrorType.UNKNOWN, `HTTP ${response.status}: ${errorMessage}`, errorData ) } // 4. Parse and validate response const data = await response.json() const choice = data.choices?.[0] if (!choice) { throw new InvokeError(InvokeErrorType.UNKNOWN, 'No choices in response', data) } // Check finish_reason switch (choice.finish_reason) { case 'tool_calls': case 'function_call': // gemini case 'stop': // some models use this even with tool calls break case 'length': throw new InvokeError( InvokeErrorType.CONTEXT_LENGTH, 'Response truncated: max tokens reached' ) case 'content_filter': throw new InvokeError(InvokeErrorType.CONTENT_FILTER, 'Content filtered by safety system') default: throw new InvokeError( InvokeErrorType.UNKNOWN, `Unexpected finish_reason: ${choice.finish_reason}` ) } // Apply normalizeResponse if provided (for fixing format issues automatically) const normalizedData = options?.normalizeResponse ? options.normalizeResponse(data) : data const normalizedChoice = (normalizedData as any).choices?.[0] // Get tool name from response const toolCallName = normalizedChoice?.message?.tool_calls?.[0]?.function?.name if (!toolCallName) { throw new InvokeError( InvokeErrorType.NO_TOOL_CALL, 'No tool call found in response', normalizedData ) } const tool = tools[toolCallName] if (!tool) { throw new InvokeError( InvokeErrorType.UNKNOWN, `Tool "${toolCallName}" not found in tools`, normalizedData ) } // Extract and parse tool arguments const argString = normalizedChoice.message?.tool_calls?.[0]?.function?.arguments if (!argString) { throw new InvokeError( InvokeErrorType.INVALID_TOOL_ARGS, 'No tool call arguments found', normalizedData ) } let parsedArgs: unknown try { parsedArgs = JSON.parse(argString) } catch (error) { throw new InvokeError( InvokeErrorType.INVALID_TOOL_ARGS, 'Failed to parse tool arguments as JSON', error ) } // Validate with schema const validation = tool.inputSchema.safeParse(parsedArgs) if (!validation.success) { throw new InvokeError( InvokeErrorType.INVALID_TOOL_ARGS, 'Tool arguments validation failed', validation.error ) } const toolInput = validation.data // 5. Execute tool let toolResult: unknown try { toolResult = await tool.execute(toolInput) } catch (e) { throw new InvokeError( InvokeErrorType.TOOL_EXECUTION_ERROR, `Tool execution failed: ${(e as Error).message}`, e ) } // Return result return { toolCall: { name: toolCallName, args: toolInput, }, toolResult, usage: { promptTokens: data.usage?.prompt_tokens ?? 0, completionTokens: data.usage?.completion_tokens ?? 0, totalTokens: data.usage?.total_tokens ?? 0, cachedTokens: data.usage?.prompt_tokens_details?.cached_tokens, reasoningTokens: data.usage?.completion_tokens_details?.reasoning_tokens, }, rawResponse: data, } } }