@convo-lang/convo-lang
Version:
The language of AI
435 lines • 16.3 kB
JavaScript
import { NotFoundError, getErrorMessage, httpClient, joinPaths, shortUuid, uuid } from "@iyio/common";
export class BaseOpenAiConvoCompletionService {
constructor({ apiKey, secretManager, secretsName, apiBaseUrl = 'https://api.openai.com', completionsEndpoint = '/v1/chat/completions', inputType, outputType, models, isFallback = false, apiKeyHeader = 'Authorization', apiKeyHeaderValuePrefix = 'Bearer ', headers = {
'Content-Type': 'application/json'
}, serviceId, logRequests = false, completeAsync, updateRequest, getModelsAsync, canComplete, supportsTranscription = false, supportsTts = false, }) {
this.clientPromises = {};
this.maxSpeakerRefs = 4;
this.serviceId = serviceId;
this.apiKey = apiKey;
this.apiBaseUrl = apiBaseUrl;
this.completionsEndpoint = completionsEndpoint;
this.secretManager = secretManager;
this.secretsName = secretsName;
this.inputType = inputType;
this.outputType = outputType;
this.isFallback = isFallback;
this.models = models;
this.apiKeyHeader = apiKeyHeader;
this.apiKeyHeaderValuePrefix = apiKeyHeaderValuePrefix ?? undefined;
this.logRequests = logRequests;
this.supportsTranscription = supportsTranscription;
this.supportsTts = supportsTts;
this.completeAsync = completeAsync;
this.headers = headers;
this.updateRequest = updateRequest;
this._getModelsAsync = getModelsAsync;
this._canComplete = canComplete;
}
getSupportAsync(modelName) {
return Promise.resolve({
streaming: true,
});
}
canComplete(model, flat) {
if (!model) {
return this.isFallback;
}
if (this._canComplete) {
return this._canComplete(model, flat);
}
return this.models?.some(m => m.name === model) ?? false;
}
async getApiClientAsync(apiKeyOverride, endpoint, childEndpoint) {
const url = endpoint ?? joinPaths(this.apiBaseUrl, childEndpoint ?? this.completionsEndpoint);
const key = `${url}:::${apiKeyOverride ?? '.'}`;
return await (this.clientPromises[key] ?? (this.clientPromises[key] = (async () => {
let apiKey = apiKeyOverride ?? this.apiKey;
if (!apiKey && this.secretManager && this.secretsName) {
const { apiKey: key } = await this.secretManager.requireSecretTAsync(this.secretsName, true);
apiKey = key;
}
return {
apiKey,
url,
};
})()));
}
async completeConvoAsync(input, flat, ctx) {
const client = await this.getApiClientAsync(flat.apiKey ?? undefined, flat.responseEndpoint);
if (flat.apiKey && flat.apiKey === client.apiKey) {
flat.apiKeyUsedForCompletion = true;
}
const headers = {
[this.apiKeyHeader]: client.apiKey ? ((this.apiKeyHeaderValuePrefix ?? '') + client.apiKey) : undefined,
...this.headers
};
if (this.updateRequest) {
input = { ...input };
this.updateRequest(input, headers);
}
await ctx.beforeComplete?.(this, input, flat);
let completion;
if (this.completeAsync) {
completion = await this.completeAsync(input, flat, client.apiKey, client.url);
}
else {
if (!input.stream) {
completion = await httpClient().postAsync(client.url, input, {
headers,
readErrors: true,
log: this.logRequests,
});
}
else {
const content = [];
const argBuffers = {};
let model = '';
let usage;
const choice = {
index: 0,
finish_reason: 'stop',
logprobs: null,
message: {
role: 'assistant',
content: null,
refusal: null
}
};
const mid = shortUuid();
for await (const evt of httpClient().streamSseAsync({
url: client.url,
body: input,
headers,
endDataFlag: '[DONE]',
logStreamErrors: true,
readErrors: true,
log: this.logRequests,
})) {
if (!evt.data) {
continue;
}
const chunk = evt.data;
if (chunk.model) {
model = chunk.model;
}
if (chunk.usage) {
usage = chunk.usage;
}
const c = chunk.choices?.[0];
if (!c) {
continue;
}
if (c.finish_reason) {
choice.finish_reason = c.finish_reason;
}
if (c.logprobs) {
choice.logprobs = c.logprobs;
}
if (c.delta) {
for (const e in c.delta) {
switch (e) {
case 'content':
if (c.delta.content !== null && c.delta.content !== undefined) {
content.push(c.delta.content);
if (ctx.onChunk) {
await ctx.onChunk(this, { id: nextChunkId(), mid, type: 'content', chunk: c.delta.content }, flat);
}
}
break;
case 'tool_calls':
if (c.delta.tool_calls) {
choice.message.tool_calls = await this.mergeToolCallsAsync(ctx, flat, mid, argBuffers, choice.message.tool_calls ?? [], c.delta.tool_calls);
}
break;
default: {
const v = c.delta[e];
if (v !== undefined && v !== null) {
choice.message[e] = v;
}
}
}
}
}
}
for (const e in argBuffers) {
const buf = argBuffers[e];
if (!buf) {
continue;
}
const index = Number(e);
const fn = choice.message.tool_calls?.[index];
if (fn?.function) {
fn.function.arguments = buf.join('');
}
}
if (content.length) {
choice.message.content = content.join('');
}
completion = {
id: mid,
created: Date.now(),
model,
usage,
object: 'chat.completion',
choices: [choice],
};
}
}
if (!completion) {
throw new NotFoundError();
}
return completion;
}
async mergeToolCallsAsync(ctx, flat, mid, argBuffers, calls, deltas) {
for (let i = 0; i < deltas.length; i++) {
const d = deltas[i];
if (!d) {
continue;
}
const index = d.index ?? 1;
const call = calls[index] ?? (calls[index] = {
id: d.id ?? uuid(),
function: { name: d.function?.name ?? '', arguments: '' },
type: 'function',
});
if (d.function?.arguments) {
const buf = argBuffers[index] ?? (argBuffers[index] = []);
buf.push(d.function.arguments);
if (ctx.onChunk) {
await ctx.onChunk(this, { id: nextChunkId(), mid, type: 'function', functionName: call.function.name, chunk: d.function.arguments }, flat);
}
}
}
return calls;
}
async getModelsAsync() {
if (this.models) {
return [...this.models];
}
if (this._getModelsAsync) {
return await this._getModelsAsync();
}
return [];
}
canTranscribe(request) {
return this.canTranscribeAsync(request);
}
canTranscribeAsync(request) {
return Promise.resolve(this.supportsTranscription);
}
async transcribeAsync({ audio, labelSpeakers, includeSegments = labelSpeakers, speakerRefs, model = includeSegments ? 'gpt-4o-transcribe-diarize' : 'gpt-4o-mini-transcribe', }) {
const startTime = Date.now();
const index = ++nextTransIndex;
const getDefaults = () => {
const now = Date.now();
return {
startTime,
endTime: now,
requestTime: now - startTime,
index,
file: audio,
};
};
try {
const form = new FormData();
form.append('model', model);
form.append('chunking_strategy', 'auto');
form.append('file', audio);
form.append('response_format', includeSegments ? 'diarized_json' : 'json');
if (labelSpeakers && speakerRefs) {
speakerRefs.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
for (let i = 0, l = Math.min(this.maxSpeakerRefs, speakerRefs.length); i < l; i++) {
const s = speakerRefs[i];
if (!s) {
continue;
}
form.append(`known_speaker_names[]`, s.id);
form.append(`known_speaker_references[]`, s.sampleBase64Url);
}
}
const client = await this.getApiClientAsync(undefined, undefined, '/v1/audio/transcriptions');
const headers = {
[this.apiKeyHeader]: client.apiKey ? ((this.apiKeyHeaderValuePrefix ?? '') + client.apiKey) : undefined,
...this.headers
};
const r = await httpClient().postAsync(client.url, form, {
rawBody: true,
headers: { ...headers, 'Content-Type': undefined },
readErrors: true,
log: this.logRequests,
});
if (!r) {
return {
success: false,
error: {
message: 'Empty response returned',
error: null,
},
...getDefaults(),
};
}
else {
return {
success: true,
...r,
...getDefaults(),
};
}
}
catch (ex) {
console.error('Transcription failed', ex);
return {
success: false,
error: {
message: getErrorMessage(ex),
error: ex,
},
...getDefaults(),
};
}
}
canConvertToSpeech(request) {
return this.canConvertToSpeechAsync(request);
}
canConvertToSpeechAsync(request) {
return Promise.resolve(this.supportsTts);
}
async convertToSpeechAsync(request) {
const client = await this.getApiClientAsync(undefined, undefined, '/v1/audio/speech');
const headers = {
[this.apiKeyHeader]: client.apiKey ? ((this.apiKeyHeaderValuePrefix ?? '') + client.apiKey) : undefined,
...this.headers
};
const r = await httpClient().postAsync(client.url, {
model: request.model ?? 'tts-1',
input: request.text,
voice: request.voice || 'ash'
}, {
headers,
readErrors: true,
log: this.logRequests,
returnFetchResponse: true,
});
if (!r) {
return {
success: false,
error: 'No response return from API',
};
}
if (!r.ok) {
try {
const text = await r.text();
return {
success: false,
error: text,
};
}
catch {
return {
success: false,
error: `API error response - ${r.status}`,
};
}
}
else {
try {
return {
success: true,
tts: { audio: await r.blob() },
};
}
catch (ex) {
return {
success: false,
error: `Failed to read audio from API - ${getErrorMessage(ex)}`,
};
}
}
}
canGenerateEmbeddings(request) {
return { success: true, result: true };
}
async generateEmbeddingsAsync(request) {
const client = await this.getApiClientAsync(undefined, undefined, '/v1/embeddings');
const headers = {
[this.apiKeyHeader]: client.apiKey ? ((this.apiKeyHeaderValuePrefix ?? '') + client.apiKey) : undefined,
...this.headers
};
const model = request.model ?? 'text-embedding-3-small';
const r = await httpClient().postAsync(client.url, {
model,
input: request.text,
encoding_format: request.format,
dimensions: request.dimensions,
}, {
headers,
readErrors: true,
log: this.logRequests,
returnFetchResponse: true,
});
if (!r) {
return {
success: false,
error: 'No response return from API',
statusCode: 500,
};
}
if (!r.ok) {
try {
const text = await r.text();
return {
success: false,
error: text,
statusCode: r.status,
};
}
catch {
return {
success: false,
error: `API error response - ${r.status}`,
statusCode: r.status,
};
}
}
else {
try {
const data = await r.json();
return {
success: true,
result: {
model,
provider: this.serviceId,
format: request.format,
text: request.text,
embedding: data?.data?.[0]?.embedding,
usage: data?.usage ? {
inputTokens: data.usage.prompt_tokens,
outputTokens: data.usage.total_tokens,
tokenPrice: 0,
} : undefined
}
};
}
catch (ex) {
return {
success: false,
error: `Failed to read audio from API - ${getErrorMessage(ex)}`,
statusCode: 500,
};
}
}
}
}
let nextTransIndex = 1;
let chunkId = 0;
let prefix = '';
const nextChunkId = () => {
chunkId++;
if (chunkId > 100000000) {
prefix += '_';
chunkId = 1;
}
return prefix + chunkId;
};
//# sourceMappingURL=BaseOpenAiConvoCompletionService.js.map