UNPKG

donobu

Version:

Create browser automations with an LLM agent and replay them as Playwright scripts.

474 lines 20.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.OpenAiGptClient = void 0; const v4_1 = require("zod/v4"); const GptModelNotFoundException_1 = require("../exceptions/GptModelNotFoundException"); const GptPlatformAuthenticationFailedException_1 = require("../exceptions/GptPlatformAuthenticationFailedException"); const GptPlatformInsufficientQuotaException_1 = require("../exceptions/GptPlatformInsufficientQuotaException"); const GptPlatformInternalErrorException_1 = require("../exceptions/GptPlatformInternalErrorException"); const GptPlatformNotReachableException_1 = require("../exceptions/GptPlatformNotReachableException"); const InvalidParamValueException_1 = require("../exceptions/InvalidParamValueException"); const JsonUtils_1 = require("../utils/JsonUtils"); const Logger_1 = require("../utils/Logger"); const MiscUtils_1 = require("../utils/MiscUtils"); const GptClient_1 = require("./GptClient"); /** * A GPT client implemented using the OpenAI API. * @see https://platform.openai.com/docs/api-reference/chat */ class OpenAiGptClient extends GptClient_1.GptClient { constructor(openAiConfig, apiUrl = OpenAiGptClient.DEFAULT_API_URL) { super(openAiConfig); this.openAiConfig = openAiConfig; this.apiUrl = apiUrl; if (!/^[\x21-\x7e]{1,128}$/.test(openAiConfig.apiKey)) { throw new InvalidParamValueException_1.InvalidParamValueException('apiKey', 'REDACTED', 'it is malformed'); } this.headers = new Headers({ Authorization: `Bearer ${openAiConfig.apiKey}`, 'Content-Type': 'application/json', }); } async ping(options) { const resp = await this.makeRequest(`/v1/models/${this.openAiConfig.modelName}`, 'GET', undefined, options?.signal); if (resp.status === 404) { throw new GptModelNotFoundException_1.GptModelNotFoundException(this.config.type, this.openAiConfig.modelName); } else if (resp.status !== 200) { throw await this.mapErrorResponseToDonobuException(resp); } } async getMessage(messages, options) { const body = { model: this.openAiConfig.modelName, messages: messages.map((msg) => this.chatRequestMessageFromGptMessage(msg)), }; const resp = await this.makeRequest('/v1/chat/completions', 'POST', body, options?.signal); if (resp.status !== 200) { throw await this.mapErrorResponseToDonobuException(resp); } else { const data = await resp.json(); return this.parseAssistantMessage(data); } } async getStructuredOutput(messages, zodSchema, options) { const jsonSchema = v4_1.z.toJSONSchema(zodSchema); const schemaName = zodSchema._def?.typeName || 'output'; const body = { model: this.openAiConfig.modelName, response_format: { type: 'json_schema', json_schema: { name: schemaName, strict: true, schema: OpenAiGptClient.createOpenAiCompatibleJsonSchema(jsonSchema), }, }, messages: messages.map((msg) => this.chatRequestMessageFromGptMessage(msg)), }; const resp = await this.makeRequest('/v1/chat/completions', 'POST', body, options?.signal); if (resp.status !== 200) { throw await this.mapErrorResponseToDonobuException(resp); } else { const data = await resp.json(); const parsedMessage = this.parseStructuredOutputMessage(data); // Validate the output against the original Zod schema const validatedOutput = (0, GptClient_1.parseOrLogAndThrow)(parsedMessage.output, zodSchema); return { ...parsedMessage, output: validatedOutput, }; } } async getToolCalls(messages, tools, options) { const body = { model: this.openAiConfig.modelName, tool_choice: 'required', tools: tools.length > 0 ? tools.map((tool) => this.toolChoiceFromTool(tool)) : undefined, messages: messages.map((msg) => this.chatRequestMessageFromGptMessage(msg)), }; const resp = await this.makeRequest('/v1/chat/completions', 'POST', body, options?.signal); if (resp.status !== 200) { throw await this.mapErrorResponseToDonobuException(resp); } else { const data = await resp.json(); return this.parseProposedToolCallsMessage(tools, data); } } chatRequestMessageFromGptMessage(gptMessage) { if (gptMessage.type === 'assistant') { return { role: 'assistant', content: { type: 'text', text: gptMessage.text }, }; } if (gptMessage.type === 'structured_output') { return { role: 'assistant', content: { type: 'text', text: JSON.stringify(gptMessage.output, null, 2), }, }; } if (gptMessage.type === 'proposed_tool_calls') { return { role: 'assistant', tool_calls: gptMessage.proposedToolCalls.map((tc) => ({ id: tc.toolCallId, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.parameters), }, })), }; } if (gptMessage.type === 'system') { return { role: 'system', content: gptMessage.text, }; } if (gptMessage.type === 'user') { return { role: 'user', content: gptMessage.items.map((item) => { if ('bytes' in item) { const imageType = MiscUtils_1.MiscUtils.detectImageType(item.bytes); const mimeType = `image/${imageType}`; return { type: 'image_url', image_url: { url: `data:${mimeType};base64,${Buffer.from(item.bytes).toString('base64')}`, }, }; } else { // Text return { type: 'text', text: item.text, }; } }), }; } if (gptMessage.type === 'tool_call_result') { return { role: 'tool', content: gptMessage.data, tool_call_id: gptMessage.toolCallId, }; } throw new Error(`Unsupported message type: ${JsonUtils_1.JsonUtils.objectToJson(gptMessage)}`); } toolChoiceFromTool(tool) { const jsonSchema = v4_1.z.toJSONSchema(tool.inputSchema); return { type: 'function', function: { name: tool.name, description: tool.description.trim(), parameters: OpenAiGptClient.createOpenAiCompatibleJsonSchema(jsonSchema), }, }; } parseAssistantMessage(response) { const text = response.choices[0].message.content; const promptTokensUsed = response.usage.prompt_tokens; const completionTokensUsed = response.usage.completion_tokens; return { type: 'assistant', text: text, promptTokensUsed: promptTokensUsed, completionTokensUsed: completionTokensUsed, }; } parseStructuredOutputMessage(response) { const text = response.choices[0].message.content; const output = JSON.parse(text); const promptTokensUsed = response.usage.prompt_tokens; const completionTokensUsed = response.usage.completion_tokens; return { type: 'structured_output', output: output, promptTokensUsed: promptTokensUsed, completionTokensUsed: completionTokensUsed, }; } parseProposedToolCallsMessage(tools, response) { const toolCalls = response.choices[0].message.tool_calls; if (!toolCalls?.length) { throw new Error(`No tool calls found in response: ${JSON.stringify(response)}`); } const proposedCalls = toolCalls.map((tc) => { const functionName = tc.function.name; const parameters = JSON.parse(tc.function.arguments); const toolCallId = tc.id; const tool = tools.find((t) => t.name === functionName); if (!tool) { throw new Error(`Tool not found: ${functionName}`); } return { name: functionName, parameters: parameters, toolCallId: toolCallId, }; }); const promptTokensUsed = response.usage.prompt_tokens; const completionTokensUsed = response.usage.completion_tokens; return { type: 'proposed_tool_calls', proposedToolCalls: proposedCalls, promptTokensUsed: promptTokensUsed, completionTokensUsed: completionTokensUsed, }; } async mapErrorResponseToDonobuException(error) { try { const errorData = await error.json(); Logger_1.appLogger.error(`OpenAI error response: ${JSON.stringify(JsonUtils_1.JsonUtils.objectToJson(errorData))}`); // Handle authentication errors if (errorData.error?.code === 'invalid_api_key') { return new GptPlatformAuthenticationFailedException_1.GptPlatformAuthenticationFailedException(this.config.type); } else if (error.status === 402) { return new GptPlatformInsufficientQuotaException_1.GptPlatformInsufficientQuotaException(this.config.type); } else if (errorData.error?.code === 'model_not_found') { return new GptModelNotFoundException_1.GptModelNotFoundException(this.config.type, this.openAiConfig.modelName); } else { return new GptPlatformInternalErrorException_1.GptPlatformInternalErrorException(errorData.error?.message || `HTTP ${error.status}: ${error.statusText}`); } } catch (_) { Logger_1.appLogger.error(`Failed to parse ${this.config.type} error response: HTTP ${error.status}: ${error.statusText}`); return new GptPlatformInternalErrorException_1.GptPlatformInternalErrorException(`HTTP ${error.status}: ${error.statusText}`); } } /** * Makes an HTTP request to the OpenAI API with standard configuration. */ async makeRequest(endpoint, method = 'GET', body, signal) { const stringyBody = body ? JSON.stringify(body) : undefined; const abortSignal = signal || AbortSignal.timeout(OpenAiGptClient.REQUEST_TIMEOUT_MILLISECONDS); try { return await fetch(`${this.apiUrl}${endpoint}`, { method, headers: this.headers, body: stringyBody, signal: abortSignal, }); } catch (error) { // 'fetch' throws a TypeError when it cannot even start/complete the HTTP request: // - DNS lookup failures. // - TLS handshake failures. // - connection resets/refusals. // - invalid URL such as a bad scheme/hostname. if (error instanceof TypeError) { Logger_1.appLogger.error('Failed to reach LLM provider due to exception', error); throw new GptPlatformNotReachableException_1.GptPlatformNotReachableException(this.config.type); } else { throw error; } } } /** * Transform a general JSON Schema into one that fits the OpenAI Structured Outputs subset. * Key behaviors: * - Enforce additionalProperties:false on objects and require all defined properties. * - Remove unsupported validation keywords. * - Convert tuple `items: [A,B]` into `items: { anyOf: [A,B] }`. * - Preserve $ref and $defs, cleaning nested definitions recursively. * - If the root is `anyOf`, wrap it into an object schema with a single `value` property. * * Notes for optional fields: * - Since all fields must be required, emulate optionals with union types that include "null". * e.g., `type: ["string", "null"]` while keeping the field in `required`. */ static createOpenAiCompatibleJsonSchema(schema) { // Defensive deep clone to avoid mutating the caller's object. const clone = (obj) => obj === null || typeof obj !== 'object' ? obj : Array.isArray(obj) ? obj.map(clone) : Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, clone(v)])); // Set of keywords we will strip because they are not supported or risky per docs. // (Docs list these as unsupported in Structured Outputs.) const STRIP_KEYS = new Set([ // string 'minLength', 'maxLength', 'pattern', 'format', 'contentMediaType', 'contentEncoding', 'contentSchema', // number 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', // object 'patternProperties', 'unevaluatedProperties', 'propertyNames', 'minProperties', 'maxProperties', 'dependentRequired', 'dependentSchemas', // arrays 'unevaluatedItems', 'contains', 'minContains', 'maxContains', 'minItems', 'maxItems', 'uniqueItems', 'additionalItems', 'prefixItems', // schema/meta rarely needed for model guidance '$dynamicRef', '$dynamicAnchor', '$vocabulary', 'allOf', // conservative: remove; can be rewritten as anyOf in many cases 'oneOf', // conservative: remove; prefer anyOf 'not', // conservative: remove 'if', 'then', 'else', ]); // Normalize `items`: // - if array (tuple validation), convert to anyOf branch for a simpler, supported pattern. const normalizeItems = (items) => { if (Array.isArray(items)) { return { anyOf: items.map((s) => cleanSchema(s)), }; } return cleanSchema(items); }; // Ensure object properties are all required and additionalProperties:false const normalizeObject = (s) => { const out = s; if (!out.properties) { out.properties = {}; } // Enforce additionalProperties:false per docs out.additionalProperties = false; // Compute required = all keys in properties (OpenAI requires all params/fields required) const propKeys = Object.keys(out.properties); out.required = Array.from(new Set([...(out.required ?? []), ...propKeys])); // Recurse into each property for (const [key, subschema] of Object.entries(out.properties)) { if (typeof subschema === 'boolean') { // Convert boolean schemas to permissive object (true) or impossible (false). // We'll treat `true` as `{}` and `false` as `{ "enum": [] }` which is unmatchable. out.properties[key] = subschema ? {} : { enum: [] }; } else { out.properties[key] = cleanSchema(subschema); } } return out; }; // Core cleaner: strip unsupported keys, normalize arrays/objects/anyOf/$defs. const cleanSchema = (input) => { if (input === undefined) { return {}; } if (typeof input === 'boolean') { return input ? {} : { enum: [] }; // keep shape valid; `enum: []` is unsatisfiable. } let s = clone(input); // Remove unsupported keys at this level for (const k of Object.keys(s)) { if (STRIP_KEYS.has(k)) { delete s[k]; } } // Clean $defs recursively if (s.$defs) { for (const [defKey, defSchema] of Object.entries(s.$defs)) { s.$defs[defKey] = cleanSchema(defSchema); } } // Handle anyOf branches if (s.anyOf) { s.anyOf = s.anyOf.map((branch) => cleanSchema(branch)); } // Keep enum/const/description/title/$ref as-is. // Normalize arrays if (s.type === 'array') { if (s.items !== undefined) { s.items = normalizeItems(s.items); } else { // Provide a permissive default for items to avoid undefined behavior. s.items = {}; } } // Normalize objects if (s.type === 'object') { s = normalizeObject(s); } // If `type` is a union array, keep it (this is how optionals are expressed: includes "null") // Nothing to do here; we just don't strip it. // If `properties` exists but `type` is missing, make it an object to be explicit if (!s.type && s.properties) { s.type = 'object'; s = normalizeObject(s); } // If we still have tuple constructs in "items" (e.g., array of schemas after clean), ensure we normalized if (Array.isArray(s.items)) { s.items = normalizeItems(s.items); } return s; }; // Start by cleaning the provided schema let cleaned = cleanSchema(schema); // Root cannot be `anyOf`: wrap if necessary if (cleaned && !cleaned.type && cleaned.anyOf && Object.keys(cleaned).every((k) => k === 'anyOf' || k === '$defs' || k === 'description' || k === 'title')) { cleaned = { type: 'object', properties: { value: { anyOf: cleaned.anyOf.map((b) => cleanSchema(b)) }, }, required: ['value'], additionalProperties: false, ...(cleaned.$defs ? { $defs: cleaned.$defs } : {}), ...(cleaned.title ? { title: cleaned.title } : {}), ...(cleaned.description ? { description: cleaned.description } : {}), }; } // Final safety: if an object lacks properties, make properties:{} and still enforce additionalProperties:false if (cleaned.type === 'object' && !cleaned.properties) { cleaned.properties = {}; cleaned.required = []; cleaned.additionalProperties = false; } return cleaned; } } exports.OpenAiGptClient = OpenAiGptClient; OpenAiGptClient.DEFAULT_API_URL = 'https://api.openai.com'; OpenAiGptClient.REQUEST_TIMEOUT_MILLISECONDS = 120000; //# sourceMappingURL=OpenAiGptClient.js.map