Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
openaicompatible.cpp
Go to the documentation of this file.
1// Copyright (C) 2025 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
3
8
9#include <QtCore/qjsonarray.h>
10#include <QtCore/qjsonobject.h>
11
12using namespace Qt::Literals::StringLiterals;
13
14QT_BEGIN_NAMESPACE
15
16OpenAICompatible::OpenAICompatible()
17 : m_payloadBase(std::make_unique<QJsonObject>()),
18 m_formatTryCounter(TranslationSettings::maxJsonFormatTries())
19{
20 m_payloadBase->insert("stream"_L1, false);
21 m_payloadBase->insert("temperature"_L1, TranslationSettings::temperature());
22}
23
25
27 const QString &userContext) const
28{
29 QHash<QString, QList<const TranslatorMessage *>> nonPluralGroups;
30 QHash<QString, QList<const TranslatorMessage *>> pluralGroups;
31
32 for (const auto &item : messages.items) {
33 const QString key = item->context() + item->label();
34 if (item->isPlural())
35 pluralGroups[key].append(item);
36 else
37 nonPluralGroups[key].append(item);
38 }
39
40 const int maxBatchSize = TranslationSettings::maxBatchSize();
41 QList<Batch> out;
42 out.reserve(nonPluralGroups.size() + pluralGroups.size());
43
44 auto createBatches = [&](const QHash<QString, QList<const TranslatorMessage *>> &groups,
45 int pluralFormsCount) {
46 for (auto it = groups.cbegin(); it != groups.cend(); ++it) {
47 auto msgIt = it.value().cbegin();
48 while (msgIt != it.value().cend()) {
49 Batch b;
50 b.srcLang = messages.srcLang;
51 b.tgtLang = messages.tgtLang;
52 b.context = it.key();
53 b.userContext = userContext;
54 b.pluralFormsCount = pluralFormsCount;
55 b.items.reserve(it.value().size());
56 while (msgIt != it.value().cend() && b.items.size() < maxBatchSize) {
57 Item item;
58 item.msg = *msgIt;
59 item.translation = item.msg->translation();
60 b.items.append(std::move(item));
61 msgIt++;
62 }
63 out.append(std::move(b));
64 }
65 }
66 };
67
68 createBatches(nonPluralGroups, 1);
69 createBatches(pluralGroups, messages.pluralFormsCount);
70
71 return out;
72}
73
75 bool plural)
76{
77 QJsonParseError err;
78 QJsonDocument doc = QJsonDocument::fromJson(response, &err);
79 if (err.error != QJsonParseError::NoError) {
80 decrementFormatCounter();
81 return {};
82 }
83
84 // OpenAI format: { "choices": [{ "message": { "content": "..." } }] }
85 const QJsonObject root = doc.object();
86 const QJsonArray choices = root.value("choices"_L1).toArray();
87 if (choices.isEmpty()) {
88 decrementFormatCounter();
89 return {};
90 }
91
92 const QJsonObject firstChoice = choices.first().toObject();
93 const QJsonObject message = firstChoice.value("message"_L1).toObject();
94 const QString content = message.value("content"_L1).toString();
95
96 // Parse the content as JSON to extract translations
97 QJsonDocument contentDoc = QJsonDocument::fromJson(content.toUtf8(), &err);
98 QJsonValue contentValue;
99 if (err.error == QJsonParseError::NoError) {
100 contentValue = contentDoc.object();
101 } else {
102 // Try to extract JSON from the content string
103 contentValue = content;
104 }
105
106 QHash<QString, QStringList> translations;
107 if (plural) {
108 translations = extractPluralTranslations(contentValue, "Plurals"_L1);
109 } else {
110 auto singleTranslations = extractKeyValuePairs(contentValue, "Translations"_L1);
111 for (auto it = singleTranslations.cbegin(); it != singleTranslations.cend(); ++it)
112 translations[it.key()] << it.value();
113 }
114
115 if (translations.isEmpty()) {
116 decrementFormatCounter();
117 return translations;
118 }
119
120 // Lock in the current format stage once we get a successful response.
121 // This prevents unnecessary fallback attempts due to occasional empty responses.
122 m_formatLocked = true;
123
124 return translations;
125}
126
127QStringList OpenAICompatible::extractModels(const QByteArray &response) const
128{
129 QJsonParseError err;
130 QJsonDocument doc = QJsonDocument::fromJson(response, &err);
131 if (err.error != QJsonParseError::NoError)
132 return {};
133
134 // OpenAI format: { "data": [{ "id": "model-name", ... }] }
135 const QJsonObject obj = doc.object();
136 const QJsonArray arr = obj.value("data"_L1).toArray();
137 QStringList models;
138 for (const QJsonValue &v : arr)
139 models.append(v.toObject().value("id"_L1).toString());
140 return models;
141}
142
144{
145 QJsonObject systemMessage;
146 systemMessage.insert("role"_L1, "system"_L1);
147 const bool plural = b.pluralFormsCount > 1;
148 systemMessage.insert("content"_L1,
149 plural ? pluralTranslationSystemPrompt() : translationSystemPrompt());
150
151 QJsonObject userMessage;
152 userMessage.insert("role"_L1, "user"_L1);
153 userMessage.insert("content"_L1, makePrompt(b));
154
155 QJsonArray messages;
156 messages.append(systemMessage);
157 messages.append(userMessage);
158
159 QJsonObject req = *m_payloadBase;
160 req.insert("messages"_L1, messages);
161
162 switch (m_formatStage) {
163 case JsonFormatStage::JsonObject: {
164 // llama.cpp style: {"type": "json_object"}
165 QJsonObject responseFormat;
166 responseFormat.insert("type"_L1, "json_object"_L1);
167 req.insert("response_format"_L1, responseFormat);
168 break;
169 }
170 case JsonFormatStage::JsonSchema: {
171 // LM Studio style: {"type": "json_schema", "json_schema": {...}}
172 QJsonObject schema;
173 schema.insert("type"_L1, "object"_L1);
174 QJsonObject properties;
175 QJsonObject translationsArray;
176 translationsArray.insert("type"_L1, "array"_L1);
177 properties.insert("Translations"_L1, translationsArray);
178 schema.insert("properties"_L1, properties);
179 QJsonArray required;
180 required.append("Translations"_L1);
181 schema.insert("required"_L1, required);
182
183 QJsonObject jsonSchema;
184 jsonSchema.insert("name"_L1, "translations"_L1);
185 jsonSchema.insert("schema"_L1, schema);
186
187 QJsonObject responseFormat;
188 responseFormat.insert("type"_L1, "json_schema"_L1);
189 responseFormat.insert("json_schema"_L1, jsonSchema);
190 req.insert("response_format"_L1, responseFormat);
191 break;
192 }
193 case JsonFormatStage::None:
194 // No response_format - rely on prompt instructions
195 break;
196 }
197
198 return QJsonDocument(req).toJson();
199}
200
202{
203 if (auto m = m_payloadBase->constFind("model"_L1);
204 m == m_payloadBase->constEnd() || *m != modelName) {
205 // Reset format fallback state for new model
206 m_formatStage = JsonFormatStage::JsonObject;
207 m_formatTryCounter = TranslationSettings::maxJsonFormatTries();
208 m_formatLocked = false;
209 m_payloadBase->insert("model"_L1, modelName);
210 }
211
212 // OpenAI-compatible servers typically don't need wake-up requests
213 // as they keep models loaded or handle loading transparently
214 return std::nullopt;
215}
216
217void OpenAICompatible::setUrl(const QString &url)
218{
219 m_url = url;
220}
221
223{
224 QString base = m_url;
225 if (!base.endsWith(u'/'))
226 base += u'/';
227 return QUrl(base + "v1/chat/completions"_L1);
228}
229
231{
232 QString base = m_url;
233 if (!base.endsWith(u'/'))
234 base += u'/';
235 return QUrl(base + "v1/models"_L1);
236}
237
239{
240 decrementFormatCounter();
241}
242
243void OpenAICompatible::decrementFormatCounter()
244{
245 if (m_formatLocked)
246 return;
247
248 if (--m_formatTryCounter <= 0) {
249 // Move to next format stage
251 switch (m_formatStage) {
252 case JsonFormatStage::JsonObject:
253 m_formatStage = JsonFormatStage::JsonSchema;
254 m_formatTryCounter = maxTries;
255 break;
256 case JsonFormatStage::JsonSchema:
257 m_formatStage = JsonFormatStage::None;
258 m_formatTryCounter = maxTries;
259 break;
260 case JsonFormatStage::None:
261 // Already at the last stage, nothing more to try
262 break;
263 }
264 }
265}
266
267QString OpenAICompatible::makePrompt(const Batch &b) const
268{
269 QStringList lines;
270 lines.reserve(b.items.size() + 32);
271
272 if (!b.userContext.isEmpty())
273 lines << "Application Context: "_L1 + b.userContext;
274
275 lines << "Context: "_L1 + b.context;
276 lines << "Target: "_L1 + b.tgtLang;
277 if (b.pluralFormsCount > 1)
278 lines << "Plural forms: "_L1 + QString::number(b.pluralFormsCount);
279 lines << "Items:"_L1;
280 for (const Item &it : b.items) {
281 QString line = "- source: '%1'"_L1.arg(it.msg->sourceText());
282 if (const QString comment = it.msg->comment(); !comment.isEmpty())
283 line += ", comment: '%1'"_L1.arg(comment);
284 lines << line;
285 }
286
287 return lines.join(QLatin1Char('\n'));
288}
289
290QT_END_NAMESPACE
[0]
Definition lalr.h:84
const TranslatorMessage * msg
std::optional< QByteArray > stageModel(const QString &modelName) override
QList< Batch > makeBatches(const Messages &messages, const QString &userContext) const override
~OpenAICompatible() override
QStringList extractModels(const QByteArray &data) const override
QHash< QString, QStringList > extractTranslations(const QByteArray &response, bool plural) override
void onRequestRejected() override
QUrl discoveryEndpoint() const override
QByteArray payload(const Batch &b) const override
void setUrl(const QString &url) override
QUrl translationEndpoint() const override