capsule-ai-cli
Version:
The AI Model Orchestrator - Intelligent multi-model workflows with device-locked licensing
489 lines • 21.6 kB
JavaScript
import { BaseProvider } from './base.js';
export class OpenAIProvider extends BaseProvider {
name = 'openai';
models = [
'gpt-4o',
'gpt-4.1',
'o3',
'o4-mini',
];
supportsStreaming = true;
supportsTools = true;
reasoningModels = ['o3', 'o4-mini'];
pricing = {
'gpt-4o': { prompt: 2.50, completion: 10.00 },
'gpt-4.1': { prompt: 2.00, completion: 8.00 },
'o3': { prompt: 2.00, completion: 8.00 },
'o4-mini': { prompt: 1.10, completion: 4.40 },
};
constructor(apiKey, baseUrl) {
super(apiKey, baseUrl);
}
async complete(messages, options = {}) {
this.validateMessages(messages);
const model = options.model || 'gpt-4o';
const isReasoningModel = this.reasoningModels.includes(model);
try {
const requestBody = {
model,
input: messages.length === 1 && messages[0].role === 'user' && typeof messages[0].content === 'string'
? messages[0].content
: this.formatMessagesForResponsesAPI(messages),
temperature: options.temperature,
max_tokens: options.maxTokens
};
if (options.tools && options.tools.length > 0) {
requestBody.tools = this.formatTools(options.tools);
requestBody.tool_choice = 'auto';
}
if (isReasoningModel) {
requestBody.reasoning = {
effort: 'high',
summary: 'auto'
};
}
const response = await fetch(this.baseUrl || 'https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`OpenAI API error: ${response.status} - ${errorData.error?.message || response.statusText}`);
}
const data = await response.json();
return this.parseResponsesAPIOutput(data, model);
}
catch (error) {
if (error.message?.includes('429')) {
await this.handleRateLimit(60);
return this.complete(messages, options);
}
throw new Error(`OpenAI API error: ${error.message}`);
}
}
async *stream(messages, options = {}) {
this.validateMessages(messages);
const model = options.model || 'gpt-4o';
const isReasoningModel = this.reasoningModels.includes(model);
if (isReasoningModel) {
try {
const response = await this.complete(messages, { ...options, stream: false });
if (response.reasoning) {
if (options.signal?.aborted)
return;
yield {
delta: '💭 Thinking: ' + response.reasoning + '\n\n',
reasoning: true,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
};
}
if (response.toolCalls && response.toolCalls.length > 0) {
for (const toolCall of response.toolCalls) {
yield {
delta: '',
toolCall: toolCall
};
}
}
const chunkSize = 20;
for (let i = 0; i < response.content.length; i += chunkSize) {
if (options.signal?.aborted)
return;
const chunk = response.content.slice(i, i + chunkSize);
yield {
delta: chunk,
usage: {
promptTokens: 0,
completionTokens: Math.ceil(i / 4),
totalTokens: Math.ceil(i / 4)
}
};
await new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, 30);
if (options.signal) {
const abortHandler = () => {
clearTimeout(timeout);
reject(new DOMException('Aborted', 'AbortError'));
};
if (options.signal.aborted) {
abortHandler();
}
else {
options.signal.addEventListener('abort', abortHandler, { once: true });
}
}
});
}
return;
}
catch (error) {
if (error.name === 'AbortError') {
return;
}
throw error;
}
}
const requestBody = {
model,
input: messages.length === 1 && messages[0].role === 'user' && typeof messages[0].content === 'string'
? messages[0].content
: this.formatMessagesForResponsesAPI(messages),
temperature: options.temperature,
max_tokens: options.maxTokens,
stream: true
};
if (options.tools && options.tools.length > 0) {
requestBody.tools = this.formatTools(options.tools);
requestBody.tool_choice = 'auto';
}
if (isReasoningModel) {
requestBody.reasoning = {
effort: 'high',
summary: 'auto'
};
}
try {
if (process.env.DEBUG) {
console.log('OpenAI Responses API Request:', JSON.stringify(requestBody, null, 2));
if (requestBody.tools) {
console.log('Tools being sent:', JSON.stringify(requestBody.tools, null, 2));
}
}
const response = await fetch(this.baseUrl || 'https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
signal: options.signal
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`OpenAI API error: ${response.status} - ${errorData.error?.message || response.statusText}`);
}
if (!response.body) {
throw new Error('No response body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let totalTokens = 0;
while (true) {
const { done, value } = await reader.read();
if (done)
break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]')
continue;
try {
const parsed = JSON.parse(data);
if (process.env.DEBUG && parsed.type && !['response.function_call_arguments.delta', 'response.output_item.added', 'response.output_item.done', 'response.text.delta', 'response.content_part.delta', 'response.done'].includes(parsed.type)) {
console.log('Unhandled streaming event type:', parsed.type);
}
if (parsed.type === 'response.done') {
const usage = parsed.response?.usage || parsed.usage;
if (usage) {
yield {
delta: '',
usage: {
promptTokens: usage.input_tokens || 0,
completionTokens: usage.output_tokens || 0,
totalTokens: usage.total_tokens || 0
}
};
}
}
if (parsed.type === 'response.function_call_arguments.delta') {
}
else if (parsed.type === 'response.output_item.added' && parsed.item?.type === 'function_call') {
}
else if (parsed.type === 'response.output_item.done' && parsed.item?.type === 'function_call') {
const args = parsed.item.arguments ? JSON.parse(parsed.item.arguments) : {};
yield {
delta: '',
toolCall: {
id: parsed.item.call_id || parsed.item.id,
name: parsed.item.name,
arguments: args
}
};
}
else if (parsed.type === 'response.output_item.added' && parsed.item?.type === 'message') {
if (parsed.item.content && parsed.item.content[0]?.text) {
const text = parsed.item.content[0].text;
totalTokens += this.countTokens(text);
yield {
delta: text,
usage: {
promptTokens: 0,
completionTokens: totalTokens,
totalTokens
}
};
}
}
else if (parsed.type === 'response.output_item.done' && parsed.item?.type === 'message') {
if (parsed.item.content && parsed.item.content[0]?.text) {
const text = parsed.item.content[0].text;
totalTokens += this.countTokens(text);
yield {
delta: text,
usage: {
promptTokens: 0,
completionTokens: totalTokens,
totalTokens
}
};
}
}
else if (parsed.type === 'response.text.delta' && parsed.delta) {
totalTokens += this.countTokens(parsed.delta);
yield {
delta: parsed.delta,
usage: {
promptTokens: 0,
completionTokens: totalTokens,
totalTokens
}
};
}
else if (parsed.type === 'response.content_part.delta' && parsed.delta?.text) {
totalTokens += this.countTokens(parsed.delta.text);
yield {
delta: parsed.delta.text,
usage: {
promptTokens: 0,
completionTokens: totalTokens,
totalTokens
}
};
}
else if (parsed.choices && parsed.choices[0]) {
const choice = parsed.choices[0];
if (choice.delta?.content) {
totalTokens += this.countTokens(choice.delta.content);
yield {
delta: choice.delta.content,
usage: {
promptTokens: 0,
completionTokens: totalTokens,
totalTokens
}
};
}
}
}
catch (e) {
console.error('Failed to parse streaming chunk:', e);
}
}
}
}
}
catch (error) {
if (error.name === 'AbortError') {
throw error;
}
if (isReasoningModel && error.message?.includes('streaming')) {
const response = await this.complete(messages, options);
if (response.reasoning) {
if (options.signal?.aborted)
return;
yield {
delta: '💭 Thinking: ' + response.reasoning + '\n\n',
reasoning: true,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
};
}
const chunkSize = 20;
for (let i = 0; i < response.content.length; i += chunkSize) {
if (options.signal?.aborted)
return;
const chunk = response.content.slice(i, i + chunkSize);
yield {
delta: chunk,
usage: {
promptTokens: 0,
completionTokens: Math.ceil(i / 4),
totalTokens: Math.ceil(i / 4)
}
};
await new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, 30);
if (options.signal) {
options.signal.addEventListener('abort', () => {
clearTimeout(timeout);
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
}
});
}
return;
}
throw error;
}
}
calculateCost(usage, model = 'gpt-4o') {
const pricing = this.pricing[model] ||
this.pricing['gpt-4o'];
const promptCost = (usage.promptTokens / 1000000) * pricing.prompt;
const completionCost = (usage.completionTokens / 1000000) * pricing.completion;
return {
amount: promptCost + completionCost,
currency: 'USD',
breakdown: {
prompt: promptCost,
completion: completionCost
}
};
}
formatTools(tools) {
return tools;
}
formatMessageContent(content) {
if (typeof content === 'string') {
return content;
}
if (content.length === 1 && content[0].type === 'text') {
return content[0].text;
}
const formatted = [];
for (const item of content) {
switch (item.type) {
case 'text':
formatted.push({
type: 'input_text',
text: item.text
});
break;
case 'image':
formatted.push({
type: 'input_image',
image_url: `data:${item.source.media_type};base64,${item.source.data}`
});
break;
case 'file':
formatted.push({
type: 'input_file',
file_url: `data:${item.media_type || 'application/pdf'};base64,${item.data}`
});
break;
}
}
return formatted;
}
formatMessagesForResponsesAPI(messages) {
const formatted = [];
const toolCallIds = new Set();
const toolResultIds = new Set();
for (const msg of messages) {
if (msg.tool_calls) {
for (const toolCall of msg.tool_calls) {
toolCallIds.add(toolCall.id);
}
}
if (msg.role === 'tool_result' && msg.tool_call_id) {
toolResultIds.add(msg.tool_call_id);
}
}
const orphanedResults = Array.from(toolResultIds).filter(id => !toolCallIds.has(id));
if (orphanedResults.length > 0) {
console.error('Warning: Found orphaned tool results without matching tool calls:', orphanedResults);
console.error('Tool call IDs:', Array.from(toolCallIds));
console.error('Tool result IDs:', Array.from(toolResultIds));
}
for (const msg of messages) {
if (msg.role === 'tool_result' && msg.tool_call_id) {
if (toolCallIds.has(msg.tool_call_id)) {
formatted.push({
type: 'function_call_output',
call_id: msg.tool_call_id,
output: typeof msg.content === 'string' ? msg.content : this.getTextContent(msg)
});
}
else {
console.error(`Skipping orphaned tool result with call_id: ${msg.tool_call_id}`);
}
}
else if (msg.role === 'assistant') {
if (msg.content && msg.content !== '') {
formatted.push({
role: 'assistant',
content: this.formatMessageContent(msg.content)
});
}
if (msg.tool_calls) {
for (const toolCall of msg.tool_calls) {
formatted.push({
type: 'function_call',
call_id: toolCall.id,
name: toolCall.name,
arguments: typeof toolCall.arguments === 'string'
? toolCall.arguments
: JSON.stringify(toolCall.arguments)
});
}
}
}
else {
formatted.push({
role: msg.role,
content: this.formatMessageContent(msg.content)
});
}
}
return formatted;
}
parseResponsesAPIOutput(data, model) {
let content = '';
let reasoningSummary = '';
let toolCalls = [];
for (const item of (data.output || [])) {
switch (item.type) {
case 'message':
if (item.content && item.content[0]) {
content = item.content[0].text || '';
}
break;
case 'reasoning':
if (item.summary && item.summary[0]) {
reasoningSummary = item.summary[0].text || '';
}
break;
case 'function_call':
toolCalls.push({
id: item.call_id || item.id,
name: item.name,
arguments: typeof item.arguments === 'string'
? JSON.parse(item.arguments)
: item.arguments
});
break;
}
}
const usage = {
promptTokens: data.usage?.input_tokens || 0,
completionTokens: data.usage?.output_tokens || 0,
totalTokens: data.usage?.total_tokens || 0
};
return {
content,
usage,
model,
provider: this.name,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
reasoning: reasoningSummary || undefined
};
}
}
//# sourceMappingURL=openai.js.map