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

File diff suppressed because it is too large Load diff

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;
}
}