Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8154485b3b |
@@ -218,6 +218,44 @@ upstreams:
|
||||
рассуждения». Если `content` не строка (мультимодальный массив частей) — ответ
|
||||
не трогаем. Без флажка (`off`) ответ идёт байт-в-байт как раньше.
|
||||
|
||||
### Нативное поле рассуждений (`reasoning_field`)
|
||||
|
||||
Некоторые шлюзы-апстримы (например, **Console Go / deepseek в thinking-режиме**)
|
||||
в thinking-режиме **требуют** вернуть им нативное поле рассуждений в каждом
|
||||
assistant-сообщении с непустым `tool_calls`. Если его нет — апстрим отвечает
|
||||
`400` (`The `reasoning_content` in the thinking mode must be passed back to the
|
||||
API.`). Клиенты при этом рассуждения держат в своих форматах: `reasoning`
|
||||
(строка) и/или `reasoning_details` (массив `{type:"reasoning.text", text, ...}`)
|
||||
— а нативное поле `reasoning_content` в истории могут и не передавать.
|
||||
|
||||
Поля (только на уровне **провайдера**, это свойство шлюза, а не модели):
|
||||
|
||||
| Поле | Тип / дефолт | Значение |
|
||||
|---|---|---|
|
||||
| `providers[].reasoning_field` | строка / отсутствует | Имя нативного поля рассуждений у шлюза-апстрима (пример: `reasoning_content`) |
|
||||
| `providers[].reasoning_empty_ok` | булево / `false` | Писать ли пустую строку, если текста рассуждений нет вовсе |
|
||||
|
||||
Зачем: при отправке запроса прокси **аддитивно** достраивает это поле в каждом
|
||||
assistant-сообщении с непустым `tool_calls` — берёт текст из `reasoning`
|
||||
(если это непустая строка), иначе склеивает `reasoning_details[*].text`
|
||||
(только элементы без `type` или с `type == "reasoning.text"`, через `"\n"`) и
|
||||
записывает в поле `reasoning_field`, **если его там ещё нет**. Существующее
|
||||
непустое поле не перезаписывается. Ничего при этом не убирается и не
|
||||
переименовывается — клиентский `reasoning`/`reasoning_details` остаются на месте,
|
||||
поле просто дополняется. Тронуто только assistant-сообщение с непустым
|
||||
`tool_calls`; сообщения без `tool_calls` и `user`/`tool`/`system` не меняются.
|
||||
Если текста рассуждений нет вовсе — поле не добавляется, кроме случая
|
||||
`reasoning_empty_ok: true` (тогда пишется пустая строка `""`). Если
|
||||
`reasoning_field` не задан — тело не меняется вовсе.
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
- id: opencode
|
||||
url: "https://opencode.ai/zen/go/v1"
|
||||
reasoning_field: reasoning_content # Console Go / deepseek в thinking-режиме
|
||||
# reasoning_empty_ok: true # опционально; дефолт false
|
||||
```
|
||||
|
||||
### Пример сборки тела (многослойный `patch`)
|
||||
|
||||
Берём модель `my-gpt` (из примера выше), маршрут уходит на апстрим
|
||||
@@ -296,9 +334,12 @@ data class ProviderConf(
|
||||
val id: String,
|
||||
val url: String,
|
||||
val key: String = "",
|
||||
val max_concurrency: Int? = null, // лимит по умолчанию для апстримов провайдера
|
||||
val patch: JsonObject? = null, // ко всем запросам провайдера
|
||||
val session_header: String? = null, // заголовок-сессия, считается из истории
|
||||
val max_concurrency: Int? = null,
|
||||
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,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
+40
-1
@@ -13,7 +13,9 @@
|
||||
| Файл | Что проверяет |
|
||||
| --- | --- |
|
||||
| `ThinkTagSplitterTest` | Автомат рассечения think-тегов: passthrough при off, вырезание рассуждений при split, отбрасывание при strip, удержание разрезанного тега, несколько блоков, незакрытый блок; |
|
||||
| `ConfigLogicTest` | Разбор конфига, приоритет источников (апстрим важнее провайдера), слияние патчей, выбор апстрима и лимиты конкурентности, заголовки, сессии; |
|
||||
| `ConfigLogicTest` | Разбор конфига (`reasoning_field`/`reasoning_empty_ok` провайдера и их дефолты), приоритет источников (апстрим важнее провайдера), слияние патчей, выбор апстрима и лимиты конкурентности, заголовки, сессии; |
|
||||
| `ReasoningFieldTest` | Достройка нативного поля рассуждений (`applyReasoningField`/`reasoningTextOf`): форма клиента opencode (`reasoning` + `reasoning_details`), чужой `type` в details, склейка нескольких details, запрет перезаписи непустого поля, неприкосновенность сообщений без `tool_calls` (в т.ч. `[]`) и `user`/`tool`, пустая строка при `emptyOk`, `reasoning_field: null`, отсутствие `messages`, сохранение порядка и прочих полей; |
|
||||
| `NullToleranceTest` | Устойчивость разбора ответов апстрима к JSON-`null` (`choices`, `delta.tool_calls`, `delta.content`) — пропуск вместо исключения; |
|
||||
| `ThinkTagTransformTest` | Non-stream путь `transformThinkMessage`: перенос рассуждений в `reasoning_content`, дописывание к уже имеющемуся, strip, незакрытый блок, отсутствие изменений → null; |
|
||||
| `ThinkTagChunkTest` | SSE-чанки `transformThinkChunk`: удержание хвоста тега между чанками, независимые сплиттеры по index, удаление пустого `content`; |
|
||||
| `ThinkTagStreamTest` | Обвязка стрима `streamSseWithThinkTags`: разрез тега между data-событиями, сброс удержанного хвоста в финиш-чанке, прохождение служебных строк и `[DONE]`, битый JSON, чанк без choices, strip. |
|
||||
@@ -44,6 +46,43 @@
|
||||
|
||||
Правило: боевой код нельзя подгонять под тест; если тест не проходит, неверен тест.
|
||||
|
||||
## Поле рассуждений для Console Go (релиз 11) — замеры приёмки
|
||||
|
||||
Причина правки: апстрим `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`.
|
||||
|
||||
**Границы требования (замер прямыми запросами к Console Go, одинаковое тело):**
|
||||
|
||||
| assistant-сообщение | HTTP |
|
||||
| --- | --- |
|
||||
| с `tool_calls`, без reasoning вовсе | 400 |
|
||||
| с `tool_calls`, `reasoning` + `reasoning_details` (форма opencode) | 400 |
|
||||
| с `tool_calls`, только `reasoning_details` | 400 |
|
||||
| с `tool_calls`, `reasoning_content` + `reasoning` + `reasoning_details` (аддитивно) | 200 |
|
||||
| с `tool_calls`, `reasoning_content: ""` | 200 |
|
||||
| без `tool_calls`, без reasoning | 200 |
|
||||
|
||||
**Живая приёмка (локальный инстанс на 8101, конфиг-копия боевого, модель с единственным апстримом
|
||||
Console Go; тело — как у opencode: assistant + `reasoning` + `reasoning_details` + `tool_calls`):**
|
||||
|
||||
| Конфиг | `stream=false` | `stream=true` |
|
||||
| --- | --- | --- |
|
||||
| без правки (`reasoning_field` не задан) | **400** — та самая ошибка про `reasoning_content` | **400** |
|
||||
| с правкой (`reasoning_field: reasoning_content`) | **200**, модель продолжила диалог после tool-результата | **200** |
|
||||
|
||||
**Регрессия на боевых цепочках (тот же инстанс, то же тело с `tool_calls`):** `codding-big` → 200
|
||||
(ушло на `minimax-m3`, поле не добавляется — флаг объявлен только у провайдера `opencode`),
|
||||
`codding` → 200 (`qwen-3.8`, local), `assistant` → 200 (Console Go с правкой).
|
||||
|
||||
**Мутационная приёмка:** 4 мутации из ТЗ отработал кодер, две проверены вручную
|
||||
(`cleanAllTests jvmTest`): снятие охраны `tool_calls` в `applyReasoningField` → падают
|
||||
`assistantWithoutToolCallsIsUntouched` и `assistantWithEmptyToolCallsIsUntouched`;
|
||||
возврат `obj["choices"]?.jsonArray` в `rebuildFromChunks` → падает `rebuildFromChunksToleratesNullChoices`.
|
||||
|
||||
## Известное ограничение
|
||||
|
||||
Живой стрим в реальном апстриме модульными тестами не проверяется: обвязка испытывается на синтетическом SSE через каналы ktor. Реальный апстрим проверяется только после деплоя.
|
||||
Правка поля рассуждений — исключение: она проверена живым инстансом против настоящего Console Go (таблица выше) до релиза.
|
||||
|
||||
|
||||
@@ -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,10 +724,12 @@ 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
|
||||
(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
|
||||
|
||||
/**
|
||||
* Детектор контракта конца SSE для сырого passthrough-потока (`think_tags: off`).
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -546,4 +546,41 @@ class ConfigLogicTest {
|
||||
assertEquals("off", effectiveThinkTags(upNull, prov(null)))
|
||||
assertEquals("off", effectiveThinkTags(upNull, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseConfigReadsReasoningFieldAndEmptyOkOnProvider() {
|
||||
val yaml = """
|
||||
providers:
|
||||
- id: p1
|
||||
url: "https://x.ru/api/v1"
|
||||
reasoning_field: reasoning_content
|
||||
reasoning_empty_ok: true
|
||||
- id: p2
|
||||
url: "https://y.ru/api/v1"
|
||||
reasoning_empty_ok: false
|
||||
models:
|
||||
- name: m1
|
||||
upstreams: []
|
||||
""".trimIndent()
|
||||
val cfg = parseConfig(Yaml.decodeYamlFromString(yaml))
|
||||
assertEquals("reasoning_content", cfg.providers[0].reasoning_field)
|
||||
assertEquals(true, cfg.providers[0].reasoning_empty_ok)
|
||||
assertEquals(null, cfg.providers[1].reasoning_field)
|
||||
assertEquals(false, cfg.providers[1].reasoning_empty_ok)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseConfigDefaultsReasoningFieldsWhenAbsent() {
|
||||
val yaml = """
|
||||
providers:
|
||||
- id: p1
|
||||
url: "https://x.ru/api/v1"
|
||||
models:
|
||||
- name: m1
|
||||
upstreams: []
|
||||
""".trimIndent()
|
||||
val cfg = parseConfig(Yaml.decodeYamlFromString(yaml))
|
||||
assertEquals(null, cfg.providers[0].reasoning_field)
|
||||
assertEquals(false, cfg.providers[0].reasoning_empty_ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package pw.binom.llmproxy
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
class NullToleranceTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun rebuildFromChunksToleratesNullChoices() {
|
||||
// Чанк с choices:null не должен ронять сборку; значимые поля сохраняются.
|
||||
val sse = """
|
||||
data: {"id":"c1","created":123,"model":"m","choices":null}
|
||||
data: {"id":"c1","created":123,"model":"m","choices":[{"index":0,"delta":{"content":"Hi"}}]}
|
||||
data: [DONE]
|
||||
""".trimIndent()
|
||||
val out = json.parseToJsonElement(rebuildFromChunks(sse)).jsonObject
|
||||
assertEquals("c1", out["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("m", out["model"]?.jsonPrimitive?.content)
|
||||
assertEquals(123, out["created"]?.jsonPrimitive?.content?.toLong())
|
||||
val choice = out["choices"]?.jsonArray?.get(0)?.jsonObject
|
||||
assertEquals("Hi", choice?.get("message")?.jsonObject?.get("content")?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rebuildFromChunksToleratesNullDeltaToolCalls() {
|
||||
val sse = """
|
||||
data: {"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":null},"finish_reason":"stop"}]}
|
||||
data: [DONE]
|
||||
""".trimIndent()
|
||||
val out = json.parseToJsonElement(rebuildFromChunks(sse)).jsonObject
|
||||
val choice = out["choices"]?.jsonArray?.get(0)?.jsonObject
|
||||
assertEquals("assistant", choice?.get("message")?.jsonObject?.get("role")?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rebuildFromChunksToleratesNullContent() {
|
||||
val sse = """
|
||||
data: {"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":"stop"}]}
|
||||
data: [DONE]
|
||||
""".trimIndent()
|
||||
val out = json.parseToJsonElement(rebuildFromChunks(sse)).jsonObject
|
||||
val choice = out["choices"]?.jsonArray?.get(0)?.jsonObject
|
||||
assertEquals("assistant", choice?.get("message")?.jsonObject?.get("role")?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hasFinishReasonToleratesNullChoices() {
|
||||
val obj = json.parseToJsonElement("""{"choices":null}""").jsonObject
|
||||
assertFalse(hasFinishReason(obj))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transformThinkChunkToleratesNullChoices() {
|
||||
val obj = json.parseToJsonElement("""{"choices":null}""").jsonObject
|
||||
assertNull(transformThinkChunk(obj, mutableMapOf(), "split", true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transformThinkMessageToleratesNullChoices() {
|
||||
val obj = json.parseToJsonElement("""{"choices":null}""").jsonObject
|
||||
assertNull(transformThinkMessage(obj, "split"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package pw.binom.llmproxy
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
class ReasoningFieldTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// Минимальный непустой tool_call для assistant-сообщения.
|
||||
private val TC = """{"id":"t1","type":"function","function":{"name":"f","arguments":"{}"}}"""
|
||||
|
||||
private fun str(obj: JsonObject, key: String): String? = (obj[key] as? JsonPrimitive)?.content
|
||||
|
||||
private fun outMsgs(out: JsonObject): List<JsonObject> =
|
||||
(out["messages"] as? JsonArray)?.mapNotNull { it as? JsonObject } ?: emptyList()
|
||||
|
||||
// Собрать тело `{"messages":[...]}` и прогнать через applyReasoningField.
|
||||
private fun apply(messagesJson: String, field: String?, emptyOk: Boolean): JsonObject {
|
||||
val body = json.parseToJsonElement("""{"messages":[$messagesJson]}""").jsonObject
|
||||
return applyReasoningField(body, field, emptyOk)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun documentedOpencodeShapeGetsNativeReasoningContent() {
|
||||
// Форма клиента opencode: reasoning (строка) + reasoning_details (массив),
|
||||
// нативного поля нет — добавляем его, текст берём из рассуждений.
|
||||
val m = """{"role":"assistant","tool_calls":[$TC],"reasoning":"R","reasoning_details":[{"type":"reasoning.text","text":"R","index":0}]}"""
|
||||
val out = apply(m, "reasoning_content", false)
|
||||
val m0 = outMsgs(out)[0]
|
||||
assertEquals("R", str(m0, "reasoning_content"))
|
||||
assertEquals("R", str(m0, "reasoning"))
|
||||
assertTrue((m0["tool_calls"] as? JsonArray)?.isNotEmpty() == true)
|
||||
assertTrue((m0["reasoning_details"] as? JsonArray)?.isNotEmpty() == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reasoningDetailsTextIsCopiedIntoReasoningContent() {
|
||||
// Только reasoning_details (без строки reasoning) — текст всё равно достаётся.
|
||||
val m = """{"role":"assistant","tool_calls":[$TC],"reasoning_details":[{"type":"reasoning.text","text":"D"}]}"""
|
||||
val out = apply(m, "reasoning_content", false)
|
||||
assertEquals("D", str(outMsgs(out)[0], "reasoning_content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reasoningDetailsWithForeignTypeAreIgnored() {
|
||||
// Чужой тип (reasoning.encrypted) игнорируется, берётся только reasoning.text.
|
||||
val m = """{"role":"assistant","tool_calls":[$TC],"reasoning_details":[{"type":"reasoning.encrypted","text":"X"},{"type":"reasoning.text","text":"T"}]}"""
|
||||
val out = apply(m, "reasoning_content", false)
|
||||
assertEquals("T", str(outMsgs(out)[0], "reasoning_content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleReasoningDetailsAreJoinedInOrder() {
|
||||
// Два reasoning.text склеиваются через "\n" в порядке массива.
|
||||
val m = """{"role":"assistant","tool_calls":[$TC],"reasoning_details":[{"type":"reasoning.text","text":"a"},{"type":"reasoning.text","text":"b"}]}"""
|
||||
val out = apply(m, "reasoning_content", false)
|
||||
assertEquals("a\nb", str(outMsgs(out)[0], "reasoning_content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existingNonEmptyReasoningContentIsNotOverwritten() {
|
||||
// Непустое нативное поле не перезаписываем текстом из рассуждений.
|
||||
val m = """{"role":"assistant","tool_calls":[$TC],"reasoning":"R","reasoning_content":"нативное"}"""
|
||||
val out = apply(m, "reasoning_content", false)
|
||||
assertEquals("нативное", str(outMsgs(out)[0], "reasoning_content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assistantWithoutToolCallsIsUntouched() {
|
||||
// Без tool_calls assistant-сообщение не трогаем (тело идентично).
|
||||
val m = """{"role":"assistant","reasoning":"R","content":"hi"}"""
|
||||
val out = apply(m, "reasoning_content", false)
|
||||
assertEquals("""{"messages":[$m]}""", out.toString())
|
||||
assertFalse(outMsgs(out)[0].containsKey("reasoning_content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assistantWithEmptyToolCallsIsUntouched() {
|
||||
// Пустой массив tool_calls = не трогаем (тело идентично).
|
||||
val m = """{"role":"assistant","tool_calls":[],"reasoning":"R"}"""
|
||||
val out = apply(m, "reasoning_content", false)
|
||||
assertEquals("""{"messages":[$m]}""", out.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun userAndToolMessagesAreUntouched() {
|
||||
// user/tool/system с теми же полями — не assistant, не трогаем.
|
||||
val msgs = """
|
||||
{"role":"user","tool_calls":[$TC],"reasoning":"R"},
|
||||
{"role":"tool","tool_calls":[$TC],"reasoning":"R"},
|
||||
{"role":"system","tool_calls":[$TC],"reasoning":"R"}
|
||||
""".trimIndent().replace("\n", " ")
|
||||
val out = apply(msgs, "reasoning_content", false)
|
||||
val roles = outMsgs(out).map { str(it, "role") }
|
||||
assertEquals(listOf("user", "tool", "system"), roles)
|
||||
outMsgs(out).forEach { assertFalse(it.containsKey("reasoning_content")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyTextWithEmptyOkFillsEmptyString() {
|
||||
// Рассуждений нет вовсе, но emptyOk=true — пишем пустую строку.
|
||||
val m = """{"role":"assistant","tool_calls":[$TC]}"""
|
||||
val out = apply(m, "reasoning_content", true)
|
||||
val m0 = outMsgs(out)[0]
|
||||
assertEquals("", str(m0, "reasoning_content"))
|
||||
assertTrue(m0.containsKey("reasoning_content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyTextWithoutEmptyOkLeavesMessageUnchanged() {
|
||||
// Рассуждений нет и emptyOk=false — сообщение не трогаем.
|
||||
val m = """{"role":"assistant","tool_calls":[$TC]}"""
|
||||
val out = apply(m, "reasoning_content", false)
|
||||
assertEquals("""{"messages":[$m]}""", out.toString())
|
||||
assertFalse(outMsgs(out)[0].containsKey("reasoning_content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reasoningFieldNullLeavesBodyUntouched() {
|
||||
// Поле не объявлено у провайдера (null) — тело не трогаем.
|
||||
val m = """{"role":"assistant","tool_calls":[$TC],"reasoning":"R"}"""
|
||||
val out = apply(m, null, false)
|
||||
assertEquals("""{"messages":[$m]}""", out.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messagesAbsentOrNotArrayLeavesBodyUntouched() {
|
||||
// Нет `messages` — не трогаем.
|
||||
val noMessages = json.parseToJsonElement("""{"model":"m1"}""").jsonObject
|
||||
assertEquals(noMessages.toString(), applyReasoningField(noMessages, "reasoning_content", false).toString())
|
||||
|
||||
// `messages: null` (JsonNull) — тоже не трогаем.
|
||||
val nullMessages = json.parseToJsonElement("""{"messages":null}""").jsonObject
|
||||
val out = applyReasoningField(nullMessages, "reasoning_content", false)
|
||||
assertEquals(nullMessages.toString(), out.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageOrderAndOtherFieldsArePreserved() {
|
||||
// Меняется только 2-е (assistant с tool_calls), порядок и остальные поля на месте.
|
||||
val msgs = """
|
||||
{"role":"user","content":"hi"},
|
||||
{"role":"assistant","tool_calls":[$TC],"reasoning":"R","content":""},
|
||||
{"role":"tool","tool_call_id":"t1","content":"ok"},
|
||||
{"role":"user","content":"again"}
|
||||
""".trimIndent().replace("\n", " ")
|
||||
val out = apply(msgs, "reasoning_content", false)
|
||||
val list = outMsgs(out)
|
||||
assertEquals(listOf("user", "assistant", "tool", "user"), list.map { str(it, "role") })
|
||||
assertEquals("R", str(list[1], "reasoning_content"))
|
||||
assertTrue((list[1]["tool_calls"] as? JsonArray)?.isNotEmpty() == true)
|
||||
assertTrue(list[1].containsKey("reasoning"))
|
||||
assertEquals("hi", str(list[0], "content"))
|
||||
assertEquals("ok", str(list[2], "content"))
|
||||
assertEquals("again", str(list[3], "content"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user