789 lines
24 KiB
TypeScript
789 lines
24 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import { APIError } from '@anthropic-ai/sdk'
|
|
import type {
|
|
BetaStopReason,
|
|
BetaUsage as Usage,
|
|
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
|
import {
|
|
addToTotalDurationState,
|
|
consumePostCompaction,
|
|
getIsNonInteractiveSession,
|
|
getLastApiCompletionTimestamp,
|
|
getTeleportedSessionInfo,
|
|
markFirstTeleportMessageLogged,
|
|
setLastApiCompletionTimestamp,
|
|
} from 'src/bootstrap/state.js'
|
|
import type { QueryChainTracking } from 'src/Tool.js'
|
|
import { isConnectorTextBlock } from 'src/types/connectorText.js'
|
|
import type { AssistantMessage } from 'src/types/message.js'
|
|
import { logForDebugging } from 'src/utils/debug.js'
|
|
import type { EffortLevel } from 'src/utils/effort.js'
|
|
import { logError } from 'src/utils/log.js'
|
|
import { getAPIProviderForStatsig } from 'src/utils/model/providers.js'
|
|
import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js'
|
|
import { jsonStringify } from 'src/utils/slowOperations.js'
|
|
import { logOTelEvent } from 'src/utils/telemetry/events.js'
|
|
import {
|
|
endLLMRequestSpan,
|
|
isBetaTracingEnabled,
|
|
type Span,
|
|
} from 'src/utils/telemetry/sessionTracing.js'
|
|
import type { NonNullableUsage } from '../../entrypoints/sdk/sdkUtilityTypes.js'
|
|
import { consumeInvokingRequestId } from '../../utils/agentContext.js'
|
|
import {
|
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
logEvent,
|
|
} from '../analytics/index.js'
|
|
import { sanitizeToolNameForAnalytics } from '../analytics/metadata.js'
|
|
import { EMPTY_USAGE } from './emptyUsage.js'
|
|
import { classifyAPIError } from './errors.js'
|
|
import { extractConnectionErrorDetails } from './errorUtils.js'
|
|
|
|
export type { NonNullableUsage }
|
|
export { EMPTY_USAGE }
|
|
|
|
// Strategy used for global prompt caching
|
|
export type GlobalCacheStrategy = 'tool_based' | 'system_prompt' | 'none'
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
if (error instanceof APIError) {
|
|
const body = error.error as { error?: { message?: string } } | undefined
|
|
if (body?.error?.message) return body.error.message
|
|
}
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
type KnownGateway =
|
|
| 'litellm'
|
|
| 'helicone'
|
|
| 'portkey'
|
|
| 'cloudflare-ai-gateway'
|
|
| 'kong'
|
|
| 'braintrust'
|
|
| 'databricks'
|
|
|
|
// Gateway fingerprints for detecting AI gateways from response headers
|
|
const GATEWAY_FINGERPRINTS: Partial<
|
|
Record<KnownGateway, { prefixes: string[] }>
|
|
> = {
|
|
// https://docs.litellm.ai/docs/proxy/response_headers
|
|
litellm: {
|
|
prefixes: ['x-litellm-'],
|
|
},
|
|
// https://docs.helicone.ai/helicone-headers/header-directory
|
|
helicone: {
|
|
prefixes: ['helicone-'],
|
|
},
|
|
// https://portkey.ai/docs/api-reference/response-schema
|
|
portkey: {
|
|
prefixes: ['x-portkey-'],
|
|
},
|
|
// https://developers.cloudflare.com/ai-gateway/evaluations/add-human-feedback-api/
|
|
'cloudflare-ai-gateway': {
|
|
prefixes: ['cf-aig-'],
|
|
},
|
|
// https://developer.konghq.com/ai-gateway/ — X-Kong-Upstream-Latency, X-Kong-Proxy-Latency
|
|
kong: {
|
|
prefixes: ['x-kong-'],
|
|
},
|
|
// https://www.braintrust.dev/docs/guides/proxy — x-bt-used-endpoint, x-bt-cached
|
|
braintrust: {
|
|
prefixes: ['x-bt-'],
|
|
},
|
|
}
|
|
|
|
// Gateways that use provider-owned domains (not self-hosted), so the
|
|
// ANTHROPIC_BASE_URL hostname is a reliable signal even without a
|
|
// distinctive response header.
|
|
const GATEWAY_HOST_SUFFIXES: Partial<Record<KnownGateway, string[]>> = {
|
|
// https://docs.databricks.com/aws/en/ai-gateway/
|
|
databricks: [
|
|
'.cloud.databricks.com',
|
|
'.azuredatabricks.net',
|
|
'.gcp.databricks.com',
|
|
],
|
|
}
|
|
|
|
function detectGateway({
|
|
headers,
|
|
baseUrl,
|
|
}: {
|
|
headers?: globalThis.Headers
|
|
baseUrl?: string
|
|
}): KnownGateway | undefined {
|
|
if (headers) {
|
|
// Header names are already lowercase from the Headers API
|
|
const headerNames: string[] = []
|
|
headers.forEach((_, key) => headerNames.push(key))
|
|
for (const [gw, { prefixes }] of Object.entries(GATEWAY_FINGERPRINTS)) {
|
|
if (prefixes.some(p => headerNames.some(h => h.startsWith(p)))) {
|
|
return gw as KnownGateway
|
|
}
|
|
}
|
|
}
|
|
|
|
if (baseUrl) {
|
|
try {
|
|
const host = new URL(baseUrl).hostname.toLowerCase()
|
|
for (const [gw, suffixes] of Object.entries(GATEWAY_HOST_SUFFIXES)) {
|
|
if (suffixes.some(s => host.endsWith(s))) {
|
|
return gw as KnownGateway
|
|
}
|
|
}
|
|
} catch {
|
|
// malformed URL — ignore
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
function getAnthropicEnvMetadata() {
|
|
return {
|
|
...(process.env.ANTHROPIC_BASE_URL
|
|
? {
|
|
baseUrl: process.env
|
|
.ANTHROPIC_BASE_URL as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
...(process.env.ANTHROPIC_MODEL
|
|
? {
|
|
envModel: process.env
|
|
.ANTHROPIC_MODEL as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
...(process.env.ANTHROPIC_SMALL_FAST_MODEL
|
|
? {
|
|
envSmallFastModel: process.env
|
|
.ANTHROPIC_SMALL_FAST_MODEL as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
}
|
|
}
|
|
|
|
function getBuildAgeMinutes(): number | undefined {
|
|
if (!MACRO.BUILD_TIME) return undefined
|
|
const buildTime = new Date(MACRO.BUILD_TIME).getTime()
|
|
if (isNaN(buildTime)) return undefined
|
|
return Math.floor((Date.now() - buildTime) / 60000)
|
|
}
|
|
|
|
export function logAPIQuery({
|
|
model,
|
|
messagesLength,
|
|
temperature,
|
|
betas,
|
|
permissionMode,
|
|
querySource,
|
|
queryTracking,
|
|
thinkingType,
|
|
effortValue,
|
|
fastMode,
|
|
previousRequestId,
|
|
}: {
|
|
model: string
|
|
messagesLength: number
|
|
temperature: number
|
|
betas?: string[]
|
|
permissionMode?: PermissionMode
|
|
querySource: string
|
|
queryTracking?: QueryChainTracking
|
|
thinkingType?: 'adaptive' | 'enabled' | 'disabled'
|
|
effortValue?: EffortLevel | null
|
|
fastMode?: boolean
|
|
previousRequestId?: string | null
|
|
}): void {
|
|
logEvent('tengu_api_query', {
|
|
model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
messagesLength,
|
|
temperature: temperature,
|
|
provider: getAPIProviderForStatsig(),
|
|
buildAgeMins: getBuildAgeMinutes(),
|
|
...(betas?.length
|
|
? {
|
|
betas: betas.join(
|
|
',',
|
|
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
permissionMode:
|
|
permissionMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
querySource:
|
|
querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
...(queryTracking
|
|
? {
|
|
queryChainId:
|
|
queryTracking.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
queryDepth: queryTracking.depth,
|
|
}
|
|
: {}),
|
|
thinkingType:
|
|
thinkingType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
effortValue:
|
|
effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
fastMode,
|
|
...(previousRequestId
|
|
? {
|
|
previousRequestId:
|
|
previousRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
...getAnthropicEnvMetadata(),
|
|
})
|
|
}
|
|
|
|
export function logAPIError({
|
|
error,
|
|
model,
|
|
messageCount,
|
|
messageTokens,
|
|
durationMs,
|
|
durationMsIncludingRetries,
|
|
attempt,
|
|
requestId,
|
|
clientRequestId,
|
|
didFallBackToNonStreaming,
|
|
promptCategory,
|
|
headers,
|
|
queryTracking,
|
|
querySource,
|
|
llmSpan,
|
|
fastMode,
|
|
previousRequestId,
|
|
}: {
|
|
error: unknown
|
|
model: string
|
|
messageCount: number
|
|
messageTokens?: number
|
|
durationMs: number
|
|
durationMsIncludingRetries: number
|
|
attempt: number
|
|
requestId?: string | null
|
|
/** Client-generated ID sent as x-client-request-id header (survives timeouts) */
|
|
clientRequestId?: string
|
|
didFallBackToNonStreaming?: boolean
|
|
promptCategory?: string
|
|
headers?: globalThis.Headers
|
|
queryTracking?: QueryChainTracking
|
|
querySource?: string
|
|
/** The span from startLLMRequestSpan - pass this to correctly match responses to requests */
|
|
llmSpan?: Span
|
|
fastMode?: boolean
|
|
previousRequestId?: string | null
|
|
}): void {
|
|
const gateway = detectGateway({
|
|
headers:
|
|
error instanceof APIError && error.headers ? error.headers : headers,
|
|
baseUrl: process.env.ANTHROPIC_BASE_URL,
|
|
})
|
|
|
|
const errStr = getErrorMessage(error)
|
|
const status = error instanceof APIError ? String(error.status) : undefined
|
|
const errorType = classifyAPIError(error)
|
|
|
|
// Log detailed connection error info to debug logs (visible via --debug)
|
|
const connectionDetails = extractConnectionErrorDetails(error)
|
|
if (connectionDetails) {
|
|
const sslLabel = connectionDetails.isSSLError ? ' (SSL error)' : ''
|
|
logForDebugging(
|
|
`Connection error details: code=${connectionDetails.code}${sslLabel}, message=${connectionDetails.message}`,
|
|
{ level: 'error' },
|
|
)
|
|
}
|
|
|
|
const invocation = consumeInvokingRequestId()
|
|
|
|
if (clientRequestId) {
|
|
logForDebugging(
|
|
`API error x-client-request-id=${clientRequestId} (give this to the API team for server-log lookup)`,
|
|
{ level: 'error' },
|
|
)
|
|
}
|
|
|
|
logError(error as Error)
|
|
logEvent('tengu_api_error', {
|
|
model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
error: errStr as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
status:
|
|
status as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
errorType:
|
|
errorType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
messageCount,
|
|
messageTokens,
|
|
durationMs,
|
|
durationMsIncludingRetries,
|
|
attempt,
|
|
provider: getAPIProviderForStatsig(),
|
|
requestId:
|
|
(requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) ||
|
|
undefined,
|
|
...(invocation
|
|
? {
|
|
invokingRequestId:
|
|
invocation.invokingRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
invocationKind:
|
|
invocation.invocationKind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
clientRequestId:
|
|
(clientRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) ||
|
|
undefined,
|
|
didFallBackToNonStreaming,
|
|
...(promptCategory
|
|
? {
|
|
promptCategory:
|
|
promptCategory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
...(gateway
|
|
? {
|
|
gateway:
|
|
gateway as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
...(queryTracking
|
|
? {
|
|
queryChainId:
|
|
queryTracking.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
queryDepth: queryTracking.depth,
|
|
}
|
|
: {}),
|
|
...(querySource
|
|
? {
|
|
querySource:
|
|
querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
fastMode,
|
|
...(previousRequestId
|
|
? {
|
|
previousRequestId:
|
|
previousRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
...getAnthropicEnvMetadata(),
|
|
})
|
|
|
|
// Log API error event for OTLP
|
|
void logOTelEvent('api_error', {
|
|
model: model,
|
|
error: errStr,
|
|
status_code: String(status),
|
|
duration_ms: String(durationMs),
|
|
attempt: String(attempt),
|
|
speed: fastMode ? 'fast' : 'normal',
|
|
})
|
|
|
|
// Pass the span to correctly match responses to requests when beta tracing is enabled
|
|
endLLMRequestSpan(llmSpan, {
|
|
success: false,
|
|
statusCode: status ? parseInt(status) : undefined,
|
|
error: errStr,
|
|
attempt,
|
|
})
|
|
|
|
// Log first error for teleported sessions (reliability tracking)
|
|
const teleportInfo = getTeleportedSessionInfo()
|
|
if (teleportInfo?.isTeleported && !teleportInfo.hasLoggedFirstMessage) {
|
|
logEvent('tengu_teleport_first_message_error', {
|
|
session_id:
|
|
teleportInfo.sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
error_type:
|
|
errorType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
markFirstTeleportMessageLogged()
|
|
}
|
|
}
|
|
|
|
function logAPISuccess({
|
|
model,
|
|
preNormalizedModel,
|
|
messageCount,
|
|
messageTokens,
|
|
usage,
|
|
durationMs,
|
|
durationMsIncludingRetries,
|
|
attempt,
|
|
ttftMs,
|
|
requestId,
|
|
stopReason,
|
|
costUSD,
|
|
didFallBackToNonStreaming,
|
|
querySource,
|
|
gateway,
|
|
queryTracking,
|
|
permissionMode,
|
|
globalCacheStrategy,
|
|
textContentLength,
|
|
thinkingContentLength,
|
|
toolUseContentLengths,
|
|
connectorTextBlockCount,
|
|
fastMode,
|
|
previousRequestId,
|
|
betas,
|
|
}: {
|
|
model: string
|
|
preNormalizedModel: string
|
|
messageCount: number
|
|
messageTokens: number
|
|
usage: Usage
|
|
durationMs: number
|
|
durationMsIncludingRetries: number
|
|
attempt: number
|
|
ttftMs: number | null
|
|
requestId: string | null
|
|
stopReason: BetaStopReason | null
|
|
costUSD: number
|
|
didFallBackToNonStreaming: boolean
|
|
querySource: string
|
|
gateway?: KnownGateway
|
|
queryTracking?: QueryChainTracking
|
|
permissionMode?: PermissionMode
|
|
globalCacheStrategy?: GlobalCacheStrategy
|
|
textContentLength?: number
|
|
thinkingContentLength?: number
|
|
toolUseContentLengths?: Record<string, number>
|
|
connectorTextBlockCount?: number
|
|
fastMode?: boolean
|
|
previousRequestId?: string | null
|
|
betas?: string[]
|
|
}): void {
|
|
const isNonInteractiveSession = getIsNonInteractiveSession()
|
|
const isPostCompaction = consumePostCompaction()
|
|
const hasPrintFlag =
|
|
process.argv.includes('-p') || process.argv.includes('--print')
|
|
|
|
const now = Date.now()
|
|
const lastCompletion = getLastApiCompletionTimestamp()
|
|
const timeSinceLastApiCallMs =
|
|
lastCompletion !== null ? now - lastCompletion : undefined
|
|
|
|
const invocation = consumeInvokingRequestId()
|
|
|
|
logEvent('tengu_api_success', {
|
|
model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
...(preNormalizedModel !== model
|
|
? {
|
|
preNormalizedModel:
|
|
preNormalizedModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
...(betas?.length
|
|
? {
|
|
betas: betas.join(
|
|
',',
|
|
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
messageCount,
|
|
messageTokens,
|
|
inputTokens: usage.input_tokens,
|
|
outputTokens: usage.output_tokens,
|
|
cachedInputTokens: usage.cache_read_input_tokens ?? 0,
|
|
uncachedInputTokens: usage.cache_creation_input_tokens ?? 0,
|
|
durationMs: durationMs,
|
|
durationMsIncludingRetries: durationMsIncludingRetries,
|
|
attempt: attempt,
|
|
ttftMs: ttftMs ?? undefined,
|
|
buildAgeMins: getBuildAgeMinutes(),
|
|
provider: getAPIProviderForStatsig(),
|
|
requestId:
|
|
(requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) ??
|
|
undefined,
|
|
...(invocation
|
|
? {
|
|
invokingRequestId:
|
|
invocation.invokingRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
invocationKind:
|
|
invocation.invocationKind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
stop_reason:
|
|
(stopReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) ??
|
|
undefined,
|
|
costUSD,
|
|
didFallBackToNonStreaming,
|
|
isNonInteractiveSession,
|
|
print: hasPrintFlag,
|
|
isTTY: process.stdout.isTTY ?? false,
|
|
querySource:
|
|
querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
...(gateway
|
|
? {
|
|
gateway:
|
|
gateway as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
...(queryTracking
|
|
? {
|
|
queryChainId:
|
|
queryTracking.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
queryDepth: queryTracking.depth,
|
|
}
|
|
: {}),
|
|
permissionMode:
|
|
permissionMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
...(globalCacheStrategy
|
|
? {
|
|
globalCacheStrategy:
|
|
globalCacheStrategy as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
...(textContentLength !== undefined
|
|
? ({
|
|
textContentLength,
|
|
} as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
|
: {}),
|
|
...(thinkingContentLength !== undefined
|
|
? ({
|
|
thinkingContentLength,
|
|
} as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
|
: {}),
|
|
...(toolUseContentLengths !== undefined
|
|
? ({
|
|
toolUseContentLengths: jsonStringify(
|
|
toolUseContentLengths,
|
|
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
} as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
|
: {}),
|
|
...(connectorTextBlockCount !== undefined
|
|
? ({
|
|
connectorTextBlockCount,
|
|
} as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
|
|
: {}),
|
|
fastMode,
|
|
// Log cache_deleted_input_tokens for cache editing analysis. Casts needed
|
|
// because the field is intentionally not on NonNullableUsage (excluded from
|
|
// external builds). Set by updateUsage() when cache editing is active.
|
|
...(feature('CACHED_MICROCOMPACT') &&
|
|
((usage as unknown as { cache_deleted_input_tokens?: number })
|
|
.cache_deleted_input_tokens ?? 0) > 0
|
|
? {
|
|
cacheDeletedInputTokens: (
|
|
usage as unknown as { cache_deleted_input_tokens: number }
|
|
).cache_deleted_input_tokens,
|
|
}
|
|
: {}),
|
|
...(previousRequestId
|
|
? {
|
|
previousRequestId:
|
|
previousRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
}
|
|
: {}),
|
|
...(isPostCompaction ? { isPostCompaction } : {}),
|
|
...getAnthropicEnvMetadata(),
|
|
timeSinceLastApiCallMs,
|
|
})
|
|
|
|
setLastApiCompletionTimestamp(now)
|
|
}
|
|
|
|
export function logAPISuccessAndDuration({
|
|
model,
|
|
preNormalizedModel,
|
|
start,
|
|
startIncludingRetries,
|
|
ttftMs,
|
|
usage,
|
|
attempt,
|
|
messageCount,
|
|
messageTokens,
|
|
requestId,
|
|
stopReason,
|
|
didFallBackToNonStreaming,
|
|
querySource,
|
|
headers,
|
|
costUSD,
|
|
queryTracking,
|
|
permissionMode,
|
|
newMessages,
|
|
llmSpan,
|
|
globalCacheStrategy,
|
|
requestSetupMs,
|
|
attemptStartTimes,
|
|
fastMode,
|
|
previousRequestId,
|
|
betas,
|
|
}: {
|
|
model: string
|
|
preNormalizedModel: string
|
|
start: number
|
|
startIncludingRetries: number
|
|
ttftMs: number | null
|
|
usage: NonNullableUsage
|
|
attempt: number
|
|
messageCount: number
|
|
messageTokens: number
|
|
requestId: string | null
|
|
stopReason: BetaStopReason | null
|
|
didFallBackToNonStreaming: boolean
|
|
querySource: string
|
|
headers?: globalThis.Headers
|
|
costUSD: number
|
|
queryTracking?: QueryChainTracking
|
|
permissionMode?: PermissionMode
|
|
/** Assistant messages from the response - used to extract model_output and thinking_output
|
|
* when beta tracing is enabled */
|
|
newMessages?: AssistantMessage[]
|
|
/** The span from startLLMRequestSpan - pass this to correctly match responses to requests */
|
|
llmSpan?: Span
|
|
/** Strategy used for global prompt caching: 'tool_based', 'system_prompt', or 'none' */
|
|
globalCacheStrategy?: GlobalCacheStrategy
|
|
/** Time spent in pre-request setup before the successful attempt */
|
|
requestSetupMs?: number
|
|
/** Timestamps (Date.now()) of each attempt start — used for retry sub-spans in Perfetto */
|
|
attemptStartTimes?: number[]
|
|
fastMode?: boolean
|
|
/** Request ID from the previous API call in this session */
|
|
previousRequestId?: string | null
|
|
betas?: string[]
|
|
}): void {
|
|
const gateway = detectGateway({
|
|
headers,
|
|
baseUrl: process.env.ANTHROPIC_BASE_URL,
|
|
})
|
|
|
|
let textContentLength: number | undefined
|
|
let thinkingContentLength: number | undefined
|
|
let toolUseContentLengths: Record<string, number> | undefined
|
|
let connectorTextBlockCount: number | undefined
|
|
|
|
if (newMessages) {
|
|
let textLen = 0
|
|
let thinkingLen = 0
|
|
let hasToolUse = false
|
|
const toolLengths: Record<string, number> = {}
|
|
let connectorCount = 0
|
|
|
|
for (const msg of newMessages) {
|
|
for (const block of msg.message.content) {
|
|
if (block.type === 'text') {
|
|
textLen += block.text.length
|
|
} else if (feature('CONNECTOR_TEXT') && isConnectorTextBlock(block)) {
|
|
connectorCount++
|
|
} else if (block.type === 'thinking') {
|
|
thinkingLen += block.thinking.length
|
|
} else if (
|
|
block.type === 'tool_use' ||
|
|
block.type === 'server_tool_use' ||
|
|
block.type === 'mcp_tool_use'
|
|
) {
|
|
const inputLen = jsonStringify(block.input).length
|
|
const sanitizedName = sanitizeToolNameForAnalytics(block.name)
|
|
toolLengths[sanitizedName] =
|
|
(toolLengths[sanitizedName] ?? 0) + inputLen
|
|
hasToolUse = true
|
|
}
|
|
}
|
|
}
|
|
|
|
textContentLength = textLen
|
|
thinkingContentLength = thinkingLen > 0 ? thinkingLen : undefined
|
|
toolUseContentLengths = hasToolUse ? toolLengths : undefined
|
|
connectorTextBlockCount = connectorCount > 0 ? connectorCount : undefined
|
|
}
|
|
|
|
const durationMs = Date.now() - start
|
|
const durationMsIncludingRetries = Date.now() - startIncludingRetries
|
|
addToTotalDurationState(durationMsIncludingRetries, durationMs)
|
|
|
|
logAPISuccess({
|
|
model,
|
|
preNormalizedModel,
|
|
messageCount,
|
|
messageTokens,
|
|
usage,
|
|
durationMs,
|
|
durationMsIncludingRetries,
|
|
attempt,
|
|
ttftMs,
|
|
requestId,
|
|
stopReason,
|
|
costUSD,
|
|
didFallBackToNonStreaming,
|
|
querySource,
|
|
gateway,
|
|
queryTracking,
|
|
permissionMode,
|
|
globalCacheStrategy,
|
|
textContentLength,
|
|
thinkingContentLength,
|
|
toolUseContentLengths,
|
|
connectorTextBlockCount,
|
|
fastMode,
|
|
previousRequestId,
|
|
betas,
|
|
})
|
|
// Log API request event for OTLP
|
|
void logOTelEvent('api_request', {
|
|
model,
|
|
input_tokens: String(usage.input_tokens),
|
|
output_tokens: String(usage.output_tokens),
|
|
cache_read_tokens: String(usage.cache_read_input_tokens),
|
|
cache_creation_tokens: String(usage.cache_creation_input_tokens),
|
|
cost_usd: String(costUSD),
|
|
duration_ms: String(durationMs),
|
|
speed: fastMode ? 'fast' : 'normal',
|
|
})
|
|
|
|
// Extract model output, thinking output, and tool call flag when beta tracing is enabled
|
|
let modelOutput: string | undefined
|
|
let thinkingOutput: string | undefined
|
|
let hasToolCall: boolean | undefined
|
|
|
|
if (isBetaTracingEnabled() && newMessages) {
|
|
// Model output - visible to all users
|
|
modelOutput =
|
|
newMessages
|
|
.flatMap(m =>
|
|
m.message.content
|
|
.filter(c => c.type === 'text')
|
|
.map(c => (c as { type: 'text'; text: string }).text),
|
|
)
|
|
.join('\n') || undefined
|
|
|
|
// Thinking output - Ant-only (build-time gated)
|
|
if (process.env.USER_TYPE === 'ant') {
|
|
thinkingOutput =
|
|
newMessages
|
|
.flatMap(m =>
|
|
m.message.content
|
|
.filter(c => c.type === 'thinking')
|
|
.map(c => (c as { type: 'thinking'; thinking: string }).thinking),
|
|
)
|
|
.join('\n') || undefined
|
|
}
|
|
|
|
// Check if any tool_use blocks were in the output
|
|
hasToolCall = newMessages.some(m =>
|
|
m.message.content.some(c => c.type === 'tool_use'),
|
|
)
|
|
}
|
|
|
|
// Pass the span to correctly match responses to requests when beta tracing is enabled
|
|
endLLMRequestSpan(llmSpan, {
|
|
success: true,
|
|
inputTokens: usage.input_tokens,
|
|
outputTokens: usage.output_tokens,
|
|
cacheReadTokens: usage.cache_read_input_tokens,
|
|
cacheCreationTokens: usage.cache_creation_input_tokens,
|
|
attempt,
|
|
modelOutput,
|
|
thinkingOutput,
|
|
hasToolCall,
|
|
ttftMs: ttftMs ?? undefined,
|
|
requestSetupMs,
|
|
attemptStartTimes,
|
|
})
|
|
|
|
// Log first successful message for teleported sessions (reliability tracking)
|
|
const teleportInfo = getTeleportedSessionInfo()
|
|
if (teleportInfo?.isTeleported && !teleportInfo.hasLoggedFirstMessage) {
|
|
logEvent('tengu_teleport_first_message_success', {
|
|
session_id:
|
|
teleportInfo.sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
markFirstTeleportMessageLogged()
|
|
}
|
|
}
|