mirror of
https://github.com/Heretek-AI/GDevelop-BYOK.git
synced 2026-07-01 18:48:04 -04:00
test: add P012 fixture selectors, byok-embedded bootstrap, and proxy module fixtures
This commit is contained in:
@@ -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\"}"}]
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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,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),
|
||||
});
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
};
|
||||
+42
@@ -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
-1
@@ -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
-1
@@ -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.',
|
||||
},
|
||||
];
|
||||
};
|
||||
+128
@@ -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 };
|
||||
+42
@@ -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.',
|
||||
},
|
||||
];
|
||||
};
|
||||
+46
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user