T08 (Provider Bridge #18): OpenAI-compatible local provider daemon + integration suite

This commit is contained in:
BillyOutlast committed 2026-10-12 00:48:05 -04:00
1 parent 619013652b
commit b44fd9bd81
5 files changed
+2535

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
// Adapter (T08, issue #18): OpenAI <-> OpenCode session/prompt translation
// for the opencode provider daemon.
//
// Pure zero-dependency ESM: no I/O, no network. `server.mjs` owns the HTTP
// surface and the full 39-tool registry (`EDITOR_TOOLS`); this module owns
// the names (kept in step with upstream `GDEVELOP_OPENAI_TOOLS` by the
// integration suite), the function-call <-> tool_calls mapping, and the
// message <-> session translation both directions use.
import crypto from 'node:crypto';
/**
* The 39 GDevelop editor functions the daemon can express as OpenAI
* `tool_calls`. Frozen; the integration suite pins this list against the
* upstream `CustomAIClient.js` registry, so rename/remove only alongside it.
*/
export const EDITOR_FUNCTION_NAMES = Object.freeze([
'create_scene',
'create_or_replace_object',
'add_behavior',
'put_2d_instances',
'add_scene_events',
'run_script',
'read_events_source',
'describe_instances',
'inspect_object_properties_effects',
'inspect_behavior_properties',
'inspect_scene_properties_layers_effects',
'change_scene_properties_layers_effects_groups',
'change_object_properties_effects',
'change_behavior_property',
'change_project_properties_resources',
'inspect_project_properties_resources',
'add_or_edit_variable',
'put_3d_instances',
'inspect_variables',
'read_game_project_json',
'search_object_asset_store',
'search_resource_store',
'search_docs',
'read_full_docs',
'initialize_project',
'get_game_starter_summary',
'create_or_update_plan',
'run_gameplay_test',
'change_gameplay_tests',
'create_extension',
'change_extension_properties',
'create_custom_object',
'change_custom_object',
'create_custom_behavior',
'change_custom_behavior',
'create_custom_function',
'change_custom_function',
'inspect_extension',
'spawn_agent',
]);
/** True when `name` is one of the 39 editor functions. */
export function isEditorFunctionName(name) {
return typeof name === 'string' && EDITOR_FUNCTION_NAMES.includes(name);
}
const randomCallId = () => `call_${crypto.randomBytes(9).toString('hex')}`;
/**
* Parse an explicit function-call message (`{"name": ..., "arguments": ...}`,
* arguments as object or JSON string). Returns `{ name, args }` or `null`
* when the text is not a function-call message. Throws on a well-formed
* call naming an unknown function (fail-closed: never emit tool_calls for
* functions outside the 39).
*/
export function parseFunctionCallMessage(text) {
if (typeof text !== 'string') return null;
const trimmed = text.trim();
if (!trimmed.startsWith('{')) return null;
let parsed;
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
if (typeof parsed.name !== 'string') return null;
if (!isEditorFunctionName(parsed.name)) {
throw new Error(
`[opencode-provider] unknown function "${parsed.name}": not one of the 39 editor functions`
);
}
let args = parsed.arguments === undefined ? {} : parsed.arguments;
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch {
throw new Error(
`[opencode-provider] function "${parsed.name}" has non-JSON arguments`
);
}
}
if (!args || typeof args !== 'object' || Array.isArray(args)) {
throw new Error(
`[opencode-provider] function "${parsed.name}" arguments must be a JSON object`
);
}
return { name: parsed.name, args };
}
/**
* Map one validated function call to an OpenAI `tool_calls` entry.
* `args` may be an object or a JSON string; it is always serialized.
*/
export function mapFunctionCallToToolCall({ name, args, id } = {}) {
if (!isEditorFunctionName(name)) {
throw new Error(
`[opencode-provider] unknown function "${String(name)}": not one of the 39 editor functions`
);
}
const input = args === undefined ? {} : args;
const normalized = typeof input === 'string' ? input : JSON.stringify(input);
if (typeof input === 'string') JSON.parse(normalized); // fail-closed on bad JSON
if (typeof input === 'object' && (input === null || Array.isArray(input))) {
throw new Error(`[opencode-provider] function "${name}" arguments must be a JSON object`);
}
return {
id: id || randomCallId(),
type: 'function',
function: { name, arguments: normalized },
};
}
/** Map an OpenAI `tool_calls` entry to an OpenCode session step. */
export function toolCallToSessionStep(toolCall) {
if (!toolCall || toolCall.type !== 'function' || !toolCall.function) {
throw new Error('[opencode-provider] malformed tool_call entry');
}
const { name, arguments: argsJson } = toolCall.function;
if (!isEditorFunctionName(name)) {
throw new Error(`[opencode-provider] unknown function "${String(name)}" in tool_call`);
}
let input = {};
if (argsJson !== undefined && argsJson !== '') {
input = JSON.parse(argsJson);
}
return { type: 'tool_call', id: toolCall.id || randomCallId(), name, input };
}
/**
* Translate an OpenAI `messages` list into an OpenCode session:
* `{ id, prompt, steps }`. System messages seed the prompt context, the
* user texts form the prompt, assistant `tool_calls` become `tool_call`
* steps, and `tool` messages become `tool_result` steps.
*/
export function openAiMessagesToSession(messages, { sessionId } = {}) {
if (!Array.isArray(messages) || messages.length === 0) {
throw new Error('[opencode-provider] messages must be a non-empty array');
}
const promptParts = [];
const steps = [];
for (const message of messages) {
if (!message || typeof message.role !== 'string') {
throw new Error('[opencode-provider] every message needs a string role');
}
const content =
typeof message.content === 'string'
? message.content
: message.content == null
? ''
: JSON.stringify(message.content);
switch (message.role) {
case 'system':
promptParts.unshift(`[system] ${content}`);
break;
case 'user':
promptParts.push(content);
break;
case 'assistant':
if (content) promptParts.push(`[assistant] ${content}`);
for (const toolCall of message.tool_calls || []) {
steps.push(toolCallToSessionStep(toolCall));
}
break;
case 'tool':
steps.push({
type: 'tool_result',
toolCallId: message.tool_call_id || null,
output: content,
});
break;
default:
promptParts.push(`[${message.role}] ${content}`);
break;
}
}
return {
id: sessionId || `sess_${crypto.randomBytes(8).toString('hex')}`,
prompt: promptParts.join('\n'),
steps,
};
}
/**
* Translate session steps back into OpenAI messages (assistant `tool_calls`
* plus `tool` results), e.g. when resuming a session as conversation history.
*/
export function sessionStepsToOpenAiMessages(steps) {
const messages = [];
for (const step of steps || []) {
if (!step || typeof step !== 'object') continue;
if (step.type === 'tool_call' && isEditorFunctionName(step.name)) {
messages.push({
role: 'assistant',
content: null,
tool_calls: [mapFunctionCallToToolCall({ name: step.name, args: step.input || {}, id: step.id })],
});
} else if (step.type === 'tool_result') {
messages.push({
role: 'tool',
tool_call_id: step.toolCallId,
content: typeof step.output === 'string' ? step.output : JSON.stringify(step.output ?? null),
});
}
}
return messages;
}
/**
* Rough token estimate (chars/4 ASCII, 1 per non-ASCII unit) — good enough
* for local usage meters, never a billing figure.
*/
export function estimateTokens(text) {
if (!text || typeof text !== 'string') return 0;
let ascii = 0;
let nonAscii = 0;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) > 0x7f) nonAscii += 1;
else ascii += 1;
}
return Math.ceil(ascii / 4) + nonAscii;
}
/** Usage meter for a prompt/completion pair. */
export function buildUsage(promptText, completionText) {
const prompt_tokens = estimateTokens(promptText);
const completion_tokens = estimateTokens(completionText);
return { prompt_tokens, completion_tokens, total_tokens: prompt_tokens + completion_tokens };
}
/** Split text into word-boundary chunks for SSE delta streaming. */
export function chunkTextForStream(text, maxChunk = 24) {
const chunks = [];
let current = '';
for (const word of String(text).split(/(\s+)/)) {
if ((current + word).length > maxChunk && current.length > 0) {
chunks.push(current);
current = '';
}
current += word;
}
if (current.length > 0) chunks.push(current);
return chunks.length > 0 ? chunks : [''];
}
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
// CLI for the opencode provider daemon (T08, issue #18).
// Zero-dependency ESM: `node cli.mjs [--port 0] [--host 127.0.0.1]
// [--model gdevelop-local] [--verbose]`.
import { startServer, OPENAI_MODEL_ID } from './server.mjs';
const usage = `opencode-provider: local OpenAI-compatible bridge for GDevelop
Usage: opencode-provider [options]
--port <n> TCP port to listen on (0 = ephemeral, default 0)
--host <addr> bind address (default 127.0.0.1; keep loopback for zero egress)
--model <id> model id advertised on /v1/models (default ${OPENAI_MODEL_ID})
--verbose log each request line
--help this message
`;
const args = process.argv.slice(2);
const options = { port: 0, host: '127.0.0.1', model: OPENAI_MODEL_ID, verbose: false };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') {
console.log(usage);
process.exit(0);
} else if (arg === '--verbose' || arg === '-v') {
options.verbose = true;
} else if (arg === '--port' && i + 1 < args.length) {
options.port = Number(args[++i]);
} else if (arg.startsWith('--port=')) {
options.port = Number(arg.split('=')[1]);
} else if (arg === '--host' && i + 1 < args.length) {
options.host = args[++i];
} else if (arg.startsWith('--host=')) {
options.host = arg.split('=')[1];
} else if (arg === '--model' && i + 1 < args.length) {
options.model = args[++i];
} else if (arg.startsWith('--model=')) {
options.model = arg.split('=')[1];
} else {
console.error(`opencode-provider: unknown argument "${arg}"\n\n${usage}`);
process.exit(2);
}
}
if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
console.error(`opencode-provider: invalid --port (${String(options.port)})`);
process.exit(2);
}
const handle = await startServer({
host: options.host,
port: options.port,
model: options.model,
});
const actual = handle.address();
console.log(
`opencode-provider listening on http://${actual.address}:${actual.port} (model ${options.model})`
);
const shutdown = () => {
handle.close().then(() => process.exit(0));
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
+14
View File
@@ -0,0 +1,14 @@
{
"name": "opencode-provider",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"description": "Local OpenAI-compatible provider bridge for GDevelop (offline daemon, zero egress).",
"bin": {
"opencode-provider": "./cli.mjs"
},
"scripts": {
"start": "node ./cli.mjs"
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,394 @@
// Integration Test: OpenAI-compatible provider daemon (T08, issue #18).
//
// Boots the real packages/opencode-provider/ HTTP daemon on an ephemeral
// loopback port and exercises it with REAL http requests: model listing,
// CORS preflight, non-streaming completions + usage, SSE streaming chunks +
// data:[DONE], function-call → tool_calls mapping for the 39 editor
// functions, malformed-payload rejection, and zero egress to api.gdevelop.io.
//
// Bare node:test + builtins only. No mocks of the code under test.
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
startServer,
EDITOR_TOOLS,
OPENAI_MODEL_ID,
} from '../../packages/opencode-provider/server.mjs';
import {
EDITOR_FUNCTION_NAMES,
openAiMessagesToSession,
mapFunctionCallToToolCall,
parseFunctionCallMessage,
} from '../../packages/opencode-provider/adapter.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..', '..');
const postJson = (port, body, { raw, headers } = {}) =>
new Promise((resolve, reject) => {
const payload = typeof raw === 'string' ? raw : JSON.stringify(body);
const req = http.request(
{
host: '127.0.0.1',
port,
path: '/v1/chat/completions',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
...headers,
},
},
res => {
let data = '';
res.on('data', c => (data += c));
res.on('end', () =>
resolve({ status: res.statusCode, headers: res.headers, body: data })
);
}
);
req.on('error', reject);
req.end(payload);
});
describe('integration: opencode provider daemon (real HTTP)', () => {
let server;
let port;
before(async () => {
server = await startServer({ host: '127.0.0.1', port: 0 });
port = server.address().port;
assert.ok(port > 0, 'ephemeral port assigned');
});
after(async () => {
await server.close();
});
it('GET /v1/models lists the local model', async () => {
const res = await fetch(`http://127.0.0.1:${port}/v1/models`);
assert.equal(res.status, 200);
assert.equal(res.headers.get('access-control-allow-origin'), '*');
const data = await res.json();
assert.equal(data.object, 'list');
assert.ok(
data.data.some(m => m.id === OPENAI_MODEL_ID),
'local model id advertised'
);
});
it('CORS preflight OPTIONS answers 204 permissively', async () => {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'OPTIONS',
});
assert.equal(res.status, 204);
assert.equal(res.headers.get('access-control-allow-origin'), '*');
assert.match(res.headers.get('access-control-allow-methods') || '', /POST/);
});
it('POST stream:false returns a standard completion with usage', async () => {
const { status, body } = await postJson(port, {
model: OPENAI_MODEL_ID,
messages: [{ role: 'user', content: 'Hello local bridge' }],
stream: false,
});
assert.equal(status, 200);
const data = JSON.parse(body);
assert.equal(data.object, 'chat.completion');
assert.equal(data.choices.length, 1);
assert.equal(data.choices[0].message.role, 'assistant');
assert.ok(data.choices[0].message.content.length > 0);
assert.ok(data.usage.total_tokens >= data.usage.prompt_tokens);
assert.ok(data.usage.completion_tokens > 0);
});
it('POST stream:true emits SSE chunk deltas ending in data:[DONE]', async () => {
const { status, headers, body } = await postJson(port, {
model: OPENAI_MODEL_ID,
messages: [{ role: 'user', content: 'Stream me' }],
stream: true,
});
assert.equal(status, 200);
assert.match(headers['content-type'] || '', /text\/event-stream/);
assert.ok(body.includes('data: '), 'SSE data frames present');
const frames = body
.split('\n\n')
.map(f => f.trim())
.filter(Boolean);
assert.ok(frames.length >= 2, 'at least one delta plus DONE');
assert.equal(frames[frames.length - 1], 'data: [DONE]');
const first = JSON.parse(frames[0].replace(/^data: /, ''));
assert.equal(first.choices[0].delta.role, 'assistant');
const contentFrames = frames
.slice(0, -1)
.map(f => JSON.parse(f.replace(/^data: /, '')))
.filter(c => typeof c.choices[0].delta.content === 'string');
assert.ok(contentFrames.length > 0, 'deltas carry content');
const reassembled = frames
.slice(0, -1)
.map(f => JSON.parse(f.replace(/^data: /, '')))
.map(c => c.choices[0].delta.content || '')
.join('');
assert.ok(reassembled.length > 0, 'deltas reassemble to content');
});
it('maps a function-call message to a tool_calls payload (39 editor fns)', async () => {
const { status, body } = await postJson(port, {
model: OPENAI_MODEL_ID,
messages: [
{ role: 'user', content: 'Build the first level' },
{
role: 'user',
content: JSON.stringify({
name: 'create_scene',
arguments: { scene_name: 'Level1' },
}),
},
],
tools: EDITOR_TOOLS.map(t => ({
type: 'function',
function: { name: t.function.name, description: t.function.description },
})),
stream: false,
});
assert.equal(status, 200);
const data = JSON.parse(body);
assert.equal(data.choices[0].finish_reason, 'tool_calls');
const calls = data.choices[0].message.tool_calls;
assert.equal(calls.length, 1);
assert.equal(calls[0].type, 'function');
assert.equal(calls[0].function.name, 'create_scene');
assert.deepEqual(JSON.parse(calls[0].function.arguments), {
scene_name: 'Level1',
});
assert.match(calls[0].id, /^call_/);
});
it('streams tool_calls as SSE deltas for function-call messages', async () => {
const { status, headers, body } = await postJson(port, {
model: OPENAI_MODEL_ID,
messages: [
{
role: 'user',
content: JSON.stringify({
name: 'add_behavior',
arguments: {
object_name: 'Hero',
behavior_name: 'Platformer',
behavior_type: 'PlatformBehavior::PlatformerObjectBehavior',
},
}),
},
],
stream: true,
});
assert.equal(status, 200);
assert.match(headers['content-type'] || '', /text\/event-stream/);
assert.ok(body.includes('tool_calls'), 'streamed deltas carry tool_calls');
assert.ok(body.trimEnd().endsWith('data: [DONE]'));
});
it('rejects malformed OpenAI payloads with 400 JSON errors', async () => {
// Not JSON.
const bad = await postJson(port, null, { raw: '{oops' });
assert.equal(bad.status, 400);
assert.equal(JSON.parse(bad.body).error.type, 'invalid_request_error');
// Missing messages.
const missing = await postJson(port, { model: OPENAI_MODEL_ID });
assert.equal(missing.status, 400);
// Messages not an array.
const wrongType = await postJson(port, {
model: OPENAI_MODEL_ID,
messages: 'hello',
});
assert.equal(wrongType.status, 400);
// Unknown function name in a function-call message.
const unknownFn = await postJson(port, {
model: OPENAI_MODEL_ID,
messages: [
{ role: 'user', content: JSON.stringify({ name: 'delete_everything', arguments: {} }) },
],
});
assert.equal(unknownFn.status, 400);
assert.match(JSON.parse(unknownFn.body).error.message, /unknown function/i);
// Malformed tool definition.
const badTool = await postJson(port, {
model: OPENAI_MODEL_ID,
messages: [{ role: 'user', content: 'hi' }],
tools: [{ type: 'function' }],
});
assert.equal(badTool.status, 400);
});
it('answers 404 JSON on unknown routes and 405 on wrong methods', async () => {
const missing = await fetch(`http://127.0.0.1:${port}/v1/does-not-exist`);
assert.equal(missing.status, 404);
assert.equal((await missing.json()).error.code, 'not_found');
const wrongMethod = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`);
assert.equal(wrongMethod.status, 405);
});
it('survives an SSE client abort mid-stream (truncation)', async () => {
await new Promise((resolve, reject) => {
const payload = JSON.stringify({
model: OPENAI_MODEL_ID,
messages: [{ role: 'user', content: 'x'.repeat(4000) }],
stream: true,
});
const req = http.request(
{
host: '127.0.0.1',
port,
path: '/v1/chat/completions',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
},
res => {
res.on('data', () => req.destroy());
res.on('close', resolve);
res.on('error', () => resolve());
}
);
req.on('error', () => resolve());
req.end(payload);
setTimeout(resolve, 2000);
});
// Daemon still serves afterwards.
const res = await fetch(`http://127.0.0.1:${port}/v1/models`);
assert.equal(res.status, 200);
});
});
describe('integration: provider adapter translation (pure, real schemas)', () => {
it('all 39 EDITOR_TOOLS match the upstream CustomAIClient registry', () => {
const src = fs.readFileSync(
path.join(root, 'packages/ai-studio/AI/CustomAIClient.js'),
'utf8'
);
const re =
/\{\s*type:\s*'function',\s*function:\s*\{\s*name:\s*'([^']+)'/g;
const upstream = [];
let m;
while ((m = re.exec(src)) !== null) upstream.push(m[1]);
assert.equal(upstream.length, 39);
const shipped = EDITOR_TOOLS.map(t => t.function.name);
assert.deepEqual([...shipped].sort(), [...upstream].sort());
assert.deepEqual([...EDITOR_FUNCTION_NAMES].sort(), [...upstream].sort());
for (const tool of EDITOR_TOOLS) {
assert.equal(tool.type, 'function');
assert.match(tool.function.name, /^[a-z][a-z0-9_]*$/);
assert.ok(tool.function.description.length > 0);
assert.equal(tool.function.parameters.type, 'object');
}
});
it('openAiMessagesToSession maps roles, tool_calls and tool results', () => {
const session = openAiMessagesToSession(
[
{ role: 'system', content: 'You are a builder.' },
{ role: 'user', content: 'Make a hero' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: 'create_scene',
arguments: '{"scene_name":"L1"}',
},
},
],
},
{ role: 'tool', tool_call_id: 'call_1', content: '{"ok":true}' },
],
{ sessionId: 'sess-1' }
);
assert.equal(session.id, 'sess-1');
assert.match(session.prompt, /Make a hero/);
const step = session.steps.find(s => s.type === 'tool_call');
assert.equal(step.name, 'create_scene');
assert.deepEqual(step.input, { scene_name: 'L1' });
const result = session.steps.find(s => s.type === 'tool_result');
assert.equal(result.toolCallId, 'call_1');
});
it('mapFunctionCallToToolCall validates against the 39 and stringifies args', () => {
const call = mapFunctionCallToToolCall({
name: 'create_scene',
args: { scene_name: 'L1' },
});
assert.equal(call.type, 'function');
assert.equal(call.function.name, 'create_scene');
assert.equal(JSON.parse(call.function.arguments).scene_name, 'L1');
assert.throws(
() => mapFunctionCallToToolCall({ name: 'nope_fn', args: {} }),
/unknown function/i
);
assert.equal(parseFunctionCallMessage('just prose'), null);
assert.deepEqual(parseFunctionCallMessage('{"name":"create_scene","arguments":{}}'), {
name: 'create_scene',
args: {},
});
});
});
describe('integration: provider zero-egress + packaging', () => {
it('daemon sources never reference the hosted API and use no external deps', () => {
for (const file of ['server.mjs', 'adapter.mjs', 'cli.mjs']) {
const src = fs.readFileSync(
path.join(root, 'packages/opencode-provider', file),
'utf8'
);
assert.ok(
!src.includes('api.gdevelop.io'),
`${file} must not reference the hosted API`
);
}
const pkg = JSON.parse(
fs.readFileSync(
path.join(root, 'packages/opencode-provider/package.json'),
'utf8'
)
);
assert.ok(!pkg.dependencies, 'no runtime dependencies allowed');
assert.ok(!pkg.devDependencies, 'no devDependencies allowed');
});
it('cli.mjs boots the daemon on an ephemeral port (real spawn)', async () => {
const cli = path.join(root, 'packages/opencode-provider/cli.mjs');
const child = spawn(process.execPath, [cli, '--port', '0', '--host', '127.0.0.1'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
child.stdout.on('data', c => (out += c));
try {
const port = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('cli boot timeout')), 8000);
const scan = () => {
const found = /listening on .*?:(\d+)/.exec(out);
if (found) {
clearTimeout(timer);
resolve(Number(found[1]));
} else setTimeout(scan, 50);
};
child.on('error', reject);
scan();
});
const res = await fetch(`http://127.0.0.1:${port}/v1/models`);
assert.equal(res.status, 200);
} finally {
child.kill('SIGTERM');
await new Promise(resolve => child.on('exit', resolve));
}
});
});