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/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 synthetic list (Grazie doesn't list codex models)
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 endpoint
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(buildModelsResponse()));
231
- log(`[models] GET /v1/models → 200 (${Date.now() - startTime}ms)`);
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
- // Build forwarded headers - pass through everything except host/authorization
273
- const fwdHeaders = {};
274
- for (const [key, value] of Object.entries(req.headers)) {
275
- const lower = key.toLowerCase();
276
- // Skip hop-by-hop and host headers
277
- if (['host', 'connection', 'keep-alive', 'transfer-encoding', 'te', 'trailer', 'upgrade'].includes(lower)) continue;
278
- // Skip authorization (we inject our own)
279
- if (lower === 'authorization') continue;
280
- fwdHeaders[key] = value;
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
- // Inject Grazie auth
284
- fwdHeaders['Grazie-Authenticate-JWT'] = token;
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
- // Ensure content-length is correct for the body we have
287
- if (body.length > 0) {
288
- fwdHeaders['content-length'] = body.length;
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
- const proxyReq = https.request({
292
- hostname: targetUrl.hostname,
293
- port: 443,
294
- path: targetUrl.pathname + targetUrl.search,
295
- method: req.method,
296
- headers: fwdHeaders,
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
- proxyReq.on('error', (err) => {
312
- log(`[${route.provider}] ${req.method} ${urlPath} → ERROR: ${err.message}`);
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
- proxyReq.end();
1365
+
1366
+ forward(token, false);
323
1367
  });
324
1368
  }
325
1369