Skip to content

Commit 2217099

Browse files
authored
Merge pull request #149 from lcomplete/dev
feat(extension): add raw OpenAI-compatible stream path for thinking-mode providers
2 parents 6f8dd7b + f0347fc commit 2217099

9 files changed

Lines changed: 663 additions & 46 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/** @jest-environment jsdom */
2+
3+
/// <reference types="node" />
4+
5+
import { afterEach, describe, expect, it, jest } from "@jest/globals";
6+
import { act } from "react-dom/test-utils";
7+
import { createRoot } from "react-dom/client";
8+
9+
import { AssistantMessage } from "../sidepanel/components/AssistantMessage";
10+
import type { ChatMessage } from "../sidepanel/types";
11+
12+
jest.mock("../sidepanel/components/AssistantStatusCard", () => ({
13+
AssistantStatusCard: () => null,
14+
}));
15+
16+
jest.mock("../sidepanel/components/IconButton", () => ({
17+
IconButton: ({
18+
children,
19+
onClick,
20+
}: {
21+
children: React.ReactNode;
22+
onClick?: () => void;
23+
}) => {
24+
const React = require("react");
25+
return React.createElement("button", { type: "button", onClick }, children);
26+
},
27+
}));
28+
29+
jest.mock("../sidepanel/components/LinkCardsBlock", () => ({
30+
LinkCardsBlock: () => null,
31+
}));
32+
33+
jest.mock("../sidepanel/components/MarkdownContent", () => ({
34+
MarkdownContent: ({ text }: { text: string }) => {
35+
const React = require("react");
36+
return React.createElement("div", null, text);
37+
},
38+
}));
39+
40+
jest.mock("../sidepanel/components/MessageFooter", () => ({
41+
MessageFooter: ({ children }: { children: React.ReactNode }) => {
42+
const React = require("react");
43+
return React.createElement("div", null, children);
44+
},
45+
}));
46+
47+
jest.mock("../sidepanel/components/ReasoningBlock", () => ({
48+
ReasoningBlock: ({ text }: { text: string }) => {
49+
const React = require("react");
50+
return React.createElement("div", null, text);
51+
},
52+
}));
53+
54+
jest.mock("../sidepanel/components/ToolCallBlock", () => ({
55+
ToolCallBlock: () => null,
56+
}));
57+
58+
jest.mock("../i18n", () => ({
59+
useI18n: () => ({
60+
t: (key: string) => key,
61+
}),
62+
}));
63+
64+
(
65+
globalThis as typeof globalThis & {
66+
IS_REACT_ACT_ENVIRONMENT?: boolean;
67+
}
68+
).IS_REACT_ACT_ENVIRONMENT = true;
69+
70+
function renderAssistantMessage(
71+
props: Partial<React.ComponentProps<typeof AssistantMessage>> = {}
72+
) {
73+
const container = document.createElement("div");
74+
document.body.appendChild(container);
75+
const root = createRoot(container);
76+
const message: ChatMessage = {
77+
id: "assistant-1",
78+
role: "assistant",
79+
parts: [],
80+
status: "running",
81+
};
82+
83+
act(() => {
84+
root.render(
85+
<AssistantMessage
86+
isLast={true}
87+
isRunning={true}
88+
message={message}
89+
thinkingMode={false}
90+
{...props}
91+
/>
92+
);
93+
});
94+
95+
return {
96+
container,
97+
cleanup: () => {
98+
act(() => root.unmount());
99+
container.remove();
100+
},
101+
};
102+
}
103+
104+
describe("AssistantMessage", () => {
105+
afterEach(() => {
106+
document.body.innerHTML = "";
107+
});
108+
109+
it("shows a preparing response indicator before assistant text arrives", () => {
110+
const { container, cleanup } = renderAssistantMessage();
111+
const indicator = container.querySelector('[role="status"]');
112+
113+
expect(indicator?.getAttribute("aria-label")).toBe("common.loading");
114+
expect(container.querySelectorAll(".claude-dot")).toHaveLength(3);
115+
expect(indicator?.className).not.toContain("rounded-full");
116+
expect(indicator?.className).not.toContain("border");
117+
118+
cleanup();
119+
});
120+
121+
it("keeps the preparing indicator visible for a leading step-start part", () => {
122+
const { container, cleanup } = renderAssistantMessage({
123+
message: {
124+
id: "assistant-2",
125+
role: "assistant",
126+
parts: [{ type: "step-start" }],
127+
status: "running",
128+
},
129+
});
130+
131+
expect(container.querySelector('[role="status"]')).not.toBeNull();
132+
expect(container.querySelectorAll(".claude-dot")).toHaveLength(3);
133+
134+
cleanup();
135+
});
136+
137+
it("keeps the preparing indicator visible while reasoning is streaming", () => {
138+
const { container, cleanup } = renderAssistantMessage({
139+
message: {
140+
id: "assistant-3",
141+
role: "assistant",
142+
parts: [{ type: "reasoning", text: "Thinking", streaming: true }],
143+
status: "running",
144+
},
145+
thinkingMode: true,
146+
});
147+
148+
expect(container.querySelector('[role="status"]')).not.toBeNull();
149+
expect(container.textContent).toContain("Thinking");
150+
151+
cleanup();
152+
});
153+
154+
it("keeps the preparing indicator visible while a tool call is running", () => {
155+
const { container, cleanup } = renderAssistantMessage({
156+
message: {
157+
id: "assistant-4",
158+
role: "assistant",
159+
parts: [
160+
{
161+
type: "tool-call",
162+
toolCallId: "tool-1",
163+
toolName: "search_web",
164+
args: { query: "huntly" },
165+
},
166+
],
167+
status: "running",
168+
},
169+
});
170+
171+
expect(container.querySelector('[role="status"]')).not.toBeNull();
172+
173+
cleanup();
174+
});
175+
176+
it("shows the preparing indicator again after earlier text when a tool call starts", () => {
177+
const { container, cleanup } = renderAssistantMessage({
178+
message: {
179+
id: "assistant-5",
180+
role: "assistant",
181+
parts: [
182+
{ type: "text", text: "先给你一个结论。" },
183+
{
184+
type: "tool-call",
185+
toolCallId: "tool-2",
186+
toolName: "search_web",
187+
args: { query: "huntly" },
188+
},
189+
],
190+
status: "running",
191+
},
192+
});
193+
194+
expect(container.querySelector('[role="status"]')).not.toBeNull();
195+
196+
cleanup();
197+
});
198+
199+
it("shows the preparing indicator again after earlier text when a new step starts", () => {
200+
const { container, cleanup } = renderAssistantMessage({
201+
message: {
202+
id: "assistant-6",
203+
role: "assistant",
204+
parts: [
205+
{ type: "text", text: "先给你一个结论。" },
206+
{ type: "step-start" },
207+
],
208+
status: "running",
209+
},
210+
});
211+
212+
expect(container.querySelector('[role="status"]')).not.toBeNull();
213+
214+
cleanup();
215+
});
216+
217+
it("hides the preparing indicator once visible text arrives", () => {
218+
const { container, cleanup } = renderAssistantMessage({
219+
message: {
220+
id: "assistant-7",
221+
role: "assistant",
222+
parts: [{ type: "text", text: "hello" }],
223+
status: "running",
224+
},
225+
});
226+
227+
expect(container.querySelector('[role="status"]')).toBeNull();
228+
229+
cleanup();
230+
});
231+
});

app/extension/src/__tests__/providers.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
getOpenAICompatibleBaseUrl,
33
getOllamaBaseUrl,
44
getOllamaOpenAIBaseUrl,
5+
usesRawOpenAICompatibleStream,
56
} from "../ai/openAICompatibleProviders";
67
import { getEffectiveApiFormat, PROVIDER_REGISTRY } from "../ai/types";
78

@@ -44,6 +45,64 @@ describe("providers helpers", () => {
4445
);
4546
});
4647

48+
it("uses raw OpenAI-compatible streaming for providers that need explicit thinking control", () => {
49+
expect(
50+
usesRawOpenAICompatibleStream({
51+
type: "qwen",
52+
enabled: true,
53+
apiKey: "test",
54+
baseUrl: "",
55+
enabledModels: ["qwen3.5-plus"],
56+
updatedAt: Date.now(),
57+
})
58+
).toBe(true);
59+
60+
expect(
61+
usesRawOpenAICompatibleStream({
62+
type: "zhipu",
63+
enabled: true,
64+
apiKey: "test",
65+
baseUrl: "",
66+
enabledModels: ["glm-5"],
67+
updatedAt: Date.now(),
68+
})
69+
).toBe(true);
70+
71+
expect(
72+
usesRawOpenAICompatibleStream({
73+
type: "openai",
74+
enabled: true,
75+
apiKey: "test",
76+
baseUrl: "https://api.openai.com/v1",
77+
enabledModels: ["gpt-4.1"],
78+
updatedAt: Date.now(),
79+
})
80+
).toBe(false);
81+
82+
expect(
83+
usesRawOpenAICompatibleStream({
84+
type: "openai",
85+
enabled: true,
86+
apiKey: "test",
87+
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
88+
enabledModels: ["qwen-plus"],
89+
updatedAt: Date.now(),
90+
})
91+
).toBe(false);
92+
93+
expect(
94+
usesRawOpenAICompatibleStream({
95+
type: "qwen",
96+
enabled: true,
97+
apiKey: "test",
98+
baseUrl: "",
99+
enabledModels: ["qwen3.5-plus"],
100+
updatedAt: Date.now(),
101+
apiFormat: "anthropic",
102+
})
103+
).toBe(false);
104+
});
105+
47106
it("falls back to the provider native format when no override is given", () => {
48107
expect(getEffectiveApiFormat({ type: "qwen" })).toBe("openai");
49108
expect(getEffectiveApiFormat({ type: "anthropic" })).toBe("anthropic");
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { getThinkingModeOptions } from "../ai/thinkingMode";
2+
3+
describe("thinking mode helpers", () => {
4+
it("always sends an explicit enable_thinking flag", () => {
5+
expect(getThinkingModeOptions(true)).toEqual({ enable_thinking: true });
6+
expect(getThinkingModeOptions(false)).toEqual({ enable_thinking: false });
7+
});
8+
});

app/extension/src/ai/openAICompatibleProviders.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { AIProviderConfig, PROVIDER_REGISTRY } from "./types";
1+
import {
2+
AIProviderConfig,
3+
getEffectiveApiFormat,
4+
PROVIDER_REGISTRY,
5+
} from "./types";
26

37
function trimTrailingSlash(url: string): string {
48
return url.replace(/\/+$/, "");
@@ -12,6 +16,24 @@ export function getProviderBaseUrl(config: AIProviderConfig): string | undefined
1216
);
1317
}
1418

19+
export function usesRawOpenAICompatibleStream(
20+
config: AIProviderConfig
21+
): boolean {
22+
const format = getEffectiveApiFormat({
23+
type: config.type,
24+
apiFormat: config.apiFormat,
25+
});
26+
if (format !== "openai") {
27+
return false;
28+
}
29+
30+
if (PROVIDER_REGISTRY[config.type]?.requiresRawOpenAICompatibleStream) {
31+
return true;
32+
}
33+
34+
return false;
35+
}
36+
1537
/**
1638
* @deprecated use {@link getProviderBaseUrl}. Kept for call sites still being migrated.
1739
*/

0 commit comments

Comments
 (0)