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 <charles@twenty.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
mfamularopsyc
2026-06-22 13:26:02 -04:00
committed by GitHub
parent 2dc6034a6c
commit d2083e7a1b
10 changed files with 299 additions and 62 deletions
@@ -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,
});
});
});
@@ -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 {
@@ -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 } =
@@ -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,
});
});
});
@@ -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 {
@@ -30,6 +30,7 @@ export const mapUIMessagePartsToDBParts = (
return {
...basePart,
reasoningContent: part.text,
providerMetadata: part.providerMetadata ?? null,
};
case 'file': {
if (!isExtendedFileUIPart(part)) {
@@ -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();
@@ -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' },
},
},
},
]);
});
});
});
@@ -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,
},
};
});
};
@@ -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,
},
};
});
};