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:
@@ -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 {
|
||||
|
||||
+19
-5
@@ -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 } =
|
||||
|
||||
+46
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
@@ -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 {
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ export const mapUIMessagePartsToDBParts = (
|
||||
return {
|
||||
...basePart,
|
||||
reasoningContent: part.text,
|
||||
providerMetadata: part.providerMetadata ?? null,
|
||||
};
|
||||
case 'file': {
|
||||
if (!isExtendedFileUIPart(part)) {
|
||||
|
||||
+7
-5
@@ -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();
|
||||
|
||||
|
||||
+127
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
-52
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
+69
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user