From 78531fe033bf5fd0c266f89fb56afafe6ed54e12 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:04:04 -0400 Subject: [PATCH] fix(reasoning): preserve chat effort and protocol labels (#1550) --- CHANGELOG.md | 4 ++ open-sse/services/provider.ts | 19 +------ src/shared/components/RequestLoggerDetail.tsx | 9 +--- src/shared/components/RequestLoggerV2.tsx | 9 +--- src/shared/constants/colors.ts | 27 ++++++++++ tests/unit/chatcore-translation-paths.test.ts | 49 +++++++++++++++++++ tests/unit/plan3-p0.test.ts | 7 +-- tests/unit/provider-service.test.ts | 4 +- tests/unit/request-log-detail-layout.test.ts | 49 +++++++++++++++++++ 9 files changed, 141 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe2be208a..004677c4b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 🐛 Bug Fixes + +- **fix(providers):** Preserve OpenAI Chat Completions `reasoning_effort` through assistant-prefill requests and label OpenAI request protocols explicitly as `OpenAI-Chat` or `OpenAI-Responses`. + --- ## [3.7.0] — 2026-04-23 diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index ae454e821b..56a8d51509 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -86,17 +86,6 @@ function buildAnthropicCompatibleUrl(baseUrl) { // contain max_tokens or Claude model names. export function detectFormatFromEndpoint(body, endpointPath = "") { const path = String(endpointPath || ""); - const hasInputField = - body && - typeof body === "object" && - Object.prototype.hasOwnProperty.call(body, "input") && - body.input !== undefined; - const hasResponsesSpecificFields = - body && - typeof body === "object" && - (body.max_output_tokens !== undefined || - body.previous_response_id !== undefined || - body.reasoning !== undefined); if (/\/responses(?=\/|$)/i.test(path) || /^responses(?=\/|$)/i.test(path)) { return "openai-responses"; @@ -110,9 +99,6 @@ export function detectFormatFromEndpoint(body, endpointPath = "") { /\/(?:chat\/completions|completions)(?=\/|$)/i.test(path) || /^(?:chat\/completions|completions)(?=\/|$)/i.test(path) ) { - if (hasInputField || hasResponsesSpecificFields) { - return "openai-responses"; - } return "openai"; } @@ -400,11 +386,10 @@ export function hasThinkingConfig(body) { } // Normalize thinking config based on last message role -// - If lastMessage is not user → remove thinking config -// - If lastMessage is user AND has thinking config → keep it (force enable) +// - If lastMessage is not user → remove Claude/Gemini-style thinking config +// - Keep OpenAI Chat Completions reasoning_effort as a request-level option. export function normalizeThinkingConfig(body) { if (!isLastMessageFromUser(body)) { - delete body.reasoning_effort; delete body.thinking; } return body; diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx index 5b60f4dba9..f599f1d87e 100644 --- a/src/shared/components/RequestLoggerDetail.tsx +++ b/src/shared/components/RequestLoggerDetail.tsx @@ -2,9 +2,9 @@ import { useState, useEffect } from "react"; import { - PROTOCOL_COLORS, PROVIDER_COLORS, getHttpStatusStyle as getStatusStyle, + getProtocolColor, } from "@/shared/constants/colors"; import { formatDuration, formatApiKeyLabel } from "@/shared/utils/formatting"; @@ -57,12 +57,7 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC const statusStyle = getStatusStyle(log.status); const protocolKey = log.sourceFormat || log.provider; - const protocol = PROTOCOL_COLORS[protocolKey] || - PROTOCOL_COLORS[log.provider] || { - bg: "#6B7280", - text: "#fff", - label: (protocolKey || log.provider || "-").toUpperCase(), - }; + const protocol = getProtocolColor(protocolKey, log.provider); const providerColor = PROVIDER_COLORS[log.provider] || { bg: "#374151", text: "#fff", diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx index f5047b4640..343c12b656 100644 --- a/src/shared/components/RequestLoggerV2.tsx +++ b/src/shared/components/RequestLoggerV2.tsx @@ -6,9 +6,9 @@ import Card from "./Card"; import RequestLoggerDetail from "./RequestLoggerDetail"; import { copyToClipboard } from "@/shared/utils/clipboard"; import { - PROTOCOL_COLORS, PROVIDER_COLORS, getHttpStatusStyle as getStatusStyle, + getProtocolColor, } from "@/shared/constants/colors"; import { formatTime, @@ -706,12 +706,7 @@ export default function RequestLoggerV2() { {sortedLogs.map((log) => { const statusStyle = getStatusStyle(log.status); const protocolKey = log.sourceFormat || log.provider; - const protocol = PROTOCOL_COLORS[protocolKey] || - PROTOCOL_COLORS[log.provider] || { - bg: "#6B7280", - text: "#fff", - label: (protocolKey || log.provider || "-").toUpperCase(), - }; + const protocol = getProtocolColor(protocolKey, log.provider); const compatLabel = getProviderDisplayLabel(log.provider, providerNodes); const providerColor = PROVIDER_COLORS[log.provider] || { bg: "#374151", diff --git a/src/shared/constants/colors.ts b/src/shared/constants/colors.ts index 356508b25b..bb51630885 100644 --- a/src/shared/constants/colors.ts +++ b/src/shared/constants/colors.ts @@ -38,6 +38,15 @@ export const PROTOCOL_COLORS = { bypass: { bg: "#6B7280", text: "#fff", label: "Bypass" }, }; +const PROTOCOL_KEY_ALIASES = { + "openai-chat": "openai", + "openai-response": "openai-responses", +}; + +function normalizeProtocolKey(protocol) { + return PROTOCOL_KEY_ALIASES[protocol] || protocol; +} + // ═══════════════════════════════════════════ // Proxy Type Colors (ProxyLogger) // ═══════════════════════════════════════════ @@ -134,3 +143,21 @@ export function getProviderColor(provider) { } ); } + +/** + * Get default fallback for a protocol color lookup. + * @param {string} protocol - Protocol key + * @param {string} fallbackProvider - Provider key to use as a secondary protocol key + * @returns {{ bg: string, text: string, label: string }} + */ +export function getProtocolColor(protocol, fallbackProvider) { + const normalized = normalizeProtocolKey(protocol); + return ( + PROTOCOL_COLORS[normalized] || + PROTOCOL_COLORS[fallbackProvider] || { + bg: "#6B7280", + text: "#fff", + label: (protocol || fallbackProvider || "-").toUpperCase(), + } + ); +} diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 0977d807e6..dc7011103a 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -797,6 +797,55 @@ test("chatCore strips unsupported reasoning params and caps provider token field assert.equal(call.body.max_completion_tokens, 16384); }); +test("chatCore preserves reasoning_effort for assistant-prefill OpenAI-compatible requests", async () => { + const { call, result } = await invokeChatCore({ + provider: "openai-compatible-aio", + model: "glm-5.1", + endpoint: "/v1/chat/completions", + body: { + model: "aio/glm-5.1", + messages: [ + { role: "user", content: "draft the answer" }, + { role: "assistant", content: "" }, + ], + reasoning_effort: "xhigh", + stream: true, + }, + responseFormat: "openai", + }); + + assert.equal(result.success, true); + assert.equal(call.body.model, "glm-5.1"); + assert.equal(call.body.reasoning_effort, "xhigh"); +}); + +test("chatCore logs chat completions endpoint as OpenAI protocol", async () => { + const { call, result } = await invokeChatCore({ + provider: "openrouter", + model: "deepseek/deepseek-v4-pro", + endpoint: "/v1/chat/completions", + body: { + model: "openrouter/deepseek/deepseek-v4-pro", + messages: [{ role: "user", content: "Human: Hi" }], + temperature: 1, + max_tokens: 64000, + stream: false, + presence_penalty: 0, + frequency_penalty: 0, + top_p: 0.9, + }, + responseFormat: "openai", + }); + + assert.equal(result.success, true); + assert.equal(call.body.model, "deepseek/deepseek-v4-pro"); + + const logEntry = await waitFor(getLatestCallLog); + assert.ok(logEntry, "expected call log to be persisted"); + assert.equal(logEntry.path, "/v1/chat/completions"); + assert.equal(logEntry.sourceFormat, FORMATS.OPENAI); +}); + test("chatCore surfaces translation errors with explicit status codes", async () => { register( FORMATS.OPENAI_RESPONSES, diff --git a/tests/unit/plan3-p0.test.ts b/tests/unit/plan3-p0.test.ts index 51a9d62911..5995642f0a 100644 --- a/tests/unit/plan3-p0.test.ts +++ b/tests/unit/plan3-p0.test.ts @@ -402,12 +402,13 @@ test("detectFormat identifies OpenAI Responses by max_output_tokens without inpu assert.equal(format, FORMATS.OPENAI_RESPONSES); }); -test("detectFormatFromEndpoint forces OpenAI for /v1/chat/completions", () => { +test("detectFormatFromEndpoint uses chat completions endpoint for OpenAI chat protocol", () => { const format = detectFormatFromEndpoint( { - model: "cc/claude-opus-4-6", + model: "test-model", messages: [{ role: "user", content: "hi" }], - max_tokens: 16, + input: "ignored for endpoint protocol detection", + max_output_tokens: 16, stream: false, }, "/v1/chat/completions" diff --git a/tests/unit/provider-service.test.ts b/tests/unit/provider-service.test.ts index bfb075ed26..e6a4c229b9 100644 --- a/tests/unit/provider-service.test.ts +++ b/tests/unit/provider-service.test.ts @@ -114,7 +114,7 @@ test("Unknown providers fall back to bearer auth and OpenAI format", () => { assert.equal(getTargetFormat("custom-provider"), "openai"); }); -test("thinking config is removed when the last message is not from the user", () => { +test("native thinking config is removed when the last message is not from the user", () => { const assistantLast = { messages: [ { role: "user", content: "hi" }, @@ -134,7 +134,7 @@ test("thinking config is removed when the last message is not from the user", () assert.equal(isLastMessageFromUser({ messages: [] }), true); assert.equal(isLastMessageFromUser(assistantLast), false); assert.equal(hasThinkingConfig(userLast), true); - assert.equal("reasoning_effort" in normalized, false); + assert.equal(normalized.reasoning_effort, "high"); assert.equal("thinking" in normalized, false); assert.equal(normalizeThinkingConfig(userLast).reasoning_effort, "medium"); }); diff --git a/tests/unit/request-log-detail-layout.test.ts b/tests/unit/request-log-detail-layout.test.ts index a6d2dd7eb3..00166ed8b2 100644 --- a/tests/unit/request-log-detail-layout.test.ts +++ b/tests/unit/request-log-detail-layout.test.ts @@ -6,6 +6,46 @@ import { renderToStaticMarkup } from "react-dom/server"; const { default: RequestLoggerDetail } = await import("../../src/shared/components/RequestLoggerDetail.tsx"); +function renderDetailWithSourceFormat(sourceFormat: string) { + return renderToStaticMarkup( + React.createElement(RequestLoggerDetail, { + log: { + status: 200, + method: "POST", + path: "/v1/chat/completions", + timestamp: "2026-04-09T21:27:08.000Z", + duration: 2500, + provider: "openrouter", + sourceFormat, + model: "deepseek/deepseek-v4-pro", + requestedModel: "openrouter/deepseek/deepseek-v4-pro", + cacheSource: "upstream", + tokens: { + in: 10, + out: 2, + cacheRead: null, + cacheWrite: null, + reasoning: null, + }, + }, + detail: { + requestedModel: "openrouter/deepseek/deepseek-v4-pro", + cacheSource: "upstream", + tokens: { + in: 10, + out: 2, + cacheRead: null, + cacheWrite: null, + reasoning: null, + }, + }, + loading: false, + onClose: () => {}, + onCopy: async () => true, + }) + ); +} + test("request log detail splits token badges into input and output groups", () => { const html = renderToStaticMarkup( React.createElement(RequestLoggerDetail, { @@ -70,6 +110,7 @@ test("request log detail splits token badges into input and output groups", () = assert.equal(modelLabelIndex < requestedModelLabelIndex, true); assert.notEqual(html.indexOf(">Cache Source<"), -1); assert.notEqual(html.indexOf(">Semantic (OmniRoute)<"), -1); + assert.notEqual(html.indexOf(">OpenAI-Chat<"), -1); assert.match( html, @@ -77,3 +118,11 @@ test("request log detail splits token badges into input and output groups", () = ); assert.match(html, /data-testid="token-group-output"[\s\S]*Total Out: 42[\s\S]*Reasoning: N\/A/); }); + +test("request log detail labels OpenAI protocol variants explicitly", () => { + const chatHtml = renderDetailWithSourceFormat("openai"); + const responsesHtml = renderDetailWithSourceFormat("openai-responses"); + + assert.notEqual(chatHtml.indexOf(">OpenAI-Chat<"), -1); + assert.notEqual(responsesHtml.indexOf(">OpenAI-Responses<"), -1); +});