feat: нативное поле рассуждений (reasoning_field) — правка 400 от Console Go
Build LLM Proxy / Build and push (release) Successful in 39s
Build LLM Proxy / Build and push (release) Successful in 39s
Диагноз: апстрим deepseek-v4.1-flash (провайдер opencode, Console Go) в thinking-режиме требует reasoning_content в assistant-сообщениях с tool_calls, а клиент opencode присылает рассуждения как reasoning + reasoning_details. Отсюда 400 'The reasoning_content in the thinking mode must be passed back to the API'. - providers[].reasoning_field / reasoning_empty_ok: прокси аддитивно достраивает нативное поле в assistant-сообщениях с непустым tool_calls (текст из reasoning или reasoning_details[].text, тип reasoning.text); существующее непустое поле не перезаписывается, ничего не переименовывается, прочие сообщения не трогаются; - устойчивость разбора ответов к JSON-null (choices/delta/content/tool_calls) — было 290 фейловеров с локального апстрима на платные из-за нашего же исключения; - лог: класс исключения в сообщении об ошибке; для 4xx логируется тело ответа апстрима (читается безопасно: 4xx — не стрим); - тесты: ReasoningFieldTest (13), NullToleranceTest (6), ConfigLogicTest (+2) — 100 всего; - CONFIG.md, TESTING.md (фактические замеры A/B против Console Go).
This commit is contained in:
@@ -176,7 +176,8 @@ private suspend fun handleChat(
|
||||
continue
|
||||
}
|
||||
|
||||
val patched = buildBody(bodyJson, provider, up, modelConf)
|
||||
val patched0 = buildBody(bodyJson, provider, up, modelConf)
|
||||
val patched = applyReasoningField(patched0, provider.reasoning_field, provider.reasoning_empty_ok)
|
||||
val forwarded = if (clientWantsStream) {
|
||||
patched
|
||||
} else {
|
||||
@@ -234,6 +235,15 @@ private suspend fun handleChat(
|
||||
failover = true
|
||||
return@execute
|
||||
}
|
||||
if (upstreamStatus in 400..499) {
|
||||
// 4xx — JSON-тело, не стрим: читаем безопасно и отдаём клиенту как есть.
|
||||
val errorBody = runCatching { resp.body<String>() }.getOrDefault("")
|
||||
log.warn { "[llm-proxy] model=$modelName upstream=${up.id} вернул $upstreamStatus errorBody=${errorBody.take(500)}" }
|
||||
val ct = resp.headers["Content-Type"] ?: "application/json"
|
||||
call.respondText(errorBody, ContentType.parse(ct), HttpStatusCode.fromValue(upstreamStatus))
|
||||
responded = true
|
||||
return@execute
|
||||
}
|
||||
responded = true
|
||||
if (clientWantsStream) {
|
||||
val ct = resp.headers["Content-Type"] ?: "text/event-stream"
|
||||
@@ -284,7 +294,7 @@ private suspend fun handleChat(
|
||||
log.info { "[llm-proxy] chat model=$modelName upstream=${up.id} ОТМЕНЕНО клиентом за ${start.elapsedNow().inWholeMilliseconds}ms" }
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log.error { "[llm-proxy] chat model=$modelName upstream=${up.id} ОШИБКА: ${e.message} за ${start.elapsedNow().inWholeMilliseconds}ms → фейловер" }
|
||||
log.error { "[llm-proxy] chat model=$modelName upstream=${up.id} ОШИБКА: ${e::class.simpleName}: ${e.message} за ${start.elapsedNow().inWholeMilliseconds}ms → фейловер" }
|
||||
failed.add(up.id)
|
||||
continue
|
||||
} finally {
|
||||
@@ -538,27 +548,27 @@ internal fun rebuildFromChunks(sse: String): String {
|
||||
val data = line.removePrefix("data:").trim()
|
||||
if (data.isEmpty() || data == "[DONE]") return@forEach
|
||||
val obj = runCatching { json.parseToJsonElement(data).jsonObject }.getOrNull() ?: return@forEach
|
||||
if (id.isEmpty()) id = obj["id"]?.jsonPrimitive?.content ?: ""
|
||||
if (created == null) created = obj["created"]?.jsonPrimitive?.content?.toLongOrNull()
|
||||
if (model.isEmpty()) model = obj["model"]?.jsonPrimitive?.content ?: ""
|
||||
if (systemFingerprint == null) systemFingerprint = obj["system_fingerprint"]?.jsonPrimitive?.content
|
||||
if (serviceTier == null) serviceTier = obj["service_tier"]?.jsonPrimitive?.content
|
||||
if (id.isEmpty()) id = (obj["id"] as? JsonPrimitive)?.content ?: ""
|
||||
if (created == null) created = (obj["created"] as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
if (model.isEmpty()) model = (obj["model"] as? JsonPrimitive)?.content ?: ""
|
||||
if (systemFingerprint == null) systemFingerprint = (obj["system_fingerprint"] as? JsonPrimitive)?.content
|
||||
if (serviceTier == null) serviceTier = (obj["service_tier"] as? JsonPrimitive)?.content
|
||||
if (provider == null) provider = obj["provider"]
|
||||
(obj["error"] as? JsonObject)?.let { error = it }
|
||||
(obj["usage"] as? JsonObject)?.let { usage = it }
|
||||
val chArr = obj["choices"]?.jsonArray ?: return@forEach
|
||||
val chArr = obj["choices"] as? JsonArray ?: return@forEach
|
||||
for (ch in chArr) {
|
||||
val c = ch.jsonObject
|
||||
val idx = c["index"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0
|
||||
val c = ch as? JsonObject ?: continue
|
||||
val idx = (c["index"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0
|
||||
val mc = choices.getOrPut(idx) { MutableChoice() }
|
||||
val delta = c["delta"]?.jsonObject
|
||||
val delta = c["delta"] as? JsonObject
|
||||
if (delta != null) {
|
||||
if (mc.role == null) mc.role = delta["role"]?.jsonPrimitive?.content
|
||||
delta["content"]?.jsonPrimitive?.content?.takeIf { it != "null" }?.let { mc.content.append(it) }
|
||||
delta["reasoning_content"]?.jsonPrimitive?.content?.takeIf { it != "null" }?.let { mc.reasoning.append(it) }
|
||||
delta["tool_calls"]?.jsonArray?.forEach { tc -> (tc as? JsonObject)?.let { mc.toolCalls.add(it) } }
|
||||
if (mc.role == null) mc.role = (delta["role"] as? JsonPrimitive)?.content
|
||||
(delta["content"] as? JsonPrimitive)?.content?.takeIf { it != "null" }?.let { mc.content.append(it) }
|
||||
(delta["reasoning_content"] as? JsonPrimitive)?.content?.takeIf { it != "null" }?.let { mc.reasoning.append(it) }
|
||||
(delta["tool_calls"] as? JsonArray)?.forEach { tc -> (tc as? JsonObject)?.let { mc.toolCalls.add(it) } }
|
||||
}
|
||||
c["finish_reason"]?.jsonPrimitive?.content?.takeIf { it.isNotEmpty() && it != "null" }?.let { mc.finishReason = it }
|
||||
(c["finish_reason"] as? JsonPrimitive)?.content?.takeIf { it.isNotEmpty() && it != "null" }?.let { mc.finishReason = it }
|
||||
c["logprobs"]?.let { mc.logprobs = it }
|
||||
}
|
||||
}
|
||||
@@ -610,12 +620,12 @@ internal fun rebuildFromChunks(sse: String): String {
|
||||
* изменился → null (отдать исходную строку как есть).
|
||||
*/
|
||||
internal fun transformThinkMessage(obj: JsonObject, thinkMode: String): String? {
|
||||
val choices = obj["choices"]?.jsonArray ?: return null
|
||||
val choices = obj["choices"] as? JsonArray ?: return null
|
||||
val addReasoning = thinkMode == "split"
|
||||
var changed = false
|
||||
val newChoices = choices.map { choiceEl ->
|
||||
val choice = choiceEl.jsonObject
|
||||
val message = choice["message"]?.jsonObject ?: return@map choiceEl
|
||||
val choice = choiceEl as? JsonObject ?: return@map choiceEl
|
||||
val message = choice["message"] as? JsonObject ?: return@map choiceEl
|
||||
val contentStr = (message["content"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
if (contentStr == null) return@map choiceEl
|
||||
val splitter = ThinkTagSplitter(thinkMode)
|
||||
@@ -636,6 +646,55 @@ internal fun transformThinkMessage(obj: JsonObject, thinkMode: String): String?
|
||||
return JsonObject(obj.toMutableMap().apply { this["choices"] = JsonArray(newChoices) }).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Достроить нативное поле рассуждений для апстримов, которые его требуют
|
||||
* (Console Go / deepseek в thinking-режиме): если у провайдера объявлено
|
||||
* `reasoningField`, то в каждом assistant-сообщении с непустым `tool_calls`
|
||||
* добавляем это поле, ЕСЛИ его там ещё нет. Текст берём из `reasoning`
|
||||
* (строка) или из `reasoning_details` (элементы с `type == "reasoning.text"`).
|
||||
* Существующее непустое поле НЕ перезаписываем. При полном отсутствии текста
|
||||
* пишем пустую строку, только если `emptyOk`.
|
||||
* Тело возвращается без изменений (тот же объект), если менять нечего.
|
||||
*/
|
||||
internal fun applyReasoningField(body: JsonObject, field: String?, emptyOk: Boolean): JsonObject {
|
||||
if (field == null) return body
|
||||
val messages = body["messages"] as? JsonArray ?: return body
|
||||
var changed = false
|
||||
val newMessages = messages.map { el ->
|
||||
val msg = el as? JsonObject ?: return@map el
|
||||
val role = (msg["role"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
if (role != "assistant") return@map el
|
||||
val toolCalls = msg["tool_calls"] as? JsonArray
|
||||
if (toolCalls == null || toolCalls.isEmpty()) return@map el
|
||||
val existing = (msg[field] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
if (existing != null && existing.isNotEmpty()) return@map el
|
||||
val text = reasoningTextOf(msg)
|
||||
if (text.isEmpty() && !emptyOk) return@map el
|
||||
changed = true
|
||||
JsonObject(msg.toMutableMap().apply { this[field] = JsonPrimitive(text) })
|
||||
}
|
||||
if (!changed) return body
|
||||
return JsonObject(body.toMutableMap().apply { this["messages"] = JsonArray(newMessages) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Текст рассуждений сообщения: `reasoning` (если непустая строка), иначе
|
||||
* склейка `reasoning_details[*].text` через "\n" — только элементы, у которых
|
||||
* `type` отсутствует или равен "reasoning.text".
|
||||
*/
|
||||
internal fun reasoningTextOf(msg: JsonObject): String {
|
||||
val reasoning = (msg["reasoning"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
if (reasoning != null && reasoning.isNotEmpty()) return reasoning
|
||||
val details = msg["reasoning_details"] as? JsonArray ?: return ""
|
||||
val parts = details.mapNotNull { el ->
|
||||
val d = el as? JsonObject ?: return@mapNotNull null
|
||||
val type = (d["type"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
if (type != null && type != "reasoning.text") return@mapNotNull null
|
||||
(d["text"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
}
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
||||
/** Терминальный маркер SSE, который клиенты (в т.ч. Bifrost) ждут как конец потока. */
|
||||
private const val SSE_DONE_MARKER = "data: [DONE]\n\n"
|
||||
|
||||
@@ -665,9 +724,11 @@ internal fun hasNonNullFinishReasonText(text: String): Boolean {
|
||||
|
||||
/** Структурная проверка чанка: в `choices[*].finish_reason` есть непустая строка. */
|
||||
internal fun hasFinishReason(obj: JsonObject): Boolean =
|
||||
obj["choices"]?.jsonArray?.any { el ->
|
||||
val fr = (el.jsonObject["finish_reason"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
!fr.isNullOrEmpty()
|
||||
(obj["choices"] as? JsonArray)?.any { el ->
|
||||
(el as? JsonObject)?.let { choice ->
|
||||
val fr = (choice["finish_reason"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
!fr.isNullOrEmpty()
|
||||
} ?: false
|
||||
} ?: false
|
||||
|
||||
/**
|
||||
@@ -805,14 +866,14 @@ internal fun transformThinkChunk(
|
||||
thinkMode: String,
|
||||
addReasoning: Boolean,
|
||||
): String? {
|
||||
val choices = obj["choices"]?.jsonArray ?: return null
|
||||
val choices = obj["choices"] as? JsonArray ?: return null
|
||||
var changed = false
|
||||
val newChoices = choices.map { choiceEl ->
|
||||
val choice = choiceEl.jsonObject
|
||||
val delta = choice["delta"]?.jsonObject
|
||||
val choice = choiceEl as? JsonObject ?: return@map choiceEl
|
||||
val delta = choice["delta"] as? JsonObject
|
||||
val contentStr = (delta?.get("content") as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
if (contentStr == null) return@map choiceEl
|
||||
val idx = choice["index"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0
|
||||
val idx = (choice["index"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0
|
||||
val splitter = splitters.getOrPut(idx) { ThinkTagSplitter(thinkMode) }
|
||||
val (newContent, reasoning) = splitter.feed(contentStr)
|
||||
changed = true
|
||||
@@ -840,6 +901,8 @@ data class ProviderConf(
|
||||
val patch: JsonObject? = null,
|
||||
val session_header: String? = null,
|
||||
val think_tags: String? = null,
|
||||
val reasoning_field: String? = null,
|
||||
val reasoning_empty_ok: Boolean = false,
|
||||
)
|
||||
|
||||
data class UpstreamConf(
|
||||
@@ -904,6 +967,8 @@ internal fun parseConfig(root: YamlElement): Config {
|
||||
patch = m.yamlMapOrNull("patch")?.let { yamlToJson(it) as JsonObject },
|
||||
session_header = m.strOrNull("session_header"),
|
||||
think_tags = m.strOrNull("think_tags"),
|
||||
reasoning_field = m.strOrNull("reasoning_field"),
|
||||
reasoning_empty_ok = m.strOrNull("reasoning_empty_ok")?.toBooleanStrictOrNull() ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user