Revision control

Copy as Markdown

// Copyright (C) 2025 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
#include "openaicompatible.h"
#include "translationsettings.h"
#include "translationutils.h"
#include "translatormessage.h"
#include <QtCore/qjsonarray.h>
#include <QtCore/qjsonobject.h>
using namespace Qt::Literals::StringLiterals;
QT_BEGIN_NAMESPACE
OpenAICompatible::OpenAICompatible()
: m_payloadBase(std::make_unique<QJsonObject>()),
m_formatTryCounter(TranslationSettings::maxJsonFormatTries())
{
m_payloadBase->insert("stream"_L1, false);
m_payloadBase->insert("temperature"_L1, TranslationSettings::temperature());
}
OpenAICompatible::~OpenAICompatible() = default;
QList<Batch> OpenAICompatible::makeBatches(const Messages &messages,
const QString &userContext) const
{
QHash<QString, QList<const TranslatorMessage *>> nonPluralGroups;
QHash<QString, QList<const TranslatorMessage *>> pluralGroups;
for (const auto &item : messages.items) {
const QString key = item->context() + item->label();
if (item->isPlural())
pluralGroups[key].append(item);
else
nonPluralGroups[key].append(item);
}
const int maxBatchSize = TranslationSettings::maxBatchSize();
QList<Batch> out;
out.reserve(nonPluralGroups.size() + pluralGroups.size());
auto createBatches = [&](const QHash<QString, QList<const TranslatorMessage *>> &groups,
int pluralFormsCount) {
for (auto it = groups.cbegin(); it != groups.cend(); ++it) {
auto msgIt = it.value().cbegin();
while (msgIt != it.value().cend()) {
Batch b;
b.srcLang = messages.srcLang;
b.tgtLang = messages.tgtLang;
b.context = it.key();
b.userContext = userContext;
b.pluralFormsCount = pluralFormsCount;
b.items.reserve(it.value().size());
while (msgIt != it.value().cend() && b.items.size() < maxBatchSize) {
Item item;
item.msg = *msgIt;
item.translation = item.msg->translation();
b.items.append(std::move(item));
msgIt++;
}
out.append(std::move(b));
}
}
};
createBatches(nonPluralGroups, 1);
createBatches(pluralGroups, messages.pluralFormsCount);
return out;
}
QHash<QString, QStringList> OpenAICompatible::extractTranslations(const QByteArray &response,
bool plural)
{
QJsonParseError err;
QJsonDocument doc = QJsonDocument::fromJson(response, &err);
if (err.error != QJsonParseError::NoError) {
decrementFormatCounter();
return {};
}
// OpenAI format: { "choices": [{ "message": { "content": "..." } }] }
const QJsonObject root = doc.object();
const QJsonArray choices = root.value("choices"_L1).toArray();
if (choices.isEmpty()) {
decrementFormatCounter();
return {};
}
const QJsonObject firstChoice = choices.first().toObject();
const QJsonObject message = firstChoice.value("message"_L1).toObject();
const QString content = message.value("content"_L1).toString();
// Parse the content as JSON to extract translations
QJsonDocument contentDoc = QJsonDocument::fromJson(content.toUtf8(), &err);
QJsonValue contentValue;
if (err.error == QJsonParseError::NoError) {
contentValue = contentDoc.object();
} else {
// Try to extract JSON from the content string
contentValue = content;
}
QHash<QString, QStringList> translations;
if (plural) {
translations = extractPluralTranslations(contentValue, "Plurals"_L1);
} else {
auto singleTranslations = extractKeyValuePairs(contentValue, "Translations"_L1);
for (auto it = singleTranslations.cbegin(); it != singleTranslations.cend(); ++it)
translations[it.key()] << it.value();
}
if (translations.isEmpty()) {
decrementFormatCounter();
return translations;
}
// Lock in the current format stage once we get a successful response.
// This prevents unnecessary fallback attempts due to occasional empty responses.
m_formatLocked = true;
return translations;
}
QStringList OpenAICompatible::extractModels(const QByteArray &response) const
{
QJsonParseError err;
QJsonDocument doc = QJsonDocument::fromJson(response, &err);
if (err.error != QJsonParseError::NoError)
return {};
// OpenAI format: { "data": [{ "id": "model-name", ... }] }
const QJsonObject obj = doc.object();
const QJsonArray arr = obj.value("data"_L1).toArray();
QStringList models;
for (const QJsonValue &v : arr)
models.append(v.toObject().value("id"_L1).toString());
return models;
}
QByteArray OpenAICompatible::payload(const Batch &b) const
{
QJsonObject systemMessage;
systemMessage.insert("role"_L1, "system"_L1);
const bool plural = b.pluralFormsCount > 1;
systemMessage.insert("content"_L1,
plural ? pluralTranslationSystemPrompt() : translationSystemPrompt());
QJsonObject userMessage;
userMessage.insert("role"_L1, "user"_L1);
userMessage.insert("content"_L1, makePrompt(b));
QJsonArray messages;
messages.append(systemMessage);
messages.append(userMessage);
QJsonObject req = *m_payloadBase;
req.insert("messages"_L1, messages);
switch (m_formatStage) {
case JsonFormatStage::JsonObject: {
// llama.cpp style: {"type": "json_object"}
QJsonObject responseFormat;
responseFormat.insert("type"_L1, "json_object"_L1);
req.insert("response_format"_L1, responseFormat);
break;
}
case JsonFormatStage::JsonSchema: {
// LM Studio style: {"type": "json_schema", "json_schema": {...}}
QJsonObject schema;
schema.insert("type"_L1, "object"_L1);
QJsonObject properties;
QJsonObject translationsArray;
translationsArray.insert("type"_L1, "array"_L1);
properties.insert("Translations"_L1, translationsArray);
schema.insert("properties"_L1, properties);
QJsonArray required;
required.append("Translations"_L1);
schema.insert("required"_L1, required);
QJsonObject jsonSchema;
jsonSchema.insert("name"_L1, "translations"_L1);
jsonSchema.insert("schema"_L1, schema);
QJsonObject responseFormat;
responseFormat.insert("type"_L1, "json_schema"_L1);
responseFormat.insert("json_schema"_L1, jsonSchema);
req.insert("response_format"_L1, responseFormat);
break;
}
case JsonFormatStage::None:
// No response_format - rely on prompt instructions
break;
}
return QJsonDocument(req).toJson();
}
std::optional<QByteArray> OpenAICompatible::stageModel(const QString &modelName)
{
if (auto m = m_payloadBase->constFind("model"_L1);
m == m_payloadBase->constEnd() || *m != modelName) {
// Reset format fallback state for new model
m_formatStage = JsonFormatStage::JsonObject;
m_formatTryCounter = TranslationSettings::maxJsonFormatTries();
m_formatLocked = false;
m_payloadBase->insert("model"_L1, modelName);
}
// OpenAI-compatible servers typically don't need wake-up requests
// as they keep models loaded or handle loading transparently
return std::nullopt;
}
void OpenAICompatible::setUrl(const QString &url)
{
m_url = url;
}
QUrl OpenAICompatible::translationEndpoint() const
{
QString base = m_url;
if (!base.endsWith(u'/'))
base += u'/';
return QUrl(base + "v1/chat/completions"_L1);
}
QUrl OpenAICompatible::discoveryEndpoint() const
{
QString base = m_url;
if (!base.endsWith(u'/'))
base += u'/';
return QUrl(base + "v1/models"_L1);
}
void OpenAICompatible::onRequestRejected()
{
decrementFormatCounter();
}
void OpenAICompatible::decrementFormatCounter()
{
if (m_formatLocked)
return;
if (--m_formatTryCounter <= 0) {
// Move to next format stage
const int maxTries = TranslationSettings::maxJsonFormatTries();
switch (m_formatStage) {
case JsonFormatStage::JsonObject:
m_formatStage = JsonFormatStage::JsonSchema;
m_formatTryCounter = maxTries;
break;
case JsonFormatStage::JsonSchema:
m_formatStage = JsonFormatStage::None;
m_formatTryCounter = maxTries;
break;
case JsonFormatStage::None:
// Already at the last stage, nothing more to try
break;
}
}
}
QString OpenAICompatible::makePrompt(const Batch &b) const
{
QStringList lines;
lines.reserve(b.items.size() + 32);
if (!b.userContext.isEmpty())
lines << "Application Context: "_L1 + b.userContext;
lines << "Context: "_L1 + b.context;
lines << "Target: "_L1 + b.tgtLang;
if (b.pluralFormsCount > 1)
lines << "Plural forms: "_L1 + QString::number(b.pluralFormsCount);
lines << "Items:"_L1;
for (const Item &it : b.items) {
QString line = "- source: '%1'"_L1.arg(it.msg->sourceText());
if (const QString comment = it.msg->comment(); !comment.isEmpty())
line += ", comment: '%1'"_L1.arg(comment);
lines << line;
}
return lines.join(QLatin1Char('\n'));
}
QT_END_NAMESPACE