Skip to content

Commit 719c0a8

Browse files
committed
feat(bailian): dynamic model fetching, provider hardening, and bug fixes (#420)(#421)
Add auto-fetching of available models from the DashScope API and display them alongside the 8 static presets. Bundle the full set of provider improvements, wiring, and tests from the bailian feature branch. ## Dynamic Model Fetching - packages/types/src/provider-settings.ts — register "bailian" in the dynamicProviders array so the model cache pipeline routes fetch requests - packages/types/src/providers/bailian.ts — correct contextWindow from 1,048,576 to 1,000,000 for the 5 models with a 1M-token product specification; expand single-line pricing and cache-price fields to multi-line for readability - src/api/providers/fetchers/bailian.ts — getBailianModels() fetcher calling DashScope /models via the standard OpenAI-compatible endpoint; case-insensitive exact/substring preset matching (e.g. "ZHIPU/GLM-5.1" matches preset "glm-5.1"); text-model keyword filter excluding image, embedding, speech, video, and OCR models; conservative defaults (8192 maxTokens, 128K context, no pricing) for unknown models; 10s AbortController timeout; error detail forwarded in thrown Error; error-body logging truncated to 500 characters - src/api/providers/fetchers/modelCache.ts — add "bailian" case to fetchModelsFromProvider() switch with exhaustive type enforcement - src/core/webview/webviewMessageHandler.ts — add bailian candidate block to requestRouterModels, conditional on bailianApiKey; try/catch wrapper around getBailianBaseUrl to skip model fetch when Frankfurt or Hong Kong is selected without a workspaceId; flush cached models regardless of whether the key came from the current webview message or stored configuration; use instanceof Error for safe type-checking in the catch block - src/shared/api.ts — add bailian entry to dynamicProviderExtras with optional apiKey and baseUrl fields - webview-ui/src/components/settings/providers/Bailian.tsx — accept RouterModels prop; merge API-fetched models with static presets (static overwrites matching keys); pass the merged collection to ModelPicker; use Object.hasOwn for isCustomModel detection; trim model IDs so whitespace from UI input does not cause false custom-model detection - webview-ui/src/components/settings/ApiOptions.tsx — pass routerModels to the Bailian component - webview-ui/src/utils/__tests__/validate.spec.ts — add bailian: {} entry to the mock RouterModels fixture for exhaustiveness ## Provider Hardening - src/api/providers/bailian.ts — rewrite getModel() as a three-tier lookup (static presets → API-fetched cache → custom model fallback) via getModelsFromCache; trim model IDs at the handler level so whitespace never reaches downstream lookups; canonicalize versioned and named-space API variants (e.g. "qwen3.7-max-2026-05-17", "kimi/kimi-k2.6") via findMatchingPreset before pricing lookup so every variant receives region-correct pricing; always merge user-configured custom model overrides regardless of whether the model ID matches a preset; remove sentinel "..." text injection for image-only messages in addPromptCaching - src/api/providers/bailian-region.ts — getBailianBaseUrl() shared utility consolidating the REGION_URLS map, workspaceId validation for Frankfurt and Hong Kong, and a console.warn when an unknown region string is provided before falling back to Beijing; eliminates duplicate logic previously in the handler constructor and webviewMessageHandler ## Tests - src/api/providers/__tests__/bailian.spec.ts — 35 handler tests - src/api/providers/fetchers/__tests__/bailian.spec.ts — 24 fetcher tests (exact/substring/no-match matching, filtering, error handling, edge cases including network failure, AbortSignal, and non-string model IDs) - webview-ui/src/components/settings/providers/__tests__/Bailian.spec.tsx — 5 UI merge tests (static-override priority, API-only model inclusion, isCustomModel detection with merged collection, whitespace trim) - apps/vscode-e2e/src/suite/providers/bailian.test.ts — E2E smoke test with fetch-interceptor pattern - apps/vscode-e2e/fixtures/bailian.json — empty fixture scaffolding Closes #420. See #421.
1 parent ef6b1f9 commit 719c0a8

17 files changed

Lines changed: 1656 additions & 157 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"fixtures": []
3+
}
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import * as assert from "assert"
2+
3+
import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
4+
5+
import { setDefaultSuiteTimeout } from "../test-utils"
6+
import { waitUntilCompleted } from "../utils"
7+
8+
const BAILIAN_API_KEY = process.env.BAILIAN_API_KEY
9+
10+
// ---------------------------------------------------------------------------
11+
// Fetch interceptor
12+
// ---------------------------------------------------------------------------
13+
14+
/** @typedef {{ model?: string; enable_thinking?: boolean; thinking_budget?: number; reasoning_effort?: string; probeTag?: string }} BailianRequestCapture */
15+
16+
type BailianRequestCapture = {
17+
url?: string
18+
model?: string
19+
enable_thinking?: boolean
20+
thinking_budget?: number
21+
reasoning_effort?: string
22+
probeTag?: string
23+
}
24+
25+
/**
26+
* @param {BailianRequestCapture[]} capture
27+
* @param {boolean} [passthrough]
28+
* @returns {() => void} restore function
29+
*/
30+
function installBailianFetchInterceptor(capture: BailianRequestCapture[], passthrough?: boolean): () => void {
31+
const original = globalThis.fetch
32+
33+
globalThis.fetch = async function (input: RequestInfo | URL, init?: RequestInit) {
34+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
35+
36+
const isBailianUrl = url.includes("dashscope.aliyuncs.com") || url.includes("maas.aliyuncs.com")
37+
38+
if (isBailianUrl && url.includes("/chat/completions")) {
39+
const body = init?.body ? JSON.parse(init.body as string) : {}
40+
const messages = body.messages ?? []
41+
const allMessagesText = JSON.stringify(messages)
42+
const probeTag = allMessagesText.match(/bailian-e2e:[^"\s]+/)?.[0]
43+
44+
capture.push({
45+
url,
46+
model: body.model,
47+
enable_thinking: body.enable_thinking,
48+
thinking_budget: body.thinking_budget,
49+
reasoning_effort: body.reasoning_effort,
50+
probeTag,
51+
})
52+
53+
if (passthrough) {
54+
return original.call(globalThis, input, init)
55+
}
56+
57+
// In mock mode, return a simple SSE response with a completion
58+
const enc = new TextEncoder()
59+
const body2 = new ReadableStream({
60+
start(controller) {
61+
controller.enqueue(
62+
enc.encode(
63+
`data: ${JSON.stringify({
64+
id: "chatcmpl-mock",
65+
object: "chat.completion.chunk",
66+
created: Math.floor(Date.now() / 1000),
67+
model: body.model || "qwen3.6-plus",
68+
choices: [{ index: 0, delta: { content: "bailian-e2e:" }, finish_reason: null }],
69+
})}\n\n`,
70+
),
71+
)
72+
controller.enqueue(
73+
enc.encode(
74+
`data: ${JSON.stringify({
75+
id: "chatcmpl-mock",
76+
object: "chat.completion.chunk",
77+
created: Math.floor(Date.now() / 1000),
78+
model: body.model || "qwen3.6-plus",
79+
choices: [{ index: 0, delta: { content: "mock-ok" }, finish_reason: null }],
80+
})}\n\n`,
81+
),
82+
)
83+
controller.enqueue(
84+
enc.encode(
85+
`data: ${JSON.stringify({
86+
id: "chatcmpl-mock",
87+
object: "chat.completion.chunk",
88+
created: Math.floor(Date.now() / 1000),
89+
model: body.model || "qwen3.6-plus",
90+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
91+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
92+
})}\n\n`,
93+
),
94+
)
95+
controller.enqueue(enc.encode("data: [DONE]\n\n"))
96+
controller.close()
97+
},
98+
})
99+
return new Response(body2, {
100+
status: 200,
101+
headers: { "content-type": "text/event-stream" },
102+
})
103+
}
104+
105+
return original.call(globalThis, input, init)
106+
} as typeof globalThis.fetch
107+
108+
return () => {
109+
globalThis.fetch = original
110+
}
111+
}
112+
113+
// ---------------------------------------------------------------------------
114+
// Suite
115+
// ---------------------------------------------------------------------------
116+
117+
describe("Bailian Provider", function () {
118+
setDefaultSuiteTimeout(this)
119+
120+
/** @type {import("../../../src/extension/api").API} */
121+
let api: any
122+
123+
before(async function () {
124+
api = globalThis.api
125+
if (!api) {
126+
throw new Error("E2E API not found — ensure the test runner initializes globalThis.api")
127+
}
128+
})
129+
130+
// -----------------------------------------------------------------------
131+
// Beijing region — basic streaming smoke test
132+
// -----------------------------------------------------------------------
133+
134+
it("completes a task on Beijing region with Qwen model", async function () {
135+
const requests: BailianRequestCapture[] = []
136+
const restore = installBailianFetchInterceptor(requests)
137+
138+
const messages: ClineMessage[] = []
139+
const messageHandler = ({ message }: { message: ClineMessage }) => {
140+
if (message.type === "say" && message.partial === false) {
141+
messages.push(message)
142+
}
143+
}
144+
api.on(RooCodeEventName.Message, messageHandler)
145+
146+
try {
147+
await api.setConfiguration({
148+
apiProvider: "bailian",
149+
bailianApiKey: BAILIAN_API_KEY ?? "mock-key",
150+
bailianRegion: "beijing",
151+
apiModelId: "qwen3.6-plus",
152+
enableReasoningEffort: false,
153+
})
154+
155+
const taskId = await api.startNewTask({
156+
configuration: { mode: "ask", autoApprovalEnabled: true },
157+
text: "bailian-e2e:beijing-basic: echo 'hello'",
158+
})
159+
await waitUntilCompleted({ api, taskId })
160+
161+
const completion = messages.find((m) => m.type === "say" && m.say === "completion_result")
162+
assert.ok(completion, "Task should complete successfully")
163+
assert.ok(
164+
completion.text?.includes("bailian-e2e:mock-ok") || completion.text?.includes("mock-ok"),
165+
`Completion should contain mock response, got: ${completion.text?.slice(0, 200)}`,
166+
)
167+
} finally {
168+
api.off(RooCodeEventName.Message, messageHandler)
169+
restore()
170+
}
171+
})
172+
173+
// -----------------------------------------------------------------------
174+
// DeepSeek V4 reasoning_effort parameter
175+
// -----------------------------------------------------------------------
176+
177+
it("sends reasoning_effort for DeepSeek V4 model", async function () {
178+
const requests: BailianRequestCapture[] = []
179+
const restore = installBailianFetchInterceptor(requests)
180+
181+
const messages: ClineMessage[] = []
182+
const messageHandler = ({ message }: { message: ClineMessage }) => {
183+
if (message.type === "say" && message.partial === false) {
184+
messages.push(message)
185+
}
186+
}
187+
api.on(RooCodeEventName.Message, messageHandler)
188+
189+
try {
190+
await api.setConfiguration({
191+
apiProvider: "bailian",
192+
bailianApiKey: BAILIAN_API_KEY ?? "mock-key",
193+
bailianRegion: "beijing",
194+
apiModelId: "deepseek-v4-pro",
195+
reasoningEffort: "high",
196+
})
197+
198+
const taskId = await api.startNewTask({
199+
configuration: { mode: "ask", autoApprovalEnabled: true },
200+
text: "bailian-e2e:deepseek-reasoning: echo 'hello'",
201+
})
202+
await waitUntilCompleted({ api, taskId })
203+
204+
const reasoningRequest = requests.find((r) => r.reasoning_effort === "high")
205+
assert.ok(reasoningRequest, "Should send reasoning_effort: high for DeepSeek V4 model")
206+
} finally {
207+
api.off(RooCodeEventName.Message, messageHandler)
208+
restore()
209+
}
210+
})
211+
212+
// -----------------------------------------------------------------------
213+
// Binary reasoning enable_thinking parameter (Qwen)
214+
// -----------------------------------------------------------------------
215+
216+
it("sends enable_thinking for binary reasoning model (Qwen)", async function () {
217+
const requests: BailianRequestCapture[] = []
218+
const restore = installBailianFetchInterceptor(requests)
219+
220+
const messages: ClineMessage[] = []
221+
const messageHandler = ({ message }: { message: ClineMessage }) => {
222+
if (message.type === "say" && message.partial === false) {
223+
messages.push(message)
224+
}
225+
}
226+
api.on(RooCodeEventName.Message, messageHandler)
227+
228+
try {
229+
await api.setConfiguration({
230+
apiProvider: "bailian",
231+
bailianApiKey: BAILIAN_API_KEY ?? "mock-key",
232+
bailianRegion: "beijing",
233+
apiModelId: "qwen3.7-max",
234+
enableReasoningEffort: true,
235+
})
236+
237+
const taskId = await api.startNewTask({
238+
configuration: { mode: "ask", autoApprovalEnabled: true },
239+
text: "bailian-e2e:qwen-thinking: echo 'hello'",
240+
})
241+
await waitUntilCompleted({ api, taskId })
242+
243+
const thinkingRequest = requests.find((r) => r.enable_thinking === true)
244+
assert.ok(thinkingRequest, "Should send enable_thinking: true for Qwen binary reasoning model")
245+
} finally {
246+
api.off(RooCodeEventName.Message, messageHandler)
247+
restore()
248+
}
249+
})
250+
251+
// -----------------------------------------------------------------------
252+
// Workspace ID for Frankfurt region
253+
// -----------------------------------------------------------------------
254+
255+
it("uses Frankfurt workspaceId-based URL", async function () {
256+
const requests: BailianRequestCapture[] = []
257+
const restore = installBailianFetchInterceptor(requests)
258+
259+
const messages: ClineMessage[] = []
260+
const messageHandler = ({ message }: { message: ClineMessage }) => {
261+
if (message.type === "say" && message.partial === false) {
262+
messages.push(message)
263+
}
264+
}
265+
api.on(RooCodeEventName.Message, messageHandler)
266+
267+
try {
268+
await api.setConfiguration({
269+
apiProvider: "bailian",
270+
bailianApiKey: BAILIAN_API_KEY ?? "mock-key",
271+
bailianRegion: "frankfurt",
272+
bailianWorkspaceId: "ws-test-123",
273+
apiModelId: "qwen3.6-flash",
274+
enableReasoningEffort: false,
275+
})
276+
277+
const taskId = await api.startNewTask({
278+
configuration: { mode: "ask", autoApprovalEnabled: true },
279+
text: "bailian-e2e:frankfurt: echo 'hello'",
280+
})
281+
await waitUntilCompleted({ api, taskId })
282+
283+
assert.ok(
284+
messages.some((m) => m.type === "say" && m.say === "completion_result"),
285+
"Task should complete with Frankfurt endpoint",
286+
)
287+
const frankfurtRequest = requests.find((r) => r.url?.includes("ws-test-123.eu-central-1.maas.aliyuncs.com"))
288+
assert.ok(frankfurtRequest, "Should use workspaceId-prefixed URL for Frankfurt region")
289+
} finally {
290+
api.off(RooCodeEventName.Message, messageHandler)
291+
restore()
292+
}
293+
})
294+
})

packages/types/src/provider-settings.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const dynamicProviders = [
4444
"unbound",
4545
"poe",
4646
"deepseek",
47+
"bailian",
4748
"opencode-go",
4849
] as const
4950

@@ -353,7 +354,16 @@ const mimoSchema = apiModelIdProviderModelSchema.extend({
353354
const bailianSchema = apiModelIdProviderModelSchema.extend({
354355
bailianApiKey: z.string().optional(),
355356
bailianRegion: z
356-
.enum(["beijing", "singapore", "virginia", "frankfurt", "hongkong", "coding-plan", "token-plan", "token-plan-sgp"])
357+
.enum([
358+
"beijing",
359+
"singapore",
360+
"virginia",
361+
"frankfurt",
362+
"hongkong",
363+
"coding-plan",
364+
"token-plan",
365+
"token-plan-sgp",
366+
])
357367
.optional(),
358368
bailianWorkspaceId: z.string().optional(),
359369
bailianCustomModelInfo: modelInfoSchema.nullish(),

0 commit comments

Comments
 (0)