diff --git a/newIDE/app/scripts/import-libGD.js b/newIDE/app/scripts/import-libGD.js index 2accaaeac2f9..7fdf3431b0ae 100644 --- a/newIDE/app/scripts/import-libGD.js +++ b/newIDE/app/scripts/import-libGD.js @@ -58,9 +58,9 @@ if (shell.test('-f', path.join(sourceDirectory, 'libGD.js'))) { if (!branch) { shell.echo( - `⚠️ Can't find the branch of the associated commit - if you're in detached HEAD, you need to be on a branch instead.` + `⚠️ Can't find the branch of the associated commit - defaulting to master.` ); - return 'unknown-branch'; + return 'master'; } return branch; diff --git a/newIDE/app/src/AiGeneration/AiProviderConfigurations.js b/newIDE/app/src/AiGeneration/AiProviderConfigurations.js new file mode 100644 index 000000000000..67c900c78d34 --- /dev/null +++ b/newIDE/app/src/AiGeneration/AiProviderConfigurations.js @@ -0,0 +1,195 @@ +// @flow +import { + type AiProviderConfiguration, + type AiRequestCustomProviderSupport, +} from '../Utils/GDevelopServices/Generation'; + +export type AiProviderPreset = {| + id: string, + name: string, + baseUrl: string, + model: string, +|}; + +export const CUSTOM_PROVIDER_SELECTION_ID = 'custom-new'; +export const PRESET_SELECTION_PREFIX = 'preset:'; +export const CONFIGURATION_SELECTION_PREFIX = 'configuration:'; + +export const aiProviderPresets: Array = [ + { + id: 'openai', + name: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.5', + }, + { + id: 'openrouter', + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api/v1', + model: 'openai/gpt-5.2', + }, + { + id: 'google-gemini', + name: 'Google Gemini', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/', + model: 'gemini-2.5-flash', + }, + { + id: 'groq', + name: 'Groq', + baseUrl: 'https://api.groq.com/openai/v1', + model: 'openai/gpt-oss-20b', + }, + { + id: 'mistral', + name: 'Mistral', + baseUrl: 'https://api.mistral.ai/v1', + model: 'codestral-latest', + }, + { + id: 'deepseek', + name: 'DeepSeek', + baseUrl: 'https://api.deepseek.com', + model: 'deepseek-v4-pro', + }, + { + id: 'xai', + name: 'xAI', + baseUrl: 'https://api.x.ai/v1', + model: 'grok-4.3', + }, +]; + +export const getPresetSelectionId = (presetId: string): string => + `${PRESET_SELECTION_PREFIX}${presetId}`; + +export const getConfigurationSelectionId = (configurationId: string): string => + `${CONFIGURATION_SELECTION_PREFIX}${configurationId}`; + +export const getPresetIdFromSelectionId = ( + selectionId: string +): string | null => + selectionId.indexOf(PRESET_SELECTION_PREFIX) === 0 + ? selectionId.slice(PRESET_SELECTION_PREFIX.length) + : null; + +export const getConfigurationIdFromSelectionId = ( + selectionId: string +): string | null => + selectionId.indexOf(CONFIGURATION_SELECTION_PREFIX) === 0 + ? selectionId.slice(CONFIGURATION_SELECTION_PREFIX.length) + : null; + +export const configurationMatchesPreset = ( + configuration: AiProviderConfiguration, + preset: AiProviderPreset +): boolean => + configuration.name === preset.name && + configuration.baseUrl === preset.baseUrl; + +const getConfigurationUpdatedAtTimestamp = ( + configuration: AiProviderConfiguration +): number => { + const updatedAtTimestamp = Date.parse(configuration.updatedAt || ''); + return Number.isFinite(updatedAtTimestamp) ? updatedAtTimestamp : 0; +}; + +export const getPresetConfiguration = ( + configurations: Array, + preset: AiProviderPreset +): AiProviderConfiguration | null => + configurations + .filter(configuration => configurationMatchesPreset(configuration, preset)) + .sort( + (configurationA, configurationB) => + getConfigurationUpdatedAtTimestamp(configurationB) - + getConfigurationUpdatedAtTimestamp(configurationA) + )[0] || null; + +export const isPresetConfiguration = ( + configuration: AiProviderConfiguration +): boolean => + aiProviderPresets.some(preset => + configurationMatchesPreset(configuration, preset) + ); + +export const getSelectionIdFromAiProviderConfiguration = ( + configuration: AiProviderConfiguration +): string => { + const matchingPreset = + aiProviderPresets.find(preset => + configurationMatchesPreset(configuration, preset) + ) || null; + return matchingPreset + ? getPresetSelectionId(matchingPreset.id) + : getConfigurationSelectionId(configuration.id); +}; + +export const shouldFetchAiProviderConfigurations = ({ + hasAuthenticatedUser, + customProviderSupport, +}: {| + hasAuthenticatedUser: boolean, + customProviderSupport: AiRequestCustomProviderSupport | null, +|}): boolean => + hasAuthenticatedUser && + !!customProviderSupport && + customProviderSupport.enabled && + customProviderSupport.openAiCompatible; + +export const getAvailableAiProviderConfigurations = ({ + aiProviderConfigurations, + customProviderSupport, +}: {| + aiProviderConfigurations: Array, + customProviderSupport: AiRequestCustomProviderSupport | null, +|}): Array => { + if ( + !customProviderSupport || + !customProviderSupport.enabled || + !customProviderSupport.openAiCompatible + ) { + return []; + } + + return aiProviderConfigurations.filter( + configuration => configuration.providerType === 'openai-compatible' + ); +}; + +export const getAiProviderConfigurationFromSelectionId = ({ + selectionId, + aiProviderConfigurations, + customProviderSupport, +}: {| + selectionId: string | null, + aiProviderConfigurations: Array, + customProviderSupport: AiRequestCustomProviderSupport | null, +|}): AiProviderConfiguration | null => { + if (!selectionId) return null; + + const availableAiProviderConfigurations = getAvailableAiProviderConfigurations( + { + aiProviderConfigurations, + customProviderSupport, + } + ); + + const configurationId = getConfigurationIdFromSelectionId(selectionId); + if (configurationId) { + return ( + availableAiProviderConfigurations.find( + configuration => configuration.id === configurationId + ) || null + ); + } + + const presetId = getPresetIdFromSelectionId(selectionId); + if (!presetId) return null; + + const preset = + aiProviderPresets.find(preset => preset.id === presetId) || null; + return preset + ? getPresetConfiguration(availableAiProviderConfigurations, preset) + : null; +}; diff --git a/newIDE/app/src/AiGeneration/AiProviderConfigurations.spec.js b/newIDE/app/src/AiGeneration/AiProviderConfigurations.spec.js new file mode 100644 index 000000000000..8b2faae3e205 --- /dev/null +++ b/newIDE/app/src/AiGeneration/AiProviderConfigurations.spec.js @@ -0,0 +1,163 @@ +// @flow +import { + CUSTOM_PROVIDER_SELECTION_ID, + getAvailableAiProviderConfigurations, + getAiProviderConfigurationFromSelectionId, + getConfigurationSelectionId, + getPresetSelectionId, + shouldFetchAiProviderConfigurations, +} from './AiProviderConfigurations'; +import { + type AiProviderConfiguration, + type AiRequestCustomProviderSupport, +} from '../Utils/GDevelopServices/Generation'; + +const supportAll: AiRequestCustomProviderSupport = { + enabled: true, + openAiCompatible: true, +}; + +const supportDisabled: AiRequestCustomProviderSupport = { + enabled: false, + openAiCompatible: true, +}; + +const supportWithoutOpenAiCompatible: AiRequestCustomProviderSupport = { + enabled: true, + openAiCompatible: false, +}; + +const openAiCompatibleProvider: AiProviderConfiguration = { + id: 'openai-compatible-provider', + name: 'OpenAI-compatible', + providerType: 'openai-compatible', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.5', + hasApiKey: true, + createdAt: '', + updatedAt: '', +}; + +const savedOpenAiProvider: AiProviderConfiguration = { + id: 'saved-openai-provider', + name: 'OpenAI', + providerType: 'openai-compatible', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.5', + hasApiKey: true, + createdAt: '2026-05-15T10:00:00.000Z', + updatedAt: '2026-05-15T10:00:00.000Z', +}; + +const newerSavedOpenAiProvider: AiProviderConfiguration = { + ...savedOpenAiProvider, + id: 'newer-saved-openai-provider', + model: 'gpt-5.5-mini', + updatedAt: '2026-05-16T10:00:00.000Z', +}; + +describe('AI provider configurations', () => { + it('fetches providers only for signed-in users with OpenAI-compatible support', () => { + expect( + shouldFetchAiProviderConfigurations({ + hasAuthenticatedUser: true, + customProviderSupport: supportAll, + }) + ).toBe(true); + expect( + shouldFetchAiProviderConfigurations({ + hasAuthenticatedUser: false, + customProviderSupport: supportAll, + }) + ).toBe(false); + expect( + shouldFetchAiProviderConfigurations({ + hasAuthenticatedUser: true, + customProviderSupport: supportWithoutOpenAiCompatible, + }) + ).toBe(false); + }); + + it('skips provider fetches when custom provider support is missing or disabled', () => { + expect( + shouldFetchAiProviderConfigurations({ + hasAuthenticatedUser: true, + customProviderSupport: null, + }) + ).toBe(false); + expect( + shouldFetchAiProviderConfigurations({ + hasAuthenticatedUser: true, + customProviderSupport: supportDisabled, + }) + ).toBe(false); + }); + + it('lists OpenAI-compatible providers in the Ask AI dropdown', () => { + expect( + getAvailableAiProviderConfigurations({ + aiProviderConfigurations: [openAiCompatibleProvider], + customProviderSupport: supportAll, + }).map(configuration => configuration.id) + ).toEqual([openAiCompatibleProvider.id]); + }); + + it('honors OpenAI-compatible feature support', () => { + expect( + getAvailableAiProviderConfigurations({ + aiProviderConfigurations: [openAiCompatibleProvider], + customProviderSupport: supportWithoutOpenAiCompatible, + }) + ).toEqual([]); + }); + + it('resolves a saved custom provider selection', () => { + expect( + getAiProviderConfigurationFromSelectionId({ + selectionId: getConfigurationSelectionId(openAiCompatibleProvider.id), + aiProviderConfigurations: [openAiCompatibleProvider], + customProviderSupport: supportAll, + }) + ).toBe(openAiCompatibleProvider); + }); + + it('resolves a saved preset selection to the newest matching provider', () => { + expect( + getAiProviderConfigurationFromSelectionId({ + selectionId: getPresetSelectionId('openai'), + aiProviderConfigurations: [ + savedOpenAiProvider, + newerSavedOpenAiProvider, + ], + customProviderSupport: supportAll, + }) + ).toBe(newerSavedOpenAiProvider); + }); + + it('does not resolve unsaved preset or custom-new selections', () => { + expect( + getAiProviderConfigurationFromSelectionId({ + selectionId: getPresetSelectionId('openrouter'), + aiProviderConfigurations: [openAiCompatibleProvider], + customProviderSupport: supportAll, + }) + ).toBe(null); + expect( + getAiProviderConfigurationFromSelectionId({ + selectionId: CUSTOM_PROVIDER_SELECTION_ID, + aiProviderConfigurations: [openAiCompatibleProvider], + customProviderSupport: supportAll, + }) + ).toBe(null); + }); + + it('does not resolve selections when OpenAI-compatible support is unavailable', () => { + expect( + getAiProviderConfigurationFromSelectionId({ + selectionId: getConfigurationSelectionId(openAiCompatibleProvider.id), + aiProviderConfigurations: [openAiCompatibleProvider], + customProviderSupport: supportWithoutOpenAiCompatible, + }) + ).toBe(null); + }); +}); diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/AiConfigurationPresetSelector.js b/newIDE/app/src/AiGeneration/AiRequestChat/AiConfigurationPresetSelector.js index ae342809f266..984de6bdfa08 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/AiConfigurationPresetSelector.js +++ b/newIDE/app/src/AiGeneration/AiRequestChat/AiConfigurationPresetSelector.js @@ -13,6 +13,9 @@ type AiConfigurationPresetSelectorProps = { aiConfigurationPresetsWithAvailability: Array, aiRequestMode: string, disabled?: boolean, + customAiConfigurationPresetId?: string, + showCustomAiConfigurationPreset?: boolean, + disableCustomAiConfigurationPreset?: boolean, }; export const AiConfigurationPresetSelector = ({ @@ -21,6 +24,9 @@ export const AiConfigurationPresetSelector = ({ aiConfigurationPresetsWithAvailability, aiRequestMode, disabled, + customAiConfigurationPresetId, + showCustomAiConfigurationPreset, + disableCustomAiConfigurationPreset, }: AiConfigurationPresetSelectorProps): React.Node => { const filteredAiConfigurationPresets = aiConfigurationPresetsWithAvailability.filter( preset => preset.mode === aiRequestMode @@ -77,6 +83,14 @@ export const AiConfigurationPresetSelector = ({ shouldNotTranslate /> ))} + {showCustomAiConfigurationPreset && customAiConfigurationPresetId && ( + + )} {upgradeAiConfigurationPresets.length > 0 && ( {upgradeAiConfigurationPresets.map(preset => ( diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/AiConfigurationPresetSelector.spec.js b/newIDE/app/src/AiGeneration/AiRequestChat/AiConfigurationPresetSelector.spec.js new file mode 100644 index 000000000000..bb76ae69f20b --- /dev/null +++ b/newIDE/app/src/AiGeneration/AiRequestChat/AiConfigurationPresetSelector.spec.js @@ -0,0 +1,87 @@ +// @flow +import * as React from 'react'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles'; +import renderer from 'react-test-renderer'; +import SelectOption from '../../UI/SelectOption'; +import { AiConfigurationPresetSelector } from './AiConfigurationPresetSelector'; + +const i18n = setupI18n({ + language: 'en', + catalogs: { + en: { messages: {} }, + }, +}); +const muiTheme = createMuiTheme(); + +const renderSelector = ({ + disableCustomAiConfigurationPreset, +}: {| + disableCustomAiConfigurationPreset?: boolean, +|} = {}) => + renderer.create( + + + {}} + aiConfigurationPresetsWithAvailability={[ + { + id: 'default', + mode: 'chat', + nameByLocale: { en: 'Default' }, + disabled: false, + enableWith: null, + isDefault: true, + }, + { + id: 'gpt-5', + mode: 'chat', + nameByLocale: { en: 'GPT-5 (Gold & Pro only)' }, + disabled: true, + enableWith: 'higher-tier-plan', + }, + ]} + aiRequestMode="chat" + customAiConfigurationPresetId="custom-model" + showCustomAiConfigurationPreset + disableCustomAiConfigurationPreset={ + disableCustomAiConfigurationPreset + } + /> + + + ); + +describe('AiConfigurationPresetSelector', () => { + it('adds Custom Model without changing subscription-disabled entries', () => { + const tree = renderSelector(); + const options = tree.root.findAllByType(SelectOption); + + expect(options.map(option => option.props.value)).toEqual([ + 'default', + 'custom-model', + 'gpt-5', + ]); + expect(options.map(option => !!option.props.disabled)).toEqual([ + false, + false, + true, + ]); + }); + + it('can disable Custom Model when no saved provider is selected', () => { + const tree = renderSelector({ disableCustomAiConfigurationPreset: true }); + const options = tree.root.findAllByType(SelectOption); + const customModelOption = options.find( + option => option.props.value === 'custom-model' + ); + + if (!customModelOption) { + throw new Error('Expected the Custom Model option to be rendered.'); + } + + expect(customModelOption.props.disabled).toBe(true); + }); +}); diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/AiRequestChat.module.css b/newIDE/app/src/AiGeneration/AiRequestChat/AiRequestChat.module.css index a2e141596549..1cd9750be4d1 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/AiRequestChat.module.css +++ b/newIDE/app/src/AiGeneration/AiRequestChat/AiRequestChat.module.css @@ -28,6 +28,10 @@ } .creditOrSubscriptionPromptContainer { - background: linear-gradient(90deg, rgb(153, 121, 241) 0%, rgb(255, 188, 87) 100%); + background: linear-gradient( + 90deg, + rgb(153, 121, 241) 0%, + rgb(255, 188, 87) 100% + ); border-radius: 8px; } diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/index.js b/newIDE/app/src/AiGeneration/AiRequestChat/index.js index b14c113b09f9..b18f577b043f 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/index.js +++ b/newIDE/app/src/AiGeneration/AiRequestChat/index.js @@ -10,6 +10,9 @@ import { type AiRequest, type AiRequestMessage, type AiRequestMessageAssistantFunctionCall, + type AiProviderConfiguration, + type AiRequestCustomProviderSupport, + isAiProviderUnavailableError, } from '../../Utils/GDevelopServices/Generation'; import RaisedButton from '../../UI/RaisedButton'; import { CompactTextAreaFieldWithControls } from '../../UI/CompactTextAreaFieldWithControls'; @@ -45,6 +48,7 @@ import { } from '../AiConfiguration'; import { AiConfigurationPresetSelector } from './AiConfigurationPresetSelector'; import { AiRequestContext } from '../AiRequestContext'; +import { getAiProviderConfigurationFromSelectionId } from '../AiProviderConfigurations'; import PreferencesContext from '../../MainFrame/Preferences/PreferencesContext'; import { useStickyVisibility } from './UseStickyVisibility'; import CircledInfo from '../../UI/CustomSvgIcons/CircledInfo'; @@ -62,6 +66,24 @@ import Stop from '../../UI/CustomSvgIcons/Stop'; const TOO_MANY_USER_MESSAGES_WARNING_COUNT = 15; const TOO_MANY_USER_MESSAGES_ERROR_COUNT = 20; +const CUSTOM_AI_CONFIGURATION_PRESET_ID = 'custom-model'; + +const getSendErrorMessage = (error: any): string | null => { + const responseMessage = + error && + error.response && + error.response.data && + typeof error.response.data.message === 'string' + ? error.response.data.message + : null; + if (responseMessage) return responseMessage; + + if (error && error.response && error.response.data) { + return JSON.stringify(error.response.data); + } + + return error instanceof Error && error.message ? error.message : null; +}; const styles = { chatScrollView: { @@ -320,6 +342,7 @@ type Props = {| mode: 'chat' | 'agent' | 'orchestrator', userRequest: string, aiConfigurationPresetId: string, + aiProviderConfigurationId?: string | null, |}) => void, onSendUserMessage: ({| userMessage: string, @@ -366,6 +389,8 @@ type Props = {| message: AiRequestMessage, aiRequest: AiRequest, |}) => Promise, + aiProviderConfigurations?: Array, + customProviderSupport?: AiRequestCustomProviderSupport | null, |}; export type AiRequestChatInterface = {| @@ -405,6 +430,8 @@ export const AiRequestChat: React.ComponentType<{ savingProjectForMessageId, forkingState, onRestore, + aiProviderConfigurations = [], + customProviderSupport = null, }: Props, ref ) => { @@ -419,7 +446,10 @@ export const AiRequestChat: React.ComponentType<{ (hasOpenedProject ? 'chat' : 'orchestrator') ); const { - values: { automaticallyUseCreditsForAiRequests }, + values: { + automaticallyUseCreditsForAiRequests, + aiProviderSettingsSelectionId, + }, setAutomaticallyUseCreditsForAiRequests, } = React.useContext(PreferencesContext); const { openSubscriptionDialog } = React.useContext(SubscriptionContext); @@ -442,6 +472,25 @@ export const AiRequestChat: React.ComponentType<{ aiConfigurationPresetId, setAiConfigurationPresetId, ] = React.useState(null); + const settingsAiProviderConfiguration = React.useMemo( + () => + getAiProviderConfigurationFromSelectionId({ + selectionId: aiProviderSettingsSelectionId, + aiProviderConfigurations, + customProviderSupport, + }), + [ + aiProviderSettingsSelectionId, + aiProviderConfigurations, + customProviderSupport, + ] + ); + const isCustomAiProviderSupported = + !!customProviderSupport && + customProviderSupport.enabled && + customProviderSupport.openAiCompatible; + const isCustomAiProviderSelected = + aiConfigurationPresetId === CUSTOM_AI_CONFIGURATION_PRESET_ID; React.useEffect( () => { @@ -449,6 +498,13 @@ export const AiRequestChat: React.ComponentType<{ if (!aiConfigurationPresetId) return; + if (aiConfigurationPresetId === CUSTOM_AI_CONFIGURATION_PRESET_ID) { + if (settingsAiProviderConfiguration) return; + + setAiConfigurationPresetId(null); + return; + } + if ( aiConfigurationPresetsWithAvailability.find( preset => @@ -469,6 +525,7 @@ export const AiRequestChat: React.ComponentType<{ selectedMode, aiConfigurationPresetsWithAvailability, aiConfigurationPresetId, + settingsAiProviderConfiguration, ] ); @@ -566,11 +623,23 @@ export const AiRequestChat: React.ComponentType<{ const { isMobile } = useResponsiveWindowSize(); + const lastSendErrorMessage = lastSendError + ? getSendErrorMessage(lastSendError) + : null; const errorText = lastSendError ? ( - - - An error happened when sending your request, please try again. - + + {isAiProviderUnavailableError(lastSendError) ? ( + + This AI provider is not available. Check it in preferences or choose + GDevelop AI. + + ) : lastSendErrorMessage ? ( + lastSendErrorMessage + ) : ( + + An error happened when sending your request, please try again. + + )} ) : null; @@ -580,21 +649,43 @@ export const AiRequestChat: React.ComponentType<{ value: !!isRefreshingLimits, }); - const priceAndRequestsText = getPriceAndRequestsTextAndTooltip({ - quota, - price, - availableCredits, - selectedMode, - automaticallyUseCreditsForAiRequests, - isRefreshingLimits: isRefreshingLimitsStable, - }); - - const chosenOrDefaultAiConfigurationPresetId = - aiConfigurationPresetId || - getDefaultAiConfigurationPresetId( + const existingAiProviderConfigurationId = + (aiRequest && + aiRequest.aiConfiguration && + aiRequest.aiConfiguration.providerConfigurationId) || + null; + const selectedAiProviderConfigurationId = + isCustomAiProviderSelected && settingsAiProviderConfiguration + ? settingsAiProviderConfiguration.id + : ''; + const isUsingCustomAiProvider = aiRequest + ? !!existingAiProviderConfigurationId + : !!selectedAiProviderConfigurationId; + const priceAndRequestsText = isUsingCustomAiProvider ? ( + + Your AI provider may bill this request. + + ) : ( + getPriceAndRequestsTextAndTooltip({ + quota, + price, + availableCredits, selectedMode, - aiConfigurationPresetsWithAvailability - ); + automaticallyUseCreditsForAiRequests, + isRefreshingLimits: isRefreshingLimitsStable, + }) + ); + + const defaultAiConfigurationPresetId = getDefaultAiConfigurationPresetId( + selectedMode, + aiConfigurationPresetsWithAvailability + ); + const chosenOrDefaultAiConfigurationPresetId = isCustomAiProviderSelected + ? CUSTOM_AI_CONFIGURATION_PRESET_ID + : aiConfigurationPresetId || defaultAiConfigurationPresetId; + const onChooseAiConfigurationPreset = React.useCallback((value: string) => { + setAiConfigurationPresetId(value); + }, []); const hasFunctionsCallsToProcess = aiRequest && getFunctionCallsToProcess({ @@ -622,6 +713,7 @@ export const AiRequestChat: React.ComponentType<{ const doesNotHaveEnoughCreditsToContinue = !!price && availableCredits < price.priceInCredits; const cannotContinue = + !isUsingCustomAiProvider && !!quota && quota.limitReached && (!automaticallyUseCreditsForAiRequests || @@ -683,7 +775,10 @@ export const AiRequestChat: React.ComponentType<{ onStartNewAiRequest({ userRequest: userRequestTextPerAiRequestId[''], - aiConfigurationPresetId: chosenOrDefaultAiConfigurationPresetId, + aiConfigurationPresetId: isCustomAiProviderSelected + ? defaultAiConfigurationPresetId + : chosenOrDefaultAiConfigurationPresetId, + aiProviderConfigurationId: selectedAiProviderConfigurationId || null, mode: selectedMode, }); }, @@ -691,6 +786,9 @@ export const AiRequestChat: React.ComponentType<{ onStartNewAiRequest, userRequestTextPerAiRequestId, chosenOrDefaultAiConfigurationPresetId, + defaultAiConfigurationPresetId, + isCustomAiProviderSelected, + selectedAiProviderConfigurationId, scrollToBottom, cannotContinue, hasOpenedProject, @@ -822,13 +920,22 @@ export const AiRequestChat: React.ComponentType<{ chosenOrDefaultAiConfigurationPresetId } setAiConfigurationPresetId={ - setAiConfigurationPresetId + onChooseAiConfigurationPreset } aiConfigurationPresetsWithAvailability={ aiConfigurationPresetsWithAvailability } aiRequestMode={selectedMode} disabled={isWorking} + customAiConfigurationPresetId={ + CUSTOM_AI_CONFIGURATION_PRESET_ID + } + showCustomAiConfigurationPreset={ + isCustomAiProviderSupported + } + disableCustomAiConfigurationPreset={ + !settingsAiProviderConfiguration + } /> - { - if ( - value !== 'chat' && - value !== 'agent' && - value !== 'orchestrator' - ) { - return; + {/* $FlowFixMe[constant-condition] */} + {!standAloneForm && ( + { + if ( + value !== 'chat' && + value !== 'agent' && + value !== 'orchestrator' + ) { + return; + } + setSelectedMode(value); + }} + renderOptionIcon={className => + selectedMode === 'chat' ? ( + + ) : ( + + ) } - setSelectedMode(value); - }} - renderOptionIcon={className => - selectedMode === 'chat' ? ( - - ) : ( - - ) - } - rounded - > - - - - + rounded + > + + + + + )} {isForAnotherProjectText || errorText || priceAndRequestsText} diff --git a/newIDE/app/src/AiGeneration/AiRequestContext.js b/newIDE/app/src/AiGeneration/AiRequestContext.js index f1e4ea6e8ba0..e379da4d6161 100644 --- a/newIDE/app/src/AiGeneration/AiRequestContext.js +++ b/newIDE/app/src/AiGeneration/AiRequestContext.js @@ -2,11 +2,16 @@ import * as React from 'react'; import { getAiRequest, + getAiRequestWithPreservedAiConfiguration, getPartialAiRequest, fetchAiSettings, + getAiRequestCustomProviderSupport, type AiRequest, + type AiProviderConfiguration, + type AiRequestCustomProviderSupport, type AiSettings, getAiRequests, + listAiProviderConfigurations, } from '../Utils/GDevelopServices/Generation'; import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; import { type EditorFunctionCallResult } from '../EditorFunctions'; @@ -16,6 +21,7 @@ import { useAsyncLazyMemo } from '../Utils/UseLazyMemo'; import { retryIfFailed } from '../Utils/RetryIfFailed'; import { useInterval } from '../Utils/UseInterval'; import useForceUpdate from '../Utils/UseForceUpdate'; +import { shouldFetchAiProviderConfigurations } from './AiProviderConfigurations'; type EditorFunctionCallResultsStorage = {| getEditorFunctionCallResults: ( @@ -130,6 +136,13 @@ type AiRequestSendState = {| lastSendError: ?Error, |}; +type AiProviderConfigurationState = {| + aiProviderConfigurations: Array, + customProviderSupport: AiRequestCustomProviderSupport | null, + isLoading: boolean, + fetchAiProviderConfigurations: () => Promise, +|}; + type PaginationState = {| aiRequests: { [aiRequestId: string]: AiRequest }, nextPageUri: ?Object, @@ -240,7 +253,11 @@ export const useAiRequestsStorage = (): AiRequestStorage => { ...prevState, aiRequests: { ...(prevState.aiRequests || {}), - [aiRequestId]: newAiRequest, + [aiRequestId]: getAiRequestWithPreservedAiConfiguration({ + aiRequest: newAiRequest, + aiConfiguration: + currentAiRequest && currentAiRequest.aiConfiguration, + }), }, }; }); @@ -460,6 +477,7 @@ type AiRequestContextState = {| selectedAiRequestId: string | null, setSelectedAiRequestId: (aiRequestId: string | null) => void, selectedAiRequest: AiRequest | null, + aiProviderConfigurationState: AiProviderConfigurationState, |}; export const initialAiRequestContextState: AiRequestContextState = { @@ -494,6 +512,12 @@ export const initialAiRequestContextState: AiRequestContextState = { selectedAiRequestId: null, setSelectedAiRequestId: () => {}, selectedAiRequest: null, + aiProviderConfigurationState: { + aiProviderConfigurations: [], + customProviderSupport: null, + isLoading: false, + fetchAiProviderConfigurations: async () => {}, + }, }; export const AiRequestContext: React.Context = React.createContext( initialAiRequestContextState @@ -669,6 +693,70 @@ export const AiRequestProvider = ({ [getAiSettings] ); + const customProviderSupport = getAiRequestCustomProviderSupport({ + aiSettings: getAiSettings(), + enableDevelopmentFallback: Window.isDev(), + }); + const [ + aiProviderConfigurations, + setAiProviderConfigurations, + ] = React.useState>([]); + const [ + isLoadingAiProviderConfigurations, + setIsLoadingAiProviderConfigurations, + ] = React.useState(false); + + const fetchAiProviderConfigurations = React.useCallback( + async () => { + if ( + !profile || + !shouldFetchAiProviderConfigurations({ + hasAuthenticatedUser: !!profile, + customProviderSupport, + }) + ) { + setAiProviderConfigurations([]); + return; + } + + setIsLoadingAiProviderConfigurations(true); + try { + const configurations = await listAiProviderConfigurations( + getAuthorizationHeader, + { userId: profile.id } + ); + setAiProviderConfigurations(configurations); + } catch (error) { + console.error('Error fetching AI provider configurations:', error); + } finally { + setIsLoadingAiProviderConfigurations(false); + } + }, + [customProviderSupport, getAuthorizationHeader, profile] + ); + + React.useEffect( + () => { + fetchAiProviderConfigurations(); + }, + [fetchAiProviderConfigurations] + ); + + const aiProviderConfigurationState = React.useMemo( + (): AiProviderConfigurationState => ({ + aiProviderConfigurations, + customProviderSupport, + isLoading: isLoadingAiProviderConfigurations, + fetchAiProviderConfigurations, + }), + [ + aiProviderConfigurations, + customProviderSupport, + isLoadingAiProviderConfigurations, + fetchAiProviderConfigurations, + ] + ); + const state = React.useMemo( (): AiRequestContextState => ({ aiRequestStorage, @@ -680,6 +768,7 @@ export const AiRequestProvider = ({ selectedAiRequestId, setSelectedAiRequestId, selectedAiRequest, + aiProviderConfigurationState, }), [ aiRequestStorage, @@ -691,6 +780,7 @@ export const AiRequestProvider = ({ selectedAiRequestId, setSelectedAiRequestId, selectedAiRequest, + aiProviderConfigurationState, ] ); diff --git a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js index e846996c2404..512be3c35991 100644 --- a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js +++ b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js @@ -18,8 +18,12 @@ import { createAiRequest, sendAiRequestFeedback, forkAiRequest, + getAiRequestWithPreservedAiConfiguration, suspendAiRequest, getAiRequest, + isAiProviderUnavailableError, + isLocalAiProviderConfigurationId, + type AiConfiguration, type AiRequest, type AiRequestMessage, } from '../Utils/GDevelopServices/Generation'; @@ -319,6 +323,11 @@ export const AskAiEditor: React.ComponentType = React.memo( selectedAiRequestId, selectedAiRequest, setSelectedAiRequestId, + aiProviderConfigurationState: { + aiProviderConfigurations, + customProviderSupport, + fetchAiProviderConfigurations, + }, } = React.useContext(AiRequestContext); const { isFetchingSuggestions, @@ -475,13 +484,19 @@ export const AskAiEditor: React.ComponentType = React.memo( mode, userRequest, aiConfigurationPresetId, + aiProviderConfigurationId, } = newAiRequestOptions; startNewAiRequest(null); // Ensure the user has enough credits to pay for the request, or ask them // to buy some more. let payWithCredits = false; - if (quota && quota.limitReached && aiRequestPriceInCredits) { + if ( + !aiProviderConfigurationId && + quota && + quota.limitReached && + aiRequestPriceInCredits + ) { payWithCredits = true; const doesNotHaveEnoughCreditsToContinue = availableCredits < aiRequestPriceInCredits; @@ -513,39 +528,65 @@ export const AskAiEditor: React.ComponentType = React.memo( setSendingAiRequest(null, true); setIsSendingUserMessage(true); + const aiConfiguration: AiConfiguration = { + presetId: aiConfigurationPresetId, + providerConfigurationId: aiProviderConfigurationId || null, + }; + const shouldUploadAiUserContent = + !aiProviderConfigurationId || + !isLocalAiProviderConfigurationId(aiProviderConfigurationId); const preparedAiUserContent = await prepareAiUserContent({ getAuthorizationHeader, userId: profile.id, simplifiedProjectJson, projectSpecificExtensionsSummaryJson, eventsJson: null, + shouldUpload: shouldUploadAiUserContent, }); - const aiRequest = await createAiRequest(getAuthorizationHeader, { - userRequest: userRequest, - userId: profile.id, - gameProjectJsonUserRelativeKey: - preparedAiUserContent.gameProjectJsonUserRelativeKey, - gameProjectJson: preparedAiUserContent.gameProjectJson, - projectSpecificExtensionsSummaryJsonUserRelativeKey: - preparedAiUserContent.projectSpecificExtensionsSummaryJsonUserRelativeKey, - projectSpecificExtensionsSummaryJson: - preparedAiUserContent.projectSpecificExtensionsSummaryJson, - payWithCredits, - gameId: project ? project.getProjectUuid() : null, - // $FlowFixMe[incompatible-type] - fileMetadata, - storageProviderName, - mode, - toolsVersion: - mode === 'agent' - ? AI_AGENT_TOOLS_VERSION - : mode === 'orchestrator' - ? AI_ORCHESTRATOR_TOOLS_VERSION - : AI_CHAT_TOOLS_VERSION, - aiConfiguration: { - presetId: aiConfigurationPresetId, - }, + const aiRequest = getAiRequestWithPreservedAiConfiguration({ + aiRequest: await createAiRequest(getAuthorizationHeader, { + userRequest: userRequest, + userId: profile.id, + gameProjectJsonUserRelativeKey: + preparedAiUserContent.gameProjectJsonUserRelativeKey, + gameProjectJson: preparedAiUserContent.gameProjectJson, + projectSpecificExtensionsSummaryJsonUserRelativeKey: + preparedAiUserContent.projectSpecificExtensionsSummaryJsonUserRelativeKey, + projectSpecificExtensionsSummaryJson: + preparedAiUserContent.projectSpecificExtensionsSummaryJson, + payWithCredits, + gameId: project ? project.getProjectUuid() : null, + // $FlowFixMe[incompatible-type] + fileMetadata, + storageProviderName, + mode, + toolsVersion: + mode === 'agent' + ? AI_AGENT_TOOLS_VERSION + : mode === 'orchestrator' + ? AI_ORCHESTRATOR_TOOLS_VERSION + : AI_CHAT_TOOLS_VERSION, + aiConfiguration, + onLocalAiRequestCreated: localAiRequest => { + const localAiRequestWithConfiguration = getAiRequestWithPreservedAiConfiguration( + { + aiRequest: localAiRequest, + aiConfiguration, + } + ); + setSendingAiRequest(null, false); + updateAiRequest( + localAiRequest.id, + () => localAiRequestWithConfiguration + ); + + if (!upToDateSelectedAiRequestId.current) { + setSelectedAiRequestId(localAiRequest.id); + } + }, + }), + aiConfiguration, }); console.info('Successfully created a new AI request:', aiRequest); @@ -579,6 +620,9 @@ export const AskAiEditor: React.ComponentType = React.memo( }); } catch (error) { console.error('Error starting a new AI request:', error); + if (isAiProviderUnavailableError(error)) { + fetchAiProviderConfigurations(); + } setLastSendError(null, error); setIsSendingUserMessage(false); } @@ -610,6 +654,7 @@ export const AskAiEditor: React.ComponentType = React.memo( newAiRequestOptions, automaticallyUseCreditsForAiRequests, storageProviderName, + fetchAiProviderConfigurations, ] ); @@ -672,8 +717,18 @@ export const AskAiEditor: React.ComponentType = React.memo( // Paying with credits is only when a user message is sent (and quota is exhausted). let payWithCredits = false; + const isUsingCustomAiProvider = + !!selectedAiRequest.aiConfiguration && + !!selectedAiRequest.aiConfiguration.providerConfigurationId; + const isUsingLocalAiProvider = + !!selectedAiRequest.aiConfiguration && + !!selectedAiRequest.aiConfiguration.providerConfigurationId && + isLocalAiProviderConfigurationId( + selectedAiRequest.aiConfiguration.providerConfigurationId + ); if ( userMessage && + !isUsingCustomAiProvider && quota && quota.limitReached && aiRequestPriceInCredits @@ -719,6 +774,7 @@ export const AskAiEditor: React.ComponentType = React.memo( simplifiedProjectJson, projectSpecificExtensionsSummaryJson, eventsJson: null, + shouldUpload: !isUsingLocalAiProvider, }); // If we're updating the request, following a function call to initialize the project, @@ -763,15 +819,16 @@ export const AskAiEditor: React.ComponentType = React.memo( userMessage, paused: hasJustInitializedProject && modeForThisMessage === 'agent', - mode, + mode: modeForThisMessage, toolsVersion: - mode === 'agent' + modeForThisMessage === 'agent' ? AI_AGENT_TOOLS_VERSION - : mode === 'orchestrator' + : modeForThisMessage === 'orchestrator' ? AI_ORCHESTRATOR_TOOLS_VERSION - : mode === 'chat' + : modeForThisMessage === 'chat' ? AI_CHAT_TOOLS_VERSION : undefined, + aiConfiguration: selectedAiRequest.aiConfiguration || undefined, }) ); updateAiRequest(aiRequest.id, () => aiRequest); @@ -795,6 +852,9 @@ export const AskAiEditor: React.ComponentType = React.memo( } } catch (error) { console.error('Error while sending AI request message:', error); + if (isAiProviderUnavailableError(error)) { + fetchAiProviderConfigurations(); + } // TODO: update the label of the button to send again. setLastSendError(selectedAiRequestId, error); setIsSendingUserMessage(false); @@ -846,6 +906,7 @@ export const AskAiEditor: React.ComponentType = React.memo( selectedAiRequest, automaticallyUseCreditsForAiRequests, triggerUnsavedChanges, + fetchAiProviderConfigurations, ] ); const onSendEditorFunctionCallResults = React.useCallback( @@ -865,13 +926,15 @@ export const AskAiEditor: React.ComponentType = React.memo( }, [onSendMessage] ); + const selectedEditorFunctionCallResults = + selectedAiRequest && getEditorFunctionCallResults(selectedAiRequest.id); const { onProcessFunctionCalls } = useProcessFunctionCalls({ project, resourceManagementProps, selectedAiRequest, editorCallbacks, onSendEditorFunctionCallResults, - getEditorFunctionCallResults, + editorFunctionCallResults: selectedEditorFunctionCallResults || null, addEditorFunctionCallResults, onSceneEventsModifiedOutsideEditor, onInstancesModifiedOutsideEditor, @@ -1387,9 +1450,8 @@ export const AskAiEditor: React.ComponentType = React.memo( onSendMessage({ userMessage, mode, - editorFunctionCallResults: selectedAiRequest - ? getEditorFunctionCallResults(selectedAiRequest.id) || [] - : [], + editorFunctionCallResults: + selectedEditorFunctionCallResults || [], }) } isSending={isSendingAiRequest(selectedAiRequestId)} @@ -1405,9 +1467,7 @@ export const AskAiEditor: React.ComponentType = React.memo( } onProcessFunctionCalls={onProcessFunctionCalls} editorFunctionCallResults={ - (selectedAiRequest && - getEditorFunctionCallResults(selectedAiRequest.id)) || - null + selectedEditorFunctionCallResults || null } price={aiRequestPrice} availableCredits={availableCredits} @@ -1422,6 +1482,8 @@ export const AskAiEditor: React.ComponentType = React.memo( savingProjectForMessageId={savingProjectForMessageId} forkingState={forkingState} onRestore={onRestore} + aiProviderConfigurations={aiProviderConfigurations} + customProviderSupport={customProviderSupport} /> diff --git a/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js b/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js index 41d3daddf544..da2c4f2135ab 100644 --- a/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js +++ b/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js @@ -5,6 +5,10 @@ import { AiRequestChat, type AiRequestChatInterface } from './AiRequestChat'; import { addMessageToAiRequest, createAiRequest, + getAiRequestWithPreservedAiConfiguration, + isAiProviderUnavailableError, + isLocalAiProviderConfigurationId, + type AiConfiguration, type AiRequest, } from '../Utils/GDevelopServices/Generation'; import { delay } from '../Utils/Delay'; @@ -157,6 +161,11 @@ export const AskAiStandAloneForm = ({ editorFunctionCallResultsStorage, getAiSettings, setSelectedAiRequestId, + aiProviderConfigurationState: { + aiProviderConfigurations, + customProviderSupport, + fetchAiProviderConfigurations, + }, } = React.useContext(AiRequestContext); const { getEditorFunctionCallResults, @@ -247,13 +256,22 @@ export const AskAiStandAloneForm = ({ onCloseAskAi(); // Read the options and reset them (to avoid launching the same request twice). - const { userRequest, aiConfigurationPresetId } = newAiRequestOptions; + const { + userRequest, + aiConfigurationPresetId, + aiProviderConfigurationId, + } = newAiRequestOptions; startNewAiRequest(null); // Ensure the user has enough credits to pay for the request, or ask them // to buy some more. let payWithCredits = false; - if (quota && quota.limitReached && aiRequestPriceInCredits) { + if ( + !aiProviderConfigurationId && + quota && + quota.limitReached && + aiRequestPriceInCredits + ) { payWithCredits = true; const doesNotHaveEnoughCreditsToContinue = availableCredits < aiRequestPriceInCredits; @@ -275,33 +293,60 @@ export const AskAiStandAloneForm = ({ setSendingAiRequest(null, true); setIsSendingUserMessage(true); + const shouldUploadAiUserContent = + !aiProviderConfigurationId || + !isLocalAiProviderConfigurationId(aiProviderConfigurationId); const preparedAiUserContent = await prepareAiUserContent({ getAuthorizationHeader, userId: profile.id, simplifiedProjectJson: null, projectSpecificExtensionsSummaryJson: null, eventsJson: null, + shouldUpload: shouldUploadAiUserContent, }); - const aiRequest = await createAiRequest(getAuthorizationHeader, { - userRequest: userRequest, - userId: profile.id, - gameProjectJsonUserRelativeKey: - preparedAiUserContent.gameProjectJsonUserRelativeKey, - gameProjectJson: preparedAiUserContent.gameProjectJson, - projectSpecificExtensionsSummaryJsonUserRelativeKey: - preparedAiUserContent.projectSpecificExtensionsSummaryJsonUserRelativeKey, - projectSpecificExtensionsSummaryJson: - preparedAiUserContent.projectSpecificExtensionsSummaryJson, - payWithCredits, - gameId: null, // No game associated when starting from the standalone form. - fileMetadata: null, // No file metadata when starting from the standalone form. - storageProviderName, - mode: aiRequestModeForForm, - toolsVersion: AI_ORCHESTRATOR_TOOLS_VERSION, - aiConfiguration: { - presetId: aiConfigurationPresetId, - }, + const aiConfiguration: AiConfiguration = { + presetId: aiConfigurationPresetId, + providerConfigurationId: aiProviderConfigurationId || null, + }; + const aiRequest = getAiRequestWithPreservedAiConfiguration({ + aiRequest: await createAiRequest(getAuthorizationHeader, { + userRequest: userRequest, + userId: profile.id, + gameProjectJsonUserRelativeKey: + preparedAiUserContent.gameProjectJsonUserRelativeKey, + gameProjectJson: preparedAiUserContent.gameProjectJson, + projectSpecificExtensionsSummaryJsonUserRelativeKey: + preparedAiUserContent.projectSpecificExtensionsSummaryJsonUserRelativeKey, + projectSpecificExtensionsSummaryJson: + preparedAiUserContent.projectSpecificExtensionsSummaryJson, + payWithCredits, + gameId: null, // No game associated when starting from the standalone form. + fileMetadata: null, // No file metadata when starting from the standalone form. + storageProviderName, + mode: aiRequestModeForForm, + toolsVersion: AI_ORCHESTRATOR_TOOLS_VERSION, + aiConfiguration, + onLocalAiRequestCreated: localAiRequest => { + const localAiRequestWithConfiguration = getAiRequestWithPreservedAiConfiguration( + { + aiRequest: localAiRequest, + aiConfiguration, + } + ); + setSendingAiRequest(null, false); + updateAiRequest( + localAiRequest.id, + () => localAiRequestWithConfiguration + ); + + if (!upToDateSelectedAiRequestId.current) { + setAiRequestIdForForm(localAiRequest.id); + setSelectedAiRequestId(localAiRequest.id); + } + }, + }), + aiConfiguration, }); console.info('Successfully created a new AI request:', aiRequest); @@ -328,6 +373,9 @@ export const AskAiStandAloneForm = ({ }); } catch (error) { console.error('Error starting a new AI request:', error); + if (isAiProviderUnavailableError(error)) { + fetchAiProviderConfigurations(); + } setLastSendError(null, error); setIsSendingUserMessage(false); } @@ -362,6 +410,7 @@ export const AskAiStandAloneForm = ({ openSubscriptionDialog, onCloseAskAi, automaticallyUseCreditsForAiRequests, + fetchAiProviderConfigurations, ] ); @@ -428,12 +477,19 @@ export const AskAiStandAloneForm = ({ ) : null; + const isUsingLocalAiProvider = + !!aiRequestForForm.aiConfiguration && + !!aiRequestForForm.aiConfiguration.providerConfigurationId && + isLocalAiProviderConfigurationId( + aiRequestForForm.aiConfiguration.providerConfigurationId + ); const preparedAiUserContent = await prepareAiUserContent({ getAuthorizationHeader, userId: profile.id, simplifiedProjectJson, projectSpecificExtensionsSummaryJson, eventsJson: null, + shouldUpload: !isUsingLocalAiProvider, }); const aiRequest: AiRequest = await retryIfFailed({ times: 2 }, () => @@ -459,12 +515,16 @@ export const AskAiStandAloneForm = ({ paused: false, mode: aiRequestModeForForm, toolsVersion: AI_ORCHESTRATOR_TOOLS_VERSION, + aiConfiguration: aiRequestForForm.aiConfiguration || undefined, }) ); updateAiRequest(aiRequest.id, () => aiRequest); setSendingAiRequest(aiRequest.id, false); clearEditorFunctionCallResults(aiRequest.id); } catch (error) { + if (isAiProviderUnavailableError(error)) { + fetchAiProviderConfigurations(); + } // TODO: update the label of the button to send again. setLastSendError(aiRequestIdForForm, error); } @@ -496,6 +556,7 @@ export const AskAiStandAloneForm = ({ project, aiRequestForForm, refreshLimits, + fetchAiProviderConfigurations, ] ); const onSendEditorFunctionCallResults = React.useCallback( @@ -515,13 +576,15 @@ export const AskAiStandAloneForm = ({ }, [onSendMessage] ); + const selectedEditorFunctionCallResults = + aiRequestForForm && getEditorFunctionCallResults(aiRequestForForm.id); const { onProcessFunctionCalls } = useProcessFunctionCalls({ project, resourceManagementProps, selectedAiRequest: aiRequestForForm, editorCallbacks, onSendEditorFunctionCallResults, - getEditorFunctionCallResults, + editorFunctionCallResults: selectedEditorFunctionCallResults || null, addEditorFunctionCallResults, i18n, onSceneEventsModifiedOutsideEditor: () => {}, @@ -593,9 +656,7 @@ export const AskAiStandAloneForm = ({ onSendMessage({ userMessage, // mode, Mode is forced to agent in standalone form, no need to pass it here. - editorFunctionCallResults: aiRequestForForm - ? getEditorFunctionCallResults(aiRequestForForm.id) || [] - : [], + editorFunctionCallResults: selectedEditorFunctionCallResults || [], }) } isSending={isLoading} @@ -610,11 +671,7 @@ export const AskAiStandAloneForm = ({ : 'none' } onProcessFunctionCalls={onProcessFunctionCalls} - editorFunctionCallResults={ - (aiRequestForForm && - getEditorFunctionCallResults(aiRequestForForm.id)) || - null - } + editorFunctionCallResults={selectedEditorFunctionCallResults || null} price={aiRequestPrice} availableCredits={availableCredits} isRefreshingLimits={isRefreshingLimits} @@ -632,6 +689,8 @@ export const AskAiStandAloneForm = ({ savingProjectForMessageId={null} forkingState={null} onRestore={async () => {}} + aiProviderConfigurations={aiProviderConfigurations} + customProviderSupport={customProviderSupport} /> ); diff --git a/newIDE/app/src/AiGeneration/PrepareAiUserContent.js b/newIDE/app/src/AiGeneration/PrepareAiUserContent.js index c9539dbc2110..836e10e225cc 100644 --- a/newIDE/app/src/AiGeneration/PrepareAiUserContent.js +++ b/newIDE/app/src/AiGeneration/PrepareAiUserContent.js @@ -96,12 +96,14 @@ export const prepareAiUserContent = async ({ simplifiedProjectJson, projectSpecificExtensionsSummaryJson, eventsJson, + shouldUpload, }: {| getAuthorizationHeader: () => Promise, userId: string, simplifiedProjectJson: string | null, projectSpecificExtensionsSummaryJson: string | null, eventsJson?: string | null, + shouldUpload?: boolean, |}): Promise<{ eventsJson: null | string, eventsJsonUserRelativeKey: null | string, @@ -146,11 +148,13 @@ export const prepareAiUserContent = async ({ hash: eventsJsonHash, contentLength: eventsJson ? eventsJson.length : 0, }); + const shouldUseUploadedContent = shouldUpload !== false; if ( - shouldUploadGameProjectJson || - shouldUploadProjectSpecificExtensionsSummary || - shouldUploadEventsJson + shouldUseUploadedContent && + (shouldUploadGameProjectJson || + shouldUploadProjectSpecificExtensionsSummary || + shouldUploadEventsJson) ) { const startTime = Date.now(); const { @@ -253,15 +257,17 @@ export const prepareAiUserContent = async ({ // Get the key at which the content was uploaded, if it was uploaded. // If not, the content will be sent as part of the request instead of the upload key. - const gameProjectJsonUserRelativeKey = gameProjectJsonUploadCache.getUserRelativeKey( - gameProjectJsonHash - ); - const projectSpecificExtensionsSummaryJsonUserRelativeKey = projectSpecificExtensionsSummaryUploadCache.getUserRelativeKey( - projectSpecificExtensionsSummaryJsonHash - ); - const eventsJsonUserRelativeKey = eventsJsonUploadCache.getUserRelativeKey( - eventsJsonHash - ); + const gameProjectJsonUserRelativeKey = shouldUseUploadedContent + ? gameProjectJsonUploadCache.getUserRelativeKey(gameProjectJsonHash) + : null; + const projectSpecificExtensionsSummaryJsonUserRelativeKey = shouldUseUploadedContent + ? projectSpecificExtensionsSummaryUploadCache.getUserRelativeKey( + projectSpecificExtensionsSummaryJsonHash + ) + : null; + const eventsJsonUserRelativeKey = shouldUseUploadedContent + ? eventsJsonUploadCache.getUserRelativeKey(eventsJsonHash) + : null; return { gameProjectJsonUserRelativeKey, gameProjectJson: gameProjectJsonUserRelativeKey diff --git a/newIDE/app/src/AiGeneration/UseGenerateEvents.js b/newIDE/app/src/AiGeneration/UseGenerateEvents.js index 67225fffbe54..96b44fc1e06c 100644 --- a/newIDE/app/src/AiGeneration/UseGenerateEvents.js +++ b/newIDE/app/src/AiGeneration/UseGenerateEvents.js @@ -6,6 +6,7 @@ import { delay } from '../Utils/Delay'; import { getAiGeneratedEvent, createAiGeneratedEvent, + isLocalAiRequestId, } from '../Utils/GDevelopServices/Generation'; import { type EventsGenerationResult } from '../EditorFunctions'; @@ -25,6 +26,7 @@ type _UseGenerateEventsReturnType = { relatedAiRequestId: string, sceneName: string, estimatedComplexity: number | null, + repairInstructions?: string | null, }) => Promise, }; export const useGenerateEvents = ({ @@ -47,6 +49,7 @@ export const useGenerateEvents = ({ placementHint, relatedAiRequestId, estimatedComplexity, + repairInstructions, }: {| sceneName: string, eventsDescription: string, @@ -57,6 +60,7 @@ export const useGenerateEvents = ({ placementHint: string, relatedAiRequestId: string, estimatedComplexity: number | null, + repairInstructions?: string | null, |}): Promise => { if (!project) throw new Error('No project is opened.'); if (!profile) throw new Error('User should be authenticated.'); @@ -76,6 +80,7 @@ export const useGenerateEvents = ({ simplifiedProjectJson, projectSpecificExtensionsSummaryJson, eventsJson: existingEventsJson, + shouldUpload: !isLocalAiRequestId(relatedAiRequestId), }); const createResult = await retryIfFailed( @@ -101,6 +106,7 @@ export const useGenerateEvents = ({ placementHint, relatedAiRequestId, estimatedComplexity, + repairInstructions, }) ); diff --git a/newIDE/app/src/AiGeneration/Utils.js b/newIDE/app/src/AiGeneration/Utils.js index c2852ad45cb8..d5efc2405610 100644 --- a/newIDE/app/src/AiGeneration/Utils.js +++ b/newIDE/app/src/AiGeneration/Utils.js @@ -13,6 +13,7 @@ import { type AiRequest, type AiRequestMessage, type AiRequestMessageAssistantFunctionCall, + isLocalAiRequestId, updateAiRequestMessage, } from '../Utils/GDevelopServices/Generation'; import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext'; @@ -98,7 +99,7 @@ export const useProcessFunctionCalls = ({ editorCallbacks, selectedAiRequest, onSendEditorFunctionCallResults, - getEditorFunctionCallResults, + editorFunctionCallResults, addEditorFunctionCallResults, onSceneEventsModifiedOutsideEditor, onInstancesModifiedOutsideEditor, @@ -113,6 +114,7 @@ export const useProcessFunctionCalls = ({ resourceManagementProps: ResourceManagementProps, editorCallbacks: EditorCallbacks, selectedAiRequest: ?AiRequest, + editorFunctionCallResults: Array | null, onSendEditorFunctionCallResults: ( editorFunctionCallResults: Array, options: {| @@ -120,7 +122,6 @@ export const useProcessFunctionCalls = ({ createdProject?: ?gdProject, |} ) => Promise, - getEditorFunctionCallResults: string => Array | null, addEditorFunctionCallResults: ( string, Array @@ -294,12 +295,10 @@ export const useProcessFunctionCalls = ({ selectedAiRequest ? getFunctionCallsToProcess({ aiRequest: selectedAiRequest, - editorFunctionCallResults: getEditorFunctionCallResults( - selectedAiRequest.id - ), + editorFunctionCallResults, }) : [], - [selectedAiRequest, getEditorFunctionCallResults] + [selectedAiRequest, editorFunctionCallResults] ); React.useEffect( @@ -407,6 +406,7 @@ export const useAiRequestState = ({ !selectedAiRequest || (selectedAiRequest.mode !== 'agent' && selectedAiRequest.mode !== 'orchestrator') || + isLocalAiRequestId(selectedAiRequest.id) || isSendingAiRequest(selectedAiRequest.id) || !selectedAiRequest.output || selectedAiRequest.output.length === 0 || @@ -906,4 +906,5 @@ export type NewAiRequestOptions = {| mode: 'chat' | 'agent' | 'orchestrator', userRequest: string, aiConfigurationPresetId: string, + aiProviderConfigurationId?: string | null, |}; diff --git a/newIDE/app/src/AiGeneration/Utils.spec.js b/newIDE/app/src/AiGeneration/Utils.spec.js new file mode 100644 index 000000000000..bcf0cd12c931 --- /dev/null +++ b/newIDE/app/src/AiGeneration/Utils.spec.js @@ -0,0 +1,136 @@ +// @flow +import * as React from 'react'; +// $FlowFixMe[missing-export] The Flow stub does not include React 18's act export. +import renderer, { act } from 'react-test-renderer'; +import { processEditorFunctionCalls } from '../EditorFunctions/EditorFunctionCallRunner'; +import { useProcessFunctionCalls } from './Utils'; + +jest.mock('../EditorFunctions/EditorFunctionCallRunner', () => ({ + processEditorFunctionCalls: jest.fn(), +})); + +jest.mock('./UseEnsureExtensionInstalled', () => ({ + useEnsureExtensionInstalled: () => ({ + ensureExtensionInstalled: jest.fn(), + }), +})); + +jest.mock('./UseGenerateEvents', () => ({ + useGenerateEvents: () => ({ + generateEvents: jest.fn(), + }), +})); + +jest.mock('./UseSearchAndInstallAsset', () => ({ + useSearchAndInstallAsset: () => ({ + searchAndInstallAsset: jest.fn(), + }), +})); + +jest.mock('./UseSearchAndInstallResource', () => ({ + useSearchAndInstallResource: () => ({ + searchAndInstallResources: jest.fn(), + }), +})); + +const selectedAiRequest: any = { + id: 'ai-request-1', + createdAt: '2026-05-16T00:00:00.000Z', + updatedAt: '2026-05-16T00:00:00.000Z', + userId: 'user-1', + status: 'ready', + mode: 'agent', + error: null, + output: [ + { + type: 'message', + status: 'completed', + role: 'assistant', + content: [ + { + type: 'function_call', + status: 'completed', + call_id: 'call-1', + name: 'create_scene', + arguments: '{"scene_name":"Game"}', + }, + ], + }, + ], +}; + +const HookHarness = ({ + editorFunctionCallResults, +}: {| + editorFunctionCallResults: any, +|}) => { + useProcessFunctionCalls({ + i18n: ({ _: message => message.id || message }: any), + project: null, + resourceManagementProps: ({}: any), + editorCallbacks: ({ + onOpenLayout: jest.fn(), + onCreateProject: jest.fn(), + }: any), + selectedAiRequest, + editorFunctionCallResults, + onSendEditorFunctionCallResults: jest.fn(), + addEditorFunctionCallResults: jest.fn(() => [ + { status: 'working', call_id: 'call-1' }, + ]), + onSceneEventsModifiedOutsideEditor: jest.fn(), + onInstancesModifiedOutsideEditor: jest.fn(), + onObjectsModifiedOutsideEditor: jest.fn(), + onObjectGroupsModifiedOutsideEditor: jest.fn(), + onWillInstallExtension: jest.fn(), + onExtensionInstalled: jest.fn(), + isReadyToProcessFunctionCalls: true, + }); + + return null; +}; + +describe('useProcessFunctionCalls', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('does not reprocess a function call after it is marked working', async () => { + let resolveProcessFunctionCalls: any = null; + (processEditorFunctionCalls: any).mockReturnValue( + new Promise(resolve => { + resolveProcessFunctionCalls = resolve; + }) + ); + const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {}); + + let tree: any = null; + await act(async () => { + tree = renderer.create(); + }); + + await act(async () => { + if (!tree) throw new Error('Expected hook harness to render.'); + tree.update( + + ); + }); + + expect(processEditorFunctionCalls).toHaveBeenCalledTimes(1); + expect(infoSpy).not.toHaveBeenCalledWith( + 'All function calls are already being processed (in-flight guard), skipping.' + ); + + await act(async () => { + resolveProcessFunctionCalls({ + results: [{ status: 'success', call_id: 'call-1', message: 'ok' }], + createdSceneNames: [], + createdProject: null, + }); + await Promise.resolve(); + }); + }); +}); diff --git a/newIDE/app/src/EditorFunctions/EditorFunctions.spec.js b/newIDE/app/src/EditorFunctions/EditorFunctions.spec.js index 0e4f282b4824..59b6587cf77e 100644 --- a/newIDE/app/src/EditorFunctions/EditorFunctions.spec.js +++ b/newIDE/app/src/EditorFunctions/EditorFunctions.spec.js @@ -4,6 +4,8 @@ import { PixiResourcesLoaderMock } from '../fixtures/TestPixiResourcesLoader'; import { editorFunctions, type EditorFunctionGenericOutput, + type EventsGenerationOptions, + type EventsGenerationResult, type LaunchFunctionOptionsWithProject, } from './index'; @@ -1095,6 +1097,157 @@ describe('editorFunctions', () => { newOrChangedAiGeneratedEventIds: new Set(['test-ai-event-id']), }); }); + + it('asks a local Custom Model to repair generated events that cannot be applied', async () => { + testScene.getObjects().insertNewObject(project, 'Sprite', 'Player', 0); + const generateEvents: JestMockFn< + [EventsGenerationOptions], + Promise + > = jest + .fn<[EventsGenerationOptions], EventsGenerationResult>() + .mockResolvedValueOnce({ + generationCompleted: true, + aiGeneratedEvent: { + id: 'local-custom-provider-ai-generated-event-first', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + userId: 'test-user', + status: 'ready', + partialGameProjectJson: '{}', + eventsDescription: 'Add a basic event', + extensionNamesList: '', + objectsList: 'Player', + existingEventsAsText: '', + existingEventsJson: null, + existingEventsJsonUserRelativeKey: null, + resultMessage: 'Generated an unsupported operation.', + changes: [ + { + operationName: 'unknown_operation', + operationTargetEvent: null, + isEventsJsonValid: true, + generatedEvents: '[]', + areEventsValid: true, + extensionNames: [], + diagnosticLines: [], + undeclaredVariables: [ + { + name: 'discardedGlobalVariable', + type: 'number', + requiredScope: 'global', + }, + { + name: 'discardedSceneVariable', + type: 'number', + requiredScope: 'scene', + }, + ], + undeclaredObjectVariables: { + Player: [ + { + name: 'discardedObjectVariable', + type: 'number', + requiredScope: 'none', + }, + ], + }, + missingObjectBehaviors: {}, + missingResources: [], + }, + ], + error: null, + stats: null, + }, + }) + .mockImplementationOnce(options => { + expect(options.repairInstructions).toContain( + 'could not be applied by GDevelop' + ); + expect(options.repairInstructions).toContain( + 'missing operationTargetEvent' + ); + return Promise.resolve({ + generationCompleted: true, + aiGeneratedEvent: { + id: 'local-custom-provider-ai-generated-event-second', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + userId: 'test-user', + status: 'ready', + partialGameProjectJson: '{}', + eventsDescription: 'Add a basic event', + extensionNamesList: '', + objectsList: 'Player', + existingEventsAsText: '', + existingEventsJson: null, + existingEventsJsonUserRelativeKey: null, + resultMessage: 'Repaired event added.', + changes: [ + { + operationName: 'insert_at_end', + operationTargetEvent: null, + isEventsJsonValid: true, + generatedEvents: JSON.stringify([ + { + type: 'BuiltinCommonInstructions::Standard', + conditions: [], + actions: [], + }, + ]), + areEventsValid: true, + extensionNames: [], + diagnosticLines: [], + undeclaredVariables: [], + undeclaredObjectVariables: {}, + missingObjectBehaviors: {}, + missingResources: [], + }, + ], + error: null, + stats: null, + }, + }); + }); + // $FlowFixMe[underconstrained-implicit-instantiation] + const onSceneEventsModifiedOutsideEditor = jest.fn(); + + const result = await editorFunctions.add_scene_events.launchFunction({ + ...makeFakeLaunchFunctionOptionsWithProject(project), + args: { + scene_name: 'TestScene', + events_description: 'Add a basic event', + extension_names_list: '', + objects_list: 'Player', + }, + generateEvents, + onSceneEventsModifiedOutsideEditor, + // $FlowFixMe[underconstrained-implicit-instantiation] + searchAndInstallResources: jest.fn().mockResolvedValue({ results: [] }), + }); + + expect(generateEvents).toHaveBeenCalledTimes(2); + expect(result).toEqual({ + success: true, + message: 'Repaired event added.', + aiGeneratedEventId: 'local-custom-provider-ai-generated-event-second', + newlyAddedResources: [], + }); + const repairedScene = project.getLayout('TestScene'); + expect(onSceneEventsModifiedOutsideEditor).toHaveBeenCalledWith({ + scene: repairedScene, + newOrChangedAiGeneratedEventIds: new Set([ + 'local-custom-provider-ai-generated-event-second', + ]), + }); + expect(project.getVariables().has('discardedGlobalVariable')).toBe(false); + expect(repairedScene.getVariables().has('discardedSceneVariable')).toBe( + false + ); + const repairedPlayer = repairedScene.getObjects().getObject('Player'); + expect(repairedPlayer.getVariables().has('discardedObjectVariable')).toBe( + false + ); + }); }); describe('add_or_edit_variable', () => { diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index be2011657b06..bd509f84f28d 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -8,7 +8,10 @@ import { serializeToJSON, unserializeFromJSObject, } from '../Utils/Serializer'; -import { type AiGeneratedEvent } from '../Utils/GDevelopServices/Generation'; +import { + isLocalAiGeneratedEventId, + type AiGeneratedEvent, +} from '../Utils/GDevelopServices/Generation'; import { renderNonTranslatedEventsAsText } from '../EventsSheet/EventsTree/TextRenderer'; import { addMissingObjectBehaviors, @@ -154,6 +157,7 @@ export type EventsGenerationOptions = {| placementHint: string, relatedAiRequestId: string, estimatedComplexity: number | null, + repairInstructions?: string | null, |}; export type AssetSearchAndInstallResult = {| @@ -3769,7 +3773,7 @@ const addSceneEvents: EditorFunction = { onWillInstallExtension, onExtensionInstalled, searchAndInstallResources, - }) => { + }): Promise => { const sceneName = extractRequiredString(args, 'scene_name'); const eventsDescription = extractRequiredString(args, 'events_description'); const extensionNamesList = extractRequiredString( @@ -3796,8 +3800,8 @@ const addSceneEvents: EditorFunction = { 'No related AI request ID found for events generation.' ); } - const scene = project.getLayout(sceneName); - const currentSceneEvents = scene.getEvents(); + let scene = project.getLayout(sceneName); + let currentSceneEvents = scene.getEvents(); const existingEventsAsText = renderNonTranslatedEventsAsText({ eventsList: currentSceneEvents, @@ -3807,220 +3811,266 @@ const addSceneEvents: EditorFunction = { ? serializeToJSON(currentSceneEvents) : null; - try { - const eventsGenerationResult: EventsGenerationResult = await generateEvents( - { - sceneName, - eventsDescription, - extensionNamesList, - objectsList, - existingEventsAsText, - existingEventsJson, - placementHint, - relatedAiRequestId, - estimatedComplexity, - } - ); - - if (eventsGenerationResult.generationAborted) { - return { success: false, aborted: true }; - } + let hasRetriedLocalEventRepair = false; + let repairInstructions: string | null = null; + const restoreProjectBeforeApplyingChanges = ( + serializedProjectBeforeApplyingChanges: any + ) => { + unserializeFromJSObject(project, serializedProjectBeforeApplyingChanges); + scene = project.getLayout(sceneName); + currentSceneEvents = scene.getEvents(); + }; - if (!eventsGenerationResult.generationCompleted) { - return makeGenericFailure( - `Infrastructure error when launching or completing events generation (${ - eventsGenerationResult.errorMessage - }). Consider trying again or a different approach.` + for (;;) { + try { + const eventsGenerationResult: EventsGenerationResult = await generateEvents( + { + sceneName, + eventsDescription, + extensionNamesList, + objectsList, + existingEventsAsText, + existingEventsJson, + placementHint, + relatedAiRequestId, + estimatedComplexity, + repairInstructions, + } ); - } - const aiGeneratedEvent = eventsGenerationResult.aiGeneratedEvent; + if (eventsGenerationResult.generationAborted) { + return { success: false, aborted: true }; + } - const makeAiGeneratedEventFailure = ( - message: string, - details?: {| - generatedEventsErrorDiagnostics: string, - |} - ) => { - return { - success: false, - message, - aiGeneratedEventId: aiGeneratedEvent.id, - ...details, - }; - }; + if (!eventsGenerationResult.generationCompleted) { + return makeGenericFailure( + `Infrastructure error when launching or completing events generation (${ + eventsGenerationResult.errorMessage + }). Consider trying again or a different approach.` + ); + } - if (aiGeneratedEvent.error) { - // $FlowFixMe[incompatible-type] - return makeAiGeneratedEventFailure( - `Infrastructure error when generating events (${ - aiGeneratedEvent.error.message - }). Consider trying again or a different approach.` - ); - } + const aiGeneratedEvent = eventsGenerationResult.aiGeneratedEvent; - const changes = aiGeneratedEvent.changes; - if (!changes || changes.length === 0) { - const resultMessage = - aiGeneratedEvent.resultMessage || - 'No generated events found and no other information was given.'; - // $FlowFixMe[incompatible-type] - return makeAiGeneratedEventFailure( - `Error when generating events: ${resultMessage}\nConsider trying again or a different approach.` - ); - } + const makeAiGeneratedEventFailure = ( + message: string, + details?: {| + generatedEventsErrorDiagnostics: string, + |} + ): EditorFunctionGenericOutput => { + return { + success: false, + message, + aiGeneratedEventId: aiGeneratedEvent.id, + ...details, + }; + }; - if ( - changes.some(change => change.isEventsJsonValid === false) || - changes.some(change => change.areEventsValid === false) - ) { - const resultMessage = - aiGeneratedEvent.resultMessage || - 'This probably means what you asked for is not possible or does not work like this.'; - // $FlowFixMe[incompatible-type] - return makeAiGeneratedEventFailure( - `Generated events are not valid: ${resultMessage}\nRead also the attached diagnostics to try to understand what went wrong and either try again differently or consider a different approach.`, - { - generatedEventsErrorDiagnostics: changes - .map(change => change.diagnosticLines.join('\n')) - .join('\n\n'), - } - ); - } + if (aiGeneratedEvent.error) { + // $FlowFixMe[incompatible-type] + return makeAiGeneratedEventFailure( + `Infrastructure error when generating events (${ + aiGeneratedEvent.error.message + }). Consider trying again or a different approach.` + ); + } - try { - const extensionNames = new Set(); - for (const change of changes) { - for (const extensionName of change.extensionNames || []) { - extensionNames.add(extensionName); - } + const changes = aiGeneratedEvent.changes; + if (!changes || changes.length === 0) { + const resultMessage = + aiGeneratedEvent.resultMessage || + 'No generated events found and no other information was given.'; + // $FlowFixMe[incompatible-type] + return makeAiGeneratedEventFailure( + `Error when generating events: ${resultMessage}\nConsider trying again or a different approach.` + ); } - for (const extensionName of extensionNames) { - await ensureExtensionInstalled({ - extensionName, - onWillInstallExtension, - onExtensionInstalled, - }); + + if ( + changes.some(change => change.isEventsJsonValid === false) || + changes.some(change => change.areEventsValid === false) + ) { + const resultMessage = + aiGeneratedEvent.resultMessage || + 'This probably means what you asked for is not possible or does not work like this.'; + // $FlowFixMe[incompatible-type] + return makeAiGeneratedEventFailure( + `Generated events are not valid: ${resultMessage}\nRead also the attached diagnostics to try to understand what went wrong and either try again differently or consider a different approach.`, + { + generatedEventsErrorDiagnostics: changes + .map(change => change.diagnosticLines.join('\n')) + .join('\n\n'), + } + ); } - } catch (e) { - // $FlowFixMe[incompatible-type] - return makeAiGeneratedEventFailure( - `Error when installing extensions: ${ - e.message - }. Consider trying again or a different approach.` + + const serializedProjectBeforeApplyingChanges = serializeToJSObject( + project ); - } - try { - for (const change of changes) { - addUndeclaredVariables({ - project, - scene, - undeclaredVariables: change.undeclaredVariables, - }); - const objectNamesWithUndeclaredVariables = Object.keys( - change.undeclaredObjectVariables - ); - for (const objectName of objectNamesWithUndeclaredVariables) { - const undeclaredVariables = - change.undeclaredObjectVariables[objectName]; - addObjectUndeclaredVariables({ - project, - scene, - objectName, - undeclaredVariables, + try { + const extensionNames = new Set(); + for (const change of changes) { + for (const extensionName of change.extensionNames || []) { + extensionNames.add(extensionName); + } + } + for (const extensionName of extensionNames) { + await ensureExtensionInstalled({ + extensionName, + onWillInstallExtension, + onExtensionInstalled, }); } - - const objectNamesWithMissingBehavior = Object.keys( - change.missingObjectBehaviors + } catch (e) { + // $FlowFixMe[incompatible-type] + return makeAiGeneratedEventFailure( + `Error when installing extensions: ${ + e.message + }. Consider trying again or a different approach.` ); - for (const objectName of objectNamesWithMissingBehavior) { - const missingBehaviors = change.missingObjectBehaviors[objectName]; - addMissingObjectBehaviors({ + } + try { + for (const change of changes) { + addUndeclaredVariables({ project, scene, - objectName, - missingBehaviors, + undeclaredVariables: change.undeclaredVariables, }); + + const objectNamesWithUndeclaredVariables = Object.keys( + change.undeclaredObjectVariables + ); + for (const objectName of objectNamesWithUndeclaredVariables) { + const undeclaredVariables = + change.undeclaredObjectVariables[objectName]; + addObjectUndeclaredVariables({ + project, + scene, + objectName, + undeclaredVariables, + }); + } + + const objectNamesWithMissingBehavior = Object.keys( + change.missingObjectBehaviors + ); + for (const objectName of objectNamesWithMissingBehavior) { + const missingBehaviors = + change.missingObjectBehaviors[objectName]; + addMissingObjectBehaviors({ + project, + scene, + objectName, + missingBehaviors, + }); + } } - } - const { applied, errors } = applyEventsChanges( - project, - currentSceneEvents, - changes, - aiGeneratedEvent.id - ); + const { applied, errors } = applyEventsChanges( + project, + currentSceneEvents, + changes, + aiGeneratedEvent.id + ); - if (applied === 0) { - return { - success: false, - message: `Changes were properly generated, but could not be applied. Event generation output is: + if (applied === 0) { + const applyErrorMessage = + errors.length > 0 + ? errors.join('\n') + : 'GDevelop could not apply the generated event changes.'; + if ( + !hasRetriedLocalEventRepair && + isLocalAiGeneratedEventId(aiGeneratedEvent.id) + ) { + restoreProjectBeforeApplyingChanges( + serializedProjectBeforeApplyingChanges + ); + hasRetriedLocalEventRepair = true; + repairInstructions = [ + 'The previously generated event changes could not be applied by GDevelop.', + `Apply error:\n${applyErrorMessage}`, + `Previous generation output:\n${aiGeneratedEvent.resultMessage || + '(no generation output was given)'}`, + 'Return corrected JSON only, using the same {"resultMessage":"...","changes":[AiGeneratedEventChange]} shape.', + ].join('\n\n'); + continue; + } + + restoreProjectBeforeApplyingChanges( + serializedProjectBeforeApplyingChanges + ); + return { + success: false, + message: `Changes were properly generated, but could not be applied. Event generation output is: ${aiGeneratedEvent.resultMessage || '(no generation output was given)'}]. No changes were done on the project, see attached errors.`, - errors, - }; - } + errors, + }; + } - onSceneEventsModifiedOutsideEditor({ - scene, - newOrChangedAiGeneratedEventIds: new Set([aiGeneratedEvent.id]), - }); + onSceneEventsModifiedOutsideEditor({ + scene, + newOrChangedAiGeneratedEventIds: new Set([aiGeneratedEvent.id]), + }); - // Search and install missing resources if any - const allMissingResources = changes.flatMap( - change => change.missingResources || [] - ); - const { - results: newlyAddedResources, - } = await searchAndInstallResources({ - resources: allMissingResources, - }); + // Search and install missing resources if any + const allMissingResources = changes.flatMap( + change => change.missingResources || [] + ); + const { + results: newlyAddedResources, + } = await searchAndInstallResources({ + resources: allMissingResources, + }); - const resultMessage = - errors.length > 0 - ? `Changes were properly generated, but some errors happened when applying some of them in the project. Generation output is: + const resultMessage = + errors.length > 0 + ? `Changes were properly generated, but some errors happened when applying some of them in the project. Generation output is: ${aiGeneratedEvent.resultMessage || '(no generation output was given)'}]. See attached errors that happened when some changes were applied in the project. Verify the content of events if necessary to be sure what was done.` - : aiGeneratedEvent.resultMessage || - 'Properly modified or added new event(s).'; - return { - success: true, - message: resultMessage, - aiGeneratedEventId: aiGeneratedEvent.id, - newlyAddedResources, - ...(errors.length > 0 ? { errors } : undefined), - }; + : aiGeneratedEvent.resultMessage || + 'Properly modified or added new event(s).'; + return { + success: true, + message: resultMessage, + aiGeneratedEventId: aiGeneratedEvent.id, + newlyAddedResources, + ...(errors.length > 0 ? { errors } : undefined), + }; + } catch (error) { + console.error( + `Unexpected error when adding events from an AI Generated Event (id: ${ + aiGeneratedEvent.id + }):`, + error + ); + // $FlowFixMe[incompatible-type] + return makeAiGeneratedEventFailure( + `An unexpected error happened in the GDevelop editor while adding generated events: ${ + error.message + }. Consider a different approach.` + ); + } } catch (error) { console.error( - `Unexpected error when adding events from an AI Generated Event (id: ${ - aiGeneratedEvent.id - }):`, + 'Unexpected error when creating AI Generated Event:', error ); - // $FlowFixMe[incompatible-type] - return makeAiGeneratedEventFailure( - `An unexpected error happened in the GDevelop editor while adding generated events: ${ + return makeGenericFailure( + `An unexpected error happened in the GDevelop editor while creating generated events: ${ error.message }. Consider a different approach.` ); } - } catch (error) { - console.error( - 'Unexpected error when creating AI Generated Event:', - error - ); - return makeGenericFailure( - `An unexpected error happened in the GDevelop editor while creating generated events: ${ - error.message - }. Consider a different approach.` - ); } + // Flow does not infer that the retry loop always returns or continues. + // eslint-disable-next-line no-unreachable + throw new Error('Unexpected event generation loop exit.'); }, modifiesProject: true, }; diff --git a/newIDE/app/src/MainFrame/Preferences/AiProvidersPreferences.js b/newIDE/app/src/MainFrame/Preferences/AiProvidersPreferences.js new file mode 100644 index 000000000000..db38e5ac65ea --- /dev/null +++ b/newIDE/app/src/MainFrame/Preferences/AiProvidersPreferences.js @@ -0,0 +1,830 @@ +// @flow +import { t, Trans } from '@lingui/macro'; +import { I18n } from '@lingui/react'; +import * as React from 'react'; +import AlertMessage from '../../UI/AlertMessage'; +import CompactSelectField from '../../UI/CompactSelectField'; +import FlatButton from '../../UI/FlatButton'; +import { Column, Line } from '../../UI/Grid'; +import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout'; +import RaisedButton from '../../UI/RaisedButton'; +import SelectField from '../../UI/SelectField'; +import SelectOption from '../../UI/SelectOption'; +import Text from '../../UI/Text'; +import TextField from '../../UI/TextField'; +import useAlertDialog from '../../UI/Alert/useAlertDialog'; +import { type MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow'; +import { extractGDevelopApiErrorStatusAndCode } from '../../Utils/GDevelopServices/Errors'; +import { AiRequestContext } from '../../AiGeneration/AiRequestContext'; +import { + aiProviderPresets, + CUSTOM_PROVIDER_SELECTION_ID, + getConfigurationIdFromSelectionId, + getConfigurationSelectionId, + getPresetConfiguration, + getPresetIdFromSelectionId, + getPresetSelectionId, + getSelectionIdFromAiProviderConfiguration, + isPresetConfiguration, + type AiProviderPreset, +} from '../../AiGeneration/AiProviderConfigurations'; +import PreferencesContext from './PreferencesContext'; +import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; +import { + createAiProviderConfiguration, + deleteAiProviderConfiguration, + isLocalAiProviderBaseUrl, + testAiProviderConfiguration, + updateAiProviderConfiguration, + type AiProviderConfiguration, + type AiProviderConfigurationWritePayload, + type AiProviderReasoningEffort, +} from '../../Utils/GDevelopServices/Generation'; + +type FormState = {| + name: string, + baseUrl: string, + model: string, + apiKey: string, + temperature: string, + maxTokens: string, + reasoningEffort: string, +|}; + +const DEFAULT_PROVIDER_TEMPERATURE = '0.2'; +const AUTO_REASONING_EFFORT = ''; + +const emptyFormState: FormState = { + name: '', + baseUrl: '', + model: '', + apiKey: '', + temperature: DEFAULT_PROVIDER_TEMPERATURE, + maxTokens: '', + reasoningEffort: AUTO_REASONING_EFFORT, +}; + +const presetToFormState = (preset: AiProviderPreset): FormState => ({ + name: preset.name, + baseUrl: preset.baseUrl, + model: preset.model, + apiKey: '', + temperature: DEFAULT_PROVIDER_TEMPERATURE, + maxTokens: '', + reasoningEffort: AUTO_REASONING_EFFORT, +}); + +const configurationToFormState = ( + configuration: AiProviderConfiguration +): FormState => ({ + name: configuration.name || '', + baseUrl: configuration.baseUrl || '', + model: configuration.model || '', + apiKey: '', + temperature: + configuration.temperature === null || + configuration.temperature === undefined + ? '' + : configuration.temperature.toString(), + maxTokens: + configuration.maxTokens === null || configuration.maxTokens === undefined + ? '' + : configuration.maxTokens.toString(), + reasoningEffort: configuration.reasoningEffort || AUTO_REASONING_EFFORT, +}); + +const getNumberOrNull = (value: string): number | null => { + if (!value.trim()) return null; + const parsedValue = Number(value); + return Number.isFinite(parsedValue) ? parsedValue : null; +}; + +const getIntegerOrNull = (value: string): number | null => { + if (!value.trim()) return null; + const parsedValue = parseInt(value, 10); + return Number.isFinite(parsedValue) ? parsedValue : null; +}; + +const getReasoningEffortOrNull = ( + value: string +): AiProviderReasoningEffort | null => { + switch (value) { + case 'none': + case 'minimal': + case 'low': + case 'medium': + case 'high': + case 'xhigh': + return value; + default: + return null; + } +}; + +const stripMatchingQuotes = (value: string): string => { + const trimmedValue = value.trim(); + if (trimmedValue.length < 2) return trimmedValue; + + const firstCharacter = trimmedValue[0]; + const lastCharacter = trimmedValue[trimmedValue.length - 1]; + return (firstCharacter === '"' && lastCharacter === '"') || + (firstCharacter === "'" && lastCharacter === "'") + ? trimmedValue.slice(1, -1).trim() + : trimmedValue; +}; + +const getSanitizedApiKey = (apiKey: string): string => { + let sanitizedApiKey = stripMatchingQuotes(apiKey); + const headerArgumentMatch = sanitizedApiKey.match( + /^(?:-H|--header)\s+(.+)$/i + ); + sanitizedApiKey = headerArgumentMatch + ? stripMatchingQuotes(headerArgumentMatch[1]) + : sanitizedApiKey; + + const apiKeyAssignmentMatch = sanitizedApiKey.match( + /^(?:[a-z0-9_]*api[_-]?key|apiKey)\s*[:=]\s*(.+)$/i + ); + sanitizedApiKey = apiKeyAssignmentMatch + ? stripMatchingQuotes(apiKeyAssignmentMatch[1]) + : sanitizedApiKey; + + const authorizationHeaderMatch = sanitizedApiKey.match( + /^authorization\s*[:=]\s*(.+)$/i + ); + sanitizedApiKey = authorizationHeaderMatch + ? stripMatchingQuotes(authorizationHeaderMatch[1]) + : sanitizedApiKey; + + const bearerTokenMatch = sanitizedApiKey.match(/^bearer\s+(.+)$/i); + return bearerTokenMatch + ? stripMatchingQuotes(bearerTokenMatch[1]) + : sanitizedApiKey; +}; + +const isInvalidAuthorizationHeaderMessage = (message: string): boolean => + /Invalid key=value pair \(missing equal-sign\) in Authorization header/i.test( + message + ); + +const getResponseHeader = (error: any, headerName: string): string | null => { + const headers = + error && error.response && error.response.headers + ? error.response.headers + : null; + if (!headers) return null; + + const normalizedHeaderName = headerName.toLowerCase(); + const matchingHeaderName = Object.keys(headers).find( + headerName => headerName.toLowerCase() === normalizedHeaderName + ); + if (!matchingHeaderName) return null; + + const value = headers[matchingHeaderName]; + return typeof value === 'string' ? value : null; +}; + +const isGDevelopServicesIncompleteSignatureError = (error: any): boolean => { + const errorType = getResponseHeader(error, 'x-amzn-errortype'); + return !!errorType && errorType.indexOf('IncompleteSignatureException') === 0; +}; + +const getErrorMessage = ( + error: any, + fallbackMessage: MessageDescriptor +): MessageDescriptor => { + const responseData = + error && error.response && error.response.data ? error.response.data : null; + if (responseData && typeof responseData.message === 'string') { + if (isInvalidAuthorizationHeaderMessage(responseData.message)) { + if (isGDevelopServicesIncompleteSignatureError(error)) { + return t`Remote AI providers are not available from this GDevelop services environment yet.`; + } + + return t`The provider rejected the API key format. Paste only the API key value, without "Bearer" or "Authorization:", then save again.`; + } + + return responseData.message; + } + + const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(error); + if (extractedStatusAndCode && extractedStatusAndCode.status === 403) { + return t`You don't have access to remote AI providers yet.`; + } + + return fallbackMessage; +}; + +const TranslatedAlertMessage = ({ + kind, + message, +}: {| + kind: 'info' | 'warning', + message: MessageDescriptor, +|}): React.Node => ( + + {({ i18n }) => {i18n._(message)}} + +); + +const GDevelopAiDefaultProvider = (): React.Node => ( + + + + GDevelop AI + + + Default provider for new Ask AI requests. + + + +); + +export const AiProvidersPreferences = (): React.Node => { + const { + aiProviderConfigurationState: { + aiProviderConfigurations, + customProviderSupport, + isLoading, + fetchAiProviderConfigurations, + }, + } = React.useContext(AiRequestContext); + const { profile, getAuthorizationHeader } = React.useContext( + AuthenticatedUserContext + ); + const { + values: { aiProviderSettingsSelectionId }, + setAiProviderSettingsSelectionId, + } = React.useContext(PreferencesContext); + const { showConfirmation } = useAlertDialog(); + + const openAiCompatibleConfigurations = React.useMemo( + (): Array => + aiProviderConfigurations.filter( + configuration => configuration.providerType === 'openai-compatible' + ), + [aiProviderConfigurations] + ); + const presetConfigurationsById = React.useMemo( + (): { [string]: AiProviderConfiguration | null } => { + const configurationsById: { + [string]: AiProviderConfiguration | null, + } = {}; + aiProviderPresets.forEach(preset => { + configurationsById[preset.id] = getPresetConfiguration( + openAiCompatibleConfigurations, + preset + ); + }); + return configurationsById; + }, + [openAiCompatibleConfigurations] + ); + const customOpenAiCompatibleConfigurations = React.useMemo( + (): Array => + openAiCompatibleConfigurations.filter( + configuration => !isPresetConfiguration(configuration) + ), + [openAiCompatibleConfigurations] + ); + + const [ + selectedProviderSelectionId, + setSelectedProviderSelectionId, + ] = React.useState( + aiProviderSettingsSelectionId || + getPresetSelectionId(aiProviderPresets[0].id) + ); + const [formState, setFormState] = React.useState(emptyFormState); + const [ + operationError, + setOperationError, + ] = React.useState(null); + const [ + operationMessage, + setOperationMessage, + ] = React.useState(null); + const [isSaving, setIsSaving] = React.useState(false); + const [isTesting, setIsTesting] = React.useState(false); + const [isDeleting, setIsDeleting] = React.useState(false); + + const selectedPresetId = getPresetIdFromSelectionId( + selectedProviderSelectionId + ); + const selectedAiProviderPreset = selectedPresetId + ? aiProviderPresets.find(preset => preset.id === selectedPresetId) || null + : null; + const selectedCustomConfigurationId = getConfigurationIdFromSelectionId( + selectedProviderSelectionId + ); + const selectedCustomConfiguration = selectedCustomConfigurationId + ? customOpenAiCompatibleConfigurations.find( + configuration => configuration.id === selectedCustomConfigurationId + ) || null + : null; + const selectedConfiguration = selectedAiProviderPreset + ? presetConfigurationsById[selectedAiProviderPreset.id] || null + : selectedCustomConfiguration; + + React.useEffect( + () => { + if (isLoading || isSaving || isDeleting) return; + + if (selectedPresetId && !selectedAiProviderPreset) { + const fallbackSelectionId = getPresetSelectionId( + aiProviderPresets[0].id + ); + setSelectedProviderSelectionId(fallbackSelectionId); + setAiProviderSettingsSelectionId(fallbackSelectionId); + return; + } + + if ( + selectedCustomConfigurationId && + !customOpenAiCompatibleConfigurations.some( + configuration => configuration.id === selectedCustomConfigurationId + ) + ) { + const fallbackSelectionId = getPresetSelectionId( + aiProviderPresets[0].id + ); + setSelectedProviderSelectionId(fallbackSelectionId); + setAiProviderSettingsSelectionId(fallbackSelectionId); + } + }, + [ + isLoading, + isSaving, + isDeleting, + customOpenAiCompatibleConfigurations, + setAiProviderSettingsSelectionId, + selectedAiProviderPreset, + selectedCustomConfigurationId, + selectedPresetId, + ] + ); + + React.useEffect( + () => { + setOperationError(null); + setOperationMessage(null); + setFormState( + selectedConfiguration + ? configurationToFormState(selectedConfiguration) + : selectedAiProviderPreset + ? presetToFormState(selectedAiProviderPreset) + : emptyFormState + ); + }, + [selectedConfiguration, selectedAiProviderPreset] + ); + + const setFormValue = React.useCallback( + (key: $Keys, value: string) => { + setFormState( + (formState: FormState): FormState => { + switch (key) { + case 'name': + return { ...formState, name: value }; + case 'baseUrl': + return { ...formState, baseUrl: value }; + case 'model': + return { ...formState, model: value }; + case 'apiKey': + return { ...formState, apiKey: value }; + case 'temperature': + return { ...formState, temperature: value }; + case 'maxTokens': + return { ...formState, maxTokens: value }; + case 'reasoningEffort': + return { ...formState, reasoningEffort: value }; + default: + return formState; + } + } + ); + }, + [] + ); + + const getPayload = React.useCallback( + (): AiProviderConfigurationWritePayload | null => { + const name = selectedAiProviderPreset + ? selectedAiProviderPreset.name + : formState.name.trim(); + const baseUrl = selectedAiProviderPreset + ? selectedAiProviderPreset.baseUrl + : formState.baseUrl.trim(); + const model = formState.model.trim(); + const apiKey = getSanitizedApiKey(formState.apiKey); + + if (!name || !baseUrl || !model) { + setOperationError(t`Enter a name, base URL, and model before saving.`); + return null; + } + + if ( + (!selectedConfiguration || !selectedConfiguration.hasApiKey) && + !apiKey + ) { + setOperationError(t`Enter an API key before saving this provider.`); + return null; + } + if ( + selectedConfiguration && + selectedConfiguration.hasApiKey && + isLocalAiProviderBaseUrl(baseUrl) && + !isLocalAiProviderBaseUrl(selectedConfiguration.baseUrl || '') && + !apiKey + ) { + setOperationError( + t`Enter an API key before saving this provider locally.` + ); + return null; + } + + const payload: AiProviderConfigurationWritePayload = { + name, + providerType: 'openai-compatible', + baseUrl, + model, + temperature: getNumberOrNull(formState.temperature), + maxTokens: getIntegerOrNull(formState.maxTokens), + reasoningEffort: getReasoningEffortOrNull(formState.reasoningEffort), + }; + if (apiKey) { + payload.apiKey = apiKey; + } + return payload; + }, + [formState, selectedAiProviderPreset, selectedConfiguration] + ); + + const onSave = React.useCallback( + async () => { + if (!profile) return; + + setOperationError(null); + setOperationMessage(null); + + const payload = getPayload(); + if (!payload) return; + + setIsSaving(true); + try { + const savedConfiguration = selectedConfiguration + ? await updateAiProviderConfiguration(getAuthorizationHeader, { + userId: profile.id, + providerConfigurationId: selectedConfiguration.id, + configuration: payload, + }) + : await createAiProviderConfiguration(getAuthorizationHeader, { + userId: profile.id, + configuration: payload, + }); + + setSelectedProviderSelectionId( + selectedAiProviderPreset + ? getPresetSelectionId(selectedAiProviderPreset.id) + : getConfigurationSelectionId(savedConfiguration.id) + ); + setAiProviderSettingsSelectionId( + selectedAiProviderPreset + ? getPresetSelectionId(selectedAiProviderPreset.id) + : getConfigurationSelectionId(savedConfiguration.id) + ); + setFormState(configurationToFormState(savedConfiguration)); + setOperationMessage(t`AI provider saved.`); + await fetchAiProviderConfigurations(); + } catch (error) { + console.error('Error saving AI provider configuration:', error); + setOperationError( + getErrorMessage(error, t`Unable to save this AI provider.`) + ); + } finally { + setIsSaving(false); + } + }, + [ + fetchAiProviderConfigurations, + getAuthorizationHeader, + getPayload, + profile, + setAiProviderSettingsSelectionId, + selectedAiProviderPreset, + selectedConfiguration, + ] + ); + + const onTest = React.useCallback( + async () => { + if (!profile || !selectedConfiguration) return; + + setOperationError(null); + setOperationMessage(null); + setIsTesting(true); + try { + const result = await testAiProviderConfiguration( + getAuthorizationHeader, + { + userId: profile.id, + providerConfigurationId: selectedConfiguration.id, + } + ); + + if (result.success) { + setOperationMessage(result.message || t`AI provider test succeeded.`); + } else { + setOperationError(result.message || t`AI provider test failed.`); + } + } catch (error) { + console.error('Error testing AI provider configuration:', error); + setOperationError( + getErrorMessage(error, t`Unable to test this AI provider.`) + ); + } finally { + setIsTesting(false); + } + }, + [getAuthorizationHeader, profile, selectedConfiguration] + ); + + const onDelete = React.useCallback( + async () => { + if (!profile || !selectedConfiguration) return; + + const shouldDelete = await showConfirmation({ + title: t`Delete AI provider?`, + message: t`This removes this provider from GDevelop. It does not revoke the key at the provider.`, + confirmButtonLabel: t`Delete`, + }); + if (!shouldDelete) return; + + setOperationError(null); + setOperationMessage(null); + setIsDeleting(true); + try { + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: profile.id, + providerConfigurationId: selectedConfiguration.id, + }); + const fallbackConfiguration = + openAiCompatibleConfigurations.find( + configuration => configuration.id !== selectedConfiguration.id + ) || null; + const fallbackSelectionId = fallbackConfiguration + ? getSelectionIdFromAiProviderConfiguration(fallbackConfiguration) + : getPresetSelectionId(aiProviderPresets[0].id); + if (fallbackConfiguration) { + setSelectedProviderSelectionId(fallbackSelectionId); + setFormState(configurationToFormState(fallbackConfiguration)); + } else { + setSelectedProviderSelectionId(fallbackSelectionId); + setFormState(presetToFormState(aiProviderPresets[0])); + } + setAiProviderSettingsSelectionId(fallbackSelectionId); + setOperationMessage(t`AI provider deleted.`); + await fetchAiProviderConfigurations(); + } catch (error) { + console.error('Error deleting AI provider configuration:', error); + setOperationError( + getErrorMessage(error, t`Unable to delete this AI provider.`) + ); + } finally { + setIsDeleting(false); + } + }, + [ + fetchAiProviderConfigurations, + getAuthorizationHeader, + profile, + openAiCompatibleConfigurations, + setAiProviderSettingsSelectionId, + selectedConfiguration, + showConfirmation, + ] + ); + + if (!customProviderSupport || !customProviderSupport.enabled) { + return ( + + + + + Custom AI providers are not available from the GDevelop services + yet. + + + + ); + } + + const isOpenAiCompatibleSupported = !!customProviderSupport.openAiCompatible; + + if (!profile) { + return ( + + + + Sign in to configure OpenAI-compatible providers. + + + ); + } + + return ( + + + Configure OpenAI-compatible providers for Ask AI. + + + {isOpenAiCompatibleSupported ? ( + + + OpenAI-compatible provider + + + + + Provider + + + + { + setSelectedProviderSelectionId(value); + setAiProviderSettingsSelectionId(value); + }} + > + {aiProviderPresets.map(preset => ( + + ))} + + {customOpenAiCompatibleConfigurations.map(configuration => ( + + ))} + + + + Name} + fullWidth + disabled={ + !!selectedAiProviderPreset || isSaving || isDeleting || isTesting + } + onChange={(event, value) => setFormValue('name', value)} + /> + Base URL} + hintText="https://api.openai.com/v1" + fullWidth + disabled={ + !!selectedAiProviderPreset || isSaving || isDeleting || isTesting + } + onChange={(event, value) => setFormValue('baseUrl', value)} + /> + Model} + hintText="gpt-5.5" + fullWidth + disabled={isSaving || isDeleting || isTesting} + onChange={(event, value) => setFormValue('model', value)} + /> + Replace API key + ) : ( + API key + ) + } + type="password" + fullWidth + disabled={isSaving || isDeleting || isTesting} + onChange={(event, value) => setFormValue('apiKey', value)} + /> + + {({ i18n }) => ( + Thinking level} + helperMarkdownText={i18n._( + t`Auto lets the model/provider choose. If a selected level is unsupported, GDevelop retries without it.` + )} + fullWidth + disabled={isSaving || isDeleting || isTesting} + onChange={(event, index, value) => + setFormValue('reasoningEffort', value) + } + > + + + + + + + + + )} + + + + Temperature} + type="number" + fullWidth + disabled={isSaving || isDeleting || isTesting} + onChange={(event, value) => setFormValue('temperature', value)} + /> + + + + {({ i18n }) => ( + Max output tokens} + helperMarkdownText={i18n._( + t`Leave blank to use the model/provider default.` + )} + type="number" + fullWidth + disabled={isSaving || isDeleting || isTesting} + onChange={(event, value) => + setFormValue('maxTokens', value) + } + /> + )} + + + + {operationError && ( + + )} + {operationMessage && ( + + )} + + + Saving... : Save + } + disabled={isSaving || isDeleting || isTesting} + onClick={onSave} + /> + Testing... : Test + } + disabled={ + !selectedConfiguration || isSaving || isDeleting || isTesting + } + onClick={onTest} + /> + + {selectedConfiguration && ( + Deleting... + ) : ( + Delete + ) + } + disabled={isSaving || isDeleting || isTesting} + onClick={onDelete} + /> + )} + + + ) : ( + + + OpenAI-compatible providers are not available from this backend yet. + + + )} + + ); +}; diff --git a/newIDE/app/src/MainFrame/Preferences/AiProvidersPreferences.spec.js b/newIDE/app/src/MainFrame/Preferences/AiProvidersPreferences.spec.js new file mode 100644 index 000000000000..eb3ac5795699 --- /dev/null +++ b/newIDE/app/src/MainFrame/Preferences/AiProvidersPreferences.spec.js @@ -0,0 +1,766 @@ +// @flow +import * as React from 'react'; +import { I18nProvider } from '@lingui/react'; +import { setupI18n } from '@lingui/core'; +import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles'; +import renderer from 'react-test-renderer'; +import CompactSelectField from '../../UI/CompactSelectField'; +import FlatButton from '../../UI/FlatButton'; +import RaisedButton from '../../UI/RaisedButton'; +import SelectField from '../../UI/SelectField'; +import SelectOption from '../../UI/SelectOption'; +import TextField from '../../UI/TextField'; +import AlertContext from '../../UI/Alert/AlertContext'; +import { AiProvidersPreferences } from './AiProvidersPreferences'; +import { + AiRequestContext, + initialAiRequestContextState, +} from '../../AiGeneration/AiRequestContext'; +import PreferencesContext, { initialPreferences } from './PreferencesContext'; +import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; +import { fakeSilverAuthenticatedUser } from '../../fixtures/GDevelopServicesTestData'; +import { + createAiProviderConfiguration, + deleteAiProviderConfiguration, + updateAiProviderConfiguration, + type AiProviderConfiguration, + type AiRequestCustomProviderSupport, +} from '../../Utils/GDevelopServices/Generation'; + +jest.mock('../../Utils/GDevelopServices/Generation', () => { + const actual: any = jest.requireActual( + '../../Utils/GDevelopServices/Generation' + ); + return { + ...actual, + createAiProviderConfiguration: jest.fn(), + deleteAiProviderConfiguration: jest.fn(), + updateAiProviderConfiguration: jest.fn(), + }; +}); + +const customProviderSupport: AiRequestCustomProviderSupport = { + enabled: true, + openAiCompatible: true, +}; +const muiTheme = createMuiTheme(); +const i18n = setupI18n({ + language: 'en', + catalogs: { + en: { messages: {} }, + }, +}); +const mockFn = (fn: Function): JestMockFn => (fn: any); +const makeSetAiProviderSettingsSelectionId = (): any => (jest.fn(): any); +const { act }: any = (renderer: any); +const authenticatedUserId = fakeSilverAuthenticatedUser.profile + ? fakeSilverAuthenticatedUser.profile.id + : ''; + +const savedOpenAiProviderConfiguration: AiProviderConfiguration = { + id: 'openai-provider-configuration', + name: 'OpenAI', + providerType: 'openai-compatible', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.5', + temperature: 0.2, + maxTokens: null, + hasApiKey: true, + createdAt: '2026-05-15T10:00:00.000Z', + updatedAt: '2026-05-15T10:00:00.000Z', +}; + +const savedCustomProviderConfiguration: AiProviderConfiguration = { + id: 'custom-provider-configuration', + name: 'My provider', + providerType: 'openai-compatible', + baseUrl: 'https://api.example.com/v1', + model: 'test-model', + temperature: 0.2, + maxTokens: null, + reasoningEffort: 'high', + hasApiKey: true, + createdAt: '2026-05-15T10:00:00.000Z', + updatedAt: '2026-05-15T10:00:00.000Z', +}; + +const savedLocalProviderConfiguration: AiProviderConfiguration = { + id: 'local-provider-configuration', + name: 'Local provider', + providerType: 'openai-compatible', + baseUrl: 'http://127.0.0.1:18080/', + model: 'gpt-5.5', + temperature: 0.2, + maxTokens: null, + reasoningEffort: 'high', + hasApiKey: true, + createdAt: '2026-05-15T10:00:00.000Z', + updatedAt: '2026-05-15T10:00:00.000Z', +}; + +const getTextContent = (node: any): string => { + if (!node) return ''; + if (typeof node === 'string') return node; + if (Array.isArray(node)) return node.map(getTextContent).join(''); + return getTextContent(node.children); +}; + +const renderAiProvidersPreferences = ({ + aiProviderConfigurations = [], + fetchAiProviderConfigurations = async () => {}, + aiProviderSettingsSelectionId = null, + setAiProviderSettingsSelectionId = () => {}, +}: {| + aiProviderConfigurations?: Array, + fetchAiProviderConfigurations?: () => Promise, + aiProviderSettingsSelectionId?: string | null, + setAiProviderSettingsSelectionId?: (string | null) => void, +|} = {}): any => { + let tree: any; + const preferencesContextValue = ({ + ...initialPreferences, + values: { + ...initialPreferences.values, + aiProviderSettingsSelectionId, + }, + setAiProviderSettingsSelectionId, + }: any); + act(() => { + tree = renderer.create( + + + {}, + showConfirmDialog: ({ callback }) => callback(true), + showConfirmDeleteDialog: ({ callback }) => callback(true), + showYesNoCancelDialog: ({ callback }) => callback(true), + }} + > + + 'Bearer token', + }} + > + + + + + + + + + ); + }); + // $FlowFixMe[incompatible-return] + return tree; +}; + +const setSelectedProvider = (root: any, value: string) => { + act(() => { + root.findByType(CompactSelectField).props.onChange(value); + }); +}; + +const setTextFieldValue = (root: any, fieldIndex: number, value: string) => { + act(() => { + root.findAllByType(TextField)[fieldIndex].props.onChange({}, value); + }); +}; + +const getTextFieldValue = (root: any, fieldIndex: number): string => + root.findAllByType(TextField)[fieldIndex].props.value; + +const setThinkingLevel = (root: any, value: string) => { + act(() => { + root.findByType(SelectField).props.onChange({}, -1, value); + }); +}; + +const getThinkingLevel = (root: any): string => + root.findByType(SelectField).props.value; + +const getProviderSelectOptions = ( + root: any +): Array<{| label: any, value: string |}> => + root + .findByType(CompactSelectField) + .findAllByType(SelectOption) + .map(option => ({ + label: option.props.label, + value: option.props.value, + })); + +const clickSave = async (root: any) => { + await act(async () => { + await root.findByType(RaisedButton).props.onClick(); + }); +}; + +const clickDelete = async (root: any) => { + await act(async () => { + const buttons = root.findAllByType(FlatButton); + await buttons[buttons.length - 1].props.onClick(); + }); +}; + +describe('AiProvidersPreferences', () => { + beforeEach(() => { + mockFn(createAiProviderConfiguration).mockReset(); + mockFn(deleteAiProviderConfiguration).mockReset(); + mockFn(updateAiProviderConfiguration).mockReset(); + }); + + it('renders common provider presets in the provider dropdown', () => { + const tree = renderAiProvidersPreferences(); + const root = tree.root; + + expect(getProviderSelectOptions(root).map(option => option.value)).toEqual([ + 'preset:openai', + 'preset:openrouter', + 'preset:google-gemini', + 'preset:groq', + 'preset:mistral', + 'preset:deepseek', + 'preset:xai', + 'custom-new', + ]); + expect(getProviderSelectOptions(root).map(option => option.label)).toEqual([ + 'OpenAI', + 'OpenRouter', + 'Google Gemini', + 'Groq', + 'Mistral', + 'DeepSeek', + 'xAI', + expect.any(Object), + ]); + }); + + it('hides saved preset configurations from the provider dropdown', () => { + const tree = renderAiProvidersPreferences({ + aiProviderConfigurations: [ + savedOpenAiProviderConfiguration, + savedCustomProviderConfiguration, + ], + }); + const root = tree.root; + + expect(getProviderSelectOptions(root).map(option => option.value)).toEqual([ + 'preset:openai', + 'preset:openrouter', + 'preset:google-gemini', + 'preset:groq', + 'preset:mistral', + 'preset:deepseek', + 'preset:xai', + 'custom-new', + 'configuration:custom-provider-configuration', + ]); + }); + + it('defaults new preset and custom provider optional settings', () => { + const tree = renderAiProvidersPreferences(); + const root = tree.root; + + expect(getTextFieldValue(root, 4)).toBe('0.2'); + expect(getThinkingLevel(root)).toBe(''); + + setSelectedProvider(root, 'custom-new'); + + expect(getTextFieldValue(root, 4)).toBe('0.2'); + expect(getThinkingLevel(root)).toBe(''); + }); + + it('displays auto for saved providers without a thinking level', () => { + const tree = renderAiProvidersPreferences({ + aiProviderConfigurations: [savedOpenAiProviderConfiguration], + }); + const root = tree.root; + + expect(getThinkingLevel(root)).toBe(''); + }); + + it('displays the saved thinking level', () => { + const tree = renderAiProvidersPreferences({ + aiProviderConfigurations: [savedCustomProviderConfiguration], + aiProviderSettingsSelectionId: + 'configuration:custom-provider-configuration', + }); + const root = tree.root; + + expect(getThinkingLevel(root)).toBe('high'); + }); + + it('persists the selected saved provider from the dropdown', () => { + const setAiProviderSettingsSelectionId = makeSetAiProviderSettingsSelectionId(); + const tree = renderAiProvidersPreferences({ + aiProviderConfigurations: [savedCustomProviderConfiguration], + setAiProviderSettingsSelectionId, + }); + const root = tree.root; + + setSelectedProvider(root, 'configuration:custom-provider-configuration'); + + expect(setAiProviderSettingsSelectionId).toHaveBeenCalledWith( + 'configuration:custom-provider-configuration' + ); + }); + + it('persists unsaved preset and custom entries from the dropdown', () => { + const setAiProviderSettingsSelectionId = makeSetAiProviderSettingsSelectionId(); + const tree = renderAiProvidersPreferences({ + setAiProviderSettingsSelectionId, + }); + const root = tree.root; + + setSelectedProvider(root, 'preset:openrouter'); + setSelectedProvider(root, 'custom-new'); + + expect(setAiProviderSettingsSelectionId).toHaveBeenCalledWith( + 'preset:openrouter' + ); + expect(setAiProviderSettingsSelectionId).toHaveBeenCalledWith('custom-new'); + }); + + it('shows a translated validation error when saving with only a provider name', async () => { + const tree = renderAiProvidersPreferences(); + const root = tree.root; + + setSelectedProvider(root, 'custom-new'); + setTextFieldValue(root, 0, 'My provider'); + await clickSave(root); + + expect(getTextContent(tree.toJSON())).toContain( + 'Enter a name, base URL, and model before saving.' + ); + }); + + it('shows a translated validation error when saving a new provider without an API key', async () => { + const tree = renderAiProvidersPreferences(); + const root = tree.root; + + setSelectedProvider(root, 'custom-new'); + setTextFieldValue(root, 0, 'My provider'); + setTextFieldValue(root, 1, 'https://api.example.com/v1'); + setTextFieldValue(root, 2, 'test-model'); + await clickSave(root); + + expect(getTextContent(tree.toJSON())).toContain( + 'Enter an API key before saving this provider.' + ); + }); + + it('shows a friendly message when provider saving is forbidden', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + mockFn(createAiProviderConfiguration).mockRejectedValue({ + response: { status: 403, data: {} }, + message: 'Request failed with status code 403', + }); + const tree = renderAiProvidersPreferences(); + const root = tree.root; + + setSelectedProvider(root, 'custom-new'); + setTextFieldValue(root, 0, 'My provider'); + setTextFieldValue(root, 1, 'https://api.example.com/v1'); + setTextFieldValue(root, 2, 'test-model'); + setTextFieldValue(root, 3, 'sk-test'); + await clickSave(root); + + expect(getTextContent(tree.toJSON())).toContain( + "You don't have access to remote AI providers yet." + ); + expect(getTextContent(tree.toJSON())).not.toContain( + 'Request failed with status code 403' + ); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('shows a friendly message when the provider rejects the authorization header', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + mockFn(createAiProviderConfiguration).mockRejectedValue({ + response: { + data: { + message: + "Invalid key=value pair (missing equal-sign) in Authorization header (hashed with SHA-256 and encoded with Base64): 'abc='.", + }, + }, + }); + const tree = renderAiProvidersPreferences(); + const root = tree.root; + + setTextFieldValue(root, 3, 'sk-test'); + await clickSave(root); + + expect(getTextContent(tree.toJSON())).toContain( + 'The provider rejected the API key format. Paste only the API key value, without "Bearer" or "Authorization:", then save again.' + ); + expect(getTextContent(tree.toJSON())).not.toContain( + 'Invalid key=value pair' + ); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('shows a service availability message when the provider route is not configured', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + mockFn(createAiProviderConfiguration).mockRejectedValue({ + response: { + headers: { + 'x-amzn-ErrorType': 'IncompleteSignatureException', + }, + data: { + message: + "Invalid key=value pair (missing equal-sign) in Authorization header (hashed with SHA-256 and encoded with Base64): 'abc='.", + }, + }, + }); + const tree = renderAiProvidersPreferences(); + const root = tree.root; + + setTextFieldValue(root, 3, 'sk-test'); + await clickSave(root); + + expect(getTextContent(tree.toJSON())).toContain( + 'Remote AI providers are not available from this GDevelop services environment yet.' + ); + expect(getTextContent(tree.toJSON())).not.toContain( + 'The provider rejected the API key format' + ); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('saves an unsaved preset by creating its backing configuration without selecting a duplicate row', async () => { + mockFn(createAiProviderConfiguration).mockResolvedValue( + savedOpenAiProviderConfiguration + ); + const fetchAiProviderConfigurations = (jest.fn(): any).mockResolvedValue( + undefined + ); + const setAiProviderSettingsSelectionId = makeSetAiProviderSettingsSelectionId(); + const tree = renderAiProvidersPreferences({ + fetchAiProviderConfigurations, + setAiProviderSettingsSelectionId, + }); + const root = tree.root; + + setTextFieldValue(root, 3, 'sk-test'); + await clickSave(root); + + expect(createAiProviderConfiguration).toHaveBeenCalledWith( + expect.any(Function), + { + userId: authenticatedUserId, + configuration: { + name: 'OpenAI', + providerType: 'openai-compatible', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.5', + temperature: 0.2, + maxTokens: null, + reasoningEffort: null, + apiKey: 'sk-test', + }, + } + ); + expect(updateAiProviderConfiguration).not.toHaveBeenCalled(); + expect(fetchAiProviderConfigurations).toHaveBeenCalled(); + expect(root.findByType(CompactSelectField).props.value).toBe( + 'preset:openai' + ); + expect(setAiProviderSettingsSelectionId).toHaveBeenCalledWith( + 'preset:openai' + ); + }); + + it('saves only the API key value when pasted with common wrappers', async () => { + const pastedApiKeys = [ + 'Authorization: Bearer sk-test', + '-H "Authorization: Bearer sk-test"', + 'OPENROUTER_API_KEY=sk-test', + ]; + + for (const pastedApiKey of pastedApiKeys) { + mockFn(createAiProviderConfiguration).mockReset(); + mockFn(createAiProviderConfiguration).mockResolvedValue( + savedOpenAiProviderConfiguration + ); + const tree = renderAiProvidersPreferences(); + const root = tree.root; + + setTextFieldValue(root, 3, pastedApiKey); + await clickSave(root); + + expect(createAiProviderConfiguration).toHaveBeenCalledWith( + expect.any(Function), + { + userId: authenticatedUserId, + configuration: { + name: 'OpenAI', + providerType: 'openai-compatible', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.5', + temperature: 0.2, + maxTokens: null, + reasoningEffort: null, + apiKey: 'sk-test', + }, + } + ); + } + }); + + it('updates the newest saved preset configuration instead of creating another one', async () => { + mockFn(updateAiProviderConfiguration).mockResolvedValue({ + ...savedOpenAiProviderConfiguration, + model: 'gpt-5.5-mini', + }); + const olderOpenAiConfiguration = { + ...savedOpenAiProviderConfiguration, + id: 'older-openai-provider-configuration', + updatedAt: '2026-05-14T10:00:00.000Z', + }; + const tree = renderAiProvidersPreferences({ + aiProviderConfigurations: [ + olderOpenAiConfiguration, + savedOpenAiProviderConfiguration, + ], + }); + const root = tree.root; + + setTextFieldValue(root, 2, 'gpt-5.5-mini'); + await clickSave(root); + + expect(updateAiProviderConfiguration).toHaveBeenCalledWith( + expect.any(Function), + { + userId: authenticatedUserId, + providerConfigurationId: savedOpenAiProviderConfiguration.id, + configuration: { + name: 'OpenAI', + providerType: 'openai-compatible', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.5-mini', + temperature: 0.2, + maxTokens: null, + reasoningEffort: null, + }, + } + ); + expect(createAiProviderConfiguration).not.toHaveBeenCalled(); + expect(root.findByType(CompactSelectField).props.value).toBe( + 'preset:openai' + ); + }); + + it('still creates custom providers from scratch', async () => { + mockFn(createAiProviderConfiguration).mockResolvedValue( + savedCustomProviderConfiguration + ); + const setAiProviderSettingsSelectionId = makeSetAiProviderSettingsSelectionId(); + const tree = renderAiProvidersPreferences({ + setAiProviderSettingsSelectionId, + }); + const root = tree.root; + + setSelectedProvider(root, 'custom-new'); + setTextFieldValue(root, 0, 'My provider'); + setTextFieldValue(root, 1, 'https://api.example.com/v1'); + setTextFieldValue(root, 2, 'test-model'); + setTextFieldValue(root, 3, 'sk-test'); + await clickSave(root); + + expect(createAiProviderConfiguration).toHaveBeenCalledWith( + expect.any(Function), + { + userId: authenticatedUserId, + configuration: { + name: 'My provider', + providerType: 'openai-compatible', + baseUrl: 'https://api.example.com/v1', + model: 'test-model', + temperature: 0.2, + maxTokens: null, + reasoningEffort: null, + apiKey: 'sk-test', + }, + } + ); + expect(setAiProviderSettingsSelectionId).toHaveBeenCalledWith( + 'configuration:custom-provider-configuration' + ); + }); + + it('saves the selected thinking level', async () => { + mockFn(createAiProviderConfiguration).mockResolvedValue( + savedCustomProviderConfiguration + ); + const tree = renderAiProvidersPreferences(); + const root = tree.root; + + setSelectedProvider(root, 'custom-new'); + setTextFieldValue(root, 0, 'My provider'); + setTextFieldValue(root, 1, 'https://api.example.com/v1'); + setTextFieldValue(root, 2, 'test-model'); + setTextFieldValue(root, 3, 'sk-test'); + setThinkingLevel(root, 'high'); + await clickSave(root); + + expect(createAiProviderConfiguration).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + configuration: expect.objectContaining({ + reasoningEffort: 'high', + }), + }) + ); + }); + + it('deleting the selected provider falls back to another saved provider', async () => { + mockFn(deleteAiProviderConfiguration).mockResolvedValue(undefined); + const fetchAiProviderConfigurations = (jest.fn(): any).mockResolvedValue( + undefined + ); + const setAiProviderSettingsSelectionId = makeSetAiProviderSettingsSelectionId(); + const tree = renderAiProvidersPreferences({ + aiProviderConfigurations: [ + savedCustomProviderConfiguration, + savedOpenAiProviderConfiguration, + ], + fetchAiProviderConfigurations, + aiProviderSettingsSelectionId: + 'configuration:custom-provider-configuration', + setAiProviderSettingsSelectionId, + }); + const root = tree.root; + + await clickDelete(root); + + expect(deleteAiProviderConfiguration).toHaveBeenCalledWith( + expect.any(Function), + { + userId: authenticatedUserId, + providerConfigurationId: savedCustomProviderConfiguration.id, + } + ); + expect(fetchAiProviderConfigurations).toHaveBeenCalled(); + expect(setAiProviderSettingsSelectionId).toHaveBeenCalledWith( + 'preset:openai' + ); + }); + + it('allows saving a localhost custom provider', async () => { + mockFn(createAiProviderConfiguration).mockResolvedValue( + savedLocalProviderConfiguration + ); + const fetchAiProviderConfigurations = (jest.fn(): any).mockResolvedValue( + undefined + ); + const tree = renderAiProvidersPreferences({ + fetchAiProviderConfigurations, + }); + const root = tree.root; + + setSelectedProvider(root, 'custom-new'); + setTextFieldValue(root, 0, 'Local provider'); + setTextFieldValue(root, 1, 'http://127.0.0.1:18080/'); + setTextFieldValue(root, 2, 'gpt-5.5'); + setTextFieldValue(root, 3, 'sk-local'); + await clickSave(root); + + expect(getTextContent(tree.toJSON())).not.toContain( + 'Localhost providers are not supported' + ); + expect(createAiProviderConfiguration).toHaveBeenCalledWith( + expect.any(Function), + { + userId: authenticatedUserId, + configuration: { + name: 'Local provider', + providerType: 'openai-compatible', + baseUrl: 'http://127.0.0.1:18080/', + model: 'gpt-5.5', + temperature: 0.2, + maxTokens: null, + reasoningEffort: null, + apiKey: 'sk-local', + }, + } + ); + expect(fetchAiProviderConfigurations).toHaveBeenCalled(); + }); + + it('requires a new API key when converting a saved remote provider to localhost', async () => { + const tree = renderAiProvidersPreferences({ + aiProviderConfigurations: [savedCustomProviderConfiguration], + aiProviderSettingsSelectionId: + 'configuration:custom-provider-configuration', + }); + const root = tree.root; + + setTextFieldValue(root, 1, 'http://127.0.0.1:18080/'); + await clickSave(root); + + expect(updateAiProviderConfiguration).not.toHaveBeenCalled(); + expect(getTextContent(tree.toJSON())).toContain( + 'Enter an API key before saving this provider locally.' + ); + }); + + it('saves a blank output token limit as the provider default and explains the behavior', async () => { + mockFn(createAiProviderConfiguration).mockResolvedValue( + savedOpenAiProviderConfiguration + ); + const tree = renderAiProvidersPreferences(); + const root = tree.root; + + setTextFieldValue(root, 3, 'sk-test'); + await clickSave(root); + + expect(createAiProviderConfiguration).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + configuration: expect.objectContaining({ + maxTokens: null, + }), + }) + ); + expect(getTextContent(tree.toJSON())).toContain('Max output tokens'); + expect(getTextContent(tree.toJSON())).toContain( + 'Leave blank to use the model/provider default.' + ); + expect(getTextContent(tree.toJSON())).toContain( + 'Auto lets the model/provider choose. If a selected level is unsupported, GDevelop retries without it.' + ); + }); +}); diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js index 9771a5593846..78f17a6ad7ee 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js @@ -241,6 +241,7 @@ export type PreferencesValues = {| takeScreenshotOnPreview: boolean, showAiAskButtonInTitleBar: boolean, automaticallyUseCreditsForAiRequests: boolean, + aiProviderSettingsSelectionId: string | null, useBackgroundSerializerForSaving: boolean, disableNpmScriptConfirmation: boolean, showJsTypeError: boolean, @@ -365,6 +366,7 @@ export type Preferences = {| setTakeScreenshotOnPreview: (enabled: boolean) => void, setShowAiAskButtonInTitleBar: (enabled: boolean) => void, setAutomaticallyUseCreditsForAiRequests: (enabled: boolean) => void, + setAiProviderSettingsSelectionId: (selectionId: string | null) => void, setUseBackgroundSerializerForSaving: (enabled: boolean) => void, setShowJsTypeError: (enabled: boolean) => void, |}; @@ -429,6 +431,7 @@ export const initialPreferences = { takeScreenshotOnPreview: true, showAiAskButtonInTitleBar: true, automaticallyUseCreditsForAiRequests: false, + aiProviderSettingsSelectionId: null, useBackgroundSerializerForSaving: false, disableNpmScriptConfirmation: false, showJsTypeError: false, @@ -516,6 +519,7 @@ export const initialPreferences = { setTakeScreenshotOnPreview: (enabled: boolean) => {}, setShowAiAskButtonInTitleBar: (enabled: boolean) => {}, setAutomaticallyUseCreditsForAiRequests: (enabled: boolean) => {}, + setAiProviderSettingsSelectionId: (selectionId: string | null) => {}, setUseBackgroundSerializerForSaving: (enabled: boolean) => {}, setShowJsTypeError: (enabled: boolean) => {}, }; diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js index a7ab698b33db..e066490a010f 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js @@ -27,6 +27,7 @@ import defaultShortcuts from '../../KeyboardShortcuts/DefaultShortcuts'; import AlertMessage from '../../UI/AlertMessage'; import ErrorBoundary from '../../UI/ErrorBoundary'; import CompactSelectField from '../../UI/CompactSelectField'; +import { AiProvidersPreferences } from './AiProvidersPreferences'; const electron = optionalRequire('electron'); type Props = {| @@ -538,6 +539,10 @@ const PreferencesDialog = ({ )} + + AI Providers + + Other diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js index 8c2860d8c039..7e83a039de02 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js @@ -125,6 +125,7 @@ export const getInitialPreferences = (): { resourcesImporationBehavior: string, shareDialogDefaultTab: string, showAiAskButtonInTitleBar: boolean, + aiProviderSettingsSelectionId: string | null, showBasicProfilingCounters: boolean, showCreateSectionByDefault: boolean, showDeprecatedInstructionWarning: string, @@ -401,6 +402,10 @@ export default class PreferencesProvider extends React.Component { this ): any), // $FlowFixMe[method-unbinding] + setAiProviderSettingsSelectionId: (this._setAiProviderSettingsSelectionId.bind( + this + ): any), + // $FlowFixMe[method-unbinding] setUseBackgroundSerializerForSaving: (this._setUseBackgroundSerializerForSaving.bind( this ): any), @@ -1398,6 +1403,18 @@ export default class PreferencesProvider extends React.Component { ); } + _setAiProviderSettingsSelectionId(newValue: string | null) { + this.setState( + state => ({ + values: { + ...state.values, + aiProviderSettingsSelectionId: newValue, + }, + }), + () => this._persistValuesToLocalStorage(this.state) + ); + } + render(): any { return ( diff --git a/newIDE/app/src/Utils/GDevelopServices/Generation.js b/newIDE/app/src/Utils/GDevelopServices/Generation.js index dc1527530e8a..9dd8edf5da0c 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Generation.js +++ b/newIDE/app/src/Utils/GDevelopServices/Generation.js @@ -54,27 +54,29 @@ export type AiRequestFunctionCallOutput = { projectVersionIdAfterMessage?: string, }; +type AiRequestAssistantMessageContent = Array< + | { + type: 'reasoning', + status: 'completed', + summary: { + text: string, + type: 'summary_text', + }, + } + | { + type: 'output_text', + status: 'completed', + text: string, + annotations: Array<{}>, + } + | AiRequestMessageAssistantFunctionCall +>; + export type AiRequestAssistantMessage = { type: 'message', status: 'completed', role: 'assistant', - content: Array< - | { - type: 'reasoning', - status: 'completed', - summary: { - text: string, - type: 'summary_text', - }, - } - | { - type: 'output_text', - status: 'completed', - text: string, - annotations: Array<{}>, - } - | AiRequestMessageAssistantFunctionCall - >, + content: AiRequestAssistantMessageContent, suggestions?: AiRequestSuggestions, messageId?: string, projectVersionIdAfterMessage?: string, @@ -98,10 +100,65 @@ export type AiRequestMessage = | AiRequestUserMessage | AiRequestFunctionCallOutput; +export type AiProviderConfigurationType = 'openai-compatible'; + +export type AiProviderConfigurationConnectionStatus = + | 'connected' + | 'expired' + | 'unavailable' + | 'error'; + +export type AiProviderReasoningEffort = + | 'none' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh'; + +export type AiProviderConfiguration = {| + id: string, + name: string, + providerType: AiProviderConfigurationType, + baseUrl?: string, + model?: string, + temperature?: number | null, + maxTokens?: number | null, + reasoningEffort?: AiProviderReasoningEffort | null, + hasApiKey?: boolean, + connectionStatus?: AiProviderConfigurationConnectionStatus, + connectionErrorMessage?: string | null, + createdAt: string, + updatedAt: string, +|}; + +export type AiProviderConfigurationWritePayload = {| + name: string, + providerType: AiProviderConfigurationType, + baseUrl: string, + model: string, + temperature?: number | null, + maxTokens?: number | null, + reasoningEffort?: AiProviderReasoningEffort | null, + apiKey?: string, +|}; + export type AiConfiguration = { presetId: string, + providerConfigurationId?: string | null, + providerConfiguration?: AiProviderConfigurationWritePayload, }; +export type AiProviderConfigurationTestResult = {| + success: boolean, + message?: string, +|}; + +export type AiRequestCustomProviderSupport = {| + enabled: boolean, + openAiCompatible: boolean, +|}; + type AiRequestToolOptions = { includeEventsJson?: boolean, watchPollingIntervalInMs?: number, @@ -134,6 +191,42 @@ export type AiRequest = { totalPriceInCredits?: number | null, }; +export const getAiRequestWithPreservedAiConfiguration = ({ + aiRequest, + aiConfiguration, +}: {| + aiRequest: AiRequest, + aiConfiguration?: AiConfiguration | null, +|}): AiRequest => { + if ( + aiRequest.aiConfiguration && + (aiRequest.aiConfiguration.providerConfigurationId || + aiRequest.aiConfiguration.providerConfiguration) + ) { + return aiRequest; + } + + if ( + !aiConfiguration || + (!aiConfiguration.providerConfigurationId && + !aiConfiguration.providerConfiguration) + ) { + return aiRequest; + } + + const preservedAiConfiguration: AiConfiguration = { + ...aiConfiguration, + ...(aiRequest.aiConfiguration || {}), + providerConfigurationId: aiConfiguration.providerConfigurationId, + }; + if (aiConfiguration.providerConfiguration) { + preservedAiConfiguration.providerConfiguration = + aiConfiguration.providerConfiguration; + } + + return { ...aiRequest, aiConfiguration: preservedAiConfiguration }; +}; + export type AiGeneratedEventStats = { retriesCount: number, finalMissingTypes: string[], @@ -176,92 +269,2138 @@ export type AiGeneratedEventMissingResource = { | string, }; -export type AiGeneratedEventChange = { - operationName: string, - operationTargetEvent: string | null, - isEventsJsonValid: boolean | null, - generatedEvents: string | null, - areEventsValid: boolean | null, - extensionNames: string[] | null, - diagnosticLines: string[], - undeclaredVariables: AiGeneratedEventUndeclaredVariable[], - undeclaredObjectVariables: { - [objectName: string]: AiGeneratedEventUndeclaredVariable[], - }, - missingObjectBehaviors: { - [objectName: string]: AiGeneratedEventMissingObjectBehavior[], - }, - missingResources: AiGeneratedEventMissingResource[], +export type AiGeneratedEventChange = { + operationName: string, + operationTargetEvent: string | null, + isEventsJsonValid: boolean | null, + generatedEvents: string | null, + areEventsValid: boolean | null, + extensionNames: string[] | null, + diagnosticLines: string[], + undeclaredVariables: AiGeneratedEventUndeclaredVariable[], + undeclaredObjectVariables: { + [objectName: string]: AiGeneratedEventUndeclaredVariable[], + }, + missingObjectBehaviors: { + [objectName: string]: AiGeneratedEventMissingObjectBehavior[], + }, + missingResources: AiGeneratedEventMissingResource[], +}; + +export type AiGeneratedEvent = { + id: string, + createdAt: string, + updatedAt: string, + userId: string | null, // null for calls made by the API. + status: GenerationStatus, + + partialGameProjectJson: string, + eventsDescription: string, + extensionNamesList: string, + objectsList: string, + existingEventsAsText: string, + existingEventsJson: string | null, + existingEventsJsonUserRelativeKey: string | null, + + resultMessage: string | null, + changes: Array | null, + + error: { + code: string, + message: string, + } | null, + + stats: AiGeneratedEventStats | null, +}; + +export type AssetSearch = { + id: string, + userId: string, + createdAt: string, + query: { + searchTerms: string[], + objectType: string, + description: string | null, + twoDimensionalViewKind: string | null, + relatedAiRequestId: string | null, + lastUserMessage: string | null, + lastAssistantMessages: string[], + }, + status: 'completed' | 'failed', + results: Array<{ + score: number, + asset: any, + }> | null, +}; + +export type ResourceSearch = { + id: string, + userId: string, + createdAt: string, + query: { + searchTerms: string[], + resourceKind: string, + }, + status: 'completed' | 'failed', + results: Array<{ + score: number, + resource: { + name: string, + url: string, + }, + }> | null, +}; + +// $FlowFixMe[cannot-resolve-name] +export const apiClient: Axios = axios.create({ + baseURL: GDevelopGenerationApi.baseUrl, +}); + +export const AI_PROVIDER_UNAVAILABLE_ERROR_CODE = 'AI_PROVIDER_UNAVAILABLE'; + +export const isAiProviderUnavailableError = (error: any): boolean => + !!error && + !!error.response && + !!error.response.data && + error.response.data.code === AI_PROVIDER_UNAVAILABLE_ERROR_CODE; + +const localAiProviderConfigurationsStorageKey = 'gd-ai-provider-configurations'; +const localAiRequestIdPrefix = 'local-custom-provider-ai-request-'; +const localAiGeneratedEventIdPrefix = + 'local-custom-provider-ai-generated-event-'; +const openAiCompatibleChatCompletionTimeoutMs = 180000; +const localEventGenerationMinimumMaxTokens = 4096; + +type LocalAiProviderConfiguration = {| + id: string, + name: string, + providerType: AiProviderConfigurationType, + baseUrl: string, + model: string, + temperature?: number | null, + maxTokens?: number | null, + reasoningEffort?: AiProviderReasoningEffort | null, + apiKey: string, + createdAt: string, + updatedAt: string, +|}; + +type OpenAiCompatibleToolCall = {| + id: string, + type: 'function', + function: {| + name: string, + arguments: string, + |}, +|}; + +type OpenAiCompatibleMessage = { + role: 'system' | 'user' | 'assistant' | 'tool', + content?: string | null, + tool_call_id?: string, + tool_calls?: Array, +}; + +type OpenAiCompatibleTool = { + type: 'function', + function: { + name: string, + description: string, + parameters: any, + }, +}; + +type OpenAiCompatibleAssistantResult = {| + text: string, + functionCalls: Array, +|}; + +type ProviderErrorDetails = {| + status: number | null, + code: string, + param: string, + message: string, +|}; + +type OpenAiCompatibleUnsupportedFeature = + | 'reasoning_effort' + | 'temperature' + | 'max_tokens' + | 'tools'; + +type OpenAiCompatibleProviderCompatibility = {| + unsupportedReasoningEffort?: boolean, + unsupportedTemperature?: boolean, + unsupportedMaxTokens?: boolean, + unsupportedTools?: boolean, +|}; + +let localAiProviderConfigurationsInMemoryByUserId: { + [userId: string]: Array, +} = {}; +let localAiRequestsInMemory: { [aiRequestId: string]: AiRequest } = {}; +let localAiRequestProviderConfigurationsInMemory: { + [aiRequestId: string]: AiProviderConfigurationWritePayload, +} = {}; +let localAiGeneratedEventsInMemory: { + [aiGeneratedEventId: string]: AiGeneratedEvent, +} = {}; +let openAiCompatibleProviderCompatibilityByKey: { + [key: string]: OpenAiCompatibleProviderCompatibility, +} = {}; + +const getResponseHeader = (error: any, headerName: string): string | null => { + const headers = + error && error.response && error.response.headers + ? error.response.headers + : null; + if (!headers) return null; + + const normalizedHeaderName = headerName.toLowerCase(); + const matchingHeaderName = Object.keys(headers).find( + headerName => headerName.toLowerCase() === normalizedHeaderName + ); + if (!matchingHeaderName) return null; + + const value = headers[matchingHeaderName]; + return typeof value === 'string' ? value : null; +}; + +const getParsedJsonObject = (value: string): any | null => { + try { + const parsedValue = JSON.parse(value); + return parsedValue && typeof parsedValue === 'object' ? parsedValue : null; + } catch (error) { + return null; + } +}; + +const getProviderErrorDetails = (error: any): ProviderErrorDetails => { + const response = error && error.response ? error.response : null; + const responseData = response ? response.data : null; + const status = + response && typeof response.status === 'number' ? response.status : null; + const messages: Array = []; + let code = ''; + let param = ''; + + const addMessage = (value: any) => { + if (typeof value === 'string' && value) messages.push(value); + }; + + const collectFromObject = (value: any) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return; + + addMessage(value.message); + if (!code && typeof value.code === 'string') code = value.code; + if (!param && typeof value.param === 'string') param = value.param; + + if (typeof value.error === 'string') { + addMessage(value.error); + const parsedError = getParsedJsonObject(value.error); + if (parsedError) collectFromObject(parsedError); + } else if (value.error && typeof value.error === 'object') { + collectFromObject(value.error); + } + }; + + if (typeof responseData === 'string') { + addMessage(responseData); + const parsedResponseData = getParsedJsonObject(responseData); + if (parsedResponseData) collectFromObject(parsedResponseData); + } else { + collectFromObject(responseData); + } + + if (!messages.length && error instanceof Error) addMessage(error.message); + + return { + status, + code, + param, + message: messages.join('\n'), + }; +}; + +const isExplicitAuthenticationProviderError = ( + details: ProviderErrorDetails +): boolean => { + if (details.status === 401) return true; + + return /AUTHENTICATION_FAILED|UNAUTHENTICATED|UNAUTHORIZED|INVALID_TOKEN|EXPIRED_TOKEN|ACCESS_DENIED|permission denied/i.test( + `${details.code} ${details.message}` + ); +}; + +const getProviderErrorDisplayMessage = (error: any): string | null => { + const details = getProviderErrorDetails(error); + const parts = []; + if (details.status) parts.push(`HTTP ${details.status}`); + if (details.message) parts.push(details.message); + const metadata = []; + if (details.code) metadata.push(`code: ${details.code}`); + if (details.param) metadata.push(`param: ${details.param}`); + + const mainMessage = + parts.length > 0 + ? parts.join(': ') + : error instanceof Error + ? error.message + : ''; + const metadataMessage = metadata.length ? ` (${metadata.join(', ')})` : ''; + return mainMessage ? `${mainMessage}${metadataMessage}` : null; +}; + +export const isAiProviderConfigurationRouteUnavailableError = ( + error: any +): boolean => { + const errorType = getResponseHeader(error, 'x-amzn-errortype'); + if (!!errorType && errorType.indexOf('IncompleteSignatureException') === 0) { + return true; + } + + const details = getProviderErrorDetails(error); + const message = details.message; + if ( + /Invalid key=value pair \(missing equal-sign\) in Authorization header/i.test( + message + ) + ) { + return true; + } + + const generationApiBaseUrl = + (apiClient.defaults && apiClient.defaults.baseURL) || + GDevelopGenerationApi.baseUrl; + const isDevGenerationApi = + typeof generationApiBaseUrl === 'string' && + generationApiBaseUrl.indexOf('https://api-dev.gdevelop.io/generation') === + 0; + return ( + details.status === 403 && + isDevGenerationApi && + !isExplicitAuthenticationProviderError(details) + ); +}; + +const getLocalAiProviderConfigurationsStorageKey = (userId: string): string => + `${localAiProviderConfigurationsStorageKey}:${userId}`; + +const getLocalAiProviderConfigurations = ( + userId: string +): Array => { + if (typeof localStorage === 'undefined') { + return localAiProviderConfigurationsInMemoryByUserId[userId] || []; + } + + try { + const serializedConfigurations = localStorage.getItem( + getLocalAiProviderConfigurationsStorageKey(userId) + ); + return serializedConfigurations ? JSON.parse(serializedConfigurations) : []; + } catch (error) { + console.error('Unable to read local AI provider configurations:', error); + return []; + } +}; + +const saveLocalAiProviderConfigurations = ( + userId: string, + configurations: Array +) => { + if (typeof localStorage === 'undefined') { + localAiProviderConfigurationsInMemoryByUserId = { + ...localAiProviderConfigurationsInMemoryByUserId, + [userId]: configurations, + }; + return; + } + + try { + localStorage.setItem( + getLocalAiProviderConfigurationsStorageKey(userId), + JSON.stringify(configurations) + ); + } catch (error) { + console.error('Unable to save local AI provider configurations:', error); + } +}; + +const localConfigurationToAiProviderConfiguration = ( + configuration: LocalAiProviderConfiguration +): AiProviderConfiguration => ({ + id: configuration.id, + name: configuration.name, + providerType: configuration.providerType, + baseUrl: configuration.baseUrl, + model: configuration.model, + temperature: configuration.temperature, + maxTokens: configuration.maxTokens, + reasoningEffort: configuration.reasoningEffort, + hasApiKey: !!configuration.apiKey, + connectionStatus: 'connected', + connectionErrorMessage: null, + createdAt: configuration.createdAt, + updatedAt: configuration.updatedAt, +}); + +const createLocalAiProviderConfigurationId = (): string => + `local-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2)}`; + +export const isLocalAiProviderConfigurationId = ( + providerConfigurationId: string +): boolean => providerConfigurationId.indexOf('local-') === 0; + +export const isLocalAiProviderBaseUrl = (baseUrl: string): boolean => { + try { + const { hostname } = new URL(baseUrl.trim()); + const normalizedHostname = hostname.toLowerCase(); + return ( + normalizedHostname === 'localhost' || + normalizedHostname === '127.0.0.1' || + normalizedHostname === '[::1]' || + normalizedHostname === '::1' + ); + } catch (error) { + return false; + } +}; + +const getLocalOnlyAiProviderConfigurations = ( + userId: string +): Array => + getLocalAiProviderConfigurations(userId).filter(configuration => + isLocalAiProviderConfigurationId(configuration.id) + ); + +const saveLocalAiProviderConfiguration = ( + userId: string, + providerConfigurationId: string, + configuration: AiProviderConfigurationWritePayload, + dates?: {| createdAt?: string, updatedAt?: string |} +): AiProviderConfiguration => { + const configurations = getLocalAiProviderConfigurations(userId); + const existingConfiguration = configurations.find( + configuration => configuration.id === providerConfigurationId + ); + const now = new Date().toISOString(); + const localConfiguration = { + id: providerConfigurationId, + name: configuration.name, + providerType: configuration.providerType, + baseUrl: configuration.baseUrl, + model: configuration.model, + temperature: configuration.temperature, + maxTokens: configuration.maxTokens, + reasoningEffort: configuration.reasoningEffort, + apiKey: + configuration.apiKey || + (existingConfiguration ? existingConfiguration.apiKey : ''), + createdAt: + (dates && dates.createdAt) || + (existingConfiguration ? existingConfiguration.createdAt : now), + updatedAt: (dates && dates.updatedAt) || now, + }; + + saveLocalAiProviderConfigurations(userId, [ + ...configurations.filter( + configuration => configuration.id !== providerConfigurationId + ), + localConfiguration, + ]); + return localConfigurationToAiProviderConfiguration(localConfiguration); +}; + +const createLocalAiProviderConfiguration = ( + userId: string, + configuration: AiProviderConfigurationWritePayload +): AiProviderConfiguration => + saveLocalAiProviderConfiguration( + userId, + createLocalAiProviderConfigurationId(), + configuration + ); + +const deleteLocalAiProviderConfiguration = ( + userId: string, + providerConfigurationId: string +) => { + saveLocalAiProviderConfigurations( + userId, + getLocalAiProviderConfigurations(userId).filter( + configuration => configuration.id !== providerConfigurationId + ) + ); +}; + +const getLocalAiProviderConfigurationPayload = ( + userId: string, + providerConfigurationId: string +): AiProviderConfigurationWritePayload | null => { + const configuration = getLocalAiProviderConfigurations(userId).find( + configuration => configuration.id === providerConfigurationId + ); + if (!configuration) return null; + + return { + name: configuration.name, + providerType: configuration.providerType, + baseUrl: configuration.baseUrl, + model: configuration.model, + temperature: configuration.temperature, + maxTokens: configuration.maxTokens, + reasoningEffort: configuration.reasoningEffort, + apiKey: configuration.apiKey, + }; +}; + +const getAiConfigurationWithLocalProvider = ( + userId: string, + aiConfiguration: AiConfiguration +): AiConfiguration => { + if (!aiConfiguration.providerConfigurationId) return aiConfiguration; + if ( + !isLocalAiProviderConfigurationId(aiConfiguration.providerConfigurationId) + ) { + return aiConfiguration; + } + + const providerConfiguration = getLocalAiProviderConfigurationPayload( + userId, + aiConfiguration.providerConfigurationId + ); + return providerConfiguration + ? { ...aiConfiguration, providerConfiguration } + : aiConfiguration; +}; + +const createLocalAiRequestId = (): string => + `${localAiRequestIdPrefix}${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2)}`; + +export const isLocalAiRequestId = (aiRequestId: string): boolean => + aiRequestId.indexOf(localAiRequestIdPrefix) === 0; + +const createLocalAiRequestMessageId = (role: 'user' | 'assistant'): string => + `${role}-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2)}`; + +const createLocalAiGeneratedEventId = (): string => + `${localAiGeneratedEventIdPrefix}${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2)}`; + +export const isLocalAiGeneratedEventId = ( + aiGeneratedEventId: string +): boolean => aiGeneratedEventId.indexOf(localAiGeneratedEventIdPrefix) === 0; + +const createLocalFunctionCallId = (): string => + `call_${Date.now().toString(36)}_${Math.random() + .toString(36) + .slice(2)}`; + +const getPublicAiConfiguration = ( + aiConfiguration: AiConfiguration +): AiConfiguration => { + const publicAiConfiguration: AiConfiguration = { + presetId: aiConfiguration.presetId, + }; + if (aiConfiguration.providerConfigurationId) { + publicAiConfiguration.providerConfigurationId = + aiConfiguration.providerConfigurationId; + } + return publicAiConfiguration; +}; + +const getMissingLocalProviderConfigurationError = (): Error => + new Error( + 'Custom Model needs the provider API key saved locally. Open Preferences, edit this AI provider, paste the API key, and save it again.' + ); + +const assertUsableOpenAiCompatibleConfiguration = ( + providerConfiguration: AiProviderConfigurationWritePayload +) => { + if (!providerConfiguration.baseUrl || !providerConfiguration.model) { + throw new Error('Custom Model needs a base URL and model.'); + } + if (!providerConfiguration.apiKey) { + throw getMissingLocalProviderConfigurationError(); + } +}; + +const getOpenAiCompatibleProviderCompatibilityKey = ( + providerConfiguration: AiProviderConfigurationWritePayload +): string => + `${providerConfiguration.baseUrl.replace(/\/+$/, '')}|${ + providerConfiguration.model + }`; + +const getOpenAiCompatibleProviderCompatibility = ( + providerConfiguration: AiProviderConfigurationWritePayload +): OpenAiCompatibleProviderCompatibility => + openAiCompatibleProviderCompatibilityByKey[ + getOpenAiCompatibleProviderCompatibilityKey(providerConfiguration) + ] || {}; + +const markOpenAiCompatibleProviderUnsupportedFeature = ( + providerConfiguration: AiProviderConfigurationWritePayload, + feature: OpenAiCompatibleUnsupportedFeature +) => { + const key = getOpenAiCompatibleProviderCompatibilityKey( + providerConfiguration + ); + const compatibility: OpenAiCompatibleProviderCompatibility = { + ...(openAiCompatibleProviderCompatibilityByKey[key] || {}), + }; + if (feature === 'reasoning_effort') { + compatibility.unsupportedReasoningEffort = true; + } else if (feature === 'temperature') { + compatibility.unsupportedTemperature = true; + } else if (feature === 'max_tokens') { + compatibility.unsupportedMaxTokens = true; + } else if (feature === 'tools') { + compatibility.unsupportedTools = true; + } + openAiCompatibleProviderCompatibilityByKey[key] = compatibility; +}; + +export const clearOpenAiCompatibleProviderCompatibilityCacheForTests = () => { + openAiCompatibleProviderCompatibilityByKey = {}; +}; + +const isOpenAiCompatibleTimeoutError = (error: any): boolean => + !!error && + (error.code === 'ECONNABORTED' || + /timeout|timed out/i.test(error.message || '')); + +const makeOpenAiCompatibleProviderError = (error: any): Error => { + if (isOpenAiCompatibleTimeoutError(error)) { + return new Error( + 'Custom Model did not respond within 180 seconds. Check that the provider is running and try again.' + ); + } + + const providerMessage = getProviderErrorDisplayMessage(error); + return new Error( + providerMessage + ? `Custom Model request failed: ${providerMessage}` + : 'Custom Model request failed.' + ); +}; + +const getOpenAiCompatibleChatCompletionsUrl = (baseUrl: string): string => { + const trimmedBaseUrl = baseUrl.replace(/\/+$/, ''); + return /\/chat\/completions$/.test(trimmedBaseUrl) + ? trimmedBaseUrl + : `${trimmedBaseUrl}/chat/completions`; +}; + +const makeOpenAiCompatibleTool = ({ + name, + description, + properties, + required, +}: {| + name: string, + description: string, + properties: any, + required: string[], +|}): OpenAiCompatibleTool => ({ + type: 'function', + function: { + name, + description, + parameters: { + type: 'object', + properties, + required, + additionalProperties: false, + }, + }, +}); + +const stringSchema = (description: string): any => ({ + type: 'string', + description, +}); + +const numberSchema = (description: string): any => ({ + type: 'number', + description, +}); + +const booleanSchema = (description: string): any => ({ + type: 'boolean', + description, +}); + +const changedPropertiesSchema = { + type: 'array', + description: + 'Properties to change, using exact property names whenever known.', + items: { + type: 'object', + properties: { + property_name: stringSchema('Property name to edit.'), + new_value: stringSchema('New value as a string.'), + }, + required: ['property_name', 'new_value'], + additionalProperties: false, + }, +}; + +const localEditorTools: Array = [ + makeOpenAiCompatibleTool({ + name: 'initialize_project', + description: + 'Create or initialize a project when no project is currently open. Use this before other project-editing tools if needed.', + properties: { + project_name: stringSchema('Name for the project.'), + template_slug: stringSchema( + 'Starter template slug, or "empty" for a blank project.' + ), + also_read_existing_events: booleanSchema( + 'Whether to include existing starter-template events in the result.' + ), + }, + required: ['project_name', 'template_slug'], + }), + makeOpenAiCompatibleTool({ + name: 'create_scene', + description: 'Create a scene/layout in the project.', + properties: { + scene_name: stringSchema('Scene name.'), + include_ui_layer: booleanSchema('Whether to add a UI layer.'), + background_color: stringSchema( + 'Optional background color, for example "#111827".' + ), + }, + required: ['scene_name'], + }), + makeOpenAiCompatibleTool({ + name: 'create_or_replace_object', + description: + 'Create, duplicate, or replace an object. Common object_type values include "Sprite", "TextObject::Text", "TiledSpriteObject::TiledSprite", and "PrimitiveDrawing::Drawer".', + properties: { + scene_name: stringSchema('Scene where the object is used.'), + object_name: stringSchema('Object name.'), + object_type: stringSchema( + 'GDevelop object type. Use "Sprite" for simple visual game objects.' + ), + target_object_scope: stringSchema('"scene" or "global". Prefer "scene".'), + replace_existing_object: booleanSchema( + 'Whether to replace an existing object.' + ), + duplicated_object_name: stringSchema( + 'Optional existing object to duplicate.' + ), + duplicated_object_scene: stringSchema( + 'Optional scene of the object to duplicate.' + ), + search_terms: stringSchema('Optional asset-store search terms.'), + description: stringSchema('Visual/functional description of the object.'), + two_dimensional_view_kind: stringSchema( + 'Optional visual kind such as "side", "top-down", or "front".' + ), + asset_id: stringSchema('Optional exact or partial asset id.'), + }, + required: ['scene_name', 'object_name'], + }), + makeOpenAiCompatibleTool({ + name: 'put_2d_instances', + description: + 'Create, move, resize, rotate, or erase 2D instances in a scene. Use brush_kind "put" to place objects and "erase" to delete.', + properties: { + scene_name: stringSchema('Scene name.'), + object_name: stringSchema('Object to place or edit.'), + layer_name: stringSchema( + 'Layer name. Use an empty string for the base layer.' + ), + brush_kind: stringSchema('"put" or "erase".'), + brush_position: stringSchema('Position as "x,y".'), + brush_end_position: stringSchema( + 'Optional end position as "x,y" for lines/grids.' + ), + brush_size: numberSchema('Brush radius/spacing in pixels.'), + existing_instance_ids: stringSchema( + 'Comma-separated instance id prefixes to edit.' + ), + new_instances_count: numberSchema('Number of new instances to create.'), + row_count: numberSchema('Optional row count for a grid.'), + column_count: numberSchema('Optional column count for a grid.'), + instances_z_order: numberSchema('Z order for instances.'), + instances_size: stringSchema('Optional size as "width,height".'), + }, + required: ['scene_name', 'object_name', 'layer_name', 'brush_kind'], + }), + makeOpenAiCompatibleTool({ + name: 'change_object_property', + description: + 'Change properties of an existing object. Inspect first if unsure about property names.', + properties: { + scene_name: stringSchema('Scene name.'), + object_name: stringSchema('Object name.'), + changed_properties: changedPropertiesSchema, + }, + required: ['scene_name', 'object_name', 'changed_properties'], + }), + makeOpenAiCompatibleTool({ + name: 'inspect_object_properties', + description: + 'Inspect object properties, behaviors, animations, and size information.', + properties: { + scene_name: stringSchema('Scene name.'), + object_name: stringSchema('Object name.'), + }, + required: ['scene_name', 'object_name'], + }), + makeOpenAiCompatibleTool({ + name: 'add_behavior', + description: 'Add a behavior to an object.', + properties: { + scene_name: stringSchema('Scene name.'), + object_name: stringSchema('Object name.'), + behavior_type: stringSchema('Behavior type name.'), + behavior_name: stringSchema('Optional behavior instance name.'), + }, + required: ['scene_name', 'object_name', 'behavior_type'], + }), + makeOpenAiCompatibleTool({ + name: 'change_behavior_property', + description: 'Change behavior properties on an object.', + properties: { + scene_name: stringSchema('Scene name.'), + object_name: stringSchema('Object name.'), + behavior_name: stringSchema('Behavior name.'), + changed_properties: changedPropertiesSchema, + }, + required: [ + 'scene_name', + 'object_name', + 'behavior_name', + 'changed_properties', + ], + }), + makeOpenAiCompatibleTool({ + name: 'describe_instances', + description: 'Read existing scene instances and their ids/positions.', + properties: { + scene_name: stringSchema('Scene name.'), + filter_by_object_name: stringSchema('Optional object-name filter.'), + }, + required: ['scene_name'], + }), + makeOpenAiCompatibleTool({ + name: 'read_scene_events', + description: 'Read the current events of a scene.', + properties: { + scene_name: stringSchema('Scene name.'), + }, + required: ['scene_name'], + }), + makeOpenAiCompatibleTool({ + name: 'add_scene_events', + description: + 'Add or modify scene events. For local custom providers, the same model will generate event-change JSON that GDevelop applies.', + properties: { + scene_name: stringSchema('Scene name.'), + events_description: stringSchema( + 'Detailed description of the event logic to create or modify.' + ), + extension_names_list: stringSchema( + 'Comma-separated extension names needed by the events, or an empty string.' + ), + objects_list: stringSchema( + 'Comma-separated relevant object names and roles.' + ), + placement_hint: stringSchema( + 'Where to place the events, for example "insert_at_end".' + ), + estimated_complexity: numberSchema( + 'Optional complexity estimate from 1 to 10.' + ), + }, + required: ['scene_name', 'events_description', 'extension_names_list'], + }), + makeOpenAiCompatibleTool({ + name: 'change_scene_properties_layers_effects_groups', + description: + 'Change scene/project properties, layers, layer effects, or object groups.', + properties: { + scene_name: stringSchema('Scene name.'), + changed_properties: changedPropertiesSchema, + changed_layers: { + type: 'array', + items: { type: 'object', additionalProperties: true }, + }, + changed_layer_effects: { + type: 'array', + items: { type: 'object', additionalProperties: true }, + }, + changed_groups: { + type: 'array', + items: { type: 'object', additionalProperties: true }, + }, + }, + required: ['scene_name'], + }), + makeOpenAiCompatibleTool({ + name: 'add_or_edit_variable', + description: 'Add or edit a global, scene, or object variable.', + properties: { + variable_name_or_path: stringSchema('Variable name or child path.'), + value: stringSchema('Value as a string.'), + variable_type: stringSchema( + 'Optional: number, string, boolean, structure, or array.' + ), + variable_scope: stringSchema('"global", "scene", or "object".'), + scene_name: stringSchema('Scene name for scene/object variables.'), + object_name: stringSchema('Object name for object variables.'), + }, + required: ['variable_name_or_path', 'value', 'variable_scope'], + }), +]; + +const shouldUseLocalTools = ( + mode: 'chat' | 'agent' | 'orchestrator' +): boolean => mode === 'agent' || mode === 'orchestrator'; + +const localEditorToolNames = localEditorTools.map(tool => tool.function.name); + +const getProjectContextMessage = ({ + gameProjectJson, + projectSpecificExtensionsSummaryJson, + mode, +}: {| + gameProjectJson: string | null, + projectSpecificExtensionsSummaryJson: string | null, + mode: 'chat' | 'agent' | 'orchestrator', +|}): OpenAiCompatibleMessage => { + const contextMessages = []; + if (gameProjectJson) { + contextMessages.push(`Simplified project JSON:\n${gameProjectJson}`); + } + if (projectSpecificExtensionsSummaryJson) { + contextMessages.push( + `Project-specific extensions summary JSON:\n${projectSpecificExtensionsSummaryJson}` + ); + } + + return { + role: 'system', + content: [ + 'You are an AI assistant inside GDevelop, a game development app.', + 'Answer the user directly and use the project context when it is available.', + mode === 'agent' || mode === 'orchestrator' + ? [ + 'The user selected a build/edit mode. You must use the available tools to create or modify the project whenever a tool can do the work.', + 'Do not answer with manual setup steps instead of tool calls. Only explain limitations when no tool can apply the requested change.', + 'For common 2D games, create objects, place instances, then add scene events. Common object types include "Sprite" and "TextObject::Text".', + ].join(' ') + : '', + ...contextMessages, + ] + .filter(Boolean) + .join('\n\n'), + }; +}; + +const getTextFromOpenAiCompatibleContent = (content: any): string => { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + + return content + .map(part => { + if (typeof part === 'string') return part; + if (!part || typeof part !== 'object') return ''; + if (part.type === 'function_call') return ''; + return typeof part.text === 'string' ? part.text : ''; + }) + .filter(Boolean) + .join('\n'); +}; + +const normalizeFunctionCallArguments = (args: any): string => { + if (typeof args === 'string') return args || '{}'; + if (args === null || typeof args === 'undefined') return '{}'; + return JSON.stringify(args); +}; + +const createAiRequestFunctionCall = ({ + callId, + name, + args, +}: {| + callId?: string, + name: string, + args: any, +|}): AiRequestMessageAssistantFunctionCall => ({ + type: 'function_call', + status: 'completed', + call_id: callId || createLocalFunctionCallId(), + name, + arguments: normalizeFunctionCallArguments(args), +}); + +const getFunctionCallsFromOpenAiCompatibleToolCalls = ( + toolCalls: any +): Array => { + if (!Array.isArray(toolCalls)) return []; + + return toolCalls + .map(toolCall => { + if (!toolCall || typeof toolCall !== 'object') return null; + const functionPayload = + toolCall.function && typeof toolCall.function === 'object' + ? toolCall.function + : toolCall; + const name = + typeof functionPayload.name === 'string' ? functionPayload.name : null; + if (!name) return null; + + return createAiRequestFunctionCall({ + callId: + typeof toolCall.id === 'string' + ? toolCall.id + : typeof toolCall.call_id === 'string' + ? toolCall.call_id + : undefined, + name, + args: + typeof functionPayload.arguments !== 'undefined' + ? functionPayload.arguments + : functionPayload.input, + }); + }) + .filter(Boolean); +}; + +const getFunctionCallsFromOpenAiCompatibleContent = ( + content: any +): Array => { + if (!Array.isArray(content)) return []; + + return content + .map(part => { + if (!part || typeof part !== 'object') return null; + if (part.type !== 'function_call') return null; + const name = typeof part.name === 'string' ? part.name : null; + if (!name) return null; + + return createAiRequestFunctionCall({ + callId: + typeof part.call_id === 'string' + ? part.call_id + : typeof part.id === 'string' + ? part.id + : undefined, + name, + args: + typeof part.arguments !== 'undefined' ? part.arguments : part.input, + }); + }) + .filter(Boolean); +}; + +const getOpenAiCompatibleAssistantResultFromResponse = ( + responseData: any +): OpenAiCompatibleAssistantResult => { + const choices = + responseData && Array.isArray(responseData.choices) + ? responseData.choices + : []; + const firstChoice = choices[0]; + const message = firstChoice && firstChoice.message ? firstChoice.message : {}; + const content = + message && 'content' in message + ? message.content + : firstChoice + ? firstChoice.text + : null; + const text = getTextFromOpenAiCompatibleContent(content); + const functionCalls = [ + ...getFunctionCallsFromOpenAiCompatibleToolCalls( + message ? message.tool_calls : null + ), + ...getFunctionCallsFromOpenAiCompatibleToolCalls( + message && message.function_call ? [message.function_call] : null + ), + ...getFunctionCallsFromOpenAiCompatibleContent(content), + ]; + + if (!text && !functionCalls.length) { + throw new Error('AI provider returned an empty response.'); + } + + return { text, functionCalls }; +}; + +const getOpenAiCompatibleToolCallsFromAiRequestMessage = ( + message: AiRequestAssistantMessage +): Array => { + const toolCalls: Array = []; + message.content.forEach(content => { + if (content.type === 'function_call') { + toolCalls.push({ + id: content.call_id, + type: 'function', + function: { + name: content.name, + arguments: content.arguments, + }, + }); + } + }); + return toolCalls; +}; + +const getOpenAiCompatibleTextFromAiRequestMessage = ( + message: AiRequestAssistantMessage | AiRequestUserMessage +): string => + message.content + .map(content => { + if (content.type === 'user_request') return content.text; + if (content.type === 'output_text') return content.text; + return ''; + }) + .filter(Boolean) + .join('\n'); + +const addAiRequestMessageToOpenAiCompatibleMessages = ( + messages: Array, + message: AiRequestMessage +) => { + if (message.type === 'function_call_output') { + messages.push({ + role: 'tool', + tool_call_id: message.call_id, + content: message.output || '', + }); + return; + } + + if (message.role === 'user') { + const text = getOpenAiCompatibleTextFromAiRequestMessage(message); + if (text) messages.push({ role: 'user', content: text }); + return; + } + + const text = getOpenAiCompatibleTextFromAiRequestMessage(message); + const toolCalls = getOpenAiCompatibleToolCallsFromAiRequestMessage(message); + if (text || toolCalls.length) { + messages.push({ + role: 'assistant', + content: text || null, + ...(toolCalls.length ? { tool_calls: toolCalls } : {}), + }); + } +}; + +const getOpenAiCompatibleToolCallPlainText = ( + toolCall: OpenAiCompatibleToolCall +): string => + `${toolCall.function.name}(${toolCall.function.arguments || '{}'})`; + +const getOpenAiCompatibleMessagesWithoutToolRoles = ( + messages: Array +): Array => + messages.map(message => { + if (message.role === 'tool') { + return { + role: 'user', + content: [ + message.tool_call_id + ? `Tool result for ${message.tool_call_id}:` + : 'Tool result:', + message.content || '', + ].join('\n'), + }; + } + + if (message.role === 'assistant' && message.tool_calls) { + return { + role: 'assistant', + content: [ + message.content || '', + 'Tool calls:', + message.tool_calls + .map(getOpenAiCompatibleToolCallPlainText) + .join('\n'), + ] + .filter(Boolean) + .join('\n'), + }; + } + + return message; + }); + +const getOpenAiCompatibleMessagesFromAiRequest = ({ + aiRequest, + userMessage, + functionCallOutputs, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + mode, +}: {| + aiRequest?: AiRequest, + userMessage: string, + functionCallOutputs?: Array, + gameProjectJson: string | null, + projectSpecificExtensionsSummaryJson: string | null, + mode: 'chat' | 'agent' | 'orchestrator', +|}): Array => { + const messages = [ + getProjectContextMessage({ + gameProjectJson, + projectSpecificExtensionsSummaryJson, + mode, + }), + ]; + + if (aiRequest && aiRequest.output) { + aiRequest.output.forEach(message => { + addAiRequestMessageToOpenAiCompatibleMessages(messages, message); + }); + } + + if (functionCallOutputs) { + functionCallOutputs.forEach(functionCallOutput => { + addAiRequestMessageToOpenAiCompatibleMessages( + messages, + functionCallOutput + ); + }); + } + if (userMessage) messages.push({ role: 'user', content: userMessage }); + return messages; +}; + +const isOpenAiCompatibleUnsupportedTemperatureError = (error: any): boolean => { + const details = getProviderErrorDetails(error); + if (details.status !== 400 && details.status !== 422) return false; + const detailsText = `${details.code} ${details.param} ${details.message}`; + const usesUnsupportedParameterLanguage = /unsupported[\s_-]*(?:value|parameter|argument)|not\s+supported|unknown\s+(?:parameter|argument)|unrecognized\s+(?:parameter|argument|request\s+argument)/i.test( + detailsText + ); + + const isUnsupportedTemperatureParam = + details.param === 'temperature' && + (details.code === 'unsupported_value' || + details.code === 'unsupported_parameter' || + details.code === 'unsupported_argument' || + usesUnsupportedParameterLanguage); + const isUnsupportedTemperatureMessage = + usesUnsupportedParameterLanguage && /temperature/i.test(detailsText); + + return isUnsupportedTemperatureParam || isUnsupportedTemperatureMessage; +}; + +const isOpenAiCompatibleUnsupportedReasoningEffortError = ( + error: any +): boolean => { + const details = getProviderErrorDetails(error); + if (details.status !== 400 && details.status !== 422) return false; + const detailsText = `${details.code} ${details.param} ${details.message}`; + const mentionsReasoningEffort = /reasoning[\s_-]*effort/i.test(detailsText); + const usesUnsupportedParameterLanguage = /unsupported[\s_-]*(?:value|parameter|argument)|not\s+supported|unknown\s+(?:parameter|argument)|unrecognized\s+(?:parameter|argument|request\s+argument)/i.test( + detailsText + ); + + const isUnsupportedReasoningEffortParam = + details.param === 'reasoning_effort' && + (details.code === 'unsupported_value' || + details.code === 'unsupported_parameter' || + details.code === 'unsupported_argument' || + usesUnsupportedParameterLanguage); + const isUnsupportedReasoningEffortMessage = + mentionsReasoningEffort && usesUnsupportedParameterLanguage; + + return ( + isUnsupportedReasoningEffortParam || isUnsupportedReasoningEffortMessage + ); +}; + +const isOpenAiCompatibleUnsupportedToolsError = (error: any): boolean => { + const details = getProviderErrorDetails(error); + if (details.status !== 400 && details.status !== 422) return false; + const detailsText = `${details.code} ${details.param} ${details.message}`; + const mentionsTools = /tools?|tool_choice|function/i.test(detailsText); + const usesUnsupportedParameterLanguage = /unsupported[\s_-]*(?:value|parameter|argument)|not\s+supported|unknown\s+(?:parameter|argument)|unrecognized\s+(?:parameter|argument|request\s+argument)/i.test( + detailsText + ); + + return mentionsTools && usesUnsupportedParameterLanguage; +}; + +const isOpenAiCompatibleUnsupportedMaxTokensError = (error: any): boolean => { + const details = getProviderErrorDetails(error); + if (details.status !== 400 && details.status !== 422) return false; + const detailsText = `${details.code} ${details.param} ${details.message}`; + const mentionsMaxTokens = /max[\s_-]*tokens|maximum[\s_-]*tokens/i.test( + detailsText + ); + const usesUnsupportedParameterLanguage = /unsupported[\s_-]*(?:value|parameter|argument)|not\s+supported|unknown\s+(?:parameter|argument)|unrecognized\s+(?:parameter|argument|request\s+argument)/i.test( + detailsText + ); + + return mentionsMaxTokens && usesUnsupportedParameterLanguage; +}; + +const getOpenAiCompatibleChatCompletionBody = ({ + providerConfiguration, + messages, + tools, + toolChoice, + omitMaxTokens, +}: {| + providerConfiguration: AiProviderConfigurationWritePayload, + messages: Array, + tools?: Array, + toolChoice?: string, + omitMaxTokens?: boolean, +|}): any => { + const compatibility = getOpenAiCompatibleProviderCompatibility( + providerConfiguration + ); + const messagesForProvider = compatibility.unsupportedTools + ? getOpenAiCompatibleMessagesWithoutToolRoles(messages) + : messages; + const body: any = { + model: providerConfiguration.model, + messages: messagesForProvider, + }; + if ( + typeof providerConfiguration.temperature === 'number' && + !compatibility.unsupportedTemperature + ) { + body.temperature = providerConfiguration.temperature; + } + if ( + typeof providerConfiguration.maxTokens === 'number' && + !omitMaxTokens && + !compatibility.unsupportedMaxTokens + ) { + body.max_tokens = providerConfiguration.maxTokens; + } + if ( + providerConfiguration.reasoningEffort && + !compatibility.unsupportedReasoningEffort + ) { + body.reasoning_effort = providerConfiguration.reasoningEffort; + } + if (tools && tools.length && !compatibility.unsupportedTools) { + body.tools = tools; + if (toolChoice) body.tool_choice = toolChoice; + } + return body; +}; + +const postOpenAiCompatibleChatCompletion = async ({ + providerConfiguration, + body, +}: {| + providerConfiguration: AiProviderConfigurationWritePayload, + body: any, +|}): Promise => { + const response = await axios.post( + getOpenAiCompatibleChatCompletionsUrl(providerConfiguration.baseUrl), + body, + { + headers: { + Authorization: `Bearer ${providerConfiguration.apiKey || ''}`, + 'Content-Type': 'application/json', + }, + timeout: openAiCompatibleChatCompletionTimeoutMs, + } + ); + return response.data; +}; + +const postOpenAiCompatibleChatCompletionWithParameterFallbacks = async ({ + providerConfiguration, + body, +}: {| + providerConfiguration: AiProviderConfigurationWritePayload, + body: any, +|}): Promise => { + let bodyToSend = body; + let didRemoveTemperature = false; + let didRemoveReasoningEffort = false; + let didRemoveMaxTokens = false; + + for (;;) { + try { + return await postOpenAiCompatibleChatCompletion({ + providerConfiguration, + body: bodyToSend, + }); + } catch (error) { + if ( + !didRemoveReasoningEffort && + typeof bodyToSend.reasoning_effort === 'string' && + isOpenAiCompatibleUnsupportedReasoningEffortError(error) + ) { + markOpenAiCompatibleProviderUnsupportedFeature( + providerConfiguration, + 'reasoning_effort' + ); + const bodyWithoutReasoningEffort = { ...bodyToSend }; + delete bodyWithoutReasoningEffort.reasoning_effort; + bodyToSend = bodyWithoutReasoningEffort; + didRemoveReasoningEffort = true; + continue; + } + + if ( + !didRemoveTemperature && + typeof bodyToSend.temperature === 'number' && + isOpenAiCompatibleUnsupportedTemperatureError(error) + ) { + markOpenAiCompatibleProviderUnsupportedFeature( + providerConfiguration, + 'temperature' + ); + const bodyWithoutTemperature = { ...bodyToSend }; + delete bodyWithoutTemperature.temperature; + bodyToSend = bodyWithoutTemperature; + didRemoveTemperature = true; + continue; + } + + if ( + !didRemoveMaxTokens && + typeof bodyToSend.max_tokens === 'number' && + isOpenAiCompatibleUnsupportedMaxTokensError(error) + ) { + markOpenAiCompatibleProviderUnsupportedFeature( + providerConfiguration, + 'max_tokens' + ); + const bodyWithoutMaxTokens = { ...bodyToSend }; + delete bodyWithoutMaxTokens.max_tokens; + bodyToSend = bodyWithoutMaxTokens; + didRemoveMaxTokens = true; + continue; + } + + if (bodyToSend.tools && isOpenAiCompatibleUnsupportedToolsError(error)) { + markOpenAiCompatibleProviderUnsupportedFeature( + providerConfiguration, + 'tools' + ); + throw error; + } + + throw makeOpenAiCompatibleProviderError(error); + } + } +}; + +const parseJsonFromOpenAiCompatibleText = (text: string): any => { + const trimmedText = text.trim(); + const candidates = [trimmedText]; + const fencedJsonMatch = trimmedText.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fencedJsonMatch) candidates.push(fencedJsonMatch[1].trim()); + + const firstObjectIndex = trimmedText.indexOf('{'); + const lastObjectIndex = trimmedText.lastIndexOf('}'); + if (firstObjectIndex !== -1 && lastObjectIndex > firstObjectIndex) { + candidates.push(trimmedText.slice(firstObjectIndex, lastObjectIndex + 1)); + } + + const firstArrayIndex = trimmedText.indexOf('['); + const lastArrayIndex = trimmedText.lastIndexOf(']'); + if (firstArrayIndex !== -1 && lastArrayIndex > firstArrayIndex) { + candidates.push(trimmedText.slice(firstArrayIndex, lastArrayIndex + 1)); + } + + let lastError = null; + for (const candidate of candidates) { + try { + return JSON.parse(candidate); + } catch (error) { + lastError = error; + } + } + + throw new Error( + `Custom Model returned invalid JSON: ${ + lastError instanceof Error ? lastError.message : 'Unable to parse JSON.' + }` + ); +}; + +const getFunctionCallsFromStrictJsonToolResponse = ( + text: string +): Array => { + const parsed = parseJsonFromOpenAiCompatibleText(text); + const rawToolCalls = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed.tool_calls) + ? parsed.tool_calls + : Array.isArray(parsed.function_calls) + ? parsed.function_calls + : Array.isArray(parsed.calls) + ? parsed.calls + : []; + + return rawToolCalls + .map(rawToolCall => { + if (!rawToolCall || typeof rawToolCall !== 'object') return null; + const functionPayload = + rawToolCall.function && typeof rawToolCall.function === 'object' + ? rawToolCall.function + : rawToolCall; + const name = + typeof functionPayload.name === 'string' ? functionPayload.name : null; + if (!name || localEditorToolNames.indexOf(name) === -1) return null; + + return createAiRequestFunctionCall({ + callId: + typeof rawToolCall.id === 'string' + ? rawToolCall.id + : typeof rawToolCall.call_id === 'string' + ? rawToolCall.call_id + : undefined, + name, + args: + typeof functionPayload.arguments !== 'undefined' + ? functionPayload.arguments + : functionPayload.input, + }); + }) + .filter(Boolean); +}; + +const getStrictJsonToolCallMessages = ( + messages: Array +): Array => [ + ...messages, + { + role: 'system', + content: [ + 'The previous response did not contain native tool calls.', + 'Reply only with a JSON object shaped like {"tool_calls":[{"name":"create_scene","arguments":{"scene_name":"Game"}}]}.', + 'Use only these available tool schemas:', + JSON.stringify(localEditorTools.map(tool => tool.function)), + 'Do not include Markdown, prose, setup instructions, or fields outside the JSON payload.', + ].join('\n'), + }, +]; + +const getOpenAiCompatibleChatCompletionResult = async ({ + providerConfiguration, + messages, + mode, + allowTextOnlyResponse = false, + omitMaxTokens = false, +}: {| + providerConfiguration: AiProviderConfigurationWritePayload, + messages: Array, + mode: 'chat' | 'agent' | 'orchestrator', + allowTextOnlyResponse?: boolean, + omitMaxTokens?: boolean, +|}): Promise => { + assertUsableOpenAiCompatibleConfiguration(providerConfiguration); + + if (!shouldUseLocalTools(mode)) { + return getOpenAiCompatibleAssistantResultFromResponse( + await postOpenAiCompatibleChatCompletionWithParameterFallbacks({ + providerConfiguration, + body: getOpenAiCompatibleChatCompletionBody({ + providerConfiguration, + messages, + omitMaxTokens, + }), + }) + ); + } + + if ( + !getOpenAiCompatibleProviderCompatibility(providerConfiguration) + .unsupportedTools + ) { + try { + const result = getOpenAiCompatibleAssistantResultFromResponse( + await postOpenAiCompatibleChatCompletionWithParameterFallbacks({ + providerConfiguration, + body: getOpenAiCompatibleChatCompletionBody({ + providerConfiguration, + messages, + tools: localEditorTools, + toolChoice: 'auto', + omitMaxTokens, + }), + }) + ); + if (result.functionCalls.length) return result; + if (allowTextOnlyResponse) return result; + } catch (error) { + if (!isOpenAiCompatibleUnsupportedToolsError(error)) throw error; + } + } + + const fallbackResult = getOpenAiCompatibleAssistantResultFromResponse( + await postOpenAiCompatibleChatCompletionWithParameterFallbacks({ + providerConfiguration, + body: getOpenAiCompatibleChatCompletionBody({ + providerConfiguration, + messages: getStrictJsonToolCallMessages(messages), + omitMaxTokens, + }), + }) + ); + if (fallbackResult.functionCalls.length) return fallbackResult; + + let fallbackFunctionCalls: Array = []; + try { + fallbackFunctionCalls = getFunctionCallsFromStrictJsonToolResponse( + fallbackResult.text + ); + } catch (error) { + if (allowTextOnlyResponse) return fallbackResult; + throw new Error( + `Custom Model did not return any tool calls for this build request. ${ + error instanceof Error ? error.message : '' + }` + ); + } + if (fallbackFunctionCalls.length) { + return { + text: '', + functionCalls: fallbackFunctionCalls, + }; + } + + throw new Error( + 'Custom Model did not return any tool calls for this build request. Choose a model/provider with tool/function calling support or try a more direct build instruction.' + ); +}; + +const createUserAiRequestMessage = ({ + text, + projectVersionIdBeforeMessage, +}: {| + text: string, + projectVersionIdBeforeMessage?: string | null, +|}): AiRequestUserMessage => { + let message: AiRequestUserMessage = { + type: 'message', + status: 'completed', + role: 'user', + content: [ + { + type: 'user_request', + status: 'completed', + text, + }, + ], + messageId: createLocalAiRequestMessageId('user'), + }; + if (projectVersionIdBeforeMessage) { + message = { ...message, projectVersionIdBeforeMessage }; + } + return message; +}; + +const createAssistantAiRequestMessage = ({ + text, + functionCalls, +}: OpenAiCompatibleAssistantResult): AiRequestAssistantMessage => { + const content: AiRequestAssistantMessageContent = []; + if (text) { + content.push({ + type: 'output_text', + status: 'completed', + text, + annotations: [], + }); + } + content.push(...functionCalls); + + return { + type: 'message', + status: 'completed', + role: 'assistant', + content, + messageId: createLocalAiRequestMessageId('assistant'), + }; +}; + +const createLocalChatAiRequest = async ({ + userId, + userRequest, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + aiConfiguration, + providerConfiguration, + gameId, + projectVersionIdBeforeMessage, + mode, + toolsVersion, + onLocalAiRequestCreated, +}: {| + userId: string, + userRequest: string, + gameProjectJson: string | null, + projectSpecificExtensionsSummaryJson: string | null, + aiConfiguration: AiConfiguration, + providerConfiguration: AiProviderConfigurationWritePayload, + gameId: string | null, + projectVersionIdBeforeMessage?: string | null, + mode: 'chat' | 'agent' | 'orchestrator', + toolsVersion: string, + onLocalAiRequestCreated?: AiRequest => void, +|}): Promise => { + const now = new Date().toISOString(); + const aiRequestId = createLocalAiRequestId(); + const workingAiRequest: AiRequest = { + id: aiRequestId, + createdAt: now, + updatedAt: now, + userId, + gameId, + status: 'working', + mode, + aiConfiguration: getPublicAiConfiguration(aiConfiguration), + toolsVersion, + toolOptions: null, + error: null, + output: [ + createUserAiRequestMessage({ + text: userRequest, + projectVersionIdBeforeMessage, + }), + ], + lastUserMessagePriceInCredits: 0, + totalPriceInCredits: 0, + }; + + localAiRequestsInMemory[workingAiRequest.id] = workingAiRequest; + localAiRequestProviderConfigurationsInMemory[ + workingAiRequest.id + ] = providerConfiguration; + if (onLocalAiRequestCreated) onLocalAiRequestCreated(workingAiRequest); + + let assistantResult: OpenAiCompatibleAssistantResult; + try { + assistantResult = await getOpenAiCompatibleChatCompletionResult({ + providerConfiguration, + mode, + messages: getOpenAiCompatibleMessagesFromAiRequest({ + userMessage: userRequest, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + mode, + }), + }); + } catch (error) { + const latestAiRequest = localAiRequestsInMemory[workingAiRequest.id]; + if (latestAiRequest && latestAiRequest.status !== 'suspended') { + localAiRequestsInMemory[workingAiRequest.id] = { + ...latestAiRequest, + updatedAt: new Date().toISOString(), + status: 'error', + error: { + code: 'ai-request/local-provider-error', + message: + error instanceof Error + ? error.message + : 'Custom Model request failed.', + }, + }; + } + throw error; + } + + const latestAiRequest = localAiRequestsInMemory[workingAiRequest.id]; + if (latestAiRequest && latestAiRequest.status === 'suspended') { + return latestAiRequest; + } + + const aiRequest: AiRequest = { + ...(latestAiRequest || workingAiRequest), + updatedAt: new Date().toISOString(), + status: 'ready', + error: null, + output: [ + ...((latestAiRequest || workingAiRequest).output || []), + createAssistantAiRequestMessage(assistantResult), + ], + lastUserMessagePriceInCredits: 0, + totalPriceInCredits: 0, + }; + + localAiRequestsInMemory[aiRequest.id] = aiRequest; + localAiRequestProviderConfigurationsInMemory[ + aiRequest.id + ] = providerConfiguration; + return aiRequest; +}; + +const addMessageToLocalChatAiRequest = async ({ + aiRequestId, + userMessage, + functionCallOutputs, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + projectVersionIdBeforeMessage, + providerConfiguration, + mode, +}: {| + aiRequestId: string, + userMessage: string, + functionCallOutputs: Array, + gameProjectJson: string | null, + projectSpecificExtensionsSummaryJson: string | null, + projectVersionIdBeforeMessage?: string | null, + providerConfiguration: AiProviderConfigurationWritePayload, + mode: 'chat' | 'agent' | 'orchestrator', +|}): Promise => { + const existingAiRequest = localAiRequestsInMemory[aiRequestId]; + if (!existingAiRequest) { + throw new Error( + 'This Custom Model chat is no longer available. Start a new Custom Model chat.' + ); + } + if (!userMessage && functionCallOutputs.length === 0) { + throw new Error('Enter a message before sending to Custom Model.'); + } + + const assistantResult = await getOpenAiCompatibleChatCompletionResult({ + providerConfiguration, + mode, + messages: getOpenAiCompatibleMessagesFromAiRequest({ + aiRequest: existingAiRequest, + userMessage, + functionCallOutputs, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + mode, + }), + allowTextOnlyResponse: functionCallOutputs.length > 0, + }); + const latestAiRequest = localAiRequestsInMemory[aiRequestId]; + if (latestAiRequest && latestAiRequest.status === 'suspended') { + return latestAiRequest; + } + + const nextOutput = [ + ...(existingAiRequest.output || []), + ...functionCallOutputs, + ]; + if (userMessage) { + nextOutput.push( + createUserAiRequestMessage({ + text: userMessage, + projectVersionIdBeforeMessage, + }) + ); + } + nextOutput.push(createAssistantAiRequestMessage(assistantResult)); + + const aiRequest: AiRequest = { + ...existingAiRequest, + updatedAt: new Date().toISOString(), + status: 'ready', + error: null, + output: nextOutput, + lastUserMessagePriceInCredits: 0, + totalPriceInCredits: 0, + }; + + localAiRequestsInMemory[aiRequest.id] = aiRequest; + localAiRequestProviderConfigurationsInMemory[ + aiRequest.id + ] = providerConfiguration; + return aiRequest; +}; + +const getProviderErrorMessage = (error: any): string | null => { + return getProviderErrorDisplayMessage(error); +}; + +const testLocalAiProviderConfiguration = async ( + userId: string, + providerConfigurationId: string +): Promise => { + const configuration = getLocalAiProviderConfigurations(userId).find( + configuration => configuration.id === providerConfigurationId + ); + if (!configuration || !configuration.apiKey) { + return { + success: false, + message: 'Enter an API key before testing this provider.', + }; + } + + try { + const baseUrl = configuration.baseUrl.replace(/\/+$/, ''); + await axios.get( + configuration.baseUrl.indexOf('https://openrouter.ai/') === 0 + ? 'https://openrouter.ai/api/v1/key' + : `${baseUrl}/models`, + { + headers: { + Authorization: `Bearer ${configuration.apiKey}`, + }, + } + ); + return { success: true, message: 'AI provider test succeeded.' }; + } catch (error) { + return { + success: false, + message: getProviderErrorMessage(error) || 'AI provider test failed.', + }; + } +}; + +export const listAiProviderConfigurations = async ( + getAuthorizationHeader: () => Promise, + { userId }: {| userId: string |} +): Promise> => { + const authorizationHeader = await getAuthorizationHeader(); + try { + const response = await apiClient.get('/ai-provider-configuration', { + params: { + userId, + }, + headers: { + Authorization: authorizationHeader, + }, + }); + const backendConfigurations = ensureIsArray({ + data: response.data, + endpointName: '/ai-provider-configuration of Generation API', + }); + return [ + ...backendConfigurations, + ...getLocalOnlyAiProviderConfigurations(userId) + .filter( + localConfiguration => + !backendConfigurations.some( + backendConfiguration => + backendConfiguration.id === localConfiguration.id + ) + ) + .map(localConfigurationToAiProviderConfiguration), + ]; + } catch (error) { + if (!isAiProviderConfigurationRouteUnavailableError(error)) throw error; + + return getLocalAiProviderConfigurations(userId).map( + localConfigurationToAiProviderConfiguration + ); + } +}; + +export const createAiProviderConfiguration = async ( + getAuthorizationHeader: () => Promise, + { + userId, + configuration, + }: {| + userId: string, + configuration: AiProviderConfigurationWritePayload, + |} +): Promise => { + if (isLocalAiProviderBaseUrl(configuration.baseUrl)) { + return createLocalAiProviderConfiguration(userId, configuration); + } + + const authorizationHeader = await getAuthorizationHeader(); + try { + const response = await apiClient.post( + '/ai-provider-configuration', + configuration, + { + params: { + userId, + }, + headers: { + Authorization: authorizationHeader, + }, + } + ); + const savedConfiguration = ensureObjectHasProperty({ + data: response.data, + propertyName: 'id', + endpointName: '/ai-provider-configuration of Generation API', + }); + return savedConfiguration; + } catch (error) { + if (!isAiProviderConfigurationRouteUnavailableError(error)) throw error; + + return createLocalAiProviderConfiguration(userId, configuration); + } }; -export type AiGeneratedEvent = { - id: string, - createdAt: string, - updatedAt: string, - userId: string | null, // null for calls made by the API. - status: GenerationStatus, +export const updateAiProviderConfiguration = async ( + getAuthorizationHeader: () => Promise, + { + userId, + providerConfigurationId, + configuration, + }: {| + userId: string, + providerConfigurationId: string, + configuration: AiProviderConfigurationWritePayload, + |} +): Promise => { + if (isLocalAiProviderConfigurationId(providerConfigurationId)) { + if (isLocalAiProviderBaseUrl(configuration.baseUrl)) { + return saveLocalAiProviderConfiguration( + userId, + providerConfigurationId, + configuration + ); + } - partialGameProjectJson: string, - eventsDescription: string, - extensionNamesList: string, - objectsList: string, - existingEventsAsText: string, - existingEventsJson: string | null, - existingEventsJsonUserRelativeKey: string | null, + const localConfiguration = getLocalAiProviderConfigurationPayload( + userId, + providerConfigurationId + ); + const configurationWithApiKey: AiProviderConfigurationWritePayload = { + ...configuration, + }; + const apiKey = + configuration.apiKey || + (localConfiguration ? localConfiguration.apiKey : null); + if (apiKey) configurationWithApiKey.apiKey = apiKey; + const authorizationHeader = await getAuthorizationHeader(); + try { + const response = await apiClient.post( + '/ai-provider-configuration', + configurationWithApiKey, + { + params: { + userId, + }, + headers: { + Authorization: authorizationHeader, + }, + } + ); + const savedConfiguration = ensureObjectHasProperty({ + data: response.data, + propertyName: 'id', + endpointName: '/ai-provider-configuration of Generation API', + }); + deleteLocalAiProviderConfiguration(userId, providerConfigurationId); + return savedConfiguration; + } catch (error) { + if (!isAiProviderConfigurationRouteUnavailableError(error)) throw error; - resultMessage: string | null, - changes: Array | null, + return saveLocalAiProviderConfiguration( + userId, + providerConfigurationId, + configurationWithApiKey + ); + } + } + if (isLocalAiProviderBaseUrl(configuration.baseUrl)) { + return createLocalAiProviderConfiguration(userId, configuration); + } - error: { - code: string, - message: string, - } | null, + const authorizationHeader = await getAuthorizationHeader(); + try { + const response = await apiClient.patch( + `/ai-provider-configuration/${providerConfigurationId}`, + configuration, + { + params: { + userId, + }, + headers: { + Authorization: authorizationHeader, + }, + } + ); + const savedConfiguration = ensureObjectHasProperty({ + data: response.data, + propertyName: 'id', + endpointName: '/ai-provider-configuration/{id} of Generation API', + }); + return savedConfiguration; + } catch (error) { + if (!isAiProviderConfigurationRouteUnavailableError(error)) throw error; - stats: AiGeneratedEventStats | null, + return createLocalAiProviderConfiguration(userId, configuration); + } }; -export type AssetSearch = { - id: string, - userId: string, - createdAt: string, - query: { - searchTerms: string[], - objectType: string, - description: string | null, - twoDimensionalViewKind: string | null, - relatedAiRequestId: string | null, - lastUserMessage: string | null, - lastAssistantMessages: string[], - }, - status: 'completed' | 'failed', - results: Array<{ - score: number, - asset: any, - }> | null, -}; +export const deleteAiProviderConfiguration = async ( + getAuthorizationHeader: () => Promise, + { + userId, + providerConfigurationId, + }: {| userId: string, providerConfigurationId: string |} +): Promise => { + if (isLocalAiProviderConfigurationId(providerConfigurationId)) { + deleteLocalAiProviderConfiguration(userId, providerConfigurationId); + return; + } -export type ResourceSearch = { - id: string, - userId: string, - createdAt: string, - query: { - searchTerms: string[], - resourceKind: string, - }, - status: 'completed' | 'failed', - results: Array<{ - score: number, - resource: { - name: string, - url: string, - }, - }> | null, + const authorizationHeader = await getAuthorizationHeader(); + try { + await apiClient.delete( + `/ai-provider-configuration/${providerConfigurationId}`, + { + params: { + userId, + }, + headers: { + Authorization: authorizationHeader, + }, + } + ); + } catch (error) { + if (!isAiProviderConfigurationRouteUnavailableError(error)) throw error; + } + deleteLocalAiProviderConfiguration(userId, providerConfigurationId); }; -// $FlowFixMe[cannot-resolve-name] -export const apiClient: Axios = axios.create({ - baseURL: GDevelopGenerationApi.baseUrl, -}); +export const testAiProviderConfiguration = async ( + getAuthorizationHeader: () => Promise, + { + userId, + providerConfigurationId, + }: {| userId: string, providerConfigurationId: string |} +): Promise => { + if (isLocalAiProviderConfigurationId(providerConfigurationId)) { + return testLocalAiProviderConfiguration(userId, providerConfigurationId); + } + + const authorizationHeader = await getAuthorizationHeader(); + try { + const response = await apiClient.post( + `/ai-provider-configuration/${providerConfigurationId}/action/test`, + {}, + { + params: { + userId, + }, + headers: { + Authorization: authorizationHeader, + }, + } + ); + return ensureIsObject({ + data: response.data, + endpointName: + '/ai-provider-configuration/{id}/action/test of Generation API', + }); + } catch (error) { + if (!isAiProviderConfigurationRouteUnavailableError(error)) throw error; + + return testLocalAiProviderConfiguration(userId, providerConfigurationId); + } +}; export const getAiRequest = async ( getAuthorizationHeader: () => Promise, @@ -273,6 +2412,14 @@ export const getAiRequest = async ( aiRequestId: string, |} ): Promise => { + if (isLocalAiRequestId(aiRequestId)) { + const localAiRequest = localAiRequestsInMemory[aiRequestId]; + if (localAiRequest) return localAiRequest; + throw new Error( + 'This Custom Model chat is no longer available. Start a new Custom Model chat.' + ); + } + const authorizationHeader = await getAuthorizationHeader(); // $FlowFixMe[underconstrained-implicit-instantiation] const response = await axios.get( @@ -306,6 +2453,22 @@ export const getPartialAiRequest = async ( |} ): // $FlowFixMe[deprecated-utility] Promise<$Shape> => { + if (isLocalAiRequestId(aiRequestId)) { + const localAiRequest = localAiRequestsInMemory[aiRequestId]; + if (localAiRequest) { + return include === 'status' + ? { + id: localAiRequest.id, + status: localAiRequest.status, + updatedAt: localAiRequest.updatedAt, + } + : localAiRequest; + } + throw new Error( + 'This Custom Model chat is no longer available. Start a new Custom Model chat.' + ); + } + const authorizationHeader = await getAuthorizationHeader(); // $FlowFixMe[underconstrained-implicit-instantiation] const response = await axios.get( @@ -379,6 +2542,7 @@ export const createAiRequest = async ( fileMetadata, storageProviderName, toolsVersion, + onLocalAiRequestCreated, }: {| userId: string, userRequest: string, @@ -399,9 +2563,45 @@ export const createAiRequest = async ( }, storageProviderName: ?string, toolsVersion: string, + onLocalAiRequestCreated?: AiRequest => void, |} ): Promise => { + const aiConfigurationWithLocalProvider = getAiConfigurationWithLocalProvider( + userId, + aiConfiguration + ); + const providerConfiguration = + aiConfigurationWithLocalProvider.providerConfiguration || null; + const isUsingLocalAiProvider = + !!providerConfiguration || + (!!aiConfigurationWithLocalProvider.providerConfigurationId && + isLocalAiProviderConfigurationId( + aiConfigurationWithLocalProvider.providerConfigurationId + )); + + if (isUsingLocalAiProvider) { + if (!providerConfiguration) { + throw getMissingLocalProviderConfigurationError(); + } + + return createLocalChatAiRequest({ + userId, + userRequest, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + aiConfiguration: aiConfigurationWithLocalProvider, + providerConfiguration, + gameId, + projectVersionIdBeforeMessage, + mode, + toolsVersion, + onLocalAiRequestCreated, + }); + } + const authorizationHeader = await getAuthorizationHeader(); + const publicAiConfiguration = getPublicAiConfiguration(aiConfiguration); + const isUsingCustomAiProvider = !!publicAiConfiguration.providerConfigurationId; const response = await apiClient.post( '/ai-request', { @@ -411,10 +2611,12 @@ export const createAiRequest = async ( gameProjectJsonUserRelativeKey, projectSpecificExtensionsSummaryJson, projectSpecificExtensionsSummaryJsonUserRelativeKey, - payWithCredits: !!payWithCredits, - payWithAiCredits: !payWithCredits, + payWithCredits: !isUsingCustomAiProvider && !!payWithCredits, + payWithAiCredits: !isUsingCustomAiProvider && !payWithCredits, mode, - aiConfiguration, + aiConfiguration: publicAiConfiguration, + providerConfigurationId: + publicAiConfiguration.providerConfigurationId || null, gameId, projectVersionIdBeforeMessage, fileMetadata, @@ -454,6 +2656,7 @@ export const addMessageToAiRequest = async ( paused, mode, toolsVersion, + aiConfiguration, }: {| userId: string, aiRequestId: string, @@ -469,9 +2672,54 @@ export const addMessageToAiRequest = async ( paused?: boolean, mode?: 'chat' | 'agent' | 'orchestrator', toolsVersion?: string, + aiConfiguration?: AiConfiguration, |} ): Promise => { + const aiConfigurationWithLocalProvider = aiConfiguration + ? getAiConfigurationWithLocalProvider(userId, aiConfiguration) + : undefined; + const providerConfiguration = + (aiConfigurationWithLocalProvider && + aiConfigurationWithLocalProvider.providerConfiguration) || + (isLocalAiRequestId(aiRequestId) + ? localAiRequestProviderConfigurationsInMemory[aiRequestId] + : null) || + null; + const isUsingLocalAiProvider = + isLocalAiRequestId(aiRequestId) || + !!providerConfiguration || + !!( + aiConfigurationWithLocalProvider && + aiConfigurationWithLocalProvider.providerConfigurationId && + isLocalAiProviderConfigurationId( + aiConfigurationWithLocalProvider.providerConfigurationId + ) + ); + + if (isUsingLocalAiProvider) { + if (!providerConfiguration) { + throw getMissingLocalProviderConfigurationError(); + } + + return addMessageToLocalChatAiRequest({ + aiRequestId, + userMessage, + functionCallOutputs, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + projectVersionIdBeforeMessage, + providerConfiguration, + mode: mode || 'chat', + }); + } + const authorizationHeader = await getAuthorizationHeader(); + const publicAiConfiguration = aiConfigurationWithLocalProvider + ? getPublicAiConfiguration(aiConfigurationWithLocalProvider) + : undefined; + const isUsingCustomAiProvider = !!( + publicAiConfiguration && publicAiConfiguration.providerConfigurationId + ); const response = await apiClient.post( `/ai-request/${aiRequestId}/action/add-message`, { @@ -480,8 +2728,8 @@ export const addMessageToAiRequest = async ( userMessage, gameId, projectVersionIdBeforeMessage, - payWithCredits: !!payWithCredits, - payWithAiCredits: !payWithCredits, + payWithCredits: !isUsingCustomAiProvider && !!payWithCredits, + payWithAiCredits: !isUsingCustomAiProvider && !payWithCredits, gameProjectJson, gameProjectJsonUserRelativeKey, projectSpecificExtensionsSummaryJson, @@ -489,6 +2737,11 @@ export const addMessageToAiRequest = async ( paused, mode, toolsVersion, + aiConfiguration: publicAiConfiguration, + providerConfigurationId: + (publicAiConfiguration && + publicAiConfiguration.providerConfigurationId) || + null, }, { params: { @@ -510,6 +2763,23 @@ export const suspendAiRequest = async ( getAuthorizationHeader: () => Promise, { userId, aiRequestId }: {| userId: string, aiRequestId: string |} ): Promise => { + if (isLocalAiRequestId(aiRequestId)) { + const localAiRequest = localAiRequestsInMemory[aiRequestId]; + if (!localAiRequest) { + throw new Error( + 'This Custom Model chat is no longer available. Start a new Custom Model chat.' + ); + } + + const suspendedAiRequest: AiRequest = { + ...localAiRequest, + status: 'suspended', + updatedAt: new Date().toISOString(), + }; + localAiRequestsInMemory[aiRequestId] = suspendedAiRequest; + return suspendedAiRequest; + } + const authorizationHeader = await getAuthorizationHeader(); const response = await apiClient.post( `/ai-request/${aiRequestId}/action/suspend`, @@ -539,6 +2809,40 @@ export const updateAiRequestMessage = async ( projectVersionIdAfterMessage?: ?string, |} ): Promise => { + if (isLocalAiRequestId(aiRequestId)) { + const localAiRequest = localAiRequestsInMemory[aiRequestId]; + if (!localAiRequest) { + throw new Error( + 'This Custom Model chat is no longer available. Start a new Custom Model chat.' + ); + } + + localAiRequestsInMemory[aiRequestId] = { + ...localAiRequest, + updatedAt: new Date().toISOString(), + output: (localAiRequest.output || []).map( + (message: AiRequestMessage): AiRequestMessage => { + if (message.messageId !== aiRequestMessageId) return message; + if (message.type === 'message' && message.role === 'user') { + return projectVersionIdBeforeMessage + ? { + ...message, + projectVersionIdBeforeMessage, + } + : message; + } + return projectVersionIdAfterMessage + ? { + ...message, + projectVersionIdAfterMessage, + } + : message; + } + ), + }; + return; + } + const authorizationHeader = await getAuthorizationHeader(); await apiClient.patch( `/ai-request/${aiRequestId}/message/${aiRequestMessageId}`, @@ -621,6 +2925,14 @@ export const getAiRequestSuggestions = async ( projectSpecificExtensionsSummaryJsonUserRelativeKey: string | null, |} ): Promise => { + if (isLocalAiRequestId(aiRequestId)) { + const localAiRequest = localAiRequestsInMemory[aiRequestId]; + if (localAiRequest) return localAiRequest; + throw new Error( + 'This Custom Model chat is no longer available. Start a new Custom Model chat.' + ); + } + const authorizationHeader = await getAuthorizationHeader(); const response = await apiClient.post( `/ai-request/${aiRequestId}/action/get-suggestions`, @@ -658,6 +2970,271 @@ export type CreateAiGeneratedEventResult = errorMessage: string, |}; +const getLocalAiRequestProviderConfiguration = ( + aiRequestId: string +): AiProviderConfigurationWritePayload => { + const providerConfiguration = + localAiRequestProviderConfigurationsInMemory[aiRequestId]; + if (!providerConfiguration) { + throw new Error( + 'This Custom Model build request is no longer available. Start a new Custom Model build request.' + ); + } + return providerConfiguration; +}; + +const getStringArrayOrNull = (value: any): string[] | null => { + if (!Array.isArray(value)) return null; + return value.filter(item => typeof item === 'string'); +}; + +const getObjectOrEmpty = (value: any): { [key: string]: any } => + value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + +const normalizeLocalAiGeneratedEventChanges = ( + rawResponse: any +): Array => { + const rawChanges = Array.isArray(rawResponse) + ? rawResponse + : rawResponse && Array.isArray(rawResponse.changes) + ? rawResponse.changes + : null; + if (!rawChanges) { + throw new Error( + 'Custom Model event response must include a "changes" array.' + ); + } + + const changes = rawChanges.map((rawChange, index) => { + if (!rawChange || typeof rawChange !== 'object') { + throw new Error(`Event change ${index + 1} must be an object.`); + } + + const operationName = + typeof rawChange.operationName === 'string' + ? rawChange.operationName + : null; + if (!operationName) { + throw new Error(`Event change ${index + 1} is missing operationName.`); + } + + let generatedEvents = null; + if ( + rawChange.generatedEvents !== null && + typeof rawChange.generatedEvents !== 'undefined' + ) { + generatedEvents = + typeof rawChange.generatedEvents === 'string' + ? rawChange.generatedEvents + : JSON.stringify(rawChange.generatedEvents); + try { + JSON.parse(generatedEvents); + } catch (error) { + throw new Error( + `Custom Model returned invalid generatedEvents JSON for ${operationName}: ${ + error instanceof Error ? error.message : 'Unable to parse JSON.' + }` + ); + } + } + + return { + operationName, + operationTargetEvent: + typeof rawChange.operationTargetEvent === 'string' + ? rawChange.operationTargetEvent + : null, + isEventsJsonValid: + typeof rawChange.isEventsJsonValid === 'boolean' + ? rawChange.isEventsJsonValid + : generatedEvents + ? true + : null, + generatedEvents, + areEventsValid: + typeof rawChange.areEventsValid === 'boolean' + ? rawChange.areEventsValid + : generatedEvents + ? true + : null, + extensionNames: getStringArrayOrNull(rawChange.extensionNames), + diagnosticLines: getStringArrayOrNull(rawChange.diagnosticLines) || [], + undeclaredVariables: Array.isArray(rawChange.undeclaredVariables) + ? rawChange.undeclaredVariables + : [], + undeclaredObjectVariables: getObjectOrEmpty( + rawChange.undeclaredObjectVariables + ), + missingObjectBehaviors: getObjectOrEmpty( + rawChange.missingObjectBehaviors + ), + missingResources: Array.isArray(rawChange.missingResources) + ? rawChange.missingResources + : [], + }; + }); + + if (!changes.length) { + throw new Error('Custom Model event response did not include any changes.'); + } + return changes; +}; + +const createLocalAiGeneratedEvent = async ({ + userId, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + sceneName, + eventsDescription, + extensionNamesList, + objectsList, + existingEventsAsText, + existingEventsJson, + existingEventsJsonUserRelativeKey, + placementHint, + relatedAiRequestId, + estimatedComplexity, + repairInstructions, +}: {| + userId: string, + gameProjectJson: string | null, + projectSpecificExtensionsSummaryJson: string | null, + sceneName: string, + eventsDescription: string, + extensionNamesList: string, + objectsList: string, + existingEventsAsText: string, + existingEventsJson: string | null, + existingEventsJsonUserRelativeKey: string | null, + placementHint: string | null, + relatedAiRequestId: string, + estimatedComplexity: number | null, + repairInstructions?: string | null, +|}): Promise => { + try { + const providerConfiguration = getLocalAiRequestProviderConfiguration( + relatedAiRequestId + ); + const omitMaxTokens = + typeof providerConfiguration.maxTokens === 'number' && + providerConfiguration.maxTokens < localEventGenerationMinimumMaxTokens; + const maxAttempts = repairInstructions ? 1 : 2; + let currentRepairInstructions = repairInstructions || null; + let lastValidationError: Error | null = null; + + for (let attemptIndex = 0; attemptIndex < maxAttempts; attemptIndex++) { + const assistantResult = await getOpenAiCompatibleChatCompletionResult({ + providerConfiguration, + mode: 'chat', + omitMaxTokens, + messages: [ + { + role: 'system', + content: [ + 'You generate GDevelop scene event changes.', + 'Return only JSON shaped like {"resultMessage":"...","changes":[AiGeneratedEventChange]}.', + 'Each change must include operationName. For inserted or replacement events, generatedEvents must be a valid serialized GDevelop EventsList JSON string.', + 'Use operationName "insert_at_end" when adding new events at the end of the scene unless the placement hint requires another supported operation.', + currentRepairInstructions + ? [ + 'The previous event response failed validation or could not be applied by GDevelop.', + 'Use the repair details to correct the same requested event changes.', + 'Return only the existing event-change JSON shape, with no Markdown, prose, or manual setup instructions.', + ].join(' ') + : 'Do not include Markdown or manual setup instructions.', + ].join('\n'), + }, + { + role: 'user', + content: JSON.stringify( + { + sceneName, + eventsDescription, + extensionNamesList, + objectsList, + existingEventsAsText, + existingEventsJson, + existingEventsJsonUserRelativeKey, + placementHint, + estimatedComplexity, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + repairInstructions: currentRepairInstructions, + }, + null, + 2 + ), + }, + ], + }); + + try { + if (!assistantResult.text) { + throw new Error( + 'Custom Model event response did not include JSON text.' + ); + } + + const rawResponse = parseJsonFromOpenAiCompatibleText( + assistantResult.text + ); + const changes = normalizeLocalAiGeneratedEventChanges(rawResponse); + const now = new Date().toISOString(); + const aiGeneratedEvent: AiGeneratedEvent = { + id: createLocalAiGeneratedEventId(), + createdAt: now, + updatedAt: now, + userId, + status: 'ready', + partialGameProjectJson: gameProjectJson || '', + eventsDescription, + extensionNamesList, + objectsList, + existingEventsAsText, + existingEventsJson, + existingEventsJsonUserRelativeKey, + resultMessage: + rawResponse && + typeof rawResponse === 'object' && + typeof rawResponse.resultMessage === 'string' + ? rawResponse.resultMessage + : 'Custom Model generated event changes.', + changes, + error: null, + stats: null, + }; + localAiGeneratedEventsInMemory[aiGeneratedEvent.id] = aiGeneratedEvent; + + return { + creationSucceeded: true, + aiGeneratedEvent, + }; + } catch (validationError) { + lastValidationError = + validationError instanceof Error + ? validationError + : new Error('Custom Model could not generate valid event JSON.'); + currentRepairInstructions = [ + 'The previous Custom Model event response failed validation.', + `Validation error: ${lastValidationError.message}`, + 'Return only JSON shaped like {"resultMessage":"...","changes":[AiGeneratedEventChange]}.', + ].join('\n'); + } + } + + throw lastValidationError || + new Error('Custom Model could not generate valid event JSON.'); + } catch (error) { + return { + creationSucceeded: false, + errorMessage: + error instanceof Error + ? error.message + : 'Custom Model could not generate valid event JSON.', + }; + } +}; + export const createAiGeneratedEvent = async ( getAuthorizationHeader: () => Promise, { @@ -676,6 +3253,7 @@ export const createAiGeneratedEvent = async ( placementHint, relatedAiRequestId, estimatedComplexity, + repairInstructions, }: {| userId: string, gameProjectJson: string | null, @@ -692,8 +3270,28 @@ export const createAiGeneratedEvent = async ( placementHint: string | null, relatedAiRequestId: string, estimatedComplexity: number | null, + repairInstructions?: string | null, |} ): Promise => { + if (isLocalAiRequestId(relatedAiRequestId)) { + return createLocalAiGeneratedEvent({ + userId, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + sceneName, + eventsDescription, + extensionNamesList, + objectsList, + existingEventsAsText, + existingEventsJson, + existingEventsJsonUserRelativeKey, + placementHint, + relatedAiRequestId, + estimatedComplexity, + repairInstructions, + }); + } + const authorizationHeader = await getAuthorizationHeader(); const response = await apiClient.post( `/ai-generated-event`, @@ -761,6 +3359,15 @@ export const getAiGeneratedEvent = async ( aiGeneratedEventId: string, |} ): Promise => { + if (isLocalAiGeneratedEventId(aiGeneratedEventId)) { + const localAiGeneratedEvent = + localAiGeneratedEventsInMemory[aiGeneratedEventId]; + if (localAiGeneratedEvent) return localAiGeneratedEvent; + throw new Error( + 'This Custom Model event generation is no longer available. Start a new Custom Model build request.' + ); + } + const authorizationHeader = await getAuthorizationHeader(); const response = await apiClient.get( `/ai-generated-event/${aiGeneratedEventId}`, @@ -929,9 +3536,35 @@ export type AiConfigurationPreset = {| export type AiSettings = { aiRequest: { presets: Array, + customProviderSupport?: AiRequestCustomProviderSupport, }, }; +export const developmentDefaultAiRequestCustomProviderSupport: AiRequestCustomProviderSupport = { + enabled: true, + openAiCompatible: true, +}; + +export const getAiRequestCustomProviderSupport = ({ + aiSettings, + enableDevelopmentFallback, +}: {| + aiSettings: AiSettings | null, + enableDevelopmentFallback: boolean, +|}): AiRequestCustomProviderSupport | null => { + if ( + aiSettings && + aiSettings.aiRequest && + aiSettings.aiRequest.customProviderSupport + ) { + return aiSettings.aiRequest.customProviderSupport; + } + + return enableDevelopmentFallback + ? developmentDefaultAiRequestCustomProviderSupport + : null; +}; + export const forkAiRequest = async ( getAuthorizationHeader: () => Promise, { @@ -944,6 +3577,54 @@ export const forkAiRequest = async ( upToMessageId?: string, |} ): Promise => { + if (isLocalAiRequestId(aiRequestId)) { + const localAiRequest = localAiRequestsInMemory[aiRequestId]; + if (!localAiRequest) { + throw new Error( + 'This Custom Model chat is no longer available. Start a new Custom Model chat.' + ); + } + + const output = localAiRequest.output || []; + let forkedOutput = output; + let forkedAfterMessageId: string | null = null; + if (upToMessageId) { + const messageIndex = output.findIndex( + message => message.messageId === upToMessageId + ); + if (messageIndex === -1) { + throw new Error( + 'This Custom Model chat message is no longer available. Start a new Custom Model chat.' + ); + } + + forkedOutput = output.slice(0, messageIndex + 1); + forkedAfterMessageId = upToMessageId; + } + + const now = new Date().toISOString(); + const forkedAiRequest: AiRequest = { + ...localAiRequest, + id: createLocalAiRequestId(), + createdAt: now, + updatedAt: now, + status: 'ready', + output: forkedOutput, + forkedFromAiRequestId: localAiRequest.id, + forkedAfterOriginalMessageId: forkedAfterMessageId, + forkedAfterNewMessageId: forkedAfterMessageId, + }; + localAiRequestsInMemory[forkedAiRequest.id] = forkedAiRequest; + const providerConfiguration = + localAiRequestProviderConfigurationsInMemory[aiRequestId]; + if (providerConfiguration) { + localAiRequestProviderConfigurationsInMemory[ + forkedAiRequest.id + ] = providerConfiguration; + } + return forkedAiRequest; + } + const authorizationHeader = await getAuthorizationHeader(); const response = await apiClient.post( `/ai-request/${aiRequestId}/action/fork`, diff --git a/newIDE/app/src/Utils/GDevelopServices/Generation.spec.js b/newIDE/app/src/Utils/GDevelopServices/Generation.spec.js new file mode 100644 index 000000000000..fcdfff6911f8 --- /dev/null +++ b/newIDE/app/src/Utils/GDevelopServices/Generation.spec.js @@ -0,0 +1,2868 @@ +// @flow +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { + addMessageToAiRequest, + apiClient, + clearOpenAiCompatibleProviderCompatibilityCacheForTests, + createAiProviderConfiguration, + createAiGeneratedEvent, + createAiRequest, + deleteAiProviderConfiguration, + forkAiRequest, + getAiGeneratedEvent, + getAiRequest, + getAiRequestWithPreservedAiConfiguration, + getAiRequestCustomProviderSupport, + getAiRequestSuggestions, + isAiProviderConfigurationRouteUnavailableError, + isLocalAiProviderBaseUrl, + isAiProviderUnavailableError, + listAiProviderConfigurations, + suspendAiRequest, + testAiProviderConfiguration, + updateAiRequestMessage, + updateAiProviderConfiguration, + type AiProviderConfiguration, + type AiProviderConfigurationWritePayload, + type AiSettings, +} from './Generation'; + +const getAuthorizationHeader = async () => 'Bearer test-token'; + +const mockProviderConfiguration: AiProviderConfiguration = { + id: 'provider-1', + name: 'OpenAI', + providerType: 'openai-compatible', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.5', + temperature: null, + maxTokens: null, + reasoningEffort: 'high', + hasApiKey: true, + createdAt: '2026-05-15T10:00:00.000Z', + updatedAt: '2026-05-15T10:00:00.000Z', +}; + +const providerPayload: AiProviderConfigurationWritePayload = { + name: 'OpenAI', + providerType: 'openai-compatible', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.5', + temperature: 0.2, + maxTokens: 1000, + reasoningEffort: 'high', + apiKey: 'sk-test', +}; + +const providerPayloadWithoutApiKey: AiProviderConfigurationWritePayload = { + name: 'OpenAI', + providerType: 'openai-compatible', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.5', + temperature: 0.2, + maxTokens: 1000, + reasoningEffort: 'high', +}; + +const localProviderPayload: AiProviderConfigurationWritePayload = { + name: 'Local provider', + providerType: 'openai-compatible', + baseUrl: 'http://127.0.0.1:18080/', + model: 'gpt-5.5', + temperature: 0.2, + maxTokens: 1000, + reasoningEffort: 'high', + apiKey: 'sk-local', +}; + +const routeUnavailableResponse: any = [ + 403, + { + message: + "Invalid key=value pair (missing equal-sign) in Authorization header: 'Bearer test-token'.", + }, + { + 'x-amzn-ErrorType': 'IncompleteSignatureException', + }, +]; + +const baseAiRequestArgs: any = { + userId: 'user-1', + userRequest: 'Build a platformer', + gameProjectJson: null, + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + payWithCredits: false, + mode: 'chat', + gameId: null, + fileMetadata: null, + storageProviderName: null, + toolsVersion: 'test-tools', +}; + +describe('Generation service', () => { + let mock: any; + let providerMock: any; + + beforeEach(() => { + mock = new MockAdapter(apiClient); + providerMock = new MockAdapter(axios); + }); + + afterEach(() => { + clearOpenAiCompatibleProviderCompatibilityCacheForTests(); + providerMock.restore(); + mock.restore(); + }); + + describe('AI requests', () => { + const expectLocalProviderRequestToRetryWithoutTemperature = async ( + firstErrorResponseData: any + ) => { + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + try { + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.temperature).toBe(0.2); + expect(data.reasoning_effort).toBe('high'); + return [400, firstErrorResponseData]; + }) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.temperature).toBeUndefined(); + expect(data.reasoning_effort).toBe('high'); + return [ + 200, + { + choices: [{ message: { content: 'pong' } }], + }, + ]; + }); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + + expect(providerMock.history.post).toHaveLength(2); + expect(aiRequest.output && aiRequest.output[1]).toEqual( + expect.objectContaining({ + role: 'assistant', + content: [ + { + type: 'output_text', + status: 'completed', + text: 'pong', + annotations: [], + }, + ], + }) + ); + } finally { + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + } + }; + + const expectLocalProviderRequestToRetryWithoutReasoningEffort = async ( + firstErrorResponseData: any + ) => { + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + try { + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.temperature).toBe(0.2); + expect(data.reasoning_effort).toBe('high'); + return [400, firstErrorResponseData]; + }) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.temperature).toBe(0.2); + expect(data.reasoning_effort).toBeUndefined(); + return [ + 200, + { + choices: [{ message: { content: 'pong' } }], + }, + ]; + }); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + + expect(providerMock.history.post).toHaveLength(2); + expect(aiRequest.output && aiRequest.output[1]).toEqual( + expect.objectContaining({ + role: 'assistant', + content: [ + { + type: 'output_text', + status: 'completed', + text: 'pong', + annotations: [], + }, + ], + }) + ); + } finally { + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + } + }; + + it('uses AI credits only when no custom provider is selected', async () => { + mock.onPost('/ai-request').reply(config => { + const data = JSON.parse(config.data); + expect(config.params).toEqual({ userId: 'user-1' }); + expect(config.headers.Authorization).toBe('Bearer test-token'); + expect(data.aiConfiguration).toEqual({ presetId: 'default' }); + expect(data.providerConfigurationId).toBe(null); + expect(data.payWithCredits).toBe(false); + expect(data.payWithAiCredits).toBe(true); + return [200, { id: 'ai-request-1' }]; + }); + + await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { presetId: 'default' }, + }); + }); + + it('sends saved custom provider requests to the backend so build modes can use tools', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => [200, mockProviderConfiguration]); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + + mock.reset(); + mock.onPost('/ai-request').reply(config => { + const data = JSON.parse(config.data); + expect(config.params).toEqual({ userId: 'user-1' }); + expect(config.headers.Authorization).toBe('Bearer test-token'); + expect(data.mode).toBe('agent'); + expect(data.aiConfiguration).toEqual({ + presetId: 'default', + providerConfigurationId: configuration.id, + }); + expect(data.providerConfigurationId).toBe(configuration.id); + expect(data.payWithCredits).toBe(false); + expect(data.payWithAiCredits).toBe(false); + expect(data.aiConfiguration.providerConfiguration).toBeUndefined(); + return [ + 200, + { + id: 'ai-request-1', + aiConfiguration: data.aiConfiguration, + }, + ]; + }); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + mode: 'agent', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + + expect(mock.history.post).toHaveLength(1); + expect(providerMock.history.post).toHaveLength(0); + expect(aiRequest.id).toBe('ai-request-1'); + expect(aiRequest.aiConfiguration).toEqual({ + presetId: 'default', + providerConfigurationId: configuration.id, + }); + + mock.onDelete('/ai-provider-configuration/provider-1').reply(() => [204]); + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('sends localhost custom provider requests directly to the local endpoint', async () => { + const getAuthorizationHeaderThatShouldNotRun = (jest.fn(async () => { + throw new Error( + 'Localhost providers should not request authorization.' + ); + }): any); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeaderThatShouldNotRun, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .reply(config => { + expect(config.headers.Authorization).toBe('Bearer sk-local'); + expect(JSON.parse(config.data)).toEqual( + expect.objectContaining({ + model: 'gpt-5.5', + reasoning_effort: 'high', + messages: expect.arrayContaining([ + { role: 'user', content: 'Build a platformer' }, + ]), + }) + ); + return [ + 200, + { + choices: [{ message: { content: 'pong' } }], + }, + ]; + }); + + const aiRequest = await createAiRequest( + getAuthorizationHeaderThatShouldNotRun, + { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + } + ); + + expect(configuration.id).toContain('local-'); + expect(aiRequest.id).toContain('local-custom-provider-ai-request-'); + expect(getAuthorizationHeaderThatShouldNotRun).not.toHaveBeenCalled(); + expect(mock.history.post).toHaveLength(0); + expect(providerMock.history.post).toHaveLength(1); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('suspends local custom provider requests without calling the backend', async () => { + const getAuthorizationHeaderThatShouldNotRun = (jest.fn(async () => { + throw new Error( + 'Local Custom Model requests should not request authorization.' + ); + }): any); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeaderThatShouldNotRun, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + let resolveSecondReply: any = null; + let resolveSecondReplyStarted: any = null; + const secondReplyStarted = new Promise(resolve => { + resolveSecondReplyStarted = resolve; + }); + + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [{ message: { content: 'first answer' } }], + }, + ]) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce( + () => + new Promise(resolve => { + resolveSecondReply = resolve; + resolveSecondReplyStarted(); + }) + ); + + const aiRequest = await createAiRequest( + getAuthorizationHeaderThatShouldNotRun, + { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + } + ); + const addMessagePromise = addMessageToAiRequest( + getAuthorizationHeaderThatShouldNotRun, + { + userId: 'user-1', + aiRequestId: aiRequest.id, + userMessage: 'Second message', + gameId: undefined, + functionCallOutputs: [], + payWithCredits: false, + gameProjectJson: null, + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + aiConfiguration: aiRequest.aiConfiguration, + } + ); + await secondReplyStarted; + + const suspendedRequest = await suspendAiRequest( + getAuthorizationHeaderThatShouldNotRun, + { + userId: 'user-1', + aiRequestId: aiRequest.id, + } + ); + expect(suspendedRequest.status).toBe('suspended'); + expect(mock.history.post).toHaveLength(0); + + resolveSecondReply([ + 200, + { + choices: [{ message: { content: 'late answer' } }], + }, + ]); + const resolvedRequest = await addMessagePromise; + + expect(resolvedRequest.status).toBe('suspended'); + expect(resolvedRequest.output).toHaveLength( + (aiRequest.output || []).length + ); + expect(JSON.stringify(resolvedRequest.output)).not.toContain( + 'Second message' + ); + expect(getAuthorizationHeaderThatShouldNotRun).not.toHaveBeenCalled(); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('stores a working local custom provider request before the first provider response', async () => { + const getAuthorizationHeaderThatShouldNotRun = (jest.fn(async () => { + throw new Error( + 'Local Custom Model requests should not request authorization.' + ); + }): any); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeaderThatShouldNotRun, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + let resolveReply: any = null; + let createdLocalRequest: any = null; + const firstReplyStarted = new Promise(resolve => { + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce( + () => + new Promise(providerResolve => { + resolveReply = providerResolve; + resolve(); + }) + ); + }); + + const createRequestPromise = createAiRequest( + getAuthorizationHeaderThatShouldNotRun, + { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + onLocalAiRequestCreated: aiRequest => { + createdLocalRequest = aiRequest; + }, + } + ); + await firstReplyStarted; + + expect(createdLocalRequest).toEqual( + expect.objectContaining({ + id: expect.stringContaining('local-custom-provider-ai-request-'), + status: 'working', + }) + ); + await expect( + getAiRequest(getAuthorizationHeaderThatShouldNotRun, { + userId: 'user-1', + aiRequestId: createdLocalRequest.id, + }) + ).resolves.toEqual(expect.objectContaining({ status: 'working' })); + + const suspendedRequest = await suspendAiRequest( + getAuthorizationHeaderThatShouldNotRun, + { + userId: 'user-1', + aiRequestId: createdLocalRequest.id, + } + ); + expect(suspendedRequest.status).toBe('suspended'); + + resolveReply([ + 200, + { + choices: [{ message: { content: 'late answer' } }], + }, + ]); + const resolvedRequest = await createRequestPromise; + + expect(resolvedRequest.status).toBe('suspended'); + expect(resolvedRequest.output).toHaveLength(1); + expect(JSON.stringify(resolvedRequest.output)).not.toContain( + 'late answer' + ); + expect(getAuthorizationHeaderThatShouldNotRun).not.toHaveBeenCalled(); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('forks local custom provider requests by trimming messages', async () => { + const getAuthorizationHeaderThatShouldNotRun = (jest.fn(async () => { + throw new Error( + 'Local Custom Model requests should not request authorization.' + ); + }): any); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeaderThatShouldNotRun, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [{ message: { content: 'first answer' } }], + }, + ]) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(JSON.stringify(data.messages)).toContain('Second message'); + return [ + 200, + { + choices: [{ message: { content: 'second answer' } }], + }, + ]; + }) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + const messages = JSON.stringify(data.messages); + expect(messages).toContain('Build a platformer'); + expect(messages).toContain('Follow up after restore'); + expect(messages).not.toContain('Second message'); + return [ + 200, + { + choices: [{ message: { content: 'fork answer' } }], + }, + ]; + }); + + const aiRequest = await createAiRequest( + getAuthorizationHeaderThatShouldNotRun, + { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + } + ); + const aiRequestWithSecondMessage = await addMessageToAiRequest( + getAuthorizationHeaderThatShouldNotRun, + { + userId: 'user-1', + aiRequestId: aiRequest.id, + userMessage: 'Second message', + gameId: undefined, + functionCallOutputs: [], + payWithCredits: false, + gameProjectJson: null, + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + aiConfiguration: aiRequest.aiConfiguration, + } + ); + const firstAssistantMessage = + aiRequest.output && aiRequest.output.length > 1 + ? aiRequest.output[1] + : null; + if (!firstAssistantMessage || !firstAssistantMessage.messageId) { + throw new Error('Expected the first local assistant message to exist.'); + } + + const forkedRequest = await forkAiRequest( + getAuthorizationHeaderThatShouldNotRun, + { + userId: 'user-1', + aiRequestId: aiRequestWithSecondMessage.id, + upToMessageId: firstAssistantMessage.messageId, + } + ); + + expect(forkedRequest.id).not.toBe(aiRequest.id); + expect(forkedRequest.id).toContain('local-custom-provider-ai-request-'); + expect(forkedRequest.forkedFromAiRequestId).toBe(aiRequest.id); + expect(forkedRequest.forkedAfterOriginalMessageId).toBe( + firstAssistantMessage.messageId + ); + expect(forkedRequest.forkedAfterNewMessageId).toBe( + firstAssistantMessage.messageId + ); + expect(forkedRequest.output).toHaveLength(2); + + await addMessageToAiRequest(getAuthorizationHeaderThatShouldNotRun, { + userId: 'user-1', + aiRequestId: forkedRequest.id, + userMessage: 'Follow up after restore', + gameId: undefined, + functionCallOutputs: [], + payWithCredits: false, + gameProjectJson: null, + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + aiConfiguration: forkedRequest.aiConfiguration, + }); + + expect(mock.history.post).toHaveLength(0); + expect(getAuthorizationHeaderThatShouldNotRun).not.toHaveBeenCalled(); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('omits reasoning effort for local custom provider requests on auto', async () => { + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: { + ...localProviderPayload, + reasoningEffort: null, + }, + } + ); + + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .reply(config => { + const data = JSON.parse(config.data); + expect(data.reasoning_effort).toBeUndefined(); + return [ + 200, + { + choices: [{ message: { content: 'pong' } }], + }, + ]; + }); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + + expect(providerMock.history.post).toHaveLength(1); + expect(aiRequest.output && aiRequest.output[1]).toEqual( + expect.objectContaining({ + role: 'assistant', + }) + ); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('retries local custom provider requests without temperature when unsupported', async () => { + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.temperature).toBe(0.2); + expect(data.reasoning_effort).toBe('high'); + return [ + 400, + { + error: { + message: + "Error code: 400 - {'error': {'message': \"Unsupported parameter: 'temperature'\", 'type': 'invalid_request_error', 'param': 'temperature', 'code': 'unsupported_parameter'}}", + type: 'api_error', + }, + }, + ]; + }) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.temperature).toBeUndefined(); + expect(data.reasoning_effort).toBe('high'); + return [ + 200, + { + choices: [{ message: { content: 'pong' } }], + }, + ]; + }); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + + expect(providerMock.history.post).toHaveLength(2); + expect(aiRequest.output && aiRequest.output[1]).toEqual( + expect.objectContaining({ + role: 'assistant', + content: [ + { + type: 'output_text', + status: 'completed', + text: 'pong', + annotations: [], + }, + ], + }) + ); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('retries local custom provider requests without temperature for raw string errors', async () => { + await expectLocalProviderRequestToRetryWithoutTemperature( + JSON.stringify({ + error: { + message: 'Unsupported parameter: temperature', + type: 'invalid_request_error', + param: 'temperature', + code: 'unsupported_parameter', + }, + }) + ); + }); + + it('retries local custom provider requests without temperature for unquoted parameter errors', async () => { + await expectLocalProviderRequestToRetryWithoutTemperature({ + error: { + message: 'Unsupported parameter: temperature', + type: 'invalid_request_error', + }, + }); + }); + + it('retries local custom provider requests without reasoning effort when unsupported', async () => { + await expectLocalProviderRequestToRetryWithoutReasoningEffort({ + error: { + message: "Unsupported parameter: 'reasoning_effort'", + type: 'invalid_request_error', + param: 'reasoning_effort', + code: 'unsupported_parameter', + }, + }); + }); + + it('retries local custom provider requests without reasoning effort for unrecognized parameter errors', async () => { + await expectLocalProviderRequestToRetryWithoutReasoningEffort({ + error: { + message: 'Unrecognized request argument supplied: reasoning_effort', + type: 'invalid_request_error', + }, + }); + }); + + it('caches unsupported temperature for later local custom provider requests', async () => { + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + try { + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.temperature).toBe(0.2); + return [ + 400, + { + error: { + message: "Unsupported parameter: 'temperature'", + param: 'temperature', + code: 'unsupported_parameter', + }, + }, + ]; + }) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.temperature).toBeUndefined(); + return [200, { choices: [{ message: { content: 'pong' } }] }]; + }) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.temperature).toBeUndefined(); + return [200, { choices: [{ message: { content: 'pong 2' } }] }]; + }); + + await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + userRequest: 'Again', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + + expect(providerMock.history.post).toHaveLength(3); + } finally { + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + } + }); + + it('caches unsupported reasoning effort for later local custom provider requests', async () => { + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + try { + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.reasoning_effort).toBe('high'); + return [ + 400, + { + error: { + message: + 'Unrecognized request argument supplied: reasoning_effort', + }, + }, + ]; + }) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.reasoning_effort).toBeUndefined(); + return [200, { choices: [{ message: { content: 'pong' } }] }]; + }) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.reasoning_effort).toBeUndefined(); + return [200, { choices: [{ message: { content: 'pong 2' } }] }]; + }); + + await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + userRequest: 'Again', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + + expect(providerMock.history.post).toHaveLength(3); + } finally { + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + } + }); + + it('retries and caches unsupported max tokens for local custom provider requests', async () => { + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + try { + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.max_tokens).toBe(1000); + return [ + 400, + { + error: { + message: "Unsupported parameter: 'max_tokens'", + param: 'max_tokens', + code: 'unsupported_parameter', + }, + }, + ]; + }) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.max_tokens).toBeUndefined(); + return [200, { choices: [{ message: { content: 'pong' } }] }]; + }) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.max_tokens).toBeUndefined(); + return [200, { choices: [{ message: { content: 'pong 2' } }] }]; + }); + + await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + userRequest: 'Again', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + + expect(providerMock.history.post).toHaveLength(3); + } finally { + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + } + }); + + it('caches unsupported native tools and tool choice for local Build requests', async () => { + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + try { + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.tools).toBeDefined(); + expect(data.tool_choice).toBe('auto'); + return [ + 400, + { + error: { + message: "Unsupported parameter: 'tools'", + param: 'tools', + code: 'unsupported_parameter', + }, + }, + ]; + }) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.tools).toBeUndefined(); + expect(data.tool_choice).toBeUndefined(); + expect(data.messages[data.messages.length - 1].content).toContain( + 'Reply only with a JSON object' + ); + return [ + 200, + { + choices: [ + { + message: { + content: + '{"tool_calls":[{"name":"create_scene","arguments":{"scene_name":"Game"}}]}', + }, + }, + ], + }, + ]; + }) + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.tools).toBeUndefined(); + expect(data.tool_choice).toBeUndefined(); + expect(data.messages[data.messages.length - 1].content).toContain( + 'Reply only with a JSON object' + ); + return [ + 200, + { + choices: [ + { + message: { + content: + '{"tool_calls":[{"name":"create_scene","arguments":{"scene_name":"Game2"}}]}', + }, + }, + ], + }, + ]; + }); + + await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + userRequest: 'make pong', + mode: 'agent', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + userRequest: 'make pong again', + mode: 'agent', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + + expect(providerMock.history.post).toHaveLength(3); + } finally { + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + } + }); + + it('does not retry local custom provider requests without temperature for unrelated errors', async () => { + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + try { + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.temperature).toBe(0.2); + return [ + 400, + { + error: { + message: 'The selected model is unavailable.', + type: 'invalid_request_error', + }, + }, + ]; + }); + + await expect( + createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }) + ).rejects.toThrow(); + + expect(providerMock.history.post).toHaveLength(1); + } finally { + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + } + }); + + it('fails clearly when a direct Custom Model chat completion times out', async () => { + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + try { + providerMock + .onPost('http://127.0.0.1:18080/chat/completions') + .replyOnce(() => + Promise.reject({ + code: 'ECONNABORTED', + message: 'timeout of 180000ms exceeded', + }) + ); + + await expect( + createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }) + ).rejects.toThrow('Custom Model did not respond within 180 seconds'); + } finally { + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + } + }); + + it('adds messages directly to local custom provider requests', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + + mock.reset(); + providerMock + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [{ message: { content: 'pong' } }], + }, + ]) + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.messages).toEqual([ + expect.objectContaining({ role: 'system' }), + { role: 'user', content: 'Build a platformer' }, + { role: 'assistant', content: 'pong' }, + { role: 'user', content: 'Continue' }, + ]); + return [ + 200, + { + choices: [{ message: { content: 'continued' } }], + }, + ]; + }); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + const updatedAiRequest = await addMessageToAiRequest( + getAuthorizationHeader, + { + userId: 'user-1', + aiRequestId: aiRequest.id, + userMessage: 'Continue', + functionCallOutputs: [], + gameProjectJson: null, + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + payWithCredits: false, + aiConfiguration: aiRequest.aiConfiguration, + toolsVersion: 'test-tools', + } + ); + + expect(providerMock.history.post).toHaveLength(2); + expect(updatedAiRequest.output).toHaveLength(4); + expect(updatedAiRequest.output && updatedAiRequest.output[3]).toEqual( + expect.objectContaining({ + role: 'assistant', + content: [ + { + type: 'output_text', + status: 'completed', + text: 'continued', + annotations: [], + }, + ], + }) + ); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('turns local Build tool calls into executable AI request function calls', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + + mock.reset(); + providerMock + .onPost('https://api.openai.com/v1/chat/completions') + .reply(config => { + const data = JSON.parse(config.data); + expect(data.tool_choice).toBe('auto'); + expect(data.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + function: expect.objectContaining({ + name: 'create_scene', + }), + }), + ]) + ); + expect(data.messages[0].content).toContain( + 'must use the available tools' + ); + return [ + 200, + { + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'call-create-scene', + type: 'function', + function: { + name: 'create_scene', + arguments: '{"scene_name":"Game"}', + }, + }, + ], + }, + }, + ], + }, + ]; + }); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + userRequest: 'make pong', + mode: 'agent', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + + expect(aiRequest.output && aiRequest.output[1]).toEqual( + expect.objectContaining({ + role: 'assistant', + content: [ + { + type: 'function_call', + status: 'completed', + call_id: 'call-create-scene', + name: 'create_scene', + arguments: '{"scene_name":"Game"}', + }, + ], + }) + ); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('sends local tool results as tool messages and accepts empty user text', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + + mock.reset(); + providerMock + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'call-create-scene', + type: 'function', + function: { + name: 'create_scene', + arguments: '{"scene_name":"Game"}', + }, + }, + ], + }, + }, + ], + }, + ]) + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + const put2dInstancesTool = data.tools.find( + tool => tool.function.name === 'put_2d_instances' + ); + expect(put2dInstancesTool.function.parameters.required).toContain( + 'object_name' + ); + expect(data.messages).toEqual([ + expect.objectContaining({ role: 'system' }), + { role: 'user', content: 'make pong' }, + { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call-create-scene', + type: 'function', + function: { + name: 'create_scene', + arguments: '{"scene_name":"Game"}', + }, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call-create-scene', + content: '{"ok":true}', + }, + ]); + return [ + 200, + { + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'call-place-ball', + type: 'function', + function: { + name: 'put_2d_instances', + arguments: + '{"scene_name":"Game","layer_name":"","brush_kind":"put","object_name":"Ball"}', + }, + }, + ], + }, + }, + ], + }, + ]; + }); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + userRequest: 'make pong', + mode: 'agent', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + const updatedAiRequest = await addMessageToAiRequest( + getAuthorizationHeader, + { + userId: 'user-1', + aiRequestId: aiRequest.id, + userMessage: '', + functionCallOutputs: [ + { + type: 'function_call_output', + call_id: 'call-create-scene', + output: '{"ok":true}', + }, + ], + gameProjectJson: null, + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + payWithCredits: false, + aiConfiguration: aiRequest.aiConfiguration, + mode: 'agent', + toolsVersion: 'test-tools', + } + ); + + expect(providerMock.history.post).toHaveLength(2); + expect(updatedAiRequest.output).toHaveLength(4); + expect(updatedAiRequest.output && updatedAiRequest.output[2]).toEqual( + expect.objectContaining({ + type: 'function_call_output', + call_id: 'call-create-scene', + }) + ); + expect(updatedAiRequest.output && updatedAiRequest.output[3]).toEqual( + expect.objectContaining({ + role: 'assistant', + content: [ + { + type: 'function_call', + status: 'completed', + call_id: 'call-place-ball', + name: 'put_2d_instances', + arguments: + '{"scene_name":"Game","layer_name":"","brush_kind":"put","object_name":"Ball"}', + }, + ], + }) + ); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('accepts local Build text responses after tool results are sent', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + + try { + mock.reset(); + providerMock + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'call-create-scene', + type: 'function', + function: { + name: 'create_scene', + arguments: '{"scene_name":"Game"}', + }, + }, + ], + }, + }, + ], + }, + ]) + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.tools).toBeDefined(); + expect(data.tool_choice).toBe('auto'); + return [ + 200, + { + choices: [ + { + message: { + content: 'Done - I made the scene.', + }, + }, + ], + }, + ]; + }); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + userRequest: 'make pong', + mode: 'agent', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + const updatedAiRequest = await addMessageToAiRequest( + getAuthorizationHeader, + { + userId: 'user-1', + aiRequestId: aiRequest.id, + userMessage: '', + functionCallOutputs: [ + { + type: 'function_call_output', + call_id: 'call-create-scene', + output: '{"ok":true}', + }, + ], + gameProjectJson: null, + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + payWithCredits: false, + aiConfiguration: aiRequest.aiConfiguration, + mode: 'agent', + toolsVersion: 'test-tools', + } + ); + + expect(providerMock.history.post).toHaveLength(2); + expect(updatedAiRequest.output).toHaveLength(4); + expect(updatedAiRequest.output && updatedAiRequest.output[3]).toEqual( + expect.objectContaining({ + role: 'assistant', + content: [ + { + type: 'output_text', + status: 'completed', + text: 'Done - I made the scene.', + annotations: [], + }, + ], + }) + ); + } finally { + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + } + }); + + it('accepts local Build fallback text responses after tool results are sent', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + + try { + mock.reset(); + providerMock + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'call-create-scene', + type: 'function', + function: { + name: 'create_scene', + arguments: '{"scene_name":"Game"}', + }, + }, + ], + }, + }, + ], + }, + ]) + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(() => [ + 400, + { + error: { + message: "Unsupported parameter: 'tools'", + param: 'tools', + code: 'unsupported_parameter', + }, + }, + ]) + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.tools).toBeUndefined(); + expect(data.messages.some(message => message.role === 'tool')).toBe( + false + ); + expect(data.messages.some(message => message.tool_calls)).toBe( + false + ); + expect(data.messages[data.messages.length - 1].content).toContain( + 'Reply only with a JSON object' + ); + return [ + 200, + { + choices: [ + { + message: { + content: 'Done - I made the scene.', + }, + }, + ], + }, + ]; + }); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + userRequest: 'make pong', + mode: 'agent', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + const updatedAiRequest = await addMessageToAiRequest( + getAuthorizationHeader, + { + userId: 'user-1', + aiRequestId: aiRequest.id, + userMessage: '', + functionCallOutputs: [ + { + type: 'function_call_output', + call_id: 'call-create-scene', + output: '{"ok":true}', + }, + ], + gameProjectJson: null, + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + payWithCredits: false, + aiConfiguration: aiRequest.aiConfiguration, + mode: 'agent', + toolsVersion: 'test-tools', + } + ); + + expect(providerMock.history.post).toHaveLength(3); + expect(updatedAiRequest.output).toHaveLength(4); + expect(updatedAiRequest.output && updatedAiRequest.output[3]).toEqual( + expect.objectContaining({ + role: 'assistant', + content: [ + { + type: 'output_text', + status: 'completed', + text: 'Done - I made the scene.', + annotations: [], + }, + ], + }) + ); + } finally { + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + } + }); + + it('retries local Build text responses with strict JSON tool-call mode', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + + mock.reset(); + providerMock + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [{ message: { content: 'Create a scene named Game.' } }], + }, + ]) + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.tools).toBeUndefined(); + expect(data.messages[data.messages.length - 1].content).toContain( + 'Reply only with a JSON object' + ); + return [ + 200, + { + choices: [ + { + message: { + content: JSON.stringify({ + tool_calls: [ + { + name: 'create_scene', + arguments: { scene_name: 'Game' }, + }, + ], + }), + }, + }, + ], + }, + ]; + }); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + userRequest: 'make pong', + mode: 'agent', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + + expect(providerMock.history.post).toHaveLength(2); + expect(aiRequest.output && aiRequest.output[1]).toEqual( + expect.objectContaining({ + role: 'assistant', + content: [ + expect.objectContaining({ + type: 'function_call', + name: 'create_scene', + arguments: '{"scene_name":"Game"}', + }), + ], + }) + ); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('errors clearly when local Build responses do not contain tool calls', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + + mock.reset(); + providerMock + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [{ message: { content: 'Here are setup steps.' } }], + }, + ]) + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [{ message: { content: '{"tool_calls":[]}' } }], + }, + ]); + + await expect( + createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + userRequest: 'make pong', + mode: 'agent', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }) + ).rejects.toThrow('Custom Model did not return any tool calls'); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('generates local event changes with the same Custom Model provider', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + + mock.reset(); + providerMock + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [{ message: { content: 'ok' } }], + }, + ]) + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.messages[0].content).toContain( + 'GDevelop scene event changes' + ); + expect(data.max_tokens).toBeUndefined(); + return [ + 200, + { + choices: [ + { + message: { + content: JSON.stringify({ + resultMessage: 'Added events.', + changes: [ + { + operationName: 'insert_at_end', + generatedEvents: '[]', + }, + ], + }), + }, + }, + ], + }, + ]; + }) + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [{ message: { content: 'not json' } }], + }, + ]) + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [{ message: { content: 'still not json' } }], + }, + ]); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + userRequest: 'continue', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + const result = await createAiGeneratedEvent(getAuthorizationHeader, { + userId: 'user-1', + gameProjectJson: '{"layouts":[]}', + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + sceneName: 'Game', + eventsDescription: 'Move the ball and bounce on paddles.', + extensionNamesList: '', + objectsList: 'Ball, PlayerPaddle, AIPaddle', + existingEventsAsText: '', + existingEventsJson: '[]', + existingEventsJsonUserRelativeKey: null, + placementHint: 'insert_at_end', + relatedAiRequestId: aiRequest.id, + estimatedComplexity: 3, + }); + + if (!result.creationSucceeded) { + throw new Error(result.errorMessage); + } + expect(result.aiGeneratedEvent.changes).toEqual([ + expect.objectContaining({ + operationName: 'insert_at_end', + generatedEvents: '[]', + isEventsJsonValid: true, + }), + ]); + await expect( + getAiGeneratedEvent(getAuthorizationHeader, { + userId: 'user-1', + aiGeneratedEventId: result.aiGeneratedEvent.id, + }) + ).resolves.toEqual(result.aiGeneratedEvent); + + await expect( + createAiGeneratedEvent(getAuthorizationHeader, { + userId: 'user-1', + gameProjectJson: '{"layouts":[]}', + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + sceneName: 'Game', + eventsDescription: 'Move the ball.', + extensionNamesList: '', + objectsList: 'Ball', + existingEventsAsText: '', + existingEventsJson: '[]', + existingEventsJsonUserRelativeKey: null, + placementHint: 'insert_at_end', + relatedAiRequestId: aiRequest.id, + estimatedComplexity: 1, + }) + ).resolves.toEqual({ + creationSucceeded: false, + errorMessage: expect.stringContaining( + 'Custom Model returned invalid JSON' + ), + }); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('repairs malformed local event generation once and returns the corrected changes', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + + mock.reset(); + providerMock + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(() => [ + 200, + { + choices: [{ message: { content: 'ok' } }], + }, + ]) + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.max_tokens).toBeUndefined(); + return [ + 200, + { + choices: [{ message: { content: 'not json' } }], + }, + ]; + }) + .onPost('https://api.openai.com/v1/chat/completions') + .replyOnce(config => { + const data = JSON.parse(config.data); + expect(data.max_tokens).toBeUndefined(); + expect(data.messages[0].content).toContain( + 'previous event response failed validation' + ); + expect(data.messages[1].content).toContain( + 'Custom Model returned invalid JSON' + ); + return [ + 200, + { + choices: [ + { + message: { + content: JSON.stringify({ + resultMessage: 'Repaired events.', + changes: [ + { + operationName: 'insert_at_end', + generatedEvents: '[]', + }, + ], + }), + }, + }, + ], + }, + ]; + }); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + const result = await createAiGeneratedEvent(getAuthorizationHeader, { + userId: 'user-1', + gameProjectJson: '{"layouts":[]}', + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + sceneName: 'Game', + eventsDescription: 'Move the ball.', + extensionNamesList: '', + objectsList: 'Ball', + existingEventsAsText: '', + existingEventsJson: '[]', + existingEventsJsonUserRelativeKey: null, + placementHint: 'insert_at_end', + relatedAiRequestId: aiRequest.id, + estimatedComplexity: 1, + }); + + expect(result).toEqual({ + creationSucceeded: true, + aiGeneratedEvent: expect.objectContaining({ + resultMessage: 'Repaired events.', + changes: [ + expect.objectContaining({ + operationName: 'insert_at_end', + generatedEvents: '[]', + }), + ], + }), + }); + expect(providerMock.history.post).toHaveLength(3); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('sends saved custom provider follow-up messages to the backend', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => [200, mockProviderConfiguration]); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + + mock.reset(); + mock + .onPost('/ai-request/ai-request-1/action/add-message') + .reply(config => { + const data = JSON.parse(config.data); + expect(config.params).toEqual({ userId: 'user-1' }); + expect(config.headers.Authorization).toBe('Bearer test-token'); + expect(data.providerConfigurationId).toBe(configuration.id); + expect(data.payWithCredits).toBe(false); + expect(data.payWithAiCredits).toBe(false); + expect(data.aiConfiguration).toEqual({ + presetId: 'default', + providerConfigurationId: configuration.id, + }); + expect(data.aiConfiguration.providerConfiguration).toBeUndefined(); + return [200, { id: 'ai-request-1' }]; + }); + + const updatedAiRequest = await addMessageToAiRequest( + getAuthorizationHeader, + { + userId: 'user-1', + aiRequestId: 'ai-request-1', + userMessage: 'Continue', + functionCallOutputs: [], + gameProjectJson: null, + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + payWithCredits: false, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + toolsVersion: 'test-tools', + } + ); + + expect(updatedAiRequest.id).toBe('ai-request-1'); + expect(mock.history.post).toHaveLength(1); + expect(providerMock.history.post).toHaveLength(0); + + mock.onDelete('/ai-provider-configuration/provider-1').reply(() => [204]); + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('handles cloud-only follow-up actions locally for custom provider requests', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + + mock.reset(); + providerMock + .onPost('https://api.openai.com/v1/chat/completions') + .reply(() => [ + 200, + { + choices: [{ message: { content: 'pong' } }], + }, + ]); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + mode: 'chat', + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + const assistantMessage = + aiRequest.output && aiRequest.output.length > 1 + ? aiRequest.output[1] + : null; + if (!assistantMessage || !assistantMessage.messageId) { + throw new Error('Expected an assistant message with an id.'); + } + + await updateAiRequestMessage(getAuthorizationHeader, { + userId: 'user-1', + aiRequestId: aiRequest.id, + aiRequestMessageId: assistantMessage.messageId, + projectVersionIdAfterMessage: 'version-1', + }); + + const updatedAiRequest = await getAiRequest(getAuthorizationHeader, { + userId: 'user-1', + aiRequestId: aiRequest.id, + }); + + expect(updatedAiRequest.output && updatedAiRequest.output[1]).toEqual( + expect.objectContaining({ + projectVersionIdAfterMessage: 'version-1', + }) + ); + await expect( + getAiRequestSuggestions(getAuthorizationHeader, { + userId: 'user-1', + aiRequestId: aiRequest.id, + suggestionsType: 'simple-list', + gameProjectJson: null, + gameProjectJsonUserRelativeKey: null, + projectSpecificExtensionsSummaryJson: null, + projectSpecificExtensionsSummaryJsonUserRelativeKey: null, + }) + ).resolves.toEqual(updatedAiRequest); + expect(mock.history.patch).toHaveLength(0); + expect(mock.history.post).toHaveLength(0); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('requires local provider settings when a local custom provider is selected', async () => { + await expect( + createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: 'local-missing-provider', + }, + }) + ).rejects.toThrow( + 'Custom Model needs the provider API key saved locally' + ); + + expect(mock.history.post).toHaveLength(0); + expect(providerMock.history.post).toHaveLength(0); + }); + + it('detects provider configuration route availability errors', () => { + expect( + isAiProviderConfigurationRouteUnavailableError({ + response: { + status: 403, + headers: { + 'x-amzn-ErrorType': 'IncompleteSignatureException', + }, + data: { + message: + "Invalid key=value pair (missing equal-sign) in Authorization header: 'Bearer test-token'.", + }, + }, + }) + ).toBe(true); + + expect( + isAiProviderConfigurationRouteUnavailableError({ + request: {}, + message: 'Network Error', + }) + ).toBe(false); + + expect( + isAiProviderConfigurationRouteUnavailableError({ + response: { + status: 401, + data: { + code: 'AUTHENTICATION_FAILED', + }, + }, + }) + ).toBe(false); + expect( + isAiProviderConfigurationRouteUnavailableError({ + response: { + status: 403, + data: { + code: 'AUTHENTICATION_FAILED', + message: 'Authentication failed.', + }, + }, + }) + ).toBe(false); + }); + + it('preserves a custom provider configuration when the API response omits it', () => { + expect( + getAiRequestWithPreservedAiConfiguration({ + aiRequest: { + id: 'ai-request-1', + createdAt: '2026-05-15T10:00:00.000Z', + updatedAt: '2026-05-15T10:00:00.000Z', + userId: 'user-1', + status: 'working', + error: null, + }, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: 'provider-1', + }, + }).aiConfiguration + ).toEqual({ + presetId: 'default', + providerConfigurationId: 'provider-1', + }); + }); + + it('keeps the provider configuration returned by the API', () => { + expect( + getAiRequestWithPreservedAiConfiguration({ + aiRequest: { + id: 'ai-request-1', + createdAt: '2026-05-15T10:00:00.000Z', + updatedAt: '2026-05-15T10:00:00.000Z', + userId: 'user-1', + status: 'working', + error: null, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: 'provider-2', + }, + }, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: 'provider-1', + }, + }).aiConfiguration + ).toEqual({ + presetId: 'default', + providerConfigurationId: 'provider-2', + }); + }); + + it('detects provider-unavailable API errors', () => { + expect( + isAiProviderUnavailableError({ + response: { + status: 409, + data: { + code: 'AI_PROVIDER_UNAVAILABLE', + }, + }, + }) + ).toBe(true); + expect( + isAiProviderUnavailableError({ + response: { + status: 500, + data: { + code: 'INTERNAL_SERVER_ERROR', + }, + }, + }) + ).toBe(false); + }); + }); + + describe('AI provider configurations', () => { + it('detects loopback provider base URLs', () => { + expect(isLocalAiProviderBaseUrl('http://localhost:18080/')).toBe(true); + expect(isLocalAiProviderBaseUrl('https://127.0.0.1/v1')).toBe(true); + expect(isLocalAiProviderBaseUrl('http://[::1]:18080/')).toBe(true); + expect(isLocalAiProviderBaseUrl('https://api.openai.com/v1')).toBe(false); + expect(isLocalAiProviderBaseUrl('not-a-url')).toBe(false); + }); + + it('stores localhost provider configurations locally without using backend auth', async () => { + const getAuthorizationHeaderThatShouldNotRun = (jest.fn(async () => { + throw new Error( + 'Localhost providers should not request authorization.' + ); + }): any); + + const configuration = await createAiProviderConfiguration( + getAuthorizationHeaderThatShouldNotRun, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + expect(configuration.id).toContain('local-'); + expect(configuration.baseUrl).toBe('http://127.0.0.1:18080/'); + expect(configuration.reasoningEffort).toBe('high'); + expect(getAuthorizationHeaderThatShouldNotRun).not.toHaveBeenCalled(); + expect(mock.history.post).toHaveLength(0); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('turns a remote provider update to localhost into a new local provider', async () => { + const getAuthorizationHeaderThatShouldNotRun = (jest.fn(async () => { + throw new Error( + 'Localhost providers should not request authorization.' + ); + }): any); + + const configuration = await updateAiProviderConfiguration( + getAuthorizationHeaderThatShouldNotRun, + { + userId: 'user-1', + providerConfigurationId: 'provider-1', + configuration: localProviderPayload, + } + ); + + expect(configuration.id).toContain('local-'); + expect(configuration.reasoningEffort).toBe('high'); + expect(getAuthorizationHeaderThatShouldNotRun).not.toHaveBeenCalled(); + expect(mock.history.patch).toHaveLength(0); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('turns a local provider update to a remote URL into a backend provider', async () => { + const getAuthorizationHeaderThatShouldNotRun = (jest.fn(async () => { + throw new Error( + 'Localhost providers should not request authorization.' + ); + }): any); + const localConfiguration = await createAiProviderConfiguration( + getAuthorizationHeaderThatShouldNotRun, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + mock.onPost('/ai-provider-configuration').reply(config => { + expect(config.params).toEqual({ userId: 'user-1' }); + expect(config.headers.Authorization).toBe('Bearer test-token'); + expect(JSON.parse(config.data)).toEqual({ + ...providerPayloadWithoutApiKey, + apiKey: 'sk-local', + }); + return [200, mockProviderConfiguration]; + }); + + const configuration = await updateAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + providerConfigurationId: localConfiguration.id, + configuration: providerPayloadWithoutApiKey, + } + ); + + expect(configuration).toEqual(mockProviderConfiguration); + expect(mock.history.post).toHaveLength(1); + + mock.reset(); + mock.onGet('/ai-provider-configuration').reply(() => [200, []]); + await expect( + listAiProviderConfigurations(getAuthorizationHeader, { + userId: 'user-1', + }) + ).resolves.not.toContainEqual(localConfiguration); + }); + + it('turns a route-unavailable remote provider update into a usable local provider', async () => { + mock + .onPatch('/ai-provider-configuration/provider-1') + .reply(() => routeUnavailableResponse); + const configuration = await updateAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + providerConfigurationId: 'provider-1', + configuration: providerPayload, + } + ); + + expect(configuration.id).toContain('local-'); + expect(configuration.id).not.toBe('provider-1'); + + providerMock + .onPost('https://api.openai.com/v1/chat/completions') + .reply(() => [ + 200, + { + choices: [{ message: { content: 'pong' } }], + }, + ]); + + const aiRequest = await createAiRequest(getAuthorizationHeader, { + ...baseAiRequestArgs, + aiConfiguration: { + presetId: 'default', + providerConfigurationId: configuration.id, + }, + }); + + expect(aiRequest.id).toContain('local-custom-provider-ai-request-'); + expect(providerMock.history.post).toHaveLength(1); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('lists backend provider configurations with local-only configurations', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => [200, mockProviderConfiguration]); + const backendConfiguration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + const localConfiguration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + mock.reset(); + mock + .onGet('/ai-provider-configuration') + .reply(() => [200, [mockProviderConfiguration]]); + const configurations = await listAiProviderConfigurations( + getAuthorizationHeader, + { userId: 'user-1' } + ); + + expect(configurations).toContainEqual(mockProviderConfiguration); + expect(configurations).toContainEqual(localConfiguration); + expect( + configurations.filter( + configuration => configuration.id === backendConfiguration.id + ) + ).toHaveLength(1); + + mock.onDelete('/ai-provider-configuration/provider-1').reply(() => [204]); + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: backendConfiguration.id, + }); + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: localConfiguration.id, + }); + }); + + it('keeps local provider configurations scoped to the signed-in user', async () => { + mock.onGet('/ai-provider-configuration').reply(() => [200, []]); + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + + await expect( + listAiProviderConfigurations(getAuthorizationHeader, { + userId: 'user-1', + }) + ).resolves.toContainEqual(configuration); + await expect( + listAiProviderConfigurations(getAuthorizationHeader, { + userId: 'user-2', + }) + ).resolves.not.toContainEqual(configuration); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('uses the backend provider configuration endpoints', async () => { + mock.onGet('/ai-provider-configuration').reply(config => { + expect(config.params).toEqual({ userId: 'user-1' }); + expect(config.headers.Authorization).toBe('Bearer test-token'); + return [200, [mockProviderConfiguration]]; + }); + mock.onPost('/ai-provider-configuration').reply(config => { + expect(JSON.parse(config.data)).toEqual(providerPayload); + return [200, mockProviderConfiguration]; + }); + mock.onPatch('/ai-provider-configuration/provider-1').reply(config => { + expect(JSON.parse(config.data)).toEqual(providerPayloadWithoutApiKey); + return [200, mockProviderConfiguration]; + }); + mock + .onPost('/ai-provider-configuration/provider-1/action/test') + .reply(() => [ + 200, + { success: true, message: 'Provider is reachable.' }, + ]); + mock.onDelete('/ai-provider-configuration/provider-1').reply(() => [204]); + + await expect( + listAiProviderConfigurations(getAuthorizationHeader, { + userId: 'user-1', + }) + ).resolves.toEqual([mockProviderConfiguration]); + await expect( + createAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + configuration: providerPayload, + }) + ).resolves.toEqual(mockProviderConfiguration); + await expect( + updateAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: 'provider-1', + configuration: providerPayloadWithoutApiKey, + }) + ).resolves.toEqual(mockProviderConfiguration); + await expect( + testAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: 'provider-1', + }) + ).resolves.toEqual({ + success: true, + message: 'Provider is reachable.', + }); + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: 'provider-1', + }); + expect(mock.history.delete).toHaveLength(1); + }); + + it('does not cache backend provider API keys locally', async () => { + const backendConfiguration = { + ...mockProviderConfiguration, + id: 'provider-with-backend-secret', + }; + mock + .onPost('/ai-provider-configuration') + .reply(() => [200, backendConfiguration]); + mock + .onPatch('/ai-provider-configuration/provider-with-backend-secret') + .reply(() => [200, backendConfiguration]); + + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + await updateAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + configuration: { + ...providerPayload, + apiKey: 'sk-updated-test', + }, + }); + + mock.reset(); + mock + .onGet('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + const localFallbackConfigurations = await listAiProviderConfigurations( + getAuthorizationHeader, + { userId: 'user-1' } + ); + + expect( + localFallbackConfigurations.some( + configuration => configuration.id === backendConfiguration.id + ) + ).toBe(false); + }); + + it('stores provider configurations locally when the backend route is unavailable', async () => { + mock + .onPost('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + mock + .onGet('/ai-provider-configuration') + .reply(() => routeUnavailableResponse); + + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: providerPayload, + } + ); + const configurations = await listAiProviderConfigurations( + getAuthorizationHeader, + { userId: 'user-1' } + ); + + expect(configuration.id).toContain('local-'); + expect(configurations).toContainEqual(configuration); + expect(configurations[0].hasApiKey).toBe(true); + expect(configurations[0].reasoningEffort).toBe('high'); + expect((configurations[0]: any).apiKey).toBeUndefined(); + + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + }); + + it('lists local provider configurations when the dev backend returns forbidden', async () => { + const configuration = await createAiProviderConfiguration( + getAuthorizationHeader, + { + userId: 'user-1', + configuration: localProviderPayload, + } + ); + const previousBaseUrl = apiClient.defaults.baseURL; + + try { + apiClient.defaults.baseURL = 'https://api-dev.gdevelop.io/generation'; + mock + .onGet('/ai-provider-configuration') + .reply(() => [403, 'Forbidden']); + + const configurations = await listAiProviderConfigurations( + getAuthorizationHeader, + { userId: 'user-1' } + ); + + expect(configurations).toContainEqual(configuration); + } finally { + apiClient.defaults.baseURL = previousBaseUrl; + await deleteAiProviderConfiguration(getAuthorizationHeader, { + userId: 'user-1', + providerConfigurationId: configuration.id, + }); + } + }); + }); + + describe('AI settings', () => { + it('uses explicit custom provider support before development defaults', () => { + const aiSettings: AiSettings = { + aiRequest: { + presets: [], + customProviderSupport: { + enabled: false, + openAiCompatible: false, + }, + }, + }; + + expect( + getAiRequestCustomProviderSupport({ + aiSettings, + enableDevelopmentFallback: true, + }) + ).toEqual({ + enabled: false, + openAiCompatible: false, + }); + }); + + it('only enables development fallback outside production settings', () => { + const aiSettings: AiSettings = { + aiRequest: { + presets: [], + }, + }; + + expect( + getAiRequestCustomProviderSupport({ + aiSettings, + enableDevelopmentFallback: false, + }) + ).toBe(null); + expect( + getAiRequestCustomProviderSupport({ + aiSettings, + enableDevelopmentFallback: true, + }) + ).toEqual({ + enabled: true, + openAiCompatible: true, + }); + }); + }); +}); diff --git a/newIDE/app/src/stories/componentStories/AiGeneration/AiRequestChat/Form.stories.js b/newIDE/app/src/stories/componentStories/AiGeneration/AiRequestChat/Form.stories.js index 0e18dfcfee07..11a1973c839e 100644 --- a/newIDE/app/src/stories/componentStories/AiGeneration/AiRequestChat/Form.stories.js +++ b/newIDE/app/src/stories/componentStories/AiGeneration/AiRequestChat/Form.stories.js @@ -19,7 +19,6 @@ import { import PreferencesContext, { initialPreferences, } from '../../../../MainFrame/Preferences/PreferencesContext'; -import FixedWidthFlexContainer from '../../../FixedWidthFlexContainer'; export default { title: 'EventsFunctionsExtensionEditor/AiRequestChat/Form', @@ -118,38 +117,32 @@ const WrappedChatComponent = (allProps: any) => { ); return ( - - { - setAutomaticallyUseCredits(value); - }, - }} - > - - - - - {({ i18n }) => ( - - )} - - - - - - + values: { + ...initialPreferences.values, + automaticallyUseCreditsForAiRequests: automaticallyUseCredits, + }, + setAutomaticallyUseCreditsForAiRequests: (value: boolean) => { + setAutomaticallyUseCredits(value); + }, + }} + > + + + + + {({ i18n }) => ( + + )} + + + + + ); };