import QtQuick ApiStrategy { // GPT-5.6 Luna uses the Responses API so reasoning and function tools can // be enabled together. Reasoning summaries are intentionally not rendered // in the chat bubble; only the final output text belongs in the transcript. property string functionName: "" property string functionCallId: "" property string functionItemId: "" property string functionArguments: "" property bool functionCallEmitted: false function buildEndpoint(model: AiModel): string { return model.endpoint; } function responseTool(tool) { const functionData = tool?.function ?? tool ?? {}; let parameters = functionData.parameters; if (!parameters || Object.keys(parameters).length === 0) { parameters = { "type": "object", "properties": {}, }; } return { "type": "function", "name": functionData.name, "description": functionData.description ?? "", "parameters": parameters, "strict": false, }; } function previousFunctionCallId(messages, index, name) { for (let i = index - 1; i >= 0; i--) { const call = messages[i].functionCall; if (call && typeof call === "object" && (!name || call.name === name)) { return call.id ?? call.call_id ?? ""; } } return ""; } function buildInput(messages) { let input = []; for (let i = 0; i < messages.length; i++) { const message = messages[i]; const functionCall = message.functionCall; // Responses requires the model's function_call item to be kept in // the input before its corresponding function_call_output item. if (message.role === "assistant" && functionCall && typeof functionCall === "object") { const callId = functionCall.id ?? functionCall.call_id ?? ""; const name = message.functionName || functionCall.name || ""; if (callId && name) { input.push({ "type": "function_call", "id": functionCall.item_id ?? callId, "call_id": callId, "name": name, "arguments": JSON.stringify(functionCall.args ?? {}), "status": "completed", }); continue; } } if (message.functionResponse !== undefined && message.functionName) { const callId = message.functionCall?.id ?? message.functionCall?.call_id ?? previousFunctionCallId(messages, i, message.functionName); if (callId) { input.push({ "type": "function_call_output", "call_id": callId, "output": String(message.functionResponse), }); continue; } } if (message.rawContent && message.rawContent.length > 0) { input.push({ "role": message.role === "assistant" ? "assistant" : "user", "content": message.rawContent, }); } } return input; } function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list, filePath: string) { let baseData = { "model": model.model, "instructions": systemPrompt, "input": buildInput(messages), "stream": true, "tools": tools.map(responseTool), // Keep Luna's normal reasoning quality. The previous Chat // Completions workaround used reasoning_effort=none, which made // the model appear responsive but substantially worse at answers. "reasoning": { "effort": "medium", }, // Luna currently rejects the temperature parameter even though // it is accepted by the general Responses schema. }; return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; } function buildAuthorizationHeader(apiKeyEnvVarName: string): string { return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`; } function appendText(text, message) { if (!text || text.length === 0) return; message.content += text; message.rawContent += text; } function appendError(error, message) { const errorMsg = `**Error**: ${error?.message || JSON.stringify(error)}`; appendText(errorMsg, message); return { finished: true }; } function appendOutputItemText(item, message) { if (message.content.length > 0 || !item || !Array.isArray(item.content)) return; for (let i = 0; i < item.content.length; i++) { const content = item.content[i]; if ((content?.type === "output_text" || content?.type === "text") && content.text) { appendText(content.text, message); } } } function appendResponseOutput(response, message) { if (message.content.length > 0 || !response || !Array.isArray(response.output)) return; for (let i = 0; i < response.output.length; i++) { const item = response.output[i]; if (item?.type === "message") appendOutputItemText(item, message); } } function parseUsage(response) { const usage = response?.usage; if (!usage) return undefined; return { input: usage.input_tokens ?? -1, output: usage.output_tokens ?? -1, total: usage.total_tokens ?? -1, }; } function parseFunctionArguments(message) { let args = {}; try { args = JSON.parse(functionArguments || "{}"); } catch (e) { appendText(`\n\n[[ Invalid function arguments: ${functionArguments} ]]\n`, message); return null; } const name = functionName; const callId = functionCallId || functionItemId; if (!name || !callId || functionCallEmitted) return null; const call = { name: name, args: args, id: callId, call_id: callId, item_id: functionItemId, }; message.functionName = name; message.functionCall = call; appendText(`\n\n[[ Function: ${name}(${JSON.stringify(args, null, 2)}) ]]\n`, message); functionCallEmitted = true; return { functionCall: call }; } function parseEventLine(line, message) { let cleanData = line.trim(); // Responses streams have both `event:` and `data:` SSE lines. The // event name is useful for humans but the JSON type is authoritative. if (cleanData.startsWith("event:")) return {}; if (cleanData.startsWith("data:")) cleanData = cleanData.slice(5).trim(); if (!cleanData || cleanData.startsWith(":")) return {}; if (cleanData === "[DONE]") return { finished: true }; let dataJson; try { dataJson = JSON.parse(cleanData); } catch (e) { // A partial/non-JSON SSE line must not be rendered as assistant // text. SplitParser normally gives us complete data lines, but // ignoring malformed metadata is safer than corrupting the UI. return {}; } if (dataJson.error) return appendError(dataJson.error, message); if (dataJson.response?.error) return appendError(dataJson.response.error, message); const eventType = dataJson.type || ""; if (eventType === "response.output_item.added") { const item = dataJson.item; if (item?.type === "function_call") { functionItemId = item.id ?? ""; functionCallId = item.call_id ?? ""; functionName = item.name ?? ""; functionArguments = item.arguments ?? ""; } return {}; } if (eventType === "response.function_call_arguments.delta") { functionArguments += dataJson.delta ?? ""; return {}; } if (eventType === "response.function_call_arguments.done") { functionArguments = dataJson.arguments ?? functionArguments; return parseFunctionArguments(message) ?? {}; } if (eventType === "response.output_item.done") { const item = dataJson.item; appendOutputItemText(item, message); if (item?.type === "function_call") { functionItemId = item.id ?? functionItemId; functionCallId = item.call_id ?? functionCallId; functionName = item.name ?? functionName; functionArguments = item.arguments ?? functionArguments; return parseFunctionArguments(message) ?? {}; } return {}; } if (eventType === "response.output_text.delta" || eventType === "response.refusal.delta") { appendText(dataJson.delta, message); return {}; } if (eventType === "response.output_text.done" || eventType === "response.refusal.done") { // The done event contains the complete text after delta events. // Only use it as a fallback when no delta was received. if (message.content.length === 0) appendText(dataJson.text ?? dataJson.output_text, message); return {}; } if (eventType === "response.completed") { appendResponseOutput(dataJson.response, message); const tokenUsage = parseUsage(dataJson.response); return tokenUsage ? { tokenUsage: tokenUsage, finished: true } : { finished: true }; } if (eventType === "response.failed" || eventType === "response.incomplete") { const response = dataJson.response ?? {}; return response.error ? appendError(response.error, message) : { finished: true }; } // Some compatible gateways return a complete response object even // when streaming was requested. Handle only its actual text field; // never dump unknown response metadata into the chat bubble. if (dataJson.output_text) appendText(dataJson.output_text, message); appendResponseOutput(dataJson, message); if (dataJson.response?.output_text) appendText(dataJson.response.output_text, message); appendResponseOutput(dataJson.response, message); const tokenUsage = parseUsage(dataJson); return tokenUsage ? { tokenUsage: tokenUsage } : {}; } function parseResponseLine(line, message) { // SplitParser usually emits one line, but curl/SSE buffering can hand // us several records at once. Parse every physical line so a metadata // event cannot hide the following output_text event. const lines = String(line).split(/\r?\n/); let result = {}; for (let i = 0; i < lines.length; i++) { const parsed = parseEventLine(lines[i], message); if (parsed.functionCall) result.functionCall = parsed.functionCall; if (parsed.tokenUsage) result.tokenUsage = parsed.tokenUsage; if (parsed.finished) result.finished = true; } return result; } function onRequestFinished(message) { return {}; } function reset() { functionName = ""; functionCallId = ""; functionItemId = ""; functionArguments = ""; functionCallEmitted = false; } }