donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
474 lines • 20.1 kB
JavaScript
;
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