test: add P012 fixture selectors, byok-embedded bootstrap, and proxy module fixtures

This commit is contained in:
John Doe
2026-05-18 13:54:38 -04:00
parent c8644c1421
commit f77d4065bc
53 changed files with 5757 additions and 88 deletions
+6 -6
View File
@@ -69,8 +69,8 @@ jobs:
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: Apply BYOK patches + deploy source modules
run: node ${{ github.workspace }}/scripts/patch.js --repo /tmp/gd --release ${{ needs.check-release.outputs.release_tag }} --deploy-src ${{ github.workspace }}
- name: Build GDevelop.js (WASM)
run: |
@@ -117,8 +117,8 @@ jobs:
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: Apply BYOK patches + deploy source modules
run: node ${{ github.workspace }}/scripts/patch.js --repo /tmp/gd --release ${{ needs.check-release.outputs.release_tag }} --deploy-src ${{ github.workspace }}
- name: Build Electron app (Linux)
run: |
@@ -156,8 +156,8 @@ jobs:
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: Apply BYOK patches + deploy source modules
run: node ${{ github.workspace }}/scripts/patch.js --repo C:\tmp\gd --release ${{ needs.check-release.outputs.release_tag }} --deploy-src ${{ github.workspace }}
- name: Build Electron app (Windows)
run: |
@@ -0,0 +1 @@
[{"ts":1778989278623,"type":"say","say":"task","text":"Write a commit message","images":[]},{"ts":1778989278624,"type":"say","say":"api_req_started","text":"{\"apiProtocol\":\"anthropic\"}"}]
+56
View File
@@ -0,0 +1,56 @@
'use strict';
/**
* BYOK embedded bootstrap — thin wrapper that wires production IPC handlers.
*
* Deployed alongside main.js in newIDE/electron-app/app/scripts/.
* Requires the deployed src/ modules (ipc/byokHandler, proxy/byokConfig)
* which are copied into the repo by the --deploy-src step in patch.js.
*
* This replaces the fixture stub (test/fixtures/.../byok-embedded.js)
* which only handles config and punts request handling to "downstream"
* code that never existed in the real build.
*
* Design (per user decision): thin wrapper + src/ directory.
* Single bundled file and relative requires to project src/ were rejected.
*/
const { registerByokIpcHandlers } = require('./src/ipc/byokHandler');
const { createByokConfigStore } = require('./src/proxy/byokConfig');
// ── Module-init: create the config store ────────────────────────────
// Lazy-created so that require() doesn't fail if electron-store is
// unavailable (tests, headless CI). SafeStorage encryption is handled
// inside byokConfig.js.
let _configStore = null;
function _ensureConfigStore() {
if (!_configStore) {
_configStore = createByokConfigStore();
}
return _configStore;
}
// ── Public registration ─────────────────────────────────────────────
/**
* Register all BYOK IPC handlers (request + config) on the provided
* ipcMain instance.
*
* @param {Electron.IpcMain} ipcMain
* @param {object} [store] Optional override for the config store
*/
function registerAllHandlers(ipcMain, store) {
const effectiveStore = store || _ensureConfigStore();
// Delegates to the full byokHandler.js which registers:
// byok-ai-request — chat / agent / orchestrator
// byok-ai-request-status — poll fire-and-forget
// byok-ai-get-config — read persisted config
// byok-ai-set-config — write persisted config
registerByokIpcHandlers(ipcMain, effectiveStore);
console.log('[byok] All IPC handlers registered (request + config)');
}
module.exports = { registerAllHandlers };
+31 -4
View File
@@ -20,11 +20,11 @@
{
"id": "P003",
"file": "newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js",
"description": "GDevelopGenerationApi baseUrl dynamic override",
"description": "Remove BYOK ApiConfigs baseUrl override (superseded by Generation.js IPC routing)",
"required": true,
"finder": "baseUrl: \\(\\(isDev[\\s\\S]*?\\): string\\),",
"replacer": "baseUrl: typeof window !== 'undefined' && window.__BYOK_API_BASE_URL__\n ? window.__BYOK_API_BASE_URL__\n : ((isDev\n ? 'https://api-dev.gdevelop.io/generation'\n : 'https://api.gdevelop.io/generation'): string), // BYOK PATCH: dynamic override",
"verify": "BYOK PATCH: dynamic override"
"finder": "baseUrl: typeof window !== 'undefined' && window\\.__BYOK_API_BASE_URL__[\\s\\S]*?\\): string\\),",
"replacer": "baseUrl: ((isDev\n ? 'https://api-dev.gdevelop.io/generation'\n : 'https://api.gdevelop.io/generation'): string),",
"verify": "baseUrl: \\(\\(isDev"
},
{
"id": "P004",
@@ -79,5 +79,32 @@
"finder": "(const \\{ app, BrowserWindow \\} = require\\('electron'\\);)([\\s\\S]*?)(app\\.on\\('ready', createWindow\\);)",
"replacer": "$1\nconst { registerByokIpcHandlers } = require('./byok-embedded'); // BYOK PATCH\n\n$2app.on('ready', () => {\n // BYOK PATCH: wire IPC handlers before creating window\n registerByokIpcHandlers(require('electron').ipcMain);\n createWindow();\n});",
"verify": "require\\('./byok-embedded'\\)"
},
{
"id": "P010",
"file": "newIDE/app/src/AiConfiguration/AiConfiguration.js",
"description": "Append BYOK synthetic preset to AI configuration preset list",
"required": true,
"finder": "endpoint: 'https://api\\.openai\\.com/v1/chat/completions',\\s*description: 'Fast, affordable model for everyday tasks\\.',\\s*\\},\\s*\\];",
"replacer": "endpoint: 'https://api.openai.com/v1/chat/completions',\n description: 'Fast, affordable model for everyday tasks.',\n },\n {\n id: 'byok',\n name: 'BYOK (Bring Your Own Key)',\n model: '',\n provider: '',\n endpoint: '',\n description: 'Use your own LLM provider. Configure in Settings.',\n },\n ];",
"verify": "id: 'byok'"
},
{
"id": "P011",
"file": "newIDE/app/src/Utils/GDevelopServices/Generation.js",
"description": "Intercept AI requests when BYOK preset detected; route through IPC",
"required": true,
"finder": "const authHeader = await getAuthorizationHeader\\(\\);",
"replacer": "// BYOK PATCH: route through IPC when BYOK preset selected\n if (aiConfiguration?.presetId === 'byok') {\n const byokConfig = await window.byokAi.getConfig();\n const result = await window.byokAi.request({ mode: 'chat', messages, aiConfiguration: { model: byokConfig.model || aiConfiguration?.model } });\n return { content: result.content, usage: result.usage || { prompt_tokens: 0, completion_tokens: 0 } };\n }\n const authHeader = await getAuthorizationHeader();",
"verify": "window\\.byokAi\\.request"
},
{
"id": "P012",
"file": "newIDE/app/src/AiConfiguration/AiConfigurationPresetSelector.js",
"description": "Inject inline BYOK config form into AiConfigurationPresetSelector",
"required": true,
"finder": "const AiConfigurationPresetSelector = \\({ value, onChange }: Props\\) => \\{[\\s\\S]*?export default AiConfigurationPresetSelector;",
"replacer": "const AiConfigurationPresetSelector = ({ value, onChange }: Props) => {\n const [provider, setProvider] = React.useState('');\n const [endpoint, setEndpoint] = React.useState('');\n const [apiKey, setApiKey] = React.useState('');\n const [model, setModel] = React.useState('');\n const [savedLabel, setSavedLabel] = React.useState('');\n const [saving, setSaving] = React.useState(false);\n const [error, setError] = React.useState('');\n\n const presets = React.useMemo(\n () =>\n getDefaultAIConfigurationPresets().map((preset) => ({\n value: preset.id,\n label: preset.name,\n })),\n [],\n );\n\n React.useEffect(() => {\n if (value === 'byok' && window.byokAi && window.byokAi.getConfig) {\n window.byokAi.getConfig().then((config) => {\n if (config) {\n setProvider(config.provider || '');\n setEndpoint(config.endpoint || '');\n setApiKey(config.apiKey || '');\n setModel(config.model || '');\n if (config.provider && config.model) {\n setSavedLabel(config.provider + '/' + config.model);\n }\n }\n }).catch((err) => {\n console.warn('[byok] config get failed', err.message);\n });\n }\n }, [value]);\n\n const handleSave = async () => {\n setSaving(true);\n setError('');\n try {\n await window.byokAi.setConfig({ provider, endpoint, apiKey, model });\n setSavedLabel(provider + '/' + model);\n } catch (err) {\n setError(err.message || 'Save failed');\n } finally {\n setSaving(false);\n }\n };\n\n return (\n <>\n <SelectField\n value={value}\n onChange={(event, index, newValue) => {\n onChange(newValue);\n }}\n >\n {presets.map((preset) => (\n <option key={preset.value} value={preset.value}>\n {preset.label}\n </option>\n ))}\n </SelectField>\n {value === 'byok' && (\n <div style={{ marginTop: 16 }}>\n <TextField\n fullWidth\n floatingLabelText=\"Provider\"\n value={provider}\n onChange={(e, newValue) => setProvider(newValue)}\n />\n <TextField\n fullWidth\n floatingLabelText=\"Endpoint URL\"\n value={endpoint}\n onChange={(e, newValue) => setEndpoint(newValue)}\n />\n <TextField\n fullWidth\n floatingLabelText=\"API Key\"\n type=\"password\"\n value={apiKey}\n onChange={(e, newValue) => setApiKey(newValue)}\n />\n <TextField\n fullWidth\n floatingLabelText=\"Model Name\"\n value={model}\n onChange={(e, newValue) => setModel(newValue)}\n />\n <FlatButton\n label={saving ? 'Saving...' : 'Save'}\n primary\n disabled={saving}\n onClick={handleSave}\n />\n {savedLabel && (\n <div style={{ marginTop: 8, color: '#4caf50' }}>\n Saved: {savedLabel}\n </div>\n )}\n {error && (\n <div style={{ marginTop: 8, color: '#f44336' }}>\n {error}\n </div>\n )}\n </div>\n )}\n </>\n );\n};\n\nexport default AiConfigurationPresetSelector;",
"verify": "window\\.byokAi\\.setConfig"
}
]
+2 -2
View File
@@ -44,8 +44,8 @@ git clone --depth 1 --branch "$RELEASE_TAG" \
# ── Apply patches ────────────────────────────────────────────────────
echo ""
echo "[2/4] Applying BYOK patches..."
node "$(dirname "$0")/patch.js" --repo "$TMPDIR" --release "$RELEASE_TAG"
echo "[2/4] Applying BYOK patches + deploying source modules..."
node "$(dirname "$0")/patch.js" --repo "$TMPDIR" --release "$RELEASE_TAG" --deploy-src "$(dirname "$0")/.."
# ── Build GDevelop.js (optional, can use pre-built) ──────────────────
echo ""
+198 -9
View File
@@ -45,14 +45,17 @@ export const getUserLimits = async (
`,
'newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js':
`// Fixture: newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js
// Contains the exact pattern for patch 3 (GDevelopGenerationApi reroute).
// Contains the BYOK override pattern for P003 to clean up.
// P003 removes this override (superseded by Generation.js IPC routing).
const isDev = process.env.NODE_ENV === 'development';
export const GDevelopGenerationApi = {
baseUrl: ((isDev
baseUrl: typeof window !== 'undefined' && window.__BYOK_API_BASE_URL__
? window.__BYOK_API_BASE_URL__
: ((isDev
? 'https://api-dev.gdevelop.io/generation'
: 'https://api.gdevelop.io/generation'): string),
: 'https://api.gdevelop.io/generation'): string), // BYOK PATCH: dynamic override
};
`,
'newIDE/electron-app/app/scripts/preload.js':
@@ -127,15 +130,201 @@ app.on('ready', createWindow);
app.on('will-quit', () => { /* cleanup */ });
`,
'newIDE/electron-app/app/scripts/byok-embedded.js':
`// Fixture: BYOK embedded IPC module stub
// Exists so patch P009 verify step passes (registerByokIpcHandlers in main.js).
`// Fixture: BYOK embedded IPC module
// Bootstraps the config store and registers both request + config IPC handlers.
// Injected into Electron main.js via P009.
'use strict';
function registerByokIpcHandlers(ipcMain) {
// Stub: real implementation lives in src/ipc/byokHandler.js
const { createByokConfigStore, getByokConfig, setByokConfig } = require('./byok-config');
const configStore = createByokConfigStore();
function _createConfigHandlers(store) {
async function byokAiGetConfig(event) {
console.log('[byok] config get entry');
try {
const config = getByokConfig(store);
console.log('[byok] config get complete', JSON.stringify({
provider: config.provider, endpoint: config.endpoint,
model: config.model, hasApiKey: config.apiKey !== '',
}));
return config;
} catch (err) {
console.warn('[byok] config get failed', err.message);
return null;
}
}
async function byokAiSetConfig(event, config) {
console.log('[byok] config set entry', JSON.stringify({
provider: config?.provider, endpoint: config?.endpoint, model: config?.model,
}));
try {
setByokConfig(store, config || {});
console.log('[byok] config set complete');
return { success: true };
} catch (err) {
console.warn('[byok] config set failed', err.message);
return { success: false, error: err.message };
}
}
return { byokAiGetConfig, byokAiSetConfig };
}
function registerByokIpcHandlers(ipcMain, store) {
const effectiveStore = store || configStore;
const { byokAiGetConfig, byokAiSetConfig } = _createConfigHandlers(effectiveStore);
ipcMain.handle('byok-ai-get-config', byokAiGetConfig);
ipcMain.handle('byok-ai-set-config', byokAiSetConfig);
}
module.exports = { registerByokIpcHandlers };
`,
'newIDE/electron-app/app/scripts/byok-config.js':
`// Fixture: BYOK LLM provider configuration persistence
'use strict';
const KEYS = Object.freeze({
PROVIDER: 'byok.provider',
ENDPOINT: 'byok.endpoint',
MODEL: 'byok.model',
API_KEY: 'byok.apiKey',
});
const DEFAULT_CONFIG = Object.freeze({
provider: '', endpoint: '', apiKey: '', model: '',
});
function createByokConfigStore(opts) {
const Store = require('electron-store');
return new Store({ name: 'byok-config', ...opts });
}
function getByokConfig(store) {
return {
provider: store.get(KEYS.PROVIDER) || DEFAULT_CONFIG.provider,
endpoint: store.get(KEYS.ENDPOINT) || DEFAULT_CONFIG.endpoint,
model: store.get(KEYS.MODEL) || DEFAULT_CONFIG.model,
apiKey: store.get(KEYS.API_KEY) || DEFAULT_CONFIG.apiKey,
};
}
function setByokConfig(store, config) {
store.set(KEYS.PROVIDER, config.provider ?? DEFAULT_CONFIG.provider);
store.set(KEYS.ENDPOINT, config.endpoint ?? DEFAULT_CONFIG.endpoint);
store.set(KEYS.MODEL, config.model ?? DEFAULT_CONFIG.model);
store.set(KEYS.API_KEY, config.apiKey ?? DEFAULT_CONFIG.apiKey);
}
module.exports = { createByokConfigStore, getByokConfig, setByokConfig };
`,
'newIDE/app/src/AiConfiguration/AiConfiguration.js':
`// Fixture: newIDE/app/src/AiConfiguration/AiConfiguration.js
// Contains the exact pattern for patch 10 (BYOK preset injection).
export const getDefaultAIConfigurationPresets = (): AIConfigurationPreset[] => {
return [
{
id: 'gpt-4o',
name: 'GPT-4o',
model: 'gpt-4o',
provider: 'OpenAI',
endpoint: 'https://api.openai.com/v1/chat/completions',
description: 'Most capable GPT-4o model — best for complex reasoning and creative tasks.',
},
{
id: 'gpt-4o-mini',
name: 'GPT-4o Mini',
model: 'gpt-4o-mini',
provider: 'OpenAI',
endpoint: 'https://api.openai.com/v1/chat/completions',
description: 'Fast, affordable model for everyday tasks.',
},
];
};
`,
'newIDE/app/src/Utils/GDevelopServices/Generation.js':
`// Fixture: newIDE/app/src/Utils/GDevelopServices/Generation.js
// Contains the exact pattern for patch 11 (BYOK detection → IPC routing).
const axios = require('axios');
const { GDevelopGenerationApi } = require('./ApiConfigs');
export const generateChatResponse = async ({
messages,
aiConfiguration,
getAuthorizationHeader,
onProgress,
}: {
messages: Array<{ role: string; content: string }>,
aiConfiguration?: { presetId?: string; model?: string; provider?: string; endpoint?: string },
getAuthorizationHeader: () => Promise<string>,
onProgress?: (chunk: string) => void,
}): Promise<string> => {
const { messages, aiConfiguration, getAuthorizationHeader } = params;
const authHeader = await getAuthorizationHeader();
const response = await axios.post(
\`\${GDevelopGenerationApi.baseUrl}/chat/completions\`,
{
model: aiConfiguration?.model || 'gpt-4o',
messages,
},
{
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
},
);
return response.data.choices[0].message.content;
};
`,
'newIDE/app/src/AiConfiguration/AiConfigurationPresetSelector.js':
`// Fixture: newIDE/app/src/AiConfiguration/AiConfigurationPresetSelector.js
// Contains the exact pattern for patch 12 (BYOK inline config form injection).
import * as React from 'react';
import { useState, useEffect } from 'react';
import { getDefaultAIConfigurationPresets } from './AiConfiguration';
import SelectField from '../../UI/SelectField';
import TextField from '../../UI/TextField';
import FlatButton from '../../UI/FlatButton';
type Props = {|
value: string,
onChange: (presetId: string) => void,
|};
const AiConfigurationPresetSelector = ({ value, onChange }: Props) => {
const presets = React.useMemo(
() =>
getDefaultAIConfigurationPresets().map((preset) => ({
value: preset.id,
label: preset.name,
})),
[],
);
return (
<SelectField
value={value}
onChange={(event, index, newValue) => {
onChange(newValue);
}}
>
{presets.map((preset) => (
<option key={preset.value} value={preset.value}>
{preset.label}
</option>
))}
</SelectField>
);
};
export default AiConfigurationPresetSelector;
`,
};
@@ -173,11 +362,11 @@ console.log('Creating sabotaged fixture sets...');
writeSet(SABOTAGED, {
'newIDE/app/src/Utils/GDevelopServices/Usage.js': usageSabotaged,
});
console.log(`${SABOTAGED} (9 files, Usage.js function renamed)`);
console.log(`${SABOTAGED} (13 files, Usage.js function renamed)`);
writeSet(SABOTAGED_DUP, {
'newIDE/app/src/Utils/GDevelopServices/Usage.js': usageDup,
});
console.log(`${SABOTAGED_DUP} (9 files, Usage.js function duplicated)`);
console.log(`${SABOTAGED_DUP} (13 files, Usage.js function duplicated)`);
console.log('Done.');
+94
View File
@@ -11,6 +11,7 @@
* node scripts/patch.js --repo test/fixtures --strict
* node scripts/patch.js --repo test/fixtures --dry-run --verbose
* node scripts/patch.js --manifest custom/manifest.json --repo my/repo
* node scripts/patch.js --repo /tmp/gdevelop --deploy-src .
*
* Constraints: Node.js built-in modules only (no npm dependencies).
*/
@@ -35,6 +36,7 @@ function hasFlag(flag) {
const RELEASE_TAG = flagValue('--release') || 'unknown';
const ROOT = flagValue('--repo') || process.cwd();
const MANIFEST_PATH = flagValue('--manifest') || path.join(process.cwd(), 'patch', 'manifest.json');
const DEPLOY_SRC = flagValue('--deploy-src') || null;
const STRICT = hasFlag('--strict');
const DRY_RUN = hasFlag('--dry-run');
const VERBOSE = hasFlag('--verbose');
@@ -156,6 +158,77 @@ function applyEntry(entry, root) {
return { pass: true, step: null, msg: 'ok' };
}
// ── Deploy source modules ───────────────────────────────────────────────
/**
* File mappings for the --deploy-src step.
*
* Each entry maps a source file (relative to --deploy-src dir) to a
* destination path (relative to --repo dir / the cloned GDevelop tree).
*/
const DEPLOY_FILES = [
// Bootstrap module (injected by P009 into main.js)
{ src: 'byok-embedded.js',
dest: 'newIDE/electron-app/app/scripts/byok-embedded.js' },
// IPC handler (full request + config handler)
{ src: 'src/ipc/byokHandler.js',
dest: 'newIDE/electron-app/app/scripts/src/ipc/byokHandler.js' },
// Proxy core modules
{ src: 'src/proxy/byokConfig.js',
dest: 'newIDE/electron-app/app/scripts/src/proxy/byokConfig.js' },
{ src: 'src/proxy/callLLM.js',
dest: 'newIDE/electron-app/app/scripts/src/proxy/callLLM.js' },
{ src: 'src/proxy/buildSystemPrompt.js',
dest: 'newIDE/electron-app/app/scripts/src/proxy/buildSystemPrompt.js' },
{ src: 'src/proxy/requestStore.js',
dest: 'newIDE/electron-app/app/scripts/src/proxy/requestStore.js' },
{ src: 'src/proxy/errors.js',
dest: 'newIDE/electron-app/app/scripts/src/proxy/errors.js' },
];
/**
* Copy source modules from deploySrcDir into the repo root.
*
* @param {string} deploySrcDir Absolute path to the source project root
* @param {string} repoRoot Absolute path to the cloned GDevelop repo
* @returns {{ copied: number, errors: string[] }}
*/
function deploySourceModules(deploySrcDir, repoRoot) {
const results = { copied: 0, errors: [] };
for (const { src, dest } of DEPLOY_FILES) {
const srcPath = path.join(deploySrcDir, src);
const destPath = path.join(repoRoot, dest);
if (!fs.existsSync(srcPath)) {
results.errors.push(`source not found: ${src}`);
continue;
}
if (DRY_RUN) {
console.log(`[DEPLOY] would copy ${src}${dest}`);
results.copied++;
continue;
}
try {
const destDir = path.dirname(destPath);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
fs.copyFileSync(srcPath, destPath);
console.log(`[DEPLOY] copied ${src}${dest}`);
results.copied++;
} catch (err) {
results.errors.push(`copy failed: ${src}${dest}: ${err.message}`);
}
}
return results;
}
// ── Main ─────────────────────────────────────────────────────────────────
function main() {
@@ -218,6 +291,27 @@ function main() {
}
}
// 2.5 Deploy source modules (if --deploy-src is set)
let deployResult = null;
if (DEPLOY_SRC) {
if (DRY_RUN) {
console.log(`\n[DEPLOY] Dry-run — previewing source module deployment from ${path.resolve(DEPLOY_SRC)}`);
} else {
console.log(`\n[DEPLOY] Deploying source modules from ${path.resolve(DEPLOY_SRC)}`);
}
deployResult = deploySourceModules(path.resolve(DEPLOY_SRC), repoRoot);
if (deployResult.errors.length > 0) {
for (const err of deployResult.errors) {
console.error(` [ERROR] ${err}`);
}
console.error(`\n[FAIL] ${deployResult.errors.length} deploy error(s).`);
process.exit(1);
}
console.log(`[DEPLOY] ${deployResult.copied}/${DEPLOY_FILES.length} files deployed`);
}
// 3. Summary
if (DRY_RUN) {
console.log(`\n[DONE] Dry-run complete. ${entries.length} entries previewed.`);
+249 -10
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env node
/**
* restore-fixtures.js — Restores all 9 fixture files to pristine state.
* restore-fixtures.js — Restores all 13 fixture files to pristine state.
* Used by test-patches-local.sh to reset state between test runs.
*
* S01 state: 7 original fixtures + 1 byok-embedded.js stub (new in T03).
* P004 and P009 are the S01 IPC-based patches; remaining patches unchanged.
* S04/T01: Added AiConfigurationPresetSelector.js as 13th fixture for P012.
* S03 state: 10 original fixtures + byok-embedded.js + byok-config.js.
* New in S03/T01: AiConfiguration.js + Generation.js for P010/P011.
*/
const fs = require('fs');
const path = require('path');
@@ -39,14 +40,17 @@ export const getUserLimits = async (
`,
'newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js':
`// Fixture: newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js
// Contains the exact pattern for patch 3 (GDevelopGenerationApi reroute).
// Contains the BYOK override pattern for P003 to clean up.
// P003 removes this override (superseded by Generation.js IPC routing).
const isDev = process.env.NODE_ENV === 'development';
export const GDevelopGenerationApi = {
baseUrl: ((isDev
baseUrl: typeof window !== 'undefined' && window.__BYOK_API_BASE_URL__
? window.__BYOK_API_BASE_URL__
: ((isDev
? 'https://api-dev.gdevelop.io/generation'
: 'https://api.gdevelop.io/generation'): string),
: 'https://api.gdevelop.io/generation'): string), // BYOK PATCH: dynamic override
};
`,
'newIDE/electron-app/app/scripts/preload.js':
@@ -121,15 +125,250 @@ app.on('ready', createWindow);
app.on('will-quit', () => { /* cleanup */ });
`,
'newIDE/electron-app/app/scripts/byok-embedded.js':
`// Fixture: BYOK embedded IPC module stub
// Exists so patch P009 verify step passes (registerByokIpcHandlers in main.js).
`// Fixture: BYOK embedded IPC module
// Bootstraps the config store and registers both request + config IPC handlers.
// Injected into Electron main.js via P009.
'use strict';
function registerByokIpcHandlers(ipcMain) {
// Stub: real implementation lives in src/ipc/byokHandler.js
const { createByokConfigStore, getByokConfig, setByokConfig } = require('./byok-config');
// ── Module-init: create the config store ────────────────────────────
const configStore = createByokConfigStore();
// ── Config handler factory ──────────────────────────────────────────
/**
* Build config IPC handler functions.
*
* @param {object} store Config store instance
* @returns {{ byokAiGetConfig: Function, byokAiSetConfig: Function }}
*/
function _createConfigHandlers(store) {
async function byokAiGetConfig(event) {
console.log('[byok] config get entry');
try {
const config = getByokConfig(store);
console.log(
'[byok] config get complete',
JSON.stringify({
provider: config.provider,
endpoint: config.endpoint,
model: config.model,
hasApiKey: config.apiKey !== '',
}),
);
return config;
} catch (err) {
console.warn('[byok] config get failed', err.message);
return null;
}
}
async function byokAiSetConfig(event, config) {
console.log(
'[byok] config set entry',
JSON.stringify({
provider: config?.provider,
endpoint: config?.endpoint,
model: config?.model,
}),
);
try {
setByokConfig(store, config || {});
console.log('[byok] config set complete');
return { success: true };
} catch (err) {
console.warn('[byok] config set failed', err.message);
return { success: false, error: err.message };
}
}
return { byokAiGetConfig, byokAiSetConfig };
}
// ── Public registration ─────────────────────────────────────────────
/**
* Register BYOK IPC handlers on the provided ipcMain instance.
*
* Registers both request-related channels (byok-ai-request,
* byok-ai-request-status) and config channels (byok-ai-get-config,
* byok-ai-set-config).
*
* @param {Electron.IpcMain} ipcMain
* @param {object} [store] Optional override for the config store
*/
function registerByokIpcHandlers(ipcMain, store) {
const effectiveStore = store || configStore;
// Register config handlers
const { byokAiGetConfig, byokAiSetConfig } =
_createConfigHandlers(effectiveStore);
ipcMain.handle('byok-ai-get-config', byokAiGetConfig);
ipcMain.handle('byok-ai-set-config', byokAiSetConfig);
// Request handlers are registered downstream by src/ipc/byokHandler.js
// when the actual module is present. In the fixture, this stub covers
// base registration; the real handlers will be layered on top.
}
module.exports = { registerByokIpcHandlers };
`,
'newIDE/electron-app/app/scripts/byok-config.js':
`// Fixture: BYOK LLM provider configuration persistence
// Simplified fixture version using electron-store as primary backend.
// Matches the public API of src/proxy/byokConfig.js.
'use strict';
const KEYS = Object.freeze({
PROVIDER: 'byok.provider',
ENDPOINT: 'byok.endpoint',
MODEL: 'byok.model',
API_KEY: 'byok.apiKey',
});
const DEFAULT_CONFIG = Object.freeze({
provider: '',
endpoint: '',
apiKey: '',
model: '',
});
function createByokConfigStore(opts) {
const Store = require('electron-store');
return new Store({ name: 'byok-config', ...opts });
}
function getByokConfig(store) {
return {
provider: store.get(KEYS.PROVIDER) || DEFAULT_CONFIG.provider,
endpoint: store.get(KEYS.ENDPOINT) || DEFAULT_CONFIG.endpoint,
model: store.get(KEYS.MODEL) || DEFAULT_CONFIG.model,
apiKey: store.get(KEYS.API_KEY) || DEFAULT_CONFIG.apiKey,
};
}
function setByokConfig(store, config) {
store.set(KEYS.PROVIDER, config.provider ?? DEFAULT_CONFIG.provider);
store.set(KEYS.ENDPOINT, config.endpoint ?? DEFAULT_CONFIG.endpoint);
store.set(KEYS.MODEL, config.model ?? DEFAULT_CONFIG.model);
store.set(KEYS.API_KEY, config.apiKey ?? DEFAULT_CONFIG.apiKey);
}
module.exports = { createByokConfigStore, getByokConfig, setByokConfig };
`,
'newIDE/app/src/AiConfiguration/AiConfiguration.js':
`// Fixture: newIDE/app/src/AiConfiguration/AiConfiguration.js
// Contains the exact pattern for patch 10 (BYOK preset injection).
// Represents the upstream GDevelop AI configuration preset dropdown.
export const getDefaultAIConfigurationPresets = (): AIConfigurationPreset[] => {
return [
{
id: 'gpt-4o',
name: 'GPT-4o',
model: 'gpt-4o',
provider: 'OpenAI',
endpoint: 'https://api.openai.com/v1/chat/completions',
description: 'Most capable GPT-4o model — best for complex reasoning and creative tasks.',
},
{
id: 'gpt-4o-mini',
name: 'GPT-4o Mini',
model: 'gpt-4o-mini',
provider: 'OpenAI',
endpoint: 'https://api.openai.com/v1/chat/completions',
description: 'Fast, affordable model for everyday tasks.',
},
];
};
`,
'newIDE/app/src/Utils/GDevelopServices/Generation.js':
`// Fixture: newIDE/app/src/Utils/GDevelopServices/Generation.js
// Contains the exact pattern for patch 11 (BYOK detection → IPC routing).
// Represents the upstream GDevelop AI generation service.
const axios = require('axios');
const { GDevelopGenerationApi } = require('./ApiConfigs');
export const generateChatResponse = async ({
messages,
aiConfiguration,
getAuthorizationHeader,
onProgress,
}: {
messages: Array<{ role: string; content: string }>,
aiConfiguration?: { presetId?: string; model?: string; provider?: string; endpoint?: string },
getAuthorizationHeader: () => Promise<string>,
onProgress?: (chunk: string) => void,
}): Promise<string> => {
const { messages, aiConfiguration, getAuthorizationHeader } = params;
const authHeader = await getAuthorizationHeader();
const response = await axios.post(
\`\${GDevelopGenerationApi.baseUrl}/chat/completions\`,
{
model: aiConfiguration?.model || 'gpt-4o',
messages,
},
{
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
},
);
return response.data.choices[0].message.content;
};
`,
'newIDE/app/src/AiConfiguration/AiConfigurationPresetSelector.js':
`// Fixture: newIDE/app/src/AiConfiguration/AiConfigurationPresetSelector.js
// Contains the exact pattern for patch 12 (BYOK inline config form injection).
// Represents the upstream GDevelop AI configuration preset selector dropdown.
//
// P012 will patch this component to add an inline BYOK configuration form
// that expands below the SelectField when the BYOK preset is selected.
import * as React from 'react';
import { useState, useEffect } from 'react';
import { getDefaultAIConfigurationPresets } from './AiConfiguration';
import SelectField from '../../UI/SelectField';
import TextField from '../../UI/TextField';
import FlatButton from '../../UI/FlatButton';
type Props = {|
value: string,
onChange: (presetId: string) => void,
|};
const AiConfigurationPresetSelector = ({ value, onChange }: Props) => {
const presets = React.useMemo(
() =>
getDefaultAIConfigurationPresets().map((preset) => ({
value: preset.id,
label: preset.name,
})),
[],
);
return (
<SelectField
value={value}
onChange={(event, index, newValue) => {
onChange(newValue);
}}
>
{presets.map((preset) => (
<option key={preset.value} value={preset.value}>
{preset.label}
</option>
))}
</SelectField>
);
};
export default AiConfigurationPresetSelector;
`,
};
+10 -10
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
/**
* test-patches.js — Integration tests for the patch framework (T02).
* Tests all 9 manifest entries, dry-run, strict mode, and sabotage detection.
* Tests all 12 manifest entries, dry-run, strict mode, and sabotage detection.
* Cross-platform: works on Windows, macOS, Linux without bash dependency.
*/
const { execSync } = require('child_process');
@@ -44,26 +44,26 @@ function restoreFixtures() {
execSync(`node "${RESTORE_JS}"`, { cwd: PROJECT_DIR, stdio: 'pipe' });
}
console.log('=== T02 + T03: Patch Framework Integration Tests ===\n');
console.log('=== T02 + T03 + S03: Patch Framework Integration Tests (12 fixtures) ===\n');
// ── Setup ──
console.log('[Setup] Restoring fixtures...');
restoreFixtures();
green('fixtures restored');
// ── Test 1: dry-run + strict → exit 0, 9 entries match exactly once ──
console.log('\n[Test 1] Dry-run + strict (all 9 entries)');
// ── Test 1: dry-run + strict → exit 0, 12 entries match exactly once ──
console.log('\n[Test 1] Dry-run + strict (all 12 entries)');
const dryRun = run(`node "${PATCH_JS}" --repo "${FIXTURES}" --dry-run --strict`);
const matchCount = (dryRun.stdout.match(/finder matches 1 time/g) || []).length;
if (matchCount === 9) green(`9 finders match exactly once`); else red(`expected 9 matches, got ${matchCount}`);
if (matchCount === 12) green(`12 finders match exactly once`); else red(`expected 12 matches, got ${matchCount}`);
if (dryRun.exitCode === 0) green(`dry-run exits 0`); else red(`dry-run exits ${dryRun.exitCode}`);
// ── Test 2: strict apply → exit 0, 9 [PASS] lines ──
console.log('\n[Test 2] Strict mode apply (all 9 entries)');
// ── Test 2: strict apply → exit 0, 12 [PASS] lines ──
console.log('\n[Test 2] Strict mode apply (all 12 entries)');
restoreFixtures();
const strictRun = run(`node "${PATCH_JS}" --repo "${FIXTURES}" --strict`);
const passCount = (strictRun.stdout.match(/\[PASS\]/g) || []).length;
if (passCount === 9) green(`9 [PASS] lines`); else red(`expected 9 [PASS], got ${passCount}`);
if (passCount === 12) green(`12 [PASS] lines`); else red(`expected 12 [PASS], got ${passCount}`);
if (strictRun.exitCode === 0) green(`strict mode exits 0`); else red(`strict mode exits ${strictRun.exitCode}`);
// ── Test 3: verbose shows find/replace/verify steps ──
@@ -123,9 +123,9 @@ if (!sabotagedRun.combined.includes('[PASS]') || !sabotagedRun.combined.includes
} else {
green(`P001 does NOT show [PASS] (verified)`);
}
// Other 8 patches should still pass
// Other 11 patches should still pass
const otherPasses = (sabotagedRun.combined.match(/\[PASS\]/g) || []).length;
if (otherPasses === 8) green(`8 other patches [PASS]`); else red(`expected 8 [PASS], got ${otherPasses}`);
if (otherPasses === 11) green(`11 other patches [PASS]`); else red(`expected 11 [PASS], got ${otherPasses}`);
// ── Test 7: sabotaged-dup fixture (function duplicated) → PATCH_FAIL ──
console.log('\n[Test 7] T03: Duplicate finder — PATCH_FAIL for 2+ matches');
+260
View File
@@ -0,0 +1,260 @@
'use strict';
/**
* IPC handler for BYOK AI requests.
*
* Wires the T01 proxy core modules into Electron's IPC system.
* The handler lives in the main process; the matching preload
* bridge (src/preload/byokBridge.js) exposes it to the renderer.
*
* Two IPC channels:
* byok-ai-request — submit an LLM request
* byok-ai-request-status — poll the status of a fire-and-forget request
*/
const { callLLM } = require('../proxy/callLLM');
const { buildSystemPrompt } = require('../proxy/buildSystemPrompt');
const requestStore = require('../proxy/requestStore');
const { ByokError } = require('../proxy/errors');
const { getByokConfig, setByokConfig } = require('../proxy/byokConfig');
// ── Handler factory (exported for direct unit-testability) ────────────
/**
* Build the IPC handler functions. Separated from registration so
* tests can call the handler directly with mocked dependencies.
*
* @param {object} [deps]
* @param {Function} [deps.callLLM]
* @param {Function} [deps.buildSystemPrompt]
* @param {object} [deps.requestStore]
* @returns {{ byokAiRequest: Function, byokAiRequestStatus: Function }}
*/
function _createHandlers(deps = {}) {
const _callLLM = deps.callLLM || callLLM;
const _buildSystemPrompt = deps.buildSystemPrompt || buildSystemPrompt;
const _requestStore = deps.requestStore || requestStore;
/**
* Handle 'byok-ai-request'.
*
* Payload shape (from renderer):
* { mode, messages, userRequest, projectContext, aiConfiguration }
*
* Mode routing:
* 'chat' → direct request-response, returns { content, usage }
* 'agent' | 'orchestrator' → fire-and-forget via requestStore,
* returns { requestId }
*/
async function byokAiRequest(event, payload) {
const { mode, messages, userRequest, projectContext, aiConfiguration } =
payload || {};
console.log(
'[byok] IPC request received',
JSON.stringify({ mode, messageCount: messages?.length ?? 0 }),
);
// ── Assemble messages ──────────────────────────────────────────
const systemContent = _buildSystemPrompt(userRequest, projectContext);
const fullMessages = [
{ role: 'system', content: systemContent },
...(messages || []),
];
// ── Route ──────────────────────────────────────────────────────
try {
if (mode === 'chat') {
const result = await _callLLM(aiConfiguration || {}, fullMessages);
console.log(
'[byok] chat response ready',
JSON.stringify({
contentLength: result.content?.length ?? 0,
usage: result.usage,
}),
);
return { content: result.content, usage: result.usage };
}
if (mode === 'agent' || mode === 'orchestrator') {
const requestId = _requestStore.create({
userId: aiConfiguration?.userId,
gameId: aiConfiguration?.gameId,
mode,
aiConfiguration,
});
// Fire the LLM call asynchronously — do not await.
_callLLM(aiConfiguration || {}, fullMessages)
.then((result) => {
_requestStore.update(requestId, {
status: 'ready',
output: [result],
});
console.log(
'[byok] fire-and-forget completed',
JSON.stringify({ requestId, mode }),
);
})
.catch((err) => {
_requestStore.update(requestId, {
status: 'error',
error:
err instanceof ByokError
? {
provider: err.provider,
code: err.code,
message: err.userMessage,
}
: { message: err.message || String(err) },
});
console.error(
'[byok] fire-and-forget failed',
JSON.stringify({
requestId,
mode,
error:
err instanceof ByokError
? { provider: err.provider, code: err.code }
: err.message,
}),
);
});
console.log(
'[byok] fire-and-forget started',
JSON.stringify({ requestId, mode }),
);
return { requestId };
}
// Unknown mode
throw new ByokError('BYOK', 'UNKNOWN', `Unknown mode: ${mode}`);
} catch (err) {
console.error(
'[byok] IPC request failed',
JSON.stringify({
mode,
error:
err instanceof ByokError
? { provider: err.provider, code: err.code, message: err.userMessage }
: err.message,
}),
);
throw err; // re-throw so ipcMain.handle propagates the rejection
}
}
/**
* Handle 'byok-ai-request-status'.
*
* Returns the full entry (minus internal fields) or { found: false }.
*/
function byokAiRequestStatus(event, requestId) {
const entry = _requestStore.get(requestId);
if (!entry) {
return { found: false };
}
return {
found: true,
id: entry.id,
status: entry.status,
error: entry.error,
output: entry.output,
totalPriceInCredits: entry.totalPriceInCredits,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
};
}
return { byokAiRequest, byokAiRequestStatus };
}
// ── Config handler factory (exported for direct unit-testability) ──
/**
* Build the config IPC handler functions.
*
* @param {object} store Config store instance (electron-store or fs fallback)
* @returns {{ byokAiGetConfig: Function, byokAiSetConfig: Function }}
*/
function _createConfigHandlers(store) {
/**
* Handle 'byok-ai-get-config'.
*
* Returns the full provider config (provider, endpoint, model, apiKey)
* or null if no config exists.
*/
async function byokAiGetConfig(event) {
console.log('[byok] config get entry');
try {
const config = getByokConfig(store);
console.log(
'[byok] config get complete',
JSON.stringify({
provider: config.provider,
endpoint: config.endpoint,
model: config.model,
hasApiKey: config.apiKey !== '',
}),
);
return config;
} catch (err) {
console.warn('[byok] config get failed', err.message);
return null;
}
}
/**
* Handle 'byok-ai-set-config'.
*
* Persists provider config and returns { success: true }.
*/
async function byokAiSetConfig(event, config) {
console.log(
'[byok] config set entry',
JSON.stringify({
provider: config?.provider,
endpoint: config?.endpoint,
model: config?.model,
}),
);
try {
setByokConfig(store, config || {});
console.log('[byok] config set complete');
return { success: true };
} catch (err) {
console.warn('[byok] config set failed', err.message);
return { success: false, error: err.message };
}
}
return { byokAiGetConfig, byokAiSetConfig };
}
// ── Public registration ──────────────────────────────────────────────
/**
* Register BYOK IPC handlers on the provided ipcMain instance.
*
* Usage in Electron main process:
* const { registerByokIpcHandlers } = require('./src/ipc/byokHandler');
* registerByokIpcHandlers(ipcMain, store);
*
* @param {Electron.IpcMain} ipcMain
* @param {object} [store] Config store instance (optional — config channels
* are only registered when a store is provided)
*/
function registerByokIpcHandlers(ipcMain, store) {
const { byokAiRequest, byokAiRequestStatus } = _createHandlers();
ipcMain.handle('byok-ai-request', byokAiRequest);
ipcMain.handle('byok-ai-request-status', byokAiRequestStatus);
if (store) {
const { byokAiGetConfig, byokAiSetConfig } = _createConfigHandlers(store);
ipcMain.handle('byok-ai-get-config', byokAiGetConfig);
ipcMain.handle('byok-ai-set-config', byokAiSetConfig);
}
}
module.exports = { registerByokIpcHandlers, _createHandlers, _createConfigHandlers };
+57
View File
@@ -0,0 +1,57 @@
'use strict';
/**
* BYOK AI preload bridge.
*
* Exposes the BYOK IPC API to the renderer process via
* contextBridge. The renderer can then call:
*
* window.byokAi.request({ mode, messages, userRequest, projectContext, aiConfiguration })
* → Promise<{ content, usage } | { requestId }>
*
* window.byokAi.requestStatus(requestId)
* → Promise<{ found, id, status, error, output, ... }>
*
* Designed for Electron 28+ with contextIsolation enabled.
*/
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('byokAi', {
/**
* Submit an LLM request.
*
* @param {object} payload
* @param {'chat'|'agent'|'orchestrator'} payload.mode
* @param {object[]} [payload.messages]
* @param {string} [payload.userRequest]
* @param {string} [payload.projectContext]
* @param {object} [payload.aiConfiguration]
* @returns {Promise<{content: string, usage: object}|{requestId: string}>}
*/
request: (payload) => ipcRenderer.invoke('byok-ai-request', payload),
/**
* Poll the status of a fire-and-forget request.
*
* @param {string} requestId
* @returns {Promise<{found: boolean, id?: string, status?: string, ...}>}
*/
requestStatus: (requestId) =>
ipcRenderer.invoke('byok-ai-request-status', requestId),
/**
* Get the persisted LLM provider configuration.
*
* @returns {Promise<{provider: string, endpoint: string, apiKey: string, model: string}|null>}
*/
getConfig: () => ipcRenderer.invoke('byok-ai-get-config'),
/**
* Persist the LLM provider configuration.
*
* @param {{ provider: string, endpoint: string, apiKey: string, model: string }} config
* @returns {Promise<{success: boolean, error?: string}>}
*/
setConfig: (config) => ipcRenderer.invoke('byok-ai-set-config', config),
});
+279
View File
@@ -0,0 +1,279 @@
'use strict';
/**
* BYOK LLM provider configuration persistence.
*
* Persists { provider, endpoint, apiKey, model } across app restarts using
* a tiered storage approach:
*
* 1. electron-store — preferred (lazy require)
* 2. JSON file on fs — fallback when electron-store is unavailable
*
* The apiKey is encrypted via Electron's safeStorage API (macOS Keychain,
* Windows DPAPI, Linux libsecret) before being stored as a hex string.
* Non-sensitive fields (provider, endpoint, model) are stored in plain text.
*
* If safeStorage.isEncryptionAvailable() returns false, the apiKey is
* stored in plain text with a console.warn — this covers headless / CI
* environments where the OS keychain is not available.
*/
const fs = require('fs');
const path = require('path');
// ── Key names used inside the store ─────────────────────────────────
const KEYS = Object.freeze({
PROVIDER: 'byok.provider',
ENDPOINT: 'byok.endpoint',
MODEL: 'byok.model',
API_KEY: 'byok.apiKey',
});
// ── Default config shape ────────────────────────────────────────────
const DEFAULT_CONFIG = Object.freeze({
provider: '',
endpoint: '',
apiKey: '',
model: '',
});
const LOG_PREFIX = '[byokConfig]';
// ══════════════════════════════════════════════════════════════════════
// FS JSON fallback store
// ══════════════════════════════════════════════════════════════════════
/**
* Simple file-backed key-value store compatible with the electron-store
* surface (get / set / delete). Used when electron-store cannot be
* require()'d (CI, headless, or plain-Node tests).
*
* @param {string} filePath Absolute path to the JSON file
* @returns {{ get: Function, set: Function, delete: Function }}
*/
function _createFsStore(filePath) {
let data = {};
// Load existing data
try {
if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(filePath, 'utf-8');
data = JSON.parse(raw);
}
} catch {
// File doesn't exist or is corrupt — start fresh
data = {};
}
function _persist() {
try {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to persist config to ${filePath}:`, err.message);
}
}
return {
get(key) {
return data[key];
},
set(key, value) {
data[key] = value;
_persist();
},
delete(key) {
delete data[key];
_persist();
},
};
}
// ══════════════════════════════════════════════════════════════════════
// Factory (with dependency injection for testability)
// ══════════════════════════════════════════════════════════════════════
/**
* Internal factory — exported for unit-test injection.
*
* @param {object} [deps]
* @param {object} [deps.safeStorage] Electron safeStorage (or mock)
* @param {Function} [deps.electronStore] Constructor for electron-store (or mock)
* @param {object} [opts]
* @param {string} [opts.filePath] Fallback JSON file path
* @returns {{ get: Function, set: Function, delete: Function }}
*/
function _createByokConfigStore(deps = {}, opts = {}) {
// 1) Try electron-store
const ElectronStore = deps.electronStore;
if (ElectronStore) {
console.log(`${LOG_PREFIX} using electron-store`);
return new ElectronStore({ name: 'byok-config', ...opts });
}
// Try native require if no dep injected
try {
const Store = require('electron-store');
console.log(`${LOG_PREFIX} using electron-store`);
return new Store({ name: 'byok-config', ...opts });
} catch (err) {
console.log(`${LOG_PREFIX} electron-store unavailable, falling back to fs JSON (${err.message})`);
}
// 2) Fall back to fs JSON
const filePath =
opts.filePath || path.join(process.cwd(), '.byok-config.json');
console.log(`${LOG_PREFIX} using fs JSON store at ${filePath}`);
return _createFsStore(filePath);
}
/**
* Public factory that tries electron-store, falls back to fs JSON.
*
* @param {object} [opts]
* @param {string} [opts.filePath] Fallback JSON file path
* @returns {{ get: Function, set: Function, delete: Function }}
*/
function createByokConfigStore(opts) {
return _createByokConfigStore({}, opts);
}
// ══════════════════════════════════════════════════════════════════════
// Read / Write helpers (with dependency injection for testability)
// ══════════════════════════════════════════════════════════════════════
/**
* Internal getter — exported for unit-test injection.
*
* @param {object} store Store instance (electron-store or fs fallback)
* @param {object} [deps]
* @param {object} [deps.safeStorage] Electron safeStorage (or mock)
* @returns {{ provider: string, endpoint: string, apiKey: string, model: string }}
*/
function _getByokConfig(store, deps = {}) {
console.log(`${LOG_PREFIX} get entry`);
const provider = store.get(KEYS.PROVIDER) || DEFAULT_CONFIG.provider;
const endpoint = store.get(KEYS.ENDPOINT) || DEFAULT_CONFIG.endpoint;
const model = store.get(KEYS.MODEL) || DEFAULT_CONFIG.model;
const apiKeyStored = store.get(KEYS.API_KEY);
let apiKey = DEFAULT_CONFIG.apiKey;
if (apiKeyStored) {
const ss = _resolveSafeStorage(deps);
if (ss && ss.isEncryptionAvailable()) {
try {
const decrypted = ss.decryptString(Buffer.from(apiKeyStored, 'hex'));
apiKey = decrypted;
} catch (_err) {
console.warn(
`${LOG_PREFIX} Decryption failed — apiKey may be corrupted or stored in plain text. ` +
'Returning apiKey=undefined.',
);
apiKey = DEFAULT_CONFIG.apiKey;
}
} else {
// safeStorage not available — value was stored in plain text
apiKey = apiKeyStored;
}
}
console.log(
`${LOG_PREFIX} get complete`,
JSON.stringify({ provider, endpoint, model, hasApiKey: apiKey !== '' }),
);
return { provider, endpoint, apiKey, model };
}
/**
* Internal setter — exported for unit-test injection.
*
* @param {object} store Store instance
* @param {object} config { provider, endpoint, apiKey, model }
* @param {object} [deps]
* @param {object} [deps.safeStorage] Electron safeStorage (or mock)
*/
function _setByokConfig(store, config, deps = {}) {
console.log(
`${LOG_PREFIX} set entry`,
JSON.stringify({
provider: config.provider,
endpoint: config.endpoint,
model: config.model,
}),
);
// Non-sensitive fields
store.set(KEYS.PROVIDER, config.provider ?? DEFAULT_CONFIG.provider);
store.set(KEYS.ENDPOINT, config.endpoint ?? DEFAULT_CONFIG.endpoint);
store.set(KEYS.MODEL, config.model ?? DEFAULT_CONFIG.model);
// apiKey — encrypt if possible
const apiKey = config.apiKey ?? DEFAULT_CONFIG.apiKey;
if (apiKey !== '') {
const ss = _resolveSafeStorage(deps);
if (ss && ss.isEncryptionAvailable()) {
const encrypted = ss.encryptString(apiKey);
store.set(KEYS.API_KEY, encrypted.toString('hex'));
} else {
console.warn(
`${LOG_PREFIX} safeStorage is not available — storing apiKey in plain text`,
);
store.set(KEYS.API_KEY, apiKey);
}
} else {
store.set(KEYS.API_KEY, '');
}
console.log(`${LOG_PREFIX} set complete`);
}
/**
* Public getter that lazily resolves safeStorage from electron.
*/
function getByokConfig(store) {
return _getByokConfig(store, {});
}
/**
* Public setter that lazily resolves safeStorage from electron.
*/
function setByokConfig(store, config) {
return _setByokConfig(store, config, {});
}
// ── Helpers ──────────────────────────────────────────────────────────
/**
* Resolve safeStorage: use injected dep if provided, otherwise try
* electron, otherwise null.
*/
function _resolveSafeStorage(deps) {
if (deps.safeStorage !== undefined) {
return deps.safeStorage;
}
try {
return require('electron').safeStorage;
} catch {
return null;
}
}
module.exports = {
createByokConfigStore,
getByokConfig,
setByokConfig,
// Internal factories exported for unit-test dependency injection
_createByokConfigStore,
_getByokConfig,
_setByokConfig,
};
@@ -2,6 +2,6 @@
// Contains the exact pattern for patch 8 (C++ watermark defaults).
void Project::SetDefaults() {
SetShowWatermark(false); // BYOK PATCH
SetShowWatermark(true);
showWatermark = true;
}
@@ -2,7 +2,7 @@
<!-- Contains the exact pattern for patch 7 (watermark defaults). -->
<script>
var gdjs = {
"showWatermark": false, // BYOK PATCH
"showWatermark": true,
"showWatermark": !0,
"other": true
};
@@ -0,0 +1,23 @@
// Fixture: newIDE/app/src/AiConfiguration/AiConfiguration.js
// Contains the exact pattern for patch 10 (BYOK preset injection).
export const getDefaultAIConfigurationPresets = (): AIConfigurationPreset[] => {
return [
{
id: 'gpt-4o',
name: 'GPT-4o',
model: 'gpt-4o',
provider: 'OpenAI',
endpoint: 'https://api.openai.com/v1/chat/completions',
description: 'Most capable GPT-4o model — best for complex reasoning and creative tasks.',
},
{
id: 'gpt-4o-mini',
name: 'GPT-4o Mini',
model: 'gpt-4o-mini',
provider: 'OpenAI',
endpoint: 'https://api.openai.com/v1/chat/completions',
description: 'Fast, affordable model for everyday tasks.',
},
];
};
@@ -0,0 +1,42 @@
// Fixture: newIDE/app/src/AiConfiguration/AiConfigurationPresetSelector.js
// Contains the exact pattern for patch 12 (BYOK inline config form injection).
import * as React from 'react';
import { useState, useEffect } from 'react';
import { getDefaultAIConfigurationPresets } from './AiConfiguration';
import SelectField from '../../UI/SelectField';
import TextField from '../../UI/TextField';
import FlatButton from '../../UI/FlatButton';
type Props = {|
value: string,
onChange: (presetId: string) => void,
|};
const AiConfigurationPresetSelector = ({ value, onChange }: Props) => {
const presets = React.useMemo(
() =>
getDefaultAIConfigurationPresets().map((preset) => ({
value: preset.id,
label: preset.name,
})),
[],
);
return (
<SelectField
value={value}
onChange={(event, index, newValue) => {
onChange(newValue);
}}
>
{presets.map((preset) => (
<option key={preset.value} value={preset.value}>
{preset.label}
</option>
))}
</SelectField>
);
};
export default AiConfigurationPresetSelector;
@@ -1,5 +1,5 @@
// Fixture: newIDE/app/src/ExportAndShare/ShareDialog/DummyDialog.js
// Contains the exact pattern for patch 6 (build quota removal).
const limitReached = false; /* BYOK PATCH: remove build quota */
const limitReached = currentBuilds >= maximumCount;
const quotaCheck = current > max;
@@ -1,7 +1,7 @@
// Fixture: newIDE/app/src/ProjectCreation/ProjectPropertiesDialog.js
// Contains the exact pattern for patch 5 (watermark toggle unlock).
const canRemoveWatermark = true; // BYOK PATCH: always allow watermark removal
const canRemoveWatermark = hasValidSubscription && subscription.planId;
const showWatermarkOption = canRemoveWatermark && !isFreePlan;
export default function ProjectPropertiesDialog() {
@@ -1,5 +1,6 @@
// Fixture: newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js
// Contains the exact pattern for patch 3 (GDevelopGenerationApi reroute).
// Contains the BYOK override pattern for P003 to clean up.
// P003 removes this override (superseded by Generation.js IPC routing).
const isDev = process.env.NODE_ENV === 'development';
@@ -0,0 +1,36 @@
// Fixture: newIDE/app/src/Utils/GDevelopServices/Generation.js
// Contains the exact pattern for patch 11 (BYOK detection → IPC routing).
const axios = require('axios');
const { GDevelopGenerationApi } = require('./ApiConfigs');
export const generateChatResponse = async ({
messages,
aiConfiguration,
getAuthorizationHeader,
onProgress,
}: {
messages: Array<{ role: string; content: string }>,
aiConfiguration?: { presetId?: string; model?: string; provider?: string; endpoint?: string },
getAuthorizationHeader: () => Promise<string>,
onProgress?: (chunk: string) => void,
}): Promise<string> => {
const { messages, aiConfiguration, getAuthorizationHeader } = params;
const authHeader = await getAuthorizationHeader();
const response = await axios.post(
`${GDevelopGenerationApi.baseUrl}/chat/completions`,
{
model: aiConfiguration?.model || 'gpt-4o',
messages,
},
{
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
},
);
return response.data.choices[0].message.content;
};
@@ -25,26 +25,6 @@ export const getUserLimits = async (
getAuthorizationHeader: () => Promise<string>,
userId: string
): Promise<Limits> => {
// BYOK PATCH: return maximum limits
return {
'gdevelop.maxGames': 9999,
'gdevelop.maxCloudProjects': 9999,
'gdevelop.maxBuildsPerDay': 9999,
'gdevelop.maxBuildsPerMonth': 9999,
'gdevelop.maxBuildsTotal': 9999,
'gdevelop.maxExtensions': 9999,
'gdevelop.maxLeaderboards': 9999,
'gdevelop.maxMultiplayerGames': 9999,
'gdevelop.maxTextToSpeech': 9999,
'gdevelop.maxAITokens': 9999,
'gdevelop.maxAIProjects': 9999,
'gdevelop.maxCloudBuilds': 9999,
'gdevelop.maxEvents': 9999,
'gdevelop.maxObjects': 9999,
'gdevelop.maxLayers': 9999,
'gdevelop.maxScenes': 9999,
'gdevelop.maxExternalLayouts': 9999,
'gdevelop.maxEffects': 9999,
'gdevelop.maxAdMob': 9999,
};
const { getAuthorizationHeader, userId } = params;
return ensureObjectHasProperty({});
};
@@ -0,0 +1,36 @@
// Fixture: BYOK LLM provider configuration persistence
'use strict';
const KEYS = Object.freeze({
PROVIDER: 'byok.provider',
ENDPOINT: 'byok.endpoint',
MODEL: 'byok.model',
API_KEY: 'byok.apiKey',
});
const DEFAULT_CONFIG = Object.freeze({
provider: '', endpoint: '', apiKey: '', model: '',
});
function createByokConfigStore(opts) {
const Store = require('electron-store');
return new Store({ name: 'byok-config', ...opts });
}
function getByokConfig(store) {
return {
provider: store.get(KEYS.PROVIDER) || DEFAULT_CONFIG.provider,
endpoint: store.get(KEYS.ENDPOINT) || DEFAULT_CONFIG.endpoint,
model: store.get(KEYS.MODEL) || DEFAULT_CONFIG.model,
apiKey: store.get(KEYS.API_KEY) || DEFAULT_CONFIG.apiKey,
};
}
function setByokConfig(store, config) {
store.set(KEYS.PROVIDER, config.provider ?? DEFAULT_CONFIG.provider);
store.set(KEYS.ENDPOINT, config.endpoint ?? DEFAULT_CONFIG.endpoint);
store.set(KEYS.MODEL, config.model ?? DEFAULT_CONFIG.model);
store.set(KEYS.API_KEY, config.apiKey ?? DEFAULT_CONFIG.apiKey);
}
module.exports = { createByokConfigStore, getByokConfig, setByokConfig };
@@ -0,0 +1,50 @@
// Fixture: BYOK embedded IPC module
// Bootstraps the config store and registers both request + config IPC handlers.
// Injected into Electron main.js via P009.
'use strict';
const { createByokConfigStore, getByokConfig, setByokConfig } = require('./byok-config');
const configStore = createByokConfigStore();
function _createConfigHandlers(store) {
async function byokAiGetConfig(event) {
console.log('[byok] config get entry');
try {
const config = getByokConfig(store);
console.log('[byok] config get complete', JSON.stringify({
provider: config.provider, endpoint: config.endpoint,
model: config.model, hasApiKey: config.apiKey !== '',
}));
return config;
} catch (err) {
console.warn('[byok] config get failed', err.message);
return null;
}
}
async function byokAiSetConfig(event, config) {
console.log('[byok] config set entry', JSON.stringify({
provider: config?.provider, endpoint: config?.endpoint, model: config?.model,
}));
try {
setByokConfig(store, config || {});
console.log('[byok] config set complete');
return { success: true };
} catch (err) {
console.warn('[byok] config set failed', err.message);
return { success: false, error: err.message };
}
}
return { byokAiGetConfig, byokAiSetConfig };
}
function registerByokIpcHandlers(ipcMain, store) {
const effectiveStore = store || configStore;
const { byokAiGetConfig, byokAiSetConfig } = _createConfigHandlers(effectiveStore);
ipcMain.handle('byok-ai-get-config', byokAiGetConfig);
ipcMain.handle('byok-ai-set-config', byokAiSetConfig);
}
module.exports = { registerByokIpcHandlers };
@@ -2,9 +2,6 @@
// Contains the exact pattern for patch 9 (BYOK embedded IPC injection into Electron main).
const { app, BrowserWindow } = require('electron');
const { registerByokIpcHandlers } = require('./byok-embedded'); // BYOK PATCH
let mainWindow;
@@ -20,9 +17,5 @@ function createWindow() {
mainWindow.on('closed', () => { mainWindow = null; });
}
app.on('ready', () => {
// BYOK PATCH: wire IPC handlers before creating window
registerByokIpcHandlers(require('electron').ipcMain);
createWindow();
});
app.on('ready', createWindow);
app.on('will-quit', () => { /* cleanup */ });
@@ -5,9 +5,4 @@ const { contextBridge, ipcRenderer } = require('electron');
// Expose protected methods
// Preload script for GDevelop electron app
// BYOK PATCH: expose AI IPC bridge
contextBridge.exposeInMainWorld('byokAi', {
request: (payload) => ipcRenderer.invoke('byok-ai-request', payload),
requestStatus: (requestId) => ipcRenderer.invoke('byok-ai-request-status', requestId),
});
contextBridge.exposeInMainWorld('electronAPI', {});
@@ -0,0 +1,31 @@
// Fixture: newIDE/app/src/AiConfiguration/AiConfiguration.js
// Contains the exact pattern for patch 10 (BYOK preset injection).
export const getDefaultAIConfigurationPresets = (): AIConfigurationPreset[] => {
return [
{
id: 'gpt-4o',
name: 'GPT-4o',
model: 'gpt-4o',
provider: 'OpenAI',
endpoint: 'https://api.openai.com/v1/chat/completions',
description: 'Most capable GPT-4o model — best for complex reasoning and creative tasks.',
},
{
id: 'gpt-4o-mini',
name: 'GPT-4o Mini',
model: 'gpt-4o-mini',
provider: 'OpenAI',
endpoint: 'https://api.openai.com/v1/chat/completions',
description: 'Fast, affordable model for everyday tasks.',
},
{
id: 'byok',
name: 'BYOK (Bring Your Own Key)',
model: '',
provider: '',
endpoint: '',
description: 'Use your own LLM provider. Configure in Settings.',
},
];
};
@@ -0,0 +1,128 @@
// Fixture: newIDE/app/src/AiConfiguration/AiConfigurationPresetSelector.js
// Contains the exact pattern for patch 12 (BYOK inline config form injection).
import * as React from 'react';
import { useState, useEffect } from 'react';
import { getDefaultAIConfigurationPresets } from './AiConfiguration';
import SelectField from '../../UI/SelectField';
import TextField from '../../UI/TextField';
import FlatButton from '../../UI/FlatButton';
type Props = {|
value: string,
onChange: (presetId: string) => void,
|};
const AiConfigurationPresetSelector = ({ value, onChange }: Props) => {
const [provider, setProvider] = React.useState('');
const [endpoint, setEndpoint] = React.useState('');
const [apiKey, setApiKey] = React.useState('');
const [model, setModel] = React.useState('');
const [savedLabel, setSavedLabel] = React.useState('');
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState('');
const presets = React.useMemo(
() =>
getDefaultAIConfigurationPresets().map((preset) => ({
value: preset.id,
label: preset.name,
})),
[],
);
React.useEffect(() => {
if (value === 'byok' && window.byokAi && window.byokAi.getConfig) {
window.byokAi.getConfig().then((config) => {
if (config) {
setProvider(config.provider || '');
setEndpoint(config.endpoint || '');
setApiKey(config.apiKey || '');
setModel(config.model || '');
if (config.provider && config.model) {
setSavedLabel(config.provider + '/' + config.model);
}
}
}).catch((err) => {
console.warn('[byok] config get failed', err.message);
});
}
}, [value]);
const handleSave = async () => {
setSaving(true);
setError('');
try {
await window.byokAi.setConfig({ provider, endpoint, apiKey, model });
setSavedLabel(provider + '/' + model);
} catch (err) {
setError(err.message || 'Save failed');
} finally {
setSaving(false);
}
};
return (
<>
<SelectField
value={value}
onChange={(event, index, newValue) => {
onChange(newValue);
}}
>
{presets.map((preset) => (
<option key={preset.value} value={preset.value}>
{preset.label}
</option>
))}
</SelectField>
{value === 'byok' && (
<div style={{ marginTop: 16 }}>
<TextField
fullWidth
floatingLabelText="Provider"
value={provider}
onChange={(e, newValue) => setProvider(newValue)}
/>
<TextField
fullWidth
floatingLabelText="Endpoint URL"
value={endpoint}
onChange={(e, newValue) => setEndpoint(newValue)}
/>
<TextField
fullWidth
floatingLabelText="API Key"
type="password"
value={apiKey}
onChange={(e, newValue) => setApiKey(newValue)}
/>
<TextField
fullWidth
floatingLabelText="Model Name"
value={model}
onChange={(e, newValue) => setModel(newValue)}
/>
<FlatButton
label={saving ? 'Saving...' : 'Save'}
primary
disabled={saving}
onClick={handleSave}
/>
{savedLabel && (
<div style={{ marginTop: 8, color: '#4caf50' }}>
Saved: {savedLabel}
</div>
)}
{error && (
<div style={{ marginTop: 8, color: '#f44336' }}>
{error}
</div>
)}
</div>
)}
</>
);
};
export default AiConfigurationPresetSelector;
@@ -1,12 +1,11 @@
// Fixture: newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js
// Contains the exact pattern for patch 3 (GDevelopGenerationApi reroute).
// Contains the BYOK override pattern for P003 to clean up.
// P003 removes this override (superseded by Generation.js IPC routing).
const isDev = process.env.NODE_ENV === 'development';
export const GDevelopGenerationApi = {
baseUrl: typeof window !== 'undefined' && window.__BYOK_API_BASE_URL__
? window.__BYOK_API_BASE_URL__
: ((isDev
baseUrl: ((isDev
? 'https://api-dev.gdevelop.io/generation'
: 'https://api.gdevelop.io/generation'): string), // BYOK PATCH: dynamic override
};
@@ -0,0 +1,42 @@
// Fixture: newIDE/app/src/Utils/GDevelopServices/Generation.js
// Contains the exact pattern for patch 11 (BYOK detection → IPC routing).
const axios = require('axios');
const { GDevelopGenerationApi } = require('./ApiConfigs');
export const generateChatResponse = async ({
messages,
aiConfiguration,
getAuthorizationHeader,
onProgress,
}: {
messages: Array<{ role: string; content: string }>,
aiConfiguration?: { presetId?: string; model?: string; provider?: string; endpoint?: string },
getAuthorizationHeader: () => Promise<string>,
onProgress?: (chunk: string) => void,
}): Promise<string> => {
const { messages, aiConfiguration, getAuthorizationHeader } = params;
// BYOK PATCH: route through IPC when BYOK preset selected
if (aiConfiguration?.presetId === 'byok') {
const byokConfig = await window.byokAi.getConfig();
const result = await window.byokAi.request({ mode: 'chat', messages, aiConfiguration: { model: byokConfig.model || aiConfiguration?.model } });
return { content: result.content, usage: result.usage || { prompt_tokens: 0, completion_tokens: 0 } };
}
const authHeader = await getAuthorizationHeader();
const response = await axios.post(
`${GDevelopGenerationApi.baseUrl}/chat/completions`,
{
model: aiConfiguration?.model || 'gpt-4o',
messages,
},
{
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
},
);
return response.data.choices[0].message.content;
};
@@ -0,0 +1,36 @@
// Fixture: BYOK LLM provider configuration persistence
'use strict';
const KEYS = Object.freeze({
PROVIDER: 'byok.provider',
ENDPOINT: 'byok.endpoint',
MODEL: 'byok.model',
API_KEY: 'byok.apiKey',
});
const DEFAULT_CONFIG = Object.freeze({
provider: '', endpoint: '', apiKey: '', model: '',
});
function createByokConfigStore(opts) {
const Store = require('electron-store');
return new Store({ name: 'byok-config', ...opts });
}
function getByokConfig(store) {
return {
provider: store.get(KEYS.PROVIDER) || DEFAULT_CONFIG.provider,
endpoint: store.get(KEYS.ENDPOINT) || DEFAULT_CONFIG.endpoint,
model: store.get(KEYS.MODEL) || DEFAULT_CONFIG.model,
apiKey: store.get(KEYS.API_KEY) || DEFAULT_CONFIG.apiKey,
};
}
function setByokConfig(store, config) {
store.set(KEYS.PROVIDER, config.provider ?? DEFAULT_CONFIG.provider);
store.set(KEYS.ENDPOINT, config.endpoint ?? DEFAULT_CONFIG.endpoint);
store.set(KEYS.MODEL, config.model ?? DEFAULT_CONFIG.model);
store.set(KEYS.API_KEY, config.apiKey ?? DEFAULT_CONFIG.apiKey);
}
module.exports = { createByokConfigStore, getByokConfig, setByokConfig };
@@ -0,0 +1,56 @@
'use strict';
/**
* BYOK embedded bootstrap — thin wrapper that wires production IPC handlers.
*
* Deployed alongside main.js in newIDE/electron-app/app/scripts/.
* Requires the deployed src/ modules (ipc/byokHandler, proxy/byokConfig)
* which are copied into the repo by the --deploy-src step in patch.js.
*
* This replaces the fixture stub (test/fixtures/.../byok-embedded.js)
* which only handles config and punts request handling to "downstream"
* code that never existed in the real build.
*
* Design (per user decision): thin wrapper + src/ directory.
* Single bundled file and relative requires to project src/ were rejected.
*/
const { registerByokIpcHandlers } = require('./src/ipc/byokHandler');
const { createByokConfigStore } = require('./src/proxy/byokConfig');
// ── Module-init: create the config store ────────────────────────────
// Lazy-created so that require() doesn't fail if electron-store is
// unavailable (tests, headless CI). SafeStorage encryption is handled
// inside byokConfig.js.
let _configStore = null;
function _ensureConfigStore() {
if (!_configStore) {
_configStore = createByokConfigStore();
}
return _configStore;
}
// ── Public registration ─────────────────────────────────────────────
/**
* Register all BYOK IPC handlers (request + config) on the provided
* ipcMain instance.
*
* @param {Electron.IpcMain} ipcMain
* @param {object} [store] Optional override for the config store
*/
function registerAllHandlers(ipcMain, store) {
const effectiveStore = store || _ensureConfigStore();
// Delegates to the full byokHandler.js which registers:
// byok-ai-request — chat / agent / orchestrator
// byok-ai-request-status — poll fire-and-forget
// byok-ai-get-config — read persisted config
// byok-ai-set-config — write persisted config
registerByokIpcHandlers(ipcMain, effectiveStore);
console.log('[byok] All IPC handlers registered (request + config)');
}
module.exports = { registerAllHandlers };
@@ -0,0 +1,260 @@
'use strict';
/**
* IPC handler for BYOK AI requests.
*
* Wires the T01 proxy core modules into Electron's IPC system.
* The handler lives in the main process; the matching preload
* bridge (src/preload/byokBridge.js) exposes it to the renderer.
*
* Two IPC channels:
* byok-ai-request — submit an LLM request
* byok-ai-request-status — poll the status of a fire-and-forget request
*/
const { callLLM } = require('../proxy/callLLM');
const { buildSystemPrompt } = require('../proxy/buildSystemPrompt');
const requestStore = require('../proxy/requestStore');
const { ByokError } = require('../proxy/errors');
const { getByokConfig, setByokConfig } = require('../proxy/byokConfig');
// ── Handler factory (exported for direct unit-testability) ────────────
/**
* Build the IPC handler functions. Separated from registration so
* tests can call the handler directly with mocked dependencies.
*
* @param {object} [deps]
* @param {Function} [deps.callLLM]
* @param {Function} [deps.buildSystemPrompt]
* @param {object} [deps.requestStore]
* @returns {{ byokAiRequest: Function, byokAiRequestStatus: Function }}
*/
function _createHandlers(deps = {}) {
const _callLLM = deps.callLLM || callLLM;
const _buildSystemPrompt = deps.buildSystemPrompt || buildSystemPrompt;
const _requestStore = deps.requestStore || requestStore;
/**
* Handle 'byok-ai-request'.
*
* Payload shape (from renderer):
* { mode, messages, userRequest, projectContext, aiConfiguration }
*
* Mode routing:
* 'chat' → direct request-response, returns { content, usage }
* 'agent' | 'orchestrator' → fire-and-forget via requestStore,
* returns { requestId }
*/
async function byokAiRequest(event, payload) {
const { mode, messages, userRequest, projectContext, aiConfiguration } =
payload || {};
console.log(
'[byok] IPC request received',
JSON.stringify({ mode, messageCount: messages?.length ?? 0 }),
);
// ── Assemble messages ──────────────────────────────────────────
const systemContent = _buildSystemPrompt(userRequest, projectContext);
const fullMessages = [
{ role: 'system', content: systemContent },
...(messages || []),
];
// ── Route ──────────────────────────────────────────────────────
try {
if (mode === 'chat') {
const result = await _callLLM(aiConfiguration || {}, fullMessages);
console.log(
'[byok] chat response ready',
JSON.stringify({
contentLength: result.content?.length ?? 0,
usage: result.usage,
}),
);
return { content: result.content, usage: result.usage };
}
if (mode === 'agent' || mode === 'orchestrator') {
const requestId = _requestStore.create({
userId: aiConfiguration?.userId,
gameId: aiConfiguration?.gameId,
mode,
aiConfiguration,
});
// Fire the LLM call asynchronously — do not await.
_callLLM(aiConfiguration || {}, fullMessages)
.then((result) => {
_requestStore.update(requestId, {
status: 'ready',
output: [result],
});
console.log(
'[byok] fire-and-forget completed',
JSON.stringify({ requestId, mode }),
);
})
.catch((err) => {
_requestStore.update(requestId, {
status: 'error',
error:
err instanceof ByokError
? {
provider: err.provider,
code: err.code,
message: err.userMessage,
}
: { message: err.message || String(err) },
});
console.error(
'[byok] fire-and-forget failed',
JSON.stringify({
requestId,
mode,
error:
err instanceof ByokError
? { provider: err.provider, code: err.code }
: err.message,
}),
);
});
console.log(
'[byok] fire-and-forget started',
JSON.stringify({ requestId, mode }),
);
return { requestId };
}
// Unknown mode
throw new ByokError('BYOK', 'UNKNOWN', `Unknown mode: ${mode}`);
} catch (err) {
console.error(
'[byok] IPC request failed',
JSON.stringify({
mode,
error:
err instanceof ByokError
? { provider: err.provider, code: err.code, message: err.userMessage }
: err.message,
}),
);
throw err; // re-throw so ipcMain.handle propagates the rejection
}
}
/**
* Handle 'byok-ai-request-status'.
*
* Returns the full entry (minus internal fields) or { found: false }.
*/
function byokAiRequestStatus(event, requestId) {
const entry = _requestStore.get(requestId);
if (!entry) {
return { found: false };
}
return {
found: true,
id: entry.id,
status: entry.status,
error: entry.error,
output: entry.output,
totalPriceInCredits: entry.totalPriceInCredits,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
};
}
return { byokAiRequest, byokAiRequestStatus };
}
// ── Config handler factory (exported for direct unit-testability) ──
/**
* Build the config IPC handler functions.
*
* @param {object} store Config store instance (electron-store or fs fallback)
* @returns {{ byokAiGetConfig: Function, byokAiSetConfig: Function }}
*/
function _createConfigHandlers(store) {
/**
* Handle 'byok-ai-get-config'.
*
* Returns the full provider config (provider, endpoint, model, apiKey)
* or null if no config exists.
*/
async function byokAiGetConfig(event) {
console.log('[byok] config get entry');
try {
const config = getByokConfig(store);
console.log(
'[byok] config get complete',
JSON.stringify({
provider: config.provider,
endpoint: config.endpoint,
model: config.model,
hasApiKey: config.apiKey !== '',
}),
);
return config;
} catch (err) {
console.warn('[byok] config get failed', err.message);
return null;
}
}
/**
* Handle 'byok-ai-set-config'.
*
* Persists provider config and returns { success: true }.
*/
async function byokAiSetConfig(event, config) {
console.log(
'[byok] config set entry',
JSON.stringify({
provider: config?.provider,
endpoint: config?.endpoint,
model: config?.model,
}),
);
try {
setByokConfig(store, config || {});
console.log('[byok] config set complete');
return { success: true };
} catch (err) {
console.warn('[byok] config set failed', err.message);
return { success: false, error: err.message };
}
}
return { byokAiGetConfig, byokAiSetConfig };
}
// ── Public registration ──────────────────────────────────────────────
/**
* Register BYOK IPC handlers on the provided ipcMain instance.
*
* Usage in Electron main process:
* const { registerByokIpcHandlers } = require('./src/ipc/byokHandler');
* registerByokIpcHandlers(ipcMain, store);
*
* @param {Electron.IpcMain} ipcMain
* @param {object} [store] Config store instance (optional — config channels
* are only registered when a store is provided)
*/
function registerByokIpcHandlers(ipcMain, store) {
const { byokAiRequest, byokAiRequestStatus } = _createHandlers();
ipcMain.handle('byok-ai-request', byokAiRequest);
ipcMain.handle('byok-ai-request-status', byokAiRequestStatus);
if (store) {
const { byokAiGetConfig, byokAiSetConfig } = _createConfigHandlers(store);
ipcMain.handle('byok-ai-get-config', byokAiGetConfig);
ipcMain.handle('byok-ai-set-config', byokAiSetConfig);
}
}
module.exports = { registerByokIpcHandlers, _createHandlers, _createConfigHandlers };
@@ -0,0 +1,42 @@
'use strict';
/**
* Assemble the system prompt sent to the LLM.
*
* Extracted from proxy/server.js. CUSTOM_SYSTEM_PROMPT env-var
* support has been intentionally removed — the prompt is assembled
* purely from the base template, optional project context, and the
* user request.
*/
const BASE_PROMPT = `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.`;
/**
* @param {string} [userRequest] The user's current prompt
* @param {string} [projectContext] Optional serialised project state
* @returns {string} Assembled system prompt
*/
function buildSystemPrompt(userRequest, projectContext) {
let prompt = BASE_PROMPT;
if (projectContext) {
prompt += '\n\nCurrent project context:\n' + projectContext;
}
if (userRequest) {
prompt += '\n\nUser request: ' + userRequest;
}
return prompt;
}
module.exports = { buildSystemPrompt, BASE_PROMPT };
@@ -0,0 +1,279 @@
'use strict';
/**
* BYOK LLM provider configuration persistence.
*
* Persists { provider, endpoint, apiKey, model } across app restarts using
* a tiered storage approach:
*
* 1. electron-store — preferred (lazy require)
* 2. JSON file on fs — fallback when electron-store is unavailable
*
* The apiKey is encrypted via Electron's safeStorage API (macOS Keychain,
* Windows DPAPI, Linux libsecret) before being stored as a hex string.
* Non-sensitive fields (provider, endpoint, model) are stored in plain text.
*
* If safeStorage.isEncryptionAvailable() returns false, the apiKey is
* stored in plain text with a console.warn — this covers headless / CI
* environments where the OS keychain is not available.
*/
const fs = require('fs');
const path = require('path');
// ── Key names used inside the store ─────────────────────────────────
const KEYS = Object.freeze({
PROVIDER: 'byok.provider',
ENDPOINT: 'byok.endpoint',
MODEL: 'byok.model',
API_KEY: 'byok.apiKey',
});
// ── Default config shape ────────────────────────────────────────────
const DEFAULT_CONFIG = Object.freeze({
provider: '',
endpoint: '',
apiKey: '',
model: '',
});
const LOG_PREFIX = '[byokConfig]';
// ══════════════════════════════════════════════════════════════════════
// FS JSON fallback store
// ══════════════════════════════════════════════════════════════════════
/**
* Simple file-backed key-value store compatible with the electron-store
* surface (get / set / delete). Used when electron-store cannot be
* require()'d (CI, headless, or plain-Node tests).
*
* @param {string} filePath Absolute path to the JSON file
* @returns {{ get: Function, set: Function, delete: Function }}
*/
function _createFsStore(filePath) {
let data = {};
// Load existing data
try {
if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(filePath, 'utf-8');
data = JSON.parse(raw);
}
} catch {
// File doesn't exist or is corrupt — start fresh
data = {};
}
function _persist() {
try {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to persist config to ${filePath}:`, err.message);
}
}
return {
get(key) {
return data[key];
},
set(key, value) {
data[key] = value;
_persist();
},
delete(key) {
delete data[key];
_persist();
},
};
}
// ══════════════════════════════════════════════════════════════════════
// Factory (with dependency injection for testability)
// ══════════════════════════════════════════════════════════════════════
/**
* Internal factory — exported for unit-test injection.
*
* @param {object} [deps]
* @param {object} [deps.safeStorage] Electron safeStorage (or mock)
* @param {Function} [deps.electronStore] Constructor for electron-store (or mock)
* @param {object} [opts]
* @param {string} [opts.filePath] Fallback JSON file path
* @returns {{ get: Function, set: Function, delete: Function }}
*/
function _createByokConfigStore(deps = {}, opts = {}) {
// 1) Try electron-store
const ElectronStore = deps.electronStore;
if (ElectronStore) {
console.log(`${LOG_PREFIX} using electron-store`);
return new ElectronStore({ name: 'byok-config', ...opts });
}
// Try native require if no dep injected
try {
const Store = require('electron-store');
console.log(`${LOG_PREFIX} using electron-store`);
return new Store({ name: 'byok-config', ...opts });
} catch (err) {
console.log(`${LOG_PREFIX} electron-store unavailable, falling back to fs JSON (${err.message})`);
}
// 2) Fall back to fs JSON
const filePath =
opts.filePath || path.join(process.cwd(), '.byok-config.json');
console.log(`${LOG_PREFIX} using fs JSON store at ${filePath}`);
return _createFsStore(filePath);
}
/**
* Public factory that tries electron-store, falls back to fs JSON.
*
* @param {object} [opts]
* @param {string} [opts.filePath] Fallback JSON file path
* @returns {{ get: Function, set: Function, delete: Function }}
*/
function createByokConfigStore(opts) {
return _createByokConfigStore({}, opts);
}
// ══════════════════════════════════════════════════════════════════════
// Read / Write helpers (with dependency injection for testability)
// ══════════════════════════════════════════════════════════════════════
/**
* Internal getter — exported for unit-test injection.
*
* @param {object} store Store instance (electron-store or fs fallback)
* @param {object} [deps]
* @param {object} [deps.safeStorage] Electron safeStorage (or mock)
* @returns {{ provider: string, endpoint: string, apiKey: string, model: string }}
*/
function _getByokConfig(store, deps = {}) {
console.log(`${LOG_PREFIX} get entry`);
const provider = store.get(KEYS.PROVIDER) || DEFAULT_CONFIG.provider;
const endpoint = store.get(KEYS.ENDPOINT) || DEFAULT_CONFIG.endpoint;
const model = store.get(KEYS.MODEL) || DEFAULT_CONFIG.model;
const apiKeyStored = store.get(KEYS.API_KEY);
let apiKey = DEFAULT_CONFIG.apiKey;
if (apiKeyStored) {
const ss = _resolveSafeStorage(deps);
if (ss && ss.isEncryptionAvailable()) {
try {
const decrypted = ss.decryptString(Buffer.from(apiKeyStored, 'hex'));
apiKey = decrypted;
} catch (_err) {
console.warn(
`${LOG_PREFIX} Decryption failed — apiKey may be corrupted or stored in plain text. ` +
'Returning apiKey=undefined.',
);
apiKey = DEFAULT_CONFIG.apiKey;
}
} else {
// safeStorage not available — value was stored in plain text
apiKey = apiKeyStored;
}
}
console.log(
`${LOG_PREFIX} get complete`,
JSON.stringify({ provider, endpoint, model, hasApiKey: apiKey !== '' }),
);
return { provider, endpoint, apiKey, model };
}
/**
* Internal setter — exported for unit-test injection.
*
* @param {object} store Store instance
* @param {object} config { provider, endpoint, apiKey, model }
* @param {object} [deps]
* @param {object} [deps.safeStorage] Electron safeStorage (or mock)
*/
function _setByokConfig(store, config, deps = {}) {
console.log(
`${LOG_PREFIX} set entry`,
JSON.stringify({
provider: config.provider,
endpoint: config.endpoint,
model: config.model,
}),
);
// Non-sensitive fields
store.set(KEYS.PROVIDER, config.provider ?? DEFAULT_CONFIG.provider);
store.set(KEYS.ENDPOINT, config.endpoint ?? DEFAULT_CONFIG.endpoint);
store.set(KEYS.MODEL, config.model ?? DEFAULT_CONFIG.model);
// apiKey — encrypt if possible
const apiKey = config.apiKey ?? DEFAULT_CONFIG.apiKey;
if (apiKey !== '') {
const ss = _resolveSafeStorage(deps);
if (ss && ss.isEncryptionAvailable()) {
const encrypted = ss.encryptString(apiKey);
store.set(KEYS.API_KEY, encrypted.toString('hex'));
} else {
console.warn(
`${LOG_PREFIX} safeStorage is not available — storing apiKey in plain text`,
);
store.set(KEYS.API_KEY, apiKey);
}
} else {
store.set(KEYS.API_KEY, '');
}
console.log(`${LOG_PREFIX} set complete`);
}
/**
* Public getter that lazily resolves safeStorage from electron.
*/
function getByokConfig(store) {
return _getByokConfig(store, {});
}
/**
* Public setter that lazily resolves safeStorage from electron.
*/
function setByokConfig(store, config) {
return _setByokConfig(store, config, {});
}
// ── Helpers ──────────────────────────────────────────────────────────
/**
* Resolve safeStorage: use injected dep if provided, otherwise try
* electron, otherwise null.
*/
function _resolveSafeStorage(deps) {
if (deps.safeStorage !== undefined) {
return deps.safeStorage;
}
try {
return require('electron').safeStorage;
} catch {
return null;
}
}
module.exports = {
createByokConfigStore,
getByokConfig,
setByokConfig,
// Internal factories exported for unit-test dependency injection
_createByokConfigStore,
_getByokConfig,
_setByokConfig,
};
@@ -0,0 +1,148 @@
'use strict';
/**
* Single code-path LLM caller using native fetch().
*
* Sends one OpenAI-compatible /v1/chat/completions HTTP POST.
* Configurable via a plain object. Uses AbortController for
* timeouts — 60 s for localhost endpoints, 30 s for everything else.
*
* Designed to run inside Electron 28+ (Node 18+) without any
* external HTTP library.
*/
const { ByokError } = require('./errors');
// ── Constants ────────────────────────────────────────────────────────
const DEFAULT_TIMEOUT_LOCAL_MS = 60_000;
const DEFAULT_TIMEOUT_CLOUD_MS = 30_000;
// ── Helpers ──────────────────────────────────────────────────────────
function isLocalhost(url) {
try {
const u = new URL(url);
return (
u.hostname === 'localhost' ||
u.hostname === '127.0.0.1' ||
u.hostname === '[::1]'
);
} catch {
return false;
}
}
/**
* Detect whether an endpoint looks like Ollama.
* Ollama default: http://localhost:11434
*/
function isOllamaEndpoint(url) {
try {
const u = new URL(url);
return parseInt(u.port, 10) === 11434;
} catch {
return false;
}
}
// ── Main ─────────────────────────────────────────────────────────────
/**
* Call an OpenAI-compatible /v1/chat/completions endpoint.
*
* @param {object} config
* @param {string} config.endpoint Base URL (e.g. "https://api.openai.com/v1")
* @param {string} config.apiKey API key
* @param {string} config.model Model id
* @param {number} [config.timeout] Override timeout (ms)
* @param {object[]} messages OpenAI-format messages array
* @param {object} [options]
* @param {number} [options.temperature=0.7]
* @param {number} [options.max_tokens=4096]
* @returns {Promise<{content: string, usage: {prompt_tokens: number, completion_tokens: number}}>}
*/
async function callLLM(config, messages, options = {}) {
const { endpoint, apiKey, model } = config;
// ── URL ──────────────────────────────────────────────────────────
const baseURL = endpoint.replace(/\/+$/, '');
const url = `${baseURL}/chat/completions`;
// ── Timeout ───────────────────────────────────────────────────────
const timeoutMs =
config.timeout ??
(isLocalhost(baseURL) || isOllamaEndpoint(baseURL)
? DEFAULT_TIMEOUT_LOCAL_MS
: DEFAULT_TIMEOUT_CLOUD_MS);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
// ── Provider name for errors ──────────────────────────────────────
const provider = deriveProviderName(baseURL, model);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({
model,
messages,
temperature: options.temperature ?? 0.7,
max_tokens: options.max_tokens ?? 4096,
}),
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
const code = ByokError.codeFromStatus(response.status);
let bodyText = '';
try { bodyText = await response.text(); } catch { /* ignore */ }
const detail = bodyText ? `${bodyText.substring(0, 200)}` : '';
throw new ByokError(provider, code);
}
const json = await response.json();
const content = json.choices?.[0]?.message?.content ?? '';
const usage = {
prompt_tokens: json.usage?.prompt_tokens ?? 0,
completion_tokens: json.usage?.completion_tokens ?? 0,
};
return { content, usage };
} catch (err) {
clearTimeout(timer);
// Already a ByokError — re-throw as-is
if (err instanceof ByokError) {
throw err;
}
// fetch-level error (AbortError, connection refused, DNS, etc.)
const code = ByokError.codeFromFetchError(err);
throw new ByokError(provider, code);
}
}
/**
* Derive a human-readable provider name from the endpoint URL.
*/
function deriveProviderName(baseURL, model) {
try {
const host = new URL(baseURL).hostname;
if (host.includes('openai')) return 'OpenAI';
if (host.includes('openrouter')) return 'OpenRouter';
if (host.includes('anthropic')) return 'Anthropic';
if (host.includes('googleapis') || host.includes('generativelanguage')) return 'Google';
if (host === 'localhost' || host === '127.0.0.1') return 'Local';
return host;
} catch {
return model || 'Unknown';
}
}
module.exports = { callLLM };
@@ -0,0 +1,73 @@
'use strict';
/**
* Structured error types for the BYOK AI proxy.
*
* Each error names the provider and a suggested fix so that UIs and
* diagnostic surfaces can surface actionable messages without
* coupling to provider internals.
*/
// ── Error codes ──────────────────────────────────────────────────────
const ERROR_CODES = Object.freeze({
INVALID_KEY: 'INVALID_KEY', // 401
ENDPOINT_UNREACHABLE: 'ENDPOINT_UNREACHABLE', // connection refused / timeout
MODEL_NOT_FOUND: 'MODEL_NOT_FOUND', // 404
RATE_LIMITED: 'RATE_LIMITED', // 429
UNKNOWN: 'UNKNOWN', // anything else
});
// ── User-facing messages per code ────────────────────────────────────
function buildUserMessage(provider, code) {
switch (code) {
case ERROR_CODES.INVALID_KEY:
return `${provider}: Invalid API key — check your key or generate a new one in the ${provider} dashboard.`;
case ERROR_CODES.ENDPOINT_UNREACHABLE:
return `${provider}: Endpoint unreachable — verify the URL is correct and the service is running.`;
case ERROR_CODES.MODEL_NOT_FOUND:
return `${provider}: Model not found — the requested model may have been removed or renamed. Check the model name in your configuration.`;
case ERROR_CODES.RATE_LIMITED:
return `${provider}: Rate limited — wait a moment before retrying, or upgrade your plan.`;
case ERROR_CODES.UNKNOWN:
default:
return `${provider}: Unexpected error — check the ${provider} status page or try again later.`;
}
}
// ── ByokError ────────────────────────────────────────────────────────
class ByokError extends Error {
/**
* @param {string} provider Human-readable provider name (e.g. "OpenAI", "Ollama")
* @param {string} code One of ERROR_CODES
* @param {string} [userMessage] Override the default user-facing message
*/
constructor(provider, code, userMessage) {
const msg = userMessage || buildUserMessage(provider, code);
super(msg);
this.name = 'ByokError';
this.provider = provider;
this.code = code;
this.userMessage = msg;
}
/** Derive the best code from an HTTP status. */
static codeFromStatus(status) {
switch (status) {
case 401: return ERROR_CODES.INVALID_KEY;
case 404: return ERROR_CODES.MODEL_NOT_FOUND;
case 429: return ERROR_CODES.RATE_LIMITED;
default: return ERROR_CODES.UNKNOWN;
}
}
/** Derive the best code from a fetch-level error (no HTTP response). */
static codeFromFetchError(err) {
if (err.name === 'AbortError') {
return ERROR_CODES.ENDPOINT_UNREACHABLE;
}
// DNS, connection refused, etc.
return ERROR_CODES.ENDPOINT_UNREACHABLE;
}
}
module.exports = { ByokError, ERROR_CODES };
@@ -0,0 +1,106 @@
'use strict';
/**
* In-memory request store with TTL-based eviction.
*
* Replaces the raw Map in proxy/server.js with a structured API
* suitable for both the IPC handler and eventual migration to a
* disk-backed store.
*/
const { randomUUID } = require('crypto');
// ── Configuration ────────────────────────────────────────────────────
const TTL_MS = 60 * 60 * 1000; // 1 hour
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
// ── Store ────────────────────────────────────────────────────────────
const store = new Map();
let _seq = 0; // monotonic counter for sub-ms uniqueness
function nowISO() {
_seq = (_seq + 1) % 1_000_000;
return new Date().toISOString().replace('Z', `.${String(_seq).padStart(6, '0')}Z`);
}
/**
* Create a new entry.
*
* @param {object} request The initial request payload (must have at
* least `userId` and `gameId`; `id` will be
* auto-generated).
* @returns {string} The generated request id.
*/
function create(request) {
const id = randomUUID();
const now = nowISO();
const entry = {
id,
createdAt: now,
updatedAt: now,
userId: request.userId || 'byok-user',
gameId: request.gameId || null,
status: 'working',
mode: request.mode || 'chat',
aiConfiguration: request.aiConfiguration || { presetId: 'byok-custom' },
output: [],
totalPriceInCredits: 0,
error: null,
};
store.set(id, entry);
return id;
}
/**
* Retrieve an entry by id.
*
* @param {string} id
* @returns {object|undefined}
*/
function get(id) {
return store.get(id);
}
/**
* Shallow-merge `partial` into the existing entry.
*
* @param {string} id
* @param {object} partial Keys to merge (top-level only).
* @returns {object|undefined} The updated entry, or undefined if not found.
*/
function update(id, partial) {
const entry = store.get(id);
if (!entry) return undefined;
Object.assign(entry, partial, { updatedAt: nowISO() });
return entry;
}
/**
* Remove expired entries. Called by the periodic sweep.
*/
function _sweep() {
const cutoff = Date.now() - TTL_MS;
for (const [id, entry] of store) {
if (new Date(entry.createdAt).getTime() < cutoff) {
store.delete(id);
}
}
}
// Start periodic cleanup
const _cleanupTimer = setInterval(_sweep, CLEANUP_INTERVAL_MS);
// Allow the timer to not keep the process alive (Node >= 18.2)
if (typeof _cleanupTimer.unref === 'function') {
_cleanupTimer.unref();
}
// ── Test helpers (not part of public API) ────────────────────────────
function _size() {
return store.size;
}
function _clear() {
store.clear();
}
module.exports = { create, get, update, _size, _clear };
@@ -0,0 +1,24 @@
// Fixture: newIDE/app/src/AiConfiguration/AiConfiguration.js
// Contains the exact pattern for patch 10 (BYOK preset injection).
// Represents the upstream GDevelop AI configuration preset dropdown.
export const getDefaultAIConfigurationPresets = (): AIConfigurationPreset[] => {
return [
{
id: 'gpt-4o',
name: 'GPT-4o',
model: 'gpt-4o',
provider: 'OpenAI',
endpoint: 'https://api.openai.com/v1/chat/completions',
description: 'Most capable GPT-4o model — best for complex reasoning and creative tasks.',
},
{
id: 'gpt-4o-mini',
name: 'GPT-4o Mini',
model: 'gpt-4o-mini',
provider: 'OpenAI',
endpoint: 'https://api.openai.com/v1/chat/completions',
description: 'Fast, affordable model for everyday tasks.',
},
];
};
@@ -0,0 +1,46 @@
// Fixture: newIDE/app/src/AiConfiguration/AiConfigurationPresetSelector.js
// Contains the exact pattern for patch 12 (BYOK inline config form injection).
// Represents the upstream GDevelop AI configuration preset selector dropdown.
//
// P012 will patch this component to add an inline BYOK configuration form
// that expands below the SelectField when the BYOK preset is selected.
import * as React from 'react';
import { useState, useEffect } from 'react';
import { getDefaultAIConfigurationPresets } from './AiConfiguration';
import SelectField from '../../UI/SelectField';
import TextField from '../../UI/TextField';
import FlatButton from '../../UI/FlatButton';
type Props = {|
value: string,
onChange: (presetId: string) => void,
|};
const AiConfigurationPresetSelector = ({ value, onChange }: Props) => {
const presets = React.useMemo(
() =>
getDefaultAIConfigurationPresets().map((preset) => ({
value: preset.id,
label: preset.name,
})),
[],
);
return (
<SelectField
value={value}
onChange={(event, index, newValue) => {
onChange(newValue);
}}
>
{presets.map((preset) => (
<option key={preset.value} value={preset.value}>
{preset.label}
</option>
))}
</SelectField>
);
};
export default AiConfigurationPresetSelector;
@@ -1,10 +1,13 @@
// Fixture: newIDE/app/src/Utils/GDevelopServices/ApiConfigs.js
// Contains the exact pattern for patch 3 (GDevelopGenerationApi reroute).
// Contains the BYOK override pattern for P003 to clean up.
// P003 removes this override (superseded by Generation.js IPC routing).
const isDev = process.env.NODE_ENV === 'development';
export const GDevelopGenerationApi = {
baseUrl: ((isDev
baseUrl: typeof window !== 'undefined' && window.__BYOK_API_BASE_URL__
? window.__BYOK_API_BASE_URL__
: ((isDev
? 'https://api-dev.gdevelop.io/generation'
: 'https://api.gdevelop.io/generation'): string),
: 'https://api.gdevelop.io/generation'): string), // BYOK PATCH: dynamic override
};
@@ -0,0 +1,37 @@
// Fixture: newIDE/app/src/Utils/GDevelopServices/Generation.js
// Contains the exact pattern for patch 11 (BYOK detection → IPC routing).
// Represents the upstream GDevelop AI generation service.
const axios = require('axios');
const { GDevelopGenerationApi } = require('./ApiConfigs');
export const generateChatResponse = async ({
messages,
aiConfiguration,
getAuthorizationHeader,
onProgress,
}: {
messages: Array<{ role: string; content: string }>,
aiConfiguration?: { presetId?: string; model?: string; provider?: string; endpoint?: string },
getAuthorizationHeader: () => Promise<string>,
onProgress?: (chunk: string) => void,
}): Promise<string> => {
const { messages, aiConfiguration, getAuthorizationHeader } = params;
const authHeader = await getAuthorizationHeader();
const response = await axios.post(
`${GDevelopGenerationApi.baseUrl}/chat/completions`,
{
model: aiConfiguration?.model || 'gpt-4o',
messages,
},
{
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
},
);
return response.data.choices[0].message.content;
};
@@ -0,0 +1,41 @@
// Fixture: BYOK LLM provider configuration persistence
// Simplified fixture version using electron-store as primary backend.
// Matches the public API of src/proxy/byokConfig.js.
'use strict';
const KEYS = Object.freeze({
PROVIDER: 'byok.provider',
ENDPOINT: 'byok.endpoint',
MODEL: 'byok.model',
API_KEY: 'byok.apiKey',
});
const DEFAULT_CONFIG = Object.freeze({
provider: '',
endpoint: '',
apiKey: '',
model: '',
});
function createByokConfigStore(opts) {
const Store = require('electron-store');
return new Store({ name: 'byok-config', ...opts });
}
function getByokConfig(store) {
return {
provider: store.get(KEYS.PROVIDER) || DEFAULT_CONFIG.provider,
endpoint: store.get(KEYS.ENDPOINT) || DEFAULT_CONFIG.endpoint,
model: store.get(KEYS.MODEL) || DEFAULT_CONFIG.model,
apiKey: store.get(KEYS.API_KEY) || DEFAULT_CONFIG.apiKey,
};
}
function setByokConfig(store, config) {
store.set(KEYS.PROVIDER, config.provider ?? DEFAULT_CONFIG.provider);
store.set(KEYS.ENDPOINT, config.endpoint ?? DEFAULT_CONFIG.endpoint);
store.set(KEYS.MODEL, config.model ?? DEFAULT_CONFIG.model);
store.set(KEYS.API_KEY, config.apiKey ?? DEFAULT_CONFIG.apiKey);
}
module.exports = { createByokConfigStore, getByokConfig, setByokConfig };
@@ -0,0 +1,88 @@
// Fixture: BYOK embedded IPC module
// Bootstraps the config store and registers both request + config IPC handlers.
// Injected into Electron main.js via P009.
'use strict';
const { createByokConfigStore, getByokConfig, setByokConfig } = require('./byok-config');
// ── Module-init: create the config store ────────────────────────────
const configStore = createByokConfigStore();
// ── Config handler factory ──────────────────────────────────────────
/**
* Build config IPC handler functions.
*
* @param {object} store Config store instance
* @returns {{ byokAiGetConfig: Function, byokAiSetConfig: Function }}
*/
function _createConfigHandlers(store) {
async function byokAiGetConfig(event) {
console.log('[byok] config get entry');
try {
const config = getByokConfig(store);
console.log(
'[byok] config get complete',
JSON.stringify({
provider: config.provider,
endpoint: config.endpoint,
model: config.model,
hasApiKey: config.apiKey !== '',
}),
);
return config;
} catch (err) {
console.warn('[byok] config get failed', err.message);
return null;
}
}
async function byokAiSetConfig(event, config) {
console.log(
'[byok] config set entry',
JSON.stringify({
provider: config?.provider,
endpoint: config?.endpoint,
model: config?.model,
}),
);
try {
setByokConfig(store, config || {});
console.log('[byok] config set complete');
return { success: true };
} catch (err) {
console.warn('[byok] config set failed', err.message);
return { success: false, error: err.message };
}
}
return { byokAiGetConfig, byokAiSetConfig };
}
// ── Public registration ─────────────────────────────────────────────
/**
* Register BYOK IPC handlers on the provided ipcMain instance.
*
* Registers both request-related channels (byok-ai-request,
* byok-ai-request-status) and config channels (byok-ai-get-config,
* byok-ai-set-config).
*
* @param {Electron.IpcMain} ipcMain
* @param {object} [store] Optional override for the config store
*/
function registerByokIpcHandlers(ipcMain, store) {
const effectiveStore = store || configStore;
// Register config handlers
const { byokAiGetConfig, byokAiSetConfig } =
_createConfigHandlers(effectiveStore);
ipcMain.handle('byok-ai-get-config', byokAiGetConfig);
ipcMain.handle('byok-ai-set-config', byokAiSetConfig);
// Request handlers are registered downstream by src/ipc/byokHandler.js
// when the actual module is present. In the fixture, this stub covers
// base registration; the real handlers will be layered on top.
}
module.exports = { registerByokIpcHandlers };
@@ -0,0 +1,260 @@
'use strict';
/**
* IPC handler for BYOK AI requests.
*
* Wires the T01 proxy core modules into Electron's IPC system.
* The handler lives in the main process; the matching preload
* bridge (src/preload/byokBridge.js) exposes it to the renderer.
*
* Two IPC channels:
* byok-ai-request — submit an LLM request
* byok-ai-request-status — poll the status of a fire-and-forget request
*/
const { callLLM } = require('../proxy/callLLM');
const { buildSystemPrompt } = require('../proxy/buildSystemPrompt');
const requestStore = require('../proxy/requestStore');
const { ByokError } = require('../proxy/errors');
const { getByokConfig, setByokConfig } = require('../proxy/byokConfig');
// ── Handler factory (exported for direct unit-testability) ────────────
/**
* Build the IPC handler functions. Separated from registration so
* tests can call the handler directly with mocked dependencies.
*
* @param {object} [deps]
* @param {Function} [deps.callLLM]
* @param {Function} [deps.buildSystemPrompt]
* @param {object} [deps.requestStore]
* @returns {{ byokAiRequest: Function, byokAiRequestStatus: Function }}
*/
function _createHandlers(deps = {}) {
const _callLLM = deps.callLLM || callLLM;
const _buildSystemPrompt = deps.buildSystemPrompt || buildSystemPrompt;
const _requestStore = deps.requestStore || requestStore;
/**
* Handle 'byok-ai-request'.
*
* Payload shape (from renderer):
* { mode, messages, userRequest, projectContext, aiConfiguration }
*
* Mode routing:
* 'chat' → direct request-response, returns { content, usage }
* 'agent' | 'orchestrator' → fire-and-forget via requestStore,
* returns { requestId }
*/
async function byokAiRequest(event, payload) {
const { mode, messages, userRequest, projectContext, aiConfiguration } =
payload || {};
console.log(
'[byok] IPC request received',
JSON.stringify({ mode, messageCount: messages?.length ?? 0 }),
);
// ── Assemble messages ──────────────────────────────────────────
const systemContent = _buildSystemPrompt(userRequest, projectContext);
const fullMessages = [
{ role: 'system', content: systemContent },
...(messages || []),
];
// ── Route ──────────────────────────────────────────────────────
try {
if (mode === 'chat') {
const result = await _callLLM(aiConfiguration || {}, fullMessages);
console.log(
'[byok] chat response ready',
JSON.stringify({
contentLength: result.content?.length ?? 0,
usage: result.usage,
}),
);
return { content: result.content, usage: result.usage };
}
if (mode === 'agent' || mode === 'orchestrator') {
const requestId = _requestStore.create({
userId: aiConfiguration?.userId,
gameId: aiConfiguration?.gameId,
mode,
aiConfiguration,
});
// Fire the LLM call asynchronously — do not await.
_callLLM(aiConfiguration || {}, fullMessages)
.then((result) => {
_requestStore.update(requestId, {
status: 'ready',
output: [result],
});
console.log(
'[byok] fire-and-forget completed',
JSON.stringify({ requestId, mode }),
);
})
.catch((err) => {
_requestStore.update(requestId, {
status: 'error',
error:
err instanceof ByokError
? {
provider: err.provider,
code: err.code,
message: err.userMessage,
}
: { message: err.message || String(err) },
});
console.error(
'[byok] fire-and-forget failed',
JSON.stringify({
requestId,
mode,
error:
err instanceof ByokError
? { provider: err.provider, code: err.code }
: err.message,
}),
);
});
console.log(
'[byok] fire-and-forget started',
JSON.stringify({ requestId, mode }),
);
return { requestId };
}
// Unknown mode
throw new ByokError('BYOK', 'UNKNOWN', `Unknown mode: ${mode}`);
} catch (err) {
console.error(
'[byok] IPC request failed',
JSON.stringify({
mode,
error:
err instanceof ByokError
? { provider: err.provider, code: err.code, message: err.userMessage }
: err.message,
}),
);
throw err; // re-throw so ipcMain.handle propagates the rejection
}
}
/**
* Handle 'byok-ai-request-status'.
*
* Returns the full entry (minus internal fields) or { found: false }.
*/
function byokAiRequestStatus(event, requestId) {
const entry = _requestStore.get(requestId);
if (!entry) {
return { found: false };
}
return {
found: true,
id: entry.id,
status: entry.status,
error: entry.error,
output: entry.output,
totalPriceInCredits: entry.totalPriceInCredits,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
};
}
return { byokAiRequest, byokAiRequestStatus };
}
// ── Config handler factory (exported for direct unit-testability) ──
/**
* Build the config IPC handler functions.
*
* @param {object} store Config store instance (electron-store or fs fallback)
* @returns {{ byokAiGetConfig: Function, byokAiSetConfig: Function }}
*/
function _createConfigHandlers(store) {
/**
* Handle 'byok-ai-get-config'.
*
* Returns the full provider config (provider, endpoint, model, apiKey)
* or null if no config exists.
*/
async function byokAiGetConfig(event) {
console.log('[byok] config get entry');
try {
const config = getByokConfig(store);
console.log(
'[byok] config get complete',
JSON.stringify({
provider: config.provider,
endpoint: config.endpoint,
model: config.model,
hasApiKey: config.apiKey !== '',
}),
);
return config;
} catch (err) {
console.warn('[byok] config get failed', err.message);
return null;
}
}
/**
* Handle 'byok-ai-set-config'.
*
* Persists provider config and returns { success: true }.
*/
async function byokAiSetConfig(event, config) {
console.log(
'[byok] config set entry',
JSON.stringify({
provider: config?.provider,
endpoint: config?.endpoint,
model: config?.model,
}),
);
try {
setByokConfig(store, config || {});
console.log('[byok] config set complete');
return { success: true };
} catch (err) {
console.warn('[byok] config set failed', err.message);
return { success: false, error: err.message };
}
}
return { byokAiGetConfig, byokAiSetConfig };
}
// ── Public registration ──────────────────────────────────────────────
/**
* Register BYOK IPC handlers on the provided ipcMain instance.
*
* Usage in Electron main process:
* const { registerByokIpcHandlers } = require('./src/ipc/byokHandler');
* registerByokIpcHandlers(ipcMain, store);
*
* @param {Electron.IpcMain} ipcMain
* @param {object} [store] Config store instance (optional — config channels
* are only registered when a store is provided)
*/
function registerByokIpcHandlers(ipcMain, store) {
const { byokAiRequest, byokAiRequestStatus } = _createHandlers();
ipcMain.handle('byok-ai-request', byokAiRequest);
ipcMain.handle('byok-ai-request-status', byokAiRequestStatus);
if (store) {
const { byokAiGetConfig, byokAiSetConfig } = _createConfigHandlers(store);
ipcMain.handle('byok-ai-get-config', byokAiGetConfig);
ipcMain.handle('byok-ai-set-config', byokAiSetConfig);
}
}
module.exports = { registerByokIpcHandlers, _createHandlers, _createConfigHandlers };
@@ -0,0 +1,42 @@
'use strict';
/**
* Assemble the system prompt sent to the LLM.
*
* Extracted from proxy/server.js. CUSTOM_SYSTEM_PROMPT env-var
* support has been intentionally removed — the prompt is assembled
* purely from the base template, optional project context, and the
* user request.
*/
const BASE_PROMPT = `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.`;
/**
* @param {string} [userRequest] The user's current prompt
* @param {string} [projectContext] Optional serialised project state
* @returns {string} Assembled system prompt
*/
function buildSystemPrompt(userRequest, projectContext) {
let prompt = BASE_PROMPT;
if (projectContext) {
prompt += '\n\nCurrent project context:\n' + projectContext;
}
if (userRequest) {
prompt += '\n\nUser request: ' + userRequest;
}
return prompt;
}
module.exports = { buildSystemPrompt, BASE_PROMPT };
@@ -0,0 +1,279 @@
'use strict';
/**
* BYOK LLM provider configuration persistence.
*
* Persists { provider, endpoint, apiKey, model } across app restarts using
* a tiered storage approach:
*
* 1. electron-store — preferred (lazy require)
* 2. JSON file on fs — fallback when electron-store is unavailable
*
* The apiKey is encrypted via Electron's safeStorage API (macOS Keychain,
* Windows DPAPI, Linux libsecret) before being stored as a hex string.
* Non-sensitive fields (provider, endpoint, model) are stored in plain text.
*
* If safeStorage.isEncryptionAvailable() returns false, the apiKey is
* stored in plain text with a console.warn — this covers headless / CI
* environments where the OS keychain is not available.
*/
const fs = require('fs');
const path = require('path');
// ── Key names used inside the store ─────────────────────────────────
const KEYS = Object.freeze({
PROVIDER: 'byok.provider',
ENDPOINT: 'byok.endpoint',
MODEL: 'byok.model',
API_KEY: 'byok.apiKey',
});
// ── Default config shape ────────────────────────────────────────────
const DEFAULT_CONFIG = Object.freeze({
provider: '',
endpoint: '',
apiKey: '',
model: '',
});
const LOG_PREFIX = '[byokConfig]';
// ══════════════════════════════════════════════════════════════════════
// FS JSON fallback store
// ══════════════════════════════════════════════════════════════════════
/**
* Simple file-backed key-value store compatible with the electron-store
* surface (get / set / delete). Used when electron-store cannot be
* require()'d (CI, headless, or plain-Node tests).
*
* @param {string} filePath Absolute path to the JSON file
* @returns {{ get: Function, set: Function, delete: Function }}
*/
function _createFsStore(filePath) {
let data = {};
// Load existing data
try {
if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(filePath, 'utf-8');
data = JSON.parse(raw);
}
} catch {
// File doesn't exist or is corrupt — start fresh
data = {};
}
function _persist() {
try {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
} catch (err) {
console.warn(`${LOG_PREFIX} Failed to persist config to ${filePath}:`, err.message);
}
}
return {
get(key) {
return data[key];
},
set(key, value) {
data[key] = value;
_persist();
},
delete(key) {
delete data[key];
_persist();
},
};
}
// ══════════════════════════════════════════════════════════════════════
// Factory (with dependency injection for testability)
// ══════════════════════════════════════════════════════════════════════
/**
* Internal factory — exported for unit-test injection.
*
* @param {object} [deps]
* @param {object} [deps.safeStorage] Electron safeStorage (or mock)
* @param {Function} [deps.electronStore] Constructor for electron-store (or mock)
* @param {object} [opts]
* @param {string} [opts.filePath] Fallback JSON file path
* @returns {{ get: Function, set: Function, delete: Function }}
*/
function _createByokConfigStore(deps = {}, opts = {}) {
// 1) Try electron-store
const ElectronStore = deps.electronStore;
if (ElectronStore) {
console.log(`${LOG_PREFIX} using electron-store`);
return new ElectronStore({ name: 'byok-config', ...opts });
}
// Try native require if no dep injected
try {
const Store = require('electron-store');
console.log(`${LOG_PREFIX} using electron-store`);
return new Store({ name: 'byok-config', ...opts });
} catch (err) {
console.log(`${LOG_PREFIX} electron-store unavailable, falling back to fs JSON (${err.message})`);
}
// 2) Fall back to fs JSON
const filePath =
opts.filePath || path.join(process.cwd(), '.byok-config.json');
console.log(`${LOG_PREFIX} using fs JSON store at ${filePath}`);
return _createFsStore(filePath);
}
/**
* Public factory that tries electron-store, falls back to fs JSON.
*
* @param {object} [opts]
* @param {string} [opts.filePath] Fallback JSON file path
* @returns {{ get: Function, set: Function, delete: Function }}
*/
function createByokConfigStore(opts) {
return _createByokConfigStore({}, opts);
}
// ══════════════════════════════════════════════════════════════════════
// Read / Write helpers (with dependency injection for testability)
// ══════════════════════════════════════════════════════════════════════
/**
* Internal getter — exported for unit-test injection.
*
* @param {object} store Store instance (electron-store or fs fallback)
* @param {object} [deps]
* @param {object} [deps.safeStorage] Electron safeStorage (or mock)
* @returns {{ provider: string, endpoint: string, apiKey: string, model: string }}
*/
function _getByokConfig(store, deps = {}) {
console.log(`${LOG_PREFIX} get entry`);
const provider = store.get(KEYS.PROVIDER) || DEFAULT_CONFIG.provider;
const endpoint = store.get(KEYS.ENDPOINT) || DEFAULT_CONFIG.endpoint;
const model = store.get(KEYS.MODEL) || DEFAULT_CONFIG.model;
const apiKeyStored = store.get(KEYS.API_KEY);
let apiKey = DEFAULT_CONFIG.apiKey;
if (apiKeyStored) {
const ss = _resolveSafeStorage(deps);
if (ss && ss.isEncryptionAvailable()) {
try {
const decrypted = ss.decryptString(Buffer.from(apiKeyStored, 'hex'));
apiKey = decrypted;
} catch (_err) {
console.warn(
`${LOG_PREFIX} Decryption failed — apiKey may be corrupted or stored in plain text. ` +
'Returning apiKey=undefined.',
);
apiKey = DEFAULT_CONFIG.apiKey;
}
} else {
// safeStorage not available — value was stored in plain text
apiKey = apiKeyStored;
}
}
console.log(
`${LOG_PREFIX} get complete`,
JSON.stringify({ provider, endpoint, model, hasApiKey: apiKey !== '' }),
);
return { provider, endpoint, apiKey, model };
}
/**
* Internal setter — exported for unit-test injection.
*
* @param {object} store Store instance
* @param {object} config { provider, endpoint, apiKey, model }
* @param {object} [deps]
* @param {object} [deps.safeStorage] Electron safeStorage (or mock)
*/
function _setByokConfig(store, config, deps = {}) {
console.log(
`${LOG_PREFIX} set entry`,
JSON.stringify({
provider: config.provider,
endpoint: config.endpoint,
model: config.model,
}),
);
// Non-sensitive fields
store.set(KEYS.PROVIDER, config.provider ?? DEFAULT_CONFIG.provider);
store.set(KEYS.ENDPOINT, config.endpoint ?? DEFAULT_CONFIG.endpoint);
store.set(KEYS.MODEL, config.model ?? DEFAULT_CONFIG.model);
// apiKey — encrypt if possible
const apiKey = config.apiKey ?? DEFAULT_CONFIG.apiKey;
if (apiKey !== '') {
const ss = _resolveSafeStorage(deps);
if (ss && ss.isEncryptionAvailable()) {
const encrypted = ss.encryptString(apiKey);
store.set(KEYS.API_KEY, encrypted.toString('hex'));
} else {
console.warn(
`${LOG_PREFIX} safeStorage is not available — storing apiKey in plain text`,
);
store.set(KEYS.API_KEY, apiKey);
}
} else {
store.set(KEYS.API_KEY, '');
}
console.log(`${LOG_PREFIX} set complete`);
}
/**
* Public getter that lazily resolves safeStorage from electron.
*/
function getByokConfig(store) {
return _getByokConfig(store, {});
}
/**
* Public setter that lazily resolves safeStorage from electron.
*/
function setByokConfig(store, config) {
return _setByokConfig(store, config, {});
}
// ── Helpers ──────────────────────────────────────────────────────────
/**
* Resolve safeStorage: use injected dep if provided, otherwise try
* electron, otherwise null.
*/
function _resolveSafeStorage(deps) {
if (deps.safeStorage !== undefined) {
return deps.safeStorage;
}
try {
return require('electron').safeStorage;
} catch {
return null;
}
}
module.exports = {
createByokConfigStore,
getByokConfig,
setByokConfig,
// Internal factories exported for unit-test dependency injection
_createByokConfigStore,
_getByokConfig,
_setByokConfig,
};
@@ -0,0 +1,148 @@
'use strict';
/**
* Single code-path LLM caller using native fetch().
*
* Sends one OpenAI-compatible /v1/chat/completions HTTP POST.
* Configurable via a plain object. Uses AbortController for
* timeouts — 60 s for localhost endpoints, 30 s for everything else.
*
* Designed to run inside Electron 28+ (Node 18+) without any
* external HTTP library.
*/
const { ByokError } = require('./errors');
// ── Constants ────────────────────────────────────────────────────────
const DEFAULT_TIMEOUT_LOCAL_MS = 60_000;
const DEFAULT_TIMEOUT_CLOUD_MS = 30_000;
// ── Helpers ──────────────────────────────────────────────────────────
function isLocalhost(url) {
try {
const u = new URL(url);
return (
u.hostname === 'localhost' ||
u.hostname === '127.0.0.1' ||
u.hostname === '[::1]'
);
} catch {
return false;
}
}
/**
* Detect whether an endpoint looks like Ollama.
* Ollama default: http://localhost:11434
*/
function isOllamaEndpoint(url) {
try {
const u = new URL(url);
return parseInt(u.port, 10) === 11434;
} catch {
return false;
}
}
// ── Main ─────────────────────────────────────────────────────────────
/**
* Call an OpenAI-compatible /v1/chat/completions endpoint.
*
* @param {object} config
* @param {string} config.endpoint Base URL (e.g. "https://api.openai.com/v1")
* @param {string} config.apiKey API key
* @param {string} config.model Model id
* @param {number} [config.timeout] Override timeout (ms)
* @param {object[]} messages OpenAI-format messages array
* @param {object} [options]
* @param {number} [options.temperature=0.7]
* @param {number} [options.max_tokens=4096]
* @returns {Promise<{content: string, usage: {prompt_tokens: number, completion_tokens: number}}>}
*/
async function callLLM(config, messages, options = {}) {
const { endpoint, apiKey, model } = config;
// ── URL ──────────────────────────────────────────────────────────
const baseURL = endpoint.replace(/\/+$/, '');
const url = `${baseURL}/chat/completions`;
// ── Timeout ───────────────────────────────────────────────────────
const timeoutMs =
config.timeout ??
(isLocalhost(baseURL) || isOllamaEndpoint(baseURL)
? DEFAULT_TIMEOUT_LOCAL_MS
: DEFAULT_TIMEOUT_CLOUD_MS);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
// ── Provider name for errors ──────────────────────────────────────
const provider = deriveProviderName(baseURL, model);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({
model,
messages,
temperature: options.temperature ?? 0.7,
max_tokens: options.max_tokens ?? 4096,
}),
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
const code = ByokError.codeFromStatus(response.status);
let bodyText = '';
try { bodyText = await response.text(); } catch { /* ignore */ }
const detail = bodyText ? `${bodyText.substring(0, 200)}` : '';
throw new ByokError(provider, code);
}
const json = await response.json();
const content = json.choices?.[0]?.message?.content ?? '';
const usage = {
prompt_tokens: json.usage?.prompt_tokens ?? 0,
completion_tokens: json.usage?.completion_tokens ?? 0,
};
return { content, usage };
} catch (err) {
clearTimeout(timer);
// Already a ByokError — re-throw as-is
if (err instanceof ByokError) {
throw err;
}
// fetch-level error (AbortError, connection refused, DNS, etc.)
const code = ByokError.codeFromFetchError(err);
throw new ByokError(provider, code);
}
}
/**
* Derive a human-readable provider name from the endpoint URL.
*/
function deriveProviderName(baseURL, model) {
try {
const host = new URL(baseURL).hostname;
if (host.includes('openai')) return 'OpenAI';
if (host.includes('openrouter')) return 'OpenRouter';
if (host.includes('anthropic')) return 'Anthropic';
if (host.includes('googleapis') || host.includes('generativelanguage')) return 'Google';
if (host === 'localhost' || host === '127.0.0.1') return 'Local';
return host;
} catch {
return model || 'Unknown';
}
}
module.exports = { callLLM };
@@ -0,0 +1,73 @@
'use strict';
/**
* Structured error types for the BYOK AI proxy.
*
* Each error names the provider and a suggested fix so that UIs and
* diagnostic surfaces can surface actionable messages without
* coupling to provider internals.
*/
// ── Error codes ──────────────────────────────────────────────────────
const ERROR_CODES = Object.freeze({
INVALID_KEY: 'INVALID_KEY', // 401
ENDPOINT_UNREACHABLE: 'ENDPOINT_UNREACHABLE', // connection refused / timeout
MODEL_NOT_FOUND: 'MODEL_NOT_FOUND', // 404
RATE_LIMITED: 'RATE_LIMITED', // 429
UNKNOWN: 'UNKNOWN', // anything else
});
// ── User-facing messages per code ────────────────────────────────────
function buildUserMessage(provider, code) {
switch (code) {
case ERROR_CODES.INVALID_KEY:
return `${provider}: Invalid API key — check your key or generate a new one in the ${provider} dashboard.`;
case ERROR_CODES.ENDPOINT_UNREACHABLE:
return `${provider}: Endpoint unreachable — verify the URL is correct and the service is running.`;
case ERROR_CODES.MODEL_NOT_FOUND:
return `${provider}: Model not found — the requested model may have been removed or renamed. Check the model name in your configuration.`;
case ERROR_CODES.RATE_LIMITED:
return `${provider}: Rate limited — wait a moment before retrying, or upgrade your plan.`;
case ERROR_CODES.UNKNOWN:
default:
return `${provider}: Unexpected error — check the ${provider} status page or try again later.`;
}
}
// ── ByokError ────────────────────────────────────────────────────────
class ByokError extends Error {
/**
* @param {string} provider Human-readable provider name (e.g. "OpenAI", "Ollama")
* @param {string} code One of ERROR_CODES
* @param {string} [userMessage] Override the default user-facing message
*/
constructor(provider, code, userMessage) {
const msg = userMessage || buildUserMessage(provider, code);
super(msg);
this.name = 'ByokError';
this.provider = provider;
this.code = code;
this.userMessage = msg;
}
/** Derive the best code from an HTTP status. */
static codeFromStatus(status) {
switch (status) {
case 401: return ERROR_CODES.INVALID_KEY;
case 404: return ERROR_CODES.MODEL_NOT_FOUND;
case 429: return ERROR_CODES.RATE_LIMITED;
default: return ERROR_CODES.UNKNOWN;
}
}
/** Derive the best code from a fetch-level error (no HTTP response). */
static codeFromFetchError(err) {
if (err.name === 'AbortError') {
return ERROR_CODES.ENDPOINT_UNREACHABLE;
}
// DNS, connection refused, etc.
return ERROR_CODES.ENDPOINT_UNREACHABLE;
}
}
module.exports = { ByokError, ERROR_CODES };
@@ -0,0 +1,106 @@
'use strict';
/**
* In-memory request store with TTL-based eviction.
*
* Replaces the raw Map in proxy/server.js with a structured API
* suitable for both the IPC handler and eventual migration to a
* disk-backed store.
*/
const { randomUUID } = require('crypto');
// ── Configuration ────────────────────────────────────────────────────
const TTL_MS = 60 * 60 * 1000; // 1 hour
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
// ── Store ────────────────────────────────────────────────────────────
const store = new Map();
let _seq = 0; // monotonic counter for sub-ms uniqueness
function nowISO() {
_seq = (_seq + 1) % 1_000_000;
return new Date().toISOString().replace('Z', `.${String(_seq).padStart(6, '0')}Z`);
}
/**
* Create a new entry.
*
* @param {object} request The initial request payload (must have at
* least `userId` and `gameId`; `id` will be
* auto-generated).
* @returns {string} The generated request id.
*/
function create(request) {
const id = randomUUID();
const now = nowISO();
const entry = {
id,
createdAt: now,
updatedAt: now,
userId: request.userId || 'byok-user',
gameId: request.gameId || null,
status: 'working',
mode: request.mode || 'chat',
aiConfiguration: request.aiConfiguration || { presetId: 'byok-custom' },
output: [],
totalPriceInCredits: 0,
error: null,
};
store.set(id, entry);
return id;
}
/**
* Retrieve an entry by id.
*
* @param {string} id
* @returns {object|undefined}
*/
function get(id) {
return store.get(id);
}
/**
* Shallow-merge `partial` into the existing entry.
*
* @param {string} id
* @param {object} partial Keys to merge (top-level only).
* @returns {object|undefined} The updated entry, or undefined if not found.
*/
function update(id, partial) {
const entry = store.get(id);
if (!entry) return undefined;
Object.assign(entry, partial, { updatedAt: nowISO() });
return entry;
}
/**
* Remove expired entries. Called by the periodic sweep.
*/
function _sweep() {
const cutoff = Date.now() - TTL_MS;
for (const [id, entry] of store) {
if (new Date(entry.createdAt).getTime() < cutoff) {
store.delete(id);
}
}
}
// Start periodic cleanup
const _cleanupTimer = setInterval(_sweep, CLEANUP_INTERVAL_MS);
// Allow the timer to not keep the process alive (Node >= 18.2)
if (typeof _cleanupTimer.unref === 'function') {
_cleanupTimer.unref();
}
// ── Test helpers (not part of public API) ────────────────────────────
function _size() {
return store.size;
}
function _clear() {
store.clear();
}
module.exports = { create, get, update, _size, _clear };
+386
View File
@@ -0,0 +1,386 @@
'use strict';
/**
* Integration tests for config IPC handlers.
*
* Covers end-to-end config persistence through the IPC pipeline:
* - Config round-trip (set → get → fields match)
* - Default config when no config is stored
* - Null config handling
* - Config update (partial overwrite)
* - Encryption round-trip (apiKey survives set → get)
* - Config IPC observability (log entry/exit)
*
* These tests wire up the handler factories directly (like the unit tests
* do) without requiring a running Electron process, using an in-memory
* store that matches the electron-store get/set/delete surface.
*
* Run: node --test test/integration/config-ipc.test.js
*/
const { describe, it, beforeEach, mock } = require('node:test');
const assert = require('node:assert/strict');
// ══════════════════════════════════════════════════════════════════════
// Modules under test
// ══════════════════════════════════════════════════════════════════════
const { _createConfigHandlers } = require('../../src/ipc/byokHandler');
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
/**
* Create an in-memory store with get/set/delete methods that match
* the electron-store surface used by byokConfig.js.
*/
function makeInMemoryStore(initial = {}) {
const data = { ...initial };
return {
get(key) {
return data[key];
},
set(key, value) {
data[key] = value;
},
delete(key) {
delete data[key];
},
};
}
beforeEach(() => {
mock.reset();
});
// ══════════════════════════════════════════════════════════════════════
// Config IPC round-trip
// ══════════════════════════════════════════════════════════════════════
describe('config IPC round-trip', () => {
it('set → get returns all fields matching', async () => {
const store = makeInMemoryStore();
const { byokAiGetConfig, byokAiSetConfig } = _createConfigHandlers(store);
const input = {
provider: 'openai',
endpoint: 'https://api.openai.com/v1',
apiKey: 'sk-test-integration-key-123',
model: 'gpt-4o',
};
const setResult = await byokAiSetConfig(null, input);
assert.equal(setResult.success, true);
const config = await byokAiGetConfig(null);
assert.equal(config.provider, input.provider);
assert.equal(config.endpoint, input.endpoint);
assert.equal(config.model, input.model);
assert.equal(config.apiKey, input.apiKey);
});
it('get with no config stored returns defaults', async () => {
const store = makeInMemoryStore();
const { byokAiGetConfig } = _createConfigHandlers(store);
const config = await byokAiGetConfig(null);
assert.equal(config.provider, '');
assert.equal(config.endpoint, '');
assert.equal(config.model, '');
assert.equal(config.apiKey, '');
});
it('set then partial update preserves unset fields', async () => {
const store = makeInMemoryStore();
const { byokAiGetConfig, byokAiSetConfig } = _createConfigHandlers(store);
// Initial full set
await byokAiSetConfig(null, {
provider: 'anthropic',
endpoint: 'https://api.anthropic.com/v1',
apiKey: 'sk-ant-full-key',
model: 'claude-sonnet-4-20250514',
});
// Partial update — only change model
const partialResult = await byokAiSetConfig(null, {
provider: 'anthropic',
endpoint: 'https://api.anthropic.com/v1',
apiKey: 'sk-ant-full-key',
model: 'claude-opus-4-20250514',
});
assert.equal(partialResult.success, true);
const config = await byokAiGetConfig(null);
assert.equal(config.provider, 'anthropic');
assert.equal(config.endpoint, 'https://api.anthropic.com/v1');
assert.equal(config.apiKey, 'sk-ant-full-key');
assert.equal(config.model, 'claude-opus-4-20250514');
});
it('overwrite with different provider changes all fields', async () => {
const store = makeInMemoryStore();
const { byokAiGetConfig, byokAiSetConfig } = _createConfigHandlers(store);
await byokAiSetConfig(null, {
provider: 'openai',
endpoint: 'https://api.openai.com/v1',
apiKey: 'sk-openai-key',
model: 'gpt-4o',
});
await byokAiSetConfig(null, {
provider: 'openrouter',
endpoint: 'https://openrouter.ai/api/v1',
apiKey: 'sk-or-key',
model: 'openai/gpt-4o',
});
const config = await byokAiGetConfig(null);
assert.equal(config.provider, 'openrouter');
assert.equal(config.endpoint, 'https://openrouter.ai/api/v1');
assert.equal(config.apiKey, 'sk-or-key');
assert.equal(config.model, 'openai/gpt-4o');
});
it('set config with empty apiKey → get returns empty apiKey', async () => {
const store = makeInMemoryStore();
const { byokAiGetConfig, byokAiSetConfig } = _createConfigHandlers(store);
await byokAiSetConfig(null, {
provider: 'ollama',
endpoint: 'http://localhost:11434',
apiKey: '',
model: 'llama3.1:8b',
});
const config = await byokAiGetConfig(null);
assert.equal(config.apiKey, '');
});
it('apiKey with special characters survives round-trip', async () => {
const store = makeInMemoryStore();
const { byokAiGetConfig, byokAiSetConfig } = _createConfigHandlers(store);
const specialKey = 'sk-!@#$%^&*()_+-=[]{}|;\':",.<>?/~`';
await byokAiSetConfig(null, {
provider: 'custom',
endpoint: '',
apiKey: specialKey,
model: '',
});
const config = await byokAiGetConfig(null);
assert.equal(config.apiKey, specialKey);
});
});
// ══════════════════════════════════════════════════════════════════════
// Null / missing config
// ══════════════════════════════════════════════════════════════════════
describe('null / missing config', () => {
it('set with null config does not throw', async () => {
const store = makeInMemoryStore();
const { byokAiSetConfig } = _createConfigHandlers(store);
const result = await byokAiSetConfig(null, null);
assert.equal(result.success, true);
});
it('set with undefined config does not throw', async () => {
const store = makeInMemoryStore();
const { byokAiSetConfig } = _createConfigHandlers(store);
const result = await byokAiSetConfig(null, undefined);
assert.equal(result.success, true);
});
it('get after set with null returns defaults', async () => {
const store = makeInMemoryStore();
const { byokAiGetConfig, byokAiSetConfig } = _createConfigHandlers(store);
await byokAiSetConfig(null, null);
const config = await byokAiGetConfig(null);
assert.equal(config.provider, '');
assert.equal(config.endpoint, '');
assert.equal(config.model, '');
assert.equal(config.apiKey, '');
});
it('get after set with empty object returns defaults', async () => {
const store = makeInMemoryStore();
const { byokAiGetConfig, byokAiSetConfig } = _createConfigHandlers(store);
await byokAiSetConfig(null, {});
const config = await byokAiGetConfig(null);
assert.equal(config.provider, '');
assert.equal(config.endpoint, '');
assert.equal(config.model, '');
assert.equal(config.apiKey, '');
});
});
// ══════════════════════════════════════════════════════════════════════
// Observability: IPC log coverage
// ══════════════════════════════════════════════════════════════════════
describe('observability', () => {
it('getConfig logs entry and complete', async () => {
const store = makeInMemoryStore();
const { byokAiGetConfig } = _createConfigHandlers(store);
const logCalls = [];
const origLog = console.log;
console.log = mock.fn((...args) => {
logCalls.push(args.join(' '));
});
try {
await byokAiGetConfig(null);
const allLogs = logCalls.join('\n');
assert.ok(allLogs.includes('[byok] config get entry'), 'should log entry');
assert.ok(allLogs.includes('[byok] config get complete'), 'should log completion');
} finally {
console.log = origLog;
}
});
it('setConfig logs entry and complete', async () => {
const store = makeInMemoryStore();
const { byokAiSetConfig } = _createConfigHandlers(store);
const logCalls = [];
const origLog = console.log;
console.log = mock.fn((...args) => {
logCalls.push(args.join(' '));
});
try {
await byokAiSetConfig(null, {
provider: 'openai',
endpoint: '',
apiKey: 'sk-secret',
model: 'gpt-4o',
});
const allLogs = logCalls.join('\n');
assert.ok(allLogs.includes('[byok] config set entry'), 'should log entry');
assert.ok(allLogs.includes('[byok] config set complete'), 'should log completion');
// apiKey value must NEVER appear in logs
assert.ok(!allLogs.includes('sk-secret'), 'apiKey value must not be logged');
} finally {
console.log = origLog;
}
});
it('getConfig logs hasApiKey as boolean but not the value', async () => {
const store = makeInMemoryStore({ 'byok.apiKey': 'sk-hidden-key-xyz' });
const { byokAiGetConfig } = _createConfigHandlers(store);
const logCalls = [];
const origLog = console.log;
console.log = mock.fn((...args) => {
logCalls.push(args.join(' '));
});
try {
await byokAiGetConfig(null);
const allLogs = logCalls.join('\n');
assert.ok(allLogs.includes('hasApiKey'), 'should log hasApiKey flag');
assert.ok(!allLogs.includes('sk-hidden-key-xyz'), 'apiKey must not be logged');
} finally {
console.log = origLog;
}
});
it('setConfig logs field names but not apiKey value', async () => {
const store = makeInMemoryStore();
const { byokAiSetConfig } = _createConfigHandlers(store);
const logCalls = [];
const origLog = console.log;
console.log = mock.fn((...args) => {
logCalls.push(args.join(' '));
});
try {
await byokAiSetConfig(null, {
provider: 'anthropic',
endpoint: 'https://api.anthropic.com/v1',
apiKey: 'sk-ant-extremely-secret-key-1234567890',
model: 'claude-sonnet-4-20250514',
});
const allLogs = logCalls.join('\n');
assert.ok(allLogs.includes('provider'), 'should log provider field name');
assert.ok(allLogs.includes('endpoint'), 'should log endpoint field name');
assert.ok(allLogs.includes('model'), 'should log model field name');
assert.ok(
!allLogs.includes('sk-ant-extremely-secret-key-1234567890'),
'apiKey value must not be logged',
);
} finally {
console.log = origLog;
}
});
it('getConfig logs a warning on store error', async () => {
// Store that throws on get
const brokenStore = {
get() { throw new Error('disk full'); },
set() {},
delete() {},
};
const { byokAiGetConfig } = _createConfigHandlers(brokenStore);
const warnCalls = [];
const origWarn = console.warn;
console.warn = mock.fn((...args) => {
warnCalls.push(args.join(' '));
});
try {
const config = await byokAiGetConfig(null);
assert.equal(config, null, 'should return null on error');
const warnMsg = warnCalls.join('\n');
assert.ok(warnMsg.includes('config get failed'), 'should log failure warning');
assert.ok(warnMsg.includes('disk full'), 'should include error message');
} finally {
console.warn = origWarn;
}
});
it('setConfig logs a warning on store error', async () => {
const brokenStore = {
get() {},
set() { throw new Error('permission denied'); },
delete() {},
};
const { byokAiSetConfig } = _createConfigHandlers(brokenStore);
const warnCalls = [];
const origWarn = console.warn;
console.warn = mock.fn((...args) => {
warnCalls.push(args.join(' '));
});
try {
const result = await byokAiSetConfig(null, {
provider: 'test',
endpoint: '',
apiKey: 'key',
model: '',
});
assert.equal(result.success, false);
assert.ok(result.error);
const warnMsg = warnCalls.join('\n');
assert.ok(warnMsg.includes('config set failed'), 'should log failure warning');
} finally {
console.warn = origWarn;
}
});
});
+403
View File
@@ -0,0 +1,403 @@
'use strict';
/**
* Integration tests for the full BYOK AI IPC pipeline.
*
* Verifies that registerByokIpcHandlers wires correctly into
* ipcMain, that _createHandlers composes the T01 proxy modules,
* and that error paths (bad config, unknown mode) produce
* structured ByokError responses.
*
* Run: node --test test/integration/proxy-ipc.test.js
*/
const { describe, it, beforeEach, mock } = require('node:test');
const assert = require('node:assert/strict');
const { _createHandlers, registerByokIpcHandlers } =
require('../../src/ipc/byokHandler');
const { ByokError } = require('../../src/proxy/errors');
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
function makeMockEvent() {
return {};
}
/**
* Create a mock ipcMain that records handler registrations
* and routes invocations to the registered functions.
*/
function makeMockIpcMain() {
const handlers = new Map();
return {
handlers,
handle: mock.fn((channel, fn) => {
handlers.set(channel, fn);
}),
invoke: async (channel, ...args) => {
const fn = handlers.get(channel);
if (!fn) throw new Error(`No handler registered for ${channel}`);
return fn(makeMockEvent(), ...args);
},
};
}
// ══════════════════════════════════════════════════════════════════════
// Full IPC pipeline: handler registration + invocation
// ══════════════════════════════════════════════════════════════════════
describe('IPC integration — register + invoke', () => {
let ipcMain;
beforeEach(() => {
mock.reset();
ipcMain = makeMockIpcMain();
});
it('registers both channels on ipcMain', () => {
registerByokIpcHandlers(ipcMain);
assert.equal(ipcMain.handle.mock.callCount(), 2);
assert.ok(
ipcMain.handlers.has('byok-ai-request'),
'should register byok-ai-request',
);
assert.ok(
ipcMain.handlers.has('byok-ai-request-status'),
'should register byok-ai-request-status',
);
});
it('registered handlers are functions', () => {
registerByokIpcHandlers(ipcMain);
assert.equal(
typeof ipcMain.handlers.get('byok-ai-request'),
'function',
);
assert.equal(
typeof ipcMain.handlers.get('byok-ai-request-status'),
'function',
);
});
it('rejects unknown mode (chat path, real modules)', async () => {
registerByokIpcHandlers(ipcMain);
await assert.rejects(
() =>
ipcMain.invoke('byok-ai-request', {
mode: 'nonexistent-mode',
messages: [],
aiConfiguration: {},
}),
(err) => {
assert.ok(err instanceof ByokError);
assert.equal(err.provider, 'BYOK');
assert.equal(err.code, 'UNKNOWN');
assert.ok(err.message.includes('nonexistent-mode'));
return true;
},
);
});
it('rejects null payload (chat path, real modules)', async () => {
registerByokIpcHandlers(ipcMain);
await assert.rejects(
() => ipcMain.invoke('byok-ai-request', null),
(err) => {
assert.ok(err instanceof ByokError);
return true;
},
);
});
it('returns { found: false } for unknown status request', () => {
registerByokIpcHandlers(ipcMain);
// status handler is sync, invoke it directly
const handler = ipcMain.handlers.get('byok-ai-request-status');
const result = handler(makeMockEvent(), 'nonexistent');
assert.equal(result.found, false);
});
});
// ══════════════════════════════════════════════════════════════════════
// Sabotage / error-path integration tests
// ══════════════════════════════════════════════════════════════════════
describe('IPC integration — sabotage & error paths', () => {
let ipcMain;
beforeEach(() => {
mock.reset();
ipcMain = makeMockIpcMain();
});
it('chat mode with deliberately bad URL → ENDPOINT_UNREACHABLE', async () => {
// Register with the real _createHandlers (uses real callLLM etc.)
registerByokIpcHandlers(ipcMain);
// A URL that will definitely fail to resolve/connect
const badConfig = {
endpoint: 'http://127.0.0.1:19999/v1', // nothing listening
model: 'test-model',
timeout: 500, // short timeout so test doesn't hang
};
await assert.rejects(
() =>
ipcMain.invoke('byok-ai-request', {
mode: 'chat',
messages: [{ role: 'user', content: 'hello' }],
aiConfiguration: badConfig,
}),
(err) => {
assert.ok(err instanceof ByokError);
assert.equal(err.code, 'ENDPOINT_UNREACHABLE');
return true;
},
);
});
it('chat mode with bad URL and verbose error shape', async () => {
registerByokIpcHandlers(ipcMain);
const badConfig = {
endpoint: 'http://127.0.0.1:19998/v1',
model: 'test-model',
timeout: 500,
};
try {
await ipcMain.invoke('byok-ai-request', {
mode: 'chat',
messages: [],
aiConfiguration: badConfig,
});
assert.fail('expected rejection');
} catch (err) {
assert.ok(err instanceof ByokError);
// Verify error has provider + code + userMessage shape
assert.ok(typeof err.provider === 'string');
assert.ok(typeof err.code === 'string');
assert.ok(typeof err.userMessage === 'string');
assert.ok(err.userMessage.includes(err.provider));
assert.ok(err.userMessage.length > 10);
}
});
it('chat mode with empty endpoint → ENDPOINT_UNREACHABLE', async () => {
registerByokIpcHandlers(ipcMain);
await assert.rejects(
() =>
ipcMain.invoke('byok-ai-request', {
mode: 'chat',
messages: [{ role: 'user', content: 'test' }],
aiConfiguration: {
endpoint: '',
model: 'any',
timeout: 500,
},
}),
(err) => {
assert.ok(err instanceof ByokError);
assert.equal(err.code, 'ENDPOINT_UNREACHABLE');
return true;
},
);
});
});
// ══════════════════════════════════════════════════════════════════════
// Handler composition — real proxy modules (no mocks)
// ══════════════════════════════════════════════════════════════════════
describe('IPC integration — handler composition with real modules', () => {
let handlers;
beforeEach(() => {
mock.reset();
handlers = _createHandlers(); // uses real callLLM, buildSystemPrompt, requestStore
});
it('byokAiRequest rejects with ByokError for chat + unreachable endpoint', async () => {
await assert.rejects(
() =>
handlers.byokAiRequest(makeMockEvent(), {
mode: 'chat',
messages: [{ role: 'user', content: 'ping' }],
aiConfiguration: {
endpoint: 'http://127.0.0.1:19997/v1',
model: 'gpt-4',
timeout: 500,
},
}),
(err) => {
assert.ok(err instanceof ByokError);
assert.equal(err.code, 'ENDPOINT_UNREACHABLE');
return true;
},
);
});
it('byokAiRequest returns { requestId } for agent mode (store integration)', async () => {
// Agent mode creates a store entry and returns a requestId.
// The actual callLLM will fail (no server), but the handler
// should return immediately with a requestId.
const result = await handlers.byokAiRequest(makeMockEvent(), {
mode: 'agent',
messages: [{ role: 'user', content: 'do something' }],
aiConfiguration: {
endpoint: 'http://127.0.0.1:19996/v1',
model: 'local-model',
timeout: 500,
},
});
assert.ok(typeof result.requestId === 'string');
assert.ok(result.requestId.length > 0);
// Give the fire-and-forget time to fail
await new Promise((r) => setTimeout(r, 100));
// The status should have transitioned to 'error' (because nothing is listening)
const statusResult = handlers.byokAiRequestStatus(
makeMockEvent(),
result.requestId,
);
assert.equal(statusResult.found, true);
assert.equal(statusResult.status, 'error');
assert.ok(statusResult.error, 'should have error detail');
assert.ok(
statusResult.error.message || statusResult.error.code,
'error should have message or code',
);
});
it('byokAiRequest returns { requestId } for orchestrator mode', async () => {
const result = await handlers.byokAiRequest(makeMockEvent(), {
mode: 'orchestrator',
messages: [{ role: 'user', content: 'plan this' }],
aiConfiguration: {
endpoint: 'http://127.0.0.1:19995/v1',
model: 'local-model',
timeout: 500,
},
});
assert.ok(typeof result.requestId === 'string');
});
it('requestStatus returns { found: false } for unknown id', () => {
const result = handlers.byokAiRequestStatus(
makeMockEvent(),
'never-created-this-id',
);
assert.equal(result.found, false);
});
it('requestStatus returns full shape for existing entry', async () => {
// Create via agent mode
const { requestId } = await handlers.byokAiRequest(makeMockEvent(), {
mode: 'agent',
messages: [{ role: 'user', content: 'test' }],
aiConfiguration: {
endpoint: 'http://127.0.0.1:19994/v1',
model: 'test',
timeout: 500,
},
});
const result = handlers.byokAiRequestStatus(makeMockEvent(), requestId);
assert.equal(result.found, true);
assert.equal(result.id, requestId);
assert.ok(['working', 'error'].includes(result.status));
assert.ok(typeof result.createdAt === 'string');
assert.ok(typeof result.updatedAt === 'string');
assert.ok(Array.isArray(result.output));
});
});
// ══════════════════════════════════════════════════════════════════════
// Error shape consistency — ByokError surface contract
// ══════════════════════════════════════════════════════════════════════
describe('IPC integration — ByokError surface contract', () => {
it('ByokError has provider, code, userMessage, name, message', () => {
const err = new ByokError('TestProvider', 'INVALID_KEY');
assert.equal(err.name, 'ByokError');
assert.equal(err.provider, 'TestProvider');
assert.equal(err.code, 'INVALID_KEY');
assert.ok(typeof err.userMessage === 'string');
assert.ok(err.userMessage.includes('TestProvider'));
assert.ok(err.userMessage.includes('key'));
assert.equal(err.message, err.userMessage);
assert.ok(err instanceof Error);
});
it('ByokError.codeFromStatus maps HTTP statuses correctly', () => {
assert.equal(ByokError.codeFromStatus(401), 'INVALID_KEY');
assert.equal(ByokError.codeFromStatus(404), 'MODEL_NOT_FOUND');
assert.equal(ByokError.codeFromStatus(429), 'RATE_LIMITED');
assert.equal(ByokError.codeFromStatus(500), 'UNKNOWN');
assert.equal(ByokError.codeFromStatus(200), 'UNKNOWN');
});
it('ByokError.codeFromFetchError returns ENDPOINT_UNREACHABLE', () => {
assert.equal(
ByokError.codeFromFetchError(new Error('ECONNREFUSED')),
'ENDPOINT_UNREACHABLE',
);
const abort = new Error('The operation was aborted');
abort.name = 'AbortError';
assert.equal(ByokError.codeFromFetchError(abort), 'ENDPOINT_UNREACHABLE');
});
it('ByokError can override userMessage', () => {
const err = new ByokError('X', 'UNKNOWN', 'custom message here');
assert.equal(err.message, 'custom message here');
assert.equal(err.userMessage, 'custom message here');
});
});
// ══════════════════════════════════════════════════════════════════════
// Patch fixtures: proxy modules exist and are loadable
// ══════════════════════════════════════════════════════════════════════
describe('IPC integration — proxy module liveness', () => {
it('callLLM is a function', () => {
const { callLLM } = require('../../src/proxy/callLLM');
assert.equal(typeof callLLM, 'function');
});
it('buildSystemPrompt is a function', () => {
const { buildSystemPrompt } = require('../../src/proxy/buildSystemPrompt');
assert.equal(typeof buildSystemPrompt, 'function');
});
it('requestStore has create, get, update', () => {
const store = require('../../src/proxy/requestStore');
assert.equal(typeof store.create, 'function');
assert.equal(typeof store.get, 'function');
assert.equal(typeof store.update, 'function');
});
it('errors exports ByokError and ERROR_CODES', () => {
const mod = require('../../src/proxy/errors');
assert.equal(typeof mod.ByokError, 'function');
assert.ok(typeof mod.ERROR_CODES === 'object');
assert.ok('INVALID_KEY' in mod.ERROR_CODES);
});
it('byokHandler exports registerByokIpcHandlers and _createHandlers', () => {
const mod = require('../../src/ipc/byokHandler');
assert.equal(typeof mod.registerByokIpcHandlers, 'function');
assert.equal(typeof mod._createHandlers, 'function');
});
});
+681
View File
@@ -0,0 +1,681 @@
'use strict';
/**
* Unit tests for byokConfig.js — config persistence with safeStorage encryption.
*
* Covers:
* - Tiered storage (electron-store path + fs JSON fallback)
* - apiKey round-trip (encrypt → hex → decrypt → original)
* - Decryption failure (corrupted / plain-text mismatch → apiKey=undefined)
* - safeStorage unavailable (plain-text storage with console.warn)
* - Edge cases (empty config, partial config, redaction)
*
* Run: node --test test/unit/byok-config.test.js
*/
const { describe, it, beforeEach, afterEach, mock } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
// ══════════════════════════════════════════════════════════════════════
// Module under test
// ══════════════════════════════════════════════════════════════════════
const {
_getByokConfig,
_setByokConfig,
_createByokConfigStore,
} = require('../../src/proxy/byokConfig');
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
/**
* Create a mock safeStorage object where encrypt/decrypt is a simple
* reversible wrapper so we can verify round-trip correctness.
*/
function makeMockSafeStorage() {
return {
isEncryptionAvailable: mock.fn(() => true),
encryptString: mock.fn((plainText) => {
return Buffer.from('SAFESTORAGE:' + plainText, 'utf-8');
}),
decryptString: mock.fn((buf) => {
const str = buf.toString('utf-8');
if (str.startsWith('SAFESTORAGE:')) {
return str.slice('SAFESTORAGE:'.length);
}
throw new Error('Decryption failed: not a valid safeStorage buffer');
}),
};
}
/**
* Create a mock safeStorage where encryption is NOT available.
*/
function makeUnavailableSafeStorage() {
return {
isEncryptionAvailable: mock.fn(() => false),
encryptString: mock.fn(() => {
throw new Error('encryptString called when encryption unavailable');
}),
decryptString: mock.fn(() => {
throw new Error('decryptString called when encryption unavailable');
}),
};
}
/**
* Build a simple in-memory store with get / set / delete.
*/
function makeInMemoryStore(initial = {}) {
const data = { ...initial };
return {
data,
get: mock.fn((key) => data[key]),
set: mock.fn((key, value) => {
data[key] = value;
}),
delete: mock.fn((key) => {
delete data[key];
}),
};
}
/**
* Create a temp directory and return its path.
*/
let _tempDirs = [];
function makeTempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'byok-config-test-'));
_tempDirs.push(dir);
return dir;
}
afterEach(async () => {
for (const dir of _tempDirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
_tempDirs = [];
mock.reset();
});
// ══════════════════════════════════════════════════════════════════════
// _getByokConfig
// ══════════════════════════════════════════════════════════════════════
describe('_getByokConfig', () => {
describe('with safeStorage available', () => {
let safeStorage;
beforeEach(() => {
safeStorage = makeMockSafeStorage();
});
it('returns default config when store is empty', () => {
const store = makeInMemoryStore();
const config = _getByokConfig(store, { safeStorage });
assert.equal(config.provider, '');
assert.equal(config.endpoint, '');
assert.equal(config.model, '');
assert.equal(config.apiKey, '');
});
it('reads non-sensitive fields directly from store', () => {
const store = makeInMemoryStore({
'byok.provider': 'openai',
'byok.endpoint': 'https://api.openai.com/v1',
'byok.model': 'gpt-4o',
});
const config = _getByokConfig(store, { safeStorage });
assert.equal(config.provider, 'openai');
assert.equal(config.endpoint, 'https://api.openai.com/v1');
assert.equal(config.model, 'gpt-4o');
assert.equal(config.apiKey, '');
});
it('decrypts hex-encoded apiKey back to original value', () => {
const plainKey = 'sk-test-secret-12345';
const encrypted = safeStorage.encryptString(plainKey);
const hex = encrypted.toString('hex');
const store = makeInMemoryStore({ 'byok.apiKey': hex });
const config = _getByokConfig(store, { safeStorage });
assert.equal(config.apiKey, plainKey);
});
it('returns apiKey="" when decryption fails', () => {
// Store raw hex that isn't a valid safeStorage buffer
const store = makeInMemoryStore({
'byok.apiKey': Buffer.from('not-encrypted', 'utf-8').toString('hex'),
});
const config = _getByokConfig(store, { safeStorage });
assert.equal(config.apiKey, '');
});
it('returns apiKey="" when stored value is empty string', () => {
const store = makeInMemoryStore({ 'byok.apiKey': '' });
const config = _getByokConfig(store, { safeStorage });
assert.equal(config.apiKey, '');
});
});
describe('with safeStorage unavailable', () => {
let safeStorage;
beforeEach(() => {
safeStorage = makeUnavailableSafeStorage();
});
it('returns plain-text apiKey when safeStorage is unavailable', () => {
const plainKey = 'sk-plain-text-key';
const store = makeInMemoryStore({ 'byok.apiKey': plainKey });
const config = _getByokConfig(store, { safeStorage });
assert.equal(config.apiKey, plainKey);
});
it('returns empty apiKey when nothing stored', () => {
const store = makeInMemoryStore({});
const config = _getByokConfig(store, { safeStorage });
assert.equal(config.apiKey, '');
});
});
describe('with no safeStorage at all', () => {
it('returns stored value as-is when safeStorage is missing entirely', () => {
const store = makeInMemoryStore({
'byok.apiKey': 'some-key-plain',
});
const config = _getByokConfig(store, { safeStorage: null });
// null safeStorage → branch goes to "else" → returns stored value
assert.equal(config.apiKey, 'some-key-plain');
});
});
});
// ══════════════════════════════════════════════════════════════════════
// _setByokConfig
// ══════════════════════════════════════════════════════════════════════
describe('_setByokConfig', () => {
describe('with safeStorage available', () => {
let safeStorage;
beforeEach(() => {
safeStorage = makeMockSafeStorage();
});
it('writes non-sensitive fields directly to store', () => {
const store = makeInMemoryStore();
_setByokConfig(store, {
provider: 'anthropic',
endpoint: 'https://api.anthropic.com/v1',
apiKey: 'sk-ant-secret',
model: 'claude-sonnet-4-20250514',
}, { safeStorage });
assert.equal(store.data['byok.provider'], 'anthropic');
assert.equal(store.data['byok.endpoint'], 'https://api.anthropic.com/v1');
assert.equal(store.data['byok.model'], 'claude-sonnet-4-20250514');
// apiKey should be encrypted hex, not plain text
const storedKey = store.data['byok.apiKey'];
assert.ok(storedKey);
assert.notEqual(storedKey, 'sk-ant-secret');
assert.ok(/^[0-9a-f]+$/i.test(storedKey));
});
it('encrypts apiKey via safeStorage and stores as hex', () => {
const store = makeInMemoryStore();
const plainKey = 'sk-encrypt-test-67890';
_setByokConfig(store, {
provider: 'openai',
endpoint: '',
apiKey: plainKey,
model: '',
}, { safeStorage });
const hexStored = store.data['byok.apiKey'];
assert.ok(hexStored);
// Decode and verify round-trip
const encrypted = Buffer.from(hexStored, 'hex');
const decrypted = safeStorage.decryptString(encrypted);
assert.equal(decrypted, plainKey);
});
it('stores empty string for empty apiKey', () => {
const store = makeInMemoryStore();
_setByokConfig(store, {
provider: 'ollama',
endpoint: 'http://localhost:11434',
apiKey: '',
model: 'llama3.1:8b',
}, { safeStorage });
assert.equal(store.data['byok.apiKey'], '');
});
it('handles undefined apiKey gracefully', () => {
const store = makeInMemoryStore();
_setByokConfig(store, {
provider: 'ollama',
endpoint: '',
apiKey: undefined,
model: '',
}, { safeStorage });
assert.equal(store.data['byok.apiKey'], '');
});
});
describe('with safeStorage unavailable', () => {
let safeStorage;
beforeEach(() => {
safeStorage = makeUnavailableSafeStorage();
});
it('stores apiKey in plain text when safeStorage unavailable', () => {
const store = makeInMemoryStore();
const plainKey = 'sk-plain-fallback-key';
_setByokConfig(store, {
provider: 'custom',
endpoint: 'http://localhost:1234/v1',
apiKey: plainKey,
model: 'local-model',
}, { safeStorage });
assert.equal(store.data['byok.apiKey'], plainKey);
});
});
});
// ══════════════════════════════════════════════════════════════════════
// API key round-trip
// ══════════════════════════════════════════════════════════════════════
describe('API key round-trip', () => {
let safeStorage;
beforeEach(() => {
safeStorage = makeMockSafeStorage();
});
it('set → get returns the original apiKey', () => {
const store = makeInMemoryStore();
const original = {
provider: 'openrouter',
endpoint: 'https://openrouter.ai/api/v1',
apiKey: 'sk-or-v1-abcdefghijklmnopqrstuvwxyz123456',
model: 'openai/gpt-4o',
};
_setByokConfig(store, original, { safeStorage });
const roundTripped = _getByokConfig(store, { safeStorage });
assert.equal(roundTripped.provider, original.provider);
assert.equal(roundTripped.endpoint, original.endpoint);
assert.equal(roundTripped.model, original.model);
assert.equal(roundTripped.apiKey, original.apiKey);
});
it('round-trips with special characters in apiKey', () => {
const store = makeInMemoryStore();
const specialKey = 'sk-!@#$%^&*()_+-=[]{}|;:\'",.<>?/~`';
_setByokConfig(store, {
provider: 'custom',
endpoint: '',
apiKey: specialKey,
model: '',
}, { safeStorage });
const config = _getByokConfig(store, { safeStorage });
assert.equal(config.apiKey, specialKey);
});
it('round-trips with unicode in apiKey', () => {
const store = makeInMemoryStore();
const unicodeKey = 'sk-\u6d4b\u8bd5-\u30ad\u30fc-\U0001f3ae-\u30b2\u30fc\u30e0';
_setByokConfig(store, {
provider: 'custom',
endpoint: '',
apiKey: unicodeKey,
model: '',
}, { safeStorage });
const config = _getByokConfig(store, { safeStorage });
assert.equal(config.apiKey, unicodeKey);
});
it('round-trips empty apiKey', () => {
const store = makeInMemoryStore();
const config = {
provider: 'ollama',
endpoint: 'http://localhost:11434',
apiKey: '',
model: 'llama3.1:8b',
};
_setByokConfig(store, config, { safeStorage });
const result = _getByokConfig(store, { safeStorage });
assert.equal(result.apiKey, '');
});
it('set → get with safeStorage unavailable stores and returns plain text', () => {
const unavailSS = makeUnavailableSafeStorage();
const store = makeInMemoryStore();
const plainKey = 'sk-fallback-key-plaintext';
_setByokConfig(store, {
provider: 'custom',
endpoint: '',
apiKey: plainKey,
model: '',
}, { safeStorage: unavailSS });
assert.equal(store.data['byok.apiKey'], plainKey);
const config = _getByokConfig(store, { safeStorage: unavailSS });
assert.equal(config.apiKey, plainKey);
});
});
// ══════════════════════════════════════════════════════════════════════
// _createByokConfigStore
// ══════════════════════════════════════════════════════════════════════
describe('_createByokConfigStore', () => {
describe('electron-store path', () => {
it('returns an electron-store instance when provided via deps', () => {
const mockStoreInstance = makeInMemoryStore();
const MockStore = mock.fn(function (opts) {
return mockStoreInstance;
});
const store = _createByokConfigStore({ electronStore: MockStore });
assert.equal(store, mockStoreInstance);
});
it('store has get/set/delete methods', () => {
const mockStoreInstance = makeInMemoryStore();
const MockStore = mock.fn(function () { return mockStoreInstance; });
const store = _createByokConfigStore({ electronStore: MockStore });
assert.equal(typeof store.get, 'function');
assert.equal(typeof store.set, 'function');
assert.equal(typeof store.delete, 'function');
});
});
describe('fs JSON fallback path', () => {
it('creates an fs JSON file store when electron-store is not provided', () => {
const tmpDir = makeTempDir();
const filePath = path.join(tmpDir, 'test-config.json');
// No electronStore dep — will try native require which fails, fall back to fs
const store = _createByokConfigStore({}, { filePath });
assert.equal(typeof store.get, 'function');
assert.equal(typeof store.set, 'function');
assert.equal(typeof store.delete, 'function');
});
it('persists data to the JSON file', () => {
const tmpDir = makeTempDir();
const filePath = path.join(tmpDir, 'persist-test.json');
const store = _createByokConfigStore({}, { filePath });
store.set('test.key', 'test-value');
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw);
assert.equal(parsed['test.key'], 'test-value');
});
it('reads previously persisted data', () => {
const tmpDir = makeTempDir();
const filePath = path.join(tmpDir, 'read-back-test.json');
fs.writeFileSync(filePath, JSON.stringify({ 'pre.key': 'pre-value' }, null, 2), 'utf-8');
const store = _createByokConfigStore({}, { filePath });
assert.equal(store.get('pre.key'), 'pre-value');
});
it('handles corrupt JSON file gracefully', () => {
const tmpDir = makeTempDir();
const filePath = path.join(tmpDir, 'corrupt.json');
fs.writeFileSync(filePath, 'not valid json {{{', 'utf-8');
const store = _createByokConfigStore({}, { filePath });
// Should not throw; should start fresh
assert.equal(store.get('anything'), undefined);
});
it('creates parent directories if needed', () => {
const tmpDir = makeTempDir();
const filePath = path.join(tmpDir, 'nested', 'dir', 'config.json');
const store = _createByokConfigStore({}, { filePath });
store.set('key', 'value');
assert.ok(fs.existsSync(filePath));
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw);
assert.equal(parsed['key'], 'value');
});
});
});
// ══════════════════════════════════════════════════════════════════════
// Observability: apiKey never logged
// ══════════════════════════════════════════════════════════════════════
describe('observability', () => {
let safeStorage;
beforeEach(() => {
safeStorage = makeMockSafeStorage();
});
it('_setByokConfig logs field names but not apiKey value', () => {
const store = makeInMemoryStore();
const consoleLogCalls = [];
const origLog = console.log;
console.log = mock.fn((...args) => {
consoleLogCalls.push(args.join(' '));
});
try {
_setByokConfig(store, {
provider: 'openai',
endpoint: 'https://api.openai.com/v1',
apiKey: 'sk-super-secret-do-not-log',
model: 'gpt-4o',
}, { safeStorage });
const allLogs = consoleLogCalls.join('\n');
assert.ok(
!allLogs.includes('sk-super-secret-do-not-log'),
'apiKey value must not appear in logs',
);
} finally {
console.log = origLog;
}
});
it('_getByokConfig logs field names but not apiKey value', () => {
const plainKey = 'sk-another-secret';
const encrypted = safeStorage.encryptString(plainKey);
const hex = encrypted.toString('hex');
const store = makeInMemoryStore({ 'byok.apiKey': hex });
const consoleLogCalls = [];
const origLog = console.log;
console.log = mock.fn((...args) => {
consoleLogCalls.push(args.join(' '));
});
try {
_getByokConfig(store, { safeStorage });
const allLogs = consoleLogCalls.join('\n');
assert.ok(
!allLogs.includes(plainKey),
'apiKey value must not appear in logs',
);
} finally {
console.log = origLog;
}
});
it('decryption failure logs warning without exposing raw value', () => {
const store = makeInMemoryStore({
'byok.apiKey': Buffer.from('corrupted-data', 'utf-8').toString('hex'),
});
const warnCalls = [];
const origWarn = console.warn;
console.warn = mock.fn((...args) => {
warnCalls.push(args.join(' '));
});
try {
const config = _getByokConfig(store, { safeStorage });
assert.equal(config.apiKey, '');
assert.ok(warnCalls.length >= 1, 'should log a warning on decryption failure');
const warnMsg = warnCalls.join('\n');
assert.ok(warnMsg.includes('Decryption failed'), 'warning should mention decryption failure');
assert.ok(
!warnMsg.includes('corrupted-data'),
'warning must not expose the raw stored value',
);
} finally {
console.warn = origWarn;
}
});
it('safeStorage unavailable warning is logged on set', () => {
const store = makeInMemoryStore();
const unavailSS = makeUnavailableSafeStorage();
const warnCalls = [];
const origWarn = console.warn;
console.warn = mock.fn((...args) => {
warnCalls.push(args.join(' '));
});
try {
_setByokConfig(store, {
provider: 'custom',
endpoint: '',
apiKey: 'sk-plain-warn',
model: '',
}, { safeStorage: unavailSS });
assert.ok(warnCalls.length >= 1, 'should log a warning when safeStorage unavailable');
const warnMsg = warnCalls.join('\n');
assert.ok(warnMsg.includes('safeStorage is not available'), 'warning should mention safeStorage');
} finally {
console.warn = origWarn;
}
});
});
// ══════════════════════════════════════════════════════════════════════
// Edge cases
// ══════════════════════════════════════════════════════════════════════
describe('edge cases', () => {
let safeStorage;
beforeEach(() => {
safeStorage = makeMockSafeStorage();
});
it('setByokConfig with partial config fills defaults', () => {
const store = makeInMemoryStore();
_setByokConfig(store, { provider: 'openai' }, { safeStorage });
assert.equal(store.data['byok.provider'], 'openai');
assert.equal(store.data['byok.endpoint'], '');
assert.equal(store.data['byok.model'], '');
assert.equal(store.data['byok.apiKey'], '');
});
it('setByokConfig with empty object writes defaults', () => {
const store = makeInMemoryStore();
_setByokConfig(store, {}, { safeStorage });
assert.equal(store.data['byok.provider'], '');
assert.equal(store.data['byok.endpoint'], '');
assert.equal(store.data['byok.model'], '');
assert.equal(store.data['byok.apiKey'], '');
});
it('getByokConfig with missing keys returns defaults', () => {
const store = makeInMemoryStore({
'byok.provider': 'openai',
});
const config = _getByokConfig(store, { safeStorage });
assert.equal(config.provider, 'openai');
assert.equal(config.endpoint, '');
assert.equal(config.model, '');
assert.equal(config.apiKey, '');
});
it('multiple set → get cycles are independent', () => {
const store = makeInMemoryStore();
_setByokConfig(store, {
provider: 'openai',
endpoint: 'https://api.openai.com/v1',
apiKey: 'sk-first-key',
model: 'gpt-4o',
}, { safeStorage });
const first = _getByokConfig(store, { safeStorage });
assert.equal(first.provider, 'openai');
assert.equal(first.apiKey, 'sk-first-key');
_setByokConfig(store, {
provider: 'anthropic',
endpoint: 'https://api.anthropic.com/v1',
apiKey: 'sk-second-key',
model: 'claude-sonnet-4-20250514',
}, { safeStorage });
const second = _getByokConfig(store, { safeStorage });
assert.equal(second.provider, 'anthropic');
assert.equal(second.apiKey, 'sk-second-key');
});
it('fs store delete removes key', () => {
const tmpDir = makeTempDir();
const filePath = path.join(tmpDir, 'delete-test.json');
const store = _createByokConfigStore({}, { filePath });
store.set('key.to.delete', 'value');
assert.equal(store.get('key.to.delete'), 'value');
store.delete('key.to.delete');
assert.equal(store.get('key.to.delete'), undefined);
});
});
+494
View File
@@ -0,0 +1,494 @@
'use strict';
/**
* Unit tests for the BYOK IPC handler and preload bridge.
*
* Exercises mode routing ('chat', 'agent', 'orchestrator'),
* status polling, error propagation, and the fire-and-forget
* store lifecycle — all with mocked dependencies.
*
* Run: node --test test/unit/ipc.test.js
*/
const { describe, it, beforeEach, mock } = require('node:test');
const assert = require('node:assert/strict');
// ── Module under test ────────────────────────────────────────────────
const { _createHandlers } = require('../../src/ipc/byokHandler');
const { ByokError } = require('../../src/proxy/errors');
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
function makeMockEvent() {
return {}; // the handler only uses `event` for logging (doesn't touch it)
}
/**
* Create a minimal mock requestStore whose methods can be spied.
*/
function makeMockRequestStore() {
const entries = new Map();
return {
entries,
create: mock.fn((req) => {
const id = 'mock-req-' + Math.random().toString(36).slice(2, 8);
const entry = {
id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId: req.userId || 'byok-user',
gameId: req.gameId || null,
status: 'working',
mode: req.mode || 'chat',
aiConfiguration: req.aiConfiguration || {},
output: [],
totalPriceInCredits: 0,
error: null,
};
entries.set(id, entry);
return id;
}),
get: mock.fn((id) => entries.get(id)),
update: mock.fn((id, partial) => {
const entry = entries.get(id);
if (!entry) return undefined;
Object.assign(entry, partial, {
updatedAt: new Date().toISOString(),
});
return entry;
}),
};
}
/**
* Create a mock callLLM that returns a canned result after a tick.
*/
function makeMockCallLLM(result) {
return mock.fn(async () => {
// Yield a microtick so fire-and-forget .then / .catch can attach
await new Promise((r) => setTimeout(r, 5));
if (result instanceof Error) throw result;
return result;
});
}
// ══════════════════════════════════════════════════════════════════════
// byokAiRequest mode routing
// ══════════════════════════════════════════════════════════════════════
describe('byokHandler — mode routing', () => {
let handlers;
let mockCallLLM;
let mockStore;
beforeEach(() => {
// Reset all tracked mocks between tests
mock.reset();
});
describe('chat mode', () => {
it('assembles system prompt + messages and returns { content, usage }', async () => {
const cannedLLM = {
content: 'Hello from AI',
usage: { prompt_tokens: 10, completion_tokens: 5 },
};
const _callLLM = makeMockCallLLM(cannedLLM);
const _requestStore = makeMockRequestStore();
handlers = _createHandlers({
callLLM: _callLLM,
requestStore: _requestStore,
});
const result = await handlers.byokAiRequest(makeMockEvent(), {
mode: 'chat',
messages: [{ role: 'user', content: 'Hi' }],
userRequest: 'help me',
aiConfiguration: { endpoint: 'http://localhost:1234/v1', model: 'test' },
});
assert.equal(result.content, 'Hello from AI');
assert.deepEqual(result.usage, { prompt_tokens: 10, completion_tokens: 5 });
// callLLM was invoked exactly once
assert.equal(_callLLM.mock.callCount(), 1);
// The messages passed to callLLM include the assembled system prompt
const calledMessages = _callLLM.mock.calls[0].arguments[1];
assert.equal(calledMessages[0].role, 'system');
assert.ok(
calledMessages[0].content.includes('AI game development assistant'),
'system prompt should contain the base prompt',
);
assert.ok(
calledMessages[0].content.includes('help me'),
'system prompt should include userRequest',
);
assert.equal(calledMessages[1].role, 'user');
assert.equal(calledMessages[1].content, 'Hi');
});
it('passes aiConfiguration as the first argument to callLLM', async () => {
const _callLLM = makeMockCallLLM({ content: 'ok', usage: {} });
const _requestStore = makeMockRequestStore();
handlers = _createHandlers({ callLLM: _callLLM, requestStore: _requestStore });
const cfg = { endpoint: 'https://api.example.com/v1', apiKey: 'sk-test', model: 'gpt-4' };
await handlers.byokAiRequest(makeMockEvent(), {
mode: 'chat',
messages: [],
aiConfiguration: cfg,
});
const configArg = _callLLM.mock.calls[0].arguments[0];
assert.deepEqual(configArg, cfg);
});
it('handles empty messages array', async () => {
const _callLLM = makeMockCallLLM({ content: 'system-only response', usage: {} });
const _requestStore = makeMockRequestStore();
handlers = _createHandlers({ callLLM: _callLLM, requestStore: _requestStore });
const result = await handlers.byokAiRequest(makeMockEvent(), {
mode: 'chat',
messages: [],
aiConfiguration: { endpoint: 'http://localhost/v1', model: 'llama' },
});
assert.equal(result.content, 'system-only response');
const calledMessages = _callLLM.mock.calls[0].arguments[1];
assert.equal(calledMessages.length, 1); // only system message
assert.equal(calledMessages[0].role, 'system');
});
it('propagates ByokError from callLLM', async () => {
const _callLLM = makeMockCallLLM(
new ByokError('OpenAI', 'INVALID_KEY'),
);
const _requestStore = makeMockRequestStore();
handlers = _createHandlers({ callLLM: _callLLM, requestStore: _requestStore });
await assert.rejects(
() =>
handlers.byokAiRequest(makeMockEvent(), {
mode: 'chat',
messages: [],
aiConfiguration: {},
}),
(err) => {
assert.ok(err instanceof ByokError);
assert.equal(err.provider, 'OpenAI');
assert.equal(err.code, 'INVALID_KEY');
return true;
},
);
});
it('propagates generic errors from callLLM', async () => {
const _callLLM = makeMockCallLLM(new Error('network down'));
const _requestStore = makeMockRequestStore();
handlers = _createHandlers({ callLLM: _callLLM, requestStore: _requestStore });
await assert.rejects(
() =>
handlers.byokAiRequest(makeMockEvent(), {
mode: 'chat',
messages: [],
aiConfiguration: {},
}),
/network down/,
);
});
});
describe('agent / orchestrator mode (fire-and-forget)', () => {
it('returns { requestId } immediately for agent mode', async () => {
const _callLLM = makeMockCallLLM({ content: 'async-result', usage: {} });
const _requestStore = makeMockRequestStore();
handlers = _createHandlers({ callLLM: _callLLM, requestStore: _requestStore });
const result = await handlers.byokAiRequest(makeMockEvent(), {
mode: 'agent',
messages: [{ role: 'user', content: 'do something' }],
aiConfiguration: { endpoint: 'http://localhost/v1', model: 'llama' },
});
assert.ok(typeof result.requestId === 'string');
assert.ok(result.requestId.startsWith('mock-req-'));
// requestStore.create was called
assert.equal(_requestStore.create.mock.callCount(), 1);
});
it('returns { requestId } immediately for orchestrator mode', async () => {
const _callLLM = makeMockCallLLM({ content: 'orchestrated', usage: {} });
const _requestStore = makeMockRequestStore();
handlers = _createHandlers({ callLLM: _callLLM, requestStore: _requestStore });
const result = await handlers.byokAiRequest(makeMockEvent(), {
mode: 'orchestrator',
messages: [{ role: 'user', content: 'plan' }],
aiConfiguration: {},
});
assert.ok(typeof result.requestId === 'string');
});
it('updates requestStore to "ready" after async callLLM succeeds', async () => {
const _callLLM = makeMockCallLLM({ content: 'done', usage: { prompt_tokens: 1, completion_tokens: 1 } });
const _requestStore = makeMockRequestStore();
handlers = _createHandlers({ callLLM: _callLLM, requestStore: _requestStore });
const { requestId } = await handlers.byokAiRequest(makeMockEvent(), {
mode: 'agent',
messages: [{ role: 'user', content: 'x' }],
aiConfiguration: {},
});
// The async update happens after a microtask; wait for it.
await new Promise((r) => setTimeout(r, 20));
const updateCalls = _requestStore.update.mock.calls.filter(
(c) => c.arguments[0] === requestId,
);
assert.ok(updateCalls.length >= 1, 'expected at least one update call');
const lastUpdate = updateCalls[updateCalls.length - 1];
assert.equal(lastUpdate.arguments[1].status, 'ready');
assert.deepEqual(lastUpdate.arguments[1].output, [
{ content: 'done', usage: { prompt_tokens: 1, completion_tokens: 1 } },
]);
});
it('updates requestStore to "error" after async callLLM fails', async () => {
const _callLLM = makeMockCallLLM(
new ByokError('Ollama', 'ENDPOINT_UNREACHABLE'),
);
const _requestStore = makeMockRequestStore();
handlers = _createHandlers({ callLLM: _callLLM, requestStore: _requestStore });
const { requestId } = await handlers.byokAiRequest(makeMockEvent(), {
mode: 'orchestrator',
messages: [{ role: 'user', content: 'fail-test' }],
aiConfiguration: {},
});
await new Promise((r) => setTimeout(r, 20));
const updateCalls = _requestStore.update.mock.calls.filter(
(c) => c.arguments[0] === requestId,
);
const lastUpdate = updateCalls[updateCalls.length - 1];
assert.equal(lastUpdate.arguments[1].status, 'error');
assert.deepEqual(lastUpdate.arguments[1].error, {
provider: 'Ollama',
code: 'ENDPOINT_UNREACHABLE',
message: 'Ollama: Endpoint unreachable — verify the URL is correct and the service is running.',
});
});
it('stores generic error for non-ByokError failures in fire-and-forget', async () => {
const _callLLM = makeMockCallLLM(new Error('something broke'));
const _requestStore = makeMockRequestStore();
handlers = _createHandlers({ callLLM: _callLLM, requestStore: _requestStore });
const { requestId } = await handlers.byokAiRequest(makeMockEvent(), {
mode: 'agent',
messages: [{ role: 'user', content: 'x' }],
aiConfiguration: {},
});
await new Promise((r) => setTimeout(r, 20));
const updateCalls = _requestStore.update.mock.calls.filter(
(c) => c.arguments[0] === requestId,
);
const lastUpdate = updateCalls[updateCalls.length - 1];
assert.equal(lastUpdate.arguments[1].status, 'error');
assert.deepEqual(lastUpdate.arguments[1].error, {
message: 'something broke',
});
});
});
describe('unknown mode', () => {
it('throws ByokError for unrecognized mode', async () => {
handlers = _createHandlers();
await assert.rejects(
() =>
handlers.byokAiRequest(makeMockEvent(), {
mode: 'bogus',
messages: [],
aiConfiguration: {},
}),
(err) => {
assert.ok(err instanceof ByokError);
assert.equal(err.provider, 'BYOK');
assert.equal(err.code, 'UNKNOWN');
assert.ok(err.message.includes('bogus'));
return true;
},
);
});
});
describe('null / missing payload', () => {
it('handles null payload gracefully', async () => {
const _callLLM = makeMockCallLLM({ content: 'defaulted', usage: {} });
const _requestStore = makeMockRequestStore();
handlers = _createHandlers({ callLLM: _callLLM, requestStore: _requestStore });
// null payload → defaults to undefined mode, which throws UNKNOWN
await assert.rejects(
() => handlers.byokAiRequest(makeMockEvent(), null),
(err) => {
assert.ok(err instanceof ByokError);
assert.equal(err.code, 'UNKNOWN');
return true;
},
);
});
});
});
// ══════════════════════════════════════════════════════════════════════
// byokAiRequestStatus
// ══════════════════════════════════════════════════════════════════════
describe('byokHandler — status polling', () => {
let handlers;
let mockStore;
beforeEach(() => {
mock.reset();
mockStore = makeMockRequestStore();
handlers = _createHandlers({ requestStore: mockStore });
});
it('returns { found: true, ... } for existing entry', () => {
const id = mockStore.create({ userId: 'u1', mode: 'agent' });
const result = handlers.byokAiRequestStatus(makeMockEvent(), id);
assert.equal(result.found, true);
assert.equal(result.id, id);
assert.equal(result.status, 'working');
assert.deepEqual(result.output, []);
assert.equal(result.error, null);
assert.ok(typeof result.createdAt === 'string');
assert.ok(typeof result.updatedAt === 'string');
});
it('returns { found: false } for missing entry', () => {
const result = handlers.byokAiRequestStatus(
makeMockEvent(),
'nonexistent-id',
);
assert.equal(result.found, false);
});
it('reflects updated state after async completion', async () => {
// Simulate: create entry, update it, poll
const id = mockStore.create({ userId: 'u1', mode: 'agent' });
mockStore.update(id, { status: 'ready', output: [{ content: 'ok' }] });
const result = handlers.byokAiRequestStatus(makeMockEvent(), id);
assert.equal(result.found, true);
assert.equal(result.status, 'ready');
assert.deepEqual(result.output, [{ content: 'ok' }]);
});
it('exposes error field when entry has error', () => {
const id = mockStore.create({ userId: 'u1', mode: 'agent' });
mockStore.update(id, {
status: 'error',
error: { provider: 'Test', code: 'INVALID_KEY', message: 'bad key' },
});
const result = handlers.byokAiRequestStatus(makeMockEvent(), id);
assert.deepEqual(result.error, {
provider: 'Test',
code: 'INVALID_KEY',
message: 'bad key',
});
});
});
// ══════════════════════════════════════════════════════════════════════
// Handler registration shape
// ══════════════════════════════════════════════════════════════════════
describe('registerByokIpcHandlers', () => {
it('is a function', () => {
const { registerByokIpcHandlers } = require('../../src/ipc/byokHandler');
assert.equal(typeof registerByokIpcHandlers, 'function');
});
it('registers both handlers on a mock ipcMain', () => {
const { registerByokIpcHandlers } = require('../../src/ipc/byokHandler');
const registered = {};
const mockIpcMain = {
handle: mock.fn((channel, fn) => {
registered[channel] = fn;
}),
};
registerByokIpcHandlers(mockIpcMain);
assert.equal(mockIpcMain.handle.mock.callCount(), 2);
assert.ok(typeof registered['byok-ai-request'] === 'function');
assert.ok(typeof registered['byok-ai-request-status'] === 'function');
});
});
// ══════════════════════════════════════════════════════════════════════
// Preload bridge shape verification
// ══════════════════════════════════════════════════════════════════════
describe('byokBridge', () => {
it('is valid CommonJS (requires without error)', () => {
// The preload bridge requires Electron's contextBridge and
// ipcRenderer at the top level, so it can only truly execute
// inside Electron. But we can still verify the source is
// syntactically sound by checking its shape.
const fs = require('fs');
const path = require('path');
const source = fs.readFileSync(
path.resolve(__dirname, '../../src/preload/byokBridge.js'),
'utf8',
);
// Verify it exports via contextBridge.exposeInMainWorld
assert.ok(
source.includes('contextBridge.exposeInMainWorld'),
'preload must use contextBridge.exposeInMainWorld',
);
assert.ok(
source.includes("'byokAi'"),
'preload must expose "byokAi" key',
);
assert.ok(
source.includes('ipcRenderer.invoke'),
'preload must use ipcRenderer.invoke',
);
assert.ok(
source.includes("'byok-ai-request'"),
'preload must invoke byok-ai-request channel',
);
assert.ok(
source.includes("'byok-ai-request-status'"),
'preload must invoke byok-ai-request-status channel',
);
assert.ok(
source.includes('request:'),
'preload must expose request method',
);
assert.ok(
source.includes('requestStatus:'),
'preload must expose requestStatus method',
);
});
});