dd-trace
Version:
Datadog APM tracing client for JavaScript
1,083 lines (901 loc) • 36 kB
JavaScript
'use strict'
const path = require('path')
const TracingPlugin = require('../../dd-trace/src/plugins/tracing')
const { storage } = require('../../datadog-core')
const services = require('./services')
const Sampler = require('../../dd-trace/src/sampler')
const { MEASURED } = require('../../../ext/tags')
const { estimateTokens } = require('./token-estimator')
const makeUtilities = require('../../dd-trace/src/plugins/util/llm')
const {
convertBuffersToObjects,
constructCompletionResponseFromStreamedChunks,
constructChatCompletionResponseFromStreamedChunks
} = require('./stream-helpers')
let normalize
const { DD_MAJOR } = require('../../../version')
function safeRequire (path) {
try {
return require(path)
} catch {
return null
}
}
const encodingForModel = safeRequire('tiktoken')?.encoding_for_model
class OpenAiTracingPlugin extends TracingPlugin {
static get id () { return 'openai' }
static get operation () { return 'request' }
static get system () { return 'openai' }
static get prefix () {
return 'tracing:apm:openai:request'
}
constructor (...args) {
super(...args)
const { metrics, logger } = services.init(this._tracerConfig)
this.metrics = metrics
this.logger = logger
this.sampler = new Sampler(0.1) // default 10% log sampling
// hoist the normalize function to avoid making all of these functions a class method
if (this._tracerConfig) {
const utilities = makeUtilities('openai', this._tracerConfig)
normalize = utilities.normalize
}
this.addSub('apm:openai:request:chunk', ({ ctx, chunk, done }) => {
if (!ctx.chunks) ctx.chunks = []
if (chunk) ctx.chunks.push(chunk)
if (!done) return
let chunks = ctx.chunks
if (chunks.length === 0) return
const firstChunk = chunks[0]
// OpenAI in legacy versions returns chunked buffers instead of objects.
// These buffers will need to be combined and coalesced into a list of object chunks.
if (firstChunk instanceof Buffer) {
chunks = convertBuffersToObjects(chunks)
}
const methodName = ctx.currentStore.normalizedMethodName
let n = 1
const prompt = ctx.args[0].prompt
if (Array.isArray(prompt) && typeof prompt[0] !== 'number') {
n *= prompt.length
}
let response = {}
if (methodName === 'createCompletion') {
response = constructCompletionResponseFromStreamedChunks(chunks, n)
} else if (methodName === 'createChatCompletion') {
response = constructChatCompletionResponseFromStreamedChunks(chunks, n)
}
ctx.result = { data: response }
})
}
configure (config) {
if (config.enabled === false) {
services.shutdown()
}
super.configure(config)
}
bindStart (ctx) {
const { methodName, args, basePath, apiKey } = ctx
const payload = normalizeRequestPayload(methodName, args)
const normalizedMethodName = normalizeMethodName(methodName)
const store = storage('legacy').getStore() || {}
// hold onto these to make response extraction matching efficient
// the original method name corresponds to the SDK method name (e.g. createChatCompletion, chat.completions.create)
// the normalized method name corresponds to the resource name (e.g. createChatCompletion, createCompletion)
store.originalMethodName = methodName
store.normalizedMethodName = normalizedMethodName
const span = this.startSpan('openai.request', {
service: this.config.service,
resource: DD_MAJOR >= 6 ? normalizedMethodName : methodName,
type: 'openai',
kind: 'client',
meta: {
[MEASURED]: 1,
// Data that is always available with a request
'openai.user.api_key': truncateApiKey(apiKey),
'openai.api_base': basePath,
// The openai.api_type (openai|azure) is present in Python but not in Node.js
// Add support once https://github.com/openai/openai-node/issues/53 is closed
// Data that is common across many requests
'openai.request.best_of': payload.best_of,
'openai.request.echo': payload.echo,
'openai.request.logprobs': payload.logprobs,
'openai.request.max_tokens': payload.max_tokens,
'openai.request.model': payload.model, // vague model
'openai.request.n': payload.n,
'openai.request.presence_penalty': payload.presence_penalty,
'openai.request.frequency_penalty': payload.frequency_penalty,
'openai.request.stop': payload.stop,
'openai.request.suffix': payload.suffix,
'openai.request.temperature': payload.temperature,
'openai.request.top_p': payload.top_p,
'openai.request.user': payload.user,
'openai.request.file_id': payload.file_id // deleteFile, retrieveFile, downloadFile
}
}, false)
const openaiStore = Object.create(null)
const tags = {} // The remaining tags are added one at a time
// createChatCompletion, createCompletion, createImage, createImageEdit, createTranscription, createTranslation
if (payload.prompt) {
const prompt = payload.prompt
openaiStore.prompt = prompt
if (typeof prompt === 'string' || (Array.isArray(prompt) && typeof prompt[0] === 'number')) {
// This is a single prompt, either String or [Number]
tags['openai.request.prompt'] = normalizeStringOrTokenArray(prompt, true)
} else if (Array.isArray(prompt)) {
// This is multiple prompts, either [String] or [[Number]]
for (let i = 0; i < prompt.length; i++) {
tags[`openai.request.prompt.${i}`] = normalizeStringOrTokenArray(prompt[i], true)
}
}
}
// createEdit, createEmbedding, createModeration
if (payload.input) {
const normalized = normalizeStringOrTokenArray(payload.input, false)
tags['openai.request.input'] = normalize(normalized)
openaiStore.input = normalized
}
// createChatCompletion, createCompletion
if (payload.logit_bias !== null && typeof payload.logit_bias === 'object') {
for (const [tokenId, bias] of Object.entries(payload.logit_bias)) {
tags[`openai.request.logit_bias.${tokenId}`] = bias
}
}
if (payload.stream) {
tags['openai.request.stream'] = payload.stream
}
switch (normalizedMethodName) {
case 'createFineTune':
createFineTuneRequestExtraction(tags, payload)
break
case 'createImage':
case 'createImageEdit':
case 'createImageVariation':
commonCreateImageRequestExtraction(tags, payload, openaiStore)
break
case 'createChatCompletion':
createChatCompletionRequestExtraction(tags, payload, openaiStore)
break
case 'createFile':
case 'retrieveFile':
commonFileRequestExtraction(tags, payload)
break
case 'createTranscription':
case 'createTranslation':
commonCreateAudioRequestExtraction(tags, payload, openaiStore)
break
case 'retrieveModel':
retrieveModelRequestExtraction(tags, payload)
break
case 'listFineTuneEvents':
case 'retrieveFineTune':
case 'deleteModel':
case 'cancelFineTune':
commonLookupFineTuneRequestExtraction(tags, payload)
break
case 'createEdit':
createEditRequestExtraction(tags, payload, openaiStore)
break
}
span.addTags(tags)
ctx.currentStore = { ...store, span, openai: openaiStore }
return ctx.currentStore
}
asyncEnd (ctx) {
const { result } = ctx
const store = ctx.currentStore
const span = store?.span
if (!span) return
const error = !!span.context()._tags.error
let headers, body, method, path
if (!error) {
headers = result.headers
body = result.data
method = result.request.method
path = result.request.path
}
if (!error && headers?.constructor.name === 'Headers') {
headers = Object.fromEntries(headers)
}
const resource = span._spanContext._tags['resource.name']
const normalizedMethodName = store.normalizedMethodName
body = coerceResponseBody(body, normalizedMethodName)
const openaiStore = store.openai
if (!error && (path?.startsWith('https://') || path?.startsWith('http://'))) {
// basic checking for if the path was set as a full URL
// not using a full regex as it will likely be "https://api.openai.com/..."
path = new URL(path).pathname
}
const originalMethodName = store.originalMethodName
const endpoint = lookupOperationEndpoint(normalizedMethodName, originalMethodName, path)
const tags = error
? {}
: {
'openai.request.endpoint': endpoint,
'openai.request.method': method.toUpperCase(),
'openai.organization.id': body.organization_id, // only available in fine-tunes endpoints
'openai.organization.name': headers['openai-organization'],
'openai.response.model': headers['openai-model'] || body.model, // specific model, often undefined
'openai.response.id': body.id, // common creation value, numeric epoch
'openai.response.deleted': body.deleted, // common boolean field in delete responses
// The OpenAI API appears to use both created and created_at in different places
// Here we're conciously choosing to surface this inconsistency instead of normalizing
'openai.response.created': body.created,
'openai.response.created_at': body.created_at
}
responseDataExtractionByMethod(normalizedMethodName, tags, body, openaiStore)
span.addTags(tags)
span.finish()
this.sendLog(resource, span, tags, openaiStore, error)
this.sendMetrics(headers, body, endpoint, span._duration, error, tags)
}
sendMetrics (headers, body, endpoint, duration, error, spanTags) {
const tags = [`error:${Number(!!error)}`]
if (error) {
this.metrics.increment('openai.request.error', 1, tags)
} else {
tags.push(
`org:${headers['openai-organization']}`,
`endpoint:${endpoint}`,
`model:${headers['openai-model'] || body.model}`
)
}
this.metrics.distribution('openai.request.duration', duration * 1000, tags)
const promptTokens = spanTags['openai.response.usage.prompt_tokens']
const promptTokensEstimated = spanTags['openai.response.usage.prompt_tokens_estimated']
const completionTokens = spanTags['openai.response.usage.completion_tokens']
const completionTokensEstimated = spanTags['openai.response.usage.completion_tokens_estimated']
const totalTokens = spanTags['openai.response.usage.total_tokens']
if (!error) {
if (promptTokens != null) {
if (promptTokensEstimated) {
this.metrics.distribution(
'openai.tokens.prompt', promptTokens, [...tags, 'openai.estimated:true'])
} else {
this.metrics.distribution('openai.tokens.prompt', promptTokens, tags)
}
}
if (completionTokens != null) {
if (completionTokensEstimated) {
this.metrics.distribution(
'openai.tokens.completion', completionTokens, [...tags, 'openai.estimated:true'])
} else {
this.metrics.distribution('openai.tokens.completion', completionTokens, tags)
}
}
if (totalTokens != null) {
if (promptTokensEstimated || completionTokensEstimated) {
this.metrics.distribution(
'openai.tokens.total', totalTokens, [...tags, 'openai.estimated:true'])
} else {
this.metrics.distribution('openai.tokens.total', totalTokens, tags)
}
}
}
if (headers) {
if (headers['x-ratelimit-limit-requests']) {
this.metrics.gauge('openai.ratelimit.requests', Number(headers['x-ratelimit-limit-requests']), tags)
}
if (headers['x-ratelimit-remaining-requests']) {
this.metrics.gauge(
'openai.ratelimit.remaining.requests', Number(headers['x-ratelimit-remaining-requests']), tags
)
}
if (headers['x-ratelimit-limit-tokens']) {
this.metrics.gauge('openai.ratelimit.tokens', Number(headers['x-ratelimit-limit-tokens']), tags)
}
if (headers['x-ratelimit-remaining-tokens']) {
this.metrics.gauge('openai.ratelimit.remaining.tokens', Number(headers['x-ratelimit-remaining-tokens']), tags)
}
}
}
sendLog (methodName, span, tags, openaiStore, error) {
if (!openaiStore) return
if (!Object.keys(openaiStore).length) return
if (!this.sampler.isSampled(span)) return
const log = {
status: error ? 'error' : 'info',
message: `sampled ${methodName}`,
...openaiStore
}
this.logger.log(log, span, tags)
}
}
function normalizeMethodName (methodName) {
switch (methodName) {
// moderations
case 'moderations.create':
return 'createModeration'
// completions
case 'completions.create':
return 'createCompletion'
// chat completions
case 'chat.completions.create':
return 'createChatCompletion'
// edits
case 'edits.create':
return 'createEdit'
// embeddings
case 'embeddings.create':
return 'createEmbedding'
// files
case 'files.create':
return 'createFile'
case 'files.retrieve':
return 'retrieveFile'
case 'files.del':
case 'files.delete':
return 'deleteFile'
case 'files.retrieveContent':
case 'files.content':
return 'downloadFile'
case 'files.list':
return 'listFiles'
// fine-tuning
case 'fine_tuning.jobs.list':
case 'fine-tune.list':
return 'listFineTunes'
case 'fine_tuning.jobs.listEvents':
case 'fine-tune.listEvents':
return 'listFineTuneEvents'
case 'fine_tuning.jobs.create':
case 'fine-tune.create':
return 'createFineTune'
case 'fine_tuning.jobs.retrieve':
case 'fine-tune.retrieve':
return 'retrieveFineTune'
case 'fine_tuning.jobs.cancel':
case 'fine-tune.cancel':
return 'cancelFineTune'
// audio
case 'audio.transcriptions.create':
return 'createTranscription'
case 'audio.translations.create':
return 'createTranslation'
// images
case 'images.generate':
return 'createImage'
case 'images.edit':
return 'createImageEdit'
case 'images.createVariation':
return 'createImageVariation'
// models
case 'models.list':
return 'listModels'
case 'models.retrieve':
return 'retrieveModel'
case 'models.del':
case 'models.delete':
return 'deleteModel'
default:
return methodName
}
}
function countPromptTokens (methodName, payload, model) {
let promptTokens = 0
let promptEstimated = false
if (methodName === 'createChatCompletion') {
const messages = payload.messages
for (const message of messages) {
const content = message.content
if (typeof content === 'string') {
const { tokens, estimated } = countTokens(content, model)
promptTokens += tokens
promptEstimated = estimated
} else if (Array.isArray(content)) {
for (const c of content) {
if (c.type === 'text') {
const { tokens, estimated } = countTokens(c.text, model)
promptTokens += tokens
promptEstimated = estimated
}
// unsupported token computation for image_url
// as even though URL is a string, its true token count
// is based on the image itself, something onerous to do client-side
}
}
}
} else if (methodName === 'createCompletion') {
let prompt = payload.prompt
if (!Array.isArray(prompt)) prompt = [prompt]
for (const p of prompt) {
const { tokens, estimated } = countTokens(p, model)
promptTokens += tokens
promptEstimated = estimated
}
}
return { promptTokens, promptEstimated }
}
function countCompletionTokens (body, model) {
let completionTokens = 0
let completionEstimated = false
if (body?.choices) {
for (const choice of body.choices) {
const message = choice.message || choice.delta // delta for streamed responses
const text = choice.text
const content = text || message?.content
const { tokens, estimated } = countTokens(content, model)
completionTokens += tokens
completionEstimated = estimated
}
}
return { completionTokens, completionEstimated }
}
function countTokens (content, model) {
if (encodingForModel) {
try {
// try using tiktoken if it was available
const encoder = encodingForModel(model)
const tokens = encoder.encode(content).length
encoder.free()
return { tokens, estimated: false }
} catch {
// possible errors from tiktoken:
// * model not available for token counts
// * issue encoding content
}
}
return {
tokens: estimateTokens(content),
estimated: true
}
}
function createEditRequestExtraction (tags, payload, openaiStore) {
const instruction = payload.instruction
tags['openai.request.instruction'] = instruction
openaiStore.instruction = instruction
}
function retrieveModelRequestExtraction (tags, payload) {
tags['openai.request.id'] = payload.id
}
function createChatCompletionRequestExtraction (tags, payload, openaiStore) {
const messages = payload.messages
if (!defensiveArrayLength(messages)) return
openaiStore.messages = payload.messages
for (let i = 0; i < payload.messages.length; i++) {
const message = payload.messages[i]
tagChatCompletionRequestContent(message.content, i, tags)
tags[`openai.request.messages.${i}.role`] = message.role
tags[`openai.request.messages.${i}.name`] = message.name
tags[`openai.request.messages.${i}.finish_reason`] = message.finish_reason
}
}
function commonCreateImageRequestExtraction (tags, payload, openaiStore) {
// createImageEdit, createImageVariation
const img = payload.file || payload.image
if (img !== null && typeof img === 'object' && img.path) {
const file = path.basename(img.path)
tags['openai.request.image'] = file
openaiStore.file = file
}
// createImageEdit
if (payload.mask !== null && typeof payload.mask === 'object' && payload.mask.path) {
const mask = path.basename(payload.mask.path)
tags['openai.request.mask'] = mask
openaiStore.mask = mask
}
tags['openai.request.size'] = payload.size
tags['openai.request.response_format'] = payload.response_format
tags['openai.request.language'] = payload.language
}
function responseDataExtractionByMethod (methodName, tags, body, openaiStore) {
switch (methodName) {
case 'createModeration':
createModerationResponseExtraction(tags, body)
break
case 'createCompletion':
case 'createChatCompletion':
case 'createEdit':
commonCreateResponseExtraction(tags, body, openaiStore, methodName)
break
case 'listFiles':
case 'listFineTunes':
case 'listFineTuneEvents':
commonListCountResponseExtraction(tags, body)
break
case 'createEmbedding':
createEmbeddingResponseExtraction(tags, body, openaiStore)
break
case 'createFile':
case 'retrieveFile':
createRetrieveFileResponseExtraction(tags, body)
break
case 'deleteFile':
deleteFileResponseExtraction(tags, body)
break
case 'downloadFile':
downloadFileResponseExtraction(tags, body)
break
case 'createFineTune':
case 'retrieveFineTune':
case 'cancelFineTune':
commonFineTuneResponseExtraction(tags, body)
break
case 'createTranscription':
case 'createTranslation':
createAudioResponseExtraction(tags, body)
break
case 'createImage':
case 'createImageEdit':
case 'createImageVariation':
commonImageResponseExtraction(tags, body)
break
case 'listModels':
listModelsResponseExtraction(tags, body)
break
case 'retrieveModel':
retrieveModelResponseExtraction(tags, body)
break
}
}
function retrieveModelResponseExtraction (tags, body) {
tags['openai.response.owned_by'] = body.owned_by
tags['openai.response.parent'] = body.parent
tags['openai.response.root'] = body.root
if (!body.permission) return
tags['openai.response.permission.id'] = body.permission[0].id
tags['openai.response.permission.created'] = body.permission[0].created
tags['openai.response.permission.allow_create_engine'] = body.permission[0].allow_create_engine
tags['openai.response.permission.allow_sampling'] = body.permission[0].allow_sampling
tags['openai.response.permission.allow_logprobs'] = body.permission[0].allow_logprobs
tags['openai.response.permission.allow_search_indices'] = body.permission[0].allow_search_indices
tags['openai.response.permission.allow_view'] = body.permission[0].allow_view
tags['openai.response.permission.allow_fine_tuning'] = body.permission[0].allow_fine_tuning
tags['openai.response.permission.organization'] = body.permission[0].organization
tags['openai.response.permission.group'] = body.permission[0].group
tags['openai.response.permission.is_blocking'] = body.permission[0].is_blocking
}
function commonLookupFineTuneRequestExtraction (tags, body) {
tags['openai.request.fine_tune_id'] = body.fine_tune_id
tags['openai.request.stream'] = !!body.stream // listFineTuneEvents
}
function listModelsResponseExtraction (tags, body) {
if (!body.data) return
tags['openai.response.count'] = body.data.length
}
function commonImageResponseExtraction (tags, body) {
if (!body.data) return
tags['openai.response.images_count'] = body.data.length
for (let i = 0; i < body.data.length; i++) {
const image = body.data[i]
// exactly one of these two options is provided
tags[`openai.response.images.${i}.url`] = normalize(image.url)
tags[`openai.response.images.${i}.b64_json`] = image.b64_json && 'returned'
}
}
function createAudioResponseExtraction (tags, body) {
tags['openai.response.text'] = body.text
tags['openai.response.language'] = body.language
tags['openai.response.duration'] = body.duration
tags['openai.response.segments_count'] = defensiveArrayLength(body.segments)
}
function createFineTuneRequestExtraction (tags, body) {
tags['openai.request.training_file'] = body.training_file
tags['openai.request.validation_file'] = body.validation_file
tags['openai.request.n_epochs'] = body.n_epochs
tags['openai.request.batch_size'] = body.batch_size
tags['openai.request.learning_rate_multiplier'] = body.learning_rate_multiplier
tags['openai.request.prompt_loss_weight'] = body.prompt_loss_weight
tags['openai.request.compute_classification_metrics'] = body.compute_classification_metrics
tags['openai.request.classification_n_classes'] = body.classification_n_classes
tags['openai.request.classification_positive_class'] = body.classification_positive_class
tags['openai.request.classification_betas_count'] = defensiveArrayLength(body.classification_betas)
}
function commonFineTuneResponseExtraction (tags, body) {
tags['openai.response.events_count'] = defensiveArrayLength(body.events)
tags['openai.response.fine_tuned_model'] = body.fine_tuned_model
const hyperparams = body.hyperparams || body.hyperparameters
const hyperparamsKey = body.hyperparams ? 'hyperparams' : 'hyperparameters'
if (hyperparams) {
tags[`openai.response.${hyperparamsKey}.n_epochs`] = hyperparams.n_epochs
tags[`openai.response.${hyperparamsKey}.batch_size`] = hyperparams.batch_size
tags[`openai.response.${hyperparamsKey}.prompt_loss_weight`] = hyperparams.prompt_loss_weight
tags[`openai.response.${hyperparamsKey}.learning_rate_multiplier`] = hyperparams.learning_rate_multiplier
}
tags['openai.response.training_files_count'] = defensiveArrayLength(body.training_files || body.training_file)
tags['openai.response.result_files_count'] = defensiveArrayLength(body.result_files)
tags['openai.response.validation_files_count'] = defensiveArrayLength(body.validation_files || body.validation_file)
tags['openai.response.updated_at'] = body.updated_at
tags['openai.response.status'] = body.status
}
// the OpenAI package appears to stream the content download then provide it all as a singular string
function downloadFileResponseExtraction (tags, body) {
if (typeof body.file !== 'string') return
tags['openai.response.total_bytes'] = Buffer.byteLength(body.file)
}
function deleteFileResponseExtraction (tags, body) {
tags['openai.response.id'] = body.id
}
function commonCreateAudioRequestExtraction (tags, body, openaiStore) {
tags['openai.request.response_format'] = body.response_format
tags['openai.request.language'] = body.language
if (body.file !== null && typeof body.file === 'object' && body.file.path) {
const filename = path.basename(body.file.path)
tags['openai.request.filename'] = filename
openaiStore.file = filename
}
}
function commonFileRequestExtraction (tags, body) {
tags['openai.request.purpose'] = body.purpose
// User can provider either exact file contents or a file read stream
// With the stream we extract the filepath
// This is a best effort attempt to extract the filename during the request
if (body.file !== null && typeof body.file === 'object' && body.file.path) {
tags['openai.request.filename'] = path.basename(body.file.path)
}
}
function createRetrieveFileResponseExtraction (tags, body) {
tags['openai.response.filename'] = body.filename
tags['openai.response.purpose'] = body.purpose
tags['openai.response.bytes'] = body.bytes
tags['openai.response.status'] = body.status
tags['openai.response.status_details'] = body.status_details
}
function createEmbeddingResponseExtraction (tags, body, openaiStore) {
usageExtraction(tags, body, openaiStore)
if (!body.data) return
tags['openai.response.embeddings_count'] = body.data.length
for (let i = 0; i < body.data.length; i++) {
tags[`openai.response.embedding.${i}.embedding_length`] = body.data[i].embedding.length
}
}
function commonListCountResponseExtraction (tags, body) {
if (!body.data) return
tags['openai.response.count'] = body.data.length
}
// TODO: Is there ever more than one entry in body.results?
function createModerationResponseExtraction (tags, body) {
tags['openai.response.id'] = body.id
// tags[`openai.response.model`] = body.model // redundant, already extracted globally
if (!body.results) return
tags['openai.response.flagged'] = body.results[0].flagged
for (const [category, match] of Object.entries(body.results[0].categories)) {
tags[`openai.response.categories.${category}`] = match
}
for (const [category, score] of Object.entries(body.results[0].category_scores)) {
tags[`openai.response.category_scores.${category}`] = score
}
}
// createCompletion, createChatCompletion, createEdit
function commonCreateResponseExtraction (tags, body, openaiStore, methodName) {
usageExtraction(tags, body, methodName, openaiStore)
if (!body.choices) return
tags['openai.response.choices_count'] = body.choices.length
openaiStore.choices = body.choices
for (let choiceIdx = 0; choiceIdx < body.choices.length; choiceIdx++) {
const choice = body.choices[choiceIdx]
// logprobs can be null and we still want to tag it as 'returned' even when set to 'null'
const specifiesLogProb = Object.keys(choice).includes('logprobs')
tags[`openai.response.choices.${choiceIdx}.finish_reason`] = choice.finish_reason
tags[`openai.response.choices.${choiceIdx}.logprobs`] = specifiesLogProb ? 'returned' : undefined
tags[`openai.response.choices.${choiceIdx}.text`] = normalize(choice.text)
// createChatCompletion only
const message = choice.message || choice.delta // delta for streamed responses
if (message) {
tags[`openai.response.choices.${choiceIdx}.message.role`] = message.role
tags[`openai.response.choices.${choiceIdx}.message.content`] = normalize(message.content)
tags[`openai.response.choices.${choiceIdx}.message.name`] = normalize(message.name)
if (message.tool_calls) {
const toolCalls = message.tool_calls
for (let toolIdx = 0; toolIdx < toolCalls.length; toolIdx++) {
tags[`openai.response.choices.${choiceIdx}.message.tool_calls.${toolIdx}.function.name`] =
toolCalls[toolIdx].function.name
tags[`openai.response.choices.${choiceIdx}.message.tool_calls.${toolIdx}.function.arguments`] =
toolCalls[toolIdx].function.arguments
tags[`openai.response.choices.${choiceIdx}.message.tool_calls.${toolIdx}.id`] =
toolCalls[toolIdx].id
}
}
}
}
}
// createCompletion, createChatCompletion, createEdit, createEmbedding
function usageExtraction (tags, body, methodName, openaiStore) {
let promptTokens = 0
let completionTokens = 0
let totalTokens = 0
if (body && body.usage) {
promptTokens = body.usage.prompt_tokens
completionTokens = body.usage.completion_tokens
totalTokens = body.usage.total_tokens
} else if (body.model && ['createChatCompletion', 'createCompletion'].includes(methodName)) {
// estimate tokens based on method name for completions and chat completions
const { model } = body
// prompt tokens
const payload = openaiStore
const promptTokensCount = countPromptTokens(methodName, payload, model)
promptTokens = promptTokensCount.promptTokens
const promptEstimated = promptTokensCount.promptEstimated
// completion tokens
const completionTokensCount = countCompletionTokens(body, model)
completionTokens = completionTokensCount.completionTokens
const completionEstimated = completionTokensCount.completionEstimated
// total tokens
totalTokens = promptTokens + completionTokens
if (promptEstimated) tags['openai.response.usage.prompt_tokens_estimated'] = true
if (completionEstimated) tags['openai.response.usage.completion_tokens_estimated'] = true
}
if (promptTokens != null) tags['openai.response.usage.prompt_tokens'] = promptTokens
if (completionTokens != null) tags['openai.response.usage.completion_tokens'] = completionTokens
if (totalTokens != null) tags['openai.response.usage.total_tokens'] = totalTokens
}
function truncateApiKey (apiKey) {
return apiKey && `sk-...${apiKey.slice(-4)}`
}
function tagChatCompletionRequestContent (contents, messageIdx, tags) {
if (typeof contents === 'string') {
tags[`openai.request.messages.${messageIdx}.content`] = normalize(contents)
} else if (Array.isArray(contents)) {
// content can also be an array of objects
// which represent text input or image url
for (const contentIdx in contents) {
const content = contents[contentIdx]
const type = content.type
tags[`openai.request.messages.${messageIdx}.content.${contentIdx}.type`] = content.type
if (type === 'text') {
tags[`openai.request.messages.${messageIdx}.content.${contentIdx}.text`] = normalize(content.text)
} else if (type === 'image_url') {
tags[`openai.request.messages.${messageIdx}.content.${contentIdx}.image_url.url`] =
normalize(content.image_url.url)
}
// unsupported type otherwise, won't be tagged
}
}
// unsupported type otherwise, won't be tagged
}
// The server almost always responds with JSON
function coerceResponseBody (body, methodName) {
switch (methodName) {
case 'downloadFile':
return { file: body }
}
const type = typeof body
if (type === 'string') {
try {
return JSON.parse(body)
} catch {
return body
}
} else if (type === 'object') {
return body
} else {
return {}
}
}
// This method is used to replace a dynamic URL segment with an asterisk
function lookupOperationEndpoint (operationId, methodName, url) {
switch (operationId) {
case 'deleteModel':
case 'retrieveModel':
return '/v1/models/*'
case 'deleteFile':
case 'retrieveFile':
return '/v1/files/*'
case 'downloadFile':
return '/v1/files/*/content'
case 'retrieveFineTune':
switch (methodName) {
case 'fine_tuning.jobs.retrieve':
return '/v1/fine_tuning/jobs/*'
default:
return '/v1/fine-tunes/*'
}
case 'listFineTuneEvents':
switch (methodName) {
case 'fine_tuning.jobs.listEvents':
return '/v1/fine_tuning/jobs/*/events'
default:
return '/v1/fine-tunes/*/events'
}
case 'cancelFineTune':
switch (methodName) {
case 'fine_tuning.jobs.cancel':
return '/v1/fine_tuning/jobs/*/cancel'
default:
return '/v1/fine-tunes/*/cancel'
}
}
return url
}
/**
* This function essentially normalizes the OpenAI method interface. Many methods accept
* a single object argument. The remaining ones take individual arguments. This function
* turns the individual arguments into an object to make extracting properties consistent.
*/
function normalizeRequestPayload (methodName, args) {
switch (methodName) {
case 'listModels':
case 'models.list':
case 'listFiles':
case 'files.list':
case 'listFineTunes':
case 'fine_tuning.jobs.list':
case 'fine-tune.list':
// no argument
return {}
case 'retrieveModel':
case 'models.retrieve':
return { id: args[0] }
case 'createFile':
return {
file: args[0],
purpose: args[1]
}
case 'deleteFile':
case 'files.del':
case 'files.delete':
case 'retrieveFile':
case 'files.retrieve':
case 'downloadFile':
case 'files.retrieveContent':
case 'files.content':
return { file_id: args[0] }
case 'listFineTuneEvents':
case 'fine_tuning.jobs.listEvents':
case 'fine-tune.listEvents':
return {
fine_tune_id: args[0],
stream: args[1] // undocumented
}
case 'retrieveFineTune':
case 'fine_tuning.jobs.retrieve':
case 'fine-tune.retrieve':
case 'deleteModel':
case 'models.del':
case 'models.delete':
case 'cancelFineTune':
case 'fine_tuning.jobs.cancel':
case 'fine-tune.cancel':
return { fine_tune_id: args[0] }
case 'createImageEdit':
return {
file: args[0],
prompt: args[1], // Note: order of prompt/mask in Node.js lib differs from public docs
mask: args[2],
n: args[3],
size: args[4],
response_format: args[5],
user: args[6]
}
case 'createImageVariation':
return {
file: args[0],
n: args[1],
size: args[2],
response_format: args[3],
user: args[4]
}
case 'createTranscription':
case 'createTranslation':
return {
file: args[0],
model: args[1],
prompt: args[2],
response_format: args[3],
temperature: args[4],
language: args[5] // only used for createTranscription
}
}
// Remaining OpenAI methods take a single object argument
return args[0]
}
/**
* Converts an array of tokens to a string
* If input is already a string it's returned
* In either case the value is truncated
* It's intentional that the array be truncated arbitrarily, e.g. "[999, 888, 77..."
* "foo" -> "foo"
* [1,2,3] -> "[1, 2, 3]"
*/
function normalizeStringOrTokenArray (input, truncate) {
const normalized = Array.isArray(input)
? `[${input.join(', ')}]` // "[1, 2, 999]"
: input // "foo"
return truncate ? normalize(normalized) : normalized
}
function defensiveArrayLength (maybeArray) {
if (maybeArray) {
// Detect singular item (ie body.training_file vs body.training_files)
return Array.isArray(maybeArray) ? maybeArray.length : 1
}
}
module.exports = OpenAiTracingPlugin