fix(reasoning): preserve chat effort and protocol labels (#1550)

This commit is contained in:
Randi
2026-04-24 08:04:04 -04:00
committed by GitHub
parent b10b724d17
commit 78531fe033
9 changed files with 141 additions and 36 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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(),
}
);
}

View File

@@ -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: "<thinking>" },
],
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,

View File

@@ -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"

View File

@@ -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");
});

View File

@@ -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);
});