jbai-cli 1.9.2 → 2.1.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/README.md +3 -4
- package/bin/jbai-claude-opus.js +6 -0
- package/bin/jbai-claude-sonnet.js +6 -0
- package/bin/jbai-claude.js +16 -9
- package/bin/jbai-codex-5.2.js +6 -0
- package/bin/jbai-codex-5.3.js +6 -0
- package/bin/jbai-codex-rockhopper.js +6 -0
- package/bin/jbai-codex.js +12 -39
- package/bin/jbai-continue.js +27 -43
- package/bin/jbai-council.js +665 -0
- package/bin/jbai-gemini-3.1.js +6 -0
- package/bin/jbai-gemini-supernova.js +6 -0
- package/bin/jbai-gemini.js +17 -6
- package/bin/jbai-goose.js +11 -39
- package/bin/jbai-opencode-deepseek.js +6 -0
- package/bin/jbai-opencode-grok.js +6 -0
- package/bin/jbai-opencode-rockhopper.js +6 -0
- package/bin/jbai-opencode.js +122 -20
- package/bin/jbai-proxy.js +1110 -66
- package/bin/jbai.js +99 -42
- package/bin/test-cli-tictactoe.js +279 -0
- package/bin/test-clients.js +38 -6
- package/bin/test-model-lists.js +100 -0
- package/lib/completions.js +258 -0
- package/lib/config.js +46 -8
- package/lib/model-list.js +117 -0
- package/lib/postinstall.js +3 -0
- package/lib/proxy.js +46 -0
- package/lib/shortcut.js +47 -0
- package/package.json +13 -2
package/bin/jbai-proxy.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* /openai/v1/* → Grazie OpenAI endpoint (explicit)
|
|
14
14
|
* /anthropic/v1/* → Grazie Anthropic endpoint (explicit)
|
|
15
15
|
* /google/v1/* → Grazie Google endpoint (explicit)
|
|
16
|
+
* /grazie-openai/v1/* → OpenAI-compatible adapter over Grazie native Chat API
|
|
16
17
|
*
|
|
17
18
|
* /v1/chat/completions → OpenAI (auto-detect)
|
|
18
19
|
* /v1/completions → OpenAI (auto-detect)
|
|
@@ -45,6 +46,7 @@ const LOG_FILE = path.join(config.CONFIG_DIR, 'proxy.log');
|
|
|
45
46
|
// ---------------------------------------------------------------------------
|
|
46
47
|
let cachedToken = null;
|
|
47
48
|
let tokenMtime = 0;
|
|
49
|
+
let refreshInFlight = null;
|
|
48
50
|
|
|
49
51
|
function getToken() {
|
|
50
52
|
try {
|
|
@@ -59,6 +61,28 @@ function getToken() {
|
|
|
59
61
|
return cachedToken;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
// Auto-refresh: returns a valid token or throws
|
|
65
|
+
async function getValidToken() {
|
|
66
|
+
let token = getToken();
|
|
67
|
+
if (!token) return null;
|
|
68
|
+
if (!config.isTokenExpiringSoon(token)) return token;
|
|
69
|
+
|
|
70
|
+
// Coalesce concurrent refresh attempts into a single API call
|
|
71
|
+
if (!refreshInFlight) {
|
|
72
|
+
refreshInFlight = config.refreshToken()
|
|
73
|
+
.then((t) => { cachedToken = t; tokenMtime = 0; return t; })
|
|
74
|
+
.finally(() => { refreshInFlight = null; });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
return await refreshInFlight;
|
|
79
|
+
} catch {
|
|
80
|
+
// Refresh failed — return current token if not fully expired yet
|
|
81
|
+
if (!config.isTokenExpired(token)) return token;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
// ---------------------------------------------------------------------------
|
|
63
87
|
// Route resolution
|
|
64
88
|
// ---------------------------------------------------------------------------
|
|
@@ -68,22 +92,45 @@ function resolveRoute(method, urlPath) {
|
|
|
68
92
|
|
|
69
93
|
// Explicit provider prefix routes
|
|
70
94
|
if (urlPath.startsWith('/openai/')) {
|
|
71
|
-
// Intercept /openai/v1/models → return
|
|
95
|
+
// Intercept /openai/v1/models → return only OpenAI + Codex models
|
|
72
96
|
if (urlPath === '/openai/v1/models') {
|
|
73
|
-
return { target: null, provider: 'models' };
|
|
97
|
+
return { target: null, provider: 'openai-models' };
|
|
74
98
|
}
|
|
75
99
|
const rest = urlPath.slice('/openai'.length); // keeps /v1/...
|
|
76
100
|
return { target: endpoints.openai.replace(/\/v1$/, '') + rest, provider: 'openai' };
|
|
77
101
|
}
|
|
78
102
|
if (urlPath.startsWith('/anthropic/')) {
|
|
103
|
+
// Intercept /anthropic/v1/models → return only Claude models
|
|
104
|
+
if (urlPath === '/anthropic/v1/models') {
|
|
105
|
+
return { target: null, provider: 'anthropic-models' };
|
|
106
|
+
}
|
|
79
107
|
const rest = urlPath.slice('/anthropic'.length);
|
|
80
108
|
return { target: endpoints.anthropic.replace(/\/v1$/, '') + rest, provider: 'anthropic' };
|
|
81
109
|
}
|
|
82
110
|
if (urlPath.startsWith('/google/')) {
|
|
111
|
+
// Intercept /google/v1/models or /google/models → return only Gemini models
|
|
112
|
+
// (Gemini SDK may append /v1/models or just /models depending on version)
|
|
113
|
+
if (urlPath === '/google/v1/models' || urlPath === '/google/models') {
|
|
114
|
+
return { target: null, provider: 'google-models' };
|
|
115
|
+
}
|
|
83
116
|
const rest = urlPath.slice('/google'.length);
|
|
84
117
|
return { target: endpoints.google + rest, provider: 'google' };
|
|
85
118
|
}
|
|
86
119
|
|
|
120
|
+
// OpenAI-compatible adapter over Grazie native chat API
|
|
121
|
+
if (urlPath.startsWith('/grazie-openai/')) {
|
|
122
|
+
if (urlPath === '/grazie-openai/v1/models') {
|
|
123
|
+
return { target: null, provider: 'grazie-openai-models' };
|
|
124
|
+
}
|
|
125
|
+
if (urlPath === '/grazie-openai/v1/chat/completions') {
|
|
126
|
+
return { target: null, provider: 'grazie-openai-chat' };
|
|
127
|
+
}
|
|
128
|
+
if (urlPath === '/grazie-openai/v1/responses') {
|
|
129
|
+
return { target: null, provider: 'grazie-openai-responses' };
|
|
130
|
+
}
|
|
131
|
+
return { target: null, provider: 'grazie-openai-unknown' };
|
|
132
|
+
}
|
|
133
|
+
|
|
87
134
|
// Auto-detect routes based on standard SDK paths
|
|
88
135
|
// Anthropic SDK always calls /v1/messages
|
|
89
136
|
if (urlPath.startsWith('/v1/messages')) {
|
|
@@ -133,6 +180,932 @@ function buildModelsResponse() {
|
|
|
133
180
|
return { object: 'list', data: models };
|
|
134
181
|
}
|
|
135
182
|
|
|
183
|
+
function buildOpenAIModelsResponse() {
|
|
184
|
+
const now = Math.floor(Date.now() / 1000);
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
const models = [];
|
|
187
|
+
|
|
188
|
+
for (const m of config.MODELS.openai.available) {
|
|
189
|
+
models.push({ id: m, object: 'model', created: now, owned_by: 'openai' });
|
|
190
|
+
seen.add(m);
|
|
191
|
+
}
|
|
192
|
+
for (const m of config.MODELS.codex.available) {
|
|
193
|
+
if (!seen.has(m)) {
|
|
194
|
+
models.push({ id: m, object: 'model', created: now, owned_by: 'openai' });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { object: 'list', data: models };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildAnthropicModelsResponse() {
|
|
202
|
+
const now = Math.floor(Date.now() / 1000);
|
|
203
|
+
const models = config.MODELS.claude.available.map(m => ({
|
|
204
|
+
id: m, object: 'model', created: now, owned_by: 'anthropic'
|
|
205
|
+
}));
|
|
206
|
+
return { object: 'list', data: models };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function buildGoogleModelsResponse() {
|
|
210
|
+
const now = Math.floor(Date.now() / 1000);
|
|
211
|
+
const models = config.MODELS.gemini.available.map(m => ({
|
|
212
|
+
id: m, object: 'model', created: now, owned_by: 'google'
|
|
213
|
+
}));
|
|
214
|
+
return { object: 'list', data: models };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Grazie profiles → OpenAI models adapter
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
let profilesCache = { tokenHash: null, fetchedAt: 0, profiles: null };
|
|
222
|
+
|
|
223
|
+
function safeJsonParse(buf) {
|
|
224
|
+
try {
|
|
225
|
+
return JSON.parse(buf.toString('utf-8'));
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function tokenHash(token) {
|
|
232
|
+
// Cheap, non-cryptographic hash for caching isolation
|
|
233
|
+
let h = 0;
|
|
234
|
+
for (let i = 0; i < token.length; i++) h = (h * 31 + token.charCodeAt(i)) | 0;
|
|
235
|
+
return String(h);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function fetchProfiles(jwt, endpoints, ttlMs = 60_000) {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
const th = tokenHash(jwt);
|
|
241
|
+
if (profilesCache.profiles && profilesCache.tokenHash === th && (now - profilesCache.fetchedAt) < ttlMs) {
|
|
242
|
+
return Promise.resolve(profilesCache.profiles);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return new Promise((resolve, reject) => {
|
|
246
|
+
const url = new URL(endpoints.profiles);
|
|
247
|
+
const req = https.request({
|
|
248
|
+
hostname: url.hostname,
|
|
249
|
+
port: 443,
|
|
250
|
+
path: url.pathname + url.search,
|
|
251
|
+
method: 'GET',
|
|
252
|
+
headers: {
|
|
253
|
+
'Content-Type': 'application/json',
|
|
254
|
+
'Grazie-Authenticate-JWT': jwt,
|
|
255
|
+
'Grazie-Agent': JSON.stringify({ name: 'jbai-proxy', version: '1.0' }),
|
|
256
|
+
},
|
|
257
|
+
}, (r) => {
|
|
258
|
+
const chunks = [];
|
|
259
|
+
r.on('data', (c) => chunks.push(c));
|
|
260
|
+
r.on('end', () => {
|
|
261
|
+
const body = Buffer.concat(chunks);
|
|
262
|
+
if (r.statusCode !== 200) {
|
|
263
|
+
reject(new Error(`Profiles fetch failed (HTTP ${r.statusCode})`));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const parsed = safeJsonParse(body);
|
|
267
|
+
const profiles = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.profiles) ? parsed.profiles : null);
|
|
268
|
+
if (!profiles) {
|
|
269
|
+
reject(new Error('Profiles fetch failed (unexpected response shape)'));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
profilesCache = { tokenHash: th, fetchedAt: now, profiles };
|
|
273
|
+
resolve(profiles);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
req.on('error', reject);
|
|
278
|
+
req.end();
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function buildGrazieOpenAIModelsResponse(profiles) {
|
|
283
|
+
const now = Math.floor(Date.now() / 1000);
|
|
284
|
+
const data = profiles
|
|
285
|
+
.filter((p) => p && p.id && !p.deprecated)
|
|
286
|
+
.map((p) => ({
|
|
287
|
+
id: p.id,
|
|
288
|
+
object: 'model',
|
|
289
|
+
created: now,
|
|
290
|
+
owned_by: p.provider || 'grazie',
|
|
291
|
+
}));
|
|
292
|
+
return { object: 'list', data };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function extractTextContent(content) {
|
|
296
|
+
if (typeof content === 'string') return content;
|
|
297
|
+
if (Array.isArray(content)) {
|
|
298
|
+
const parts = [];
|
|
299
|
+
for (const item of content) {
|
|
300
|
+
if (!item || typeof item !== 'object') continue;
|
|
301
|
+
if (typeof item.text === 'string') parts.push(item.text);
|
|
302
|
+
else if ((item.type === 'text' || item.type === 'input_text' || item.type === 'output_text') && typeof item.text === 'string') parts.push(item.text);
|
|
303
|
+
}
|
|
304
|
+
return parts.join('\n');
|
|
305
|
+
}
|
|
306
|
+
return '';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function toOpenAiMessagesFromResponsesInput(input) {
|
|
310
|
+
// Minimal converter for OpenAI Responses API "input" shapes.
|
|
311
|
+
// Supports:
|
|
312
|
+
// - string
|
|
313
|
+
// - [{ role, content: string | [{type:"input_text",text}] }]
|
|
314
|
+
if (typeof input === 'string') {
|
|
315
|
+
return [{ role: 'user', content: input }];
|
|
316
|
+
}
|
|
317
|
+
if (!Array.isArray(input)) return [];
|
|
318
|
+
|
|
319
|
+
const out = [];
|
|
320
|
+
for (const item of input) {
|
|
321
|
+
if (!item || typeof item !== 'object') continue;
|
|
322
|
+
if (typeof item.role !== 'string') continue;
|
|
323
|
+
const role = item.role;
|
|
324
|
+
if (!['system', 'user', 'assistant', 'tool'].includes(role)) continue;
|
|
325
|
+
// OpenAI Responses uses content parts like {type:"input_text", text:"..."}
|
|
326
|
+
const content = extractTextContent(item.content);
|
|
327
|
+
out.push({ role, content });
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function toGrazieMessages(openAiMessages) {
|
|
333
|
+
const out = [];
|
|
334
|
+
for (const m of (openAiMessages || [])) {
|
|
335
|
+
const role = m && m.role;
|
|
336
|
+
|
|
337
|
+
// Tool result messages: OpenAI role:"tool" → Grazie "tool_message"
|
|
338
|
+
if (role === 'tool') {
|
|
339
|
+
out.push({
|
|
340
|
+
type: 'tool_message',
|
|
341
|
+
id: m.tool_call_id || '',
|
|
342
|
+
toolName: m.name || '',
|
|
343
|
+
result: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
|
344
|
+
});
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Assistant with tool_calls → emit assistant_message_tool for each call
|
|
349
|
+
if (role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
350
|
+
// If there's also text content, emit it as assistant_message_text first
|
|
351
|
+
const text = extractTextContent(m.content);
|
|
352
|
+
if (text) {
|
|
353
|
+
out.push({ type: 'assistant_message_text', content: text });
|
|
354
|
+
}
|
|
355
|
+
for (const tc of m.tool_calls) {
|
|
356
|
+
const fn = tc.function || {};
|
|
357
|
+
out.push({
|
|
358
|
+
type: 'assistant_message_tool',
|
|
359
|
+
id: tc.id || '',
|
|
360
|
+
toolName: fn.name || '',
|
|
361
|
+
content: fn.arguments || '',
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let type;
|
|
368
|
+
if (role === 'system') type = 'system_message';
|
|
369
|
+
else if (role === 'user') type = 'user_message';
|
|
370
|
+
else if (role === 'assistant') type = 'assistant_message_text';
|
|
371
|
+
else continue;
|
|
372
|
+
|
|
373
|
+
const content = extractTextContent(m.content);
|
|
374
|
+
out.push({ type, content });
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function toGrazieParameters(openAiBody) {
|
|
380
|
+
const data = [];
|
|
381
|
+
const add = (paramType, fqdn, value) => {
|
|
382
|
+
data.push({ type: paramType, fqdn, value });
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
if (openAiBody && typeof openAiBody.temperature === 'number') {
|
|
386
|
+
add('double', 'llm.parameters.temperature', openAiBody.temperature);
|
|
387
|
+
}
|
|
388
|
+
if (openAiBody && typeof openAiBody.top_p === 'number') {
|
|
389
|
+
add('double', 'llm.parameters.top-p', openAiBody.top_p);
|
|
390
|
+
}
|
|
391
|
+
const maxTokens = openAiBody && (openAiBody.max_output_tokens ?? openAiBody.max_tokens ?? openAiBody.max_completion_tokens);
|
|
392
|
+
if (Number.isInteger(maxTokens)) {
|
|
393
|
+
add('int', 'llm.parameters.length', maxTokens);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Stop sequences — Grazie uses 'stop-token' (string per token)
|
|
397
|
+
if (openAiBody && openAiBody.stop) {
|
|
398
|
+
const stops = Array.isArray(openAiBody.stop) ? openAiBody.stop : [openAiBody.stop];
|
|
399
|
+
// Send each stop token individually (Grazie accepts repeated keys)
|
|
400
|
+
for (const s of stops) {
|
|
401
|
+
add('string', 'llm.parameters.stop-token', s);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Tools — unwrap from OpenAI {type:"function",function:{...}} to Grazie {name,description,parameters:{schema}}
|
|
406
|
+
if (openAiBody && openAiBody.tools && openAiBody.tools.length) {
|
|
407
|
+
const grazieTools = toGrazieTools(openAiBody.tools);
|
|
408
|
+
add('json', 'llm.parameters.tools', grazieTools);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Tool choice — Grazie uses separate attribute keys, not a single JSON object
|
|
412
|
+
if (openAiBody && openAiBody.tool_choice !== undefined) {
|
|
413
|
+
const tc = openAiBody.tool_choice;
|
|
414
|
+
if (tc === 'auto') {
|
|
415
|
+
add('bool', 'llm.parameters.tool-choice-auto', true);
|
|
416
|
+
} else if (tc === 'none') {
|
|
417
|
+
add('bool', 'llm.parameters.tool-choice-none', true);
|
|
418
|
+
} else if (tc === 'required') {
|
|
419
|
+
add('bool', 'llm.parameters.tool-choice-required', true);
|
|
420
|
+
} else if (typeof tc === 'object' && tc.type === 'function' && tc.function) {
|
|
421
|
+
add('string', 'llm.parameters.tool-choice-named', tc.function.name);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Parallel tool calls
|
|
426
|
+
if (openAiBody && openAiBody.parallel_tool_calls !== undefined) {
|
|
427
|
+
add('bool', 'llm.parameters.parallel-tool-calls', !!openAiBody.parallel_tool_calls);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return data.length ? { data } : null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function toGrazieTools(openAiTools) {
|
|
434
|
+
if (!Array.isArray(openAiTools) || openAiTools.length === 0) return null;
|
|
435
|
+
return openAiTools.map((t) => {
|
|
436
|
+
const fn = (t && t.type === 'function' && t.function) ? t.function : t;
|
|
437
|
+
return {
|
|
438
|
+
name: fn && fn.name,
|
|
439
|
+
description: (fn && fn.description) || undefined,
|
|
440
|
+
parameters: (fn && fn.parameters) || undefined,
|
|
441
|
+
};
|
|
442
|
+
}).filter(t => t && typeof t.name === 'string' && t.name.length > 0);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function extractGrazieToolCallEvent(evt) {
|
|
446
|
+
if (!evt || typeof evt !== 'object') return null;
|
|
447
|
+
|
|
448
|
+
const tcIdFromEvt = typeof evt.id === 'string'
|
|
449
|
+
? evt.id
|
|
450
|
+
: (typeof evt.tool_call_id === 'string' ? evt.tool_call_id : (typeof evt.toolCallId === 'string' ? evt.toolCallId : ''));
|
|
451
|
+
|
|
452
|
+
const pti = Number.isInteger(evt.parallelToolIndex)
|
|
453
|
+
? evt.parallelToolIndex
|
|
454
|
+
: (Number.isInteger(evt.parallel_tool_index) ? evt.parallel_tool_index : null);
|
|
455
|
+
|
|
456
|
+
const tcName = typeof evt.name === 'string'
|
|
457
|
+
? evt.name
|
|
458
|
+
: (typeof evt.toolName === 'string' ? evt.toolName : (evt.function && typeof evt.function.name === 'string' ? evt.function.name : ''));
|
|
459
|
+
|
|
460
|
+
const tcChunk = (typeof evt.content === 'string')
|
|
461
|
+
? evt.content
|
|
462
|
+
: (typeof evt.arguments === 'string')
|
|
463
|
+
? evt.arguments
|
|
464
|
+
: (evt.function && typeof evt.function.arguments === 'string')
|
|
465
|
+
? evt.function.arguments
|
|
466
|
+
: (typeof evt.args === 'string' ? evt.args : '');
|
|
467
|
+
|
|
468
|
+
const looksLikeTool =
|
|
469
|
+
(typeof evt.type === 'string' && /(tool|function)_?call/i.test(evt.type)) ||
|
|
470
|
+
!!tcIdFromEvt ||
|
|
471
|
+
pti !== null ||
|
|
472
|
+
!!tcName ||
|
|
473
|
+
!!tcChunk;
|
|
474
|
+
|
|
475
|
+
if (!looksLikeTool) return null;
|
|
476
|
+
return { tcIdFromEvt, pti, tcName, tcChunk };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function openAiSseWrite(res, obj) {
|
|
480
|
+
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function openAiSseDone(res) {
|
|
484
|
+
res.write('data: [DONE]\n\n');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function handleGrazieOpenAIChat({ req, res, jwt, endpoints, urlPath, startTime, requestBody }) {
|
|
488
|
+
let parsed;
|
|
489
|
+
try {
|
|
490
|
+
parsed = JSON.parse(requestBody.toString('utf-8'));
|
|
491
|
+
} catch {
|
|
492
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
493
|
+
res.end(JSON.stringify({ error: { message: 'Invalid JSON body', type: 'invalid_request_error' } }));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const model = parsed.model;
|
|
498
|
+
if (!model || typeof model !== 'string') {
|
|
499
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
500
|
+
res.end(JSON.stringify({ error: { message: 'Missing required field: model', type: 'invalid_request_error' } }));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const messages = toGrazieMessages(parsed.messages);
|
|
505
|
+
const payload = {
|
|
506
|
+
profile: model,
|
|
507
|
+
chat: { messages },
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// Some Grazie backends expect tools on the chat payload (not only in parameters).
|
|
511
|
+
const chatTools = toGrazieTools(parsed.tools);
|
|
512
|
+
if (chatTools && chatTools.length) {
|
|
513
|
+
payload.chat.tools = chatTools;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const parameters = toGrazieParameters(parsed);
|
|
517
|
+
if (parameters) payload.parameters = parameters;
|
|
518
|
+
|
|
519
|
+
const stream = parsed.stream !== false;
|
|
520
|
+
const chatUrl = new URL(endpoints.base);
|
|
521
|
+
const chatPaths = [
|
|
522
|
+
'/user/v5/llm/chat/stream/v9',
|
|
523
|
+
'/user/v5/llm/chat/stream/v8',
|
|
524
|
+
];
|
|
525
|
+
const payloadStr = JSON.stringify(payload);
|
|
526
|
+
|
|
527
|
+
// Gap 4: retry helper — wraps the upstream call; retries once on 401 after token refresh
|
|
528
|
+
function doUpstream(currentJwt, isRetry, chatPathIndex = 0) {
|
|
529
|
+
const chatPath = chatPaths[Math.min(chatPathIndex, chatPaths.length - 1)];
|
|
530
|
+
const upstreamReq = https.request({
|
|
531
|
+
hostname: chatUrl.hostname,
|
|
532
|
+
port: 443,
|
|
533
|
+
path: chatPath,
|
|
534
|
+
method: 'POST',
|
|
535
|
+
headers: {
|
|
536
|
+
'Content-Type': 'application/json',
|
|
537
|
+
'Accept': 'text/event-stream',
|
|
538
|
+
'Grazie-Authenticate-JWT': currentJwt,
|
|
539
|
+
'Grazie-Agent': JSON.stringify({ name: 'jbai-proxy', version: '1.0' }),
|
|
540
|
+
},
|
|
541
|
+
}, (upstreamRes) => {
|
|
542
|
+
// Gap 4: on 401, refresh token and retry once
|
|
543
|
+
if (upstreamRes.statusCode === 401 && !isRetry) {
|
|
544
|
+
upstreamRes.resume();
|
|
545
|
+
log(`[grazie-openai] POST ${urlPath} → 401 from upstream, refreshing token…`);
|
|
546
|
+
config.refreshToken()
|
|
547
|
+
.then((fresh) => {
|
|
548
|
+
cachedToken = fresh;
|
|
549
|
+
tokenMtime = 0;
|
|
550
|
+
doUpstream(fresh, true, chatPathIndex);
|
|
551
|
+
})
|
|
552
|
+
.catch(() => {
|
|
553
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
554
|
+
res.end(JSON.stringify({ error: { message: 'Grazie token expired. Run: jbai token set', type: 'authentication_error' } }));
|
|
555
|
+
});
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Prefer v9, fall back to v8 if v9 is not available upstream.
|
|
560
|
+
if (upstreamRes.statusCode === 404 && chatPathIndex === 0 && chatPaths.length > 1) {
|
|
561
|
+
upstreamRes.resume();
|
|
562
|
+
log(`[grazie-openai] POST ${urlPath} → 404 on ${chatPath}, retrying with ${chatPaths[1]}…`);
|
|
563
|
+
doUpstream(currentJwt, isRetry, 1);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (upstreamRes.statusCode !== 200) {
|
|
568
|
+
const chunks = [];
|
|
569
|
+
upstreamRes.on('data', (c) => chunks.push(c));
|
|
570
|
+
upstreamRes.on('end', () => {
|
|
571
|
+
const msg = Buffer.concat(chunks).toString('utf-8');
|
|
572
|
+
res.writeHead(upstreamRes.statusCode || 502, { 'Content-Type': 'application/json' });
|
|
573
|
+
res.end(JSON.stringify({ error: { message: msg || `Upstream error (${upstreamRes.statusCode})`, type: 'upstream_error' } }));
|
|
574
|
+
});
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (stream) {
|
|
579
|
+
res.writeHead(200, {
|
|
580
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
581
|
+
'Cache-Control': 'no-cache',
|
|
582
|
+
'Connection': 'keep-alive',
|
|
583
|
+
'Access-Control-Allow-Origin': '*',
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const id = `chatcmpl_${Math.random().toString(16).slice(2)}`;
|
|
588
|
+
const created = Math.floor(Date.now() / 1000);
|
|
589
|
+
let full = '';
|
|
590
|
+
let sentRole = false;
|
|
591
|
+
// Tool call state: support both v9 (id/name/content) and legacy parallelToolIndex.
|
|
592
|
+
// seenTools maps a stable key → { index, id, name, args }
|
|
593
|
+
const seenTools = new Map();
|
|
594
|
+
const toolOrder = [];
|
|
595
|
+
let nextToolIndex = 0;
|
|
596
|
+
let finishReason = 'stop';
|
|
597
|
+
|
|
598
|
+
let buf = '';
|
|
599
|
+
upstreamRes.on('data', (chunk) => {
|
|
600
|
+
buf += chunk.toString('utf-8');
|
|
601
|
+
const lines = buf.split('\n');
|
|
602
|
+
buf = lines.pop() || '';
|
|
603
|
+
for (const line of lines) {
|
|
604
|
+
if (!line.startsWith('data: ')) continue;
|
|
605
|
+
const dataStr = line.slice(6).trim();
|
|
606
|
+
if (!dataStr) continue;
|
|
607
|
+
if (dataStr === 'end') continue;
|
|
608
|
+
let evt;
|
|
609
|
+
try {
|
|
610
|
+
evt = JSON.parse(dataStr);
|
|
611
|
+
} catch {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (!evt || typeof evt !== 'object') continue;
|
|
616
|
+
|
|
617
|
+
// Emit initial role delta before first content (OpenAI convention)
|
|
618
|
+
if (!sentRole && stream) {
|
|
619
|
+
openAiSseWrite(res, {
|
|
620
|
+
id,
|
|
621
|
+
object: 'chat.completion.chunk',
|
|
622
|
+
created,
|
|
623
|
+
model,
|
|
624
|
+
choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],
|
|
625
|
+
});
|
|
626
|
+
sentRole = true;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Error events
|
|
630
|
+
if (evt.type === 'Error') {
|
|
631
|
+
const errMsg = evt.content || evt.message || 'Unknown upstream error';
|
|
632
|
+
if (stream) {
|
|
633
|
+
openAiSseWrite(res, {
|
|
634
|
+
id,
|
|
635
|
+
object: 'chat.completion.chunk',
|
|
636
|
+
created,
|
|
637
|
+
model,
|
|
638
|
+
choices: [{ index: 0, delta: { content: `[Error: ${errMsg}]` }, finish_reason: null }],
|
|
639
|
+
});
|
|
640
|
+
} else {
|
|
641
|
+
full += `[Error: ${errMsg}]`;
|
|
642
|
+
}
|
|
643
|
+
log(`[grazie-openai] Upstream error event: ${errMsg}`);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Content chunks
|
|
648
|
+
if (evt.type === 'Content' && typeof evt.content === 'string') {
|
|
649
|
+
if (stream) {
|
|
650
|
+
openAiSseWrite(res, {
|
|
651
|
+
id,
|
|
652
|
+
object: 'chat.completion.chunk',
|
|
653
|
+
created,
|
|
654
|
+
model,
|
|
655
|
+
choices: [{ index: 0, delta: { content: evt.content }, finish_reason: null }],
|
|
656
|
+
});
|
|
657
|
+
} else {
|
|
658
|
+
full += evt.content;
|
|
659
|
+
}
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Tool call events: support v9 ToolCall plus older/variant shapes.
|
|
664
|
+
const tce = extractGrazieToolCallEvent(evt);
|
|
665
|
+
if (tce) {
|
|
666
|
+
const { tcIdFromEvt, pti, tcName, tcChunk } = tce;
|
|
667
|
+
const key = tcIdFromEvt
|
|
668
|
+
? `id:${tcIdFromEvt}`
|
|
669
|
+
: (pti !== null ? `pti:${pti}` : null);
|
|
670
|
+
if (!key) {
|
|
671
|
+
// If it looks like a tool call but lacks identifiers, log for discovery.
|
|
672
|
+
if (!evt.type) {
|
|
673
|
+
log(`[grazie-openai] Tool-like SSE event missing id/index: ${JSON.stringify(evt).slice(0, 200)}`);
|
|
674
|
+
}
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
let entry = seenTools.get(key);
|
|
679
|
+
const isFirst = !entry;
|
|
680
|
+
|
|
681
|
+
if (isFirst) {
|
|
682
|
+
const index = (pti !== null && pti >= 0) ? pti : nextToolIndex++;
|
|
683
|
+
const idVal = tcIdFromEvt || `call_${Math.random().toString(16).slice(2)}`;
|
|
684
|
+
entry = { index, id: idVal, name: tcName || '', args: tcChunk || '' };
|
|
685
|
+
seenTools.set(key, entry);
|
|
686
|
+
toolOrder.push(key);
|
|
687
|
+
finishReason = 'tool_calls';
|
|
688
|
+
|
|
689
|
+
if (stream) {
|
|
690
|
+
openAiSseWrite(res, {
|
|
691
|
+
id,
|
|
692
|
+
object: 'chat.completion.chunk',
|
|
693
|
+
created,
|
|
694
|
+
model,
|
|
695
|
+
choices: [{
|
|
696
|
+
index: 0,
|
|
697
|
+
delta: {
|
|
698
|
+
tool_calls: [{
|
|
699
|
+
index: entry.index,
|
|
700
|
+
id: entry.id,
|
|
701
|
+
type: 'function',
|
|
702
|
+
function: {
|
|
703
|
+
...(entry.name ? { name: entry.name } : {}),
|
|
704
|
+
...(tcChunk ? { arguments: tcChunk } : {}),
|
|
705
|
+
},
|
|
706
|
+
}],
|
|
707
|
+
},
|
|
708
|
+
finish_reason: null,
|
|
709
|
+
}],
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
} else {
|
|
713
|
+
// Subsequent chunk: append name/arguments as they arrive
|
|
714
|
+
let nameDelta = '';
|
|
715
|
+
if (tcName && !entry.name) {
|
|
716
|
+
entry.name = tcName;
|
|
717
|
+
nameDelta = tcName;
|
|
718
|
+
}
|
|
719
|
+
if (tcChunk) entry.args += tcChunk;
|
|
720
|
+
finishReason = 'tool_calls';
|
|
721
|
+
|
|
722
|
+
if (stream && (nameDelta || tcChunk)) {
|
|
723
|
+
openAiSseWrite(res, {
|
|
724
|
+
id,
|
|
725
|
+
object: 'chat.completion.chunk',
|
|
726
|
+
created,
|
|
727
|
+
model,
|
|
728
|
+
choices: [{
|
|
729
|
+
index: 0,
|
|
730
|
+
delta: {
|
|
731
|
+
tool_calls: [{
|
|
732
|
+
index: entry.index,
|
|
733
|
+
...(nameDelta ? { function: { name: nameDelta } } : {}),
|
|
734
|
+
...(tcChunk ? { function: { ...(nameDelta ? { name: nameDelta } : {}), arguments: tcChunk } } : {}),
|
|
735
|
+
}],
|
|
736
|
+
},
|
|
737
|
+
finish_reason: null,
|
|
738
|
+
}],
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// FinishMetadata — map reason to OpenAI format
|
|
746
|
+
if (evt.type === 'FinishMetadata') {
|
|
747
|
+
const reason = evt.reason;
|
|
748
|
+
if (reason === 'tool_call' || reason === 'function_call') {
|
|
749
|
+
finishReason = 'tool_calls';
|
|
750
|
+
} else if (reason === 'length') {
|
|
751
|
+
finishReason = 'length';
|
|
752
|
+
}
|
|
753
|
+
// 'stop' is already the default
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Log unrecognized event types for discovery
|
|
758
|
+
if (evt.type && !['Content', 'FinishMetadata', 'Error', 'ToolCall', 'tool_call', 'QuotaMetadata', 'UnknownMetadata'].includes(evt.type)) {
|
|
759
|
+
log(`[grazie-openai] Unrecognized SSE event type: ${evt.type} — ${JSON.stringify(evt).slice(0, 200)}`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
upstreamRes.on('end', () => {
|
|
765
|
+
if (stream) {
|
|
766
|
+
openAiSseWrite(res, {
|
|
767
|
+
id,
|
|
768
|
+
object: 'chat.completion.chunk',
|
|
769
|
+
created,
|
|
770
|
+
model,
|
|
771
|
+
choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
|
|
772
|
+
});
|
|
773
|
+
openAiSseDone(res);
|
|
774
|
+
res.end();
|
|
775
|
+
} else {
|
|
776
|
+
const message = { role: 'assistant', content: full || null };
|
|
777
|
+
// Build tool_calls array from accumulated seenTools
|
|
778
|
+
if (toolOrder.length > 0) {
|
|
779
|
+
message.tool_calls = toolOrder
|
|
780
|
+
.map((k) => seenTools.get(k))
|
|
781
|
+
.filter(Boolean)
|
|
782
|
+
.map((tc) => ({
|
|
783
|
+
id: tc.id,
|
|
784
|
+
type: 'function',
|
|
785
|
+
function: { name: tc.name, arguments: tc.args },
|
|
786
|
+
}));
|
|
787
|
+
}
|
|
788
|
+
res.writeHead(200, {
|
|
789
|
+
'Content-Type': 'application/json',
|
|
790
|
+
'Access-Control-Allow-Origin': '*',
|
|
791
|
+
});
|
|
792
|
+
res.end(JSON.stringify({
|
|
793
|
+
id,
|
|
794
|
+
object: 'chat.completion',
|
|
795
|
+
created,
|
|
796
|
+
model,
|
|
797
|
+
choices: [{ index: 0, message, finish_reason: finishReason }],
|
|
798
|
+
}));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
log(`[grazie-openai] POST ${urlPath} → 200 (${Date.now() - startTime}ms)`);
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
upstreamReq.on('error', (e) => {
|
|
806
|
+
if (!res.headersSent) {
|
|
807
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
808
|
+
res.end(JSON.stringify({ error: { message: `Upstream request failed: ${e.message}`, type: 'upstream_error' } }));
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
upstreamReq.write(payloadStr);
|
|
813
|
+
upstreamReq.end();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
doUpstream(jwt, false, 0);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function responsesSseWrite(res, obj) {
|
|
820
|
+
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function responsesSseDone(res) {
|
|
824
|
+
res.write('data: [DONE]\n\n');
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function handleGrazieOpenAIResponses({ req, res, jwt, endpoints, urlPath, startTime, requestBody }) {
|
|
828
|
+
let parsed;
|
|
829
|
+
try {
|
|
830
|
+
parsed = JSON.parse(requestBody.toString('utf-8'));
|
|
831
|
+
} catch {
|
|
832
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
833
|
+
res.end(JSON.stringify({ error: { message: 'Invalid JSON body', type: 'invalid_request_error' } }));
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const model = parsed.model;
|
|
838
|
+
if (!model || typeof model !== 'string') {
|
|
839
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
840
|
+
res.end(JSON.stringify({ error: { message: 'Missing required field: model', type: 'invalid_request_error' } }));
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Responses API: map instructions + input/messages into OpenAI-like messages
|
|
845
|
+
const openAiMessages = [];
|
|
846
|
+
if (typeof parsed.instructions === 'string' && parsed.instructions.trim()) {
|
|
847
|
+
openAiMessages.push({ role: 'system', content: parsed.instructions });
|
|
848
|
+
}
|
|
849
|
+
if (parsed.messages) {
|
|
850
|
+
// Some clients still send chat-completions style "messages".
|
|
851
|
+
openAiMessages.push(...(Array.isArray(parsed.messages) ? parsed.messages : []));
|
|
852
|
+
} else if (parsed.input !== undefined) {
|
|
853
|
+
openAiMessages.push(...toOpenAiMessagesFromResponsesInput(parsed.input));
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const messages = toGrazieMessages(openAiMessages);
|
|
857
|
+
const payload = {
|
|
858
|
+
profile: model,
|
|
859
|
+
chat: { messages },
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const chatTools = toGrazieTools(parsed.tools);
|
|
863
|
+
if (chatTools && chatTools.length) {
|
|
864
|
+
payload.chat.tools = chatTools;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const parameters = toGrazieParameters(parsed);
|
|
868
|
+
if (parameters) payload.parameters = parameters;
|
|
869
|
+
|
|
870
|
+
const stream = parsed.stream === true;
|
|
871
|
+
const chatUrl = new URL(endpoints.base);
|
|
872
|
+
const chatPaths = [
|
|
873
|
+
'/user/v5/llm/chat/stream/v9',
|
|
874
|
+
'/user/v5/llm/chat/stream/v8',
|
|
875
|
+
];
|
|
876
|
+
const payloadStr = JSON.stringify(payload);
|
|
877
|
+
|
|
878
|
+
function doUpstream(currentJwt, isRetry, chatPathIndex = 0) {
|
|
879
|
+
const chatPath = chatPaths[Math.min(chatPathIndex, chatPaths.length - 1)];
|
|
880
|
+
const upstreamReq = https.request({
|
|
881
|
+
hostname: chatUrl.hostname,
|
|
882
|
+
port: 443,
|
|
883
|
+
path: chatPath,
|
|
884
|
+
method: 'POST',
|
|
885
|
+
headers: {
|
|
886
|
+
'Content-Type': 'application/json',
|
|
887
|
+
'Accept': 'text/event-stream',
|
|
888
|
+
'Grazie-Authenticate-JWT': currentJwt,
|
|
889
|
+
'Grazie-Agent': JSON.stringify({ name: 'jbai-proxy', version: '1.0' }),
|
|
890
|
+
},
|
|
891
|
+
}, (upstreamRes) => {
|
|
892
|
+
if (upstreamRes.statusCode === 401 && !isRetry) {
|
|
893
|
+
upstreamRes.resume();
|
|
894
|
+
log(`[grazie-openai] POST ${urlPath} → 401 from upstream, refreshing token…`);
|
|
895
|
+
config.refreshToken()
|
|
896
|
+
.then((fresh) => {
|
|
897
|
+
cachedToken = fresh;
|
|
898
|
+
tokenMtime = 0;
|
|
899
|
+
doUpstream(fresh, true, chatPathIndex);
|
|
900
|
+
})
|
|
901
|
+
.catch(() => {
|
|
902
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
903
|
+
res.end(JSON.stringify({ error: { message: 'Grazie token expired. Run: jbai token set', type: 'authentication_error' } }));
|
|
904
|
+
});
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (upstreamRes.statusCode === 404 && chatPathIndex === 0 && chatPaths.length > 1) {
|
|
909
|
+
upstreamRes.resume();
|
|
910
|
+
log(`[grazie-openai] POST ${urlPath} → 404 on ${chatPath}, retrying with ${chatPaths[1]}…`);
|
|
911
|
+
doUpstream(currentJwt, isRetry, 1);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (upstreamRes.statusCode !== 200) {
|
|
916
|
+
const chunks = [];
|
|
917
|
+
upstreamRes.on('data', (c) => chunks.push(c));
|
|
918
|
+
upstreamRes.on('end', () => {
|
|
919
|
+
const msg = Buffer.concat(chunks).toString('utf-8');
|
|
920
|
+
res.writeHead(upstreamRes.statusCode || 502, { 'Content-Type': 'application/json' });
|
|
921
|
+
res.end(JSON.stringify({ error: { message: msg || `Upstream error (${upstreamRes.statusCode})`, type: 'upstream_error' } }));
|
|
922
|
+
});
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const id = `resp_${Math.random().toString(16).slice(2)}`;
|
|
927
|
+
const createdAt = Math.floor(Date.now() / 1000);
|
|
928
|
+
|
|
929
|
+
let fullText = '';
|
|
930
|
+
const seenTools = new Map();
|
|
931
|
+
const toolOrder = [];
|
|
932
|
+
let nextToolIndex = 0;
|
|
933
|
+
|
|
934
|
+
const output = [];
|
|
935
|
+
const messageItemId = `msg_${Math.random().toString(16).slice(2)}`;
|
|
936
|
+
const messageOutputIndex = 0;
|
|
937
|
+
output.push({
|
|
938
|
+
id: messageItemId,
|
|
939
|
+
type: 'message',
|
|
940
|
+
role: 'assistant',
|
|
941
|
+
content: [{ type: 'output_text', text: '' }],
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
const responseObj = () => ({
|
|
945
|
+
id,
|
|
946
|
+
object: 'response',
|
|
947
|
+
created_at: createdAt,
|
|
948
|
+
model,
|
|
949
|
+
output,
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
if (stream) {
|
|
953
|
+
res.writeHead(200, {
|
|
954
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
955
|
+
'Cache-Control': 'no-cache',
|
|
956
|
+
'Connection': 'keep-alive',
|
|
957
|
+
'Access-Control-Allow-Origin': '*',
|
|
958
|
+
});
|
|
959
|
+
responsesSseWrite(res, { type: 'response.created', response: responseObj() });
|
|
960
|
+
responsesSseWrite(res, {
|
|
961
|
+
type: 'response.output_item.added',
|
|
962
|
+
output_index: messageOutputIndex,
|
|
963
|
+
item: output[messageOutputIndex],
|
|
964
|
+
});
|
|
965
|
+
responsesSseWrite(res, {
|
|
966
|
+
type: 'response.content_part.added',
|
|
967
|
+
output_index: messageOutputIndex,
|
|
968
|
+
content_index: 0,
|
|
969
|
+
part: output[messageOutputIndex].content[0],
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
let buf = '';
|
|
974
|
+
upstreamRes.on('data', (chunk) => {
|
|
975
|
+
buf += chunk.toString('utf-8');
|
|
976
|
+
const lines = buf.split('\n');
|
|
977
|
+
buf = lines.pop() || '';
|
|
978
|
+
for (const line of lines) {
|
|
979
|
+
if (!line.startsWith('data: ')) continue;
|
|
980
|
+
const dataStr = line.slice(6).trim();
|
|
981
|
+
if (!dataStr) continue;
|
|
982
|
+
if (dataStr === 'end') continue;
|
|
983
|
+
let evt;
|
|
984
|
+
try {
|
|
985
|
+
evt = JSON.parse(dataStr);
|
|
986
|
+
} catch {
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
if (!evt || typeof evt !== 'object') continue;
|
|
990
|
+
|
|
991
|
+
if (evt.type === 'Error') {
|
|
992
|
+
const errMsg = evt.content || evt.message || 'Unknown upstream error';
|
|
993
|
+
if (stream) {
|
|
994
|
+
responsesSseWrite(res, { type: 'response.output_text.delta', output_index: messageOutputIndex, content_index: 0, delta: `\n[Error: ${errMsg}]` });
|
|
995
|
+
}
|
|
996
|
+
fullText += `\n[Error: ${errMsg}]`;
|
|
997
|
+
output[messageOutputIndex].content[0].text = fullText;
|
|
998
|
+
log(`[grazie-openai] Upstream error event: ${errMsg}`);
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (evt.type === 'Content' && typeof evt.content === 'string') {
|
|
1003
|
+
fullText += evt.content;
|
|
1004
|
+
output[messageOutputIndex].content[0].text = fullText;
|
|
1005
|
+
if (stream) {
|
|
1006
|
+
responsesSseWrite(res, { type: 'response.output_text.delta', output_index: messageOutputIndex, content_index: 0, delta: evt.content });
|
|
1007
|
+
}
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const tce = extractGrazieToolCallEvent(evt);
|
|
1012
|
+
if (tce) {
|
|
1013
|
+
const { tcIdFromEvt, pti, tcName, tcChunk } = tce;
|
|
1014
|
+
const key = tcIdFromEvt
|
|
1015
|
+
? `id:${tcIdFromEvt}`
|
|
1016
|
+
: (pti !== null ? `pti:${pti}` : null);
|
|
1017
|
+
if (!key) {
|
|
1018
|
+
if (!evt.type) {
|
|
1019
|
+
log(`[grazie-openai] Tool-like SSE event missing id/index (responses): ${JSON.stringify(evt).slice(0, 200)}`);
|
|
1020
|
+
}
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
let entry = seenTools.get(key);
|
|
1025
|
+
const isFirst = !entry;
|
|
1026
|
+
if (isFirst) {
|
|
1027
|
+
const index = (pti !== null && pti >= 0) ? pti : nextToolIndex++;
|
|
1028
|
+
const callId = tcIdFromEvt || `call_${Math.random().toString(16).slice(2)}`;
|
|
1029
|
+
entry = { index, id: callId, name: tcName || '', args: tcChunk || '' };
|
|
1030
|
+
seenTools.set(key, entry);
|
|
1031
|
+
toolOrder.push(key);
|
|
1032
|
+
|
|
1033
|
+
const outputIndex = output.length;
|
|
1034
|
+
entry.outputIndex = outputIndex;
|
|
1035
|
+
output.push({
|
|
1036
|
+
id: entry.id,
|
|
1037
|
+
type: 'function_call',
|
|
1038
|
+
call_id: entry.id,
|
|
1039
|
+
name: entry.name,
|
|
1040
|
+
arguments: entry.args,
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
if (stream) {
|
|
1044
|
+
responsesSseWrite(res, { type: 'response.output_item.added', output_index: outputIndex, item: output[outputIndex] });
|
|
1045
|
+
if (tcChunk) {
|
|
1046
|
+
responsesSseWrite(res, { type: 'response.function_call_arguments.delta', output_index: outputIndex, item_id: entry.id, delta: tcChunk });
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
} else {
|
|
1050
|
+
let argsDelta = '';
|
|
1051
|
+
if (tcName && !entry.name) {
|
|
1052
|
+
entry.name = tcName;
|
|
1053
|
+
const item = output[entry.outputIndex];
|
|
1054
|
+
if (item) item.name = tcName;
|
|
1055
|
+
}
|
|
1056
|
+
if (tcChunk) {
|
|
1057
|
+
entry.args += tcChunk;
|
|
1058
|
+
argsDelta = tcChunk;
|
|
1059
|
+
const item = output[entry.outputIndex];
|
|
1060
|
+
if (item) item.arguments = entry.args;
|
|
1061
|
+
}
|
|
1062
|
+
if (stream && argsDelta) {
|
|
1063
|
+
responsesSseWrite(res, { type: 'response.function_call_arguments.delta', output_index: entry.outputIndex, item_id: entry.id, delta: argsDelta });
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Log unrecognized event types for discovery (helps map tool calling variants)
|
|
1070
|
+
if (evt.type && !['Content', 'Error', 'ToolCall', 'tool_call', 'FinishMetadata', 'QuotaMetadata', 'UnknownMetadata'].includes(evt.type)) {
|
|
1071
|
+
log(`[grazie-openai] Unrecognized SSE event type (responses): ${evt.type} — ${JSON.stringify(evt).slice(0, 200)}`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
upstreamRes.on('end', () => {
|
|
1077
|
+
const finalResponse = responseObj();
|
|
1078
|
+
|
|
1079
|
+
if (stream) {
|
|
1080
|
+
responsesSseWrite(res, { type: 'response.completed', response: finalResponse });
|
|
1081
|
+
responsesSseDone(res);
|
|
1082
|
+
res.end();
|
|
1083
|
+
} else {
|
|
1084
|
+
res.writeHead(200, {
|
|
1085
|
+
'Content-Type': 'application/json',
|
|
1086
|
+
'Access-Control-Allow-Origin': '*',
|
|
1087
|
+
});
|
|
1088
|
+
res.end(JSON.stringify(finalResponse));
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
log(`[grazie-openai] POST ${urlPath} → 200 (${Date.now() - startTime}ms)`);
|
|
1092
|
+
});
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
upstreamReq.on('error', (e) => {
|
|
1096
|
+
if (!res.headersSent) {
|
|
1097
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
1098
|
+
res.end(JSON.stringify({ error: { message: `Upstream request failed: ${e.message}`, type: 'upstream_error' } }));
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
upstreamReq.write(payloadStr);
|
|
1103
|
+
upstreamReq.end();
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
doUpstream(jwt, false, 0);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
136
1109
|
// Codex CLI model picker response (matches chatgpt.com/backend-api/codex/models format)
|
|
137
1110
|
function buildCodexModelsResponse() {
|
|
138
1111
|
const descriptions = {
|
|
@@ -209,6 +1182,7 @@ function proxy(req, res) {
|
|
|
209
1182
|
openai: 'http://localhost:' + (res.socket?.localPort || DEFAULT_PORT) + '/openai/v1 OR /v1/chat/completions',
|
|
210
1183
|
anthropic: 'http://localhost:' + (res.socket?.localPort || DEFAULT_PORT) + '/anthropic/v1 OR /v1/messages',
|
|
211
1184
|
google: 'http://localhost:' + (res.socket?.localPort || DEFAULT_PORT) + '/google/v1',
|
|
1185
|
+
grazie_openai: 'http://localhost:' + (res.socket?.localPort || DEFAULT_PORT) + '/grazie-openai/v1',
|
|
212
1186
|
}
|
|
213
1187
|
};
|
|
214
1188
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -221,35 +1195,93 @@ function proxy(req, res) {
|
|
|
221
1195
|
return;
|
|
222
1196
|
}
|
|
223
1197
|
|
|
224
|
-
// Synthetic models
|
|
225
|
-
if (route.provider === 'models') {
|
|
1198
|
+
// Synthetic models endpoints (provider-specific and catch-all)
|
|
1199
|
+
if (route.provider === 'models' || route.provider === 'openai-models' || route.provider === 'anthropic-models' || route.provider === 'google-models') {
|
|
1200
|
+
let body;
|
|
1201
|
+
switch (route.provider) {
|
|
1202
|
+
case 'openai-models': body = buildOpenAIModelsResponse(); break;
|
|
1203
|
+
case 'anthropic-models': body = buildAnthropicModelsResponse(); break;
|
|
1204
|
+
case 'google-models': body = buildGoogleModelsResponse(); break;
|
|
1205
|
+
default: body = buildModelsResponse(); break;
|
|
1206
|
+
}
|
|
226
1207
|
res.writeHead(200, {
|
|
227
1208
|
'Content-Type': 'application/json',
|
|
228
1209
|
'Access-Control-Allow-Origin': '*',
|
|
229
1210
|
});
|
|
230
|
-
res.end(JSON.stringify(
|
|
231
|
-
log(`[
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Get token
|
|
236
|
-
const token = getToken();
|
|
237
|
-
if (!token) {
|
|
238
|
-
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
239
|
-
res.end(JSON.stringify({ error: { message: 'No Grazie token found. Run: jbai token set', type: 'authentication_error' } }));
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (config.isTokenExpired(token)) {
|
|
244
|
-
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
245
|
-
res.end(JSON.stringify({ error: { message: 'Grazie token expired. Run: jbai token set', type: 'authentication_error' } }));
|
|
1211
|
+
res.end(JSON.stringify(body));
|
|
1212
|
+
log(`[${route.provider}] GET ${urlPath} → 200 (${Date.now() - startTime}ms)`);
|
|
246
1213
|
return;
|
|
247
1214
|
}
|
|
248
1215
|
|
|
249
|
-
// Read request body
|
|
1216
|
+
// Read request body, then authenticate + forward
|
|
250
1217
|
const chunks = [];
|
|
251
1218
|
req.on('data', (chunk) => chunks.push(chunk));
|
|
252
|
-
req.on('end', () => {
|
|
1219
|
+
req.on('end', async () => {
|
|
1220
|
+
// --- Token (with auto-refresh) ---
|
|
1221
|
+
let token;
|
|
1222
|
+
try {
|
|
1223
|
+
token = await getValidToken();
|
|
1224
|
+
} catch {
|
|
1225
|
+
token = null;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (!token) {
|
|
1229
|
+
const msg = getToken() ? 'Grazie token expired. Run: jbai token set' : 'No Grazie token found. Run: jbai token set';
|
|
1230
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1231
|
+
res.end(JSON.stringify({ error: { message: msg, type: 'authentication_error' } }));
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Grazie → OpenAI adapter endpoints
|
|
1236
|
+
if (route.provider === 'grazie-openai-models') {
|
|
1237
|
+
try {
|
|
1238
|
+
const profiles = await fetchProfiles(token, config.getEndpoints());
|
|
1239
|
+
const body = buildGrazieOpenAIModelsResponse(profiles);
|
|
1240
|
+
res.writeHead(200, {
|
|
1241
|
+
'Content-Type': 'application/json',
|
|
1242
|
+
'Access-Control-Allow-Origin': '*',
|
|
1243
|
+
});
|
|
1244
|
+
res.end(JSON.stringify(body));
|
|
1245
|
+
log(`[grazie-openai-models] GET ${urlPath} → 200 (${Date.now() - startTime}ms)`);
|
|
1246
|
+
} catch (e) {
|
|
1247
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
1248
|
+
res.end(JSON.stringify({ error: { message: e.message || 'Failed to fetch profiles', type: 'upstream_error' } }));
|
|
1249
|
+
}
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
if (route.provider === 'grazie-openai-chat') {
|
|
1254
|
+
handleGrazieOpenAIChat({
|
|
1255
|
+
req,
|
|
1256
|
+
res,
|
|
1257
|
+
jwt: token,
|
|
1258
|
+
endpoints: config.getEndpoints(),
|
|
1259
|
+
urlPath,
|
|
1260
|
+
startTime,
|
|
1261
|
+
requestBody: Buffer.concat(chunks),
|
|
1262
|
+
});
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (route.provider === 'grazie-openai-responses') {
|
|
1267
|
+
handleGrazieOpenAIResponses({
|
|
1268
|
+
req,
|
|
1269
|
+
res,
|
|
1270
|
+
jwt: token,
|
|
1271
|
+
endpoints: config.getEndpoints(),
|
|
1272
|
+
urlPath,
|
|
1273
|
+
startTime,
|
|
1274
|
+
requestBody: Buffer.concat(chunks),
|
|
1275
|
+
});
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (route.provider === 'grazie-openai-unknown') {
|
|
1280
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1281
|
+
res.end(JSON.stringify({ error: { message: `Unknown grazie-openai route: ${urlPath}`, type: 'invalid_request_error' } }));
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
253
1285
|
let body = Buffer.concat(chunks);
|
|
254
1286
|
|
|
255
1287
|
// Rewrite model aliases so Grazie accepts the request
|
|
@@ -269,57 +1301,69 @@ function proxy(req, res) {
|
|
|
269
1301
|
|
|
270
1302
|
const targetUrl = new URL(route.target + (query ? '?' + query : ''));
|
|
271
1303
|
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
1304
|
+
// --- Forward helper (used for initial attempt + 401 retry) ---
|
|
1305
|
+
function forward(jwt, isRetry) {
|
|
1306
|
+
const fwdHeaders = {};
|
|
1307
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
1308
|
+
const lower = key.toLowerCase();
|
|
1309
|
+
if (['host', 'connection', 'keep-alive', 'transfer-encoding', 'te', 'trailer', 'upgrade'].includes(lower)) continue;
|
|
1310
|
+
if (lower === 'authorization') continue;
|
|
1311
|
+
fwdHeaders[key] = value;
|
|
1312
|
+
}
|
|
1313
|
+
fwdHeaders['Grazie-Authenticate-JWT'] = jwt;
|
|
1314
|
+
if (body.length > 0) {
|
|
1315
|
+
fwdHeaders['content-length'] = body.length;
|
|
1316
|
+
}
|
|
282
1317
|
|
|
283
|
-
|
|
284
|
-
|
|
1318
|
+
const proxyReq = https.request({
|
|
1319
|
+
hostname: targetUrl.hostname,
|
|
1320
|
+
port: 443,
|
|
1321
|
+
path: targetUrl.pathname + targetUrl.search,
|
|
1322
|
+
method: req.method,
|
|
1323
|
+
headers: fwdHeaders,
|
|
1324
|
+
}, (proxyRes) => {
|
|
1325
|
+
// On 401 from Grazie, try refreshing the token once
|
|
1326
|
+
if (proxyRes.statusCode === 401 && !isRetry) {
|
|
1327
|
+
// Consume the error response before retrying
|
|
1328
|
+
proxyRes.resume();
|
|
1329
|
+
log(`[${route.provider}] ${req.method} ${urlPath} → 401 from upstream, refreshing token…`);
|
|
1330
|
+
config.refreshToken()
|
|
1331
|
+
.then((fresh) => {
|
|
1332
|
+
cachedToken = fresh;
|
|
1333
|
+
tokenMtime = 0;
|
|
1334
|
+
forward(fresh, true);
|
|
1335
|
+
})
|
|
1336
|
+
.catch(() => {
|
|
1337
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1338
|
+
res.end(JSON.stringify({ error: { message: 'Grazie token expired. Run: jbai token set', type: 'authentication_error' } }));
|
|
1339
|
+
});
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
285
1342
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
1343
|
+
const resHeaders = { ...proxyRes.headers, 'Access-Control-Allow-Origin': '*' };
|
|
1344
|
+
res.writeHead(proxyRes.statusCode, resHeaders);
|
|
1345
|
+
proxyRes.pipe(res);
|
|
1346
|
+
proxyRes.on('end', () => {
|
|
1347
|
+
const elapsed = Date.now() - startTime;
|
|
1348
|
+
log(`[${route.provider}] ${req.method} ${urlPath} → ${proxyRes.statusCode} (${elapsed}ms)`);
|
|
1349
|
+
});
|
|
1350
|
+
});
|
|
290
1351
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}, (proxyRes) => {
|
|
298
|
-
// Forward status and headers
|
|
299
|
-
const resHeaders = { ...proxyRes.headers, 'Access-Control-Allow-Origin': '*' };
|
|
300
|
-
res.writeHead(proxyRes.statusCode, resHeaders);
|
|
301
|
-
|
|
302
|
-
// Stream response (supports SSE streaming)
|
|
303
|
-
proxyRes.pipe(res);
|
|
304
|
-
|
|
305
|
-
proxyRes.on('end', () => {
|
|
306
|
-
const elapsed = Date.now() - startTime;
|
|
307
|
-
log(`[${route.provider}] ${req.method} ${urlPath} → ${proxyRes.statusCode} (${elapsed}ms)`);
|
|
1352
|
+
proxyReq.on('error', (err) => {
|
|
1353
|
+
log(`[${route.provider}] ${req.method} ${urlPath} → ERROR: ${err.message}`);
|
|
1354
|
+
if (!res.headersSent) {
|
|
1355
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
1356
|
+
res.end(JSON.stringify({ error: { message: `Proxy error: ${err.message}`, type: 'proxy_error' } }));
|
|
1357
|
+
}
|
|
308
1358
|
});
|
|
309
|
-
});
|
|
310
1359
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if (!res.headersSent) {
|
|
314
|
-
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
315
|
-
res.end(JSON.stringify({ error: { message: `Proxy error: ${err.message}`, type: 'proxy_error' } }));
|
|
1360
|
+
if (body.length > 0) {
|
|
1361
|
+
proxyReq.write(body);
|
|
316
1362
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (body.length > 0) {
|
|
320
|
-
proxyReq.write(body);
|
|
1363
|
+
proxyReq.end();
|
|
321
1364
|
}
|
|
322
|
-
|
|
1365
|
+
|
|
1366
|
+
forward(token, false);
|
|
323
1367
|
});
|
|
324
1368
|
}
|
|
325
1369
|
|