wow
This commit is contained in:
parent
1ab0263e27
commit
15091ac422
19 changed files with 2328 additions and 263 deletions
File diff suppressed because it is too large
Load diff
303
hosts/machine/ena/home/config/quickshell/OpenAiApiStrategy.qml
Normal file
303
hosts/machine/ena/home/config/quickshell/OpenAiApiStrategy.qml
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue