From d2083e7a1b89eaa5961a5648ab5989dfe20782c7 Mon Sep 17 00:00:00 2001 From: mfamularopsyc <215557151+mfamularopsyc@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:26:02 -0400 Subject: [PATCH] Set OpenAI Responses store false for AI chat and agents (#20888) ## Summary This PR sets `openai.store = false` for Twenty's `@ai-sdk/openai` AI calls. This follows the approach discussed in #20877: instead of adding a new Twenty-specific Zero Data Retention config variable, OpenAI Responses calls no longer rely on OpenAI-stored response/item references. This should help Zero Data Retention organizations and may also avoid stale persisted-item replay errors for non-ZDR OpenAI users. Changes included: - Adds a shared OpenAI provider-options helper that merges `openai.store = false` for `@ai-sdk/openai` models. - Applies the helper to AI chat `streamText` calls. - Applies the helper to workflow/agent `generateText` calls. - Preserves OpenAI encrypted reasoning metadata through DB/UI message mappers so reasoning context can be replayed without stored OpenAI item references. - Does not add a new env/config variable. Related to issue #20877. ## Behavior / Tradeoffs This changes OpenAI Responses behavior for all Twenty OpenAI users, not only ZDR users. The intended benefit is that Twenty no longer depends on OpenAI-stored response/item references. The main tradeoff is reduced provider-side item-reference reuse for non-ZDR OpenAI users. To reduce the impact for reasoning models, this PR preserves `providerMetadata.openai.reasoningEncryptedContent` through message persistence/replay so reasoning context can still be provided without stored OpenAI item references. ## Tests - Focused server Jest tests for OpenAI provider-options merging and reasoning metadata mapping. - Focused frontend Jest test for reasoning metadata mapping. - `oxlint` and `oxfmt --check` on changed files. - `git diff --check`. --------- Co-authored-by: Charles Bochet Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> --- .../mapDBPartToUIMessagePart.test.ts | 28 ++++ .../ai/utils/mapDBPartToUIMessagePart.ts | 1 + .../services/agent-async-executor.service.ts | 24 +++- ...art-provider-metadata-mapping.util.spec.ts | 46 +++++++ .../utils/mapDBPartToUIMessagePart.ts | 1 + .../utils/mapUIMessagePartsToDBParts.ts | 1 + .../services/chat-execution.service.ts | 12 +- .../__tests__/provider-options.util.spec.ts | 127 ++++++++++++++++++ .../utils/inject-cache-breakpoint.util.ts | 52 ------- .../ai/ai-chat/utils/provider-options.util.ts | 69 ++++++++++ 10 files changed, 299 insertions(+), 62 deletions(-) create mode 100644 packages/twenty-front/src/modules/ai/utils/__tests__/mapDBPartToUIMessagePart.test.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/message-part-provider-metadata-mapping.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/provider-options.util.spec.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/inject-cache-breakpoint.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/provider-options.util.ts diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/mapDBPartToUIMessagePart.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/mapDBPartToUIMessagePart.test.ts new file mode 100644 index 0000000000..ce28c72084 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/mapDBPartToUIMessagePart.test.ts @@ -0,0 +1,28 @@ +import { type AgentMessagePart } from '~/generated-metadata/graphql'; + +import { mapDBPartToUIMessagePart } from '@/ai/utils/mapDBPartToUIMessagePart'; + +describe('mapDBPartToUIMessagePart', () => { + it('preserves OpenAI encrypted reasoning metadata', () => { + const providerMetadata = { + openai: { + itemId: 'rs_123', + reasoningEncryptedContent: 'encrypted-content', + }, + }; + + expect( + mapDBPartToUIMessagePart({ + type: 'reasoning', + reasoningContent: 'reasoning summary', + state: 'done', + providerMetadata, + } as AgentMessagePart), + ).toEqual({ + type: 'reasoning', + text: 'reasoning summary', + state: 'done', + providerMetadata, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/mapDBPartToUIMessagePart.ts b/packages/twenty-front/src/modules/ai/utils/mapDBPartToUIMessagePart.ts index 9bd828d3d9..6193fed81d 100644 --- a/packages/twenty-front/src/modules/ai/utils/mapDBPartToUIMessagePart.ts +++ b/packages/twenty-front/src/modules/ai/utils/mapDBPartToUIMessagePart.ts @@ -23,6 +23,7 @@ export const mapDBPartToUIMessagePart = ( type: 'reasoning', text: part.reasoningContent!, state: part.state as ReasoningUIPart['state'], + providerMetadata: part.providerMetadata ?? undefined, }; case 'file': return { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts index c48a602f38..4649b1568e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts @@ -42,6 +42,7 @@ import { extractCacheCreationTokensFromSteps, } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util'; import { mergeLanguageModelUsage } from 'src/engine/metadata-modules/ai/ai-billing/utils/merge-language-model-usage.util'; +import { getCallLevelProviderOptions } from 'src/engine/metadata-modules/ai/ai-chat/utils/provider-options.util'; import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; @@ -148,7 +149,11 @@ export class AgentAsyncExecutorService { await this.aiModelRegistryService.resolveModelForAgent(agent); let tools: ToolSet = {}; - let providerOptions = {}; + let providerOptions = getCallLevelProviderOptions({ + sdkPackage: registeredModel.sdkPackage, + providerOptions: undefined, + promptCacheKey: agent?.id, + }); if (agent) { const agentRoleId = await this.getAgentRoleId( @@ -206,10 +211,14 @@ export class AgentAsyncExecutorService { ...nativeTools, }; - providerOptions = - this.aiModelConfigService.getReasoningProviderOptions( - registeredModel, - ); + providerOptions = getCallLevelProviderOptions({ + sdkPackage: registeredModel.sdkPackage, + providerOptions: + this.aiModelConfigService.getReasoningProviderOptions( + registeredModel, + ), + promptCacheKey: agent?.id, + }); } this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`); @@ -317,6 +326,11 @@ export class AgentAsyncExecutorService { Please generate the structured output based on the execution results and context above.`, output: Output.object({ schema: jsonSchema(agentSchema) }), + providerOptions: getCallLevelProviderOptions({ + sdkPackage: registeredModel.sdkPackage, + providerOptions: undefined, + promptCacheKey: agent?.id, + }), experimental_telemetry: AI_TELEMETRY_CONFIG, onStepFinish: async (step) => { const { hasNoMoreAvailableCredits: stepHasNoMoreAvailableCredits } = diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/message-part-provider-metadata-mapping.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/message-part-provider-metadata-mapping.util.spec.ts new file mode 100644 index 0000000000..598ea7c5fb --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/__tests__/message-part-provider-metadata-mapping.util.spec.ts @@ -0,0 +1,46 @@ +import { type ExtendedUIMessagePart } from 'twenty-shared/ai'; + +import { type AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity'; +import { mapDBPartToUIMessagePart } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart'; +import { mapUIMessagePartsToDBParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts'; + +describe('message part provider metadata mapping', () => { + it('persists and restores OpenAI encrypted reasoning metadata', () => { + const providerMetadata = { + openai: { + itemId: 'rs_123', + reasoningEncryptedContent: 'encrypted-content', + }, + }; + + const reasoningPart = { + type: 'reasoning', + text: 'reasoning summary', + state: 'done', + providerMetadata, + } satisfies ExtendedUIMessagePart; + + const dbParts = mapUIMessagePartsToDBParts( + [reasoningPart], + 'message-id', + 'workspace-id', + ); + + expect(dbParts).toEqual([ + expect.objectContaining({ + type: 'reasoning', + reasoningContent: 'reasoning summary', + providerMetadata, + }), + ]); + + expect( + mapDBPartToUIMessagePart(dbParts[0] as AgentMessagePartEntity), + ).toEqual({ + type: 'reasoning', + text: 'reasoning summary', + state: 'done', + providerMetadata, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart.ts index 5c943ea248..be7dbcbcec 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartToUIMessagePart.ts @@ -23,6 +23,7 @@ export const mapDBPartToUIMessagePart = ( type: 'reasoning', text: part.reasoningContent ?? '', state: (part.state as 'streaming' | 'done') ?? 'done', + providerMetadata: part.providerMetadata ?? undefined, }; case 'file': return { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts.ts index 9c7eed7ba8..de46de0c1a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts.ts @@ -30,6 +30,7 @@ export const mapUIMessagePartsToDBParts = ( return { ...basePart, reasoningContent: part.text, + providerMetadata: part.providerMetadata ?? null, }; case 'file': { if (!isExtendedFileUIPart(part)) { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index 900f82a33e..f0f684efbb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -59,9 +59,9 @@ import { } from 'src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util'; import { getCacheProviderOptions, - getCallLevelCacheProviderOptions, + getCallLevelProviderOptions, injectCacheBreakpoint, -} from 'src/engine/metadata-modules/ai/ai-chat/utils/inject-cache-breakpoint.util'; +} from 'src/engine/metadata-modules/ai/ai-chat/utils/provider-options.util'; import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; import { NativeToolBinderService } from 'src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service'; @@ -407,9 +407,11 @@ export class ChatExecutionService { stopWhen: (step) => stepCountIs(AGENT_CONFIG.MAX_STEPS)(step) || hasNoMoreAvailableCredits, experimental_telemetry: AI_TELEMETRY_CONFIG, - providerOptions: getCallLevelCacheProviderOptions( - registeredModel.sdkPackage, - ), + providerOptions: getCallLevelProviderOptions({ + sdkPackage: registeredModel.sdkPackage, + providerOptions: undefined, + promptCacheKey: threadId, + }), prepareStep: ({ messages }) => { stepStartedAt = performance.now(); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/provider-options.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/provider-options.util.spec.ts new file mode 100644 index 0000000000..629e39ae38 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/__tests__/provider-options.util.spec.ts @@ -0,0 +1,127 @@ +import { + getCallLevelProviderOptions, + getCacheProviderOptions, + injectCacheBreakpoint, +} from 'src/engine/metadata-modules/ai/ai-chat/utils/provider-options.util'; +import { + AI_SDK_ANTHROPIC, + AI_SDK_BEDROCK, + AI_SDK_OPENAI, +} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const'; + +describe('provider-options.util', () => { + describe('getCallLevelProviderOptions', () => { + it('returns cache provider options for Anthropic models', () => { + expect( + getCallLevelProviderOptions({ sdkPackage: AI_SDK_ANTHROPIC }), + ).toEqual({ + anthropic: { + cacheControl: { type: 'ephemeral' }, + }, + }); + }); + + it('merges existing provider options with call-level options', () => { + expect( + getCallLevelProviderOptions({ + sdkPackage: AI_SDK_OPENAI, + providerOptions: { + xai: { + searchParameters: { mode: 'auto' }, + }, + }, + }), + ).toEqual({ + xai: { + searchParameters: { mode: 'auto' }, + }, + openai: { + store: false, + }, + }); + }); + + it('returns store false for OpenAI models', () => { + expect( + getCallLevelProviderOptions({ sdkPackage: AI_SDK_OPENAI }), + ).toEqual({ + openai: { + store: false, + }, + }); + }); + + it('includes promptCacheKey for OpenAI when provided', () => { + expect( + getCallLevelProviderOptions({ + sdkPackage: AI_SDK_OPENAI, + promptCacheKey: 'thread-123', + }), + ).toEqual({ + openai: { + store: false, + promptCacheKey: 'thread-123', + }, + }); + }); + + it('omits promptCacheKey for non-OpenAI providers', () => { + expect( + getCallLevelProviderOptions({ + sdkPackage: AI_SDK_ANTHROPIC, + promptCacheKey: 'thread-123', + }), + ).toEqual({ + anthropic: { + cacheControl: { type: 'ephemeral' }, + }, + }); + }); + }); + + describe('getCacheProviderOptions', () => { + it('returns cache point provider options for Bedrock models', () => { + expect(getCacheProviderOptions(AI_SDK_BEDROCK)).toEqual({ + bedrock: { + cachePoint: { type: 'default' }, + }, + }); + }); + }); + + describe('injectCacheBreakpoint', () => { + it('injects cache provider options on the last message only', () => { + expect( + injectCacheBreakpoint( + [ + { role: 'user', content: 'first' }, + { + role: 'user', + content: 'last', + providerOptions: { + openai: { + store: false, + }, + }, + }, + ], + AI_SDK_BEDROCK, + ), + ).toEqual([ + { role: 'user', content: 'first' }, + { + role: 'user', + content: 'last', + providerOptions: { + openai: { + store: false, + }, + bedrock: { + cachePoint: { type: 'default' }, + }, + }, + }, + ]); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/inject-cache-breakpoint.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/inject-cache-breakpoint.util.ts deleted file mode 100644 index ec1b4a06f8..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/inject-cache-breakpoint.util.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { type ModelMessage } from 'ai'; -import { type ProviderOptions } from '@ai-sdk/provider-utils'; - -import { - AI_SDK_ANTHROPIC, - AI_SDK_BEDROCK, -} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const'; - -export const getCallLevelCacheProviderOptions = ( - sdkPackage: string, -): ProviderOptions | undefined => { - if (sdkPackage === AI_SDK_ANTHROPIC) { - return { anthropic: { cacheControl: { type: 'ephemeral' } } }; - } - - return undefined; -}; - -export const getCacheProviderOptions = ( - sdkPackage: string, -): ProviderOptions | undefined => { - if (sdkPackage === AI_SDK_BEDROCK) { - return { bedrock: { cachePoint: { type: 'default' } } }; - } - - return undefined; -}; - -export const injectCacheBreakpoint = ( - messages: ModelMessage[], - sdkPackage: string, -): ModelMessage[] => { - if (messages.length === 0) return messages; - - const cacheOptions = getCacheProviderOptions(sdkPackage); - - if (!cacheOptions) return messages; - - const lastIdx = messages.length - 1; - - return messages.map((message, index) => { - if (index !== lastIdx) return message; - - return { - ...message, - providerOptions: { - ...(message.providerOptions ?? {}), - ...cacheOptions, - }, - }; - }); -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/provider-options.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/provider-options.util.ts new file mode 100644 index 0000000000..399ae2d9f2 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/utils/provider-options.util.ts @@ -0,0 +1,69 @@ +import { type ProviderOptions } from '@ai-sdk/provider-utils'; +import { type ModelMessage } from 'ai'; +import { type AiSdkPackage } from 'twenty-shared/ai'; + +import { + AI_SDK_ANTHROPIC, + AI_SDK_BEDROCK, + AI_SDK_OPENAI, +} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const'; + +export const getCacheProviderOptions = ( + sdkPackage: AiSdkPackage, +): ProviderOptions | undefined => { + switch (sdkPackage) { + case AI_SDK_BEDROCK: + return { bedrock: { cachePoint: { type: 'default' } } }; + default: + return undefined; + } +}; +export const getCallLevelProviderOptions = ({ + sdkPackage, + providerOptions, + promptCacheKey, +}: { + sdkPackage: AiSdkPackage; + providerOptions?: ProviderOptions; + promptCacheKey?: string; +}): ProviderOptions | undefined => { + switch (sdkPackage) { + case AI_SDK_ANTHROPIC: + return { + ...(providerOptions ?? {}), + anthropic: { cacheControl: { type: 'ephemeral' } }, + }; + case AI_SDK_OPENAI: + return { + ...(providerOptions ?? {}), + openai: { store: false, ...(promptCacheKey ? { promptCacheKey } : {}) }, + }; + default: + return providerOptions; + } +}; + +export const injectCacheBreakpoint = ( + messages: ModelMessage[], + sdkPackage: AiSdkPackage, +): ModelMessage[] => { + if (messages.length === 0) return messages; + + const cacheOptions = getCacheProviderOptions(sdkPackage); + + if (!cacheOptions) return messages; + + const lastIdx = messages.length - 1; + + return messages.map((message, index) => { + if (index !== lastIdx) return message; + + return { + ...message, + providerOptions: { + ...(message.providerOptions ?? {}), + ...cacheOptions, + }, + }; + }); +};