206 lines
5.4 KiB
TypeScript
206 lines
5.4 KiB
TypeScript
/**
|
|
* 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<LLMConfig>
|
|
private fetch: typeof globalThis.fetch
|
|
|
|
constructor(config: Required<LLMConfig>) {
|
|
this.config = config
|
|
this.fetch = config.customFetch
|
|
}
|
|
|
|
async invoke(
|
|
messages: Message[],
|
|
tools: Record<string, Tool>,
|
|
abortSignal?: AbortSignal,
|
|
options?: InvokeOptions
|
|
): Promise<InvokeResult> {
|
|
// 1. Convert tools to OpenAI format
|
|
const openaiTools = Object.entries(tools).map(([name, t]) => zodToOpenAITool(name, t))
|
|
|
|
// Build request body
|
|
const requestBody: Record<string, unknown> = {
|
|
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,
|
|
}
|
|
}
|
|
}
|