This commit is contained in:
암냥 2026-09-14 12:09:52 +09:00
commit 15091ac422
No known key found for this signature in database
19 changed files with 2328 additions and 263 deletions

View file

@ -0,0 +1,303 @@
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<var>, 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;
}
}