fix(ai): address code review feedback for BYOK custom AI client

- package.json: remove unused react-dnd-html5-backend and react-refresh, pin babel-plugin-macros to ^2.8.0
- CustomAIClient: exclude apiKey from localStorage persistence, normalize base URL without auto-appending /v1, bound local requests cache to 20 items, add cancel token support with axios, restrict markdown tool parsing to side-effect-free tools, return empty results for asset/resource searches
- UseGenerateEvents: use activeUserId in getAiGeneratedEvent polling
- EditorFunctions: validate batch entries before scene insertion in put2dInstances and put3dInstances, fix instance rotation with setAngle for 3D
- Preferences: fix temperature input to support 0 and clamp 0.0-1.0, exclude apiKey from localStorage
- Generation & Usage: route requests and events by local ID prefix, partition status requests, decouple BYOK checks from subscription status
- Tests: reset state in beforeEach, update tests for base URL normalization, side-effect-free tools, and empty search results
This commit is contained in:
John Smith
2026-08-19 16:02:19 -04:00
parent c3989ddd56
commit 1e0c0a4e7e
15 changed files with 531 additions and 448 deletions
+1 -3
View File
@@ -22,7 +22,7 @@
"axios-mock-adapter": "^1.22.0",
"babel-core": "^7.0.0-bridge.0",
"babel-loader": "8.1.0",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-macros": "^2.8.0",
"chokidar": "4.0.3",
"concurrently": "9.2.1",
"flow-bin": "0.299.0",
@@ -35,7 +35,6 @@
"patch-package": "^6.4.7",
"prettier": "1.15.3",
"react-app-rewired": "^2.2.1",
"react-refresh": "^0.18.0",
"react-scripts": "5.0.1",
"recursive-copy": "^2.0.14",
"recursive-readdir": "^2.2.2",
@@ -78,7 +77,6 @@
"react": "18.2.0",
"react-color": "2.13.8",
"react-dnd": "14.0.5",
"react-dnd-html5-backend": "^14.1.0",
"react-dnd-touch-backend": "14.1.1",
"react-dom": "18.2.0",
"react-error-boundary": "^1.2.0",
+278 -273
View File
@@ -14,6 +14,8 @@ import {
type AiSettings,
} from '../Utils/GDevelopServices/Generation';
export const LOCAL_BYOK_USER_ID = 'local-byok-user';
export type CustomAIConfig = {|
enabled: boolean,
baseUrl: string,
@@ -34,6 +36,7 @@ export const DEFAULT_CUSTOM_AI_CONFIG: CustomAIConfig = {
const LOCAL_STORAGE_CONFIG_KEY = 'gd-custom-ai-config';
const LOCAL_STORAGE_REQUESTS_KEY = 'gd-custom-ai-requests';
const MAX_LOCAL_SAVED_REQUESTS = 20;
/**
* In-memory configuration cache.
@@ -70,6 +73,7 @@ export const getCustomEndpointConfig = (): CustomAIConfig => {
/**
* Save custom AI config to local storage and update in-memory cache.
* Excludes apiKey from cleartext localStorage while retaining it in memory.
*/
export const setCustomEndpointConfig = (
updates: $Shape<CustomAIConfig>
@@ -83,9 +87,10 @@ export const setCustomEndpointConfig = (
try {
if (typeof localStorage !== 'undefined') {
const { apiKey, ...persistableConfig } = nextConfig;
localStorage.setItem(
LOCAL_STORAGE_CONFIG_KEY,
JSON.stringify(nextConfig)
JSON.stringify(persistableConfig)
);
}
} catch (err) {
@@ -105,6 +110,7 @@ export const isCustomEndpointEnabled = (): boolean => {
/**
* Normalize base URL ensuring protocol and removing trailing slashes.
* Preserves the configured path exactly without automatically appending /v1.
*/
export const normalizeBaseUrl = (baseUrl: string): string => {
let url = (baseUrl || '').trim();
@@ -113,12 +119,13 @@ export const normalizeBaseUrl = (baseUrl: string): string => {
}
// Remove trailing slashes
url = url.replace(/\/+$/, '');
// If user entered just localhost:11434, add http://
// If user entered a schemeless URL, default to http:// for loopback and https:// otherwise
if (!/^https?:\/\//i.test(url)) {
url = `http://${url}`;
}
if (!url.endsWith('/v1')) {
url = `${url}/v1`;
if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/.*)?$/i.test(url)) {
url = `http://${url}`;
} else {
url = `https://${url}`;
}
}
return url;
};
@@ -135,11 +142,6 @@ export const getEndpointUrl = (
? endpointPath
: `/${endpointPath}`;
// If baseUrl already ends with /v1 and endpointPath starts with /v1, don't duplicate /v1
if (normalized.endsWith('/v1') && cleanPath.startsWith('/v1/')) {
return `${normalized}${cleanPath.substring(3)}`;
}
return `${normalized}${cleanPath}`;
};
@@ -148,6 +150,16 @@ export const getEndpointUrl = (
*/
const localAiRequestsCache: { [id: string]: AiRequest } = {};
/**
* Reset CustomAIClient state (for testing).
*/
export const _resetCustomAiClientForTesting = () => {
cachedConfig = null;
for (const key of Object.keys(localAiRequestsCache)) {
delete localAiRequestsCache[key];
}
};
/**
* Load local AI requests from local storage.
*/
@@ -167,19 +179,28 @@ export const loadLocalAiRequests = (): { [id: string]: AiRequest } => {
};
/**
* Save local AI requests to local storage.
* Save local AI requests to local storage, retaining a bounded number of requests
* and excluding full gameProjectJson to prevent storage quota exhaustion.
*/
export const saveLocalAiRequests = () => {
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(
LOCAL_STORAGE_REQUESTS_KEY,
JSON.stringify(localAiRequestsCache)
);
if (typeof localStorage === 'undefined') return;
const keys = Object.keys(localAiRequestsCache);
const recentKeys = keys.slice(-MAX_LOCAL_SAVED_REQUESTS);
const persistableMap: { [id: string]: AiRequest } = {};
for (const key of recentKeys) {
const req = localAiRequestsCache[key];
if (req) {
const { gameProjectJson, ...persistableReq } = req;
persistableMap[key] = (persistableReq: any);
}
} catch (err) {
console.warn('Error saving local AI requests to localStorage:', err);
}
localStorage.setItem(
LOCAL_STORAGE_REQUESTS_KEY,
JSON.stringify(persistableMap)
);
};
// Initialize requests from localStorage
@@ -1064,7 +1085,7 @@ export const extractThinkingAndContent = (
*/
export const transformGDevelopMessagesToOpenAi = (
outputMessages: Array<any>,
systemPromptOrProjectJson?: ?string,
systemPromptOrGameProjectJson?: ?string,
projectSpecificExtensionsSummaryJson?: ?string,
mode?: string
): Array<{|
@@ -1082,26 +1103,25 @@ export const transformGDevelopMessagesToOpenAi = (
|}> => {
const openAiMessages = [];
let systemPrompt: ?string = null;
if (typeof systemPromptOrProjectJson === 'string') {
let effectiveSystemPrompt: ?string = null;
if (systemPromptOrGameProjectJson) {
if (
systemPromptOrProjectJson.includes('GDevelop AI Assistant') ||
systemPromptOrProjectJson.includes('You are')
systemPromptOrGameProjectJson.includes('You are GDevelop AI Assistant')
) {
systemPrompt = systemPromptOrProjectJson;
effectiveSystemPrompt = systemPromptOrGameProjectJson;
} else {
systemPrompt = buildSystemPrompt({
gameProjectJson: systemPromptOrProjectJson,
effectiveSystemPrompt = buildSystemPrompt({
gameProjectJson: systemPromptOrGameProjectJson,
projectSpecificExtensionsSummaryJson,
mode,
mode: (mode: any),
});
}
}
if (systemPrompt) {
if (effectiveSystemPrompt) {
openAiMessages.push({
role: 'system',
content: systemPrompt,
content: effectiveSystemPrompt,
});
}
@@ -1120,142 +1140,129 @@ export const transformGDevelopMessagesToOpenAi = (
.map(item => item.text)
.join('\n')
: '');
if (text) {
openAiMessages.push({
role: 'user',
content: text,
});
}
openAiMessages.push({
role: 'user',
content: text,
});
} else if (
msg.role === 'assistant' ||
msg.type === 'assistant' ||
(msg.type === 'message' && msg.role === 'assistant')
) {
const textParts = [];
const content = msg.text || '';
const toolCalls = [];
const seenCallIds = new Set();
if (msg.text) {
textParts.push(msg.text);
} else if (typeof msg.content === 'string') {
textParts.push(msg.content);
} else if (Array.isArray(msg.content)) {
for (const item of msg.content) {
if (item.type === 'output_text') {
textParts.push(item.text);
} else if (item.type === 'function_call') {
const callId = item.call_id || item.id;
if (callId && !seenCallIds.has(callId)) {
seenCallIds.add(callId);
toolCalls.push({
id: callId,
type: 'function',
function: {
name: item.name,
arguments:
typeof item.arguments === 'string'
? item.arguments
: JSON.stringify(item.arguments || {}),
},
});
}
}
// Extract function calls
if (Array.isArray(msg.functionCalls)) {
for (const fc of msg.functionCalls) {
toolCalls.push({
id: fc.id || `call_${Date.now()}`,
type: 'function',
function: {
name: fc.name,
arguments:
typeof fc.callArguments === 'string'
? fc.callArguments
: JSON.stringify(fc.callArguments || {}),
},
});
}
}
if (msg.functionCalls && Array.isArray(msg.functionCalls)) {
for (const fc of msg.functionCalls) {
const callId = fc.id || fc.call_id;
if (callId && !seenCallIds.has(callId)) {
seenCallIds.add(callId);
if (Array.isArray(msg.content)) {
for (const item of msg.content) {
if (item.type === 'function_call') {
toolCalls.push({
id: callId,
id:
item.call_id || item.callId || item.id || `call_${Date.now()}`,
type: 'function',
function: {
name: fc.name,
name: item.name,
arguments:
typeof fc.callArguments === 'string'
? fc.callArguments
: JSON.stringify(fc.callArguments || fc.arguments || {}),
typeof item.arguments === 'string'
? item.arguments
: JSON.stringify(item.arguments || {}),
},
});
}
}
}
openAiMessages.push({
const assistantMsg: Object = {
role: 'assistant',
content: textParts.join('\n') || (toolCalls.length ? null : ''),
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
});
} else if (
msg.type === 'function_call_output' ||
msg.role === 'tool' ||
msg.functionCallOutputs
) {
const seenToolOutputIds = new Set();
if (msg.functionCallOutputs && Array.isArray(msg.functionCallOutputs)) {
for (const fco of msg.functionCallOutputs) {
const callId = fco.callId || fco.call_id;
if (callId && !seenToolOutputIds.has(callId)) {
seenToolOutputIds.add(callId);
openAiMessages.push({
role: 'tool',
tool_call_id: callId,
content:
typeof fco.output === 'string'
? fco.output
: JSON.stringify(fco.output || {}),
});
content: content || null,
};
if (toolCalls.length > 0) {
// De-duplicate tool calls by ID
const seenCallIds = new Set<string>();
const uniqueToolCalls = [];
for (const tc of toolCalls) {
if (!seenCallIds.has(tc.id)) {
seenCallIds.add(tc.id);
uniqueToolCalls.push(tc);
}
}
} else {
const callId = msg.call_id || msg.callId || msg.tool_call_id;
if (callId && !seenToolOutputIds.has(callId)) {
seenToolOutputIds.add(callId);
assistantMsg.tool_calls = uniqueToolCalls;
}
openAiMessages.push(assistantMsg);
} else if (msg.type === 'function_call_output' || msg.role === 'tool') {
if (Array.isArray(msg.functionCallOutputs)) {
for (const fco of msg.functionCallOutputs) {
openAiMessages.push({
role: 'tool',
tool_call_id: callId,
tool_call_id: fco.callId || fco.call_id || fco.id,
content:
typeof msg.output === 'string'
? msg.output
: JSON.stringify(msg.output || msg.content || {}),
typeof fco.output === 'string'
? fco.output
: JSON.stringify(fco.output || {}),
});
}
} else {
openAiMessages.push({
role: 'tool',
tool_call_id: msg.callId || msg.call_id || msg.id,
content:
typeof msg.output === 'string'
? msg.output
: JSON.stringify(msg.output || {}),
});
}
}
}
return openAiMessages;
// Ensure consecutive tool outputs don't have duplicate tool_call_id
const seenToolOutputIds = new Set<string>();
return openAiMessages.filter(msg => {
if (msg.role === 'tool' && msg.tool_call_id) {
if (seenToolOutputIds.has(msg.tool_call_id)) {
return false;
}
seenToolOutputIds.add(msg.tool_call_id);
}
return true;
});
};
/**
* Build standard system prompt with GDevelop context and instructions.
* Build the system prompt with project structure and guidelines.
*/
export const buildSystemPrompt = ({
gameProjectJson,
projectSpecificExtensionsSummaryJson,
mode,
}: {|
gameProjectJson?: ?string,
projectSpecificExtensionsSummaryJson?: ?string,
mode?: string,
gameProjectJson?: string | null,
projectSpecificExtensionsSummaryJson?: string | null,
mode?: 'chat' | 'agent' | 'orchestrator',
|}): string => {
let prompt = `You are the GDevelop AI Assistant. You help users create, design, and modify games in GDevelop 5.
You have access to editor tools to create scenes, objects, behaviors, place instances, change scene properties (such as background color), and generate event logic.
Always think carefully, explain your design concisely to the user, and call the appropriate tool functions when modifying the game.
let prompt = `You are GDevelop AI Assistant, an expert game engine developer. You help users build games in GDevelop 5.
Guidelines for modifying the project:
1. To change scene properties (background color, dimensions, name, first scene):
Call change_scene_properties_layers_effects_groups with scene_name and changed_properties: [{ property_name: "backgroundColor", new_value: "#3498db" }].
2. To create a scene:
Call create_scene with scene_name (e.g. { scene_name: "Level1" }).
3. To create or replace objects:
Call create_or_replace_object.
4. To add behaviors:
Call add_behavior.
5. To place instances in a scene:
Call put_2d_instances.
Guidelines:
1. Always use available tools/functions to inspect or modify the project.
2. When creating objects, use \`create_or_replace_object\`.
3. When adding behaviors, use \`add_behavior\`.
4. When placing instances in scenes, use \`put_2d_instances\` or \`put_3d_instances\`.
5. When changing scene properties (e.g. background color), use \`change_scene_properties_layers_effects_groups\`.
6. To modify scene events:
Call add_scene_events.
7. If using run_script:
@@ -1318,165 +1325,112 @@ export const sendChatCompletion = async ({
payload.max_tokens = currentConfig.maxTokens;
}
let cancelToken;
if (signal) {
const cancelTokenSource = axios.CancelToken.source();
cancelToken = cancelTokenSource.token;
if (signal.aborted) {
cancelTokenSource.cancel('Aborted');
} else {
signal.addEventListener('abort', () => {
cancelTokenSource.cancel('Aborted');
});
}
}
try {
const response = await axios.post(endpointUrl, payload, {
headers,
signal,
cancelToken,
timeout: 120000,
});
if (
response.data &&
response.data.choices &&
response.data.choices.length > 0
response.data.choices[0] &&
response.data.choices[0].message
) {
return response.data.choices[0].message;
}
throw new Error(
'Invalid response structure from custom AI endpoint: choices array is empty.'
);
return response.data;
} catch (error) {
if (axios.isCancel(error) || (signal && signal.aborted)) {
throw new Error('AI request was aborted.');
}
if (error.response) {
const status = error.response.status;
const data = error.response.data;
const errorMsg =
(data && data.error && (data.error.message || data.error)) ||
error.response.statusText ||
'Server Error';
throw new Error(`Custom AI Endpoint Error (${status}): ${errorMsg}`);
} else if (error.request) {
throw new Error(
`Failed to reach custom AI endpoint at ${endpointUrl}. Please verify that your local model server (Ollama/llama.cpp/LM Studio) or proxy is running and accessible.`
);
error.message ||
`HTTP error ${status}`;
throw new Error(`AI Provider Error (${status}): ${errorMsg}`);
}
throw error;
}
};
/**
* Test connectivity with the configured custom endpoint.
*/
export const testConnection = async (
customConfig?: $Shape<CustomAIConfig>
): Promise<{|
success: boolean,
message: string,
models?: Array<string>,
|}> => {
const config = {
...getCustomEndpointConfig(),
...(customConfig || {}),
};
const baseUrl = normalizeBaseUrl(config.baseUrl);
// First attempt: Try GET /models
try {
const modelsUrl = getEndpointUrl(baseUrl, '/models');
const headers: { [string]: string } = {
'HTTP-Referer': 'https://gdevelop.io',
'X-Title': 'GDevelop IDE',
...(config.customHeaders || {}),
};
if (config.apiKey && config.apiKey.trim()) {
headers['Authorization'] = `Bearer ${config.apiKey.trim()}`;
}
const response = await axios.get(modelsUrl, { headers, timeout: 5000 });
if (response.status === 200 && response.data) {
const data = response.data.data || response.data.models || response.data;
const models = Array.isArray(data)
? data.map(item => item.id || item.name || String(item)).filter(Boolean)
: [];
return {
success: true,
message: `Successfully connected to endpoint. Found ${
models.length
} model(s).`,
models,
};
}
} catch (err) {
// If /models failed, fallback to a lightweight completion test
}
// Second attempt: Minimal chat completion
try {
const res = await sendChatCompletion({
messages: [{ role: 'user', content: 'Say "OK"' }],
config,
});
return {
success: true,
message: `Successfully connected! Model responded: ${res.content ||
'OK'}`,
};
} catch (err) {
return {
success: false,
message: `Connection failed: ${err.message ||
'Failed to connect to custom endpoint.'}`,
};
}
};
const SIDE_EFFECT_FREE_TOOLS = new Set([
'describe_instances',
'describe_events',
'describe_scene_layers_effects_groups',
'describe_variables',
'read_game_project_json',
'read_full_docs',
'search_docs',
'get_game_starter_summary',
]);
/**
* Parse an assistant message into GDevelop AiRequestAssistantMessage structure.
* Parse an OpenAI assistant message response into GDevelop's internal format.
*/
export const parseAssistantMessage = (
openAiMessageOrChoice: Object,
openAiMessageOrChoiceOrResponse: Object,
messageId?: string
): Object => {
const openAiMessage = openAiMessageOrChoice.message || openAiMessageOrChoice;
const contentArray: Array<any> = [];
const functionCalls: Array<{|
id: string,
name: string,
callArguments: Object,
|}> = [];
): AiRequestMessage => {
const openAiMessage =
openAiMessageOrChoiceOrResponse.message ||
(openAiMessageOrChoiceOrResponse.choices &&
openAiMessageOrChoiceOrResponse.choices[0] &&
openAiMessageOrChoiceOrResponse.choices[0].message) ||
openAiMessageOrChoiceOrResponse;
const rawContent = openAiMessage.content;
const reasoningContent = openAiMessage.reasoning_content;
const { thinking, cleanContent } = extractThinkingAndContent(
rawContent,
reasoningContent
);
const rawContent = openAiMessage.content || '';
const reasoningContent = openAiMessage.reasoning_content || null;
if (thinking) {
contentArray.push({
type: 'reasoning',
status: 'completed',
summary: {
text: thinking,
type: 'summary_text',
},
});
let { thinking, cleanContent } = extractThinkingAndContent(rawContent);
if (!thinking && reasoningContent) {
thinking = reasoningContent;
}
const contentArray: Array<any> = [];
if (cleanContent) {
contentArray.push({
type: 'output_text',
type: 'text',
status: 'completed',
text: cleanContent,
annotations: [],
});
}
if (openAiMessage.tool_calls && Array.isArray(openAiMessage.tool_calls)) {
const functionCalls: Array<any> = [];
// Standard tool_calls
if (Array.isArray(openAiMessage.tool_calls)) {
for (const toolCall of openAiMessage.tool_calls) {
const functionName = toolCall.function && toolCall.function.name;
const functionArgs =
toolCall.function &&
(typeof toolCall.function.arguments === 'string'
? toolCall.function.arguments
: JSON.stringify(toolCall.function.arguments || {}));
let parsedArgs = {};
try {
parsedArgs = JSON.parse(functionArgs || '{}');
} catch (e) {}
if (functionName) {
if (toolCall.function) {
const functionName = toolCall.function.name;
const functionArgs = toolCall.function.arguments;
let parsedArgs = {};
try {
parsedArgs =
typeof functionArgs === 'string'
? JSON.parse(functionArgs)
: functionArgs || {};
} catch (e) {
console.warn('Error parsing function call arguments JSON:', e);
}
const callId =
toolCall.id ||
`call_${Date.now()}_${Math.random()
@@ -1498,7 +1452,7 @@ export const parseAssistantMessage = (
}
}
// Fallback: If no tool_calls but content contains markdown JSON tool call blocks
// Fallback: If no tool_calls but content contains markdown JSON tool call blocks (restricted to side-effect-free tools)
if (
(!openAiMessage.tool_calls || openAiMessage.tool_calls.length === 0) &&
cleanContent
@@ -1510,7 +1464,11 @@ export const parseAssistantMessage = (
const parsed = JSON.parse(match[1]);
const name = parsed.function || parsed.name || parsed.tool;
const args = parsed.arguments || parsed.args || parsed.parameters || {};
if (name && typeof name === 'string') {
if (
name &&
typeof name === 'string' &&
SIDE_EFFECT_FREE_TOOLS.has(name)
) {
const callId = `call_parsed_${Date.now()}_${Math.random()
.toString(36)
.substr(2, 5)}`;
@@ -1616,7 +1574,7 @@ export const customCreateAiRequest = async ({
id: reqId,
createdAt: now,
updatedAt: now,
userId: 'local-byok-user',
userId: LOCAL_BYOK_USER_ID,
gameId: gameId || null,
gameProjectJson: gameProjectJson || null,
status: 'ready',
@@ -1658,7 +1616,7 @@ export const customAddMessageToAiRequest = async ({
id: aiRequestId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId: 'local-byok-user',
userId: LOCAL_BYOK_USER_ID,
status: 'ready',
output: [],
error: null,
@@ -1743,7 +1701,7 @@ export const customGetAiRequest = (aiRequestId: string): AiRequest => {
id: aiRequestId,
createdAt: now,
updatedAt: now,
userId: 'local-byok-user',
userId: LOCAL_BYOK_USER_ID,
status: 'ready',
error: null,
output: [],
@@ -1780,7 +1738,7 @@ export const customGetAiRequestStatuses = (
return {
id,
status: req ? req.status : 'ready',
userId: 'local-byok-user',
userId: LOCAL_BYOK_USER_ID,
};
});
};
@@ -1828,7 +1786,7 @@ export const customForkAiRequest = (
id: newReqId,
createdAt: now,
updatedAt: now,
userId: 'local-byok-user',
userId: LOCAL_BYOK_USER_ID,
status: 'ready',
forkedFromAiRequestId: aiRequestId,
output,
@@ -1985,7 +1943,7 @@ Ensure generatedEvents is a JSON string of standard GDevelop event objects (e.g.
id: `local-evt-${Date.now()}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId: 'local-byok-user',
userId: LOCAL_BYOK_USER_ID,
status: 'ready',
partialGameProjectJson: '',
eventsDescription: eventsDescription || null,
@@ -2031,6 +1989,72 @@ Ensure generatedEvents is a JSON string of standard GDevelop event objects (e.g.
}
};
/**
* Test connectivity with the configured custom endpoint.
*/
export const testConnection = async (
customConfig?: $Shape<CustomAIConfig>
): Promise<{|
success: boolean,
message: string,
models?: Array<string>,
|}> => {
const config = {
...getCustomEndpointConfig(),
...(customConfig || {}),
};
const baseUrl = normalizeBaseUrl(config.baseUrl);
// First attempt: Try GET /models
try {
const modelsUrl = getEndpointUrl(baseUrl, '/models');
const headers: { [string]: string } = {
'HTTP-Referer': 'https://gdevelop.io',
'X-Title': 'GDevelop IDE',
...(config.customHeaders || {}),
};
if (config.apiKey && config.apiKey.trim()) {
headers['Authorization'] = `Bearer ${config.apiKey.trim()}`;
}
const response = await axios.get(modelsUrl, { headers, timeout: 5000 });
if (response.status === 200 && response.data) {
const data = response.data.data || response.data.models || response.data;
const models = Array.isArray(data)
? data.map(item => item.id || item.name || String(item)).filter(Boolean)
: [];
return {
success: true,
message: `Successfully connected to endpoint. Found ${
models.length
} model(s).`,
models,
};
}
} catch (err) {
// If /models failed, fallback to a lightweight completion test
}
// Second attempt: Minimal chat completion
try {
const res = await sendChatCompletion({
messages: [{ role: 'user', content: 'Say "OK"' }],
config,
});
return {
success: true,
message: `Successfully connected! Model responded: ${res.content ||
'OK'}`,
};
} catch (err) {
return {
success: false,
message: `Connection failed: ${err.message ||
'Failed to connect to custom endpoint.'}`,
};
}
};
/**
* Client-side Asset Search.
*/
@@ -2043,7 +2067,7 @@ export const customCreateAssetSearch = async ({
|}): Promise<AssetSearch> => {
return {
id: `local-asset-search-${Date.now()}`,
userId: 'local-byok-user',
userId: LOCAL_BYOK_USER_ID,
createdAt: new Date().toISOString(),
query: {
searchTerms: [searchTerms],
@@ -2055,16 +2079,7 @@ export const customCreateAssetSearch = async ({
lastAssistantMessages: [],
},
status: 'completed',
results: [
{
asset: {
id: `local-asset-${Date.now()}`,
name: searchTerms,
objectType: objectType || 'Sprite',
tags: [searchTerms],
},
},
],
results: [],
};
};
@@ -2080,24 +2095,14 @@ export const customCreateResourceSearch = async ({
|}): Promise<ResourceSearch> => {
return {
id: `local-resource-search-${Date.now()}`,
userId: 'local-byok-user',
userId: LOCAL_BYOK_USER_ID,
createdAt: new Date().toISOString(),
query: {
searchTerms: [searchTerms],
resourceKind,
},
status: 'completed',
results: [
{
resource: {
name: searchTerms,
kind: resourceKind,
url: `https://resources.gdevelop.io/${encodeURIComponent(
searchTerms
)}`,
},
},
],
results: [],
};
};
+23 -12
View File
@@ -12,6 +12,8 @@ import {
DEFAULT_CUSTOM_AI_CONFIG,
DEFAULT_LOCAL_AI_SETTINGS,
GDEVELOP_OPENAI_TOOLS,
LOCAL_BYOK_USER_ID,
_resetCustomAiClientForTesting,
customCreateAiRequest,
customAddMessageToAiRequest,
customGetAiRequest,
@@ -31,6 +33,7 @@ jest.mock('axios');
describe('CustomAIClient', () => {
beforeEach(() => {
jest.clearAllMocks();
_resetCustomAiClientForTesting();
setCustomEndpointConfig({
enabled: false,
baseUrl: 'http://localhost:11434/v1',
@@ -65,16 +68,22 @@ describe('CustomAIClient', () => {
expect(config.temperature).toBe(0.2);
});
it('normalizes base URLs correctly', () => {
it('normalizes base URLs correctly without auto-appending /v1', () => {
expect(normalizeBaseUrl('http://localhost:11434')).toBe(
'http://localhost:11434/v1'
'http://localhost:11434'
);
expect(normalizeBaseUrl('http://localhost:11434/')).toBe(
'http://localhost:11434/v1'
'http://localhost:11434'
);
expect(normalizeBaseUrl('http://localhost:11434/v1/')).toBe(
'http://localhost:11434/v1'
);
expect(normalizeBaseUrl('localhost:11434/v1')).toBe(
'http://localhost:11434/v1'
);
expect(normalizeBaseUrl('api.openai.com/v1')).toBe(
'https://api.openai.com/v1'
);
expect(normalizeBaseUrl('https://api.openai.com/v1')).toBe(
'https://api.openai.com/v1'
);
@@ -89,7 +98,7 @@ describe('CustomAIClient', () => {
getEndpointUrl('http://localhost:11434/v1', '/chat/completions')
).toBe('http://localhost:11434/v1/chat/completions');
expect(getEndpointUrl('http://localhost:11434', '/models')).toBe(
'http://localhost:11434/v1/models'
'http://localhost:11434/models'
);
});
});
@@ -240,20 +249,20 @@ describe('CustomAIClient', () => {
});
});
it('extracts embedded JSON function call markdown if tool_calls not present', () => {
it('extracts embedded JSON function call markdown if tool_calls not present for side-effect-free tools', () => {
const choice = {
message: {
role: 'assistant',
content:
'I will create the object now:\n```json\n{"name":"create_or_replace_object","arguments":{"objectName":"Coin"}}\n```',
'I will inspect the instances now:\n```json\n{"name":"describe_instances","arguments":{"scene_name":"Level1"}}\n```',
},
};
const parsed = parseAssistantMessage(choice);
expect(parsed.functionCalls).toHaveLength(1);
expect(parsed.functionCalls[0].name).toBe('create_or_replace_object');
expect(parsed.functionCalls[0].name).toBe('describe_instances');
expect(parsed.functionCalls[0].callArguments).toEqual({
objectName: 'Coin',
scene_name: 'Level1',
});
});
@@ -364,7 +373,7 @@ describe('CustomAIClient', () => {
const statuses = await customGetAiRequestStatuses([aiRequest.id]);
expect(statuses).toEqual([
{ id: aiRequest.id, status: 'ready', userId: 'local-byok-user' },
{ id: aiRequest.id, status: 'ready', userId: LOCAL_BYOK_USER_ID },
]);
});
@@ -451,20 +460,22 @@ describe('CustomAIClient', () => {
}
});
it('provides mock asset and resource search results', async () => {
it('provides empty asset and resource search results in BYOK mode', async () => {
const assetResult = await customCreateAssetSearch({
searchTerms: 'coin',
objectType: 'Sprite',
});
expect(assetResult.id).toMatch(/^local-asset-/);
expect(assetResult.results.length).toBeGreaterThan(0);
expect(assetResult.userId).toBe(LOCAL_BYOK_USER_ID);
expect(assetResult.results).toEqual([]);
const resourceResult = await customCreateResourceSearch({
searchTerms: 'jump sound',
resourceKind: 'audio',
});
expect(resourceResult.id).toMatch(/^local-resource-/);
expect(resourceResult.results.length).toBeGreaterThan(0);
expect(resourceResult.userId).toBe(LOCAL_BYOK_USER_ID);
expect(resourceResult.results).toEqual([]);
});
});
});
@@ -23,9 +23,8 @@ export const getAiConfigurationPresetsWithAvailability = ({
getAiSettings: () => AiSettings | null,
limits: ?Limits,
|}): Array<AiConfigurationPresetWithAvailability> => {
const aiSettings = getAiSettings() || DEFAULT_LOCAL_AI_SETTINGS;
if (isCustomEndpointEnabled()) {
const aiSettings = getAiSettings() || DEFAULT_LOCAL_AI_SETTINGS;
return aiSettings.aiRequest.presets.map(preset => ({
...preset,
enableWith: null,
@@ -34,12 +33,15 @@ export const getAiConfigurationPresetsWithAvailability = ({
}));
}
const aiSettings = getAiSettings();
if (!aiSettings) return [];
if (!limits) {
return aiSettings.aiRequest.presets.map(preset => ({
...preset,
enableWith: null,
enabledWithPlans: [],
disabled: false,
disabled: !preset.isDefault,
}));
}
@@ -43,7 +43,10 @@ import {
canUpgradeSubscription,
hasValidSubscriptionPlan,
} from '../Utils/GDevelopServices/Usage';
import { isCustomEndpointEnabled } from '../AI/CustomAIClient';
import {
isCustomEndpointEnabled,
LOCAL_BYOK_USER_ID,
} from '../AI/CustomAIClient';
import { retryIfFailed } from '../Utils/RetryIfFailed';
import { type EditorCallbacks } from '../EditorFunctions';
import {
@@ -528,7 +531,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
return;
}
const activeUserId = profile ? profile.id : 'local-byok-user';
const activeUserId = profile ? profile.id : LOCAL_BYOK_USER_ID;
// Read the options and reset them (to avoid launching the same request twice).
const {
@@ -690,7 +693,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
|}) => {
if (!profile && !isCustomEndpointEnabled()) return;
const activeUserId = profile ? profile.id : 'local-byok-user';
const activeUserId = profile ? profile.id : LOCAL_BYOK_USER_ID;
const aiRequestForMessage = aiRequests[aiRequestId];
if (!aiRequestForMessage) return;
@@ -1584,7 +1587,9 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
lastSendError={getLastSendError(selectedAiRequestId)}
quota={quota}
increaseQuotaOffering={
!hasValidSubscriptionPlan(subscription)
isCustomEndpointEnabled()
? 'none'
: !hasValidSubscriptionPlan(subscription)
? 'subscribe'
: canUpgradeSubscription(subscription)
? 'upgrade'
@@ -37,8 +37,10 @@ import { prepareAiUserContent } from './PrepareAiUserContent';
import { AiRequestContext } from './AiRequestContext';
import { getAiConfigurationPresetsWithAvailability } from './AiConfiguration';
import { type CreateProjectResult } from '../Utils/UseCreateProject';
import { SubscriptionContext } from '../Profile/Subscription/SubscriptionContext';
import { isCustomEndpointEnabled } from '../AI/CustomAIClient';
import {
isCustomEndpointEnabled,
LOCAL_BYOK_USER_ID,
} from '../AI/CustomAIClient';
import {
useProcessFunctionCalls,
useRefreshLimits,
@@ -273,7 +275,7 @@ export const AskAiStandAloneForm = ({
return;
}
const activeUserId = profile ? profile.id : 'local-byok-user';
const activeUserId = profile ? profile.id : LOCAL_BYOK_USER_ID;
// Read the options and reset them immediately to prevent the effect from firing
// again if dependencies change during the async operations below (e.g. when
@@ -503,7 +505,7 @@ export const AskAiStandAloneForm = ({
)
: null;
const activeUserId = profile ? profile.id : 'local-byok-user';
const activeUserId = profile ? profile.id : LOCAL_BYOK_USER_ID;
const preparedAiUserContent = await prepareAiUserContent({
getAuthorizationHeader,
@@ -712,7 +714,9 @@ export const AskAiStandAloneForm = ({
lastSendError={getLastSendError(aiRequestIdForForm)}
quota={quota}
increaseQuotaOffering={
!hasValidSubscriptionPlan(subscription)
isCustomEndpointEnabled()
? 'none'
: !hasValidSubscriptionPlan(subscription)
? 'subscribe'
: canUpgradeSubscription(subscription)
? 'upgrade'
@@ -1,3 +1,2 @@
// @flow
export * from '../AI/CustomAIClient';
export { default } from '../AI/CustomAIClient';
@@ -15,7 +15,10 @@ import {
} from '../EditorFunctions';
import { makeSimplifiedProjectBuilder } from '../EditorFunctions/SimplifiedProject/SimplifiedProject';
import { prepareAiUserContent } from './PrepareAiUserContent';
import { isCustomEndpointEnabled } from '../AI/CustomAIClient';
import {
isCustomEndpointEnabled,
LOCAL_BYOK_USER_ID,
} from '../AI/CustomAIClient';
const gd: libGDevelop = global.gd;
@@ -70,7 +73,7 @@ export const useGenerateEvents = ({
if (!profile && !isCustomEndpointEnabled())
throw new Error('User should be authenticated.');
const activeUserId = profile ? profile.id : 'local-byok-user';
const activeUserId = profile ? profile.id : LOCAL_BYOK_USER_ID;
const simplifiedProjectBuilder = makeSimplifiedProjectBuilder(gd);
const simplifiedProjectJson = JSON.stringify(
@@ -137,7 +140,7 @@ export const useGenerateEvents = ({
aiGeneratedEvent = await getAiGeneratedEvent(
getAuthorizationHeader,
{
userId: profile.id,
userId: activeUserId,
aiGeneratedEventId: aiGeneratedEvent.id,
}
);
@@ -13,7 +13,10 @@ import { retryIfFailed } from '../Utils/RetryIfFailed';
import { useInstallAsset } from '../AssetStore/NewObjectDialog';
import { type ResourceManagementProps } from '../ResourcesList/ResourceSource';
import { AssetStoreContext } from '../AssetStore/AssetStoreContext';
import { isCustomEndpointEnabled } from '../AI/CustomAIClient';
import {
isCustomEndpointEnabled,
LOCAL_BYOK_USER_ID,
} from '../AI/CustomAIClient';
type _FuncReturnType = {
searchAndInstallAsset: AssetSearchAndInstallOptions => Promise<AssetSearchAndInstallResult>,
@@ -53,7 +56,7 @@ export const useSearchAndInstallAsset = ({
if (!profile && !isCustomEndpointEnabled())
throw new Error('User should be authenticated.');
const activeUserId = profile ? profile.id : 'local-byok-user';
const activeUserId = profile ? profile.id : LOCAL_BYOK_USER_ID;
let assetShortHeader;
if (exactOrPartialAssetId) {
@@ -14,7 +14,10 @@ import { retryIfFailed } from '../Utils/RetryIfFailed';
import { type ResourceManagementProps } from '../ResourcesList/ResourceSource';
import { createNewResource } from '../ResourcesList/ResourceSource';
import { applyResourceDefaults } from '../ResourcesList/ResourceUtils';
import { isCustomEndpointEnabled } from '../AI/CustomAIClient';
import {
isCustomEndpointEnabled,
LOCAL_BYOK_USER_ID,
} from '../AI/CustomAIClient';
import PromisePool from '@supercharge/promise-pool';
@@ -41,7 +44,7 @@ export const useSearchAndInstallResource = ({
throw new Error('User should be authenticated.');
if (!project) throw new Error('Project should be opened.');
const activeUserId = profile ? profile.id : 'local-byok-user';
const activeUserId = profile ? profile.id : LOCAL_BYOK_USER_ID;
const { results } = await PromisePool.withConcurrency(5)
.for(resources)
+107 -50
View File
@@ -3663,7 +3663,7 @@ const put2dInstances: EditorFunction = {
);
const newInstancesCount =
new_instances_count === null && existingInstanceIds.length === 0
? Array.isArray(args.instances)
? Array.isArray(args.instances) && args.instances.length > 0
? args.instances.length
: 1
: new_instances_count;
@@ -3747,33 +3747,63 @@ const put2dInstances: EditorFunction = {
return makeSceneNotFoundFailure(project, scene_name);
}
const layout = project.getLayout(scene_name);
const initialInstances = layout.getInitialInstances();
const createdCount = args.instances.length;
const validEntries = [];
for (const instData of args.instances) {
if (!instData || typeof instData !== 'object') continue;
const instObjName =
instData.object_name || instData.name || object_name;
if (!instObjName) continue;
const inst = initialInstances.insertNewInitialInstance();
inst.setObjectName(instObjName);
inst.setLayer(
instData.layer_name !== undefined
? instData.layer_name
: instData.layer !== undefined
? instData.layer
: layer_name
const x = Number(instData.x);
const y = Number(instData.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
validEntries.push({
objectName: instObjName,
layer:
instData.layer_name !== undefined
? String(instData.layer_name)
: instData.layer !== undefined
? String(instData.layer)
: layer_name,
x,
y,
angle:
instData.angle !== undefined &&
Number.isFinite(Number(instData.angle))
? Number(instData.angle)
: null,
zOrder:
instData.z_order !== undefined &&
Number.isFinite(Number(instData.z_order))
? Number(instData.z_order)
: instData.zOrder !== undefined &&
Number.isFinite(Number(instData.zOrder))
? Number(instData.zOrder)
: null,
});
}
if (validEntries.length === 0) {
return makeGenericFailure(
'No valid instances found in the batch to place.'
);
inst.setX(Number(instData.x) || 0);
inst.setY(Number(instData.y) || 0);
if (instData.angle !== undefined)
inst.setAngle(Number(instData.angle) || 0);
if (instData.z_order !== undefined || instData.zOrder !== undefined) {
inst.setZOrder(Number(instData.z_order || instData.zOrder) || 0);
}
}
const initialInstances = layout.getInitialInstances();
for (const entry of validEntries) {
const inst = initialInstances.insertNewInitialInstance();
inst.setObjectName(entry.objectName);
inst.setLayer(entry.layer);
inst.setX(entry.x);
inst.setY(entry.y);
if (entry.angle !== null) inst.setAngle(entry.angle);
if (entry.zOrder !== null) inst.setZOrder(entry.zOrder);
}
onInstancesModifiedOutsideEditor({ scene: layout });
return {
success: true,
message: `Placed ${createdCount} instance(s) in scene "${scene_name}".`,
message: `Placed ${
validEntries.length
} instance(s) in scene "${scene_name}".`,
};
}
@@ -3793,11 +3823,7 @@ const put2dInstances: EditorFunction = {
args,
'existing_instance_ids'
);
const brush_position =
rawBrushPosition ||
(requested_brush_kind !== 'none' && !existing_instance_ids
? '0, 0'
: null);
const brush_position = rawBrushPosition;
// A "none" brush with both a `brush_position` and `existing_instance_ids`
// is contradictory ("none" never positions anything) but unambiguous: move
// THOSE instances there. Read it as the "point" brush, which is what the
@@ -4619,7 +4645,7 @@ const put3dInstances: EditorFunction = {
);
const newInstancesCount =
new_instances_count === null && existingInstanceIds.length === 0
? Array.isArray(args.instances)
? Array.isArray(args.instances) && args.instances.length > 0
? args.instances.length
: 1
: new_instances_count;
@@ -4703,35 +4729,70 @@ const put3dInstances: EditorFunction = {
return makeSceneNotFoundFailure(project, scene_name);
}
const layout = project.getLayout(scene_name);
const initialInstances = layout.getInitialInstances();
const createdCount = args.instances.length;
const validEntries = [];
for (const instData of args.instances) {
if (!instData || typeof instData !== 'object') continue;
const instObjName =
instData.object_name || instData.name || object_name;
if (!instObjName) continue;
const inst = initialInstances.insertNewInitialInstance();
inst.setObjectName(instObjName);
inst.setLayer(
instData.layer_name !== undefined
? instData.layer_name
: instData.layer !== undefined
? instData.layer
: layer_name
const x = Number(instData.x);
const y = Number(instData.y);
const z = Number(instData.z || 0);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
continue;
validEntries.push({
objectName: instObjName,
layer:
instData.layer_name !== undefined
? String(instData.layer_name)
: instData.layer !== undefined
? String(instData.layer)
: layer_name,
x,
y,
z,
rotationX:
instData.rotation_x !== undefined &&
Number.isFinite(Number(instData.rotation_x))
? Number(instData.rotation_x)
: null,
rotationY:
instData.rotation_y !== undefined &&
Number.isFinite(Number(instData.rotation_y))
? Number(instData.rotation_y)
: null,
rotationZ:
instData.rotation_z !== undefined &&
Number.isFinite(Number(instData.rotation_z))
? Number(instData.rotation_z)
: null,
});
}
if (validEntries.length === 0) {
return makeGenericFailure(
'No valid 3D instances found in the batch to place.'
);
inst.setX(Number(instData.x) || 0);
inst.setY(Number(instData.y) || 0);
inst.setZ(Number(instData.z) || 0);
if (instData.rotation_x !== undefined)
inst.setRotationX(Number(instData.rotation_x) || 0);
if (instData.rotation_y !== undefined)
inst.setRotationY(Number(instData.rotation_y) || 0);
if (instData.rotation_z !== undefined)
inst.setRotationZ(Number(instData.rotation_z) || 0);
}
const initialInstances = layout.getInitialInstances();
for (const entry of validEntries) {
const inst = initialInstances.insertNewInitialInstance();
inst.setObjectName(entry.objectName);
inst.setLayer(entry.layer);
inst.setX(entry.x);
inst.setY(entry.y);
inst.setZ(entry.z);
if (entry.rotationX !== null) inst.setRotationX(entry.rotationX);
if (entry.rotationY !== null) inst.setRotationY(entry.rotationY);
if (entry.rotationZ !== null) inst.setAngle(entry.rotationZ);
}
onInstancesModifiedOutsideEditor({ scene: layout });
return {
success: true,
message: `Placed ${createdCount} 3D instance(s) in scene "${scene_name}".`,
message: `Placed ${
validEntries.length
} 3D instance(s) in scene "${scene_name}".`,
};
}
@@ -4751,11 +4812,7 @@ const put3dInstances: EditorFunction = {
args,
'existing_instance_ids'
);
const brush_position =
rawBrushPosition ||
(requested_brush_kind !== 'none' && !existing_instance_ids
? '0, 0, 0'
: null);
const brush_position = rawBrushPosition;
// A "none" brush with both a `brush_position` and `existing_instance_ids`
// is contradictory ("none" never positions anything) but unambiguous: move
// THOSE instances there. Read it as the "point" brush, which is what the
@@ -761,9 +761,14 @@ const PreferencesDialog = ({
type="number"
floatingLabelText={<Trans>Temperature (0.0 to 1.0)</Trans>}
value={values.aiCustomTemperature}
onChange={(e, value) =>
setAiCustomTemperature(parseFloat(value) || 0.7)
}
onChange={(e, value) => {
const parsed = parseFloat(value);
if (Number.isNaN(parsed)) {
setAiCustomTemperature(0.7);
} else {
setAiCustomTemperature(Math.max(0.0, Math.min(1.0, parsed)));
}
}}
/>
<LineStackLayout alignItems="center">
<RaisedButton
@@ -983,10 +983,8 @@ export default class PreferencesProvider extends React.Component<Props, State> {
_persistValuesToLocalStorage(preferences: Preferences): any {
try {
localStorage.setItem(
localStorageItem,
JSON.stringify(preferences.values)
);
const { aiCustomApiKey, ...persistableValues } = preferences.values;
localStorage.setItem(localStorageItem, JSON.stringify(persistableValues));
} catch (e) {
console.warn('Unable to persist preferences', e);
}
@@ -1511,7 +1509,6 @@ export default class PreferencesProvider extends React.Component<Props, State> {
},
}),
() => {
this._persistValuesToLocalStorage(this.state);
setCustomEndpointConfig({ apiKey: newValue });
}
);
@@ -23,6 +23,7 @@ import {
customCreateAssetSearch,
customCreateResourceSearch,
DEFAULT_LOCAL_AI_SETTINGS,
LOCAL_BYOK_USER_ID,
} from '../../AI/CustomAIClient';
export type Environment = 'staging' | 'live';
@@ -312,7 +313,7 @@ export const getAiRequest = async (
outputFromMessageId?: ?string,
|}
): Promise<AiRequest> => {
if (isCustomEndpointEnabled() || aiRequestId.startsWith('local-ai-')) {
if (aiRequestId.startsWith('local-ai-')) {
return customGetAiRequest(aiRequestId);
}
@@ -356,32 +357,33 @@ export const getAiRequestStatuses = async (
> => {
if (aiRequestIds.length === 0) return [];
if (
isCustomEndpointEnabled() ||
aiRequestIds.every(id => id.startsWith('local-ai-'))
) {
return customGetAiRequestStatuses(aiRequestIds);
}
const localIds = aiRequestIds.filter(id => id.startsWith('local-ai-'));
const hostedIds = aiRequestIds.filter(id => !id.startsWith('local-ai-'));
const authorizationHeader = await getAuthorizationHeader();
// $FlowFixMe[underconstrained-implicit-instantiation]
const response = await axios.get(
`${GDevelopGenerationApi.baseUrl}/ai-request`,
{
params: {
userId,
ids: aiRequestIds.join(','),
include: 'status',
},
headers: {
Authorization: authorizationHeader,
},
}
);
return ensureIsArray({
data: response.data,
endpointName: '/ai-request?ids=...&include=status of Generation API',
});
const results = [];
if (localIds.length > 0) {
const localStatuses = await customGetAiRequestStatuses(localIds);
results.push(...localStatuses);
}
if (hostedIds.length > 0) {
const authorizationHeader = await getAuthorizationHeader();
// $FlowFixMe[underconstrained-implicit-instantiation]
const response = await axios.get(
`${GDevelopGenerationApi.baseUrl}/ai-request`,
{
params: {
userId,
ids: hostedIds.join(','),
include: 'status',
},
headers: {
Authorization: authorizationHeader,
},
}
);
results.push(...(response.data || []));
}
return results;
};
export const getAiRequests = async (
@@ -543,7 +545,7 @@ export const addMessageToAiRequest = async (
toolsVersion?: string,
|}
): Promise<AiRequest> => {
if (isCustomEndpointEnabled() || aiRequestId.startsWith('local-ai-')) {
if (aiRequestId.startsWith('local-ai-')) {
return customAddMessageToAiRequest({
aiRequestId,
userMessage,
@@ -593,7 +595,7 @@ export const suspendAiRequest = async (
getAuthorizationHeader: () => Promise<string>,
{ userId, aiRequestId }: {| userId: string, aiRequestId: string |}
): Promise<AiRequest> => {
if (isCustomEndpointEnabled() || aiRequestId.startsWith('local-ai-')) {
if (aiRequestId.startsWith('local-ai-')) {
return customSuspendAiRequest(aiRequestId);
}
@@ -626,7 +628,7 @@ export const updateAiRequestMessage = async (
projectVersionIdAfterMessage?: ?string,
|}
): Promise<void> => {
if (isCustomEndpointEnabled() || aiRequestId.startsWith('local-ai-')) {
if (aiRequestId.startsWith('local-ai-')) {
return;
}
const authorizationHeader = await getAuthorizationHeader();
@@ -711,7 +713,7 @@ export const getAiRequestSuggestions = async (
projectSpecificExtensionsSummaryJsonUserRelativeKey: string | null,
|}
): Promise<AiRequest> => {
if (isCustomEndpointEnabled() || aiRequestId.startsWith('local-ai-')) {
if (aiRequestId.startsWith('local-ai-')) {
return customGetAiRequestSuggestions(aiRequestId);
}
@@ -749,7 +751,8 @@ export type CreateAiGeneratedEventResult =
|}
| {|
creationSucceeded: false,
errorMessage: string,
errorMessage?: string,
isCreditLimitReached?: boolean,
|};
export const createAiGeneratedEvent = async (
@@ -760,14 +763,14 @@ export const createAiGeneratedEvent = async (
gameProjectJsonUserRelativeKey,
projectSpecificExtensionsSummaryJson,
projectSpecificExtensionsSummaryJsonUserRelativeKey,
existingEventsJson,
existingEventsJsonUserRelativeKey,
sceneName,
eventsDescription,
eventBatches,
extensionNamesList,
objectsList,
existingEventsAsText,
existingEventsJson,
existingEventsJsonUserRelativeKey,
placementHint,
relatedAiRequestId,
estimatedComplexity,
@@ -777,14 +780,14 @@ export const createAiGeneratedEvent = async (
gameProjectJsonUserRelativeKey: string | null,
projectSpecificExtensionsSummaryJson: string | null,
projectSpecificExtensionsSummaryJsonUserRelativeKey: string | null,
existingEventsJson: string | null,
existingEventsJsonUserRelativeKey: string | null,
sceneName: string,
eventsDescription: string | null,
eventBatches: Array<AiGeneratedEventBatch> | null,
eventBatches: ?EventsBatchToGenerate,
extensionNamesList: string,
objectsList: string,
existingEventsAsText: string,
existingEventsJson: string | null,
existingEventsJsonUserRelativeKey: string | null,
placementHint: string | null,
relatedAiRequestId: string,
estimatedComplexity: number | null,
@@ -810,14 +813,14 @@ export const createAiGeneratedEvent = async (
gameProjectJsonUserRelativeKey,
projectSpecificExtensionsSummaryJson,
projectSpecificExtensionsSummaryJsonUserRelativeKey,
existingEventsJson,
existingEventsJsonUserRelativeKey,
sceneName,
eventsDescription,
eventBatches,
extensionNamesList,
objectsList,
existingEventsAsText,
existingEventsJson,
existingEventsJsonUserRelativeKey,
placementHint,
relatedAiRequestId,
estimatedComplexity,
@@ -829,32 +832,34 @@ export const createAiGeneratedEvent = async (
headers: {
Authorization: authorizationHeader,
},
validateStatus: status => true,
}
);
if (response.status === 200) {
const data = ensureObjectHasProperty({
data: response.data,
propertyName: 'id',
endpointName: '/ai-generated-event of Generation API',
});
return {
creationSucceeded: true,
aiGeneratedEvent: data,
};
} else if (response.status === 400) {
// Report the failure to give a chance to the caller to save this message
// and retry the generation in a different way.
return {
creationSucceeded: false,
errorMessage: JSON.stringify(response.data),
aiGeneratedEvent: ensureObjectHasProperty({
data: response.data,
propertyName: 'id',
endpointName: '/ai-generated-event of Generation API',
}),
};
}
// Unexpected error: throw an exception as this can be something like a network error
// or a server error.
throw new Error(
const errorStatusAndCode = extractGDevelopApiErrorStatusAndCode(
response.data
);
if (
errorStatusAndCode &&
errorStatusAndCode.code === 'credit-limit-reached'
) {
return {
creationSucceeded: false,
isCreditLimitReached: true,
};
}
return makeGenericFailureResult(
`Error while running AI event generation: ${response.statusText}`
);
};
@@ -869,15 +874,12 @@ export const getAiGeneratedEvent = async (
aiGeneratedEventId: string,
|}
): Promise<AiGeneratedEvent> => {
if (
isCustomEndpointEnabled() ||
aiGeneratedEventId.startsWith('local-evt-')
) {
if (aiGeneratedEventId.startsWith('local-evt-')) {
return {
id: aiGeneratedEventId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId: 'local-byok-user',
userId: LOCAL_BYOK_USER_ID,
status: 'ready',
partialGameProjectJson: '',
eventsDescription: null,
@@ -918,8 +920,8 @@ export const createAssetSearch = async (
{
userId,
searchTerms,
description,
objectType,
description,
twoDimensionalViewKind,
exactOrPartialAssetId,
relatedAiRequestId,
@@ -928,12 +930,12 @@ export const createAssetSearch = async (
}: {|
userId: string,
searchTerms: string,
description: string,
objectType: string | null,
twoDimensionalViewKind: string,
exactOrPartialAssetId?: string | null,
relatedAiRequestId?: string | null,
lastUserMessage?: string | null,
objectType?: ?string,
description?: ?string,
twoDimensionalViewKind?: ?string,
exactOrPartialAssetId?: ?string,
relatedAiRequestId?: ?string,
lastUserMessage?: ?string,
lastAssistantMessages?: string[],
|}
): Promise<AssetSearch> => {
@@ -945,10 +947,9 @@ export const createAssetSearch = async (
const response = await apiClient.post(
`/asset-search`,
{
gdevelopVersionWithHash: getIDEVersionWithHash(),
searchTerms,
description,
objectType,
description,
twoDimensionalViewKind,
exactOrPartialAssetId,
relatedAiRequestId,
@@ -991,7 +992,6 @@ export const createResourceSearch = async (
const response = await apiClient.post(
`/resource-search`,
{
gdevelopVersionWithHash: getIDEVersionWithHash(),
searchTerms,
resourceKind,
},
@@ -1048,7 +1048,6 @@ export const createAiUserContentPresignedUrls = async (
const response = await apiClient.post(
`/ai-user-content/action/create-presigned-urls`,
{
gdevelopVersionWithHash: getIDEVersionWithHash(),
gameProjectJsonHash,
projectSpecificExtensionsSummaryJsonHash,
eventsJsonHash,
@@ -1062,8 +1061,9 @@ export const createAiUserContentPresignedUrls = async (
},
}
);
return ensureIsObject({
return ensureObjectHasProperty({
data: response.data,
propertyName: 'gameProjectJsonSignedUrl',
endpointName:
'/ai-user-content/action/create-presigned-urls of Generation API',
});
@@ -1097,7 +1097,7 @@ export const forkAiRequest = async (
upToMessageId?: string,
|}
): Promise<AiRequest> => {
if (isCustomEndpointEnabled() || aiRequestId.startsWith('local-ai-')) {
if (aiRequestId.startsWith('local-ai-')) {
return customForkAiRequest(aiRequestId, upToMessageId);
}
@@ -16,7 +16,6 @@ import {
ensureIsObjectWithPropertyOfType,
} from '../DataValidator';
import { type BundleListingData } from './Shop';
import { isCustomEndpointEnabled } from '../../AI/CustomAIClient';
export type Usage = {
id: string,
@@ -584,10 +583,6 @@ export const isSubscriptionComingFromTeam = (
export const hasValidSubscriptionPlan = (
subscription: ?Subscription
): boolean => {
if (isCustomEndpointEnabled()) {
return true;
}
const hasValidSubscription =
!!subscription &&
!!subscription.planId &&
@@ -763,10 +758,6 @@ export const canBenefitFromSocialRole = (
export const canUpgradeSubscription = (
subscription: ?Subscription
): false | true | boolean => {
if (isCustomEndpointEnabled()) {
return false;
}
return (
!!subscription &&
// $FlowFixMe[incompatible-type]