Skip to content

Commit e6c9c20

Browse files
xiaojingmingxjm
andauthored
feat: add auto discount (#607)
Co-authored-by: xjm <[email protected]>
1 parent 8746ea9 commit e6c9c20

File tree

14 files changed

+178
-25
lines changed

14 files changed

+178
-25
lines changed

packages/types/src/model.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ export const modelInfoSchema = z.object({
8484
deprecated: z.boolean().optional(),
8585
// Flag to indicate if the model is free (no cost)
8686
isFree: z.boolean().optional(),
87+
// Credit consumption and discount for the model
88+
creditConsumption: z.number().optional(),
89+
creditDiscount: z.number().optional(),
8790
/**
8891
* Service tiers with pricing information.
8992
* Each tier can have a name (for OpenAI service tiers) and pricing overrides.

src/api/providers/zgsm.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl
176176
})
177177
}
178178
let stream
179+
let selectedLlm: string | undefined
180+
let selectReason: string | undefined
179181
try {
180182
this.logger.info(`[RequestID]:`, requestId)
181183
const { data, response } = await this.client.chat.completions
@@ -189,20 +191,31 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl
189191
.withResponse()
190192
this.logger.info(`[ResponseID]:`, response.headers.get("x-request-id"))
191193

192-
stream = data
193194
if (this.options.zgsmModelId === autoModeModelId) {
195+
selectedLlm = response.headers.get("x-select-llm") || ""
196+
selectReason = response.headers.get("x-select-reason") || ""
197+
198+
if (selectedLlm) {
199+
this.logger.info(`[Selected LLM]:`, selectedLlm)
200+
}
201+
if (selectReason) {
202+
this.logger.info(`[Select Reason]:`, selectReason)
203+
}
204+
194205
const userInputHeader = response.headers.get("x-user-input")
195206
if (userInputHeader) {
196207
const decodedUserInput = Buffer.from(userInputHeader, "base64").toString("utf-8")
197208
this.logger.info(`[x-user-input]: ${decodedUserInput}`)
198209
}
199210
}
211+
212+
stream = data
200213
} catch (error) {
201214
throw handleOpenAIError(error, this.providerName)
202215
}
203216

204217
// 6. Optimize stream processing - use batch processing and buffer
205-
yield* this.handleOptimizedStream(stream, modelInfo)
218+
yield* this.handleOptimizedStream(stream, modelInfo, selectedLlm, selectReason)
206219
} else {
207220
// Non-streaming processing
208221
const requestOptions = this.buildNonStreamingRequestOptions(
@@ -406,6 +419,8 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl
406419
private async *handleOptimizedStream(
407420
stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
408421
modelInfo: ModelInfo,
422+
selectedLlm?: string,
423+
selectReason?: string,
409424
): ApiStream {
410425
const matcher = new XmlMatcher(
411426
"think",
@@ -423,6 +438,14 @@ export class ZgsmAiHandler extends BaseProvider implements SingleCompletionHandl
423438
let time = Date.now()
424439
let isPrinted = false
425440

441+
// Yield selected LLM info if available (for Auto model mode)
442+
if (selectedLlm && this.options.zgsmModelId === autoModeModelId) {
443+
yield {
444+
type: "text",
445+
text: `[Selected LLM: ${selectedLlm}${selectReason ? ` (${selectReason})` : ""}]`,
446+
}
447+
}
448+
426449
// chunk
427450
for await (const chunk of stream) {
428451
const delta = chunk.choices[0]?.delta ?? {}

src/core/task/Task.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2038,6 +2038,36 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
20382038
}
20392039
break
20402040
case "text": {
2041+
// Check if it is Selected LLM information (only in Auto model mode).
2042+
if (
2043+
this.apiConfiguration.zgsmModelId === "Auto" &&
2044+
chunk.text?.startsWith("[Selected LLM:")
2045+
) {
2046+
// Extract Selected LLM and Reason information and update the api_req_started message.
2047+
const match = chunk.text.match(/\[Selected LLM:\s*([^\]]+)\]/)
2048+
if (match && lastApiReqIndex >= 0 && this.clineMessages[lastApiReqIndex]) {
2049+
const existingData = JSON.parse(
2050+
this.clineMessages[lastApiReqIndex].text || "{}",
2051+
)
2052+
// Parse the model name and reason
2053+
const fullInfo = match[1]
2054+
const reasonMatch = fullInfo.match(/^(.+?)\s*\((.+?)\)$/)
2055+
const selectedLlm = reasonMatch ? reasonMatch[1].trim() : fullInfo.trim()
2056+
const selectReason = reasonMatch ? reasonMatch[2].trim() : undefined
2057+
2058+
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
2059+
...existingData,
2060+
selectedLlm,
2061+
selectReason,
2062+
} satisfies ClineApiReqInfo)
2063+
// Save the selection information but do not add it to the assistant message to avoid it being processed by the parser.
2064+
console.log(
2065+
`[Auto Model] Selected: ${selectedLlm}${selectReason ? ` (${selectReason})` : ""}`,
2066+
)
2067+
break
2068+
}
2069+
}
2070+
20412071
assistantMessage += chunk.text
20422072

20432073
// Parse raw assistant message chunk into content blocks.

src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,30 @@ vi.mock("vscode", () => ({
88
showErrorMessage: vi.fn(),
99
showWarningMessage: vi.fn(),
1010
showInformationMessage: vi.fn(),
11+
createTextEditorDecorationType: vi.fn(() => ({
12+
dispose: vi.fn(),
13+
})),
14+
createOutputChannel: vi.fn(() => ({
15+
appendLine: vi.fn(),
16+
append: vi.fn(),
17+
clear: vi.fn(),
18+
show: vi.fn(),
19+
hide: vi.fn(),
20+
dispose: vi.fn(),
21+
})),
1122
},
1223
workspace: {
1324
workspaceFolders: undefined,
1425
getConfiguration: vi.fn(() => ({
1526
get: vi.fn(),
1627
update: vi.fn(),
1728
})),
29+
createFileSystemWatcher: vi.fn(() => ({
30+
onDidCreate: vi.fn(),
31+
onDidChange: vi.fn(),
32+
onDidDelete: vi.fn(),
33+
dispose: vi.fn(),
34+
})),
1835
},
1936
env: {
2037
clipboard: { writeText: vi.fn() },
@@ -32,6 +49,7 @@ vi.mock("vscode", () => ({
3249
Workspace: 2,
3350
WorkspaceFolder: 3,
3451
},
52+
RelativePattern: vi.fn().mockImplementation((base, pattern) => ({ base, pattern })),
3553
}))
3654

3755
// Mock modelCache getModels/flushModels used by the handler

src/shared/ExtensionMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,8 @@ export interface ClineApiReqInfo {
500500
cancelReason?: ClineApiReqCancelReason
501501
streamingFailedMessage?: string
502502
apiProtocol?: "anthropic" | "openai"
503+
selectedLlm?: string
504+
selectReason?: string
503505
}
504506

505507
export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled"

webview-ui/src/components/chat/ChatRow.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,13 +223,13 @@ export const ChatRowContent = ({
223223
vscode.postMessage({ type: "selectImages", context: "edit", messageTs: message.ts })
224224
}, [message.ts])
225225

226-
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
226+
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage, selectedLlm, selectReason] = useMemo(() => {
227227
if (message.text !== null && message.text !== undefined && message.say === "api_req_started") {
228228
const info = safeJsonParse<ClineApiReqInfo>(message.text)
229-
return [info?.cost, info?.cancelReason, info?.streamingFailedMessage]
229+
return [info?.cost, info?.cancelReason, info?.streamingFailedMessage, info?.selectedLlm, info?.selectReason]
230230
}
231231

232-
return [undefined, undefined, undefined]
232+
return [undefined, undefined, undefined, undefined, undefined]
233233
}, [message.text, message.say])
234234

235235
// When resuming task, last wont be api_req_failed but a resume_task
@@ -1119,6 +1119,24 @@ export const ChatRowContent = ({
11191119
${Number(cost || 0)?.toFixed(4)}
11201120
</div>
11211121
</div>
1122+
{(selectedLlm || selectReason) && (
1123+
<div className="mt-2 flex items-center flex-wrap gap-2">
1124+
{selectedLlm && (
1125+
<div
1126+
className="text-xs text-vscode-descriptionForeground border-vscode-dropdown-border/50 border px-1.5 py-0.5 rounded-lg"
1127+
title="Selected Model">
1128+
{t("chat:autoMode.selectedLlm", { selectedLlm })}
1129+
</div>
1130+
)}
1131+
{selectReason && (
1132+
<div
1133+
className="text-xs text-vscode-descriptionForeground border-vscode-dropdown-border/50 border px-1.5 py-0.5 rounded-lg"
1134+
title="Selection Reason">
1135+
{t("chat:autoMode.selectReason", { selectReason })}
1136+
</div>
1137+
)}
1138+
</div>
1139+
)}
11221140
{(((cost === null || cost === undefined) && apiRequestFailedMessage) ||
11231141
apiReqStreamingFailedMessage) && (
11241142
<ErrorRow

webview-ui/src/components/settings/ModelPicker.tsx

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -304,26 +304,50 @@ export const ModelPicker = ({
304304
)}
305305
</CommandEmpty>
306306
<CommandGroup>
307-
{modelIds.map((model) => (
308-
<CommandItem
309-
key={model}
310-
value={model}
311-
onSelect={onSelect}
312-
data-testid={`model-option-${model}`}
313-
className={
314-
model === "Auto" ? "border-b border-vscode-dropdown-border" : ""
315-
}>
316-
<span className="truncate" title={model}>
317-
{model}
318-
</span>
319-
<Check
307+
{modelIds.map((model) => {
308+
const modelInfo = models?.[model]
309+
const creditConsumption = modelInfo?.creditConsumption
310+
const creditDiscount = modelInfo?.creditDiscount
311+
312+
return (
313+
<CommandItem
314+
key={model}
315+
value={model}
316+
onSelect={onSelect}
317+
data-testid={`model-option-${model}`}
320318
className={cn(
321-
"size-4 p-0.5 ml-auto",
322-
model === selectedModelId ? "opacity-100" : "opacity-0",
323-
)}
324-
/>
325-
</CommandItem>
326-
))}
319+
model === "Auto" ? "border-b border-vscode-dropdown-border" : "",
320+
)}>
321+
<Check
322+
className={cn(
323+
"size-4 p-0.5",
324+
model === selectedModelId ? "opacity-100" : "opacity-0",
325+
)}
326+
/>
327+
<span className="truncate" title={model}>
328+
{model}
329+
</span>
330+
{model === "Auto"
331+
? creditDiscount && (
332+
<span
333+
className="ml-auto text-xs text-vscode-foreground bg-vscode-statusBarItem-prominentBackground px-1.5 py-0.5 rounded border border-vscode-button-border"
334+
title={t("settings:autoMode.discountTitle")}>
335+
{t("settings:autoMode.discount", {
336+
discount: `🎯 ${creditDiscount * 100}%`,
337+
})}
338+
</span>
339+
)
340+
: creditConsumption &&
341+
creditConsumption !== -1 && (
342+
<span
343+
className="ml-auto text-sm text-vscode-descriptionForeground"
344+
title={t("settings:autoMode.consumptionTitle")}>
345+
{creditConsumption}x credit
346+
</span>
347+
)}
348+
</CommandItem>
349+
)
350+
})}
327351
</CommandGroup>
328352
</CommandList>
329353
{searchValue && !modelIds.includes(searchValue) && (

webview-ui/src/components/settings/ProviderRenderer.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,15 @@ const ProviderRenderer: React.FC<ProviderRendererProps> = ({
5353
switch (message.type) {
5454
case "zgsmModels": {
5555
const updatedModels = message.openAiModels ?? []
56-
setOpenAiModels(Object.fromEntries(updatedModels.map((item) => [item, zgsmModels.default])))
56+
const { fullResponseData = [] } = message
57+
setOpenAiModels(
58+
Object.fromEntries(
59+
updatedModels.map((item) => [
60+
item,
61+
fullResponseData.find((itm) => itm.id === item) || zgsmModels.default,
62+
]),
63+
),
64+
)
5765
break
5866
}
5967
case "openAiModels": {

webview-ui/src/i18n/costrict-i18n/locales/en/chat.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,5 +125,9 @@
125125
"description": "Generates executable test solutions based on analysis of project code.",
126126
"initPrompt": "Generate test solution for the current project"
127127
}
128+
},
129+
"autoMode": {
130+
"selectedLlm": "Select model: {{selectedLlm}}",
131+
"selectReason": "Select reason: {{selectReason}}"
128132
}
129133
}

webview-ui/src/i18n/costrict-i18n/locales/en/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,5 +201,10 @@
201201
"failedToDisableCodebaseIndex": "Failed to disable codebase index:",
202202
"failedToCopyToClipboard": "Failed to copy to clipboard:"
203203
}
204+
},
205+
"autoMode": {
206+
"discountTitle": "auto模式折扣",
207+
"discount": "{{discount}} 折扣",
208+
"consumptionTitle": "使用该模型消耗的credit"
204209
}
205210
}

0 commit comments

Comments
 (0)