openvibers/openvibe.ai Commits
AI: OpenAI Responses API and Gemini generateContent compatible routes (#521)
Changes
@@@ -1,13 +1,602 @@'use strict';++/**++ * The newest compatible routes, which reuse the Chat Completions path rather than a second router:++ *++ * POST /v1/responses OpenAI's Responses API (stateless), converted in and out++ * POST /v1beta/models/{model}:generateContent Gemini's generateContent, converted in and out++ * POST /v1beta/models/{model}:streamGenerateContent the same stream, SSE with ?alt=sse, else a JSON array++ *++ * Each request becomes one POST /v1/chat/completions on the same connection (the way /v1/completions++ * falls through), so it is the same run, auth, quota, hold, routing, usage record and receipt. The++ * response is converted back on res.json / res.write. Gemini also accepts x-goog-api-key and ?key=++ * carrying an OpenVibe key, and its errors take Google's { error: { code, message, status } } shape.++ */const express = require('express');const { CAPS } = require('../auth');const { appAttribution } = require('../apps');const { AiError, sendError } = require('../util');++function accounting(run) {return { cost: Number(run.usage.cost_usd || 0), latency: Math.max(0, Date.parse(run.finished_at) - Date.parse(run.started_at || run.created_at)) || 0,provider: run.provenance?.provider || null };}++const INVALID = (message) => new AiError(400, 'input.invalid', message);++const passThrough = ['max_cost_usd', 'on_behalf_of', 'policy', 'service_tier', 'data_policy', 'x_openvibe', 'provider', 'models', 'prefer', 'effort', 'agent_role', 'kind'];++++// --- OpenAI error shape (the same as server/api/openai.js) -------------------------------------------------------++++// openaiError (below, with the transcription routes' helpers) writes OpenAI's { error } shape for every route here.++++/** A problem+json from the auth layer is converted to OpenAI's { error } as it is written. */++function asOpenAiErrors(req, res, next) {++ const end = res.end.bind(res);++ res.end = (chunk, ...rest) => {++ const ct = String(res.getHeader('Content-Type') || '');++ if (res.statusCode >= 400 && /problem\+json/.test(ct) && chunk) {++ let p = null;++ try { p = JSON.parse(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); } catch { p = null; }++ if (p && p.error == null) {++ const body = JSON.stringify({ error: { message: String(p.detail || p.title || p.code || 'error'), type: res.statusCode === 401 || res.statusCode === 403 ? 'authentication_error' : res.statusCode >= 500 ? 'api_error' : 'invalid_request_error', code: p.code || null, param: null } });++ res.setHeader('Content-Type', 'application/json; charset=utf-8');++ res.setHeader('Content-Length', Buffer.byteLength(body));++ return end(body);++ }++ }++ return end(chunk, ...rest);++ };++ next();++}++++// --- OpenAI Responses API -----------------------------------------------------------------------------------------++++const runIdOf = (id) => String(id || '').replace(/^chatcmpl-/, '') || `run_${Date.now()}`;++++/** One Responses content array -> a Chat content value (a string when it is all text). */++function responsesContent(content) {++ if (content == null) return '';++ if (typeof content === 'string') return content;++ if (!Array.isArray(content)) throw INVALID('message content must be a string or an array of content parts');++ const parts = [];++ for (const p of content) {++ if (typeof p === 'string') { parts.push({ type: 'text', text: p }); continue; }++ if (!p || typeof p !== 'object') throw INVALID('message content parts must be objects');++ if (p.type === 'input_text' || p.type === 'output_text' || p.type === 'text') {++ parts.push({ type: 'text', text: String(p.text == null ? '' : p.text) });++ } else if (p.type === 'input_image' || p.type === 'image_url') {++ const url = typeof p.image_url === 'string' ? p.image_url : p.image_url && p.image_url.url;++ if (typeof url !== 'string' || !/^(data:image\/|https:\/\/)/i.test(url)) throw INVALID('input_image.image_url must be a data:image URL or an https URL');++ parts.push({ type: 'image_url', image_url: { url, ...(p.detail ? { detail: p.detail } : {}) } });++ } else throw INVALID(`unsupported content part type ${String(p.type || 'unknown')}`);++ }++ if (!parts.length) return '';++ return parts.every((p) => p.type === 'text') ? parts.map((p) => p.text).join('') : parts;++}++++/** One Responses input item (a message, or a tool call / result from a previous response). */++function responsesItem(item) {++ if (!item || typeof item !== 'object' || Array.isArray(item)) throw INVALID('input items must be objects');++ if (item.type === 'function_call') {++ if (typeof item.name !== 'string' || !item.name) throw INVALID('function_call.name is required');++ return [{ role: 'assistant', content: null, tool_calls: [{ id: item.call_id || item.id || `call_${Date.now()}`, type: 'function',++ function: { name: item.name, arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments || {}) } }] }];++ }++ if (item.type === 'function_call_output') {++ if (typeof item.call_id !== 'string' || !item.call_id) throw INVALID('function_call_output.call_id is required');++ return [{ role: 'tool', tool_call_id: item.call_id, content: typeof item.output === 'string' ? item.output : JSON.stringify(item.output == null ? '' : item.output) }];++ }++ const role = item.role === 'developer' ? 'system' : item.role;++ if (!['system', 'user', 'assistant', 'tool'].includes(role)) throw INVALID('message.role must be user, assistant, system or developer');++ return [{ role, content: responsesContent(item.content) }];++}++++/** Responses function tools are flat ({ type, name, parameters }); Chat tools nest them under `function`. */++function responsesTools(tools) {++ if (tools == null) return undefined;++ if (!Array.isArray(tools)) throw INVALID('tools must be an array');++ return tools.map((t) => {++ if (!t || typeof t !== 'object' || t.type !== 'function') throw INVALID('only function tools are supported');++ const name = t.name || (t.function && t.function.name);++ if (typeof name !== 'string' || !name) throw INVALID('a function tool needs a name');++ const description = t.description != null ? t.description : t.function && t.function.description;++ const parameters = t.parameters != null ? t.parameters : t.function && t.function.parameters;++ return { type: 'function', function: { name, ...(description != null ? { description } : {}), ...(parameters != null ? { parameters } : {}), ...(t.strict != null ? { strict: t.strict } : {}) } };++ });++}++++function responsesToolChoice(choice) {++ if (choice == null) return undefined;++ if (typeof choice === 'string') {++ if (!['auto', 'none', 'required'].includes(choice)) throw INVALID('tool_choice must be auto, none, required or a function');++ return choice;++ }++ if (choice && typeof choice === 'object') {++ const name = choice.name || (choice.function && choice.function.name);++ if ((choice.type === 'function' || choice.type == null) && typeof name === 'string' && name) return { type: 'function', function: { name } };++ }++ throw INVALID('tool_choice must be auto, none, required or a function');++}++++/** text.format -> Chat response_format. Responses writes json_schema flat, Chat nests it. */++function responsesTextFormat(format) {++ if (format == null) return undefined;++ if (typeof format !== 'object') throw INVALID('text.format must be an object');++ if (format.type === 'text') return undefined;++ if (format.type === 'json_object') return { type: 'json_object' };++ if (format.type === 'json_schema') {++ const js = format.json_schema && typeof format.json_schema === 'object' ? format.json_schema : format;++ if (!js.schema || typeof js.schema !== 'object') throw INVALID('text.format.schema must be an object');++ return { type: 'json_schema', json_schema: { name: typeof js.name === 'string' && js.name ? js.name : 'response', schema: js.schema, ...(js.strict != null ? { strict: js.strict } : {}) } };++ }++ throw INVALID('text.format.type must be text, json_object or json_schema');++}++++function responsesToChat(b) {++ const messages = [];++ let hasInput = false;++ if (typeof b.input === 'string') {++ if (!b.input.length) throw INVALID('input must not be empty');++ messages.push({ role: 'user', content: b.input });++ hasInput = true;++ } else if (Array.isArray(b.input)) {++ for (const item of b.input) {++ const added = responsesItem(item);++ if (added.some((m) => m.role !== 'system')) hasInput = true;++ messages.push(...added);++ }++ } else throw INVALID('input must be a string or an array of message items');++ if (b.instructions != null) {++ if (typeof b.instructions !== 'string') throw INVALID('instructions must be a string');++ messages.unshift({ role: 'system', content: b.instructions });++ }++ if (!hasInput) throw INVALID('input must not be empty');++ const out = { messages };++ if (b.model != null) out.model = b.model;++ if (b.temperature != null) out.temperature = b.temperature;++ if (b.top_p != null) out.top_p = b.top_p;++ if (b.max_output_tokens != null) out.max_tokens = Math.max(1, Math.min(Number(b.max_output_tokens) || 1, 16000));++ if (b.stream != null) out.stream = b.stream === true;++ if (b.stream === true) out.stream_options = { include_usage: true };++ if (b.tools !== undefined) out.tools = responsesTools(b.tools);++ if (b.tool_choice !== undefined) out.tool_choice = responsesToolChoice(b.tool_choice);++ if (b.parallel_tool_calls !== undefined) out.parallel_tool_calls = b.parallel_tool_calls;++ const format = responsesTextFormat(b.text && b.text.format);++ if (format) out.response_format = format;++ for (const k of passThrough) if (b[k] !== undefined) out[k] = b[k];++ return out;++}++++function responsesMeta(b) {++ return {++ instructions: b.instructions != null ? b.instructions : null,++ max_output_tokens: b.max_output_tokens != null ? b.max_output_tokens : null,++ temperature: b.temperature != null ? b.temperature : null,++ top_p: b.top_p != null ? b.top_p : null,++ tools: Array.isArray(b.tools) ? b.tools : [],++ tool_choice: b.tool_choice != null ? b.tool_choice : 'auto',++ parallel_tool_calls: b.parallel_tool_calls !== false,++ store: b.store === true,++ metadata: b.metadata && typeof b.metadata === 'object' ? b.metadata : {},++ text_format: b.text && b.text.format ? b.text.format : { type: 'text' },++ model: b.model != null ? b.model : 'openvibe',++ };++}++++function responsesUsage(usage) {++ const u = usage || {};++ const input = Number(u.prompt_tokens) || 0;++ const output = Number(u.completion_tokens) || 0;++ return {++ input_tokens: input,++ input_tokens_details: { cached_tokens: Number(u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0 },++ output_tokens: output,++ output_tokens_details: { reasoning_tokens: Number(u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens) || 0 },++ total_tokens: Number(u.total_tokens) || input + output,++ };++}++++function responsesBase(chat, meta, status) {++ return {++ id: `resp_${runIdOf(chat.id)}`, object: 'response',++ created_at: Number(chat.created) || Math.floor(Date.now() / 1000), status, error: null, incomplete_details: null,++ instructions: meta.instructions, max_output_tokens: meta.max_output_tokens,++ model: chat.model || meta.model || 'openvibe', output: [],++ parallel_tool_calls: meta.parallel_tool_calls, previous_response_id: null,++ reasoning: { effort: null, summary: null }, store: meta.store, temperature: meta.temperature,++ text: { format: meta.text_format }, tool_choice: meta.tool_choice, tools: meta.tools, top_p: meta.top_p,++ truncation: 'disabled', usage: null, user: null, metadata: meta.metadata,++ };++}++++function responsesFromChat(chat, meta) {++ const out = responsesBase(chat, meta, 'completed');++ const choice = (chat.choices && chat.choices[0]) || {};++ const message = choice.message || {};++ const runId = runIdOf(chat.id);++ const text = typeof message.content === 'string' ? message.content : '';++ const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];++ if (text.length || !calls.length) {++ out.output.push({ type: 'message', id: `msg_${runId}`, status: 'completed', role: 'assistant',++ content: [{ type: 'output_text', text, annotations: [], logprobs: [] }] });++ }++ calls.forEach((call, i) => out.output.push({ type: 'function_call', id: `fc_${runId}_${i}`, call_id: call.id, name: call.function && call.function.name,++ arguments: (call.function && call.function.arguments) || '', status: 'completed' }));++ out.usage = responsesUsage(chat.usage);++ if (choice.finish_reason === 'length') { out.status = 'incomplete'; out.incomplete_details = { reason: 'max_output_tokens' }; }++ if (chat.x_openvibe) out.x_openvibe = chat.x_openvibe;++ return out;++}++++/** Convert the Chat Completions SSE stream to Responses events, in the documented order. */++function responsesStream(res, meta) {++ const rawWrite = res.write.bind(res);++ const rawEnd = res.end.bind(res);++ let started = false;++ let done = false;++ let response = null;++ let seq = 0;++ let text = '';++ let textItem = null;++ let textIndex = -1;++ let outputIndex = 0;++ let finishReason = null;++ let usage = null;++ const calls = new Map();++ const items = [];++ const send = (type, data) => {++ if (done || res.writableEnded || res.destroyed) return;++ rawWrite(`event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: seq++, ...data })}\n\n`);++ };++ const start = (chat) => {++ if (started) return;++ started = true;++ response = responsesBase(chat, meta, 'in_progress');++ send('response.created', { response });++ };++ const addText = () => {++ if (textItem) return;++ const part = { type: 'output_text', text: '', annotations: [], logprobs: [] };++ textItem = { type: 'message', id: `msg_${response.id.slice(5)}`, status: 'in_progress', role: 'assistant', content: [part] };++ textIndex = outputIndex++;++ send('response.output_item.added', { output_index: textIndex, item: textItem });++ send('response.content_part.added', { item_id: textItem.id, output_index: textIndex, content_index: 0, part });++ items.push({ index: textIndex, item: textItem });++ };++ const addCall = (call, idx) => {++ const item = { type: 'function_call', id: `fc_${response.id.slice(5)}_${calls.size}`, call_id: call.id || `call_${idx}`,++ name: call.function && call.function.name ? call.function.name : 'function', arguments: '', status: 'in_progress' };++ const placed = { index: outputIndex++, item, args: '', finished: false };++ calls.set(idx, placed);++ items.push({ index: placed.index, item });++ send('response.output_item.added', { output_index: placed.index, item });++ };++ const finish = (reason) => {++ if (reason) finishReason = reason;++ if (!started) return;++ if (!textItem && !calls.size) addText();++ if (textItem && textItem.status !== 'completed') {++ textItem.status = 'completed';++ const part = textItem.content[0];++ part.text = text;++ send('response.output_text.done', { item_id: textItem.id, output_index: textIndex, content_index: 0, text, logprobs: [] });++ send('response.content_part.done', { item_id: textItem.id, output_index: textIndex, content_index: 0, part });++ send('response.output_item.done', { output_index: textIndex, item: textItem });++ }++ for (const call of calls.values()) {++ if (call.finished) continue;++ call.finished = true;++ call.item.status = 'completed';++ call.item.arguments = call.args;++ send('response.function_call_arguments.done', { item_id: call.item.id, output_index: call.index, arguments: call.args });++ send('response.output_item.done', { output_index: call.index, item: call.item });++ }++ };++ const complete = () => {++ if (done) return;++ finish(finishReason);++ if (!started) { done = true; return; }++ const output = items.slice().sort((a, b) => a.index - b.index).map((x) => x.item);++ const final = { ...response, status: finishReason === 'length' ? 'incomplete' : 'completed',++ incomplete_details: finishReason === 'length' ? { reason: 'max_output_tokens' } : null, output, usage: responsesUsage(usage) };++ send('response.completed', { response: final });++ done = true;++ };++ const handle = (chat) => {++ if (!chat || typeof chat !== 'object') return;++ if (chat.error) {++ send('error', { code: chat.error.code || null, message: chat.error.message || 'error', param: null });++ done = true;++ return;++ }++ start(chat);++ if (chat.usage) usage = chat.usage;++ if (chat.x_openvibe) response.x_openvibe = chat.x_openvibe;++ for (const choice of chat.choices || []) {++ const d = choice.delta || {};++ if (typeof d.content === 'string' && d.content.length) {++ addText();++ text += d.content;++ send('response.output_text.delta', { item_id: textItem.id, output_index: textIndex, content_index: 0, delta: d.content, logprobs: [] });++ }++ for (const call of d.tool_calls || []) {++ const idx = Number(call.index) || 0;++ if (!calls.has(idx)) addCall(call, idx);++ const placed = calls.get(idx);++ if (call.id) placed.item.call_id = call.id;++ if (call.function && call.function.name) placed.item.name = call.function.name;++ const piece = call.function && call.function.arguments;++ if (typeof piece === 'string' && piece.length) {++ placed.args += piece;++ send('response.function_call_arguments.delta', { item_id: placed.item.id, output_index: placed.index, delta: piece });++ }++ }++ if (choice.finish_reason) finish(choice.finish_reason);++ }++ };++ res.write = (chunk, ...args) => {++ const value = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);++ if (value.startsWith(': ')) return true;++ if (value.startsWith('data: ')) {++ const data = value.slice(6).replace(/\s+$/, '');++ if (data === '[DONE]') { complete(); return true; }++ try { handle(JSON.parse(data)); } catch { /* not a chat chunk */ }++ return true;++ }++ return rawWrite(chunk, ...args);++ };++ res.end = (chunk, ...args) => {++ if (chunk == null) complete();++ return rawEnd(chunk, ...args);++ };++}++++// --- Gemini generateContent ---------------------------------------------------------------------------------------++++const GEMINI_STATUS = { 400: 'INVALID_ARGUMENT', 401: 'UNAUTHENTICATED', 403: 'PERMISSION_DENIED', 404: 'NOT_FOUND', 409: 'ABORTED', 429: 'RESOURCE_EXHAUSTED', 500: 'INTERNAL', 503: 'UNAVAILABLE', 504: 'DEADLINE_EXCEEDED' };++function geminiError(status, message, code) {++ return { error: { code: status, message: String(message || 'error').slice(0, 1000), status: GEMINI_STATUS[status] || 'UNKNOWN', ...(code ? { details: [{ '@type': 'type.googleapis.com/google.rpc.ErrorInfo', reason: code }] } : {}) } };++}++function geminiStatusFor(code) {++ if (/^(input|validation)\./.test(code || '')) return 400;++ if (/quota|allowance|budget/.test(code || '')) return 429;++ if (code === 'project_secret.refused' || /capability\.denied|policy\./.test(code || '')) return 403;++ if (code === 'ai.model_not_found') return 404;++ return 503;++}++function asGeminiErrors(req, res, next) {++ const end = res.end.bind(res);++ res.end = (chunk, ...rest) => {++ const ct = String(res.getHeader('Content-Type') || '');++ if (res.statusCode >= 400 && /problem\+json/.test(ct) && chunk) {++ let p = null;++ try { p = JSON.parse(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); } catch { p = null; }++ if (p && p.error == null) {++ const body = JSON.stringify(geminiError(res.statusCode, p.detail || p.title || p.code || 'error', p.code));++ res.setHeader('Content-Type', 'application/json; charset=utf-8');++ res.setHeader('Content-Length', Buffer.byteLength(body));++ return end(body);++ }++ }++ return end(chunk, ...rest);++ };++ next();++}++++function geminiText(parts) {++ if (!Array.isArray(parts)) return '';++ return parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('');++}++function geminiSystem(instruction) {++ if (!instruction) return '';++ if (typeof instruction === 'string') return instruction;++ return geminiText(instruction.parts);++}++function geminiLastCallId(messages, name) {++ for (let i = messages.length - 1; i >= 0; i--) {++ const m = messages[i];++ for (const t of (m && m.tool_calls) || []) if (t.function && t.function.name === name) return t.id;++ }++ return `call_${name || 'function'}`;++}++function geminiArgs(value) {++ if (value == null) return {};++ if (typeof value === 'object') return value;++ try { const parsed = JSON.parse(String(value)); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; }++}++function geminiFinish(reason) {++ if (reason === 'length') return 'MAX_TOKENS';++ if (reason === 'content_filter') return 'SAFETY';++ if (reason === 'stop' || reason === 'tool_calls') return 'STOP';++ return reason ? 'OTHER' : undefined;++}++function geminiUsage(usage) {++ const u = usage || {};++ const input = Number(u.prompt_tokens) || 0;++ const output = Number(u.completion_tokens) || 0;++ return {++ promptTokenCount: input, candidatesTokenCount: output,++ totalTokenCount: Number(u.total_tokens) || input + output,++ cachedContentTokenCount: Number(u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0,++ };++}++++function geminiToChat(body, model, stream) {++ const b = body && typeof body === 'object' ? body : {};++ if (!Array.isArray(b.contents) || !b.contents.length) throw INVALID('contents must be a non-empty array');++ const messages = [];++ const system = geminiSystem(b.systemInstruction);++ if (system) messages.push({ role: 'system', content: system });++ for (const content of b.contents) {++ if (!content || typeof content !== 'object') throw INVALID('contents must be objects');++ const role = content.role === 'model' ? 'assistant' : 'user';++ const texts = [];++ const images = [];++ const calls = [];++ const results = [];++ for (const part of content.parts || []) {++ if (!part || typeof part !== 'object') continue;++ if (typeof part.text === 'string') texts.push(part.text);++ else if (part.inlineData && part.inlineData.data) {++ const mime = part.inlineData.mimeType || 'image/png';++ images.push({ type: 'image_url', image_url: { url: `data:${mime};base64,${part.inlineData.data}` } });++ } else if (part.functionCall) {++ if (typeof part.functionCall.name !== 'string' || !part.functionCall.name) throw INVALID('functionCall.name is required');++ calls.push({ id: `call_${messages.length}_${calls.length}`, type: 'function',++ function: { name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args || {}) } });++ } else if (part.functionResponse) {++ results.push({ name: part.functionResponse.name, response: part.functionResponse.response });++ }++ }++ if (results.length) {++ for (const r of results) messages.push({ role: 'tool', tool_call_id: geminiLastCallId(messages, r.name), content: JSON.stringify(r.response == null ? {} : r.response) });++ continue;++ }++ const value = images.length ? [...texts.map((text) => ({ type: 'text', text })), ...images] : texts.join('');++ if (calls.length) messages.push({ role: 'assistant', content: value || null, tool_calls: calls });++ else if (texts.length || images.length) messages.push({ role, content: value });++ }++ if (!messages.some((m) => m.role !== 'system')) throw INVALID('contents must not be empty');++ const out = { messages, model };++ const tools = [];++ for (const t of Array.isArray(b.tools) ? b.tools : []) {++ for (const d of Array.isArray(t.functionDeclarations) ? t.functionDeclarations : []) {++ if (!d || typeof d.name !== 'string' || !d.name) throw INVALID('a function declaration needs a name');++ tools.push({ type: 'function', function: { name: d.name, ...(d.description != null ? { description: d.description } : {}), ...(d.parameters != null ? { parameters: d.parameters } : {}) } });++ }++ }++ if (tools.length) out.tools = tools;++ const tc = b.toolConfig && b.toolConfig.functionCallingConfig;++ if (tc) {++ if (tc.mode === 'NONE') out.tool_choice = 'none';++ else if (tc.mode === 'ANY') {++ const names = Array.isArray(tc.allowedFunctionNames) ? tc.allowedFunctionNames : [];++ out.tool_choice = names.length === 1 ? { type: 'function', function: { name: names[0] } } : 'required';++ } else if (tc.mode === 'AUTO') out.tool_choice = 'auto';++ }++ const gc = b.generationConfig && typeof b.generationConfig === 'object' ? b.generationConfig : {};++ if (gc.temperature != null) out.temperature = gc.temperature;++ if (gc.topP != null) out.top_p = gc.topP;++ if (gc.maxOutputTokens != null) out.max_tokens = Math.max(1, Math.min(Number(gc.maxOutputTokens) || 1, 16000));++ if (gc.stopSequences != null) out.stop = gc.stopSequences;++ if (gc.candidateCount != null && Number(gc.candidateCount) !== 1) throw INVALID('candidateCount must be 1');++ if (gc.responseMimeType === 'application/json') {++ const schema = gc.responseSchema || gc.responseJsonSchema;++ out.response_format = schema && typeof schema === 'object'++ ? { type: 'json_schema', json_schema: { name: 'response', schema, strict: true } }++ : { type: 'json_object' };++ }++ if (stream) {++ out.stream = true;++ out.stream_options = { include_usage: true };++ }++ return out;++}++++function geminiResponse(chat, model) {++ const choice = (chat.choices && chat.choices[0]) || {};++ const message = choice.message || {};++ const parts = [];++ if (typeof message.content === 'string' && message.content.length) parts.push({ text: message.content });++ for (const call of message.tool_calls || []) parts.push({ functionCall: { name: call.function && call.function.name, args: geminiArgs(call.function && call.function.arguments) } });++ if (!parts.length) parts.push({ text: '' });++ const out = {++ candidates: [{ content: { role: 'model', parts }, finishReason: geminiFinish(choice.finish_reason) || 'STOP', index: 0 }],++ usageMetadata: geminiUsage(chat.usage), modelVersion: chat.model || model,++ };++ if (chat.x_openvibe) out.x_openvibe = chat.x_openvibe;++ return out;++}++++function installGeminiJson(res, model) {++ const json = res.json.bind(res);++ res.json = (value) => {++ if (value && Array.isArray(value.choices)) return json(geminiResponse(value, model));++ if (value && value.error && res.statusCode >= 400) return json(geminiError(res.statusCode, value.error.message, value.error.code));++ return json(value);++ };++}++++/** The Chat SSE stream -> Gemini chunks, written as SSE, or collected into the JSON array Gemini's REST returns. */++function installGeminiStream(res, model, sse) {++ const rawWrite = res.write.bind(res);++ const rawEnd = res.end.bind(res);++ const rawSetHeader = res.setHeader.bind(res);++ const chunks = [];++ let done = false;++ let finished = false;++ let usage = null;++ let responseModel = model;++ const calls = new Map();++ const emit = (chunk) => {++ if (done) return;++ if (sse) rawWrite(`data: ${JSON.stringify(chunk)}\n\n`);++ else chunks.push(chunk);++ };++ const finish = (reason) => {++ if (finished) return;++ finished = true;++ const parts = [...calls.values()].map((c) => ({ functionCall: { name: c.name, args: geminiArgs(c.args) } }));++ emit({ candidates: [{ content: { role: 'model', parts }, finishReason: geminiFinish(reason || (calls.size ? 'tool_calls' : 'stop')) || 'STOP', index: 0 }], modelVersion: responseModel });++ };++ const handle = (chat) => {++ if (!chat || typeof chat !== 'object') return;++ if (chat.error) {++ emit(geminiError(geminiStatusFor(chat.error.code), chat.error.message, chat.error.code));++ done = true;++ return;++ }++ if (chat.usage) {++ usage = chat.usage;++ emit({ ...(sse && finished ? { candidates: [] } : {}), usageMetadata: geminiUsage(usage), modelVersion: responseModel });++ return;++ }++ for (const choice of chat.choices || []) {++ const d = choice.delta || {};++ if (typeof d.content === 'string' && d.content.length) emit({ candidates: [{ content: { role: 'model', parts: [{ text: d.content }] }, index: 0 }], modelVersion: responseModel });++ for (const call of d.tool_calls || []) {++ const idx = Number(call.index) || 0;++ if (!calls.has(idx)) calls.set(idx, { name: (call.function && call.function.name) || 'function', args: '' });++ const held = calls.get(idx);++ if (call.function && call.function.name) held.name = call.function.name;++ if (call.function && typeof call.function.arguments === 'string') held.args += call.function.arguments;++ }++ if (choice.finish_reason) finish(choice.finish_reason);++ }++ };++ if (!sse) {++ // Gemini's REST stream is a JSON array, not SSE: keep the chat stream's headers from going out and++ // answer the collected chunks in one JSON body when the chat stream ends.++ res.setHeader = (name, value) => {++ if (String(name).toLowerCase() === 'content-type' && /text\/event-stream/.test(String(value))) return res;++ return rawSetHeader(name, value);++ };++ res.flushHeaders = () => {};++ }++ res.write = (chunk, ...args) => {++ const value = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);++ if (value.startsWith(': ')) return true;++ if (value.startsWith('data: ')) {++ const data = value.slice(6).replace(/\s+$/, '');++ if (data === '[DONE]') { done = true; return true; }++ try { handle(JSON.parse(data)); } catch { /* not a chat chunk */ }++ return true;++ }++ if (sse) return rawWrite(chunk, ...args);++ return true;++ };++ let flushed = false;++ res.end = (chunk, ...args) => {++ if (chunk != null) return rawEnd(chunk, ...args);++ if (sse) return rawEnd(chunk, ...args);++ if (flushed) return rawEnd();++ flushed = true;++ rawSetHeader('Content-Type', 'application/json; charset=utf-8');++ return rawEnd(JSON.stringify(chunks));++ };++}++++// --- router -------------------------------------------------------------------------------------------------------++// POST /v1/audio/transcriptions: a run of ai.transcribe carries the audio as a data: URL, and a run's// input is capped (AI_MAX_INPUT_BYTES, 6 MB by default), so the file is 3 MB at most.const MAX_MULTIPART_BYTES = 5 * 1024 * 1024;@@@ -131,6 +720,17 @@ function compatExtra({ runs, auth, config, log }) {const r = express.Router();const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);const wrap = fn => async (req, res) => { try { await fn(req, res); } catch (err) { sendError(res, err, req.ov, log); } };++++ // Gemini's own key headers name the same OpenVibe keys the bearer header carries. Authorization wins++ // when both are present; nothing is invented from an empty key.++ const geminiKey = (req, res, next) => {++ const header = req.get('x-goog-api-key');++ const query = typeof req.query.key === 'string' ? req.query.key : '';++ const supplied = header || query;++ if (!req.get('authorization') && supplied && String(supplied).trim()) req.headers.authorization = `Bearer ${String(supplied).trim()}`;++ next();++ };++const openaiRoute = (label, fn) => async (req, res) => {try { await fn(req, res); } catch (err) {if (res.headersSent) { try { res.end(); } catch { /* already closed */ } return; }@@@ -180,6 +780,39 @@ function compatExtra({ runs, auth, config, log }) {...accounting(run), total_cost: Number(run.usage.cost_usd || 0),tokens_prompt: run.usage.tokens_in, tokens_completion: run.usage.tokens_out, created_at: run.created_at } });}));++++ // OpenAI Responses API, stateless. `store` is accepted and ignored; previous_response_id is refused++ // rather than pretending the conversation is kept.++ r.post('/v1/responses', asOpenAiErrors, allow, (req, res, next) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ if (b.previous_response_id != null) return openaiError(res, 400, 'previous_response_id is not supported: send the whole conversation as input', 'input.invalid');++ let chat;++ try { chat = responsesToChat(b); } catch (err) { return openaiError(res, err.status || 400, err.detail || err.message, err.code); }++ const meta = responsesMeta(b);++ if (b.stream === true) responsesStream(res, meta);++ else {++ const json = res.json.bind(res);++ res.json = (value) => (value && Array.isArray(value.choices) ? json(responsesFromChat(value, meta)) : json(value));++ }++ req.body = chat;++ req.url = '/v1/chat/completions';++ next();++ });++++ // Gemini. Express reads the literal colon in ":model\:generateContent" as part of the path (and the++ // documentation's {model}:generateContent), so the model is req.params.model.++ const gemini = (stream) => (req, res, next) => {++ let chat;++ try { chat = geminiToChat(req.body, req.params.model, stream); } catch (err) { return res.status(err.status || 400).json(geminiError(err.status || 400, err.detail || err.message, err.code)); }++ if (stream) installGeminiStream(res, req.params.model, String(req.query.alt || '').toLowerCase() === 'sse');++ else installGeminiJson(res, req.params.model);++ req.body = chat;++ req.url = '/v1/chat/completions';++ next();++ };++ r.post('/v1beta/models/:model\\:generateContent', geminiKey, asGeminiErrors, allow, gemini(false));++ r.post('/v1beta/models/:model\\:streamGenerateContent', geminiKey, asGeminiErrors, allow, gemini(true));++r.post('/v1/embeddings', allow, wrap(async (req, res) => {const b = req.body || {};if (b.encoding_format && b.encoding_format !== 'float') throw new AiError(400, 'input.invalid', 'Only float embeddings are supported');@@@ -104,7 +104,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p// Terms, Privacy and DMCA moved with the human site: /terms, /privacy and /dmca answer 301 to// OpenVibe.Exchange's own legal pages (see the page map below).// Inline images arrive as data URLs; the per-run input cap is enforced again in runs.create.-- app.use(['/api', '/v1'], express.json({ limit: Math.ceil(config.runs.maxInputBytes * 1.5), type: ['application/json', 'application/*+json'] }));++ app.use(['/api', '/v1', '/v1beta'], express.json({ limit: Math.ceil(config.runs.maxInputBytes * 1.5), type: ['application/json', 'application/*+json'] }));app.get('/api/health', (_req, res) => {res.json({ status: 'ok', service: 'openvibe-ai', version: pkg.version });@@@ -17,6 +17,39 @@ const operation = (operationId, summary, props, required = []) => ({ operationIdconst paths = {'/v1/models': { get: { operationId: 'compatModels', summary: 'Catalogue models with OpenRouter pricing and context fields', tags: ['exchange'], security: [{ bearer: [] }], responses: { 200: { description: 'data model entries: id, name, pricing (USD per token strings), context_length, architecture, top_provider, and features: { tools, structured_output, vision, audio_in, audio_out, reasoning_effort, prompt_caching, logprobs, streaming, batch, realtime, files }. A feature is true or false only when the registry model and provider metadata say so; unknown is null. Anthropic header selects Anthropic format. Catalogue flag off retains the virtual model.' } } } },'/v1/chat/completions': { post: operation('compatChatCompletions', 'Chat completion with provider routing', { ...properties, messages: { type: 'array', items: { type: 'object' } }, stream: { type: 'boolean' } }, ['messages']) },++ '/v1/responses': { post: { operationId: 'compatResponses', summary: 'OpenAI Responses API (stateless) over the chat completion path', tags: ['exchange'], security: [{ bearer: [] }],++ parameters: [{ in: 'header', name: 'Idempotency-Key', schema: { type: 'string' }, description: 'Owner-scoped replay; changed input or endpoint conflicts.' }],++ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', additionalProperties: false, required: ['input'], properties: {++ ...properties,++ input: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'object' }, description: 'Message items (role + content with input_text and input_image), or function_call / function_call_output items.' }] },++ instructions: { type: 'string' }, max_output_tokens: { type: 'integer', minimum: 1 },++ tools: { type: 'array', items: { type: 'object' }, description: 'Function tools only: { type: "function", name, description, parameters }.' },++ tool_choice: { anyOf: [{ enum: ['auto', 'none', 'required'] }, { type: 'object' }] },++ parallel_tool_calls: { type: 'boolean' }, temperature: { type: 'number' },++ text: { type: 'object', description: 'format: text, json_object or json_schema (name, schema, strict).' },++ stream: { type: 'boolean' }, store: { type: 'boolean', description: 'Accepted and ignored: responses are not kept.' },++ previous_response_id: { type: 'string', description: 'Refused with 400: send the whole conversation as input.' },++ } } } } },++ responses: { 200: { description: 'The response object: id resp_<run id>, status, output items (message with output_text, function_call) and usage with input/output token details. stream: true sends response.created, output_item.added, content_part.added, output_text.delta, output_text.done, content_part.done, output_item.done and response.completed events.' },++ 400: { description: 'Invalid request, or previous_response_id supplied' }, 401: { description: 'Invalid or expired key' }, 402: { description: 'Quota or balance refused' }, 429: { description: 'Key cap reached' }, 503: { description: 'No eligible provider' } } } },++ '/v1beta/models/{model}:generateContent': { post: { operationId: 'compatGeminiGenerateContent', summary: 'Gemini generateContent over the chat completion path', tags: ['exchange'], security: [{ bearer: [] }],++ parameters: [{ in: 'header', name: 'x-goog-api-key', schema: { type: 'string' }, description: 'An OpenVibe key; ?key= also carries one.' }],++ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', additionalProperties: false, required: ['contents'], properties: {++ contents: { type: 'array', minItems: 1, items: { type: 'object' }, description: 'role user/model, parts text, inlineData, functionCall, functionResponse.' },++ systemInstruction: { type: 'object' }, generationConfig: { type: 'object', description: 'temperature, topP, maxOutputTokens, stopSequences, responseMimeType, responseSchema, candidateCount (1).' },++ tools: { type: 'array', items: { type: 'object' }, description: 'functionDeclarations.' }, toolConfig: { type: 'object' },++ } } } } },++ responses: { 200: { description: 'candidates[].content.parts (text or functionCall), finishReason, usageMetadata (promptTokenCount, candidatesTokenCount, totalTokenCount, cachedContentTokenCount) and modelVersion.' },++ 400: { description: 'Invalid request' }, 401: { description: 'Invalid or expired key' }, 402: { description: 'Quota or balance refused' }, 429: { description: 'Key cap reached' }, 503: { description: 'No eligible provider' } } } },++ '/v1beta/models/{model}:streamGenerateContent': { post: { operationId: 'compatGeminiStreamGenerateContent', summary: 'Gemini streamGenerateContent: SSE with ?alt=sse, otherwise a JSON array', tags: ['exchange'], security: [{ bearer: [] }],++ parameters: [{ in: 'header', name: 'x-goog-api-key', schema: { type: 'string' }, description: 'An OpenVibe key; ?key= also carries one.' },++ { in: 'query', name: 'alt', schema: { const: 'sse' }, description: 'sse: server-sent events. Other values answer one JSON array of GenerateContentResponse chunks.' }],++ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', additionalProperties: false, required: ['contents'], properties: {++ contents: { type: 'array', minItems: 1, items: { type: 'object' } }, systemInstruction: { type: 'object' }, generationConfig: { type: 'object' },++ tools: { type: 'array', items: { type: 'object' } }, toolConfig: { type: 'object' },++ } } } } },++ responses: { 200: { description: 'text/event-stream of GenerateContentResponse chunks when ?alt=sse; otherwise application/json with an array of the same chunks.' },++ 400: { description: 'Invalid request' }, 401: { description: 'Invalid or expired key' }, 402: { description: 'Quota or balance refused' }, 429: { description: 'Key cap reached' }, 503: { description: 'No eligible provider' } } } },'/v1/messages': { post: operation('compatMessages', 'Anthropic Messages with provider routing', { ...properties, messages: { type: 'array', items: { type: 'object' } }, max_tokens: { type: 'integer', minimum: 1 }, stream: { type: 'boolean' } }, ['messages', 'max_tokens']) },'/v1/completions': { post: operation('compatCompletions', 'Legacy string-prompt completion', { ...properties, prompt: { type: 'string', minLength: 1 }, stream: { type: 'boolean' } }, ['prompt']) },'/v1/embeddings': { post: operation('compatEmbeddings', 'Float embeddings from a registered provider', { ...properties, input: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] }, encoding_format: { const: 'float' } }, ['input']) },nnew file mode 100644@@@ -0,0 +1,157 @@++'use strict';++// The two newest compatible surfaces (server/api/compat-extra.js), both converted onto the Chat Completions path:++// OpenAI's stateless Responses API (a plain call, a stream with the documented event order, a function_call item,++// previous_response_id refused) and Gemini's generateContent / streamGenerateContent (a plain call, SSE with ?alt=sse,++// the JSON array without it, x-goog-api-key and ?key= carrying an OpenVibe key).++const assert = require('assert');++const { boot, request, token, suite, ALL } = require('./helpers');++++const t = suite('responses-gemini');++const service = token('live', ALL);++let h;++const post = (path, body, headers = {}) => request(h.base, 'POST', path, { tok: service, body, headers });++++/** event/data SSE blocks -> [{ event, data }]. */++async function sseEvents(res) {++ const text = await res.text();++ const events = [];++ for (const block of text.split('\n\n')) {++ let event = null;++ let data = null;++ for (const line of block.split('\n')) {++ if (line.startsWith('event: ')) event = line.slice(7).trim();++ else if (line.startsWith('data: ')) data = line.slice(6).trim();++ }++ if (event && data) events.push({ event, data: JSON.parse(data) });++ }++ return events;++}++++const dataChunks = async (res) => (await res.text()).split('\n').filter((l) => l.startsWith('data: ')).map((l) => l.slice(6).trim()).filter((d) => d !== '[DONE]').map((d) => JSON.parse(d));++++t.test('boot', async () => { h = await boot({ env: { AI_FREE_PROVIDERS: 'stub' } }); });++++t.test('Responses: a plain call answers the response object, usage and the run it was', async () => {++ const r = await post('/v1/responses', { model: 'openvibe', input: 'Say hello', instructions: 'Be brief.', store: true });++ assert.strictEqual(r.status, 200, r.text);++ const response = r.body;++ assert.strictEqual(response.object, 'response');++ assert.match(response.id, /^resp_run_/);++ assert.strictEqual(response.status, 'completed');++ assert.strictEqual(response.store, true, 'store is accepted and echoed');++ assert.strictEqual(response.instructions, 'Be brief.');++ assert.strictEqual(response.output[0].type, 'message');++ assert.strictEqual(response.output[0].role, 'assistant');++ assert.strictEqual(response.output[0].content[0].type, 'output_text');++ assert.ok(response.output[0].content[0].text.length > 0);++ assert.ok(response.usage.input_tokens > 0 && response.usage.output_tokens > 0);++ assert.strictEqual(response.usage.total_tokens, response.usage.input_tokens + response.usage.output_tokens);++ assert.strictEqual(response.usage.input_tokens_details.cached_tokens, 0);++ assert.strictEqual(response.usage.output_tokens_details.reasoning_tokens, 0);++ const runId = response.x_openvibe.run_id;++ assert.strictEqual(response.id, `resp_${runId}`);++ const run = await request(h.base, 'GET', `/api/v1/runs/${runId}`, { tok: service });++ assert.strictEqual(run.body.run.workflow.key, 'ai.complete');++ assert.ok(run.body.run.usage.tokens_in > 0, 'the run path recorded the usage');++});++++t.test('Responses stream: the documented events in order, the deltas joining into the final text', async () => {++ const res = await fetch(`${h.base}/v1/responses`, { method: 'POST', headers: { Authorization: `Bearer ${service}`, 'Content-Type': 'application/json' },++ body: JSON.stringify({ model: 'openvibe', input: 'Stream please', stream: true }) });++ assert.strictEqual(res.status, 200);++ assert.match(res.headers.get('content-type'), /^text\/event-stream/);++ assert.match(String(res.headers.get('x-openvibe-run-id')), /^run_/);++ const events = await sseEvents(res);++ const types = events.map((e) => e.event);++ assert.deepStrictEqual(types.slice(0, 3), ['response.created', 'response.output_item.added', 'response.content_part.added']);++ const lastDelta = types.lastIndexOf('response.output_text.delta');++ assert.ok(types.indexOf('response.output_text.delta') > 2 && lastDelta > types.indexOf('response.output_text.delta'), 'streamed in pieces');++ assert.deepStrictEqual(types.slice(lastDelta + 1), ['response.output_text.done', 'response.content_part.done', 'response.output_item.done', 'response.completed']);++ const joined = events.filter((e) => e.event === 'response.output_text.delta').map((e) => e.data.delta).join('');++ const done = events.find((e) => e.event === 'response.output_text.done').data;++ const completed = events.find((e) => e.event === 'response.completed').data.response;++ assert.strictEqual(done.text, joined);++ assert.strictEqual(completed.output[0].content[0].text, joined);++ assert.strictEqual(completed.id, `resp_${res.headers.get('x-openvibe-run-id')}`);++ assert.ok(completed.usage.input_tokens > 0);++});++++t.test('Responses: a function tool call is a function_call item, and its output can be sent back', async () => {++ const tools = [{ type: 'function', name: 'get_weather', description: 'Weather for a city',++ parameters: { type: 'object', required: ['city'], properties: { city: { type: 'string' } } } }];++ const r = await post('/v1/responses', { input: 'Weather in Lisbon?', tools, tool_choice: 'auto' });++ assert.strictEqual(r.status, 200, r.text);++ const call = r.body.output.find((item) => item.type === 'function_call');++ assert.ok(call, JSON.stringify(r.body).slice(0, 300));++ assert.strictEqual(call.name, 'get_weather');++ assert.match(call.call_id, /^call_/);++ assert.ok(JSON.parse(call.arguments).city);++ const back = await post('/v1/responses', { input: [++ { role: 'user', content: [{ type: 'input_text', text: 'Weather in Lisbon?' }] },++ { type: 'function_call', call_id: call.call_id, name: call.name, arguments: call.arguments },++ { type: 'function_call_output', call_id: call.call_id, output: '19 C and sunny' },++ ] });++ assert.strictEqual(back.status, 200, back.text);++ assert.match(back.body.output[0].content[0].text, /19 C and sunny/);++});++++t.test('Responses: previous_response_id is refused, and only function tools are accepted', async () => {++ const r = await post('/v1/responses', { input: 'hello', previous_response_id: 'resp_old' });++ assert.strictEqual(r.status, 400);++ assert.strictEqual(r.body.error.message, 'previous_response_id is not supported: send the whole conversation as input');++ assert.strictEqual(r.body.error.type, 'invalid_request_error');++ const other = await post('/v1/responses', { input: 'hello', tools: [{ type: 'web_search' }] });++ assert.strictEqual(other.status, 400);++ assert.strictEqual(other.body.error.type, 'invalid_request_error');++});++++t.test('Gemini generateContent answers candidates and usage, with x-goog-api-key and ?key= auth', async () => {++ const body = { contents: [{ role: 'user', parts: [{ text: 'Hello Gemini' }] }], systemInstruction: { parts: [{ text: 'Be nice' }] },++ generationConfig: { temperature: 0.2, maxOutputTokens: 64 } };++ const r = await request(h.base, 'POST', '/v1beta/models/gemini-2.0-flash:generateContent', { body, headers: { 'x-goog-api-key': service } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.candidates[0].content.role, 'model');++ assert.strictEqual(typeof r.body.candidates[0].content.parts[0].text, 'string');++ assert.ok(r.body.candidates[0].content.parts[0].text.length > 0);++ assert.strictEqual(r.body.candidates[0].finishReason, 'STOP');++ assert.ok(r.body.usageMetadata.promptTokenCount > 0 && r.body.usageMetadata.candidatesTokenCount > 0);++ assert.strictEqual(r.body.usageMetadata.totalTokenCount, r.body.usageMetadata.promptTokenCount + r.body.usageMetadata.candidatesTokenCount);++ assert.strictEqual(r.body.usageMetadata.cachedContentTokenCount, 0);++ assert.ok(r.body.modelVersion);++ const run = await request(h.base, 'GET', `/api/v1/runs/${r.headers.get('x-openvibe-run-id')}`, { tok: service });++ assert.strictEqual(run.body.run.workflow.key, 'ai.complete');++ assert.ok(run.body.run.usage.tokens_in > 0, 'the run path recorded the usage');++ const byQuery = await request(h.base, 'POST', `/v1beta/models/gemini-2.0-flash:generateContent?key=${encodeURIComponent(service)}`, { body });++ assert.strictEqual(byQuery.status, 200, byQuery.text);++ assert.ok(byQuery.body.candidates[0].content.parts[0].text.length > 0);++});++++t.test('Gemini streamGenerateContent?alt=sse streams candidate chunks and usageMetadata', async () => {++ const res = await fetch(`${h.base}/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse`, { method: 'POST',++ headers: { Authorization: `Bearer ${service}`, 'Content-Type': 'application/json' },++ body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: 'Stream Gemini' }] }] }) });++ assert.strictEqual(res.status, 200);++ assert.match(res.headers.get('content-type'), /^text\/event-stream/);++ const chunks = await dataChunks(res);++ assert.ok(chunks.length > 2, `streamed in pieces (${chunks.length})`);++ const text = chunks.flatMap((c) => (c.candidates || []).flatMap((cand) => (cand.content && cand.content.parts) || [])).filter((p) => typeof p.text === 'string').map((p) => p.text).join('');++ assert.ok(text.length > 0);++ const finish = chunks.find((c) => (c.candidates || []).some((cand) => cand.finishReason));++ assert.strictEqual(finish.candidates[0].finishReason, 'STOP');++ assert.ok(chunks.some((c) => c.usageMetadata && c.usageMetadata.promptTokenCount > 0), 'usageMetadata in the stream');++});++++t.test('Gemini streamGenerateContent without alt answers one JSON array', async () => {++ const res = await fetch(`${h.base}/v1beta/models/gemini-2.0-flash:streamGenerateContent`, { method: 'POST',++ headers: { 'x-goog-api-key': service, 'Content-Type': 'application/json' },++ body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: 'Array Gemini' }] }] }) });++ assert.strictEqual(res.status, 200);++ assert.match(res.headers.get('content-type'), /^application\/json/);++ const chunks = await res.json();++ assert.ok(Array.isArray(chunks) && chunks.length > 2);++ const text = chunks.flatMap((c) => (c.candidates || []).flatMap((cand) => (cand.content && cand.content.parts) || [])).filter((p) => typeof p.text === 'string').map((p) => p.text).join('');++ assert.ok(text.length > 0);++});++++t.test('close', async () => { await h.stop(); });++t.run();
@@ -1,13 +1,602 @@
@@ -1,13 +1,602 @@
'use strict';
'use strict';
+/**
+ * The newest compatible routes, which reuse the Chat Completions path rather than a second router:
+ *
+ * POST /v1/responses OpenAI's Responses API (stateless), converted in and out
+ * POST /v1beta/models/{model}:generateContent Gemini's generateContent, converted in and out
+ * POST /v1beta/models/{model}:streamGenerateContent the same stream, SSE with ?alt=sse, else a JSON array
+ *
+ * Each request becomes one POST /v1/chat/completions on the same connection (the way /v1/completions
+ * falls through), so it is the same run, auth, quota, hold, routing, usage record and receipt. The
+ * response is converted back on res.json / res.write. Gemini also accepts x-goog-api-key and ?key=
+ * carrying an OpenVibe key, and its errors take Google's { error: { code, message, status } } shape.
+ */
const express = require('express');
const express = require('express');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { appAttribution } = require('../apps');
const { appAttribution } = require('../apps');
const { AiError, sendError } = require('../util');
const { AiError, sendError } = require('../util');
+
function accounting(run) {
function accounting(run) {
return { cost: Number(run.usage.cost_usd || 0), latency: Math.max(0, Date.parse(run.finished_at) - Date.parse(run.started_at || run.created_at)) || 0,
return { cost: Number(run.usage.cost_usd || 0), latency: Math.max(0, Date.parse(run.finished_at) - Date.parse(run.started_at || run.created_at)) || 0,
provider: run.provenance?.provider || null };
provider: run.provenance?.provider || null };
}
}
+const INVALID = (message) => new AiError(400, 'input.invalid', message);
+const passThrough = ['max_cost_usd', 'on_behalf_of', 'policy', 'service_tier', 'data_policy', 'x_openvibe', 'provider', 'models', 'prefer', 'effort', 'agent_role', 'kind'];
+
+// --- OpenAI error shape (the same as server/api/openai.js) -------------------------------------------------------
+
+// openaiError (below, with the transcription routes' helpers) writes OpenAI's { error } shape for every route here.
+
+/** A problem+json from the auth layer is converted to OpenAI's { error } as it is written. */
+function asOpenAiErrors(req, res, next) {
+ const end = res.end.bind(res);
+ res.end = (chunk, ...rest) => {
+ const ct = String(res.getHeader('Content-Type') || '');
+ if (res.statusCode >= 400 && /problem\+json/.test(ct) && chunk) {
+ let p = null;
+ try { p = JSON.parse(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); } catch { p = null; }
+ if (p && p.error == null) {
+ const body = JSON.stringify({ error: { message: String(p.detail || p.title || p.code || 'error'), type: res.statusCode === 401 || res.statusCode === 403 ? 'authentication_error' : res.statusCode >= 500 ? 'api_error' : 'invalid_request_error', code: p.code || null, param: null } });
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ res.setHeader('Content-Length', Buffer.byteLength(body));
+ return end(body);
+ }
+ }
+ return end(chunk, ...rest);
+ };
+ next();
+}
+
+// --- OpenAI Responses API -----------------------------------------------------------------------------------------
+
+const runIdOf = (id) => String(id || '').replace(/^chatcmpl-/, '') || `run_${Date.now()}`;
+
+/** One Responses content array -> a Chat content value (a string when it is all text). */
+function responsesContent(content) {
+ if (content == null) return '';
+ if (typeof content === 'string') return content;
+ if (!Array.isArray(content)) throw INVALID('message content must be a string or an array of content parts');
+ const parts = [];
+ for (const p of content) {
+ if (typeof p === 'string') { parts.push({ type: 'text', text: p }); continue; }
+ if (!p || typeof p !== 'object') throw INVALID('message content parts must be objects');
+ if (p.type === 'input_text' || p.type === 'output_text' || p.type === 'text') {
+ parts.push({ type: 'text', text: String(p.text == null ? '' : p.text) });
+ } else if (p.type === 'input_image' || p.type === 'image_url') {
+ const url = typeof p.image_url === 'string' ? p.image_url : p.image_url && p.image_url.url;
+ if (typeof url !== 'string' || !/^(data:image\/|https:\/\/)/i.test(url)) throw INVALID('input_image.image_url must be a data:image URL or an https URL');
+ parts.push({ type: 'image_url', image_url: { url, ...(p.detail ? { detail: p.detail } : {}) } });
+ } else throw INVALID(`unsupported content part type ${String(p.type || 'unknown')}`);
+ }
+ if (!parts.length) return '';
+ return parts.every((p) => p.type === 'text') ? parts.map((p) => p.text).join('') : parts;
+}
+
+/** One Responses input item (a message, or a tool call / result from a previous response). */
+function responsesItem(item) {
+ if (!item || typeof item !== 'object' || Array.isArray(item)) throw INVALID('input items must be objects');
+ if (item.type === 'function_call') {
+ if (typeof item.name !== 'string' || !item.name) throw INVALID('function_call.name is required');
+ return [{ role: 'assistant', content: null, tool_calls: [{ id: item.call_id || item.id || `call_${Date.now()}`, type: 'function',
+ function: { name: item.name, arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments || {}) } }] }];
+ }
+ if (item.type === 'function_call_output') {
+ if (typeof item.call_id !== 'string' || !item.call_id) throw INVALID('function_call_output.call_id is required');
+ return [{ role: 'tool', tool_call_id: item.call_id, content: typeof item.output === 'string' ? item.output : JSON.stringify(item.output == null ? '' : item.output) }];
+ }
+ const role = item.role === 'developer' ? 'system' : item.role;
+ if (!['system', 'user', 'assistant', 'tool'].includes(role)) throw INVALID('message.role must be user, assistant, system or developer');
+ return [{ role, content: responsesContent(item.content) }];
+}
+
+/** Responses function tools are flat ({ type, name, parameters }); Chat tools nest them under `function`. */
+function responsesTools(tools) {
+ if (tools == null) return undefined;
+ if (!Array.isArray(tools)) throw INVALID('tools must be an array');
+ return tools.map((t) => {
+ if (!t || typeof t !== 'object' || t.type !== 'function') throw INVALID('only function tools are supported');
+ const name = t.name || (t.function && t.function.name);
+ if (typeof name !== 'string' || !name) throw INVALID('a function tool needs a name');
+ const description = t.description != null ? t.description : t.function && t.function.description;
+ const parameters = t.parameters != null ? t.parameters : t.function && t.function.parameters;
+ return { type: 'function', function: { name, ...(description != null ? { description } : {}), ...(parameters != null ? { parameters } : {}), ...(t.strict != null ? { strict: t.strict } : {}) } };
+ });
+}
+
+function responsesToolChoice(choice) {
+ if (choice == null) return undefined;
+ if (typeof choice === 'string') {
+ if (!['auto', 'none', 'required'].includes(choice)) throw INVALID('tool_choice must be auto, none, required or a function');
+ return choice;
+ }
+ if (choice && typeof choice === 'object') {
+ const name = choice.name || (choice.function && choice.function.name);
+ if ((choice.type === 'function' || choice.type == null) && typeof name === 'string' && name) return { type: 'function', function: { name } };
+ }
+ throw INVALID('tool_choice must be auto, none, required or a function');
+}
+
+/** text.format -> Chat response_format. Responses writes json_schema flat, Chat nests it. */
+function responsesTextFormat(format) {
+ if (format == null) return undefined;
+ if (typeof format !== 'object') throw INVALID('text.format must be an object');
+ if (format.type === 'text') return undefined;
+ if (format.type === 'json_object') return { type: 'json_object' };
+ if (format.type === 'json_schema') {
+ const js = format.json_schema && typeof format.json_schema === 'object' ? format.json_schema : format;
+ if (!js.schema || typeof js.schema !== 'object') throw INVALID('text.format.schema must be an object');
+ return { type: 'json_schema', json_schema: { name: typeof js.name === 'string' && js.name ? js.name : 'response', schema: js.schema, ...(js.strict != null ? { strict: js.strict } : {}) } };
+ }
+ throw INVALID('text.format.type must be text, json_object or json_schema');
+}
+
+function responsesToChat(b) {
+ const messages = [];
+ let hasInput = false;
+ if (typeof b.input === 'string') {
+ if (!b.input.length) throw INVALID('input must not be empty');
+ messages.push({ role: 'user', content: b.input });
+ hasInput = true;
+ } else if (Array.isArray(b.input)) {
+ for (const item of b.input) {
+ const added = responsesItem(item);
+ if (added.some((m) => m.role !== 'system')) hasInput = true;
+ messages.push(...added);
+ }
+ } else throw INVALID('input must be a string or an array of message items');
+ if (b.instructions != null) {
+ if (typeof b.instructions !== 'string') throw INVALID('instructions must be a string');
+ messages.unshift({ role: 'system', content: b.instructions });
+ }
+ if (!hasInput) throw INVALID('input must not be empty');
+ const out = { messages };
+ if (b.model != null) out.model = b.model;
+ if (b.temperature != null) out.temperature = b.temperature;
+ if (b.top_p != null) out.top_p = b.top_p;
+ if (b.max_output_tokens != null) out.max_tokens = Math.max(1, Math.min(Number(b.max_output_tokens) || 1, 16000));
+ if (b.stream != null) out.stream = b.stream === true;
+ if (b.stream === true) out.stream_options = { include_usage: true };
+ if (b.tools !== undefined) out.tools = responsesTools(b.tools);
+ if (b.tool_choice !== undefined) out.tool_choice = responsesToolChoice(b.tool_choice);
+ if (b.parallel_tool_calls !== undefined) out.parallel_tool_calls = b.parallel_tool_calls;
+ const format = responsesTextFormat(b.text && b.text.format);
+ if (format) out.response_format = format;
+ for (const k of passThrough) if (b[k] !== undefined) out[k] = b[k];
+ return out;
+}
+
+function responsesMeta(b) {
+ return {
+ instructions: b.instructions != null ? b.instructions : null,
+ max_output_tokens: b.max_output_tokens != null ? b.max_output_tokens : null,
+ temperature: b.temperature != null ? b.temperature : null,
+ top_p: b.top_p != null ? b.top_p : null,
+ tools: Array.isArray(b.tools) ? b.tools : [],
+ tool_choice: b.tool_choice != null ? b.tool_choice : 'auto',
+ parallel_tool_calls: b.parallel_tool_calls !== false,
+ store: b.store === true,
+ metadata: b.metadata && typeof b.metadata === 'object' ? b.metadata : {},
+ text_format: b.text && b.text.format ? b.text.format : { type: 'text' },
+ model: b.model != null ? b.model : 'openvibe',
+ };
+}
+
+function responsesUsage(usage) {
+ const u = usage || {};
+ const input = Number(u.prompt_tokens) || 0;
+ const output = Number(u.completion_tokens) || 0;
+ return {
+ input_tokens: input,
+ input_tokens_details: { cached_tokens: Number(u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0 },
+ output_tokens: output,
+ output_tokens_details: { reasoning_tokens: Number(u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens) || 0 },
+ total_tokens: Number(u.total_tokens) || input + output,
+ };
+}
+
+function responsesBase(chat, meta, status) {
+ return {
+ id: `resp_${runIdOf(chat.id)}`, object: 'response',
+ created_at: Number(chat.created) || Math.floor(Date.now() / 1000), status, error: null, incomplete_details: null,
+ instructions: meta.instructions, max_output_tokens: meta.max_output_tokens,
+ model: chat.model || meta.model || 'openvibe', output: [],
+ parallel_tool_calls: meta.parallel_tool_calls, previous_response_id: null,
+ reasoning: { effort: null, summary: null }, store: meta.store, temperature: meta.temperature,
+ text: { format: meta.text_format }, tool_choice: meta.tool_choice, tools: meta.tools, top_p: meta.top_p,
+ truncation: 'disabled', usage: null, user: null, metadata: meta.metadata,
+ };
+}
+
+function responsesFromChat(chat, meta) {
+ const out = responsesBase(chat, meta, 'completed');
+ const choice = (chat.choices && chat.choices[0]) || {};
+ const message = choice.message || {};
+ const runId = runIdOf(chat.id);
+ const text = typeof message.content === 'string' ? message.content : '';
+ const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
+ if (text.length || !calls.length) {
+ out.output.push({ type: 'message', id: `msg_${runId}`, status: 'completed', role: 'assistant',
+ content: [{ type: 'output_text', text, annotations: [], logprobs: [] }] });
+ }
+ calls.forEach((call, i) => out.output.push({ type: 'function_call', id: `fc_${runId}_${i}`, call_id: call.id, name: call.function && call.function.name,
+ arguments: (call.function && call.function.arguments) || '', status: 'completed' }));
+ out.usage = responsesUsage(chat.usage);
+ if (choice.finish_reason === 'length') { out.status = 'incomplete'; out.incomplete_details = { reason: 'max_output_tokens' }; }
+ if (chat.x_openvibe) out.x_openvibe = chat.x_openvibe;
+ return out;
+}
+
+/** Convert the Chat Completions SSE stream to Responses events, in the documented order. */
+function responsesStream(res, meta) {
+ const rawWrite = res.write.bind(res);
+ const rawEnd = res.end.bind(res);
+ let started = false;
+ let done = false;
+ let response = null;
+ let seq = 0;
+ let text = '';
+ let textItem = null;
+ let textIndex = -1;
+ let outputIndex = 0;
+ let finishReason = null;
+ let usage = null;
+ const calls = new Map();
+ const items = [];
+ const send = (type, data) => {
+ if (done || res.writableEnded || res.destroyed) return;
+ rawWrite(`event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: seq++, ...data })}\n\n`);
+ };
+ const start = (chat) => {
+ if (started) return;
+ started = true;
+ response = responsesBase(chat, meta, 'in_progress');
+ send('response.created', { response });
+ };
+ const addText = () => {
+ if (textItem) return;
+ const part = { type: 'output_text', text: '', annotations: [], logprobs: [] };
+ textItem = { type: 'message', id: `msg_${response.id.slice(5)}`, status: 'in_progress', role: 'assistant', content: [part] };
+ textIndex = outputIndex++;
+ send('response.output_item.added', { output_index: textIndex, item: textItem });
+ send('response.content_part.added', { item_id: textItem.id, output_index: textIndex, content_index: 0, part });
+ items.push({ index: textIndex, item: textItem });
+ };
+ const addCall = (call, idx) => {
+ const item = { type: 'function_call', id: `fc_${response.id.slice(5)}_${calls.size}`, call_id: call.id || `call_${idx}`,
+ name: call.function && call.function.name ? call.function.name : 'function', arguments: '', status: 'in_progress' };
+ const placed = { index: outputIndex++, item, args: '', finished: false };
+ calls.set(idx, placed);
+ items.push({ index: placed.index, item });
+ send('response.output_item.added', { output_index: placed.index, item });
+ };
+ const finish = (reason) => {
+ if (reason) finishReason = reason;
+ if (!started) return;
+ if (!textItem && !calls.size) addText();
+ if (textItem && textItem.status !== 'completed') {
+ textItem.status = 'completed';
+ const part = textItem.content[0];
+ part.text = text;
+ send('response.output_text.done', { item_id: textItem.id, output_index: textIndex, content_index: 0, text, logprobs: [] });
+ send('response.content_part.done', { item_id: textItem.id, output_index: textIndex, content_index: 0, part });
+ send('response.output_item.done', { output_index: textIndex, item: textItem });
+ }
+ for (const call of calls.values()) {
+ if (call.finished) continue;
+ call.finished = true;
+ call.item.status = 'completed';
+ call.item.arguments = call.args;
+ send('response.function_call_arguments.done', { item_id: call.item.id, output_index: call.index, arguments: call.args });
+ send('response.output_item.done', { output_index: call.index, item: call.item });
+ }
+ };
+ const complete = () => {
+ if (done) return;
+ finish(finishReason);
+ if (!started) { done = true; return; }
+ const output = items.slice().sort((a, b) => a.index - b.index).map((x) => x.item);
+ const final = { ...response, status: finishReason === 'length' ? 'incomplete' : 'completed',
+ incomplete_details: finishReason === 'length' ? { reason: 'max_output_tokens' } : null, output, usage: responsesUsage(usage) };
+ send('response.completed', { response: final });
+ done = true;
+ };
+ const handle = (chat) => {
+ if (!chat || typeof chat !== 'object') return;
+ if (chat.error) {
+ send('error', { code: chat.error.code || null, message: chat.error.message || 'error', param: null });
+ done = true;
+ return;
+ }
+ start(chat);
+ if (chat.usage) usage = chat.usage;
+ if (chat.x_openvibe) response.x_openvibe = chat.x_openvibe;
+ for (const choice of chat.choices || []) {
+ const d = choice.delta || {};
+ if (typeof d.content === 'string' && d.content.length) {
+ addText();
+ text += d.content;
+ send('response.output_text.delta', { item_id: textItem.id, output_index: textIndex, content_index: 0, delta: d.content, logprobs: [] });
+ }
+ for (const call of d.tool_calls || []) {
+ const idx = Number(call.index) || 0;
+ if (!calls.has(idx)) addCall(call, idx);
+ const placed = calls.get(idx);
+ if (call.id) placed.item.call_id = call.id;
+ if (call.function && call.function.name) placed.item.name = call.function.name;
+ const piece = call.function && call.function.arguments;
+ if (typeof piece === 'string' && piece.length) {
+ placed.args += piece;
+ send('response.function_call_arguments.delta', { item_id: placed.item.id, output_index: placed.index, delta: piece });
+ }
+ }
+ if (choice.finish_reason) finish(choice.finish_reason);
+ }
+ };
+ res.write = (chunk, ...args) => {
+ const value = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
+ if (value.startsWith(': ')) return true;
+ if (value.startsWith('data: ')) {
+ const data = value.slice(6).replace(/\s+$/, '');
+ if (data === '[DONE]') { complete(); return true; }
+ try { handle(JSON.parse(data)); } catch { /* not a chat chunk */ }
+ return true;
+ }
+ return rawWrite(chunk, ...args);
+ };
+ res.end = (chunk, ...args) => {
+ if (chunk == null) complete();
+ return rawEnd(chunk, ...args);
+ };
+}
+
+// --- Gemini generateContent ---------------------------------------------------------------------------------------
+
+const GEMINI_STATUS = { 400: 'INVALID_ARGUMENT', 401: 'UNAUTHENTICATED', 403: 'PERMISSION_DENIED', 404: 'NOT_FOUND', 409: 'ABORTED', 429: 'RESOURCE_EXHAUSTED', 500: 'INTERNAL', 503: 'UNAVAILABLE', 504: 'DEADLINE_EXCEEDED' };
+function geminiError(status, message, code) {
+ return { error: { code: status, message: String(message || 'error').slice(0, 1000), status: GEMINI_STATUS[status] || 'UNKNOWN', ...(code ? { details: [{ '@type': 'type.googleapis.com/google.rpc.ErrorInfo', reason: code }] } : {}) } };
+}
+function geminiStatusFor(code) {
+ if (/^(input|validation)\./.test(code || '')) return 400;
+ if (/quota|allowance|budget/.test(code || '')) return 429;
+ if (code === 'project_secret.refused' || /capability\.denied|policy\./.test(code || '')) return 403;
+ if (code === 'ai.model_not_found') return 404;
+ return 503;
+}
+function asGeminiErrors(req, res, next) {
+ const end = res.end.bind(res);
+ res.end = (chunk, ...rest) => {
+ const ct = String(res.getHeader('Content-Type') || '');
+ if (res.statusCode >= 400 && /problem\+json/.test(ct) && chunk) {
+ let p = null;
+ try { p = JSON.parse(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); } catch { p = null; }
+ if (p && p.error == null) {
+ const body = JSON.stringify(geminiError(res.statusCode, p.detail || p.title || p.code || 'error', p.code));
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ res.setHeader('Content-Length', Buffer.byteLength(body));
+ return end(body);
+ }
+ }
+ return end(chunk, ...rest);
+ };
+ next();
+}
+
+function geminiText(parts) {
+ if (!Array.isArray(parts)) return '';
+ return parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('');
+}
+function geminiSystem(instruction) {
+ if (!instruction) return '';
+ if (typeof instruction === 'string') return instruction;
+ return geminiText(instruction.parts);
+}
+function geminiLastCallId(messages, name) {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const m = messages[i];
+ for (const t of (m && m.tool_calls) || []) if (t.function && t.function.name === name) return t.id;
+ }
+ return `call_${name || 'function'}`;
+}
+function geminiArgs(value) {
+ if (value == null) return {};
+ if (typeof value === 'object') return value;
+ try { const parsed = JSON.parse(String(value)); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; }
+}
+function geminiFinish(reason) {
+ if (reason === 'length') return 'MAX_TOKENS';
+ if (reason === 'content_filter') return 'SAFETY';
+ if (reason === 'stop' || reason === 'tool_calls') return 'STOP';
+ return reason ? 'OTHER' : undefined;
+}
+function geminiUsage(usage) {
+ const u = usage || {};
+ const input = Number(u.prompt_tokens) || 0;
+ const output = Number(u.completion_tokens) || 0;
+ return {
+ promptTokenCount: input, candidatesTokenCount: output,
+ totalTokenCount: Number(u.total_tokens) || input + output,
+ cachedContentTokenCount: Number(u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0,
+ };
+}
+
+function geminiToChat(body, model, stream) {
+ const b = body && typeof body === 'object' ? body : {};
+ if (!Array.isArray(b.contents) || !b.contents.length) throw INVALID('contents must be a non-empty array');
+ const messages = [];
+ const system = geminiSystem(b.systemInstruction);
+ if (system) messages.push({ role: 'system', content: system });
+ for (const content of b.contents) {
+ if (!content || typeof content !== 'object') throw INVALID('contents must be objects');
+ const role = content.role === 'model' ? 'assistant' : 'user';
+ const texts = [];
+ const images = [];
+ const calls = [];
+ const results = [];
+ for (const part of content.parts || []) {
+ if (!part || typeof part !== 'object') continue;
+ if (typeof part.text === 'string') texts.push(part.text);
+ else if (part.inlineData && part.inlineData.data) {
+ const mime = part.inlineData.mimeType || 'image/png';
+ images.push({ type: 'image_url', image_url: { url: `data:${mime};base64,${part.inlineData.data}` } });
+ } else if (part.functionCall) {
+ if (typeof part.functionCall.name !== 'string' || !part.functionCall.name) throw INVALID('functionCall.name is required');
+ calls.push({ id: `call_${messages.length}_${calls.length}`, type: 'function',
+ function: { name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args || {}) } });
+ } else if (part.functionResponse) {
+ results.push({ name: part.functionResponse.name, response: part.functionResponse.response });
+ }
+ }
+ if (results.length) {
+ for (const r of results) messages.push({ role: 'tool', tool_call_id: geminiLastCallId(messages, r.name), content: JSON.stringify(r.response == null ? {} : r.response) });
+ continue;
+ }
+ const value = images.length ? [...texts.map((text) => ({ type: 'text', text })), ...images] : texts.join('');
+ if (calls.length) messages.push({ role: 'assistant', content: value || null, tool_calls: calls });
+ else if (texts.length || images.length) messages.push({ role, content: value });
+ }
+ if (!messages.some((m) => m.role !== 'system')) throw INVALID('contents must not be empty');
+ const out = { messages, model };
+ const tools = [];
+ for (const t of Array.isArray(b.tools) ? b.tools : []) {
+ for (const d of Array.isArray(t.functionDeclarations) ? t.functionDeclarations : []) {
+ if (!d || typeof d.name !== 'string' || !d.name) throw INVALID('a function declaration needs a name');
+ tools.push({ type: 'function', function: { name: d.name, ...(d.description != null ? { description: d.description } : {}), ...(d.parameters != null ? { parameters: d.parameters } : {}) } });
+ }
+ }
+ if (tools.length) out.tools = tools;
+ const tc = b.toolConfig && b.toolConfig.functionCallingConfig;
+ if (tc) {
+ if (tc.mode === 'NONE') out.tool_choice = 'none';
+ else if (tc.mode === 'ANY') {
+ const names = Array.isArray(tc.allowedFunctionNames) ? tc.allowedFunctionNames : [];
+ out.tool_choice = names.length === 1 ? { type: 'function', function: { name: names[0] } } : 'required';
+ } else if (tc.mode === 'AUTO') out.tool_choice = 'auto';
+ }
+ const gc = b.generationConfig && typeof b.generationConfig === 'object' ? b.generationConfig : {};
+ if (gc.temperature != null) out.temperature = gc.temperature;
+ if (gc.topP != null) out.top_p = gc.topP;
+ if (gc.maxOutputTokens != null) out.max_tokens = Math.max(1, Math.min(Number(gc.maxOutputTokens) || 1, 16000));
+ if (gc.stopSequences != null) out.stop = gc.stopSequences;
+ if (gc.candidateCount != null && Number(gc.candidateCount) !== 1) throw INVALID('candidateCount must be 1');
+ if (gc.responseMimeType === 'application/json') {
+ const schema = gc.responseSchema || gc.responseJsonSchema;
+ out.response_format = schema && typeof schema === 'object'
+ ? { type: 'json_schema', json_schema: { name: 'response', schema, strict: true } }
+ : { type: 'json_object' };
+ }
+ if (stream) {
+ out.stream = true;
+ out.stream_options = { include_usage: true };
+ }
+ return out;
+}
+
+function geminiResponse(chat, model) {
+ const choice = (chat.choices && chat.choices[0]) || {};
+ const message = choice.message || {};
+ const parts = [];
+ if (typeof message.content === 'string' && message.content.length) parts.push({ text: message.content });
+ for (const call of message.tool_calls || []) parts.push({ functionCall: { name: call.function && call.function.name, args: geminiArgs(call.function && call.function.arguments) } });
+ if (!parts.length) parts.push({ text: '' });
+ const out = {
+ candidates: [{ content: { role: 'model', parts }, finishReason: geminiFinish(choice.finish_reason) || 'STOP', index: 0 }],
+ usageMetadata: geminiUsage(chat.usage), modelVersion: chat.model || model,
+ };
+ if (chat.x_openvibe) out.x_openvibe = chat.x_openvibe;
+ return out;
+}
+
+function installGeminiJson(res, model) {
+ const json = res.json.bind(res);
+ res.json = (value) => {
+ if (value && Array.isArray(value.choices)) return json(geminiResponse(value, model));
+ if (value && value.error && res.statusCode >= 400) return json(geminiError(res.statusCode, value.error.message, value.error.code));
+ return json(value);
+ };
+}
+
+/** The Chat SSE stream -> Gemini chunks, written as SSE, or collected into the JSON array Gemini's REST returns. */
+function installGeminiStream(res, model, sse) {
+ const rawWrite = res.write.bind(res);
+ const rawEnd = res.end.bind(res);
+ const rawSetHeader = res.setHeader.bind(res);
+ const chunks = [];
+ let done = false;
+ let finished = false;
+ let usage = null;
+ let responseModel = model;
+ const calls = new Map();
+ const emit = (chunk) => {
+ if (done) return;
+ if (sse) rawWrite(`data: ${JSON.stringify(chunk)}\n\n`);
+ else chunks.push(chunk);
+ };
+ const finish = (reason) => {
+ if (finished) return;
+ finished = true;
+ const parts = [...calls.values()].map((c) => ({ functionCall: { name: c.name, args: geminiArgs(c.args) } }));
+ emit({ candidates: [{ content: { role: 'model', parts }, finishReason: geminiFinish(reason || (calls.size ? 'tool_calls' : 'stop')) || 'STOP', index: 0 }], modelVersion: responseModel });
+ };
+ const handle = (chat) => {
+ if (!chat || typeof chat !== 'object') return;
+ if (chat.error) {
+ emit(geminiError(geminiStatusFor(chat.error.code), chat.error.message, chat.error.code));
+ done = true;
+ return;
+ }
+ if (chat.usage) {
+ usage = chat.usage;
+ emit({ ...(sse && finished ? { candidates: [] } : {}), usageMetadata: geminiUsage(usage), modelVersion: responseModel });
+ return;
+ }
+ for (const choice of chat.choices || []) {
+ const d = choice.delta || {};
+ if (typeof d.content === 'string' && d.content.length) emit({ candidates: [{ content: { role: 'model', parts: [{ text: d.content }] }, index: 0 }], modelVersion: responseModel });
+ for (const call of d.tool_calls || []) {
+ const idx = Number(call.index) || 0;
+ if (!calls.has(idx)) calls.set(idx, { name: (call.function && call.function.name) || 'function', args: '' });
+ const held = calls.get(idx);
+ if (call.function && call.function.name) held.name = call.function.name;
+ if (call.function && typeof call.function.arguments === 'string') held.args += call.function.arguments;
+ }
+ if (choice.finish_reason) finish(choice.finish_reason);
+ }
+ };
+ if (!sse) {
+ // Gemini's REST stream is a JSON array, not SSE: keep the chat stream's headers from going out and
+ // answer the collected chunks in one JSON body when the chat stream ends.
+ res.setHeader = (name, value) => {
+ if (String(name).toLowerCase() === 'content-type' && /text\/event-stream/.test(String(value))) return res;
+ return rawSetHeader(name, value);
+ };
+ res.flushHeaders = () => {};
+ }
+ res.write = (chunk, ...args) => {
+ const value = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
+ if (value.startsWith(': ')) return true;
+ if (value.startsWith('data: ')) {
+ const data = value.slice(6).replace(/\s+$/, '');
+ if (data === '[DONE]') { done = true; return true; }
+ try { handle(JSON.parse(data)); } catch { /* not a chat chunk */ }
+ return true;
+ }
+ if (sse) return rawWrite(chunk, ...args);
+ return true;
+ };
+ let flushed = false;
+ res.end = (chunk, ...args) => {
+ if (chunk != null) return rawEnd(chunk, ...args);
+ if (sse) return rawEnd(chunk, ...args);
+ if (flushed) return rawEnd();
+ flushed = true;
+ rawSetHeader('Content-Type', 'application/json; charset=utf-8');
+ return rawEnd(JSON.stringify(chunks));
+ };
+}
+
+// --- router -------------------------------------------------------------------------------------------------------
+
// POST /v1/audio/transcriptions: a run of ai.transcribe carries the audio as a data: URL, and a run's
// POST /v1/audio/transcriptions: a run of ai.transcribe carries the audio as a data: URL, and a run's
// input is capped (AI_MAX_INPUT_BYTES, 6 MB by default), so the file is 3 MB at most.
// input is capped (AI_MAX_INPUT_BYTES, 6 MB by default), so the file is 3 MB at most.
const MAX_MULTIPART_BYTES = 5 * 1024 * 1024;
const MAX_MULTIPART_BYTES = 5 * 1024 * 1024;
@@ -131,6 +720,17 @@ function compatExtra({ runs, auth, config, log }) {
@@ -131,6 +720,17 @@ function compatExtra({ runs, auth, config, log }) {
const r = express.Router();
const r = express.Router();
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const wrap = fn => async (req, res) => { try { await fn(req, res); } catch (err) { sendError(res, err, req.ov, log); } };
const wrap = fn => async (req, res) => { try { await fn(req, res); } catch (err) { sendError(res, err, req.ov, log); } };
+
+ // Gemini's own key headers name the same OpenVibe keys the bearer header carries. Authorization wins
+ // when both are present; nothing is invented from an empty key.
+ const geminiKey = (req, res, next) => {
+ const header = req.get('x-goog-api-key');
+ const query = typeof req.query.key === 'string' ? req.query.key : '';
+ const supplied = header || query;
+ if (!req.get('authorization') && supplied && String(supplied).trim()) req.headers.authorization = `Bearer ${String(supplied).trim()}`;
+ next();
+ };
+
const openaiRoute = (label, fn) => async (req, res) => {
const openaiRoute = (label, fn) => async (req, res) => {
try { await fn(req, res); } catch (err) {
try { await fn(req, res); } catch (err) {
if (res.headersSent) { try { res.end(); } catch { /* already closed */ } return; }
if (res.headersSent) { try { res.end(); } catch { /* already closed */ } return; }
@@ -180,6 +780,39 @@ function compatExtra({ runs, auth, config, log }) {
@@ -180,6 +780,39 @@ function compatExtra({ runs, auth, config, log }) {
...accounting(run), total_cost: Number(run.usage.cost_usd || 0),
...accounting(run), total_cost: Number(run.usage.cost_usd || 0),
tokens_prompt: run.usage.tokens_in, tokens_completion: run.usage.tokens_out, created_at: run.created_at } });
tokens_prompt: run.usage.tokens_in, tokens_completion: run.usage.tokens_out, created_at: run.created_at } });
}));
}));
+
+ // OpenAI Responses API, stateless. `store` is accepted and ignored; previous_response_id is refused
+ // rather than pretending the conversation is kept.
+ r.post('/v1/responses', asOpenAiErrors, allow, (req, res, next) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ if (b.previous_response_id != null) return openaiError(res, 400, 'previous_response_id is not supported: send the whole conversation as input', 'input.invalid');
+ let chat;
+ try { chat = responsesToChat(b); } catch (err) { return openaiError(res, err.status || 400, err.detail || err.message, err.code); }
+ const meta = responsesMeta(b);
+ if (b.stream === true) responsesStream(res, meta);
+ else {
+ const json = res.json.bind(res);
+ res.json = (value) => (value && Array.isArray(value.choices) ? json(responsesFromChat(value, meta)) : json(value));
+ }
+ req.body = chat;
+ req.url = '/v1/chat/completions';
+ next();
+ });
+
+ // Gemini. Express reads the literal colon in ":model\:generateContent" as part of the path (and the
+ // documentation's {model}:generateContent), so the model is req.params.model.
+ const gemini = (stream) => (req, res, next) => {
+ let chat;
+ try { chat = geminiToChat(req.body, req.params.model, stream); } catch (err) { return res.status(err.status || 400).json(geminiError(err.status || 400, err.detail || err.message, err.code)); }
+ if (stream) installGeminiStream(res, req.params.model, String(req.query.alt || '').toLowerCase() === 'sse');
+ else installGeminiJson(res, req.params.model);
+ req.body = chat;
+ req.url = '/v1/chat/completions';
+ next();
+ };
+ r.post('/v1beta/models/:model\\:generateContent', geminiKey, asGeminiErrors, allow, gemini(false));
+ r.post('/v1beta/models/:model\\:streamGenerateContent', geminiKey, asGeminiErrors, allow, gemini(true));
+
r.post('/v1/embeddings', allow, wrap(async (req, res) => {
r.post('/v1/embeddings', allow, wrap(async (req, res) => {
const b = req.body || {};
const b = req.body || {};
if (b.encoding_format && b.encoding_format !== 'float') throw new AiError(400, 'input.invalid', 'Only float embeddings are supported');
if (b.encoding_format && b.encoding_format !== 'float') throw new AiError(400, 'input.invalid', 'Only float embeddings are supported');
@@ -104,7 +104,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -104,7 +104,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
// Terms, Privacy and DMCA moved with the human site: /terms, /privacy and /dmca answer 301 to
// Terms, Privacy and DMCA moved with the human site: /terms, /privacy and /dmca answer 301 to
// OpenVibe.Exchange's own legal pages (see the page map below).
// OpenVibe.Exchange's own legal pages (see the page map below).
// Inline images arrive as data URLs; the per-run input cap is enforced again in runs.create.
// Inline images arrive as data URLs; the per-run input cap is enforced again in runs.create.
- app.use(['/api', '/v1'], express.json({ limit: Math.ceil(config.runs.maxInputBytes * 1.5), type: ['application/json', 'application/*+json'] }));
+ app.use(['/api', '/v1', '/v1beta'], express.json({ limit: Math.ceil(config.runs.maxInputBytes * 1.5), type: ['application/json', 'application/*+json'] }));
app.get('/api/health', (_req, res) => {
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', service: 'openvibe-ai', version: pkg.version });
res.json({ status: 'ok', service: 'openvibe-ai', version: pkg.version });
@@ -17,6 +17,39 @@ const operation = (operationId, summary, props, required = []) => ({ operationId
@@ -17,6 +17,39 @@ const operation = (operationId, summary, props, required = []) => ({ operationId
const paths = {
const paths = {
'/v1/models': { get: { operationId: 'compatModels', summary: 'Catalogue models with OpenRouter pricing and context fields', tags: ['exchange'], security: [{ bearer: [] }], responses: { 200: { description: 'data model entries: id, name, pricing (USD per token strings), context_length, architecture, top_provider, and features: { tools, structured_output, vision, audio_in, audio_out, reasoning_effort, prompt_caching, logprobs, streaming, batch, realtime, files }. A feature is true or false only when the registry model and provider metadata say so; unknown is null. Anthropic header selects Anthropic format. Catalogue flag off retains the virtual model.' } } } },
'/v1/models': { get: { operationId: 'compatModels', summary: 'Catalogue models with OpenRouter pricing and context fields', tags: ['exchange'], security: [{ bearer: [] }], responses: { 200: { description: 'data model entries: id, name, pricing (USD per token strings), context_length, architecture, top_provider, and features: { tools, structured_output, vision, audio_in, audio_out, reasoning_effort, prompt_caching, logprobs, streaming, batch, realtime, files }. A feature is true or false only when the registry model and provider metadata say so; unknown is null. Anthropic header selects Anthropic format. Catalogue flag off retains the virtual model.' } } } },
'/v1/chat/completions': { post: operation('compatChatCompletions', 'Chat completion with provider routing', { ...properties, messages: { type: 'array', items: { type: 'object' } }, stream: { type: 'boolean' } }, ['messages']) },
'/v1/chat/completions': { post: operation('compatChatCompletions', 'Chat completion with provider routing', { ...properties, messages: { type: 'array', items: { type: 'object' } }, stream: { type: 'boolean' } }, ['messages']) },
+ '/v1/responses': { post: { operationId: 'compatResponses', summary: 'OpenAI Responses API (stateless) over the chat completion path', tags: ['exchange'], security: [{ bearer: [] }],
+ parameters: [{ in: 'header', name: 'Idempotency-Key', schema: { type: 'string' }, description: 'Owner-scoped replay; changed input or endpoint conflicts.' }],
+ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', additionalProperties: false, required: ['input'], properties: {
+ ...properties,
+ input: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'object' }, description: 'Message items (role + content with input_text and input_image), or function_call / function_call_output items.' }] },
+ instructions: { type: 'string' }, max_output_tokens: { type: 'integer', minimum: 1 },
+ tools: { type: 'array', items: { type: 'object' }, description: 'Function tools only: { type: "function", name, description, parameters }.' },
+ tool_choice: { anyOf: [{ enum: ['auto', 'none', 'required'] }, { type: 'object' }] },
+ parallel_tool_calls: { type: 'boolean' }, temperature: { type: 'number' },
+ text: { type: 'object', description: 'format: text, json_object or json_schema (name, schema, strict).' },
+ stream: { type: 'boolean' }, store: { type: 'boolean', description: 'Accepted and ignored: responses are not kept.' },
+ previous_response_id: { type: 'string', description: 'Refused with 400: send the whole conversation as input.' },
+ } } } } },
+ responses: { 200: { description: 'The response object: id resp_<run id>, status, output items (message with output_text, function_call) and usage with input/output token details. stream: true sends response.created, output_item.added, content_part.added, output_text.delta, output_text.done, content_part.done, output_item.done and response.completed events.' },
+ 400: { description: 'Invalid request, or previous_response_id supplied' }, 401: { description: 'Invalid or expired key' }, 402: { description: 'Quota or balance refused' }, 429: { description: 'Key cap reached' }, 503: { description: 'No eligible provider' } } } },
+ '/v1beta/models/{model}:generateContent': { post: { operationId: 'compatGeminiGenerateContent', summary: 'Gemini generateContent over the chat completion path', tags: ['exchange'], security: [{ bearer: [] }],
+ parameters: [{ in: 'header', name: 'x-goog-api-key', schema: { type: 'string' }, description: 'An OpenVibe key; ?key= also carries one.' }],
+ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', additionalProperties: false, required: ['contents'], properties: {
+ contents: { type: 'array', minItems: 1, items: { type: 'object' }, description: 'role user/model, parts text, inlineData, functionCall, functionResponse.' },
+ systemInstruction: { type: 'object' }, generationConfig: { type: 'object', description: 'temperature, topP, maxOutputTokens, stopSequences, responseMimeType, responseSchema, candidateCount (1).' },
+ tools: { type: 'array', items: { type: 'object' }, description: 'functionDeclarations.' }, toolConfig: { type: 'object' },
+ } } } } },
+ responses: { 200: { description: 'candidates[].content.parts (text or functionCall), finishReason, usageMetadata (promptTokenCount, candidatesTokenCount, totalTokenCount, cachedContentTokenCount) and modelVersion.' },
+ 400: { description: 'Invalid request' }, 401: { description: 'Invalid or expired key' }, 402: { description: 'Quota or balance refused' }, 429: { description: 'Key cap reached' }, 503: { description: 'No eligible provider' } } } },
+ '/v1beta/models/{model}:streamGenerateContent': { post: { operationId: 'compatGeminiStreamGenerateContent', summary: 'Gemini streamGenerateContent: SSE with ?alt=sse, otherwise a JSON array', tags: ['exchange'], security: [{ bearer: [] }],
+ parameters: [{ in: 'header', name: 'x-goog-api-key', schema: { type: 'string' }, description: 'An OpenVibe key; ?key= also carries one.' },
+ { in: 'query', name: 'alt', schema: { const: 'sse' }, description: 'sse: server-sent events. Other values answer one JSON array of GenerateContentResponse chunks.' }],
+ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', additionalProperties: false, required: ['contents'], properties: {
+ contents: { type: 'array', minItems: 1, items: { type: 'object' } }, systemInstruction: { type: 'object' }, generationConfig: { type: 'object' },
+ tools: { type: 'array', items: { type: 'object' } }, toolConfig: { type: 'object' },
+ } } } } },
+ responses: { 200: { description: 'text/event-stream of GenerateContentResponse chunks when ?alt=sse; otherwise application/json with an array of the same chunks.' },
+ 400: { description: 'Invalid request' }, 401: { description: 'Invalid or expired key' }, 402: { description: 'Quota or balance refused' }, 429: { description: 'Key cap reached' }, 503: { description: 'No eligible provider' } } } },
'/v1/messages': { post: operation('compatMessages', 'Anthropic Messages with provider routing', { ...properties, messages: { type: 'array', items: { type: 'object' } }, max_tokens: { type: 'integer', minimum: 1 }, stream: { type: 'boolean' } }, ['messages', 'max_tokens']) },
'/v1/messages': { post: operation('compatMessages', 'Anthropic Messages with provider routing', { ...properties, messages: { type: 'array', items: { type: 'object' } }, max_tokens: { type: 'integer', minimum: 1 }, stream: { type: 'boolean' } }, ['messages', 'max_tokens']) },
'/v1/completions': { post: operation('compatCompletions', 'Legacy string-prompt completion', { ...properties, prompt: { type: 'string', minLength: 1 }, stream: { type: 'boolean' } }, ['prompt']) },
'/v1/completions': { post: operation('compatCompletions', 'Legacy string-prompt completion', { ...properties, prompt: { type: 'string', minLength: 1 }, stream: { type: 'boolean' } }, ['prompt']) },
'/v1/embeddings': { post: operation('compatEmbeddings', 'Float embeddings from a registered provider', { ...properties, input: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] }, encoding_format: { const: 'float' } }, ['input']) },
'/v1/embeddings': { post: operation('compatEmbeddings', 'Float embeddings from a registered provider', { ...properties, input: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] }, encoding_format: { const: 'float' } }, ['input']) },
new file mode 100644
new file mode 100644
@@ -0,0 +1,157 @@
@@ -0,0 +1,157 @@
+'use strict';
+// The two newest compatible surfaces (server/api/compat-extra.js), both converted onto the Chat Completions path:
+// OpenAI's stateless Responses API (a plain call, a stream with the documented event order, a function_call item,
+// previous_response_id refused) and Gemini's generateContent / streamGenerateContent (a plain call, SSE with ?alt=sse,
+// the JSON array without it, x-goog-api-key and ?key= carrying an OpenVibe key).
+const assert = require('assert');
+const { boot, request, token, suite, ALL } = require('./helpers');
+
+const t = suite('responses-gemini');
+const service = token('live', ALL);
+let h;
+const post = (path, body, headers = {}) => request(h.base, 'POST', path, { tok: service, body, headers });
+
+/** event/data SSE blocks -> [{ event, data }]. */
+async function sseEvents(res) {
+ const text = await res.text();
+ const events = [];
+ for (const block of text.split('\n\n')) {
+ let event = null;
+ let data = null;
+ for (const line of block.split('\n')) {
+ if (line.startsWith('event: ')) event = line.slice(7).trim();
+ else if (line.startsWith('data: ')) data = line.slice(6).trim();
+ }
+ if (event && data) events.push({ event, data: JSON.parse(data) });
+ }
+ return events;
+}
+
+const dataChunks = async (res) => (await res.text()).split('\n').filter((l) => l.startsWith('data: ')).map((l) => l.slice(6).trim()).filter((d) => d !== '[DONE]').map((d) => JSON.parse(d));
+
+t.test('boot', async () => { h = await boot({ env: { AI_FREE_PROVIDERS: 'stub' } }); });
+
+t.test('Responses: a plain call answers the response object, usage and the run it was', async () => {
+ const r = await post('/v1/responses', { model: 'openvibe', input: 'Say hello', instructions: 'Be brief.', store: true });
+ assert.strictEqual(r.status, 200, r.text);
+ const response = r.body;
+ assert.strictEqual(response.object, 'response');
+ assert.match(response.id, /^resp_run_/);
+ assert.strictEqual(response.status, 'completed');
+ assert.strictEqual(response.store, true, 'store is accepted and echoed');
+ assert.strictEqual(response.instructions, 'Be brief.');
+ assert.strictEqual(response.output[0].type, 'message');
+ assert.strictEqual(response.output[0].role, 'assistant');
+ assert.strictEqual(response.output[0].content[0].type, 'output_text');
+ assert.ok(response.output[0].content[0].text.length > 0);
+ assert.ok(response.usage.input_tokens > 0 && response.usage.output_tokens > 0);
+ assert.strictEqual(response.usage.total_tokens, response.usage.input_tokens + response.usage.output_tokens);
+ assert.strictEqual(response.usage.input_tokens_details.cached_tokens, 0);
+ assert.strictEqual(response.usage.output_tokens_details.reasoning_tokens, 0);
+ const runId = response.x_openvibe.run_id;
+ assert.strictEqual(response.id, `resp_${runId}`);
+ const run = await request(h.base, 'GET', `/api/v1/runs/${runId}`, { tok: service });
+ assert.strictEqual(run.body.run.workflow.key, 'ai.complete');
+ assert.ok(run.body.run.usage.tokens_in > 0, 'the run path recorded the usage');
+});
+
+t.test('Responses stream: the documented events in order, the deltas joining into the final text', async () => {
+ const res = await fetch(`${h.base}/v1/responses`, { method: 'POST', headers: { Authorization: `Bearer ${service}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: 'openvibe', input: 'Stream please', stream: true }) });
+ assert.strictEqual(res.status, 200);
+ assert.match(res.headers.get('content-type'), /^text\/event-stream/);
+ assert.match(String(res.headers.get('x-openvibe-run-id')), /^run_/);
+ const events = await sseEvents(res);
+ const types = events.map((e) => e.event);
+ assert.deepStrictEqual(types.slice(0, 3), ['response.created', 'response.output_item.added', 'response.content_part.added']);
+ const lastDelta = types.lastIndexOf('response.output_text.delta');
+ assert.ok(types.indexOf('response.output_text.delta') > 2 && lastDelta > types.indexOf('response.output_text.delta'), 'streamed in pieces');
+ assert.deepStrictEqual(types.slice(lastDelta + 1), ['response.output_text.done', 'response.content_part.done', 'response.output_item.done', 'response.completed']);
+ const joined = events.filter((e) => e.event === 'response.output_text.delta').map((e) => e.data.delta).join('');
+ const done = events.find((e) => e.event === 'response.output_text.done').data;
+ const completed = events.find((e) => e.event === 'response.completed').data.response;
+ assert.strictEqual(done.text, joined);
+ assert.strictEqual(completed.output[0].content[0].text, joined);
+ assert.strictEqual(completed.id, `resp_${res.headers.get('x-openvibe-run-id')}`);
+ assert.ok(completed.usage.input_tokens > 0);
+});
+
+t.test('Responses: a function tool call is a function_call item, and its output can be sent back', async () => {
+ const tools = [{ type: 'function', name: 'get_weather', description: 'Weather for a city',
+ parameters: { type: 'object', required: ['city'], properties: { city: { type: 'string' } } } }];
+ const r = await post('/v1/responses', { input: 'Weather in Lisbon?', tools, tool_choice: 'auto' });
+ assert.strictEqual(r.status, 200, r.text);
+ const call = r.body.output.find((item) => item.type === 'function_call');
+ assert.ok(call, JSON.stringify(r.body).slice(0, 300));
+ assert.strictEqual(call.name, 'get_weather');
+ assert.match(call.call_id, /^call_/);
+ assert.ok(JSON.parse(call.arguments).city);
+ const back = await post('/v1/responses', { input: [
+ { role: 'user', content: [{ type: 'input_text', text: 'Weather in Lisbon?' }] },
+ { type: 'function_call', call_id: call.call_id, name: call.name, arguments: call.arguments },
+ { type: 'function_call_output', call_id: call.call_id, output: '19 C and sunny' },
+ ] });
+ assert.strictEqual(back.status, 200, back.text);
+ assert.match(back.body.output[0].content[0].text, /19 C and sunny/);
+});
+
+t.test('Responses: previous_response_id is refused, and only function tools are accepted', async () => {
+ const r = await post('/v1/responses', { input: 'hello', previous_response_id: 'resp_old' });
+ assert.strictEqual(r.status, 400);
+ assert.strictEqual(r.body.error.message, 'previous_response_id is not supported: send the whole conversation as input');
+ assert.strictEqual(r.body.error.type, 'invalid_request_error');
+ const other = await post('/v1/responses', { input: 'hello', tools: [{ type: 'web_search' }] });
+ assert.strictEqual(other.status, 400);
+ assert.strictEqual(other.body.error.type, 'invalid_request_error');
+});
+
+t.test('Gemini generateContent answers candidates and usage, with x-goog-api-key and ?key= auth', async () => {
+ const body = { contents: [{ role: 'user', parts: [{ text: 'Hello Gemini' }] }], systemInstruction: { parts: [{ text: 'Be nice' }] },
+ generationConfig: { temperature: 0.2, maxOutputTokens: 64 } };
+ const r = await request(h.base, 'POST', '/v1beta/models/gemini-2.0-flash:generateContent', { body, headers: { 'x-goog-api-key': service } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.candidates[0].content.role, 'model');
+ assert.strictEqual(typeof r.body.candidates[0].content.parts[0].text, 'string');
+ assert.ok(r.body.candidates[0].content.parts[0].text.length > 0);
+ assert.strictEqual(r.body.candidates[0].finishReason, 'STOP');
+ assert.ok(r.body.usageMetadata.promptTokenCount > 0 && r.body.usageMetadata.candidatesTokenCount > 0);
+ assert.strictEqual(r.body.usageMetadata.totalTokenCount, r.body.usageMetadata.promptTokenCount + r.body.usageMetadata.candidatesTokenCount);
+ assert.strictEqual(r.body.usageMetadata.cachedContentTokenCount, 0);
+ assert.ok(r.body.modelVersion);
+ const run = await request(h.base, 'GET', `/api/v1/runs/${r.headers.get('x-openvibe-run-id')}`, { tok: service });
+ assert.strictEqual(run.body.run.workflow.key, 'ai.complete');
+ assert.ok(run.body.run.usage.tokens_in > 0, 'the run path recorded the usage');
+ const byQuery = await request(h.base, 'POST', `/v1beta/models/gemini-2.0-flash:generateContent?key=${encodeURIComponent(service)}`, { body });
+ assert.strictEqual(byQuery.status, 200, byQuery.text);
+ assert.ok(byQuery.body.candidates[0].content.parts[0].text.length > 0);
+});
+
+t.test('Gemini streamGenerateContent?alt=sse streams candidate chunks and usageMetadata', async () => {
+ const res = await fetch(`${h.base}/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse`, { method: 'POST',
+ headers: { Authorization: `Bearer ${service}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: 'Stream Gemini' }] }] }) });
+ assert.strictEqual(res.status, 200);
+ assert.match(res.headers.get('content-type'), /^text\/event-stream/);
+ const chunks = await dataChunks(res);
+ assert.ok(chunks.length > 2, `streamed in pieces (${chunks.length})`);
+ const text = chunks.flatMap((c) => (c.candidates || []).flatMap((cand) => (cand.content && cand.content.parts) || [])).filter((p) => typeof p.text === 'string').map((p) => p.text).join('');
+ assert.ok(text.length > 0);
+ const finish = chunks.find((c) => (c.candidates || []).some((cand) => cand.finishReason));
+ assert.strictEqual(finish.candidates[0].finishReason, 'STOP');
+ assert.ok(chunks.some((c) => c.usageMetadata && c.usageMetadata.promptTokenCount > 0), 'usageMetadata in the stream');
+});
+
+t.test('Gemini streamGenerateContent without alt answers one JSON array', async () => {
+ const res = await fetch(`${h.base}/v1beta/models/gemini-2.0-flash:streamGenerateContent`, { method: 'POST',
+ headers: { 'x-goog-api-key': service, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: 'Array Gemini' }] }] }) });
+ assert.strictEqual(res.status, 200);
+ assert.match(res.headers.get('content-type'), /^application\/json/);
+ const chunks = await res.json();
+ assert.ok(Array.isArray(chunks) && chunks.length > 2);
+ const text = chunks.flatMap((c) => (c.candidates || []).flatMap((cand) => (cand.content && cand.content.parts) || [])).filter((p) => typeof p.text === 'string').map((p) => p.text).join('');
+ assert.ok(text.length > 0);
+});
+
+t.test('close', async () => { await h.stop(); });
+t.run();