UNPKG

@convo-lang/convo-lang

Version:
435 lines 16.3 kB
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