mirror of
https://github.com/Heretek-AI/GDevelop-Unlocked.git
synced 2026-11-07 10:55:58 -10:00
T08 (Provider Bridge #18): OpenAI-compatible local provider daemon + integration suite
This commit is contained in:
1 parent
619013652b
commit
b44fd9bd81
5 files changed
+2535
No files matched your search
@@ -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 : [''];
|
||||
}
|
||||
Executable
+65
@@ -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);
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user