feat: initial BYOK patching system for GDevelop

Automated pipeline to watch GDevelop releases, patch source for BYOK AI
support, unlock all premium features, and build for Windows/macOS/Linux.

- GitHub Actions workflow checks for new releases every 30 minutes
- patch.js applies 6 patches: premium capabilities, AI proxy reroute,
  watermark defaults, build quotas, preload proxy detection
- AI proxy server (Express) implements GDevelop Generation API protocol
  with support for OpenAI, Anthropic, Google, OpenRouter, and Ollama
- Local build script for manual testing

D001-D003: architectural decisions captured in .gsd/DECISIONS.md
This commit is contained in:
John Doe
2026-05-15 16:44:54 -04:00
commit 4a375b3f3d
9 changed files with 1379 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
name: Build Patched GDevelop
on:
schedule:
- cron: '*/30 * * * *'
workflow_dispatch:
inputs:
release_tag:
description: 'Specific release tag (e.g. v5.6.269). Empty = latest.'
required: false
default: ''
permissions:
contents: write
actions: write
jobs:
# ── Check for new release ────────────────────────────────────────
check-release:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }}
release_tag: ${{ steps.check.outputs.release_tag }}
steps:
- uses: actions/checkout@v4
- name: Determine target release
id: check
run: |
if [ -n "${{ github.event.inputs.release_tag }}" ]; then
echo "release_tag=${{ github.event.inputs.release_tag }}" >> $GITHUB_OUTPUT
echo "should_build=true" >> $GITHUB_OUTPUT
echo "Manual build for ${{ github.event.inputs.release_tag }}"
exit 0
fi
LATEST=$(curl -sS https://api.github.com/repos/4ian/GDevelop/releases/latest | jq -r .tag_name)
echo "release_tag=$LATEST" >> $GITHUB_OUTPUT
if gh release view "byok-$LATEST" &>/dev/null 2>&1; then
echo "Already built $LATEST, skipping"
echo "should_build=false" >> $GITHUB_OUTPUT
else
echo "New release: $LATEST"
echo "should_build=true" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ github.token }}
# ── Build macOS (arm64 + x64) ────────────────────────────────────
build-macos:
needs: check-release
if: needs.check-release.outputs.should_build == 'true'
runs-on: macos-latest
strategy:
matrix:
arch: [x64, arm64]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Install system deps
run: brew install cmake
- name: Clone upstream at release tag
run: |
git clone --depth 1 --branch ${{ needs.check-release.outputs.release_tag }} \
https://github.com/4ian/GDevelop.git /tmp/gd
- name: Apply BYOK patches
run: node ${{ github.workspace }}/scripts/patch.js --repo /tmp/gd --release ${{ needs.check-release.outputs.release_tag }}
- name: Build GDevelop.js (WASM)
run: |
git clone https://github.com/emscripten-core/emsdk.git /tmp/emsdk
cd /tmp/emsdk && ./emsdk install 3.1.21 && ./emsdk activate 3.1.21
cd /tmp/gd/GDevelop.js
npm install
source /tmp/emsdk/emsdk_env.sh
npm run build
- name: Build Electron app (macOS ${{ matrix.arch }})
run: |
cd /tmp/gd/newIDE/app && npm install
cd /tmp/gd/newIDE/electron-app && npm install
export NODE_OPTIONS="--max-old-space-size=7168"
export CSC_FOR_PULL_REQUEST=true
npx electron-builder --mac --${{ matrix.arch }} --publish=never
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: gdevelop-macos-${{ matrix.arch }}
path: /tmp/gd/newIDE/electron-app/dist/*.dmg
retention-days: 7
# ── Build Linux ──────────────────────────────────────────────────
build-linux:
needs: check-release
if: needs.check-release.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Install system deps
run: |
sudo apt-get update
sudo apt-get install -y icnsutils graphicsmagick rsync
- name: Clone upstream at release tag
run: |
git clone --depth 1 --branch ${{ needs.check-release.outputs.release_tag }} \
https://github.com/4ian/GDevelop.git /tmp/gd
- name: Apply BYOK patches
run: node ${{ github.workspace }}/scripts/patch.js --repo /tmp/gd --release ${{ needs.check-release.outputs.release_tag }}
- name: Build Electron app (Linux)
run: |
export REQUIRES_EXACT_LIBGD_JS_VERSION=true
cd /tmp/gd/newIDE/app && npm install
cd /tmp/gd/newIDE/electron-app && npm install
export NODE_OPTIONS="--max-old-space-size=7168"
npx electron-builder --linux --publish=never
- name: Clean dist (keep installers only)
run: |
rm -rf /tmp/gd/newIDE/electron-app/dist/linux-unpacked
rm -rf /tmp/gd/newIDE/electron-app/dist/linux-arm64-unpacked
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: gdevelop-linux
path: /tmp/gd/newIDE/electron-app/dist/*.AppImage
retention-days: 7
# ── Build Windows ────────────────────────────────────────────────
build-windows:
needs: check-release
if: needs.check-release.outputs.should_build == 'true'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Clone upstream at release tag
run: |
git clone --depth 1 --branch ${{ needs.check-release.outputs.release_tag }} `
https://github.com/4ian/GDevelop.git C:\tmp\gd
- name: Apply BYOK patches
run: node ${{ github.workspace }}/scripts/patch.js --repo C:\tmp\gd --release ${{ needs.check-release.outputs.release_tag }}
- name: Build Electron app (Windows)
run: |
cd C:\tmp\gd\newIDE\app
npm install
cd C:\tmp\gd\newIDE\electron-app
npm install
$env:NODE_OPTIONS="--max-old-space-size=7168"
npx electron-builder --win nsis --publish=never
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: gdevelop-windows
path: C:\tmp\gd\newIDE\electron-app\dist\*.exe
retention-days: 7
# ── Publish GitHub Release ───────────────────────────────────────
publish:
needs: [check-release, build-macos, build-linux, build-windows]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: gdevelop-*
merge-multiple: true
path: artifacts/
- name: Create GitHub Release
run: |
TAG="byok-${{ needs.check-release.outputs.release_tag }}"
UPSTREAM="${{ needs.check-release.outputs.release_tag }}"
gh release create "$TAG" artifacts/* \
--title "GDevelop $UPSTREAM — BYOK + Premium Unlocked" \
--notes "## GDevelop $UPSTREAM — Patched Build
### What's included
- **BYOK AI** — use your own OpenAI / Anthropic / Ollama / OpenRouter keys via the [AI Proxy](https://github.com/${{ github.repository }}#option-b-run-the-ai-proxy-locally)
- **All premium features unlocked** — watermark removal, unlimited builds, full leaderboards, multiplayer, version history, and more
- **No export branding** — disable the GDevelop logo & watermark freely
### Install
| Platform | File |
|---|---|
| **macOS (Intel)** | \`GDevelop-*-mac-x64.dmg\` |
| **macOS (Apple Silicon)** | \`GDevelop-*-mac-arm64.dmg\` |
| **Windows** | \`GDevelop-Setup-*.exe\` |
| **Linux** | \`GDevelop-*.AppImage\` |
### Using BYOK AI
1. Download & install the patched GDevelop
2. Clone this repo: \`git clone https://github.com/${{ github.repository }}.git\`
3. \`cd proxy && npm install && cp .env.example .env\`
4. Edit \`.env\` with your API keys
5. \`npm start\` — the proxy runs on \`localhost:11400\`
6. Launch GDevelop — it auto-detects the proxy
### Limitations
- **Cloud builds** (one-click packaging) still require a GDevelop account but quotas are bypassed
- **AI proxy** must run locally when using BYOK; it's not bundled
- This targets the **desktop Electron app** — the web editor at editor.gdevelop.io cannot be patched this way
> ⚠️ Unofficial build. Use at your own risk. GDevelop is MIT-licensed. Not affiliated with or endorsed by GDevelop or Florian Rival.
"
env:
GH_TOKEN: ${{ github.token }}
+7
View File
@@ -0,0 +1,7 @@
node_modules/
proxy/.env
*.log
.gsd/
.DS_Store
Thumbs.db
/tmp/
+119
View File
@@ -0,0 +1,119 @@
# GDevelop BYOK — Automated Patched Builds
Automatically patches [GDevelop](https://github.com/4ian/GDevelop) on every new release to:
- **BYOK (Bring Your Own Key) AI support** — use OpenAI, Anthropic, Ollama, OpenRouter, or any OpenAI-compatible endpoint instead of GDevelop's built-in AI
- **Unlock all premium features** — remove watermark/logo, unlimited cloud projects, multiplayer, leaderboard customization, and more
- **Remove export branding** — disable the GDevelop splash screen & watermark without a subscription
## How it works
```
New GDevelop Release → GitHub Actions triggers → Clone repo at tag → Apply patches → Build all platforms → Publish artifacts
```
Three subsystems work together:
| Component | What it does |
|---|---|
| **Patches** (`patch/`) | Modifies GDevelop source to unlock premium capabilities and route AI to local proxy |
| **AI Proxy** (`proxy/`) | Local server that implements GDevelop's Generation API, but uses your LLM keys |
| **CI/CD** (`.github/workflows/`) | Watches for new releases, runs the full patch→build→publish pipeline |
## Quick Start
### Option A: Download pre-built patched release
1. Go to [Releases](https://github.com/YOUR_USER/GDevelop-BYOK/releases) of this repo
2. Download the patched GDevelop for your platform
3. Optionally run the AI proxy for BYOK (see below)
### Option B: Run the AI Proxy locally
```bash
cd proxy
npm install
cp .env.example .env
# Edit .env with your API keys
npm start
```
The proxy runs on `http://localhost:11400` and translates GDevelop's AI API calls into requests to your configured LLM provider.
### Option C: Build yourself
```bash
# The CI pipeline does this automatically, but you can run it locally:
gh workflow run build-patched.yml # triggers a build from latest release
# Or run patches manually:
node scripts/patch.js --repo ../GDevelop --release v5.6.269
```
## Supported AI Providers
| Provider | Backend |
|---|---|
| **OpenAI** | GPT-4o, GPT-4-turbo, o1, o3, etc. |
| **Anthropic** | Claude Opus, Sonnet, Haiku |
| **Google** | Gemini 2.0 Flash, Pro |
| **OpenRouter** | Any model available on OpenRouter |
| **Ollama** | Local models (llama3, deepseek, qwen, etc.) |
| **OpenAI-compatible** | Any endpoint that speaks the OpenAI chat/completions API |
## Premium Features Unlocked
- ✅ Remove GDevelop logo & watermark from exports
- ✅ Unlimited cloud projects
- ✅ Unlimited leaderboards (with full theme customization)
- ✅ Unlimited multiplayer lobbies
- ✅ Extended version history
- ✅ No nag screen
- ✅ Unlimited desktop/mobile builds per day
- ✅ Advanced analytics
- ✅ Collaboration features
## Patches Applied
| File(s) | Change |
|---|---|
| `Usage.js` | `hasValidSubscriptionPlan()` → always returns `true` |
| `Usage.js` | `getUserLimits()` → returns maxed capabilities (all premium features) |
| `ApiConfigs.js` | `GDevelopGenerationApi.baseUrl` → points to local proxy (`http://localhost:11400`) |
| `ProjectPropertiesDialog.js` | Watermark toggle → always enabled regardless of subscription |
| `ShareDialog/*` | Remove build quota checks |
| `index.html` (GDJS) | Remove forced watermark/splash screen defaults |
## Architecture
```
┌─────────────────────────────────────────────────┐
│ GitHub Actions (.github/workflows/build.yml) │
│ ┌───────────────────────────────────────────┐ │
│ │ 1. Watch releases (schedule + webhook) │ │
│ │ 2. Clone 4ian/GDevelop at release tag │ │
│ │ 3. Run patch.js → modify source │ │
│ │ 4. Build macOS/Linux/Windows │ │
│ │ 5. Upload artifacts to GitHub Releases │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ AI Proxy (proxy/) — runs on user's machine │
│ ┌────────────────────────────────────────────┐ │
│ │ Express server on localhost:11400 │ │
│ │ Implements /ai-request endpoints │ │
│ │ Routes to OpenAI/Anthropic/Ollama/... │ │
│ │ Returns GDevelop-format responses │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
```
## Limitations
- **Cloud services** that require GDevelop's backend (gd.games publishing, cloud builds) still need a valid account, but quotas are bypassed
- **AI proxy** needs to run locally when using custom AI models; it's not bundled into the patched app (security — your keys stay on your machine)
- Patches target the **desktop Electron app** (Windows/macOS/Linux). The web app at editor.gdevelop.io cannot be patched this way
## Disclaimer
GDevelop is MIT-licensed and this project exercises the right to modify and redistribute it. This project is not affiliated with or endorsed by GDevelop or Florian Rival. Use at your own risk.
+37
View File
@@ -0,0 +1,37 @@
# ── BYOK AI Proxy Configuration ────────────────────────────────────
# Copy this file to .env and fill in your API keys
# Active provider: openai | anthropic | google | openrouter | ollama | custom
PROVIDER=openai
# ── OpenAI ──────────────────────────────────────────────────────────
OPENAI_API_KEY=sk-your-key-here
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-4o
# ── Anthropic ───────────────────────────────────────────────────────
ANTHROPIC_API_KEY=sk-ant-your-key-here
ANTHROPIC_MODEL=claude-sonnet-4-20250514
# ── Google Gemini ───────────────────────────────────────────────────
GOOGLE_API_KEY=your-google-key-here
GOOGLE_MODEL=gemini-2.0-flash
# ── OpenRouter ──────────────────────────────────────────────────────
OPENROUTER_API_KEY=sk-or-your-key-here
OPENROUTER_MODEL=openai/gpt-4o
# ── Ollama (local) ──────────────────────────────────────────────────
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=llama3.1:8b
# ── Custom OpenAI-compatible endpoint ───────────────────────────────
CUSTOM_BASE_URL=http://localhost:1234/v1
CUSTOM_API_KEY=not-needed
CUSTOM_MODEL=local-model
# ── Server ──────────────────────────────────────────────────────────
PORT=11400
# Optional: custom system prompt appended to GDevelop's prompts
# CUSTOM_SYSTEM_PROMPT=You are an expert GDevelop game developer. Always provide working event-based solutions.
+18
View File
@@ -0,0 +1,18 @@
{
"name": "gdevelop-byok-proxy",
"version": "1.0.0",
"description": "Local AI proxy for GDevelop BYOK — translates GDevelop Generation API calls to user-configured LLM backends",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.32.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"openai": "^4.67.0",
"uuid": "^10.0.0"
}
}
+521
View File
@@ -0,0 +1,521 @@
#!/usr/bin/env node
/**
* BYOK AI Proxy Server
*
* Implements a subset of the GDevelop Generation API, routing
* AI requests to user-configured LLM backends (OpenAI, Anthropic,
* Google, OpenRouter, Ollama, or any OpenAI-compatible endpoint).
*
* The patched GDevelop IDE routes its /ai-request calls here
* instead of the official GDevelop Generation API.
*/
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { v4: uuidv4 } = require('uuid');
// ── Configuration ───────────────────────────────────────────────────
const PORT = parseInt(process.env.PORT || '11400', 10);
const PROVIDER = process.env.PROVIDER || 'openai';
const CONFIG = {
openai: {
key: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
model: process.env.OPENAI_MODEL || 'gpt-4o',
},
anthropic: {
key: process.env.ANTHROPIC_API_KEY,
model: process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514',
},
google: {
key: process.env.GOOGLE_API_KEY,
model: process.env.GOOGLE_MODEL || 'gemini-2.0-flash',
},
openrouter: {
key: process.env.OPENROUTER_API_KEY,
baseURL: 'https://openrouter.ai/api/v1',
model: process.env.OPENROUTER_MODEL || 'openai/gpt-4o',
},
ollama: {
baseURL: process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
model: process.env.OLLAMA_MODEL || 'llama3.1:8b',
key: 'ollama', // no auth needed
},
custom: {
key: process.env.CUSTOM_API_KEY || '',
baseURL: process.env.CUSTOM_BASE_URL || 'http://localhost:1234/v1',
model: process.env.CUSTOM_MODEL || 'local-model',
},
};
const providerConfig = CONFIG[PROVIDER];
if (!providerConfig) {
console.error(`Unknown provider: ${PROVIDER}`);
process.exit(1);
}
if (providerConfig.key && providerConfig.key.includes('your-') && PROVIDER !== 'ollama') {
console.warn(`\n⚠️ WARNING: ${PROVIDER.toUpperCase()} API key is not set!`);
console.warn(' Create proxy/.env with your actual keys.\n');
}
// ── LLM Client Setup ────────────────────────────────────────────────
let aiClient;
function buildSystemPrompt(userRequest, projectContext) {
const basePrompt = `You are an AI game development assistant integrated into GDevelop, a no-code game engine. You help users build games by generating events, creating objects, modifying scenes, and explaining game development concepts.
You have access to the following capabilities:
- Creating and modifying game objects (sprites, text, shapes, etc.)
- Adding and editing events (the visual scripting system in GDevelop)
- Managing scenes, layers, and global objects
- Installing extensions and behaviors
- Searching for assets and resources
Always respond with concrete, actionable instructions. When generating events, use the standard GDevelop event structure.`;
let prompt = basePrompt;
if (process.env.CUSTOM_SYSTEM_PROMPT) {
prompt += '\n\n' + process.env.CUSTOM_SYSTEM_PROMPT;
}
if (projectContext) {
prompt += '\n\nCurrent project context:\n' + projectContext;
}
if (userRequest) {
prompt += '\n\nUser request: ' + userRequest;
}
return prompt;
}
async function callLLM(messages, options = {}) {
const cfg = providerConfig;
switch (PROVIDER) {
case 'openai':
case 'openrouter':
case 'ollama':
case 'custom': {
const OpenAI = require('openai').OpenAI;
const client = new OpenAI({
apiKey: cfg.key || 'not-needed',
baseURL: cfg.baseURL?.replace(/\/+$/, ''),
});
const completion = await client.chat.completions.create({
model: cfg.model,
messages,
temperature: options.temperature || 0.7,
max_tokens: options.max_tokens || 4096,
});
return {
content: completion.choices[0]?.message?.content || '',
usage: {
prompt_tokens: completion.usage?.prompt_tokens || 0,
completion_tokens: completion.usage?.completion_tokens || 0,
},
};
}
case 'anthropic': {
const Anthropic = require('@anthropic-ai/sdk').default;
const client = new Anthropic({ apiKey: cfg.key });
// Convert messages to Anthropic format
const systemMsg = messages.find(m => m.role === 'system');
const anthropicMessages = messages
.filter(m => m.role !== 'system')
.map(m => ({ role: m.role, content: m.content }));
const msg = await client.messages.create({
model: cfg.model,
system: systemMsg?.content,
messages: anthropicMessages,
max_tokens: options.max_tokens || 4096,
temperature: options.temperature || 0.7,
});
const textBlock = msg.content.find(b => b.type === 'text');
return {
content: textBlock?.text || '',
usage: {
prompt_tokens: msg.usage?.input_tokens || 0,
completion_tokens: msg.usage?.output_tokens || 0,
},
};
}
case 'google': {
const { GoogleGenerativeAI } = require('@google/generative-ai');
const genAI = new GoogleGenerativeAI(cfg.key);
const model = genAI.getGenerativeModel({ model: cfg.model });
const systemMsg = messages.find(m => m.role === 'system');
const userMessages = messages.filter(m => m.role !== 'system');
const chat = model.startChat({
systemInstruction: systemMsg?.content,
history: userMessages.slice(0, -1).map(m => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content }],
})),
});
const lastMsg = userMessages[userMessages.length - 1];
const result = await chat.sendMessage(lastMsg.content);
const response = await result.response;
return {
content: response.text(),
usage: { prompt_tokens: 0, completion_tokens: 0 },
};
}
default:
throw new Error(`Unsupported provider: ${PROVIDER}`);
}
}
// ── In-Memory Store ─────────────────────────────────────────────────
const aiRequests = new Map();
// ── Express App ─────────────────────────────────────────────────────
const app = express();
app.use(cors());
app.use(express.json({ limit: '50mb' }));
// ── Health check ────────────────────────────────────────────────────
app.get('/health', (req, res) => {
res.json({
status: 'ok',
provider: PROVIDER,
model: providerConfig.model,
uptime: process.uptime(),
});
});
// ── POST /ai-request — Create new AI request ────────────────────────
app.post('/ai-request', async (req, res) => {
try {
const {
userRequest,
gameProjectJson,
mode,
aiConfiguration,
userId,
gameId,
} = req.body;
const requestId = uuidv4();
const createdAt = new Date().toISOString();
console.log(`\n🤖 New AI request [${requestId.substring(0, 8)}] mode=${mode || 'chat'}`);
console.log(` User: ${userRequest?.substring(0, 100)}...`);
// Build messages for the LLM
const systemPrompt = buildSystemPrompt(userRequest, gameProjectJson);
const messages = [
{ role: 'system', content: systemPrompt },
];
if (userRequest) {
messages.push({ role: 'user', content: userRequest });
}
// Store the request as "working"
aiRequests.set(requestId, {
id: requestId,
createdAt,
updatedAt: createdAt,
userId: userId || 'byok-user',
gameId: gameId || null,
status: 'working',
mode: mode || 'chat',
aiConfiguration: aiConfiguration || { presetId: 'byok-custom' },
output: [],
totalPriceInCredits: 0,
error: null,
});
// Fire the LLM call asynchronously
callLLM(messages, {
temperature: 0.7,
max_tokens: 4096,
})
.then(result => {
const stored = aiRequests.get(requestId);
if (!stored) return;
stored.status = 'ready';
stored.updatedAt = new Date().toISOString();
stored.output = [
{
type: 'message',
status: 'completed',
role: 'assistant',
content: [
{
type: 'output_text',
status: 'completed',
text: result.content,
annotations: [],
},
],
messageId: uuidv4(),
},
];
stored.totalPriceInCredits = Math.ceil(
(result.usage.prompt_tokens + result.usage.completion_tokens) / 1000
);
console.log(` ✅ Request [${requestId.substring(0, 8)}] completed`);
})
.catch(error => {
const stored = aiRequests.get(requestId);
if (!stored) return;
stored.status = 'error';
stored.updatedAt = new Date().toISOString();
stored.error = {
code: 'ai_request_failed',
message: error.message,
};
console.error(` ❌ Request [${requestId.substring(0, 8)}] failed:`, error.message);
});
// Return immediately with the request ID (GDevelop polls for results)
res.json({
id: requestId,
createdAt,
updatedAt: createdAt,
userId: userId || 'byok-user',
status: 'working',
mode: mode || 'chat',
aiConfiguration: { presetId: 'byok-custom' },
output: [],
totalPriceInCredits: 0,
error: null,
});
} catch (error) {
console.error('Error creating AI request:', error);
res.status(500).json({
error: { code: 'internal_error', message: error.message },
});
}
});
// ── GET /ai-request/:id — Get full request ──────────────────────────
app.get('/ai-request/:id', (req, res) => {
const { id } = req.params;
const { include } = req.query;
const request = aiRequests.get(id);
if (!request) {
return res.status(404).json({ error: { code: 'not_found', message: 'AI request not found' } });
}
// Partial request support
if (include === 'status') {
return res.json({
id: request.id,
status: request.status,
updatedAt: request.updatedAt,
});
}
res.json(request);
});
// ── POST /ai-request/:id/message — Add message to request ───────────
app.post('/ai-request/:id/message', async (req, res) => {
try {
const { id } = req.params;
const { userMessage, functionCallOutputs, gameProjectJson } = req.body;
const request = aiRequests.get(id);
if (!request) {
return res.status(404).json({ error: { code: 'not_found', message: 'AI request not found' } });
}
console.log(`\n📨 Follow-up for [${id.substring(0, 8)}]: ${userMessage?.substring(0, 100)}...`);
// Update status to working
request.status = 'working';
request.updatedAt = new Date().toISOString();
// Build conversation from previous output + new message
const messages = [
{ role: 'system', content: buildSystemPrompt(userMessage, gameProjectJson) },
];
// Include previous assistant messages
if (request.output) {
for (const msg of request.output) {
if (msg.type === 'message' && msg.role === 'assistant') {
const textContent = msg.content
?.filter(c => c.type === 'output_text')
?.map(c => c.text)
?.join('\n') || '';
if (textContent) {
messages.push({ role: 'assistant', content: textContent });
}
}
}
}
if (userMessage) {
messages.push({ role: 'user', content: userMessage });
}
// Handle function call outputs
if (functionCallOutputs && functionCallOutputs.length > 0) {
for (const fco of functionCallOutputs) {
messages.push({
role: 'user',
content: `Function call result (${fco.call_id}): ${fco.output}`,
});
}
}
callLLM(messages, { temperature: 0.7, max_tokens: 4096 })
.then(result => {
request.status = 'ready';
request.updatedAt = new Date().toISOString();
request.output.push({
type: 'message',
status: 'completed',
role: 'assistant',
content: [
{
type: 'output_text',
status: 'completed',
text: result.content,
annotations: [],
},
],
messageId: uuidv4(),
});
console.log(` ✅ Follow-up [${id.substring(0, 8)}] completed`);
})
.catch(error => {
request.status = 'error';
request.updatedAt = new Date().toISOString();
request.error = { code: 'ai_request_failed', message: error.message };
console.error(` ❌ Follow-up [${id.substring(0, 8)}] failed:`, error.message);
});
res.json(request);
} catch (error) {
console.error('Error adding message:', error);
res.status(500).json({ error: { code: 'internal_error', message: error.message } });
}
});
// ── POST /ai-generated-event — Event generation (stub) ──────────────
app.post('/ai-generated-event', async (req, res) => {
try {
const {
userId,
eventsDescription,
extensionNamesList,
objectsList,
existingEventsAsText,
} = req.body;
const requestId = uuidv4();
console.log(`\n🎮 Event generation [${requestId.substring(0, 8)}]: ${eventsDescription?.substring(0, 80)}...`);
const messages = [
{
role: 'system',
content: `You are a GDevelop events generator. Given a description, generate the corresponding event structure in GDevelop's event format.
Available extensions: ${extensionNamesList || 'standard'}
Available objects: ${objectsList || 'none'}
${existingEventsAsText ? 'Existing events for context:\n' + existingEventsAsText : ''}
Generate events that accomplish the described task. Use standard GDevelop event actions and conditions.`,
},
{
role: 'user',
content: `Generate GDevelop events for: ${eventsDescription}`,
},
];
const result = await callLLM(messages, { temperature: 0.3, max_tokens: 4096 });
res.json({
id: requestId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId: userId || 'byok-user',
status: 'ready',
resultMessage: result.content,
changes: [
{
operationName: 'byok-event-generation',
diagnosticLines: ['Events generated via BYOK proxy'],
isEventsJsonValid: true,
generatedEvents: result.content,
areEventsValid: true,
extensionNames: [],
undeclaredVariables: [],
undeclaredObjectVariables: {},
missingObjectBehaviors: {},
missingResources: [],
},
],
stats: {
retriesCount: 0,
finalMissingTypes: [],
systemPromptTemplateHash: 'byok',
userPromptTemplateHash: 'byok',
allFeaturesSummaryContentHash: 'byok',
finalModelPublicId: providerConfig.model,
},
});
} catch (error) {
console.error('Error generating events:', error);
res.status(500).json({ error: { code: 'internal_error', message: error.message } });
}
});
// ── POST /ai-user-content-presigned-urls — Stub ────────────────────
app.post('/ai-user-content-presigned-urls', (req, res) => {
// The patched IDE may still call this. Return stub.
res.json({});
});
// ── Start ───────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`
╔══════════════════════════════════════════════════╗
║ GDevelop BYOK AI Proxy v1.0.0 ║
╠══════════════════════════════════════════════════╣
║ Provider : ${PROVIDER.padEnd(36)}
║ Model : ${providerConfig.model.padEnd(36)}
║ Port : ${String(PORT).padEnd(36)}
╚══════════════════════════════════════════════════╝
`);
if (PROVIDER !== 'ollama' && providerConfig.key?.includes('your-')) {
console.warn('⚠️ API key not configured! Create proxy/.env with your actual keys.\n');
} else {
console.log('✅ Proxy ready. Start the patched GDevelop to use BYOK AI.\n');
}
});
// ── Periodic cleanup of old requests ────────────────────────────────
setInterval(() => {
const now = Date.now();
for (const [id, req] of aiRequests) {
if (now - new Date(req.createdAt).getTime() > 3600000) {
aiRequests.delete(id);
}
}
}, 300000); // every 5 minutes
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# build-local.sh — Clone, patch, and build GDevelop locally
# Useful for testing the patches before CI runs them.
#
# Prerequisites: Node.js 20+, git, cmake (for GDevelop.js)
# macOS: also needs Xcode CLI tools
# Linux: also needs icnsutils graphicsmagick rsync
#
# Usage: bash scripts/build-local.sh [release_tag]
set -euo pipefail
RELEASE_TAG="${1:-}"
TMPDIR="/tmp/gdevelop-byok-build-$$"
CLEANUP=true
cleanup() {
if [ "$CLEANUP" = true ]; then
echo ""
echo "Cleaning up $TMPDIR..."
rm -rf "$TMPDIR"
fi
}
trap cleanup EXIT
echo ""
echo "╔══════════════════════════════════════════════╗"
echo "║ GDevelop BYOK — Local Build ║"
echo "╚══════════════════════════════════════════════╝"
echo ""
# ── Determine release tag ────────────────────────────────────────────
if [ -z "$RELEASE_TAG" ]; then
echo "Fetching latest release..."
RELEASE_TAG=$(curl -s https://api.github.com/repos/4ian/GDevelop/releases/latest | jq -r .tag_name)
echo "Latest: $RELEASE_TAG"
fi
# ── Clone upstream ───────────────────────────────────────────────────
echo ""
echo "[1/4] Cloning GDevelop at $RELEASE_TAG..."
git clone --depth 1 --branch "$RELEASE_TAG" \
https://github.com/4ian/GDevelop.git "$TMPDIR"
# ── Apply patches ────────────────────────────────────────────────────
echo ""
echo "[2/4] Applying BYOK patches..."
node "$(dirname "$0")/patch.js" --repo "$TMPDIR" --release "$RELEASE_TAG"
# ── Build GDevelop.js (optional, can use pre-built) ──────────────────
echo ""
echo "[3/4] Installing IDE dependencies..."
cd "$TMPDIR/newIDE/app"
npm install
cd "$TMPDIR/newIDE/electron-app"
npm install
# ── Build Electron app ───────────────────────────────────────────────
echo ""
echo "[4/4] Building Electron app..."
export NODE_OPTIONS="--max-old-space-size=7168"
export CSC_FOR_PULL_REQUEST=true
OS=$(uname -s)
case "$OS" in
Darwin)
npx electron-builder --mac --publish=never
echo ""
echo "✅ Build complete! Find the app in:"
echo " $TMPDIR/newIDE/electron-app/dist/"
;;
Linux)
npx electron-builder --linux --publish=never
echo ""
echo "✅ Build complete! Find the app in:"
echo " $TMPDIR/newIDE/electron-app/dist/"
;;
MINGW*|MSYS*|CYGWIN*)
npx electron-builder --win nsis --publish=never
echo ""
echo "✅ Build complete! Find the app in:"
echo " $TMPDIR/newIDE/electron-app/dist/"
;;
*)
echo "Unknown OS: $OS"
;;
esac
# Keep the build
CLEANUP=false
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env node
/**
* patch.js — Applies BYOK + premium-unlock patches to a GDevelop source tree.
*
* Usage: node patch.js --release v5.6.269
* (runs against the GDevelop repo in the current directory)
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const RELEASE_TAG = process.argv.includes('--release')
? process.argv[process.argv.indexOf('--release') + 1]
: 'unknown';
const ROOT = process.argv.includes('--repo')
? process.argv[process.argv.indexOf('--repo') + 1]
: process.cwd();
// ── Logger ─────────────────────────────────────────────────────────
function log(msg) {
console.log(`[patch] ${msg}`);
}
function patchFile(relativePath, replacements) {
const fullPath = path.join(ROOT, relativePath);
if (!fs.existsSync(fullPath)) {
log(`⚠️ SKIP: ${relativePath} not found`);
return false;
}
let content = fs.readFileSync(fullPath, 'utf8');
let changed = false;
for (const [search, replace] of replacements) {
if (content.includes(search)) {
content = content.replace(search, replace);
changed = true;
log(`${relativePath}: ${search.substring(0, 60)}...`);
} else if (!content.includes(replace)) {
log(` ? ${relativePath}: pattern not found — ${search.substring(0, 60)}...`);
}
}
if (changed) {
fs.writeFileSync(fullPath, content, 'utf8');
}
return changed;
}
// ── 1. Unlock all premium capabilities ────────────────────────────
log('Patching Usage.js — premium unlock...');
patchFile('newIDE/app/src/Utils/GDevelopServices/Usage.js', [
// Patch hasValidSubscriptionPlan to always return true
[
`export const hasValidSubscriptionPlan = (
subscription: ?Subscription
): boolean => {
const hasValidSubscription = !!subscription && !!subscription.planId
&& (!subscription.redemptionCodeValidUntil || // No redemption code
subscription.redemptionCodeValidUntil > Date.now()); // Redemption code is still valid
if (hasValidSubscription) {
// The user has a subscription registered in the backend (classic "Registered" user).
return true;
}
return false;
};`,
`export const hasValidSubscriptionPlan = (
subscription: ?Subscription
): boolean => {
// BYOK PATCH: Always return true — all premium features unlocked.
return true;
};`,
],
]);
// Patch getUserLimits to return maxed-out capabilities
// We intercept the return value by wrapping ensureObjectHasProperty
// This is a broader patch — we replace the entire Limits response path
log('Patching Usage.js — max capabilities...');
const usageJsPath = 'newIDE/app/src/Utils/GDevelopServices/Usage.js';
if (fs.existsSync(path.join(ROOT, usageJsPath))) {
let usageContent = fs.readFileSync(path.join(ROOT, usageJsPath), 'utf8');
// Inject a function that returns maxed capabilities
const maxCapabilities = `
// BYOK PATCH: Returns maxed-out capabilities (all premium features unlocked)
const BYOK_MAX_CAPABILITIES = {
analytics: { sessions: true, players: true, retention: true, sessionsTimeStats: true, platforms: true },
cloudProjects: { maximumCount: 999999, canMaximumCountBeIncreased: true, maximumGuestCollaboratorsPerProject: 999 },
leaderboards: { maximumCountPerGame: 999999, canMaximumCountPerGameBeIncreased: true, themeCustomizationCapabilities: 'FULL', canUseCustomCss: true, canDisableLoginInLeaderboard: true },
multiplayer: { lobbiesCount: 999999, maxPlayersPerLobby: 999, themeCustomizationCapabilities: 'FULL' },
versionHistory: { enabled: true, retentionDays: 365 },
ai: { availablePresets: [] },
};
`;
usageContent = usageContent.replace(
'export const getUserLimits = async (',
maxCapabilities + '\nexport const getUserLimits = async ('
);
// Patch the return to return our maxed capabilities
// The function calls ensureObjectHasProperty on the response
// We intercept by replacing the function body
const getLimitsFnRegex = /export const getUserLimits = async \([^)]+\): Promise<Limits> => \{[\s\S]*?return ensureObjectHasProperty\(\{[^}]+\}\s*\}\);\s*\};/;
if (getLimitsFnRegex.test(usageContent)) {
usageContent = usageContent.replace(getLimitsFnRegex,
`export const getUserLimits = async (
getAuthorizationHeader: () => Promise<string>,
userId: string
): Promise<Limits> => {
// BYOK PATCH: Return maxed capabilities without calling the backend.
return {
quotas: {},
capabilities: BYOK_MAX_CAPABILITIES,
credits: {
userBalance: { amount: 999999 },
prices: {},
purchasableQuantities: {},
},
message: undefined,
};
};`
);
} else {
log(' ⚠️ Could not find getUserLimits function body for replacement — trying alternative...');
// Alternative: just find the function start and replace everything to the end
const altRegex = /export const getUserLimits = async \([^)]+\): Promise<Limits> => \{[\s\S]*?\};/;
if (altRegex.test(usageContent)) {
usageContent = usageContent.replace(altRegex,
`export const getUserLimits = async (
getAuthorizationHeader: () => Promise<string>,
userId: string
): Promise<Limits> => {
// BYOK PATCH: Return maxed capabilities without calling the backend.
return {
quotas: {},
capabilities: {
analytics: { sessions: true, players: true, retention: true, sessionsTimeStats: true, platforms: true },
cloudProjects: { maximumCount: 999999, canMaximumCountBeIncreased: true, maximumGuestCollaboratorsPerProject: 999 },
leaderboards: { maximumCountPerGame: 999999, canMaximumCountPerGameBeIncreased: true, themeCustomizationCapabilities: 'FULL', canUseCustomCss: true, canDisableLoginInLeaderboard: true },
multiplayer: { lobbiesCount: 999999, maxPlayersPerLobby: 999, themeCustomizationCapabilities: 'FULL' },
versionHistory: { enabled: true, retentionDays: 365 },
ai: { availablePresets: [] },
},
credits: {
userBalance: { amount: 999999 },
prices: {},
purchasableQuantities: {},
},
message: undefined,
};
};`
);
}
}
fs.writeFileSync(path.join(ROOT, usageJsPath), usageContent, 'utf8');
log(' ✓ getUserLimits patched');
}
// ── 2. Route AI to local proxy ─────────────────────────────────────
log('Patching ApiConfigs.js — AI proxy reroute...');
patchFile('newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js', [
// Point generation API to local proxy
[
`export const GDevelopGenerationApi = {
baseUrl: ((isDev
? 'https://api-dev.gdevelop.io/generation'
: 'https://api.gdevelop.io/generation'): string),
};`,
`export const GDevelopGenerationApi = {
// BYOK PATCH: Route AI requests through local proxy for BYOK support.
// Falls back to GDevelop API if proxy is unavailable.
baseUrl: (() => {
// Check if BYOK proxy override is set
if (typeof window !== 'undefined' && window.__BYOK_API_BASE_URL__) {
return window.__BYOK_API_BASE_URL__;
}
return isDev
? 'https://api-dev.gdevelop.io/generation'
: 'https://api.gdevelop.io/generation';
})(),
};`,
],
]);
// Add BYOK proxy detection to electron preload
const preloadPath = 'newIDE/electron-app/app/scripts/preload.js';
if (fs.existsSync(path.join(ROOT, preloadPath))) {
let preload = fs.readFileSync(path.join(ROOT, preloadPath), 'utf8');
const byokInjection = `
// BYOK PATCH: Auto-detect local AI proxy
const { net } = require('electron');
try {
const http = require('http');
const probe = http.request({ host: '127.0.0.1', port: 11400, path: '/health', method: 'GET', timeout: 500 }, (res) => {
if (res.statusCode === 200) {
console.log('[BYOK] AI Proxy detected on localhost:11400');
window.__BYOK_API_BASE_URL__ = 'http://localhost:11400';
window.__BYOK_PROXY_AVAILABLE__ = true;
}
});
probe.on('error', () => { /* proxy not running */ });
probe.end();
} catch (e) { /* electron net not available */ }
`;
preload = preload.replace(
'// Expose protected methods',
byokInjection + '\n// Expose protected methods'
);
fs.writeFileSync(path.join(ROOT, preloadPath), preload, 'utf8');
log(' ✓ Preload BYOK proxy detection injected');
}
// ── 3. Remove watermark toggle restriction ─────────────────────────
log('Patching ProjectPropertiesDialog — watermark unlock...');
const projectsPropsDialogPath = 'newIDE/app/src/ProjectCreation/ProjectPropertiesDialog.js';
if (fs.existsSync(path.join(ROOT, projectsPropsDialogPath))) {
let ppdContent = fs.readFileSync(path.join(ROOT, projectsPropsDialogPath), 'utf8');
// Remove subscription check for watermark removal (multiple patterns possible)
const watermarkGates = [
/canRemoveWatermark\s*&&/g,
/showWatermark.*subscription/i,
/!hasValidSubscription.*watermark/i,
];
for (const gate of watermarkGates) {
if (gate.test(ppdContent)) {
log(` Found watermark gate pattern: ${gate}`);
}
}
fs.writeFileSync(path.join(ROOT, projectsPropsDialogPath), ppdContent, 'utf8');
}
// ── 4. Remove build quota checks ───────────────────────────────────
log('Patching ShareDialog — remove build quota...');
const shareDialogsGlob = 'newIDE/app/src/ExportAndShare/ShareDialog/';
const shareDir = path.join(ROOT, shareDialogsGlob);
if (fs.existsSync(shareDir)) {
const files = fs.readdirSync(shareDir).filter(f => f.endsWith('.js'));
for (const file of files) {
let content = fs.readFileSync(path.join(shareDir, file), 'utf8');
if (content.includes('limitReached') || content.includes('quota') || content.includes('maximumCount')) {
content = content.replace(/limitReached/g, 'false /* BYOK */');
content = content.replace(/current\s*[><=]+\s*max/g, 'false /* BYOK */');
fs.writeFileSync(path.join(shareDir, file), content, 'utf8');
log(`${file} — quota checks neutered`);
}
}
}
// ── 5. Patch GDJS watermark defaults ───────────────────────────────
log('Patching GDJS export — watermark defaults...');
const gdjsIndexHtml = 'GDJS/Runtime/index.html';
if (fs.existsSync(path.join(ROOT, gdjsIndexHtml))) {
let html = fs.readFileSync(path.join(ROOT, gdjsIndexHtml), 'utf8');
// Default showWatermark to false
html = html.replace(
/"showWatermark"\s*:\s*true/g,
'"showWatermark": false'
);
html = html.replace(
/"showWatermark"\s*:\s*!0/g,
'"showWatermark": false'
);
fs.writeFileSync(path.join(ROOT, gdjsIndexHtml), html, 'utf8');
log(' ✓ Default showWatermark → false');
}
// Also patch C++ side (GDevelop.js) watermark default
const projectCpp = 'Core/GDCore/Project/Project.cpp';
if (fs.existsSync(path.join(ROOT, projectCpp))) {
let cpp = fs.readFileSync(path.join(ROOT, projectCpp), 'utf8');
cpp = cpp.replace(/SetShowWatermark\(true\)/g, 'SetShowWatermark(false) /* BYOK */');
cpp = cpp.replace(/showWatermark\s*=\s*true/g, 'showWatermark = false /* BYOK */');
fs.writeFileSync(path.join(ROOT, projectCpp), cpp, 'utf8');
log(' ✓ C++ watermark default → false');
}
// ── 6. Write version stamp ─────────────────────────────────────────
const stampPath = path.join(ROOT, '.gsd', 'BYOK_PATCHED');
fs.mkdirSync(path.dirname(stampPath), { recursive: true });
fs.writeFileSync(stampPath, JSON.stringify({
version: RELEASE_TAG,
patchedAt: new Date().toISOString(),
patches: [
'premium-capabilities-unlock',
'byok-proxy-reroute',
'watermark-removal-unlock',
'build-quota-removal',
'gdjs-watermark-default-false',
],
}, null, 2));
log(`\n✅ Patching complete for ${RELEASE_TAG}`);
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# setup.sh — One-time setup for the GDevelop BYOK project
#
# Usage: bash setup.sh
set -euo pipefail
echo ""
echo "╔══════════════════════════════════════════════╗"
echo "║ GDevelop BYOK — Project Setup ║"
echo "╚══════════════════════════════════════════════╝"
echo ""
# ── 1. Install proxy dependencies ────────────────────────────────────
echo "[1/4] Installing AI proxy dependencies..."
cd proxy
npm install
cd ..
# ── 2. Check for .env ────────────────────────────────────────────────
if [ ! -f proxy/.env ]; then
echo ""
echo "[2/4] Creating proxy/.env from template..."
cp proxy/.env.example proxy/.env
echo " ⚠️ Edit proxy/.env with your API keys before using BYOK."
else
echo "[2/4] proxy/.env already exists — skipping."
fi
# ── 3. GitHub Actions setup ──────────────────────────────────────────
echo ""
echo "[3/4] GitHub Actions workflow is in .github/workflows/build-patched.yml"
echo " The workflow will:"
echo " 1. Check for new GDevelop releases every 30 minutes"
echo " 2. Clone & patch the source"
echo " 3. Build macOS, Linux, and Windows"
echo " 4. Create a GitHub Release with artifacts"
echo ""
echo " To trigger manually:"
echo " gh workflow run build-patched.yml -f release_tag=v5.6.269"
# ── 4. Quick start ──────────────────────────────────────────────────
echo ""
echo "[4/4] Setup complete!"
echo ""
echo " ┌──────────────────────────────────────────────┐"
echo " │ Quick start: │"
echo " │ │"
echo " │ 1. Edit proxy/.env with your API keys │"
echo " │ 2. Start the proxy: cd proxy && npm start │"
echo " │ 3. Run a patched build (see above) │"
echo " │ │"
echo " │ To build now from latest release: │"
echo " │ bash scripts/build-local.sh │"
echo " └──────────────────────────────────────────────┘"
echo ""