收敛稳定性风险

This commit is contained in:
2026-05-31 16:27:49 +08:00
parent 49fd9b3130
commit 4388a168f1
31 changed files with 1445 additions and 384 deletions
+33 -169
View File
@@ -1,5 +1,6 @@
#include "OpenAICompatibleProvider.h"
#include "AIDiagnostics.h"
#include "../util/Logger.h"
#include <QJsonArray>
@@ -8,147 +9,10 @@
#include <QJsonParseError>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStringList>
#include <QUrl>
#include <QUrlQuery>
#include <utility>
namespace
{
constexpr int MaxDiagnosticBodyLength = 1000;
QString trimmedResponseBody(const QByteArray &body)
{
const QString text = QString::fromUtf8(body).trimmed();
if (text.size() <= MaxDiagnosticBodyLength)
{
return text;
}
return text.left(MaxDiagnosticBodyLength) + QStringLiteral("...");
}
QString oneLine(QString text)
{
text.replace(QLatin1Char('\r'), QLatin1Char(' '));
text.replace(QLatin1Char('\n'), QLatin1Char(' '));
text.replace(QLatin1Char('\t'), QLatin1Char(' '));
return text.simplified();
}
bool isSensitiveQueryName(const QString &name)
{
const QString lowerName = name.toLower();
return lowerName.contains(QStringLiteral("key"))
|| lowerName.contains(QStringLiteral("token"))
|| lowerName.contains(QStringLiteral("secret"))
|| lowerName.contains(QStringLiteral("password"))
|| lowerName.contains(QStringLiteral("authorization"))
|| lowerName.contains(QStringLiteral("signature"));
}
QString sanitizedUrlString(QUrl url)
{
url.setUserInfo(QString());
QUrlQuery query(url);
if (!query.isEmpty())
{
QUrlQuery sanitizedQuery;
const auto items = query.queryItems(QUrl::FullyDecoded);
for (const auto &item : items)
{
sanitizedQuery.addQueryItem(
item.first,
isSensitiveQueryName(item.first) ? QStringLiteral("<redacted>") : item.second);
}
url.setQuery(sanitizedQuery);
}
return url.toString(QUrl::FullyEncoded);
}
QString diagnosticContext(const AIConfig &config, const QUrl &url)
{
return QStringLiteral("provider=%1 protocol=%2 model=%3 url=%4 timeoutMs=%5 maxTokens=%6 temperature=%7")
.arg(config.provider.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.provider.trimmed())
.arg(config.protocol.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.protocol.trimmed())
.arg(config.model.trimmed().isEmpty() ? QStringLiteral("<empty>") : config.model.trimmed())
.arg(sanitizedUrlString(url))
.arg(config.timeoutMs)
.arg(config.maxTokens)
.arg(QString::number(config.temperature, 'f', 2));
}
QString errorMessageFromBody(const QByteArray &body)
{
if (body.trimmed().isEmpty())
{
return {};
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
return trimmedResponseBody(body);
}
const QJsonObject root = document.object();
QStringList details;
const QJsonValue errorValue = root.value(QStringLiteral("error"));
if (errorValue.isObject())
{
const QJsonObject error = errorValue.toObject();
const QString message = error.value(QStringLiteral("message")).toString().trimmed();
const QString code = error.value(QStringLiteral("code")).toString().trimmed();
const QString type = error.value(QStringLiteral("type")).toString().trimmed();
if (!message.isEmpty())
{
details.append(message);
}
if (!code.isEmpty())
{
details.append(QStringLiteral("code=") + code);
}
if (!type.isEmpty() && type != code)
{
details.append(QStringLiteral("type=") + type);
}
}
else if (errorValue.isString())
{
const QString error = errorValue.toString().trimmed();
if (!error.isEmpty())
{
details.append(error);
}
}
const QString message = root.value(QStringLiteral("message")).toString().trimmed();
if (!message.isEmpty() && !details.contains(message))
{
details.append(message);
}
const QString requestId = root.value(QStringLiteral("request_id")).toString().trimmed();
if (!requestId.isEmpty())
{
details.append(QStringLiteral("request_id=") + requestId);
}
if (!details.isEmpty())
{
return details.join(QStringLiteral("; "));
}
return trimmedResponseBody(body);
}
}
OpenAICompatibleProvider::OpenAICompatibleProvider(const AIConfig &config)
: m_config(config)
{
@@ -239,7 +103,7 @@ void OpenAICompatibleProvider::sendChatRequestInternal(
const QJsonDocument document(buildPayload(request, stream));
const QByteArray payload = document.toJson(QJsonDocument::Compact);
Logger::info(QStringLiteral("AI request started: %1 stream=%2 messageCount=%3 payloadBytes=%4")
.arg(diagnosticContext(m_config, url))
.arg(AIDiagnostics::diagnosticContext(m_config, url))
.arg(stream ? QStringLiteral("true") : QStringLiteral("false"))
.arg(QString::number(request.messages.size()))
.arg(QString::number(payload.size())));
@@ -311,7 +175,7 @@ void OpenAICompatibleProvider::cancel()
if (!reply.isNull())
{
Logger::info(QStringLiteral("AI request canceled: %1")
.arg(diagnosticContext(m_config, reply->request().url())));
.arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())));
}
clearReply();
@@ -416,8 +280,8 @@ bool OpenAICompatibleProvider::handleStreamPayload(const QByteArray &payload)
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
Logger::warning(QStringLiteral("AI stream chunk JSON parse failed: %1 parseError=\"%2\" chunkBytes=%3")
.arg(diagnosticContext(m_config, requestUrl()))
.arg(oneLine(parseError.errorString()))
.arg(AIDiagnostics::diagnosticContext(m_config, requestUrl()))
.arg(AIDiagnostics::oneLine(parseError.errorString()))
.arg(QString::number(payload.size())));
return true;
}
@@ -425,7 +289,7 @@ bool OpenAICompatibleProvider::handleStreamPayload(const QByteArray &payload)
const QJsonObject root = document.object();
if (root.contains(QStringLiteral("error")))
{
const QString bodyError = errorMessageFromBody(payload);
const QString bodyError = AIDiagnostics::errorMessageFromBody(payload);
finishWithError(bodyError.isEmpty() ? QStringLiteral("AI stream returned an error.") : bodyError);
return false;
}
@@ -458,13 +322,13 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError)
{
const QString bodyError = errorMessageFromBody(body);
Logger::warning(QStringLiteral("AI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 body=\"%5\"")
.arg(diagnosticContext(m_config, reply->request().url()))
const QString bodyError = AIDiagnostics::errorMessageFromBody(body);
Logger::warning(QStringLiteral("AI request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\"")
.arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(static_cast<int>(reply->error()))
.arg(oneLine(reply->errorString()))
.arg(AIDiagnostics::oneLine(reply->errorString()))
.arg(httpStatus)
.arg(oneLine(trimmedResponseBody(body))));
.arg(AIDiagnostics::responseBodySummary(body)));
if (!bodyError.isEmpty())
{
@@ -478,11 +342,11 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
Logger::warning(QStringLiteral("AI response JSON parse failed: %1 httpStatus=%2 parseError=\"%3\" body=\"%4\"")
.arg(diagnosticContext(m_config, reply->request().url()))
Logger::warning(QStringLiteral("AI response JSON parse failed: %1 httpStatus=%2 parseError=\"%3\" bodySummary=\"%4\"")
.arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus)
.arg(oneLine(parseError.errorString()))
.arg(oneLine(trimmedResponseBody(body))));
.arg(AIDiagnostics::oneLine(parseError.errorString()))
.arg(AIDiagnostics::responseBodySummary(body)));
return {false, {}, QStringLiteral("AI response is not valid JSON."), httpStatus};
}
@@ -490,10 +354,10 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
const QJsonArray choices = root.value(QStringLiteral("choices")).toArray();
if (choices.isEmpty() || !choices.first().isObject())
{
Logger::warning(QStringLiteral("AI response has no choices: %1 httpStatus=%2 body=\"%3\"")
.arg(diagnosticContext(m_config, reply->request().url()))
Logger::warning(QStringLiteral("AI response has no choices: %1 httpStatus=%2 bodySummary=\"%3\"")
.arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus)
.arg(oneLine(trimmedResponseBody(body))));
.arg(AIDiagnostics::responseBodySummary(body)));
return {false, {}, QStringLiteral("AI response has no choices."), httpStatus};
}
@@ -501,15 +365,15 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const
const QString content = message.value(QStringLiteral("content")).toString();
if (content.isEmpty())
{
Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 body=\"%3\"")
.arg(diagnosticContext(m_config, reply->request().url()))
Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 bodySummary=\"%3\"")
.arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus)
.arg(oneLine(trimmedResponseBody(body))));
.arg(AIDiagnostics::responseBodySummary(body)));
return {false, {}, QStringLiteral("AI response content is empty."), httpStatus};
}
Logger::info(QStringLiteral("AI request completed: %1 httpStatus=%2 responseChars=%3 responseBytes=%4")
.arg(diagnosticContext(m_config, reply->request().url()))
.arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus)
.arg(QString::number(content.size()))
.arg(QString::number(body.size())));
@@ -525,13 +389,13 @@ ChatResponse OpenAICompatibleProvider::finishStreamingResponse(QNetworkReply *re
: QByteArray();
if (reply->error() != QNetworkReply::NoError)
{
const QString bodyError = errorMessageFromBody(diagnosticBody);
Logger::warning(QStringLiteral("AI streaming request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 body=\"%5\" streamedChars=%6")
.arg(diagnosticContext(m_config, reply->request().url()))
const QString bodyError = AIDiagnostics::errorMessageFromBody(diagnosticBody);
Logger::warning(QStringLiteral("AI streaming request network error: %1 qtError=%2 errorString=\"%3\" httpStatus=%4 bodySummary=\"%5\" streamedChars=%6")
.arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(static_cast<int>(reply->error()))
.arg(oneLine(reply->errorString()))
.arg(AIDiagnostics::oneLine(reply->errorString()))
.arg(httpStatus)
.arg(oneLine(trimmedResponseBody(diagnosticBody)))
.arg(AIDiagnostics::responseBodySummary(diagnosticBody))
.arg(QString::number(m_streamedContent.size())));
if (!bodyError.isEmpty())
@@ -544,17 +408,17 @@ ChatResponse OpenAICompatibleProvider::finishStreamingResponse(QNetworkReply *re
if (m_streamedContent.isEmpty())
{
Logger::warning(QStringLiteral("AI streaming response content is empty: %1 httpStatus=%2 streamDone=%3 residualBytes=%4 body=\"%5\"")
.arg(diagnosticContext(m_config, reply->request().url()))
Logger::warning(QStringLiteral("AI streaming response content is empty: %1 httpStatus=%2 streamDone=%3 residualBytes=%4 bodySummary=\"%5\"")
.arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus)
.arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false"))
.arg(QString::number(m_streamBuffer.size()))
.arg(oneLine(trimmedResponseBody(diagnosticBody))));
.arg(AIDiagnostics::responseBodySummary(diagnosticBody)));
return {false, {}, QStringLiteral("AI streaming response content is empty."), httpStatus};
}
Logger::info(QStringLiteral("AI streaming request completed: %1 httpStatus=%2 streamDone=%3 responseChars=%4")
.arg(diagnosticContext(m_config, reply->request().url()))
.arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url()))
.arg(httpStatus)
.arg(m_streamDone ? QStringLiteral("true") : QStringLiteral("false"))
.arg(QString::number(m_streamedContent.size())));
@@ -567,9 +431,9 @@ void OpenAICompatibleProvider::finishWithError(const QString &message, int httpS
QPointer<QNetworkReply> reply = m_currentReply;
const QUrl url = reply.isNull() ? requestUrl() : reply->request().url();
Logger::warning(QStringLiteral("AI request finished with error: %1 httpStatus=%2 error=\"%3\"")
.arg(diagnosticContext(m_config, url))
.arg(AIDiagnostics::diagnosticContext(m_config, url))
.arg(httpStatus)
.arg(oneLine(message)));
.arg(AIDiagnostics::safeTextSummary(message)));
clearReply();