From bf5b13abcb6348b5cb612d57e5064aa1280357f0 Mon Sep 17 00:00:00 2001 From: John Kim Date: Tue, 19 May 2026 16:01:10 -0700 Subject: [PATCH 1/3] feat(ai): add $ai_completion_id and $ai_provider_metadata to OpenAI events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new auto-captured properties to `$ai_generation` events emitted by the OpenAI and Azure OpenAI wrappers, enabling direct correlation between PostHog events and OpenAI's Logs dashboard (`platform.openai.com/logs/{completion_id}`): - `$ai_completion_id` — provider-assigned response ID (e.g. `chatcmpl-…`, `resp_…`). Generalises across providers, so it lives at the top of the `$ai_*` namespace. - `$ai_provider_metadata` — OpenAI-specific fields (`system_fingerprint`, `request_id`) collected under a single blob rather than polluting the shared schema. Sets the pattern for Anthropic / Gemini wrappers to follow. Both options are accepted by the public `captureAiGeneration` primitive, so external instrumentation produces identical events. Coverage spans non-streaming and streaming Chat Completions plus Responses API (`create` + `parse`) for both OpenAI and Azure OpenAI. Streaming paths extract `id` / `system_fingerprint` from accumulated chunks; the `x-request-id` header is read via the OpenAI SDK's semi-private `_request_id` field through a small typed helper. Streaming `request_id` capture (which needs `.withResponse()`) is left as a follow-up — see review thread on #3306. Addresses review feedback on #3306: - restructure `system_fingerprint` / `request_id` under `$ai_provider_metadata` - fix the TS2353 build error on the test mock by widening `ChatCompletion` with `{ _request_id?: string }` - consolidate the duplicated `(result as any)._request_id` cast into `extractRequestId` with an explanatory comment in one place - add Responses API `$ai_completion_id` / `request_id` test coverage Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/ai-completion-id.md | 5 ++++ packages/ai/src/captureAiGeneration.ts | 19 +++++++++++++ packages/ai/src/openai/azure.ts | 30 ++++++++++++++++++-- packages/ai/src/openai/index.ts | 30 ++++++++++++++++++-- packages/ai/src/openai/utils.ts | 31 ++++++++++++++++++++ packages/ai/tests/azure-openai.test.ts | 10 ++++++- packages/ai/tests/openai-utils.test.ts | 39 ++++++++++++++++++++++++++ packages/ai/tests/openai.test.ts | 26 +++++++++++++++-- 8 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 .changeset/ai-completion-id.md create mode 100644 packages/ai/tests/openai-utils.test.ts diff --git a/.changeset/ai-completion-id.md b/.changeset/ai-completion-id.md new file mode 100644 index 0000000000..14f37ff48c --- /dev/null +++ b/.changeset/ai-completion-id.md @@ -0,0 +1,5 @@ +--- +'@posthog/ai': minor +--- + +Add `$ai_completion_id` and `$ai_provider_metadata` to `$ai_generation` events for the OpenAI and Azure OpenAI wrappers. `$ai_completion_id` is the provider's response ID (e.g. `chatcmpl-…` / `resp_…`); `$ai_provider_metadata` carries OpenAI-specific fields (`system_fingerprint`, `request_id`). Together they enable correlating PostHog events with OpenAI's Logs dashboard (`platform.openai.com/logs/{completion_id}`). The same options (`completionId`, `providerMetadata`) are now accepted by the public `captureAiGeneration` primitive so other provider wrappers can follow the same pattern. diff --git a/packages/ai/src/captureAiGeneration.ts b/packages/ai/src/captureAiGeneration.ts index 328da8cde5..eb85ef09bc 100644 --- a/packages/ai/src/captureAiGeneration.ts +++ b/packages/ai/src/captureAiGeneration.ts @@ -60,6 +60,21 @@ export interface CaptureAiGenerationOptions { tools?: ChatCompletionTool[] | AnthropicTool[] | GeminiTool[] | null stopReason?: string + + /** + * Provider-assigned ID for the generation (e.g. OpenAI's `chatcmpl-…` / + * `resp_…`). Maps to `$ai_completion_id`. Response IDs generalize across + * providers, so this lives in the shared schema rather than under + * `providerMetadata`. + */ + completionId?: string + /** + * Provider-specific response metadata that has no place in the shared, + * provider-agnostic `$ai_*` schema (e.g. OpenAI's `system_fingerprint` and + * `request_id`). Maps to `$ai_provider_metadata`; omitted when empty. + */ + providerMetadata?: Record + /** When set, the event is captured as an error. */ error?: unknown @@ -161,6 +176,10 @@ export const captureAiGeneration = async (client: PostHog, options: CaptureAiGen ...(options.distinctId ? {} : { $process_person_profile: false }), ...(options.stopReason ? { $ai_stop_reason: options.stopReason } : {}), ...(options.tools ? { $ai_tools: options.tools } : {}), + ...(options.completionId ? { $ai_completion_id: options.completionId } : {}), + ...(options.providerMetadata && Object.keys(options.providerMetadata).length > 0 + ? { $ai_provider_metadata: options.providerMetadata } + : {}), ...errorData, ...costOverrideData, } diff --git a/packages/ai/src/openai/azure.ts b/packages/ai/src/openai/azure.ts index 8d4eb1fe4f..541d741a06 100644 --- a/packages/ai/src/openai/azure.ts +++ b/packages/ai/src/openai/azure.ts @@ -16,7 +16,7 @@ import type { ResponseCreateParamsWithTools, ExtractParsedContentFromParams } fr import type { FormattedMessage, FormattedContent, FormattedFunctionCall } from '../types' import { sanitizeOpenAI } from '../sanitization' import { extractPosthogParams } from '../utils' -import { isResponseTokenChunk } from './utils' +import { isResponseTokenChunk, extractRequestId, buildProviderMetadata } from './utils' type ChatCompletion = OpenAIOrignal.ChatCompletion type ChatCompletionChunk = OpenAIOrignal.ChatCompletionChunk @@ -107,6 +107,8 @@ export class WrappedCompletions extends AzureOpenAI.Chat.Completions { const contentBlocks: FormattedContent = [] let accumulatedContent = '' let modelFromResponse: string | undefined + let completionIdFromResponse: string | undefined + let systemFingerprintFromResponse: string | undefined let firstTokenTime: number | undefined let usage: { inputTokens?: number @@ -129,10 +131,16 @@ export class WrappedCompletions extends AzureOpenAI.Chat.Completions { >() for await (const chunk of stream1) { - // Extract model from response if not in params + // Extract model and completion metadata from chunk (Chat Completions chunks carry these fields) if (!modelFromResponse && chunk.model) { modelFromResponse = chunk.model } + if (!completionIdFromResponse && chunk.id) { + completionIdFromResponse = chunk.id + } + if (systemFingerprintFromResponse === undefined && chunk.system_fingerprint) { + systemFingerprintFromResponse = chunk.system_fingerprint + } const choice = chunk?.choices?.[0] @@ -241,6 +249,8 @@ export class WrappedCompletions extends AzureOpenAI.Chat.Completions { modelParameters: getModelParams(body), httpStatus: 200, usage, + completionId: completionIdFromResponse, + providerMetadata: buildProviderMetadata({ systemFingerprint: systemFingerprintFromResponse }), }) } catch (error: unknown) { await captureAiGeneration(this.phClient, { @@ -285,6 +295,11 @@ export class WrappedCompletions extends AzureOpenAI.Chat.Completions { reasoningTokens: result.usage?.completion_tokens_details?.reasoning_tokens ?? 0, cacheReadInputTokens: result.usage?.prompt_tokens_details?.cached_tokens ?? 0, }, + completionId: result.id, + providerMetadata: buildProviderMetadata({ + systemFingerprint: result.system_fingerprint, + requestId: extractRequestId(result), + }), }) } return result @@ -366,6 +381,7 @@ export class WrappedResponses extends AzureOpenAI.Responses { try { let finalContent: any[] = [] let modelFromResponse: string | undefined + let completionIdFromResponse: string | undefined let firstTokenTime: number | undefined let usage: { inputTokens?: number @@ -384,10 +400,13 @@ export class WrappedResponses extends AzureOpenAI.Responses { } if ('response' in chunk && chunk.response) { - // Extract model from response if not in params (for stored prompts) + // Extract model and completion ID from the response object in the chunk (for stored prompts) if (!modelFromResponse && chunk.response.model) { modelFromResponse = chunk.response.model } + if (!completionIdFromResponse && chunk.response.id) { + completionIdFromResponse = chunk.response.id + } } if ( chunk.type === 'response.completed' && @@ -421,6 +440,7 @@ export class WrappedResponses extends AzureOpenAI.Responses { modelParameters: getModelParams(body), httpStatus: 200, usage, + completionId: completionIdFromResponse, }) } catch (error: unknown) { await captureAiGeneration(this.phClient, { @@ -464,6 +484,8 @@ export class WrappedResponses extends AzureOpenAI.Responses { reasoningTokens: result.usage?.output_tokens_details?.reasoning_tokens ?? 0, cacheReadInputTokens: result.usage?.input_tokens_details?.cached_tokens ?? 0, }, + completionId: result.id, + providerMetadata: buildProviderMetadata({ requestId: extractRequestId(result) }), }) } return result @@ -526,6 +548,8 @@ export class WrappedResponses extends AzureOpenAI.Responses { reasoningTokens: result.usage?.output_tokens_details?.reasoning_tokens ?? 0, cacheReadInputTokens: result.usage?.input_tokens_details?.cached_tokens ?? 0, }, + completionId: result.id, + providerMetadata: buildProviderMetadata({ requestId: extractRequestId(result) }), }) return result }, diff --git a/packages/ai/src/openai/index.ts b/packages/ai/src/openai/index.ts index 927a9aa5ad..942b429e59 100644 --- a/packages/ai/src/openai/index.ts +++ b/packages/ai/src/openai/index.ts @@ -18,7 +18,7 @@ import type { ResponseCreateParamsWithTools, ExtractParsedContentFromParams } fr import type { FormattedMessage, FormattedContent, FormattedFunctionCall } from '../types' import { sanitizeOpenAI, sanitizeOpenAIResponse } from '../sanitization' import { extractPosthogParams } from '../utils' -import { isResponseTokenChunk } from './utils' +import { isResponseTokenChunk, extractRequestId, buildProviderMetadata } from './utils' const Chat = OpenAIOrignal.Chat const Completions = Chat.Completions @@ -120,6 +120,8 @@ export class WrappedCompletions extends Completions { const contentBlocks: FormattedContent = [] let accumulatedContent = '' let modelFromResponse: string | undefined + let completionIdFromResponse: string | undefined + let systemFingerprintFromResponse: string | undefined let firstTokenTime: number | undefined let stopReason: string | undefined let usage: { @@ -146,10 +148,16 @@ export class WrappedCompletions extends Completions { let rawUsageData: unknown for await (const chunk of stream1) { - // Extract model from chunk (Chat Completions chunks have model field) + // Extract model and completion metadata from chunk (Chat Completions chunks carry these fields) if (!modelFromResponse && chunk.model) { modelFromResponse = chunk.model } + if (!completionIdFromResponse && chunk.id) { + completionIdFromResponse = chunk.id + } + if (systemFingerprintFromResponse === undefined && chunk.system_fingerprint) { + systemFingerprintFromResponse = chunk.system_fingerprint + } const choice = chunk?.choices?.[0] @@ -279,6 +287,8 @@ export class WrappedCompletions extends Completions { }, stopReason, tools: availableTools, + completionId: completionIdFromResponse, + providerMetadata: buildProviderMetadata({ systemFingerprint: systemFingerprintFromResponse }), }) } catch (error: unknown) { await captureAiGeneration(this.phClient, { @@ -329,6 +339,11 @@ export class WrappedCompletions extends Completions { }, stopReason: result.choices[0]?.finish_reason ?? undefined, tools: availableTools, + completionId: result.id, + providerMetadata: buildProviderMetadata({ + systemFingerprint: result.system_fingerprint, + requestId: extractRequestId(result), + }), }) } return result @@ -410,6 +425,7 @@ export class WrappedResponses extends Responses { try { let finalContent: unknown[] = [] let modelFromResponse: string | undefined + let completionIdFromResponse: string | undefined let firstTokenTime: number | undefined let stopReason: string | undefined let usage: { @@ -432,10 +448,13 @@ export class WrappedResponses extends Responses { } if ('response' in chunk && chunk.response) { - // Extract model from response object in chunk (for stored prompts) + // Extract model and completion ID from the response object in the chunk (for stored prompts) if (!modelFromResponse && chunk.response.model) { modelFromResponse = chunk.response.model } + if (!completionIdFromResponse && chunk.response.id) { + completionIdFromResponse = chunk.response.id + } const chunkWebSearchCount = calculateWebSearchCount(chunk.response) if (chunkWebSearchCount > 0 && chunkWebSearchCount > (usage.webSearchCount ?? 0)) { @@ -493,6 +512,7 @@ export class WrappedResponses extends Responses { }, stopReason, tools: availableTools, + completionId: completionIdFromResponse, }) } catch (error: unknown) { await captureAiGeneration(this.phClient, { @@ -545,6 +565,8 @@ export class WrappedResponses extends Responses { }, stopReason: result.status ?? undefined, tools: availableTools, + completionId: result.id, + providerMetadata: buildProviderMetadata({ requestId: extractRequestId(result) }), }) } return result @@ -615,6 +637,8 @@ export class WrappedResponses extends Responses { rawUsage: result.usage, }, stopReason: result.status ?? undefined, + completionId: result.id, + providerMetadata: buildProviderMetadata({ requestId: extractRequestId(result) }), }) return result }, diff --git a/packages/ai/src/openai/utils.ts b/packages/ai/src/openai/utils.ts index 058753f2bc..9c450539fd 100644 --- a/packages/ai/src/openai/utils.ts +++ b/packages/ai/src/openai/utils.ts @@ -16,3 +16,34 @@ export function isResponseTokenChunk(chunk: OpenAI.Responses.ResponseStreamEvent chunk.type === 'response.refusal.delta' ) } + +/** + * Reads the OpenAI SDK's `_request_id` field from a response object. The SDK + * attaches the `x-request-id` response header here, but it is not part of the + * public response types, so it has to be read through a cast. Used to populate + * `$ai_provider_metadata.request_id`. + */ +export function extractRequestId(result: unknown): string | undefined { + return (result as { _request_id?: string | null } | null | undefined)?._request_id ?? undefined +} + +/** + * Assembles the `$ai_provider_metadata` blob for OpenAI / Azure OpenAI events. + * Provider-specific fields (system fingerprint, request id) live here rather + * than in the shared, provider-agnostic `$ai_*` namespace. Only keys with a + * truthy value are included, and `undefined` is returned when there is nothing + * to report so the property can be omitted from the event entirely. + */ +export function buildProviderMetadata(fields: { + systemFingerprint?: string | null + requestId?: string | null +}): Record | undefined { + const metadata: Record = {} + if (fields.systemFingerprint) { + metadata.system_fingerprint = fields.systemFingerprint + } + if (fields.requestId) { + metadata.request_id = fields.requestId + } + return Object.keys(metadata).length > 0 ? metadata : undefined +} diff --git a/packages/ai/tests/azure-openai.test.ts b/packages/ai/tests/azure-openai.test.ts index 9c56a02bb9..cddb32419a 100644 --- a/packages/ai/tests/azure-openai.test.ts +++ b/packages/ai/tests/azure-openai.test.ts @@ -142,10 +142,13 @@ describe('PostHogAzureOpenAI - Embeddings test suite', () => { conditionalTest('basic completion', async () => { // Set up mock response for chat completions const mockAzureChatResponse = { - id: 'test-response-id', + id: 'chatcmpl-test-response-id', model: 'gpt-4', object: 'chat.completion', created: Date.now() / 1000, + system_fingerprint: 'fp_test123', + // `_request_id` is attached by the OpenAI SDK from the `x-request-id` header. + _request_id: 'req_test-request-id', choices: [ { index: 0, @@ -202,6 +205,11 @@ describe('PostHogAzureOpenAI - Embeddings test suite', () => { expect(properties['$ai_http_status']).toBe(200) expect(properties['foo']).toBe('bar') expect(typeof properties['$ai_latency']).toBe('number') + expect(properties['$ai_completion_id']).toBe('chatcmpl-test-response-id') + expect(properties['$ai_provider_metadata']).toEqual({ + system_fingerprint: 'fp_test123', + request_id: 'req_test-request-id', + }) }) conditionalTest('groups', async () => { diff --git a/packages/ai/tests/openai-utils.test.ts b/packages/ai/tests/openai-utils.test.ts new file mode 100644 index 0000000000..975818715d --- /dev/null +++ b/packages/ai/tests/openai-utils.test.ts @@ -0,0 +1,39 @@ +import { extractRequestId, buildProviderMetadata } from '../src/openai/utils' + +describe('extractRequestId', () => { + it('reads `_request_id` from a response object', () => { + expect(extractRequestId({ _request_id: 'req_abc123' })).toBe('req_abc123') + }) + + it('returns undefined when `_request_id` is absent', () => { + expect(extractRequestId({ id: 'chatcmpl-1' })).toBeUndefined() + }) + + it.each([[null], [undefined], ['not-an-object'], [42]])('returns undefined for non-object input %p', (input) => { + expect(extractRequestId(input)).toBeUndefined() + }) + + it('returns undefined when `_request_id` is null', () => { + expect(extractRequestId({ _request_id: null })).toBeUndefined() + }) +}) + +describe('buildProviderMetadata', () => { + it('includes both keys when present', () => { + expect(buildProviderMetadata({ systemFingerprint: 'fp_1', requestId: 'req_1' })).toEqual({ + system_fingerprint: 'fp_1', + request_id: 'req_1', + }) + }) + + it('omits keys whose value is missing', () => { + expect(buildProviderMetadata({ systemFingerprint: 'fp_1' })).toEqual({ system_fingerprint: 'fp_1' }) + expect(buildProviderMetadata({ requestId: 'req_1' })).toEqual({ request_id: 'req_1' }) + }) + + it('returns undefined when there is nothing to report', () => { + expect(buildProviderMetadata({})).toBeUndefined() + expect(buildProviderMetadata({ systemFingerprint: undefined, requestId: undefined })).toBeUndefined() + expect(buildProviderMetadata({ systemFingerprint: null, requestId: null })).toBeUndefined() + }) +}) diff --git a/packages/ai/tests/openai.test.ts b/packages/ai/tests/openai.test.ts index ce72a56cf0..3382a92b02 100644 --- a/packages/ai/tests/openai.test.ts +++ b/packages/ai/tests/openai.test.ts @@ -11,7 +11,9 @@ interface MockAsyncIterator { [Symbol.asyncIterator](): AsyncIterator } -let mockOpenAiChatResponse: ChatCompletion = {} as ChatCompletion +// `_request_id` is attached by the OpenAI SDK from the `x-request-id` response +// header; it is not part of the public `ChatCompletion` type, so widen it here. +let mockOpenAiChatResponse: ChatCompletion & { _request_id?: string } = {} as ChatCompletion let mockOpenAiParsedResponse: ParsedResponse = {} as ParsedResponse let mockOpenAiEmbeddingResponse: any = {} let mockStreamChunks: ChatCompletionChunk[] = [] @@ -155,6 +157,7 @@ const createMockStreamChunks = (options: { model: 'gpt-4', object: 'chat.completion.chunk', created: Date.now() / 1000, + system_fingerprint: 'fp_stream_test', } if (options.content) { @@ -283,10 +286,12 @@ describe('PostHogOpenAI - Jest test suite', () => { // Default chat completion mock for non-streaming responses mockOpenAiChatResponse = { - id: 'test-response-id', + id: 'chatcmpl-test-response-id', model: 'gpt-4', object: 'chat.completion', created: Date.now() / 1000, + system_fingerprint: 'fp_test123', + _request_id: 'req_test-request-id', choices: [ { index: 0, @@ -346,6 +351,7 @@ describe('PostHogOpenAI - Jest test suite', () => { input: [], metadata: null, response_id: 'test-parsed-response-id', + _request_id: 'req_test-parsed-request-id', service_tier: null, system_fingerprint: null, queue_time: null, @@ -448,6 +454,11 @@ describe('PostHogOpenAI - Jest test suite', () => { expect(properties['foo']).toBe('bar') expect(typeof properties['$ai_latency']).toBe('number') expect(properties['$ai_usage']).toBeDefined() + expect(properties['$ai_completion_id']).toBe('chatcmpl-test-response-id') + expect(properties['$ai_provider_metadata']).toEqual({ + system_fingerprint: 'fp_test123', + request_id: 'req_test-request-id', + }) }) conditionalTest('groups', async () => { @@ -629,6 +640,9 @@ describe('PostHogOpenAI - Jest test suite', () => { expect(properties['$ai_http_status']).toBe(200) expect(properties['foo']).toBe('bar') expect(typeof properties['$ai_latency']).toBe('number') + expect(properties['$ai_completion_id']).toBe('test-parsed-response-id') + // Responses API has no system_fingerprint, so only request_id is reported. + expect(properties['$ai_provider_metadata']).toEqual({ request_id: 'req_test-parsed-request-id' }) }) conditionalTest('responses parse with instructions parameter', async () => { @@ -759,6 +773,10 @@ describe('PostHogOpenAI - Jest test suite', () => { expect(properties['$ai_input_tokens']).toBe(25) expect(properties['$ai_output_tokens']).toBe(15) expect(properties['streamTest']).toBe(true) + // Completion metadata is accumulated from the streamed chunks. The + // streaming path has no request id, so only system_fingerprint is reported. + expect(properties['$ai_completion_id']).toBe('chatcmpl-test') + expect(properties['$ai_provider_metadata']).toEqual({ system_fingerprint: 'fp_stream_test' }) }) conditionalTest('handles streaming with tool calls', async () => { @@ -1574,6 +1592,7 @@ describe('PostHogOpenAI - Jest test suite', () => { // Mock Responses API response with web_search_call items const mockResponsesResult = { id: 'resp_test', + _request_id: 'req_test-responses-create', output: [ { type: 'web_search_call', @@ -1617,6 +1636,9 @@ describe('PostHogOpenAI - Jest test suite', () => { // Should detect 2 web_search_call items (exact count) expect(properties['$ai_web_search_count']).toBe(2) + // Completion metadata for the non-streaming Responses API create path. + expect(properties['$ai_completion_id']).toBe('resp_test') + expect(properties['$ai_provider_metadata']).toEqual({ request_id: 'req_test-responses-create' }) }) conditionalTest('should track web search in streaming with citations on final chunk', async () => { From 1547db602d676b63928b8555897dd63959723c03 Mon Sep 17 00:00:00 2001 From: John Kim Date: Tue, 19 May 2026 16:13:58 -0700 Subject: [PATCH 2/3] fix(ai): address Greptile feedback on the revision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Streaming error path now surfaces accumulated completion metadata. When a stream fails mid-flight after consuming chunks that carried `chunk.id` / `chunk.system_fingerprint`, the error event was being emitted without `$ai_completion_id` / `$ai_provider_metadata` — the exact correlation IDs this PR is meant to enable. Hoisted the accumulators above the `try` block and pass them in the `catch` capture for both `index.ts` and `azure.ts`, chat and Responses. - Convert the `openai-utils` tests to parameterised `it.each` tables per the repo's "always prefer parameterised tests" convention; each input/output pair is now its own row so a failure points to the specific case that broke. --- packages/ai/src/openai/azure.ts | 18 +++++-- packages/ai/src/openai/index.ts | 18 +++++-- packages/ai/tests/openai-utils.test.ts | 67 ++++++++++++++------------ 3 files changed, 67 insertions(+), 36 deletions(-) diff --git a/packages/ai/src/openai/azure.ts b/packages/ai/src/openai/azure.ts index 541d741a06..5e448e88a5 100644 --- a/packages/ai/src/openai/azure.ts +++ b/packages/ai/src/openai/azure.ts @@ -103,12 +103,14 @@ export class WrappedCompletions extends AzureOpenAI.Chat.Completions { if ('tee' in value) { const [stream1, stream2] = value.tee() ;(async () => { + // Hoisted so the catch block can surface whatever was accumulated + // from the streamed chunks before the failure. + let completionIdFromResponse: string | undefined + let systemFingerprintFromResponse: string | undefined try { const contentBlocks: FormattedContent = [] let accumulatedContent = '' let modelFromResponse: string | undefined - let completionIdFromResponse: string | undefined - let systemFingerprintFromResponse: string | undefined let firstTokenTime: number | undefined let usage: { inputTokens?: number @@ -263,6 +265,11 @@ export class WrappedCompletions extends AzureOpenAI.Chat.Completions { baseURL: this.baseURL, modelParameters: getModelParams(body), usage: { inputTokens: 0, outputTokens: 0 }, + // If the stream fails mid-flight, surface whatever completion + // metadata the consumed chunks already provided so the error + // event can still be correlated to OpenAI's Logs dashboard. + completionId: completionIdFromResponse, + providerMetadata: buildProviderMetadata({ systemFingerprint: systemFingerprintFromResponse }), error: error, }) throw error @@ -378,10 +385,12 @@ export class WrappedResponses extends AzureOpenAI.Responses { if ('tee' in value && typeof (value as any).tee === 'function') { const [stream1, stream2] = (value as any).tee() ;(async () => { + // Hoisted so the catch block can surface the completion ID that + // was accumulated from the streamed chunks before the failure. + let completionIdFromResponse: string | undefined try { let finalContent: any[] = [] let modelFromResponse: string | undefined - let completionIdFromResponse: string | undefined let firstTokenTime: number | undefined let usage: { inputTokens?: number @@ -453,6 +462,9 @@ export class WrappedResponses extends AzureOpenAI.Responses { baseURL: this.baseURL, modelParameters: getModelParams(body), usage: { inputTokens: 0, outputTokens: 0 }, + // Surface the completion ID from any chunks consumed before + // the stream failed so the error event remains correlatable. + completionId: completionIdFromResponse, error: error, }) throw error diff --git a/packages/ai/src/openai/index.ts b/packages/ai/src/openai/index.ts index 942b429e59..06fb18b4e0 100644 --- a/packages/ai/src/openai/index.ts +++ b/packages/ai/src/openai/index.ts @@ -116,12 +116,14 @@ export class WrappedCompletions extends Completions { if ('tee' in value) { const [stream1, stream2] = value.tee() ;(async () => { + // Hoisted so the catch block can surface whatever was accumulated + // from the streamed chunks before the failure. + let completionIdFromResponse: string | undefined + let systemFingerprintFromResponse: string | undefined try { const contentBlocks: FormattedContent = [] let accumulatedContent = '' let modelFromResponse: string | undefined - let completionIdFromResponse: string | undefined - let systemFingerprintFromResponse: string | undefined let firstTokenTime: number | undefined let stopReason: string | undefined let usage: { @@ -301,6 +303,11 @@ export class WrappedCompletions extends Completions { baseURL: this.baseURL, modelParameters: getModelParams(body), usage: { inputTokens: 0, outputTokens: 0 }, + // If the stream fails mid-flight, surface whatever completion + // metadata the consumed chunks already provided so the error + // event can still be correlated to OpenAI's Logs dashboard. + completionId: completionIdFromResponse, + providerMetadata: buildProviderMetadata({ systemFingerprint: systemFingerprintFromResponse }), error, }) throw error @@ -422,10 +429,12 @@ export class WrappedResponses extends Responses { if ('tee' in value && typeof value.tee === 'function') { const [stream1, stream2] = value.tee() ;(async () => { + // Hoisted so the catch block can surface the completion ID that + // was accumulated from the streamed chunks before the failure. + let completionIdFromResponse: string | undefined try { let finalContent: unknown[] = [] let modelFromResponse: string | undefined - let completionIdFromResponse: string | undefined let firstTokenTime: number | undefined let stopReason: string | undefined let usage: { @@ -528,6 +537,9 @@ export class WrappedResponses extends Responses { baseURL: this.baseURL, modelParameters: getModelParams(body), usage: { inputTokens: 0, outputTokens: 0 }, + // Surface the completion ID from any chunks consumed before + // the stream failed so the error event remains correlatable. + completionId: completionIdFromResponse, error, }) throw error diff --git a/packages/ai/tests/openai-utils.test.ts b/packages/ai/tests/openai-utils.test.ts index 975818715d..a663e596bc 100644 --- a/packages/ai/tests/openai-utils.test.ts +++ b/packages/ai/tests/openai-utils.test.ts @@ -1,39 +1,46 @@ import { extractRequestId, buildProviderMetadata } from '../src/openai/utils' describe('extractRequestId', () => { - it('reads `_request_id` from a response object', () => { - expect(extractRequestId({ _request_id: 'req_abc123' })).toBe('req_abc123') - }) - - it('returns undefined when `_request_id` is absent', () => { - expect(extractRequestId({ id: 'chatcmpl-1' })).toBeUndefined() - }) - - it.each([[null], [undefined], ['not-an-object'], [42]])('returns undefined for non-object input %p', (input) => { - expect(extractRequestId(input)).toBeUndefined() - }) - - it('returns undefined when `_request_id` is null', () => { - expect(extractRequestId({ _request_id: null })).toBeUndefined() + it.each<[name: string, input: unknown, expected: string | undefined]>([ + ['reads `_request_id` when present', { _request_id: 'req_abc123' }, 'req_abc123'], + ['returns undefined when `_request_id` is absent', { id: 'chatcmpl-1' }, undefined], + ['returns undefined when `_request_id` is null', { _request_id: null }, undefined], + ['returns undefined for null input', null, undefined], + ['returns undefined for undefined input', undefined, undefined], + ['returns undefined for string input', 'not-an-object', undefined], + ['returns undefined for numeric input', 42, undefined], + ])('%s', (_name, input, expected) => { + expect(extractRequestId(input)).toBe(expected) }) }) describe('buildProviderMetadata', () => { - it('includes both keys when present', () => { - expect(buildProviderMetadata({ systemFingerprint: 'fp_1', requestId: 'req_1' })).toEqual({ - system_fingerprint: 'fp_1', - request_id: 'req_1', - }) - }) - - it('omits keys whose value is missing', () => { - expect(buildProviderMetadata({ systemFingerprint: 'fp_1' })).toEqual({ system_fingerprint: 'fp_1' }) - expect(buildProviderMetadata({ requestId: 'req_1' })).toEqual({ request_id: 'req_1' }) - }) - - it('returns undefined when there is nothing to report', () => { - expect(buildProviderMetadata({})).toBeUndefined() - expect(buildProviderMetadata({ systemFingerprint: undefined, requestId: undefined })).toBeUndefined() - expect(buildProviderMetadata({ systemFingerprint: null, requestId: null })).toBeUndefined() + it.each< + [ + name: string, + input: Parameters[0], + expected: ReturnType, + ] + >([ + [ + 'includes both keys when both values are present', + { systemFingerprint: 'fp_1', requestId: 'req_1' }, + { system_fingerprint: 'fp_1', request_id: 'req_1' }, + ], + [ + 'omits requestId when only systemFingerprint is present', + { systemFingerprint: 'fp_1' }, + { system_fingerprint: 'fp_1' }, + ], + ['omits systemFingerprint when only requestId is present', { requestId: 'req_1' }, { request_id: 'req_1' }], + ['returns undefined for an empty input object', {}, undefined], + [ + 'returns undefined when both values are undefined', + { systemFingerprint: undefined, requestId: undefined }, + undefined, + ], + ['returns undefined when both values are null', { systemFingerprint: null, requestId: null }, undefined], + ])('%s', (_name, input, expected) => { + expect(buildProviderMetadata(input)).toEqual(expected) }) }) From 929a94959367db0ae7e11f15d17e61ed8f6d3c29 Mon Sep 17 00:00:00 2001 From: Carlos Marchal Date: Thu, 28 May 2026 18:09:37 +0200 Subject: [PATCH 3/3] fix(aiobs): align system_fingerprint guard with surrounding style Generated-By: PostHog Code Task-Id: 672e907b-e741-4f0e-abf2-76722b296982 --- packages/ai/src/openai/azure.ts | 2 +- packages/ai/src/openai/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/openai/azure.ts b/packages/ai/src/openai/azure.ts index 5e448e88a5..6e56210ba7 100644 --- a/packages/ai/src/openai/azure.ts +++ b/packages/ai/src/openai/azure.ts @@ -140,7 +140,7 @@ export class WrappedCompletions extends AzureOpenAI.Chat.Completions { if (!completionIdFromResponse && chunk.id) { completionIdFromResponse = chunk.id } - if (systemFingerprintFromResponse === undefined && chunk.system_fingerprint) { + if (!systemFingerprintFromResponse && chunk.system_fingerprint) { systemFingerprintFromResponse = chunk.system_fingerprint } diff --git a/packages/ai/src/openai/index.ts b/packages/ai/src/openai/index.ts index 06fb18b4e0..a0fc71e93d 100644 --- a/packages/ai/src/openai/index.ts +++ b/packages/ai/src/openai/index.ts @@ -157,7 +157,7 @@ export class WrappedCompletions extends Completions { if (!completionIdFromResponse && chunk.id) { completionIdFromResponse = chunk.id } - if (systemFingerprintFromResponse === undefined && chunk.system_fingerprint) { + if (!systemFingerprintFromResponse && chunk.system_fingerprint) { systemFingerprintFromResponse = chunk.system_fingerprint }