Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-ai-stop-reason.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@posthog/ai': minor
---

Add `$ai_stop_reason` property capturing the LLM's reason for stopping generation across all providers
10 changes: 10 additions & 0 deletions packages/ai/src/anthropic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export class WrappedMessages extends AnthropicOriginal.Messages {
const toolsInProgress: Map<string, ToolInProgress> = new Map()
let currentTextBlock: FormattedTextContent | null = null
let firstTokenTime: number | undefined
let stopReason: string | undefined

const usage: {
inputTokens: number
Expand Down Expand Up @@ -202,6 +203,13 @@ export class WrappedMessages extends AnthropicOriginal.Messages {
usage.webSearchCount = chunk.usage.server_tool_use.web_search_requests
}
}

if (chunk.type === 'message_delta' && 'delta' in chunk) {
const delta = chunk.delta
if ('stop_reason' in delta && typeof delta.stop_reason === 'string' && delta.stop_reason) {
stopReason = delta.stop_reason
}
}
Comment on lines +207 to +212

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unsafe type cast can be replaced with a structural check

chunk.delta is cast to { stop_reason?: string } to satisfy TypeScript, but the SDK already guarantees that a message_delta event's delta object contains stop_reason. Using an explicit property-existence check avoids the cast and is safer if the SDK types ever change.

Suggested change
if (chunk.type === 'message_delta' && 'delta' in chunk) {
const delta = chunk.delta as { stop_reason?: string }
if (delta.stop_reason) {
stopReason = delta.stop_reason
}
}
if (chunk.type === 'message_delta' && 'delta' in chunk) {
const delta = chunk.delta
if ('stop_reason' in delta && typeof delta.stop_reason === 'string' && delta.stop_reason) {
stopReason = delta.stop_reason
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/ai/src/anthropic/index.ts
Line: 207-212

Comment:
**Unsafe type cast can be replaced with a structural check**

`chunk.delta` is cast to `{ stop_reason?: string }` to satisfy TypeScript, but the SDK already guarantees that a `message_delta` event's `delta` object contains `stop_reason`. Using an explicit property-existence check avoids the cast and is safer if the SDK types ever change.

```suggestion
                if (chunk.type === 'message_delta' && 'delta' in chunk) {
                  const delta = chunk.delta
                  if ('stop_reason' in delta && typeof delta.stop_reason === 'string' && delta.stop_reason) {
                    stopReason = delta.stop_reason
                  }
                }
```

How can I resolve this? If you propose a fix, please make it concise.

}
usage.rawUsage = lastRawUsage

Expand Down Expand Up @@ -239,6 +247,7 @@ export class WrappedMessages extends AnthropicOriginal.Messages {
params: body,
httpStatus: 200,
usage,
stopReason,
tools: availableTools,
})
} catch (error: unknown) {
Expand Down Expand Up @@ -294,6 +303,7 @@ export class WrappedMessages extends AnthropicOriginal.Messages {
webSearchCount: result.usage.server_tool_use?.web_search_requests ?? 0,
rawUsage: result.usage,
},
stopReason: result.stop_reason ?? undefined,
tools: availableTools,
})
}
Expand Down
64 changes: 64 additions & 0 deletions packages/ai/src/gemini/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
GenerateContentParameters,
Part,
GenerateContentResponseUsageMetadata,
EmbedContentParameters,
EmbedContentResponse,
} from '@google/genai'
import type { GoogleGenAIOptions } from '@google/genai'
import { PostHog } from 'posthog-node'
Expand All @@ -15,6 +17,8 @@ import {
extractPosthogParams,
toContentString,
sendEventWithErrorToPosthog,
AIEvent,
withPrivacyMode,
} from '../utils'
import { sanitizeGemini } from '../sanitization'
import type { TokenUsage, FormattedContent, FormattedContentItem, FormattedMessage } from '../types'
Expand Down Expand Up @@ -57,6 +61,7 @@ export class WrappedModels {
const availableTools = extractAvailableToolCalls('gemini', geminiParams)

const metadata = response.usageMetadata
const finishReason = response.candidates?.[0]?.finishReason
await sendEventToPosthog({
client: this.phClient,
...posthogParams,
Expand All @@ -78,6 +83,7 @@ export class WrappedModels {
webSearchCount: calculateGoogleWebSearchCount(response),
rawUsage: metadata,
},
stopReason: finishReason ?? undefined,
tools: availableTools,
})

Expand Down Expand Up @@ -111,6 +117,7 @@ export class WrappedModels {
const startTime = Date.now()
const accumulatedContent: FormattedContent = []
let firstTokenTime: number | undefined
let stopReason: string | undefined
let usage: TokenUsage = {
inputTokens: 0,
outputTokens: 0,
Expand Down Expand Up @@ -149,6 +156,11 @@ export class WrappedModels {
}
}

// Track finish reason from candidates
if (chunk.candidates?.[0]?.finishReason) {
stopReason = chunk.candidates[0].finishReason
}

// Handle function calls from candidates
if (chunk.candidates && Array.isArray(chunk.candidates)) {
for (const candidate of chunk.candidates) {
Expand Down Expand Up @@ -217,6 +229,7 @@ export class WrappedModels {
webSearchCount: usage.webSearchCount,
rawUsage: usage.rawUsage,
},
stopReason,
tools: availableTools,
})
} catch (error: unknown) {
Expand All @@ -241,6 +254,57 @@ export class WrappedModels {
}
}

public async embedContent(params: EmbedContentParameters & MonitoringParams): Promise<EmbedContentResponse> {
const { providerParams: geminiParams, posthogParams } = extractPosthogParams(params)
const startTime = Date.now()

try {
const response = await this.client.models.embedContent(geminiParams as EmbedContentParameters)
const latency = (Date.now() - startTime) / 1000

const tokenCount =
response.embeddings?.reduce((sum, embedding) => sum + (embedding.statistics?.tokenCount ?? 0), 0) ?? 0

await sendEventToPosthog({
client: this.phClient,
...posthogParams,
eventType: AIEvent.Embedding,
model: geminiParams.model,
provider: 'gemini',
input: withPrivacyMode(this.phClient, posthogParams.privacyMode, geminiParams.contents),
output: null,
latency,
baseURL: 'https://generativelanguage.googleapis.com',
params: params as EmbedContentParameters & MonitoringParams,
httpStatus: 200,
usage: {
inputTokens: tokenCount,
},
})

return response
} catch (error: unknown) {
const latency = (Date.now() - startTime) / 1000
const enrichedError = await sendEventWithErrorToPosthog({
client: this.phClient,
...posthogParams,
eventType: AIEvent.Embedding,
model: geminiParams.model,
provider: 'gemini',
input: withPrivacyMode(this.phClient, posthogParams.privacyMode, geminiParams.contents),
output: null,
latency,
baseURL: 'https://generativelanguage.googleapis.com',
params: params as EmbedContentParameters & MonitoringParams,
usage: {
inputTokens: 0,
},
error: error,
})
throw enrichedError
}
}

private formatPartsAsContentBlocks(parts: unknown[]): FormattedContent {
const blocks: FormattedContent = []

Expand Down
39 changes: 39 additions & 0 deletions packages/ai/src/langchain/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,12 @@ export class LangChainCallbackHandler extends BaseCallbackHandler {
eventProperties['$ai_web_search_count'] = additionalTokenData.webSearchCount
}

// Extract stop reason from generation info
const stopReason = this._extractStopReason(output)
if (stopReason) {
eventProperties['$ai_stop_reason'] = stopReason
}

// Handle generations/completions
let completions
if (output.generations && Array.isArray(output.generations)) {
Expand Down Expand Up @@ -588,6 +594,39 @@ export class LangChainCallbackHandler extends BaseCallbackHandler {
return sanitizeLangChain(messageDict) as Record<string, any>
}

private _extractStopReason(output: LLMResult): string | undefined {
if (!output.generations || !Array.isArray(output.generations)) {
return undefined
}
const lastGeneration = output.generations[output.generations.length - 1]
if (!Array.isArray(lastGeneration) || lastGeneration.length === 0) {
return undefined
}
const gen = lastGeneration[0]

// Check generationInfo for finish_reason (OpenAI format)
if (gen.generationInfo?.finish_reason) {
return String(gen.generationInfo.finish_reason)
}

// Check generationInfo for response_metadata.stop_reason (Anthropic format)
if (gen.generationInfo?.response_metadata?.stop_reason) {
return String(gen.generationInfo.response_metadata.stop_reason)
}

// Check message response_metadata for finish_reason (common LangChain format)
if (gen.generationInfo?.response_metadata?.finish_reason) {
return String(gen.generationInfo.response_metadata.finish_reason)
}

// Check for stop_reason directly in generationInfo
if (gen.generationInfo?.stop_reason) {
return String(gen.generationInfo.stop_reason)
}

return undefined
}

private _parseUsageModel(usage: any, provider?: string, model?: string): [number, number, Record<string, any>] {
const conversionList: Array<[string, 'input' | 'output']> = [
['promptTokens', 'input'],
Expand Down
14 changes: 14 additions & 0 deletions packages/ai/src/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export class WrappedCompletions extends Completions {
let accumulatedContent = ''
let modelFromResponse: string | undefined
let firstTokenTime: number | undefined
let stopReason: string | undefined
let usage: {
inputTokens?: number
outputTokens?: number
Expand Down Expand Up @@ -152,6 +153,10 @@ export class WrappedCompletions extends Completions {

const choice = chunk?.choices?.[0]

if (choice?.finish_reason) {
stopReason = choice.finish_reason
}

const chunkWebSearchCount = calculateWebSearchCount(chunk)
if (chunkWebSearchCount > 0 && chunkWebSearchCount > (usage.webSearchCount ?? 0)) {
usage.webSearchCount = chunkWebSearchCount
Expand Down Expand Up @@ -273,6 +278,7 @@ export class WrappedCompletions extends Completions {
webSearchCount: usage.webSearchCount,
rawUsage: rawUsageData,
},
stopReason,
tools: availableTools,
})
} catch (error: unknown) {
Expand Down Expand Up @@ -324,6 +330,7 @@ export class WrappedCompletions extends Completions {
webSearchCount: calculateWebSearchCount(result),
rawUsage: result.usage,
},
stopReason: result.choices[0]?.finish_reason ?? undefined,
tools: availableTools,
})
}
Expand Down Expand Up @@ -408,6 +415,7 @@ export class WrappedResponses extends Responses {
let finalContent: unknown[] = []
let modelFromResponse: string | undefined
let firstTokenTime: number | undefined
let stopReason: string | undefined
let usage: {
inputTokens?: number
outputTokens?: number
Expand Down Expand Up @@ -446,6 +454,9 @@ export class WrappedResponses extends Responses {
chunk.response.output.length > 0
) {
finalContent = chunk.response.output
if (chunk.response.status) {
stopReason = chunk.response.status
}
}
if ('response' in chunk && chunk.response?.usage) {
rawUsageData = chunk.response.usage
Expand Down Expand Up @@ -485,6 +496,7 @@ export class WrappedResponses extends Responses {
webSearchCount: usage.webSearchCount,
rawUsage: rawUsageData,
},
stopReason,
tools: availableTools,
})
} catch (error: unknown) {
Expand Down Expand Up @@ -538,6 +550,7 @@ export class WrappedResponses extends Responses {
webSearchCount: calculateWebSearchCount(result),
rawUsage: result.usage,
},
stopReason: result.status ?? undefined,
tools: availableTools,
})
}
Expand Down Expand Up @@ -610,6 +623,7 @@ export class WrappedResponses extends Responses {
cacheReadInputTokens: result.usage?.input_tokens_details?.cached_tokens ?? 0,
rawUsage: result.usage,
},
stopReason: result.status ?? undefined,
})
return result
},
Expand Down
3 changes: 3 additions & 0 deletions packages/ai/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ export type SendEventToPosthogParams = {
MonitoringParams
error?: unknown
exceptionId?: string
stopReason?: string
tools?: ChatCompletionTool[] | AnthropicTool[] | GeminiTool[] | null
captureImmediate?: boolean
}
Expand Down Expand Up @@ -688,6 +689,7 @@ export const sendEventToPosthog = async ({
usage = {},
error,
exceptionId,
stopReason,
tools,
captureImmediate = false,
}: SendEventToPosthogParams): Promise<void> => {
Expand Down Expand Up @@ -745,6 +747,7 @@ export const sendEventToPosthog = async ({
...params.posthogProperties,
$ai_tokens_source: getTokensSource(params.posthogProperties),
...(distinctId ? {} : { $process_person_profile: false }),
...(stopReason ? { $ai_stop_reason: stopReason } : {}),
...(tools ? { $ai_tools: tools } : {}),
...errorData,
...costOverrideData,
Expand Down
20 changes: 20 additions & 0 deletions packages/ai/src/vercel/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,15 @@ export const wrapVercelLanguageModel = <T extends LanguageModel>(

adjustAnthropicV3CacheTokens(model, provider, usage)

// Extract finish reason - V2 returns a string, V3 returns an object with .unified
const rawFinishReason = result.finishReason
const finishReasonStr =
typeof rawFinishReason === 'string'
? rawFinishReason
: rawFinishReason && typeof rawFinishReason === 'object' && 'unified' in rawFinishReason
? String(rawFinishReason.unified)
: undefined

await sendEventToPosthog({
client: phClient,
distinctId: mergedOptions.posthogDistinctId,
Expand All @@ -480,6 +489,7 @@ export const wrapVercelLanguageModel = <T extends LanguageModel>(
params: mergedParams as any,
httpStatus: 200,
usage,
stopReason: finishReasonStr,
tools: availableTools,
captureImmediate: mergedOptions.posthogCaptureImmediate,
})
Expand Down Expand Up @@ -519,6 +529,7 @@ export const wrapVercelLanguageModel = <T extends LanguageModel>(
let firstTokenTime: number | undefined
let generatedText = ''
let reasoningText = ''
let stopReason: string | undefined
let usage: {
inputTokens?: number
outputTokens?: number
Expand Down Expand Up @@ -610,6 +621,14 @@ export const wrapVercelLanguageModel = <T extends LanguageModel>(
cacheReadInputTokens: extractCacheReadTokens(chunkUsage),
...additionalTokenValues,
}

// Extract finish reason - V2 returns a string, V3 returns an object with .unified
const rawFinishReason = chunk.finishReason
if (typeof rawFinishReason === 'string') {
stopReason = rawFinishReason
} else if (rawFinishReason && typeof rawFinishReason === 'object' && 'unified' in rawFinishReason) {
stopReason = String(rawFinishReason.unified)
}
}
controller.enqueue(chunk)
},
Expand Down Expand Up @@ -676,6 +695,7 @@ export const wrapVercelLanguageModel = <T extends LanguageModel>(
params: mergedParams as any,
httpStatus: 200,
usage: finalUsage,
stopReason,
tools: availableTools,
captureImmediate: mergedOptions.posthogCaptureImmediate,
})
Expand Down
Loading
Loading