feat(ai): add Bring Your Own Key (BYOK) custom OpenAI-compatible endpoint support

- Add CustomAIClient with full OpenAI chat completions, reasoning/thinking extraction, tool calling, and local request caching
- Add complete tool schemas and robust argument extractors for all 29 GDevelop editor functions
- Add AI Settings tab in Preferences to configure custom endpoint URL, API Key, Model ID, temperature, and streaming
- Bypass cloud subscription/credit entitlement gates and S3 presigned URL uploads when custom endpoint is active
- Support batch instance placement in put_2d_instances / put_3d_instances and flexible parameter aliases
This commit is contained in:
John Smith
2026-08-19 15:27:38 -04:00
parent 7582465da2
commit c3989ddd56
22 changed files with 3525 additions and 422 deletions
-194
View File
@@ -74,31 +74,6 @@
"node": ">=12"
}
},
"node_modules/@pixi/assets": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/assets/-/assets-7.4.3.tgz",
"integrity": "sha512-StvjiJBSp/j9hHkGu8AFHNvwYUazXq64WhyhytztyDMRkg/l/cL7EcttY5T0qZNWlIpccdr60LUKrWDOuMpkiw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/css-font-loading-module": "^0.0.12"
},
"peerDependencies": {
"@pixi/core": "7.4.3"
}
},
"node_modules/@pixi/color": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz",
"integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/colord": "^2.9.6"
}
},
"node_modules/@pixi/colord": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
@@ -106,175 +81,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@pixi/constants": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz",
"integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@pixi/core": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/core/-/core-7.4.3.tgz",
"integrity": "sha512-5YDs11faWgVVTL8VZtLU05/Fl47vaP5Tnsbf+y/WRR0VSW3KhRRGTBU1J3Gdc2xEWbJhUK07KGP7eSZpvtPVgA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/color": "7.4.3",
"@pixi/constants": "7.4.3",
"@pixi/extensions": "7.4.3",
"@pixi/math": "7.4.3",
"@pixi/runner": "7.4.3",
"@pixi/settings": "7.4.3",
"@pixi/ticker": "7.4.3",
"@pixi/utils": "7.4.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/pixijs"
}
},
"node_modules/@pixi/display": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/display/-/display-7.4.3.tgz",
"integrity": "sha512-b5m2dAaoNAVdxz1oDaxl3XZ059NEOcNtGkxTOZ4EYCw/jcp9sZXkgSROHRzsGn4k+NugH7+9MP4Id2Z0kkdUhw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@pixi/core": "7.4.3"
}
},
"node_modules/@pixi/events": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/events/-/events-7.4.3.tgz",
"integrity": "sha512-o3j/5Dxq6WDVS6eHfURB/cf/MP+NcsF/eC5PnbSHjXxJmDE7PoTVwLvxexm5uuvNRpFh/6/Fn0V8Vl4gV8sc8w==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@pixi/core": "7.4.3",
"@pixi/display": "7.4.3"
}
},
"node_modules/@pixi/extensions": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz",
"integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@pixi/graphics": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/graphics/-/graphics-7.4.3.tgz",
"integrity": "sha512-wWLivD8/URb8A7X4TqCZGG39C91IE+aOuWY/z9NCz5Z6WvA/VWnsc5fLTlO+ggjGHgKF0cSucCXZfUe1wm0AOQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@pixi/core": "7.4.3",
"@pixi/display": "7.4.3",
"@pixi/sprite": "7.4.3"
}
},
"node_modules/@pixi/math": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz",
"integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@pixi/mesh": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/mesh/-/mesh-7.4.3.tgz",
"integrity": "sha512-CikqFPtKvU3Zj986/MSoC8X39CWv5CEpiEW/tYp47p4tgQNDSkNWYnDiNYgb+4VX6pNsBrgX4DALLdTR17SlSA==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@pixi/core": "7.4.3",
"@pixi/display": "7.4.3"
}
},
"node_modules/@pixi/runner": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz",
"integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@pixi/settings": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz",
"integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/constants": "7.4.3",
"@types/css-font-loading-module": "^0.0.12",
"ismobilejs": "^1.1.0"
}
},
"node_modules/@pixi/sprite": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/sprite/-/sprite-7.4.3.tgz",
"integrity": "sha512-iNBrpOFF9nXDT6m2jcyYy6l/sRzklLDDck1eFHprHZwvNquY2nzRfh+RGBCecxhBcijiLJ3fsZN33fP0LDXkvw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@pixi/core": "7.4.3",
"@pixi/display": "7.4.3"
}
},
"node_modules/@pixi/text": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/text/-/text-7.4.3.tgz",
"integrity": "sha512-IAF0iu04rPg3oiL0HZsEZI44fpJxq3UZ4xTmx8l1RyhhSXiElLvvSlSH57vt/BKMQZtCs+AqEit7yn8heK2+nQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@pixi/core": "7.4.3",
"@pixi/sprite": "7.4.3"
}
},
"node_modules/@pixi/ticker": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz",
"integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/extensions": "7.4.3",
"@pixi/settings": "7.4.3",
"@pixi/utils": "7.4.3"
}
},
"node_modules/@pixi/utils": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz",
"integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/color": "7.4.3",
"@pixi/constants": "7.4.3",
"@pixi/settings": "7.4.3",
"@types/earcut": "^2.1.0",
"earcut": "^2.2.4",
"eventemitter3": "^4.0.0",
"url": "^0.11.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+26 -132
View File
@@ -39,6 +39,7 @@
"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",
@@ -82,6 +83,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",
"chokidar": "4.0.3",
"concurrently": "9.2.1",
"flow-bin": "0.299.0",
@@ -94,6 +96,7 @@
"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",
@@ -3511,13 +3514,6 @@
"xmlhttprequest": "1.8.0"
}
},
"node_modules/@firebase/app-types": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz",
"integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==",
"license": "Apache-2.0",
"peer": true
},
"node_modules/@firebase/auth": {
"version": "0.0.900-exp.d92a36260",
"resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-0.0.900-exp.d92a36260.tgz",
@@ -14082,34 +14078,36 @@
}
},
"node_modules/babel-plugin-macros": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz",
"integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"cosmiconfig": "^6.0.0",
"resolve": "^1.12.0"
"@babel/runtime": "^7.12.5",
"cosmiconfig": "^7.0.0",
"resolve": "^1.19.0"
},
"engines": {
"node": ">=10",
"npm": ">=6"
}
},
"node_modules/babel-plugin-macros/node_modules/cosmiconfig": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz",
"integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/parse-json": "^4.0.0",
"import-fresh": "^3.1.0",
"import-fresh": "^3.2.1",
"parse-json": "^5.0.0",
"path-type": "^4.0.0",
"yaml": "^1.7.2"
"yaml": "^1.10.0"
},
"engines": {
"node": ">=8"
"node": ">=10"
}
},
"node_modules/babel-plugin-macros/node_modules/import-fresh": {
@@ -14118,7 +14116,6 @@
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
@@ -14136,7 +14133,6 @@
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
@@ -14156,18 +14152,16 @@
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=4"
}
},
"node_modules/babel-plugin-macros/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"dev": true,
"license": "ISC",
"peer": true,
"engines": {
"node": ">= 6"
}
@@ -14362,95 +14356,6 @@
"babel-plugin-transform-react-remove-prop-types": "^0.4.24"
}
},
"node_modules/babel-preset-react-app/node_modules/babel-plugin-macros": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"cosmiconfig": "^7.0.0",
"resolve": "^1.19.0"
},
"engines": {
"node": ">=10",
"npm": ">=6"
}
},
"node_modules/babel-preset-react-app/node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/parse-json": "^4.0.0",
"import-fresh": "^3.2.1",
"parse-json": "^5.0.0",
"path-type": "^4.0.0",
"yaml": "^1.10.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/babel-preset-react-app/node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/babel-preset-react-app/node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/babel-preset-react-app/node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/babel-preset-react-app/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
@@ -34246,13 +34151,12 @@
}
},
"node_modules/react-dnd-html5-backend": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-11.1.3.tgz",
"integrity": "sha512-/1FjNlJbW/ivkUxlxQd7o3trA5DE33QiRZgxent3zKme8DwF4Nbw3OFVhTRFGaYhHFNL1rZt6Rdj1D78BjnNLw==",
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz",
"integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==",
"license": "MIT",
"peer": true,
"dependencies": {
"dnd-core": "^11.1.3"
"dnd-core": "14.0.1"
}
},
"node_modules/react-dnd-multi-backend": {
@@ -34541,15 +34445,6 @@
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/react-mosaic-component/node_modules/react-dnd-html5-backend": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz",
"integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==",
"license": "MIT",
"dependencies": {
"dnd-core": "14.0.1"
}
},
"node_modules/react-mosaic-component/node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
@@ -34566,7 +34461,6 @@
"integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
+3
View File
@@ -22,6 +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",
"chokidar": "4.0.3",
"concurrently": "9.2.1",
"flow-bin": "0.299.0",
@@ -34,6 +35,7 @@
"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",
@@ -76,6 +78,7 @@
"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",
File diff suppressed because it is too large Load Diff
+470
View File
@@ -0,0 +1,470 @@
// @flow
import axios from 'axios';
import {
getCustomEndpointConfig,
setCustomEndpointConfig,
isCustomEndpointEnabled,
normalizeBaseUrl,
getEndpointUrl,
extractThinkingAndContent,
transformGDevelopMessagesToOpenAi,
parseAssistantMessage,
DEFAULT_CUSTOM_AI_CONFIG,
DEFAULT_LOCAL_AI_SETTINGS,
GDEVELOP_OPENAI_TOOLS,
customCreateAiRequest,
customAddMessageToAiRequest,
customGetAiRequest,
customGetAiRequests,
customGetAiRequestStatuses,
customSuspendAiRequest,
customForkAiRequest,
customGetAiRequestSuggestions,
customCreateAiGeneratedEvent,
customCreateAssetSearch,
customCreateResourceSearch,
testConnection,
} from './CustomAIClient';
jest.mock('axios');
describe('CustomAIClient', () => {
beforeEach(() => {
jest.clearAllMocks();
setCustomEndpointConfig({
enabled: false,
baseUrl: 'http://localhost:11434/v1',
apiKey: '',
model: 'qwen2.5-coder',
temperature: 0.7,
});
});
describe('Configuration & URL normalization', () => {
it('returns default configuration initially', () => {
const config = getCustomEndpointConfig();
expect(config.baseUrl).toBe('http://localhost:11434/v1');
expect(config.model).toBe('qwen2.5-coder');
expect(config.temperature).toBe(0.7);
});
it('updates configuration and reflects enabled state', () => {
expect(isCustomEndpointEnabled()).toBe(false);
setCustomEndpointConfig({
enabled: true,
baseUrl: 'http://localhost:8080/v1',
apiKey: 'test-key',
model: 'llama-3.2',
temperature: 0.2,
});
expect(isCustomEndpointEnabled()).toBe(true);
const config = getCustomEndpointConfig();
expect(config.baseUrl).toBe('http://localhost:8080/v1');
expect(config.apiKey).toBe('test-key');
expect(config.model).toBe('llama-3.2');
expect(config.temperature).toBe(0.2);
});
it('normalizes base URLs correctly', () => {
expect(normalizeBaseUrl('http://localhost:11434')).toBe(
'http://localhost:11434/v1'
);
expect(normalizeBaseUrl('http://localhost:11434/')).toBe(
'http://localhost:11434/v1'
);
expect(normalizeBaseUrl('http://localhost:11434/v1/')).toBe(
'http://localhost:11434/v1'
);
expect(normalizeBaseUrl('https://api.openai.com/v1')).toBe(
'https://api.openai.com/v1'
);
expect(normalizeBaseUrl('https://openrouter.ai/api/v1/')).toBe(
'https://openrouter.ai/api/v1'
);
expect(normalizeBaseUrl('')).toBe(DEFAULT_CUSTOM_AI_CONFIG.baseUrl);
});
it('constructs correct endpoint URLs', () => {
expect(
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'
);
});
});
describe('extractThinkingAndContent', () => {
it('extracts <think> tags from text', () => {
const input =
'<think>I should create an object</think>Here is the answer';
const result = extractThinkingAndContent(input);
expect(result.thinking).toBe('I should create an object');
expect(result.content).toBe('Here is the answer');
});
it('handles text without <think> tags', () => {
const input = 'Simple response with no thinking tag';
const result = extractThinkingAndContent(input);
expect(result.thinking).toBeNull();
expect(result.content).toBe('Simple response with no thinking tag');
});
it('handles empty or non-string input', () => {
expect(extractThinkingAndContent('')).toEqual({
thinking: null,
cleanContent: '',
content: '',
});
// $FlowFixMe
expect(extractThinkingAndContent(null)).toEqual({
thinking: null,
cleanContent: '',
content: '',
});
});
});
describe('transformGDevelopMessagesToOpenAi', () => {
it('formats user messages and system prompt', () => {
const messages = [
{
id: 'msg-1',
type: 'user',
text: 'Hello, create a player object',
createdAt: new Date().toISOString(),
},
];
const openAiMessages = transformGDevelopMessagesToOpenAi(
messages,
'{"objects":[]}',
null,
'agent'
);
expect(openAiMessages[0].role).toBe('system');
expect(openAiMessages[0].content).toContain('GDevelop AI Assistant');
expect(openAiMessages[0].content).toContain('{"objects":[]}');
const userMsg = openAiMessages.find(m => m.role === 'user');
expect(userMsg).toBeDefined();
expect(userMsg && userMsg.content).toBe('Hello, create a player object');
});
it('formats assistant messages with function calls and reasoning', () => {
const messages = [
{
id: 'msg-1',
type: 'user',
text: 'Add sprite',
createdAt: new Date().toISOString(),
},
{
id: 'msg-2',
type: 'assistant',
text: 'I am creating the object',
thinking: 'Let us make Player',
functionCalls: [
{
id: 'call-1',
name: 'create_or_replace_object',
callArguments: { objectName: 'Player', objectType: 'Sprite' },
},
],
createdAt: new Date().toISOString(),
},
{
id: 'msg-3',
type: 'function_call_output',
functionCallOutputs: [
{
callId: 'call-1',
output: '{"status":"ok"}',
},
],
createdAt: new Date().toISOString(),
},
];
const openAiMessages = transformGDevelopMessagesToOpenAi(
messages,
null,
null,
'agent'
);
const assistantMsg = openAiMessages.find(m => m.role === 'assistant');
expect(assistantMsg).toBeDefined();
expect(assistantMsg && assistantMsg.content).toBe(
'I am creating the object'
);
expect(assistantMsg && assistantMsg.tool_calls).toBeDefined();
expect(
assistantMsg &&
assistantMsg.tool_calls &&
assistantMsg.tool_calls[0].function.name
).toBe('create_or_replace_object');
const toolMsg = openAiMessages.find(m => m.role === 'tool');
expect(toolMsg).toBeDefined();
expect(toolMsg && toolMsg.tool_call_id).toBe('call-1');
expect(toolMsg && toolMsg.content).toBe('{"status":"ok"}');
});
});
describe('parseAssistantMessage', () => {
it('parses standard OpenAI tool calls', () => {
const choice = {
message: {
role: 'assistant',
content: 'Creating a scene',
tool_calls: [
{
id: 'call_123',
type: 'function',
function: {
name: 'create_scene',
arguments: '{"sceneName":"Level1"}',
},
},
],
},
};
const parsed = parseAssistantMessage(choice);
expect(parsed.text).toBe('Creating a scene');
expect(parsed.functionCalls).toHaveLength(1);
expect(parsed.functionCalls[0].name).toBe('create_scene');
expect(parsed.functionCalls[0].callArguments).toEqual({
sceneName: 'Level1',
});
});
it('extracts embedded JSON function call markdown if tool_calls not present', () => {
const choice = {
message: {
role: 'assistant',
content:
'I will create the object now:\n```json\n{"name":"create_or_replace_object","arguments":{"objectName":"Coin"}}\n```',
},
};
const parsed = parseAssistantMessage(choice);
expect(parsed.functionCalls).toHaveLength(1);
expect(parsed.functionCalls[0].name).toBe('create_or_replace_object');
expect(parsed.functionCalls[0].callArguments).toEqual({
objectName: 'Coin',
});
});
it('extracts reasoning_content if provided by model', () => {
const choice = {
message: {
role: 'assistant',
content: 'Done!',
reasoning_content: 'Step 1: check parameters. Step 2: verify.',
},
};
const parsed = parseAssistantMessage(choice);
expect(parsed.text).toBe('Done!');
expect(parsed.thinking).toBe('Step 1: check parameters. Step 2: verify.');
});
});
describe('GDevelop OpenAI Tool Definitions', () => {
it('defines standard GDevelop tools', () => {
const toolNames = GDEVELOP_OPENAI_TOOLS.map(t => t.function.name);
expect(toolNames).toContain('create_scene');
expect(toolNames).toContain('create_or_replace_object');
expect(toolNames).toContain('add_behavior');
expect(toolNames).toContain('put_2d_instances');
expect(toolNames).toContain('add_scene_events');
expect(toolNames).toContain('run_script');
expect(toolNames).toContain('create_or_update_plan');
});
});
describe('testConnection', () => {
it('successfully connects when endpoint responds', async () => {
// $FlowFixMe
axios.get.mockResolvedValueOnce({
status: 200,
data: {
data: [{ id: 'qwen2.5-coder' }, { id: 'gpt-4o' }],
},
});
const result = await testConnection({
enabled: true,
baseUrl: 'http://localhost:11434/v1',
apiKey: '',
model: 'qwen2.5-coder',
temperature: 0.7,
});
expect(result.success).toBe(true);
expect(result.models).toContain('qwen2.5-coder');
});
it('handles endpoint error gracefully', async () => {
// $FlowFixMe
axios.get.mockRejectedValueOnce(new Error('Connection refused'));
// $FlowFixMe
axios.post.mockRejectedValueOnce(new Error('Connection refused'));
const result = await testConnection({
enabled: true,
baseUrl: 'http://localhost:9999/v1',
apiKey: '',
model: 'test-model',
temperature: 0.7,
});
expect(result.success).toBe(false);
expect(result.message).toContain('Connection failed');
});
});
describe('Request Store & Lifecycle Methods', () => {
it('creates, retrieves, and updates local AI requests', async () => {
// $FlowFixMe
axios.post.mockResolvedValueOnce({
status: 200,
data: {
choices: [
{
message: {
role: 'assistant',
content: 'I have processed your request.',
},
},
],
},
});
const aiRequest = await customCreateAiRequest({
userRequest: 'Hello test AI',
gameProjectJson: null,
projectSpecificExtensionsSummaryJson: null,
mode: 'chat',
aiConfiguration: { presetId: 'default' },
gameId: null,
});
expect(aiRequest.id).toMatch(/^local-ai-/);
expect(aiRequest.status).toBe('ready');
expect(aiRequest.output.length).toBeGreaterThanOrEqual(2); // user + assistant
const fetched = await customGetAiRequest(aiRequest.id);
expect(fetched.id).toBe(aiRequest.id);
const all = await customGetAiRequests();
expect(all.aiRequests.some(r => r.id === aiRequest.id)).toBe(true);
const statuses = await customGetAiRequestStatuses([aiRequest.id]);
expect(statuses).toEqual([
{ id: aiRequest.id, status: 'ready', userId: 'local-byok-user' },
]);
});
it('suspends an active AI request', async () => {
// $FlowFixMe
axios.post.mockResolvedValueOnce({
status: 200,
data: {
choices: [{ message: { role: 'assistant', content: 'Working...' } }],
},
});
const aiRequest = await customCreateAiRequest({
userRequest: 'Test suspend',
gameProjectJson: null,
projectSpecificExtensionsSummaryJson: null,
mode: 'chat',
aiConfiguration: { presetId: 'default' },
gameId: null,
});
const suspended = await customSuspendAiRequest(aiRequest.id);
expect(suspended.status).toBe('suspended');
});
it('forks an AI request up to a message id', async () => {
// $FlowFixMe
axios.post.mockResolvedValueOnce({
status: 200,
data: {
choices: [{ message: { role: 'assistant', content: 'Fork me' } }],
},
});
const aiRequest = await customCreateAiRequest({
userRequest: 'Original thread',
gameProjectJson: null,
projectSpecificExtensionsSummaryJson: null,
mode: 'chat',
aiConfiguration: { presetId: 'default' },
gameId: null,
});
const forked = await customForkAiRequest(aiRequest.id);
expect(forked.id).not.toBe(aiRequest.id);
expect(forked.output.length).toBe(aiRequest.output.length);
});
it('returns suggestions for an AI request', async () => {
const suggestions = await customGetAiRequestSuggestions('local-ai-123');
expect(suggestions.suggestions).toHaveLength(3);
expect(suggestions.suggestions[0].suggestedMessage).toBeDefined();
});
it('generates event changes with customCreateAiGeneratedEvent', async () => {
// $FlowFixMe
axios.post.mockResolvedValueOnce({
status: 200,
data: {
choices: [
{
message: {
role: 'assistant',
content:
'```json\n[{"type":"add_events","events":[{"type":"BuiltinCommonInstructions::Standard","conditions":[],"actions":[]}]}]\n```',
},
},
],
},
});
const result = await customCreateAiGeneratedEvent({
sceneName: 'MainScene',
eventsDescription: 'Move player right when key pressed',
eventBatches: null,
extensionNamesList: '',
objectsList: 'Player',
existingEventsAsText: '',
});
expect(result.creationSucceeded).toBe(true);
if (result.creationSucceeded) {
expect(result.aiGeneratedEvent.changes.length).toBeGreaterThan(0);
}
});
it('provides mock asset and resource search results', async () => {
const assetResult = await customCreateAssetSearch({
searchTerms: 'coin',
objectType: 'Sprite',
});
expect(assetResult.id).toMatch(/^local-asset-/);
expect(assetResult.results.length).toBeGreaterThan(0);
const resourceResult = await customCreateResourceSearch({
searchTerms: 'jump sound',
resourceKind: 'audio',
});
expect(resourceResult.id).toMatch(/^local-resource-/);
expect(resourceResult.results.length).toBeGreaterThan(0);
});
});
});
+14 -4
View File
@@ -4,6 +4,10 @@ import {
type AiConfigurationPreset,
type AiSettings,
} from '../Utils/GDevelopServices/Generation';
import {
isCustomEndpointEnabled,
DEFAULT_LOCAL_AI_SETTINGS,
} from '../AI/CustomAIClient';
export type AiConfigurationPresetWithAvailability = {|
...AiConfigurationPreset,
@@ -19,9 +23,15 @@ export const getAiConfigurationPresetsWithAvailability = ({
getAiSettings: () => AiSettings | null,
limits: ?Limits,
|}): Array<AiConfigurationPresetWithAvailability> => {
const aiSettings = getAiSettings();
if (!aiSettings) {
return [];
const aiSettings = getAiSettings() || DEFAULT_LOCAL_AI_SETTINGS;
if (isCustomEndpointEnabled()) {
return aiSettings.aiRequest.presets.map(preset => ({
...preset,
enableWith: null,
enabledWithPlans: [],
disabled: false,
}));
}
if (!limits) {
@@ -29,7 +39,7 @@ export const getAiConfigurationPresetsWithAvailability = ({
...preset,
enableWith: null,
enabledWithPlans: [],
disabled: preset.isDefault ? false : true,
disabled: false,
}));
}
@@ -43,6 +43,7 @@ import PreferencesContext from '../../MainFrame/Preferences/PreferencesContext';
import Coin from '../../Credits/Icons/Coin';
import { CreditsPackageStoreContext } from '../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
import RobotIcon from '../../ProjectCreation/RobotIcon';
import { isCustomEndpointEnabled } from '../../AI/CustomAIClient';
import { Divider } from '@material-ui/core';
import CheckCircle from '@material-ui/icons/CheckCircle';
import Link from '../../UI/Link';
@@ -221,7 +222,8 @@ export const ChatMessages: React.ComponentType<Props> = React.memo<Props>(
const availableCredits = limits ? limits.credits.userBalance.amount : 0;
const quota =
(limits && limits.quotas && limits.quotas['consumed-ai-credits']) || null;
const hasReachedLimit = !!quota && quota.limitReached;
const hasReachedLimit =
!isCustomEndpointEnabled() && !!quota && quota.limitReached;
const { hasUnsavedChanges } = React.useContext(UnsavedChangesContext);
const {
@@ -63,6 +63,10 @@ import Stop from '../../UI/CustomSvgIcons/Stop';
import AutoEditButton from './AutoEditButton';
import { EditApprovalRow } from './EditApprovalRow';
import { type EditApprovalRequest } from '../Utils';
import {
isCustomEndpointEnabled,
getCustomEndpointConfig,
} from '../../AI/CustomAIClient';
const TOO_MANY_USER_MESSAGES_WARNING_COUNT = 15;
const TOO_MANY_USER_MESSAGES_ERROR_COUNT = 20;
@@ -148,6 +152,17 @@ const getPriceAndRequestsTextAndTooltip = ({
onOpenSubscriptionDialog: () => void,
hideLabel?: boolean,
|}): React.Node => {
if (isCustomEndpointEnabled()) {
const config = getCustomEndpointConfig();
return (
<div style={styles.quotaContainer}>
<Text size="body-small" color="secondary" noMargin>
<Trans>BYOK: {config.model || 'Custom Model'}</Trans>
</Text>
</div>
);
}
if (!quota || !price) {
if (isRefreshingLimits) {
// No value yet: show only the indeterminate bar and the (i) icon, no label.
@@ -749,6 +764,7 @@ export const AiRequestChat: React.ComponentType<{
const doesNotHaveEnoughCreditsToContinue =
!!price && availableCredits < price.priceInCredits;
const cannotContinue =
!isCustomEndpointEnabled() &&
!!quota &&
quota.limitReached &&
(!automaticallyUseCreditsForAiRequests ||
@@ -43,6 +43,7 @@ import {
canUpgradeSubscription,
hasValidSubscriptionPlan,
} from '../Utils/GDevelopServices/Usage';
import { isCustomEndpointEnabled } from '../AI/CustomAIClient';
import { retryIfFailed } from '../Utils/RetryIfFailed';
import { type EditorCallbacks } from '../EditorFunctions';
import {
@@ -462,13 +463,27 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const {
profile,
getAuthorizationHeader,
getAuthorizationHeader: rawGetAuthorizationHeader,
onOpenCreateAccountDialog,
limits,
onRefreshLimits,
subscription,
} = authenticatedUser;
const getAuthorizationHeader = React.useCallback(
async (): Promise<string> => {
try {
return await rawGetAuthorizationHeader();
} catch (error) {
if (isCustomEndpointEnabled()) {
return 'Bearer local-byok-token';
}
throw error;
}
},
[rawGetAuthorizationHeader]
);
const { isRefreshingLimits, refreshLimits } = useRefreshLimits(
onRefreshLimits
);
@@ -507,12 +522,14 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
if (!newAiRequestOptions) return;
console.info('Starting a new AI request...');
if (!profile) {
if (!profile && !isCustomEndpointEnabled()) {
onOpenCreateAccountDialog();
startNewAiRequest(null);
return;
}
const activeUserId = profile ? profile.id : 'local-byok-user';
// Read the options and reset them (to avoid launching the same request twice).
const {
mode,
@@ -524,7 +541,12 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
// 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 (
quota &&
quota.limitReached &&
aiRequestPriceInCredits &&
!isCustomEndpointEnabled()
) {
payWithCredits = true;
const doesNotHaveEnoughCreditsToContinue =
availableCredits < aiRequestPriceInCredits;
@@ -558,7 +580,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
const preparedAiUserContent = await prepareAiUserContent({
getAuthorizationHeader,
userId: profile.id,
userId: activeUserId,
simplifiedProjectJson,
projectSpecificExtensionsSummaryJson,
eventsJson: null,
@@ -566,7 +588,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
const aiRequest = await createAiRequest(getAuthorizationHeader, {
userRequest: userRequest,
userId: profile.id,
userId: activeUserId,
gameProjectJsonUserRelativeKey:
preparedAiUserContent.gameProjectJsonUserRelativeKey,
gameProjectJson: preparedAiUserContent.gameProjectJson,
@@ -666,7 +688,9 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
createdProject?: ?gdProject,
editorFunctionCallResults: Array<EditorFunctionCallResult>,
|}) => {
if (!profile) return;
if (!profile && !isCustomEndpointEnabled()) return;
const activeUserId = profile ? profile.id : 'local-byok-user';
const aiRequestForMessage = aiRequests[aiRequestId];
if (!aiRequestForMessage) return;
@@ -717,7 +741,8 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
userMessage &&
quota &&
quota.limitReached &&
aiRequestPriceInCredits
aiRequestPriceInCredits &&
!isCustomEndpointEnabled()
) {
payWithCredits = true;
const doesNotHaveEnoughCreditsToContinue =
@@ -760,7 +785,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
const preparedAiUserContent = await prepareAiUserContent({
getAuthorizationHeader,
userId: profile.id,
userId: activeUserId,
simplifiedProjectJson,
projectSpecificExtensionsSummaryJson,
eventsJson: null,
@@ -778,7 +803,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
const aiRequest: AiRequest = await retryIfFailed({ times: 2 }, () =>
addMessageToAiRequest(getAuthorizationHeader, {
userId: profile.id,
userId: activeUserId,
aiRequestId,
functionCallOutputs,
gameProjectJsonUserRelativeKey:
@@ -38,6 +38,7 @@ 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 {
useProcessFunctionCalls,
useRefreshLimits,
@@ -202,16 +203,31 @@ export const AskAiStandAloneForm = ({
const {
values: { automaticallyUseCreditsForAiRequests },
} = React.useContext(PreferencesContext);
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const {
profile,
getAuthorizationHeader,
getAuthorizationHeader: rawGetAuthorizationHeader,
onOpenCreateAccountDialog,
limits,
onRefreshLimits,
subscription,
} = React.useContext(AuthenticatedUserContext);
} = authenticatedUser;
const { openSubscriptionDialog } = React.useContext(SubscriptionContext);
const getAuthorizationHeader = React.useCallback(
async (): Promise<string> => {
try {
return await rawGetAuthorizationHeader();
} catch (error) {
if (isCustomEndpointEnabled()) {
return 'Bearer local-byok-token';
}
throw error;
}
},
[rawGetAuthorizationHeader]
);
const { isRefreshingLimits, refreshLimits } = useRefreshLimits(
onRefreshLimits
);
@@ -251,12 +267,14 @@ export const AskAiStandAloneForm = ({
if (!newAiRequestOptions) return;
console.info('Starting a new AI request...');
if (!profile) {
if (!profile && !isCustomEndpointEnabled()) {
onOpenCreateAccountDialog();
startNewAiRequest(null);
return;
}
const activeUserId = profile ? profile.id : 'local-byok-user';
// 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
// closeProject causes project to become null).
@@ -275,7 +293,12 @@ export const AskAiStandAloneForm = ({
// 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 (
quota &&
quota.limitReached &&
aiRequestPriceInCredits &&
!isCustomEndpointEnabled()
) {
payWithCredits = true;
const doesNotHaveEnoughCreditsToContinue =
availableCredits < aiRequestPriceInCredits;
@@ -299,7 +322,7 @@ export const AskAiStandAloneForm = ({
const preparedAiUserContent = await prepareAiUserContent({
getAuthorizationHeader,
userId: profile.id,
userId: activeUserId,
simplifiedProjectJson: null,
projectSpecificExtensionsSummaryJson: null,
eventsJson: null,
@@ -307,7 +330,7 @@ export const AskAiStandAloneForm = ({
const aiRequest = await createAiRequest(getAuthorizationHeader, {
userRequest: userRequest,
userId: profile.id,
userId: activeUserId,
gameProjectJsonUserRelativeKey:
preparedAiUserContent.gameProjectJsonUserRelativeKey,
gameProjectJson: preparedAiUserContent.gameProjectJson,
@@ -480,9 +503,11 @@ export const AskAiStandAloneForm = ({
)
: null;
const activeUserId = profile ? profile.id : 'local-byok-user';
const preparedAiUserContent = await prepareAiUserContent({
getAuthorizationHeader,
userId: profile.id,
userId: activeUserId,
simplifiedProjectJson,
projectSpecificExtensionsSummaryJson,
eventsJson: null,
@@ -490,7 +515,7 @@ export const AskAiStandAloneForm = ({
const aiRequest: AiRequest = await retryIfFailed({ times: 2 }, () =>
addMessageToAiRequest(getAuthorizationHeader, {
userId: profile.id,
userId: activeUserId,
aiRequestId,
functionCallOutputs,
gameProjectJsonUserRelativeKey:
@@ -0,0 +1,3 @@
// @flow
export * from '../AI/CustomAIClient';
export { default } from '../AI/CustomAIClient';
@@ -6,6 +6,7 @@ import {
type AiUserContentPresignedUrlsResult,
} from '../Utils/GDevelopServices/Generation';
import jsSHA from '../Utils/Sha256';
import { isCustomEndpointEnabled } from '../AI/CustomAIClient';
type UploadInfo = {
uploadedAt: number,
@@ -110,6 +111,17 @@ export const prepareAiUserContent = async ({
projectSpecificExtensionsSummaryJson: null | string,
projectSpecificExtensionsSummaryJsonUserRelativeKey: null | string,
}> => {
if (isCustomEndpointEnabled() || (userId && userId.startsWith('local-'))) {
return {
gameProjectJsonUserRelativeKey: null,
gameProjectJson: simplifiedProjectJson,
projectSpecificExtensionsSummaryJsonUserRelativeKey: null,
projectSpecificExtensionsSummaryJson: projectSpecificExtensionsSummaryJson,
eventsJsonUserRelativeKey: null,
eventsJson: eventsJson || null,
};
}
// Hash the contents, if provided, to then upload it only once (as long as the hash stays
// the same, no need to re-upload it for a while).
// If the content is not provided, no hash is computed because there is no content to upload.
@@ -15,6 +15,7 @@ import {
} from '../EditorFunctions';
import { makeSimplifiedProjectBuilder } from '../EditorFunctions/SimplifiedProject/SimplifiedProject';
import { prepareAiUserContent } from './PrepareAiUserContent';
import { isCustomEndpointEnabled } from '../AI/CustomAIClient';
const gd: libGDevelop = global.gd;
@@ -66,7 +67,10 @@ export const useGenerateEvents = ({
estimatedComplexity: number | null,
|}): Promise<EventsGenerationResult> => {
if (!project) throw new Error('No project is opened.');
if (!profile) throw new Error('User should be authenticated.');
if (!profile && !isCustomEndpointEnabled())
throw new Error('User should be authenticated.');
const activeUserId = profile ? profile.id : 'local-byok-user';
const simplifiedProjectBuilder = makeSimplifiedProjectBuilder(gd);
const simplifiedProjectJson = JSON.stringify(
@@ -79,7 +83,7 @@ export const useGenerateEvents = ({
try {
const preparedAiUserContent = await prepareAiUserContent({
getAuthorizationHeader,
userId: profile.id,
userId: activeUserId,
simplifiedProjectJson,
projectSpecificExtensionsSummaryJson,
eventsJson: existingEventsJson,
@@ -89,7 +93,7 @@ export const useGenerateEvents = ({
{ times: 3, backoff: { initialDelay: 200, factor: 2 } },
() =>
createAiGeneratedEvent(getAuthorizationHeader, {
userId: profile.id,
userId: activeUserId,
gameProjectJsonUserRelativeKey:
preparedAiUserContent.gameProjectJsonUserRelativeKey,
gameProjectJson: preparedAiUserContent.gameProjectJson,
@@ -13,6 +13,7 @@ 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';
type _FuncReturnType = {
searchAndInstallAsset: AssetSearchAndInstallOptions => Promise<AssetSearchAndInstallResult>,
@@ -49,7 +50,10 @@ export const useSearchAndInstallAsset = ({
exactOrPartialAssetId,
...assetSearchOptions
}: AssetSearchAndInstallOptions): Promise<AssetSearchAndInstallResult> => {
if (!profile) throw new Error('User should be authenticated.');
if (!profile && !isCustomEndpointEnabled())
throw new Error('User should be authenticated.');
const activeUserId = profile ? profile.id : 'local-byok-user';
let assetShortHeader;
if (exactOrPartialAssetId) {
@@ -90,7 +94,7 @@ export const useSearchAndInstallAsset = ({
{ times: 3, backoff: { initialDelay: 300, factor: 2 } },
() =>
createAssetSearch(getAuthorizationHeader, {
userId: profile.id,
userId: activeUserId,
objectType,
exactOrPartialAssetId,
...assetSearchOptions,
@@ -14,6 +14,7 @@ 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 PromisePool from '@supercharge/promise-pool';
@@ -36,9 +37,12 @@ export const useSearchAndInstallResource = ({
async ({
resources,
}: ResourceSearchAndInstallOptions): Promise<ResourceSearchAndInstallResult> => {
if (!profile) throw new Error('User should be authenticated.');
if (!profile && !isCustomEndpointEnabled())
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 { results } = await PromisePool.withConcurrency(5)
.for(resources)
.process<SingleResourceSearchAndInstallResult>(
@@ -47,7 +51,7 @@ export const useSearchAndInstallResource = ({
{ times: 3, backoff: { initialDelay: 300, factor: 2 } },
() =>
createResourceSearch(getAuthorizationHeader, {
userId: profile.id,
userId: activeUserId,
searchTerms: resourceToSearch.resourceName,
resourceKind: resourceToSearch.resourceKind,
})
+384 -56
View File
@@ -472,7 +472,145 @@ export type EditorFunctionWithoutProject = {|
* Helper function to safely extract required string arguments
*/
const extractRequiredString = (args: any, propertyName: string): string => {
const value = SafeExtractor.extractStringProperty(args, propertyName);
if (typeof args === 'string' && args.trim()) {
return args.trim();
}
let value = SafeExtractor.extractStringProperty(args, propertyName);
if (
value === null &&
(propertyName === 'scene_name' || propertyName === 'sceneName')
) {
value =
SafeExtractor.extractStringProperty(args, 'scene_name') ||
SafeExtractor.extractStringProperty(args, 'name') ||
SafeExtractor.extractStringProperty(args, 'sceneName') ||
SafeExtractor.extractStringProperty(args, 'scene');
} else if (
value === null &&
(propertyName === 'object_name' || propertyName === 'objectName')
) {
value =
SafeExtractor.extractStringProperty(args, 'object_name') ||
SafeExtractor.extractStringProperty(args, 'name') ||
SafeExtractor.extractStringProperty(args, 'objectName') ||
SafeExtractor.extractStringProperty(args, 'object');
} else if (
value === null &&
(propertyName === 'behavior_name' || propertyName === 'behaviorName')
) {
value =
SafeExtractor.extractStringProperty(args, 'behavior_name') ||
SafeExtractor.extractStringProperty(args, 'name') ||
SafeExtractor.extractStringProperty(args, 'behaviorName') ||
SafeExtractor.extractStringProperty(args, 'behavior');
} else if (
value === null &&
(propertyName === 'behavior_type' || propertyName === 'behaviorType')
) {
value =
SafeExtractor.extractStringProperty(args, 'behavior_type') ||
SafeExtractor.extractStringProperty(args, 'type') ||
SafeExtractor.extractStringProperty(args, 'behaviorType');
} else if (
value === null &&
(propertyName === 'layer_name' ||
propertyName === 'layerName' ||
propertyName === 'layer')
) {
value =
SafeExtractor.extractStringProperty(args, 'layer_name') ||
SafeExtractor.extractStringProperty(args, 'layerName') ||
SafeExtractor.extractStringProperty(args, 'layer') ||
'';
} else if (
value === null &&
(propertyName === 'brush_kind' ||
propertyName === 'brushKind' ||
propertyName === 'brush')
) {
value =
SafeExtractor.extractStringProperty(args, 'brush_kind') ||
SafeExtractor.extractStringProperty(args, 'brushKind') ||
SafeExtractor.extractStringProperty(args, 'brush') ||
'point';
} else if (
value === null &&
(propertyName === 'variable_scope' ||
propertyName === 'variableScope' ||
propertyName === 'scope')
) {
value =
SafeExtractor.extractStringProperty(args, 'variable_scope') ||
SafeExtractor.extractStringProperty(args, 'scope') ||
SafeExtractor.extractStringProperty(args, 'variableScope') ||
'global';
} else if (
value === null &&
(propertyName === 'project_name' ||
propertyName === 'projectName' ||
propertyName === 'game_name' ||
propertyName === 'gameName' ||
propertyName === 'title' ||
propertyName === 'name')
) {
value =
SafeExtractor.extractStringProperty(args, 'project_name') ||
SafeExtractor.extractStringProperty(args, 'game_name') ||
SafeExtractor.extractStringProperty(args, 'projectName') ||
SafeExtractor.extractStringProperty(args, 'gameName') ||
SafeExtractor.extractStringProperty(args, 'title') ||
SafeExtractor.extractStringProperty(args, 'name') ||
'My Project';
} else if (
value === null &&
(propertyName === 'template_slug' ||
propertyName === 'templateSlug' ||
propertyName === 'template' ||
propertyName === 'slug')
) {
value =
SafeExtractor.extractStringProperty(args, 'template_slug') ||
SafeExtractor.extractStringProperty(args, 'templateSlug') ||
SafeExtractor.extractStringProperty(args, 'template') ||
SafeExtractor.extractStringProperty(args, 'slug') ||
'';
} else if (
value === null &&
(propertyName === 'test_name' ||
propertyName === 'testName' ||
propertyName === 'name')
) {
value =
SafeExtractor.extractStringProperty(args, 'test_name') ||
SafeExtractor.extractStringProperty(args, 'testName') ||
SafeExtractor.extractStringProperty(args, 'name') ||
'Test';
} else if (
value === null &&
(propertyName === 'events_description' ||
propertyName === 'eventsDescription' ||
propertyName === 'description')
) {
value =
SafeExtractor.extractStringProperty(args, 'events_description') ||
SafeExtractor.extractStringProperty(args, 'description') ||
SafeExtractor.extractStringProperty(args, 'eventsDescription') ||
SafeExtractor.extractStringProperty(args, 'events') ||
SafeExtractor.extractStringProperty(args, 'prompt');
} else if (
value === null &&
(propertyName === 'query' ||
propertyName === 'search' ||
propertyName === 'searchTerm' ||
propertyName === 'searchTerms')
) {
value =
SafeExtractor.extractStringProperty(args, 'query') ||
SafeExtractor.extractStringProperty(args, 'search') ||
SafeExtractor.extractStringProperty(args, 'searchTerm') ||
SafeExtractor.extractStringProperty(args, 'searchTerms') ||
'';
}
if (value === null) {
throw new Error(
`Missing or invalid required string argument: ${propertyName}`
@@ -3492,10 +3630,23 @@ const put2dInstances: EditorFunction = {
);
const layer_name = extractRequiredString(args, 'layer_name');
const brush_kind = extractRequiredString(args, 'brush_kind');
const brush_position = SafeExtractor.extractStringProperty(
args,
'brush_position'
);
const rawBrushPos =
SafeExtractor.extractStringProperty(args, 'brush_position') ||
SafeExtractor.extractStringProperty(args, 'position') ||
SafeExtractor.extractStringProperty(args, 'pos') ||
(args && args.x !== undefined && args.y !== undefined
? `${args.x}, ${args.y}`
: null) ||
(args && Array.isArray(args.position) && args.position.length >= 2
? `${args.position[0]}, ${args.position[1]}`
: null) ||
(args &&
Array.isArray(args.instances) &&
args.instances.length > 0 &&
args.instances[0].x !== undefined
? `${args.instances[0].x}, ${args.instances[0].y}`
: null);
const brush_position = rawBrushPos;
const existing_instance_ids = SafeExtractor.extractStringProperty(
args,
'existing_instance_ids'
@@ -3512,7 +3663,9 @@ const put2dInstances: EditorFunction = {
);
const newInstancesCount =
new_instances_count === null && existingInstanceIds.length === 0
? 1
? Array.isArray(args.instances)
? args.instances.length
: 1
: new_instances_count;
const existingInstanceCount = existingInstanceIds.length;
@@ -3588,15 +3741,63 @@ const put2dInstances: EditorFunction = {
'object_name'
);
const layer_name = extractRequiredString(args, 'layer_name');
if (Array.isArray(args.instances) && args.instances.length > 0) {
if (!project.hasLayoutNamed(scene_name)) {
return makeSceneNotFoundFailure(project, scene_name);
}
const layout = project.getLayout(scene_name);
const initialInstances = layout.getInitialInstances();
const createdCount = args.instances.length;
for (const instData of args.instances) {
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
);
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);
}
}
onInstancesModifiedOutsideEditor({ scene: layout });
return {
success: true,
message: `Placed ${createdCount} instance(s) in scene "${scene_name}".`,
};
}
const requested_brush_kind = extractRequiredString(args, 'brush_kind');
const brush_position = SafeExtractor.extractStringProperty(
args,
'brush_position'
);
const rawBrushPosition =
SafeExtractor.extractStringProperty(args, 'brush_position') ||
SafeExtractor.extractStringProperty(args, 'position') ||
SafeExtractor.extractStringProperty(args, 'pos') ||
SafeExtractor.extractStringProperty(args, 'coordinates') ||
(args && args.x !== undefined && args.y !== undefined
? `${args.x}, ${args.y}`
: null) ||
(args && Array.isArray(args.position) && args.position.length >= 2
? `${args.position[0]}, ${args.position[1]}`
: null);
const existing_instance_ids = SafeExtractor.extractStringProperty(
args,
'existing_instance_ids'
);
const brush_position =
rawBrushPosition ||
(requested_brush_kind !== 'none' && !existing_instance_ids
? '0, 0'
: null);
// 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
@@ -4384,10 +4585,24 @@ const put3dInstances: EditorFunction = {
);
const layer_name = extractRequiredString(args, 'layer_name');
const brush_kind = extractRequiredString(args, 'brush_kind');
const brush_position = SafeExtractor.extractStringProperty(
args,
'brush_position'
);
const rawBrushPos =
SafeExtractor.extractStringProperty(args, 'brush_position') ||
SafeExtractor.extractStringProperty(args, 'position') ||
SafeExtractor.extractStringProperty(args, 'pos') ||
(args && args.x !== undefined && args.y !== undefined
? `${args.x}, ${args.y}, ${args.z || 0}`
: null) ||
(args && Array.isArray(args.position) && args.position.length >= 3
? `${args.position[0]}, ${args.position[1]}, ${args.position[2]}`
: null) ||
(args &&
Array.isArray(args.instances) &&
args.instances.length > 0 &&
args.instances[0].x !== undefined
? `${args.instances[0].x}, ${args.instances[0].y}, ${args.instances[0]
.z || 0}`
: null);
const brush_position = rawBrushPos;
const existing_instance_ids = SafeExtractor.extractStringProperty(
args,
'existing_instance_ids'
@@ -4404,7 +4619,9 @@ const put3dInstances: EditorFunction = {
);
const newInstancesCount =
new_instances_count === null && existingInstanceIds.length === 0
? 1
? Array.isArray(args.instances)
? args.instances.length
: 1
: new_instances_count;
const existingInstanceCount = existingInstanceIds.length;
@@ -4480,15 +4697,65 @@ const put3dInstances: EditorFunction = {
'object_name'
);
const layer_name = extractRequiredString(args, 'layer_name');
if (Array.isArray(args.instances) && args.instances.length > 0) {
if (!project.hasLayoutNamed(scene_name)) {
return makeSceneNotFoundFailure(project, scene_name);
}
const layout = project.getLayout(scene_name);
const initialInstances = layout.getInitialInstances();
const createdCount = args.instances.length;
for (const instData of args.instances) {
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
);
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);
}
onInstancesModifiedOutsideEditor({ scene: layout });
return {
success: true,
message: `Placed ${createdCount} 3D instance(s) in scene "${scene_name}".`,
};
}
const requested_brush_kind = extractRequiredString(args, 'brush_kind');
const brush_position = SafeExtractor.extractStringProperty(
args,
'brush_position'
);
const rawBrushPosition =
SafeExtractor.extractStringProperty(args, 'brush_position') ||
SafeExtractor.extractStringProperty(args, 'position') ||
SafeExtractor.extractStringProperty(args, 'pos') ||
SafeExtractor.extractStringProperty(args, 'coordinates') ||
(args && args.x !== undefined && args.y !== undefined
? `${args.x}, ${args.y}, ${args.z || 0}`
: null) ||
(args && Array.isArray(args.position) && args.position.length >= 3
? `${args.position[0]}, ${args.position[1]}, ${args.position[2]}`
: null);
const existing_instance_ids = SafeExtractor.extractStringProperty(
args,
'existing_instance_ids'
);
const brush_position =
rawBrushPosition ||
(requested_brush_kind !== 'none' && !existing_instance_ids
? '0, 0, 0'
: null);
// 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
@@ -6115,10 +6382,10 @@ const createScene: EditorFunction = {
args,
'include_ui_layer'
);
const background_color = SafeExtractor.extractStringProperty(
args,
'background_color'
);
const background_color =
SafeExtractor.extractStringProperty(args, 'background_color') ||
SafeExtractor.extractStringProperty(args, 'backgroundColor') ||
SafeExtractor.extractStringProperty(args, 'color');
const is_first_scene = SafeExtractor.extractBooleanProperty(
args,
'is_first_scene'
@@ -6715,10 +6982,27 @@ const changeScenePropertiesLayersEffectsGroups: EditorFunction = {
const changes = [];
const warnings = [];
const changed_properties = SafeExtractor.extractArrayProperty(
let changed_properties = SafeExtractor.extractArrayProperty(
args,
'changed_properties'
);
if (!changed_properties || changed_properties.length === 0) {
const syntheticProperties = [];
const topLevelBg =
SafeExtractor.extractStringProperty(args, 'background_color') ||
SafeExtractor.extractStringProperty(args, 'backgroundColor') ||
SafeExtractor.extractStringProperty(args, 'bgColor') ||
SafeExtractor.extractStringProperty(args, 'color');
if (topLevelBg) {
syntheticProperties.push({
property_name: 'backgroundColor',
new_value: topLevelBg,
});
}
if (syntheticProperties.length > 0) {
changed_properties = syntheticProperties;
}
}
const changed_layers = SafeExtractor.extractArrayProperty(
args,
'changed_layers'
@@ -6734,14 +7018,21 @@ const changeScenePropertiesLayersEffectsGroups: EditorFunction = {
if (changed_properties)
changed_properties.forEach(changed_property => {
const propertyName = SafeExtractor.extractStringProperty(
changed_property,
'property_name'
);
const newValue = SafeExtractor.extractStringProperty(
changed_property,
'new_value'
);
const propertyName =
SafeExtractor.extractStringProperty(
changed_property,
'property_name'
) ||
SafeExtractor.extractStringProperty(
changed_property,
'propertyName'
) ||
SafeExtractor.extractStringProperty(changed_property, 'name') ||
SafeExtractor.extractStringProperty(changed_property, 'property');
const newValue =
SafeExtractor.extractStringProperty(changed_property, 'new_value') ||
SafeExtractor.extractStringProperty(changed_property, 'newValue') ||
SafeExtractor.extractStringProperty(changed_property, 'value');
if (propertyName === null || newValue === null) {
warnings.push(
`Missing "property_name" or "new_value" in changed_properties item: ${JSON.stringify(
@@ -7946,33 +8237,62 @@ const extractVariableOperations = (
|}> => {
const variablesArray = SafeExtractor.extractArrayProperty(args, 'variables');
if (variablesArray) {
return variablesArray.map(variableArgs => ({
variable_name_or_path: SafeExtractor.extractStringProperty(
variableArgs,
'variable_name_or_path'
),
value: SafeExtractor.extractStringProperty(variableArgs, 'value'),
variable_type: SafeExtractor.extractStringProperty(
variableArgs,
'variable_type'
),
delete_this_variable:
SafeExtractor.extractBooleanProperty(
return variablesArray.map(variableArgs => {
const varName =
SafeExtractor.extractStringProperty(
variableArgs,
'delete_this_variable'
) || false,
}));
'variable_name_or_path'
) ||
SafeExtractor.extractStringProperty(variableArgs, 'variable_name') ||
SafeExtractor.extractStringProperty(variableArgs, 'name') ||
SafeExtractor.extractStringProperty(variableArgs, 'variableName');
const varVal =
SafeExtractor.extractStringProperty(variableArgs, 'value') !== null
? SafeExtractor.extractStringProperty(variableArgs, 'value')
: variableArgs && variableArgs.value !== undefined
? String(variableArgs.value)
: null;
const varType =
SafeExtractor.extractStringProperty(variableArgs, 'variable_type') ||
SafeExtractor.extractStringProperty(variableArgs, 'type') ||
SafeExtractor.extractStringProperty(variableArgs, 'variableType');
return {
variable_name_or_path: varName,
value: varVal,
variable_type: varType,
delete_this_variable:
SafeExtractor.extractBooleanProperty(
variableArgs,
'delete_this_variable'
) || false,
};
});
}
const varName =
SafeExtractor.extractStringProperty(args, 'variable_name_or_path') ||
SafeExtractor.extractStringProperty(args, 'variable_name') ||
SafeExtractor.extractStringProperty(args, 'name') ||
SafeExtractor.extractStringProperty(args, 'variableName');
const varVal =
SafeExtractor.extractStringProperty(args, 'value') !== null
? SafeExtractor.extractStringProperty(args, 'value')
: args && args.value !== undefined
? String(args.value)
: null;
const varType =
SafeExtractor.extractStringProperty(args, 'variable_type') ||
SafeExtractor.extractStringProperty(args, 'type') ||
SafeExtractor.extractStringProperty(args, 'variableType');
return [
{
variable_name_or_path: SafeExtractor.extractStringProperty(
args,
'variable_name_or_path'
),
value: SafeExtractor.extractStringProperty(args, 'value'),
variable_type: SafeExtractor.extractStringProperty(args, 'variable_type'),
delete_this_variable: false,
variable_name_or_path: varName,
value: varVal,
variable_type: varType,
delete_this_variable:
SafeExtractor.extractBooleanProperty(args, 'delete_this_variable') ||
false,
},
];
};
@@ -8820,12 +9140,20 @@ const runScript: EditorFunction = {
},
launchFunction: async ({ args, project, ...launchOptions }) => {
const jsCode =
args && typeof args.js_code === 'string' ? args.js_code : null;
args && typeof args.js_code === 'string'
? args.js_code
: args && typeof args.script === 'string'
? args.script
: args && typeof args.code === 'string'
? args.code
: args && typeof args.javascript === 'string'
? args.javascript
: null;
if (!jsCode) {
return {
success: false,
message:
'run_script requires a `js_code` string argument (the JavaScript to run).',
'run_script requires a `js_code` or `script` string argument (the JavaScript to run).',
};
}
@@ -256,6 +256,11 @@ export type PreferencesValues = {|
disableNpmScriptConfirmation: boolean,
showJsTypeError: boolean,
canonicalEventSerialization: boolean,
aiCustomEndpointEnabled: boolean,
aiCustomBaseUrl: string,
aiCustomApiKey: string,
aiCustomModel: string,
aiCustomTemperature: number,
|};
/**
@@ -384,6 +389,11 @@ export type Preferences = {|
setUseBackgroundSerializerForSaving: (enabled: boolean) => void,
setShowJsTypeError: (enabled: boolean) => void,
setCanonicalEventSerialization: (enabled: boolean) => void,
setAiCustomEndpointEnabled: (enabled: boolean) => void,
setAiCustomBaseUrl: (baseUrl: string) => void,
setAiCustomApiKey: (apiKey: string) => void,
setAiCustomModel: (model: string) => void,
setAiCustomTemperature: (temperature: number) => void,
|};
export const initialPreferences = {
@@ -451,6 +461,11 @@ export const initialPreferences = {
disableNpmScriptConfirmation: false,
showJsTypeError: false,
canonicalEventSerialization: false,
aiCustomEndpointEnabled: false,
aiCustomBaseUrl: 'http://localhost:11434/v1',
aiCustomApiKey: '',
aiCustomModel: 'qwen2.5-coder',
aiCustomTemperature: 0.7,
},
setMultipleValues: () => {},
setLanguage: () => {},
@@ -542,6 +557,11 @@ export const initialPreferences = {
setUseBackgroundSerializerForSaving: (enabled: boolean) => {},
setShowJsTypeError: (enabled: boolean) => {},
setCanonicalEventSerialization: (enabled: boolean) => {},
setAiCustomEndpointEnabled: (enabled: boolean) => {},
setAiCustomBaseUrl: (baseUrl: string) => {},
setAiCustomApiKey: (apiKey: string) => {},
setAiCustomModel: (model: string) => {},
setAiCustomTemperature: (temperature: number) => {},
};
const PreferencesContext: React.Context<Preferences> = React.createContext<Preferences>(
@@ -24,6 +24,9 @@ import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMea
import { adaptAcceleratorString } from '../../UI/AcceleratorString';
import { getElectronAccelerator } from '../../KeyboardShortcuts';
import defaultShortcuts from '../../KeyboardShortcuts/DefaultShortcuts';
import RaisedButton from '../../UI/RaisedButton';
import TextField from '../../UI/TextField';
import { testConnection } from '../../AI/CustomAIClient';
import AlertMessage from '../../UI/AlertMessage';
import ErrorBoundary from '../../UI/ErrorBoundary';
import CompactSelectField from '../../UI/CompactSelectField';
@@ -90,8 +93,23 @@ const PreferencesDialog = ({
setDisableNpmScriptConfirmation,
setUseBackgroundSerializerForSaving,
setShowJsTypeError,
setAiCustomEndpointEnabled,
setAiCustomBaseUrl,
setAiCustomApiKey,
setAiCustomModel,
setAiCustomTemperature,
} = React.useContext(PreferencesContext);
const [
isTestingAiConnection,
setIsTestingAiConnection,
] = React.useState<boolean>(false);
const [aiTestResult, setAiTestResult] = React.useState<null | {|
success: boolean,
message: string,
models?: Array<string>,
|}>(null);
const initialUse3DEditor = React.useRef<boolean>(values.use3DEditor);
return (
@@ -114,6 +132,7 @@ const PreferencesDialog = ({
onChange={setCurrentTab}
options={[
{ value: 'preferences', label: <Trans>Preferences</Trans> },
{ value: 'ai', label: <Trans>AI Settings</Trans> },
{ value: 'shortcuts', label: <Trans>Keyboard Shortcuts</Trans> },
...(electron
? [{ value: 'folders', label: <Trans>Folders</Trans> }]
@@ -694,6 +713,93 @@ const PreferencesDialog = ({
)}
</ColumnStackLayout>
)}
{currentTab === 'ai' && (
<ColumnStackLayout noMargin>
<Text size="block-title">
<Trans>Custom AI Provider (BYOK)</Trans>
</Text>
<Text size="body" color="secondary">
<Trans>
Connect GDevelop to a local AI server (such as Ollama, llama.cpp,
LM Studio, LocalAI) or any OpenAI-compatible API endpoint.
</Trans>
</Text>
<CompactToggleField
checked={values.aiCustomEndpointEnabled}
onCheck={setAiCustomEndpointEnabled}
label={<Trans>Enable Custom / Local AI Endpoint (BYOK)</Trans>}
/>
<TextField
floatingLabelText={<Trans>Base URL</Trans>}
helperMarkdownText={i18n._(
t`Examples: \`http://localhost:11434/v1\` (Ollama), \`http://localhost:8080/v1\` (llama.cpp), \`http://localhost:1234/v1\` (LM Studio), \`https://api.openai.com/v1\`, \`https://openrouter.ai/api/v1\``
)}
fullWidth
value={values.aiCustomBaseUrl}
onChange={(e, value) => setAiCustomBaseUrl(value)}
/>
<TextField
type="password"
floatingLabelText={<Trans>API Key</Trans>}
helperMarkdownText={i18n._(
t`Bearer token for authentication. Leave empty for local servers (Ollama / llama.cpp).`
)}
fullWidth
value={values.aiCustomApiKey}
onChange={(e, value) => setAiCustomApiKey(value)}
/>
<TextField
floatingLabelText={<Trans>Model Identifier</Trans>}
helperMarkdownText={i18n._(
t`Examples: \`qwen2.5-coder\`, \`gpt-4o\`, \`llama3.2\`, \`deepseek-chat\`, \`claude-3-5-sonnet\`, \`openrouter/poolside/laguna-s-2.1:free\``
)}
fullWidth
value={values.aiCustomModel}
onChange={(e, value) => setAiCustomModel(value)}
/>
<TextField
type="number"
floatingLabelText={<Trans>Temperature (0.0 to 1.0)</Trans>}
value={values.aiCustomTemperature}
onChange={(e, value) =>
setAiCustomTemperature(parseFloat(value) || 0.7)
}
/>
<LineStackLayout alignItems="center">
<RaisedButton
label={<Trans>Test Connection</Trans>}
primary
disabled={isTestingAiConnection}
onClick={async () => {
setIsTestingAiConnection(true);
setAiTestResult(null);
try {
const res = await testConnection({
enabled: true,
baseUrl: values.aiCustomBaseUrl,
apiKey: values.aiCustomApiKey,
model: values.aiCustomModel,
temperature: values.aiCustomTemperature,
});
setAiTestResult(res);
} catch (err) {
setAiTestResult({
success: false,
message: err.message || 'Connection test failed',
});
} finally {
setIsTestingAiConnection(false);
}
}}
/>
</LineStackLayout>
{aiTestResult && (
<AlertMessage kind={aiTestResult.success ? 'info' : 'error'}>
{aiTestResult.message}
</AlertMessage>
)}
</ColumnStackLayout>
)}
{currentTab === 'shortcuts' && (
<Line expand>
<Column expand noMargin>
@@ -34,6 +34,7 @@ import {
CHECK_APP_UPDATES_TIMEOUT,
PERIODIC_APP_UPDATES_TIMEOUT,
} from '../../Utils/GlobalFetchTimeouts';
import { setCustomEndpointConfig } from '../../AI/CustomAIClient';
const electron = optionalRequire('electron');
const ipcRenderer = electron ? electron.ipcRenderer : null;
@@ -417,9 +418,30 @@ export default class PreferencesProvider extends React.Component<Props, State> {
setCanonicalEventSerialization: (this._setCanonicalEventSerialization.bind(
this
): any),
// $FlowFixMe[method-unbinding]
setAiCustomEndpointEnabled: (this._setAiCustomEndpointEnabled.bind(
this
): any),
// $FlowFixMe[method-unbinding]
setAiCustomBaseUrl: (this._setAiCustomBaseUrl.bind(this): any),
// $FlowFixMe[method-unbinding]
setAiCustomApiKey: (this._setAiCustomApiKey.bind(this): any),
// $FlowFixMe[method-unbinding]
setAiCustomModel: (this._setAiCustomModel.bind(this): any),
// $FlowFixMe[method-unbinding]
setAiCustomTemperature: (this._setAiCustomTemperature.bind(this): any),
};
componentDidMount() {
// Sync custom AI config on mount
setCustomEndpointConfig({
enabled: this.state.values.aiCustomEndpointEnabled,
baseUrl: this.state.values.aiCustomBaseUrl,
apiKey: this.state.values.aiCustomApiKey,
model: this.state.values.aiCustomModel,
temperature: this.state.values.aiCustomTemperature,
});
this._periodicUpdateCheckTimeout = setTimeout(
() => this._checkUpdates(),
CHECK_APP_UPDATES_TIMEOUT
@@ -1450,6 +1472,81 @@ export default class PreferencesProvider extends React.Component<Props, State> {
);
}
_setAiCustomEndpointEnabled(newValue: boolean) {
this.setState(
state => ({
values: {
...state.values,
aiCustomEndpointEnabled: newValue,
},
}),
() => {
this._persistValuesToLocalStorage(this.state);
setCustomEndpointConfig({ enabled: newValue });
}
);
}
_setAiCustomBaseUrl(newValue: string) {
this.setState(
state => ({
values: {
...state.values,
aiCustomBaseUrl: newValue,
},
}),
() => {
this._persistValuesToLocalStorage(this.state);
setCustomEndpointConfig({ baseUrl: newValue });
}
);
}
_setAiCustomApiKey(newValue: string) {
this.setState(
state => ({
values: {
...state.values,
aiCustomApiKey: newValue,
},
}),
() => {
this._persistValuesToLocalStorage(this.state);
setCustomEndpointConfig({ apiKey: newValue });
}
);
}
_setAiCustomModel(newValue: string) {
this.setState(
state => ({
values: {
...state.values,
aiCustomModel: newValue,
},
}),
() => {
this._persistValuesToLocalStorage(this.state);
setCustomEndpointConfig({ model: newValue });
}
);
}
_setAiCustomTemperature(newValue: number) {
this.setState(
state => ({
values: {
...state.values,
aiCustomTemperature: newValue,
},
}),
() => {
this._persistValuesToLocalStorage(this.state);
setCustomEndpointConfig({ temperature: newValue });
}
);
}
render(): any {
return (
<PreferencesContext.Provider value={this.state}>
@@ -18,6 +18,7 @@ import { showErrorBox } from '../../UI/Messages/MessageBox';
import { type CommunityLinks, type UserSurvey } from './User';
import { userCancellationErrorName } from '../../LoginProvider/Utils';
import { ensureIsObject, ensureObjectHasProperty } from '../DataValidator';
import { isCustomEndpointEnabled } from '../../AI/CustomAIClient';
export type Profile = {|
id: string,
@@ -577,13 +578,18 @@ export default class Authentication {
getAuthorizationHeader = async (): Promise<string> => {
const { currentUser } = this.auth;
if (!currentUser) throw new Error('User is not authenticated.');
if (!currentUser) {
if (isCustomEndpointEnabled()) {
return 'Bearer local-byok-token';
}
throw new Error('User is not authenticated.');
}
return currentUser.getIdToken().then(token => `Bearer ${token}`);
};
isAuthenticated = (): boolean => {
return !!this.auth.currentUser;
return !!this.auth.currentUser || isCustomEndpointEnabled();
};
waitForInitialAuthCheck = (): Promise<void> => {
@@ -9,6 +9,21 @@ import {
ensureIsObject,
ensureObjectHasProperty,
} from '../DataValidator';
import {
isCustomEndpointEnabled,
customCreateAiRequest,
customAddMessageToAiRequest,
customGetAiRequest,
customGetAiRequests,
customGetAiRequestStatuses,
customSuspendAiRequest,
customForkAiRequest,
customGetAiRequestSuggestions,
customCreateAiGeneratedEvent,
customCreateAssetSearch,
customCreateResourceSearch,
DEFAULT_LOCAL_AI_SETTINGS,
} from '../../AI/CustomAIClient';
export type Environment = 'staging' | 'live';
@@ -297,6 +312,10 @@ export const getAiRequest = async (
outputFromMessageId?: ?string,
|}
): Promise<AiRequest> => {
if (isCustomEndpointEnabled() || aiRequestId.startsWith('local-ai-')) {
return customGetAiRequest(aiRequestId);
}
const authorizationHeader = await getAuthorizationHeader();
// $FlowFixMe[underconstrained-implicit-instantiation]
const response = await axios.get(
@@ -337,6 +356,13 @@ export const getAiRequestStatuses = async (
> => {
if (aiRequestIds.length === 0) return [];
if (
isCustomEndpointEnabled() ||
aiRequestIds.every(id => id.startsWith('local-ai-'))
) {
return customGetAiRequestStatuses(aiRequestIds);
}
const authorizationHeader = await getAuthorizationHeader();
// $FlowFixMe[underconstrained-implicit-instantiation]
const response = await axios.get(
@@ -371,6 +397,10 @@ export const getAiRequests = async (
aiRequests: Array<AiRequest>,
nextPageUri: ?string,
}> => {
if (isCustomEndpointEnabled()) {
return customGetAiRequests();
}
const authorizationHeader = await getAuthorizationHeader();
const uri = forceUri || '/ai-request';
@@ -432,6 +462,17 @@ export const createAiRequest = async (
toolsVersion: string,
|}
): Promise<AiRequest> => {
if (isCustomEndpointEnabled()) {
return customCreateAiRequest({
userRequest,
gameProjectJson,
projectSpecificExtensionsSummaryJson,
mode,
aiConfiguration,
gameId,
});
}
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
'/ai-request',
@@ -502,6 +543,17 @@ export const addMessageToAiRequest = async (
toolsVersion?: string,
|}
): Promise<AiRequest> => {
if (isCustomEndpointEnabled() || aiRequestId.startsWith('local-ai-')) {
return customAddMessageToAiRequest({
aiRequestId,
userMessage,
functionCallOutputs,
gameProjectJson,
projectSpecificExtensionsSummaryJson,
mode,
});
}
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-request/${aiRequestId}/action/add-message`,
@@ -541,6 +593,10 @@ export const suspendAiRequest = async (
getAuthorizationHeader: () => Promise<string>,
{ userId, aiRequestId }: {| userId: string, aiRequestId: string |}
): Promise<AiRequest> => {
if (isCustomEndpointEnabled() || aiRequestId.startsWith('local-ai-')) {
return customSuspendAiRequest(aiRequestId);
}
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-request/${aiRequestId}/action/suspend`,
@@ -570,6 +626,9 @@ export const updateAiRequestMessage = async (
projectVersionIdAfterMessage?: ?string,
|}
): Promise<void> => {
if (isCustomEndpointEnabled() || aiRequestId.startsWith('local-ai-')) {
return;
}
const authorizationHeader = await getAuthorizationHeader();
await apiClient.patch(
`/ai-request/${aiRequestId}/message/${aiRequestMessageId}`,
@@ -652,6 +711,10 @@ export const getAiRequestSuggestions = async (
projectSpecificExtensionsSummaryJsonUserRelativeKey: string | null,
|}
): Promise<AiRequest> => {
if (isCustomEndpointEnabled() || aiRequestId.startsWith('local-ai-')) {
return customGetAiRequestSuggestions(aiRequestId);
}
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-request/${aiRequestId}/action/get-suggestions`,
@@ -727,6 +790,17 @@ export const createAiGeneratedEvent = async (
estimatedComplexity: number | null,
|}
): Promise<CreateAiGeneratedEventResult> => {
if (isCustomEndpointEnabled()) {
return customCreateAiGeneratedEvent({
sceneName,
eventsDescription,
eventBatches,
extensionNamesList,
objectsList,
existingEventsAsText,
});
}
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-generated-event`,
@@ -795,6 +869,31 @@ export const getAiGeneratedEvent = async (
aiGeneratedEventId: string,
|}
): Promise<AiGeneratedEvent> => {
if (
isCustomEndpointEnabled() ||
aiGeneratedEventId.startsWith('local-evt-')
) {
return {
id: aiGeneratedEventId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId: 'local-byok-user',
status: 'ready',
partialGameProjectJson: '',
eventsDescription: null,
eventBatches: null,
extensionNamesList: '',
objectsList: '',
existingEventsAsText: '',
existingEventsJson: null,
existingEventsJsonUserRelativeKey: null,
resultMessage: null,
changes: [],
error: null,
stats: null,
};
}
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.get(
`/ai-generated-event/${aiGeneratedEventId}`,
@@ -838,6 +937,10 @@ export const createAssetSearch = async (
lastAssistantMessages?: string[],
|}
): Promise<AssetSearch> => {
if (isCustomEndpointEnabled()) {
return customCreateAssetSearch({ searchTerms, objectType });
}
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/asset-search`,
@@ -880,6 +983,10 @@ export const createResourceSearch = async (
resourceKind: string,
|}
): Promise<ResourceSearch> => {
if (isCustomEndpointEnabled()) {
return customCreateResourceSearch({ searchTerms, resourceKind });
}
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/resource-search`,
@@ -927,6 +1034,16 @@ export const createAiUserContentPresignedUrls = async (
eventsJsonHash: string | null,
|}
): Promise<AiUserContentPresignedUrlsResult> => {
if (isCustomEndpointEnabled() || (userId && userId.startsWith('local-'))) {
return {
gameProjectJsonSignedUrl: undefined,
gameProjectJsonUserRelativeKey: undefined,
projectSpecificExtensionsSummaryJsonSignedUrl: undefined,
projectSpecificExtensionsSummaryJsonUserRelativeKey: undefined,
eventsJsonSignedUrl: undefined,
eventsJsonUserRelativeKey: undefined,
};
}
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-user-content/action/create-presigned-urls`,
@@ -980,6 +1097,10 @@ export const forkAiRequest = async (
upToMessageId?: string,
|}
): Promise<AiRequest> => {
if (isCustomEndpointEnabled() || aiRequestId.startsWith('local-ai-')) {
return customForkAiRequest(aiRequestId, upToMessageId);
}
const authorizationHeader = await getAuthorizationHeader();
const response = await apiClient.post(
`/ai-request/${aiRequestId}/action/fork`,
@@ -1007,13 +1128,18 @@ export const fetchAiSettings = async ({
}: {|
environment: Environment,
|}): Promise<AiSettings> => {
// $FlowFixMe[underconstrained-implicit-instantiation]
const response = await axios.get(
`${GDevelopAiCdn.baseUrl[environment]}/ai-settings-v2.json`
);
return ensureObjectHasProperty({
data: response.data,
propertyName: 'aiRequest',
endpointName: '/ai-settings-v2.json of Generation API',
});
try {
// $FlowFixMe[underconstrained-implicit-instantiation]
const response = await axios.get(
`${GDevelopAiCdn.baseUrl[environment]}/ai-settings-v2.json`,
{ timeout: 5000 }
);
return ensureObjectHasProperty({
data: response.data,
propertyName: 'aiRequest',
endpointName: '/ai-settings-v2.json of Generation API',
});
} catch (err) {
return DEFAULT_LOCAL_AI_SETTINGS;
}
};
@@ -16,6 +16,7 @@ import {
ensureIsObjectWithPropertyOfType,
} from '../DataValidator';
import { type BundleListingData } from './Shop';
import { isCustomEndpointEnabled } from '../../AI/CustomAIClient';
export type Usage = {
id: string,
@@ -583,6 +584,10 @@ export const isSubscriptionComingFromTeam = (
export const hasValidSubscriptionPlan = (
subscription: ?Subscription
): boolean => {
if (isCustomEndpointEnabled()) {
return true;
}
const hasValidSubscription =
!!subscription &&
!!subscription.planId &&
@@ -758,6 +763,10 @@ export const canBenefitFromSocialRole = (
export const canUpgradeSubscription = (
subscription: ?Subscription
): false | true | boolean => {
if (isCustomEndpointEnabled()) {
return false;
}
return (
!!subscription &&
// $FlowFixMe[incompatible-type]