zengate 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/config.json.example +23 -0
- package/index.js +84 -0
- package/package.json +57 -0
- package/scripts/setup.mjs +24 -0
- package/src/bootstrap.js +66 -0
- package/src/cli.js +55 -0
- package/src/config.js +193 -0
- package/src/gateway.js +90 -0
- package/src/logger.js +35 -0
- package/src/openai/chat-request.js +183 -0
- package/src/openai/chat.js +100 -0
- package/src/openai/generate.js +143 -0
- package/src/openai/media.js +104 -0
- package/src/openai/models.js +17 -0
- package/src/openai/prompt.js +101 -0
- package/src/openai/response-builder.js +135 -0
- package/src/openai/responses-request.js +176 -0
- package/src/openai/responses-store.js +78 -0
- package/src/openai/responses.js +65 -0
- package/src/openai/sse-writer.js +37 -0
- package/src/openai/stop.js +41 -0
- package/src/openai/tool-calls.js +187 -0
- package/src/openai/url-guard.js +61 -0
- package/src/opencode/backend.js +201 -0
- package/src/opencode/binary.js +75 -0
- package/src/opencode/catalog.js +83 -0
- package/src/opencode/client.js +84 -0
- package/src/opencode/events.js +168 -0
- package/src/opencode/isolation.js +119 -0
- package/src/opencode/model-errors.js +66 -0
- package/src/opencode/runner.js +224 -0
- package/src/opencode/sse-reader.js +40 -0
- package/src/paths.js +40 -0
- package/src/server/app.js +96 -0
- package/src/server/errors.js +56 -0
- package/src/server/limiter.js +72 -0
- package/src/server/metrics.js +31 -0
- package/src/server/middleware.js +94 -0
- package/src/server/slot.js +32 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every setting can come from the environment or config.json (same key).
|
|
5
|
+
* Environment wins. Values are validated up front so a typo fails at startup
|
|
6
|
+
* with a clear message instead of silently falling back to a default.
|
|
7
|
+
*/
|
|
8
|
+
const SPEC = Object.freeze({
|
|
9
|
+
PORT: { type: 'int', default: 8083, min: 1, max: 65535 },
|
|
10
|
+
HOST: { type: 'string', default: '127.0.0.1' },
|
|
11
|
+
API_KEY: { type: 'string', default: '', secret: true },
|
|
12
|
+
API_KEYS: { type: 'list', default: [], secret: true },
|
|
13
|
+
ALLOW_NO_AUTH: { type: 'bool', default: false },
|
|
14
|
+
OPENCODE_PATH: { type: 'string', default: '' },
|
|
15
|
+
OPENCODE_SERVER_URL: { type: 'string', default: '' },
|
|
16
|
+
OPENCODE_SERVER_USERNAME: { type: 'string', default: 'opencode' },
|
|
17
|
+
OPENCODE_SERVER_PASSWORD: { type: 'string', default: '', secret: true },
|
|
18
|
+
ALLOW_INSECURE_BACKEND_HTTP: { type: 'bool', default: false },
|
|
19
|
+
OPENCODE_AGENT: { type: 'string', default: 'plan' },
|
|
20
|
+
MAX_CONCURRENT: { type: 'int', default: 8, min: 1, max: 64 },
|
|
21
|
+
MAX_QUEUE: { type: 'int', default: 32, min: 0, max: 1000 },
|
|
22
|
+
RATE_LIMIT_PER_MINUTE: { type: 'int', default: 120, min: 0, max: 100000 },
|
|
23
|
+
REQUEST_TIMEOUT_MS: { type: 'int', default: 300000, min: 10000, max: 3600000 },
|
|
24
|
+
MAX_BODY_MB: { type: 'int', default: 25, min: 1, max: 200 },
|
|
25
|
+
MAX_MEDIA_MB: { type: 'int', default: 20, min: 1, max: 100 },
|
|
26
|
+
RESPONSES_STORE_MAX: { type: 'int', default: 500, min: 0, max: 100000 },
|
|
27
|
+
CORS_ORIGINS: { type: 'list', default: [] },
|
|
28
|
+
TRUST_PROXY: { type: 'hops', default: 0 },
|
|
29
|
+
LOG_LEVEL: { type: 'enum', default: 'info', values: ['debug', 'info', 'warn', 'error'] },
|
|
30
|
+
LOG_JSON: { type: 'bool', default: false },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const CONFIG_KEYS = Object.freeze(Object.keys(SPEC));
|
|
34
|
+
|
|
35
|
+
/** Keys that are too short or too well known to protect anything. */
|
|
36
|
+
const MIN_KEY_LENGTH = 16;
|
|
37
|
+
const PLACEHOLDER_KEYS = new Set(['change-me', 'changeme', 'your-secret-api-key', 'your-api-key', 'sk-xxxx']);
|
|
38
|
+
|
|
39
|
+
export class ConfigError extends Error {
|
|
40
|
+
constructor(problems) {
|
|
41
|
+
super(`Invalid configuration:\n - ${problems.join('\n - ')}`);
|
|
42
|
+
this.name = 'ConfigError';
|
|
43
|
+
this.problems = problems;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseBool(value) {
|
|
48
|
+
if (typeof value === 'boolean') return value;
|
|
49
|
+
if (typeof value === 'number' && (value === 0 || value === 1)) return value === 1;
|
|
50
|
+
if (typeof value !== 'string') return undefined;
|
|
51
|
+
const v = value.trim().toLowerCase();
|
|
52
|
+
if (['1', 'true', 'yes', 'on'].includes(v)) return true;
|
|
53
|
+
if (['0', 'false', 'no', 'off'].includes(v)) return false;
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseValue(key, spec, raw) {
|
|
58
|
+
switch (spec.type) {
|
|
59
|
+
case 'string':
|
|
60
|
+
return { value: String(raw).trim() };
|
|
61
|
+
case 'bool': {
|
|
62
|
+
const value = parseBool(raw);
|
|
63
|
+
return value === undefined ? { error: `${key} must be true or false (got "${raw}")` } : { value };
|
|
64
|
+
}
|
|
65
|
+
case 'int': {
|
|
66
|
+
const text = String(raw).trim();
|
|
67
|
+
if (!/^-?\d+$/.test(text)) return { error: `${key} must be an integer (got "${raw}")` };
|
|
68
|
+
const value = Number(text);
|
|
69
|
+
if (value < spec.min || value > spec.max) {
|
|
70
|
+
return { error: `${key} must be between ${spec.min} and ${spec.max} (got ${value})` };
|
|
71
|
+
}
|
|
72
|
+
return { value };
|
|
73
|
+
}
|
|
74
|
+
case 'list': {
|
|
75
|
+
const items = Array.isArray(raw) ? raw : String(raw).split(',');
|
|
76
|
+
return { value: [...new Set(items.map((item) => String(item).trim()).filter(Boolean))] };
|
|
77
|
+
}
|
|
78
|
+
case 'hops': {
|
|
79
|
+
const bool = parseBool(raw);
|
|
80
|
+
if (bool !== undefined) return { value: bool ? 1 : 0 };
|
|
81
|
+
return parseValue(key, { type: 'int', min: 0, max: 10 }, raw);
|
|
82
|
+
}
|
|
83
|
+
case 'enum': {
|
|
84
|
+
const value = String(raw).trim().toLowerCase();
|
|
85
|
+
return spec.values.includes(value) ? { value } : { error: `${key} must be one of: ${spec.values.join(', ')}` };
|
|
86
|
+
}
|
|
87
|
+
default:
|
|
88
|
+
return { error: `${key} has an unknown type` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Reads config.json. A missing file is fine; a malformed one is fatal. */
|
|
93
|
+
export function readConfigFile(filePath) {
|
|
94
|
+
if (!filePath || !fs.existsSync(filePath)) return {};
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new ConfigError([`${filePath} is not valid JSON: ${error.message}`]);
|
|
100
|
+
}
|
|
101
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
102
|
+
throw new ConfigError([`${filePath} must contain a JSON object`]);
|
|
103
|
+
}
|
|
104
|
+
return parsed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateKeys(keys, problems) {
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
if (PLACEHOLDER_KEYS.has(key.toLowerCase())) {
|
|
110
|
+
problems.push('API_KEY is a placeholder value; run `npm run setup` or set a unique random key');
|
|
111
|
+
} else if (key.length < MIN_KEY_LENGTH) {
|
|
112
|
+
problems.push(`API_KEY must be at least ${MIN_KEY_LENGTH} characters (run \`npm run setup\` to generate one)`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validateServerUrl(config, problems) {
|
|
118
|
+
if (!config.OPENCODE_SERVER_URL) return;
|
|
119
|
+
let url;
|
|
120
|
+
try {
|
|
121
|
+
url = new URL(config.OPENCODE_SERVER_URL);
|
|
122
|
+
} catch {
|
|
123
|
+
problems.push('OPENCODE_SERVER_URL must be an absolute http(s) URL');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
127
|
+
problems.push('OPENCODE_SERVER_URL must use http or https');
|
|
128
|
+
} else if (url.protocol === 'http:' && !isLoopbackHost(url.hostname) && !config.ALLOW_INSECURE_BACKEND_HTTP) {
|
|
129
|
+
problems.push('a remote OPENCODE_SERVER_URL must use https (or set ALLOW_INSECURE_BACKEND_HTTP=true on a trusted private network)');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Merge config.json and the environment into one validated, frozen config.
|
|
135
|
+
* @param {Record<string, unknown>} fileConfig parsed config.json (may be empty)
|
|
136
|
+
* @param {Record<string, string|undefined>} env usually process.env
|
|
137
|
+
*/
|
|
138
|
+
export function loadConfig(fileConfig = {}, env = process.env) {
|
|
139
|
+
const problems = [];
|
|
140
|
+
const unknown = Object.keys(fileConfig).filter((key) => !(key in SPEC) && key !== '$schema');
|
|
141
|
+
if (unknown.length) problems.push(`unknown config.json keys: ${unknown.join(', ')}`);
|
|
142
|
+
|
|
143
|
+
const parsed = {};
|
|
144
|
+
for (const [key, spec] of Object.entries(SPEC)) {
|
|
145
|
+
const raw = env[key] !== undefined && env[key] !== '' ? env[key] : fileConfig[key];
|
|
146
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
147
|
+
parsed[key] = spec.default;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const result = parseValue(key, spec, raw);
|
|
151
|
+
if (result.error) problems.push(result.error);
|
|
152
|
+
else parsed[key] = result.value;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const { API_KEY: primaryKey, ...rest } = parsed;
|
|
156
|
+
const config = {
|
|
157
|
+
...rest,
|
|
158
|
+
API_KEYS: [...new Set([primaryKey, ...(rest.API_KEYS || [])].filter(Boolean))],
|
|
159
|
+
OPENCODE_SERVER_URL: String(rest.OPENCODE_SERVER_URL || '').replace(/\/+$/, ''),
|
|
160
|
+
};
|
|
161
|
+
if (config.CORS_ORIGINS?.includes('*')) {
|
|
162
|
+
problems.push('CORS_ORIGINS must list explicit origins; "*" would let any website use the gateway');
|
|
163
|
+
}
|
|
164
|
+
if (!/^[\w.-]{1,64}$/.test(config.OPENCODE_AGENT || '')) {
|
|
165
|
+
problems.push('OPENCODE_AGENT must be a simple agent name');
|
|
166
|
+
}
|
|
167
|
+
validateKeys(config.API_KEYS, problems);
|
|
168
|
+
validateServerUrl(config, problems);
|
|
169
|
+
if (problems.length) throw new ConfigError(problems);
|
|
170
|
+
return Object.freeze(config);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** True when the gateway must refuse to start: no key and no explicit opt-out. */
|
|
174
|
+
export function needsApiKey(config) {
|
|
175
|
+
return config.API_KEYS.length === 0 && !config.ALLOW_NO_AUTH;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function isLoopbackHost(host) {
|
|
179
|
+
const raw = String(host || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
|
|
180
|
+
return raw === 'localhost' || raw === '::1' || /^127(\.\d{1,3}){3}$/.test(raw);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Config safe to print: secrets are reduced to a count or "set"/"unset". */
|
|
184
|
+
export function describeConfig(config) {
|
|
185
|
+
const out = {};
|
|
186
|
+
for (const [key, spec] of Object.entries(SPEC)) {
|
|
187
|
+
if (key === 'API_KEY') continue;
|
|
188
|
+
const value = config[key];
|
|
189
|
+
if (!spec.secret) out[key] = value;
|
|
190
|
+
else out[key] = Array.isArray(value) ? `${value.length} configured` : (value ? 'set' : 'unset');
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
package/src/gateway.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createAttachedBackend, createManagedBackend } from './opencode/backend.js';
|
|
2
|
+
import { createCatalog } from './opencode/catalog.js';
|
|
3
|
+
import { createEventHub } from './opencode/events.js';
|
|
4
|
+
import { createRunner } from './opencode/runner.js';
|
|
5
|
+
import { createResponsesStore } from './openai/responses-store.js';
|
|
6
|
+
import { createApp } from './server/app.js';
|
|
7
|
+
|
|
8
|
+
const HUB_CONNECT_TIMEOUT_MS = 10000;
|
|
9
|
+
const DRAIN_TIMEOUT_MS = 10000;
|
|
10
|
+
|
|
11
|
+
export function createBackend(config, logger) {
|
|
12
|
+
return config.OPENCODE_SERVER_URL
|
|
13
|
+
? createAttachedBackend({
|
|
14
|
+
url: config.OPENCODE_SERVER_URL,
|
|
15
|
+
username: config.OPENCODE_SERVER_USERNAME,
|
|
16
|
+
password: config.OPENCODE_SERVER_PASSWORD,
|
|
17
|
+
logger,
|
|
18
|
+
})
|
|
19
|
+
: createManagedBackend({ opencodePath: config.OPENCODE_PATH, logger });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function listen(app, { PORT, HOST }) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const server = app.listen(PORT, HOST);
|
|
25
|
+
server.once('listening', () => resolve(server));
|
|
26
|
+
server.once('error', (error) => reject(error.code === 'EADDRINUSE'
|
|
27
|
+
? new Error(`Port ${PORT} on ${HOST} is already in use. Stop the other process or set PORT.`)
|
|
28
|
+
: error));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function drain(server) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const force = setTimeout(() => server.closeAllConnections(), DRAIN_TIMEOUT_MS);
|
|
35
|
+
force.unref();
|
|
36
|
+
server.close(() => { clearTimeout(force); resolve(); });
|
|
37
|
+
server.closeIdleConnections();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wires the OpenCode backend, event hub, catalog and HTTP app together and
|
|
43
|
+
* starts listening.
|
|
44
|
+
* @param {object} config validated config from loadConfig
|
|
45
|
+
* @param {{ logger: object, backend?: object }} deps backend is injectable for tests
|
|
46
|
+
*/
|
|
47
|
+
export async function startGateway(config, { logger, backend = createBackend(config, logger) }) {
|
|
48
|
+
const getClient = () => backend.getClient();
|
|
49
|
+
const hub = createEventHub({ getClient, logger, ownsAllSessions: backend.ownsAllSessions });
|
|
50
|
+
const catalog = createCatalog({ getClient, logger });
|
|
51
|
+
const runner = createRunner({ getClient, hub, logger, agent: config.OPENCODE_AGENT });
|
|
52
|
+
const store = createResponsesStore({ maxEntries: config.RESPONSES_STORE_MAX });
|
|
53
|
+
const { app, close: closeApp } = createApp({ config, logger, backend, hub, catalog, runner, store });
|
|
54
|
+
|
|
55
|
+
await backend.start();
|
|
56
|
+
let server;
|
|
57
|
+
try {
|
|
58
|
+
hub.start();
|
|
59
|
+
if (!(await hub.waitConnected(HUB_CONNECT_TIMEOUT_MS))) {
|
|
60
|
+
logger.warn('OpenCode event stream is not connected yet; streaming falls back to final output until it is');
|
|
61
|
+
}
|
|
62
|
+
catalog.list().then(
|
|
63
|
+
(models) => logger.info(`${models.length} models: ${models.map((m) => m.id).join(', ')}`),
|
|
64
|
+
(error) => logger.warn('Could not load the model list yet', { error: error.message }),
|
|
65
|
+
);
|
|
66
|
+
server = await listen(app, config);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
await hub.stop();
|
|
69
|
+
closeApp();
|
|
70
|
+
await backend.stop();
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
// Generations can legitimately run for minutes; REQUEST_TIMEOUT_MS bounds them instead.
|
|
74
|
+
server.requestTimeout = 0;
|
|
75
|
+
server.headersTimeout = 30000;
|
|
76
|
+
server.keepAliveTimeout = 65000;
|
|
77
|
+
|
|
78
|
+
let stopping = null;
|
|
79
|
+
const stop = () => {
|
|
80
|
+
stopping ??= (async () => {
|
|
81
|
+
await drain(server);
|
|
82
|
+
closeApp();
|
|
83
|
+
await hub.stop();
|
|
84
|
+
await backend.stop();
|
|
85
|
+
})();
|
|
86
|
+
return stopping;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return Object.freeze({ server, stop, address: server.address(), backend });
|
|
90
|
+
}
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const LEVELS = Object.freeze({ debug: 10, info: 20, warn: 30, error: 40 });
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Small leveled logger. Human-readable by default, JSON lines when json=true.
|
|
5
|
+
* Callers pass structured fields; request bodies and secrets are never logged.
|
|
6
|
+
* @param {{ level?: keyof LEVELS, json?: boolean, sink?: { out: Function, err: Function } }} [options]
|
|
7
|
+
*/
|
|
8
|
+
export function createLogger({ level = 'info', json = false, sink } = {}) {
|
|
9
|
+
const threshold = LEVELS[level] ?? LEVELS.info;
|
|
10
|
+
const out = sink?.out ?? ((line) => process.stdout.write(`${line}\n`));
|
|
11
|
+
const err = sink?.err ?? ((line) => process.stderr.write(`${line}\n`));
|
|
12
|
+
|
|
13
|
+
const write = (name, message, fields) => {
|
|
14
|
+
if (LEVELS[name] < threshold) return;
|
|
15
|
+
const target = LEVELS[name] >= LEVELS.warn ? err : out;
|
|
16
|
+
if (json) {
|
|
17
|
+
target(JSON.stringify({ ts: new Date().toISOString(), level: name, msg: message, ...fields }));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const extra = fields && Object.keys(fields).length ? ` ${JSON.stringify(fields)}` : '';
|
|
21
|
+
const tag = name === 'info' ? '' : `[${name}] `;
|
|
22
|
+
target(`${tag}${message}${extra}`);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
debug: (message, fields) => write('debug', message, fields),
|
|
27
|
+
info: (message, fields) => write('info', message, fields),
|
|
28
|
+
warn: (message, fields) => write('warn', message, fields),
|
|
29
|
+
error: (message, fields) => write('error', message, fields),
|
|
30
|
+
isDebug: threshold <= LEVELS.debug,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Logger that discards everything; handy for tests and embedding. */
|
|
35
|
+
export const silentLogger = createLogger({ level: 'error', sink: { out: () => {}, err: () => {} } });
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { invalidRequest, unsupported } from '../server/errors.js';
|
|
2
|
+
import { audioFromBase64, fileAttachment, imageFromUrl, videoFromUrl } from './media.js';
|
|
3
|
+
import { newCallId } from './tool-calls.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parameters that are accepted for compatibility but cannot be applied
|
|
7
|
+
* through OpenCode (sampling and token limits are set by the model/agent).
|
|
8
|
+
* They are reported back in the `x-gateway-ignored-params` header.
|
|
9
|
+
*/
|
|
10
|
+
const IGNORED = new Set([
|
|
11
|
+
'temperature', 'top_p', 'max_tokens', 'max_completion_tokens', 'presence_penalty', 'frequency_penalty',
|
|
12
|
+
'seed', 'logit_bias', 'user', 'metadata', 'store', 'service_tier', 'prompt_cache_key', 'prompt_cache_retention',
|
|
13
|
+
'safety_identifier', 'verbosity', 'prediction', 'web_search_options', 'top_k', 'min_p', 'repetition_penalty',
|
|
14
|
+
]);
|
|
15
|
+
const HANDLED = new Set([
|
|
16
|
+
'model', 'messages', 'stream', 'stream_options', 'n', 'stop', 'response_format', 'reasoning_effort',
|
|
17
|
+
'tools', 'tool_choice', 'parallel_tool_calls', 'functions', 'function_call', 'logprobs', 'top_logprobs',
|
|
18
|
+
'modalities', 'audio',
|
|
19
|
+
]);
|
|
20
|
+
const ROLES = new Set(['system', 'developer', 'user', 'assistant', 'tool', 'function']);
|
|
21
|
+
const MAX_MESSAGES = 2000;
|
|
22
|
+
export const MAX_CHOICES = 4;
|
|
23
|
+
|
|
24
|
+
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
25
|
+
|
|
26
|
+
export function parseTools(tools, param = 'tools') {
|
|
27
|
+
if (tools === undefined || tools === null) return [];
|
|
28
|
+
if (!Array.isArray(tools)) throw invalidRequest('tools must be an array.', param);
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
return tools.map((tool, index) => {
|
|
31
|
+
const fn = tool?.type === 'function' ? tool.function : tool?.type === undefined ? tool : null;
|
|
32
|
+
if (!isObject(fn) || typeof fn.name !== 'string' || !/^[\w.-]{1,128}$/.test(fn.name)) {
|
|
33
|
+
throw invalidRequest('Each tool must be {"type":"function","function":{"name":...}} with a name of letters, digits, _ . or -.', `${param}[${index}]`);
|
|
34
|
+
}
|
|
35
|
+
if (seen.has(fn.name)) throw invalidRequest(`Duplicate tool name '${fn.name}'.`, `${param}[${index}]`);
|
|
36
|
+
seen.add(fn.name);
|
|
37
|
+
if (fn.parameters !== undefined && !isObject(fn.parameters)) throw invalidRequest('function.parameters must be a JSON Schema object.', `${param}[${index}]`);
|
|
38
|
+
return { name: fn.name, description: typeof fn.description === 'string' ? fn.description : '', parameters: fn.parameters, kind: 'function' };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseToolChoice(choice, tools, param = 'tool_choice') {
|
|
43
|
+
if (choice === undefined || choice === null) return 'auto';
|
|
44
|
+
if (choice === 'auto' || choice === 'none' || choice === 'required') return choice;
|
|
45
|
+
const name = choice?.function?.name ?? choice?.name;
|
|
46
|
+
if (typeof name === 'string') {
|
|
47
|
+
if (!tools.some((tool) => tool.name === name)) throw invalidRequest(`tool_choice names '${name}', which is not in tools.`, param);
|
|
48
|
+
return { name };
|
|
49
|
+
}
|
|
50
|
+
if (choice?.type === 'allowed_tools') return choice.mode === 'required' ? 'required' : 'auto';
|
|
51
|
+
throw invalidRequest('tool_choice must be "auto", "none", "required" or a named function.', param);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function textOf(parts, param) {
|
|
55
|
+
return parts.map((part, index) => {
|
|
56
|
+
if (typeof part === 'string') return part;
|
|
57
|
+
if (part?.type === 'text' || part?.type === 'refusal') return String(part.text ?? part.refusal ?? '');
|
|
58
|
+
throw invalidRequest('Only text parts are allowed here.', `${param}[${index}]`);
|
|
59
|
+
}).join('');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseUserContent(content, param, media) {
|
|
63
|
+
if (typeof content === 'string') return { text: content, media: [] };
|
|
64
|
+
if (content === null || content === undefined) return { text: '', media: [] };
|
|
65
|
+
if (!Array.isArray(content)) throw invalidRequest('content must be a string or an array of content parts.', param);
|
|
66
|
+
const texts = [];
|
|
67
|
+
const attachments = [];
|
|
68
|
+
content.forEach((part, index) => {
|
|
69
|
+
const where = `${param}[${index}]`;
|
|
70
|
+
const options = { ...media, param: where };
|
|
71
|
+
switch (part?.type) {
|
|
72
|
+
case 'text': texts.push(String(part.text ?? '')); break;
|
|
73
|
+
case 'image_url': attachments.push(imageFromUrl(typeof part.image_url === 'string' ? part.image_url : part.image_url?.url, options)); break;
|
|
74
|
+
case 'input_audio': attachments.push(audioFromBase64(part.input_audio?.data, part.input_audio?.format, options)); break;
|
|
75
|
+
case 'file': attachments.push(fileAttachment(part.file || {}, options)); break;
|
|
76
|
+
case 'video_url': attachments.push(videoFromUrl(typeof part.video_url === 'string' ? part.video_url : part.video_url?.url, options)); break;
|
|
77
|
+
case 'refusal': texts.push(String(part.refusal ?? '')); break;
|
|
78
|
+
default:
|
|
79
|
+
throw unsupported(`Unsupported content part type '${part?.type}'. Use text, image_url, input_audio, file or video_url.`, where);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
const inlined = attachments.filter((a) => a.kind === 'text');
|
|
83
|
+
for (const file of inlined) texts.push(`\n<file name="${file.filename || 'attachment'}">\n${file.text}\n</file>\n`);
|
|
84
|
+
return { text: texts.join(''), media: attachments.filter((a) => a.kind !== 'text') };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseAssistantCalls(message, param) {
|
|
88
|
+
const calls = [];
|
|
89
|
+
if (Array.isArray(message.tool_calls)) {
|
|
90
|
+
message.tool_calls.forEach((call, index) => {
|
|
91
|
+
const name = call?.function?.name;
|
|
92
|
+
if (typeof name !== 'string') throw invalidRequest('tool_calls[].function.name is required.', `${param}.tool_calls[${index}]`);
|
|
93
|
+
const args = call.function.arguments;
|
|
94
|
+
calls.push({ id: String(call.id || newCallId()), name, arguments: typeof args === 'string' ? args : JSON.stringify(args ?? {}) });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (isObject(message.function_call) && typeof message.function_call.name === 'string') {
|
|
98
|
+
calls.push({ id: `call_${message.function_call.name}`, name: message.function_call.name, arguments: String(message.function_call.arguments || '{}') });
|
|
99
|
+
}
|
|
100
|
+
return calls;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function parseChatMessages(messages, media) {
|
|
104
|
+
if (!Array.isArray(messages) || messages.length === 0) throw invalidRequest('messages must be a non-empty array.', 'messages');
|
|
105
|
+
if (messages.length > MAX_MESSAGES) throw invalidRequest(`messages may contain at most ${MAX_MESSAGES} entries.`, 'messages');
|
|
106
|
+
return messages.map((message, index) => {
|
|
107
|
+
const param = `messages[${index}]`;
|
|
108
|
+
if (!isObject(message) || !ROLES.has(message.role)) {
|
|
109
|
+
throw invalidRequest(`${param}.role must be one of: system, developer, user, assistant, tool.`, `${param}.role`);
|
|
110
|
+
}
|
|
111
|
+
const { role } = message;
|
|
112
|
+
if (role === 'system' || role === 'developer') {
|
|
113
|
+
const text = Array.isArray(message.content) ? textOf(message.content, `${param}.content`) : String(message.content ?? '');
|
|
114
|
+
return { role: 'system', content: text, media: [] };
|
|
115
|
+
}
|
|
116
|
+
if (role === 'tool' || role === 'function') {
|
|
117
|
+
const text = Array.isArray(message.content) ? textOf(message.content, `${param}.content`) : String(message.content ?? '');
|
|
118
|
+
const toolCallId = role === 'function' ? `call_${message.name}` : message.tool_call_id;
|
|
119
|
+
if (typeof toolCallId !== 'string' || !toolCallId) throw invalidRequest('Tool messages need tool_call_id.', `${param}.tool_call_id`);
|
|
120
|
+
return { role: 'tool', content: text, media: [], toolCallId, name: typeof message.name === 'string' ? message.name : undefined };
|
|
121
|
+
}
|
|
122
|
+
if (role === 'assistant') {
|
|
123
|
+
const text = Array.isArray(message.content) ? textOf(message.content, `${param}.content`) : String(message.content ?? '');
|
|
124
|
+
return { role: 'assistant', content: text, media: [], toolCalls: parseAssistantCalls(message, param) };
|
|
125
|
+
}
|
|
126
|
+
const { text, media: attachments } = parseUserContent(message.content, `${param}.content`, media);
|
|
127
|
+
return { role: 'user', content: text, media: attachments };
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function parseResponseFormat(format, param = 'response_format') {
|
|
132
|
+
if (format === undefined || format === null || format?.type === 'text') return null;
|
|
133
|
+
if (format?.type === 'json_object') return { type: 'json_object' };
|
|
134
|
+
if (format?.type === 'json_schema') {
|
|
135
|
+
const spec = format.json_schema ?? format;
|
|
136
|
+
if (!isObject(spec?.schema)) throw invalidRequest('json_schema.schema must be a JSON Schema object.', param);
|
|
137
|
+
return { type: 'json_schema', name: spec.name || 'response', schema: spec.schema };
|
|
138
|
+
}
|
|
139
|
+
throw invalidRequest('response_format.type must be text, json_object or json_schema.', param);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseStop(stop) {
|
|
143
|
+
if (stop === undefined || stop === null) return [];
|
|
144
|
+
const list = Array.isArray(stop) ? stop : [stop];
|
|
145
|
+
if (list.length > 4 || list.some((s) => typeof s !== 'string')) throw invalidRequest('stop must be a string or up to 4 strings.', 'stop');
|
|
146
|
+
return list.filter(Boolean);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Validate a Chat Completions request into the gateway's canonical form.
|
|
151
|
+
* @param {unknown} body parsed JSON body
|
|
152
|
+
* @param {{ maxBytes: number }} media per-attachment limits
|
|
153
|
+
*/
|
|
154
|
+
export function parseChatRequest(body, media) {
|
|
155
|
+
if (!isObject(body)) throw invalidRequest('Request body must be a JSON object.');
|
|
156
|
+
if (typeof body.model !== 'string' || !body.model.trim()) throw invalidRequest('model is required (see GET /v1/models).', 'model');
|
|
157
|
+
if (body.logprobs === true) throw unsupported('logprobs are not available through OpenCode.', 'logprobs');
|
|
158
|
+
if (body.audio || (Array.isArray(body.modalities) && body.modalities.some((m) => m !== 'text'))) {
|
|
159
|
+
throw unsupported('Only text output is supported (modalities: ["text"]).', 'modalities');
|
|
160
|
+
}
|
|
161
|
+
const n = body.n ?? 1;
|
|
162
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_CHOICES) throw invalidRequest(`n must be an integer from 1 to ${MAX_CHOICES}.`, 'n');
|
|
163
|
+
const legacyTools = Array.isArray(body.functions) ? body.functions.map((fn) => ({ type: 'function', function: fn })) : undefined;
|
|
164
|
+
const tools = parseTools(body.tools ?? legacyTools, body.tools ? 'tools' : 'functions');
|
|
165
|
+
const legacyChoice = typeof body.function_call === 'string' ? body.function_call : body.function_call?.name ? { name: body.function_call.name } : undefined;
|
|
166
|
+
const ignored = Object.keys(body).filter((key) => IGNORED.has(key) || !HANDLED.has(key));
|
|
167
|
+
return {
|
|
168
|
+
model: body.model.trim(),
|
|
169
|
+
messages: parseChatMessages(body.messages, media),
|
|
170
|
+
tools,
|
|
171
|
+
toolChoice: parseToolChoice(body.tool_choice ?? legacyChoice, tools),
|
|
172
|
+
// Legacy `functions` clients expect a single `function_call` back.
|
|
173
|
+
legacyFunctions: !body.tools && Boolean(legacyTools),
|
|
174
|
+
parallelToolCalls: body.parallel_tool_calls !== false && !(!body.tools && legacyTools),
|
|
175
|
+
format: parseResponseFormat(body.response_format),
|
|
176
|
+
reasoningEffort: typeof body.reasoning_effort === 'string' ? body.reasoning_effort : null,
|
|
177
|
+
stop: parseStop(body.stop),
|
|
178
|
+
n,
|
|
179
|
+
stream: body.stream === true,
|
|
180
|
+
includeUsage: body.stream_options?.include_usage === true,
|
|
181
|
+
ignored,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { toApiError } from '../server/errors.js';
|
|
3
|
+
import { parseChatRequest } from './chat-request.js';
|
|
4
|
+
import { chatUsage, generate } from './generate.js';
|
|
5
|
+
import { buildPrompt } from './prompt.js';
|
|
6
|
+
import { openSse } from './sse-writer.js';
|
|
7
|
+
import { assertPublicUrls } from './url-guard.js';
|
|
8
|
+
|
|
9
|
+
const completionId = () => `chatcmpl-${crypto.randomBytes(12).toString('hex')}`;
|
|
10
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
11
|
+
|
|
12
|
+
function toolCallsJson(calls) {
|
|
13
|
+
return calls.map((call) => ({ id: call.id, type: 'function', function: { name: call.name, arguments: call.arguments } }));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function chatMessage(result, legacy) {
|
|
17
|
+
const calls = result.toolCalls.length ? toolCallsJson(result.toolCalls) : null;
|
|
18
|
+
return {
|
|
19
|
+
role: 'assistant',
|
|
20
|
+
content: result.content || (calls ? null : ''),
|
|
21
|
+
refusal: null,
|
|
22
|
+
annotations: [],
|
|
23
|
+
...(calls && legacy ? { function_call: calls[0].function } : {}),
|
|
24
|
+
...(calls && !legacy ? { tool_calls: calls } : {}),
|
|
25
|
+
...(result.reasoning ? { reasoning_content: result.reasoning } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const finishReason = (result, legacy) => (legacy && result.finish === 'tool_calls' ? 'function_call' : result.finish);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* POST /v1/chat/completions
|
|
33
|
+
* @param {{ runner: object, catalog: object, limits: { maxBytes: number } }} deps
|
|
34
|
+
*/
|
|
35
|
+
export function chatCompletionsHandler({ runner, catalog, limits }) {
|
|
36
|
+
return async (req, res) => {
|
|
37
|
+
const request = parseChatRequest(req.body, limits);
|
|
38
|
+
const model = await catalog.resolve(request.model);
|
|
39
|
+
const prompt = buildPrompt(request, model);
|
|
40
|
+
await assertPublicUrls(prompt.parts);
|
|
41
|
+
if (request.ignored.length) res.set('x-gateway-ignored-params', request.ignored.join(','));
|
|
42
|
+
await req.withSlot((signal) => (request.stream
|
|
43
|
+
? streamChat({ req, res, runner, request, prompt, model, signal })
|
|
44
|
+
: jsonChat({ res, runner, request, prompt, model, signal })), request.n);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function jsonChat({ res, runner, request, prompt, model, signal }) {
|
|
49
|
+
const results = await Promise.all(Array.from({ length: request.n }, () => generate({ runner, prompt, request, signal })));
|
|
50
|
+
res.json({
|
|
51
|
+
id: completionId(),
|
|
52
|
+
object: 'chat.completion',
|
|
53
|
+
created: now(),
|
|
54
|
+
model: model.id,
|
|
55
|
+
system_fingerprint: null,
|
|
56
|
+
choices: results.map((result, index) => ({
|
|
57
|
+
index, message: chatMessage(result, request.legacyFunctions), logprobs: null, finish_reason: finishReason(result, request.legacyFunctions),
|
|
58
|
+
})),
|
|
59
|
+
usage: chatUsage(results.map((r) => r.usage)),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function streamChat({ req, res, runner, request, prompt, model, signal }) {
|
|
64
|
+
const id = completionId();
|
|
65
|
+
const created = now();
|
|
66
|
+
const sse = openSse(res);
|
|
67
|
+
const chunk = (index, delta, finishReason = null) => sse.send({
|
|
68
|
+
id, object: 'chat.completion.chunk', created, model: model.id, system_fingerprint: null,
|
|
69
|
+
choices: [{ index, delta, logprobs: null, finish_reason: finishReason }],
|
|
70
|
+
...(request.includeUsage ? { usage: null } : {}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
for (let index = 0; index < request.n; index += 1) chunk(index, { role: 'assistant', content: '', refusal: null });
|
|
74
|
+
try {
|
|
75
|
+
const results = await Promise.all(Array.from({ length: request.n }, (_, index) => generate({
|
|
76
|
+
runner, prompt, request, signal,
|
|
77
|
+
onText: (text) => chunk(index, { content: text }),
|
|
78
|
+
onReasoning: (text) => chunk(index, { reasoning_content: text }),
|
|
79
|
+
}).then((result) => {
|
|
80
|
+
const calls = toolCallsJson(result.toolCalls);
|
|
81
|
+
if (calls.length && request.legacyFunctions) chunk(index, { function_call: calls[0].function });
|
|
82
|
+
else if (calls.length) chunk(index, { tool_calls: calls.map((call, position) => ({ index: position, ...call })) });
|
|
83
|
+
chunk(index, {}, finishReason(result, request.legacyFunctions));
|
|
84
|
+
return result;
|
|
85
|
+
})));
|
|
86
|
+
if (request.includeUsage) {
|
|
87
|
+
sse.send({ id, object: 'chat.completion.chunk', created, model: model.id, system_fingerprint: null, choices: [], usage: chatUsage(results.map((r) => r.usage)) });
|
|
88
|
+
}
|
|
89
|
+
sse.raw('data: [DONE]\n\n');
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!signal.aborted || error?.status) {
|
|
92
|
+
const apiError = toApiError(error);
|
|
93
|
+
req.log.warn('Chat stream failed', { status: apiError.status, code: apiError.code, error: apiError.message });
|
|
94
|
+
sse.send(apiError.toJSON());
|
|
95
|
+
sse.raw('data: [DONE]\n\n');
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
sse.end();
|
|
99
|
+
}
|
|
100
|
+
}
|