-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvscode-lm.ts
More file actions
1083 lines (996 loc) · 40.4 KB
/
Copy pathvscode-lm.ts
File metadata and controls
1083 lines (996 loc) · 40.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import OpenAI from "openai"
import { v7 as uuidv7 } from "uuid"
import { getHost, type ModelInfo, openAiModelInfoSaneDefaults } from "@shofer/types"
import type { ApiHandlerOptions } from "@shofer/core"
import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils"
import { normalizeToolSchema } from "@shofer/core"
import { ApiStream } from "@shofer/core"
import { convertToVsCodeLmMessages, extractTextCountFromMessage } from "../transform/vscode-lm-format"
import { BaseProvider } from "@shofer/core"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "@shofer/core"
import { apiLog } from "@shofer/core"
import { stringifyForLog } from "@shofer/core"
/**
* Converts OpenAI-format tools to VSCode Language Model tools.
* Normalizes the JSON Schema to draft 2020-12 compliant format required by
* GitHub Copilot's backend, converting type: ["T", "null"] to anyOf format.
* @param tools Array of OpenAI ChatCompletionTool definitions
* @returns Array of VSCode LanguageModelChatTool definitions
*/
function convertToVsCodeLmTools(tools: OpenAI.Chat.ChatCompletionTool[]): vscode.LanguageModelChatTool[] {
return tools
.filter((tool) => tool.type === "function")
.map((tool) => ({
name: tool.function.name,
description: tool.function.description || "",
inputSchema: tool.function.parameters
? normalizeToolSchema(tool.function.parameters as Record<string, unknown>)
: undefined,
}))
}
/**
* Handles interaction with VS Code's Language Model API for chat-based operations.
* This handler extends BaseProvider to provide VS Code LM specific functionality.
*
* @extends {BaseProvider}
*
* @remarks
* The handler manages a VS Code language model chat client and provides methods to:
* - Create and manage chat client instances
* - Stream messages using VS Code's Language Model API
* - Retrieve model information
*
* @example
* ```typescript
* const options = {
* vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4" }
* };
* const handler = new VsCodeLmHandler(options);
*
* // Stream a conversation
* const systemPrompt = "You are a helpful assistant";
* const messages = [{ role: "user", content: "Hello!" }];
* for await (const chunk of handler.createMessage(systemPrompt, messages)) {
* console.log(chunk);
* }
* ```
*/
export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHandler {
/**
* Latches so we only warn once per session when the llmLocalRouter.*
* commands are missing (e.g. the llm-provider extension isn't
* installed or its command names don't match).
*/
private static _warnedMissingPricing = false
private static _warnedMissingCapabilities = false
private static _warnedMissingRequestCost = false
/**
* Whether the user has opted into llm-provider integration via the
* `shofer.enableLlmProviderIntegration` setting. Cached on first read
* to avoid repeated config lookups during streaming.
*/
private static _llmProviderIntegrationEnabled: boolean | undefined
private static isLlmProviderIntegrationEnabled(): boolean {
if (VsCodeLmHandler._llmProviderIntegrationEnabled === undefined) {
VsCodeLmHandler._llmProviderIntegrationEnabled =
getHost().config.get<boolean>("shofer", "enableLlmProviderIntegration", false) ?? false
}
return VsCodeLmHandler._llmProviderIntegrationEnabled
}
protected options: ApiHandlerOptions
private client: vscode.LanguageModelChat | null
private disposable: vscode.Disposable | null
private currentRequestCancellation: vscode.CancellationTokenSource | null
private taskId: string
private parentTaskId: string | undefined
private rootTaskId: string | undefined
/**
* Pricing in USD per 1M tokens for the currently selected model, when
* known. Populated asynchronously after `initializeClient` by querying
* the well-known `llmLocalRouter.getModelPricing` command exposed by the
* LLM Local Router extension. The VS Code LM Chat API itself
* carries no pricing fields, so without this side channel `getModel()`
* would have to keep returning `inputPrice: 0`/`outputPrice: 0`, which
* makes Shofer's downstream `calculateApiCostOpenAI` produce `0` and the
* task header's `apiCost` row never render. Stays `undefined` for
* non-shofer vendors, in which case behaviour is unchanged.
*/
private shoferPricing:
| {
inputPrice: number
outputPrice: number
cacheReadsPrice?: number
cacheWritesPrice?: number
}
| undefined
/**
* Capability flags for the active client's model, fetched from the
* LLM Local Router extension via the well-known
* `llmLocalRouter.getModelCapabilities` command. The VS Code LM Chat API's
* `LanguageModelChatProviderCapabilities` only models `imageInput` and
* `toolCalling`, with no slot for prompt-cache support — and even those
* two we prefer to source from llm-router's registry rather than rely on
* VS Code's own capability surface, which is the single source of truth
* for model capabilities across the stack. Stays `undefined` until the
* async refresh completes or for non-shofer vendors.
*/
private shoferCapabilities: shoferLmCapabilities | undefined
constructor(options: ApiHandlerOptions) {
super()
this.options = options
this.client = null
this.disposable = null
this.currentRequestCancellation = null
// Use taskId from options if provided, otherwise generate a fallback UUID
this.taskId = options.taskId ?? uuidv7()
this.parentTaskId = options.parentTaskId
this.rootTaskId = options.rootTaskId
try {
// Listen for model changes and reset client
this.disposable = vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration("lm")) {
try {
this.client = null
this.ensureCleanState()
} catch (error) {
apiLog.error(`Error during configuration change cleanup: ${error}`)
}
}
})
this.initializeClient()
} catch (error) {
// Ensure cleanup if constructor fails
this.dispose()
throw new Error(
`Shofer <Language Model API>: Failed to initialize handler: ${error instanceof Error ? error.message : "Unknown error"}`,
)
}
}
/**
* Initializes the VS Code Language Model client.
* This method is called during the constructor to set up the client.
* This useful when the client is not created yet and call getModel() before the client is created.
* @returns Promise<void>
* @throws Error when client initialization fails
*/
async initializeClient(): Promise<void> {
try {
// Check if the client is already initialized
if (this.client) {
apiLog.info("Shofer <Language Model API>: Client already initialized")
return
}
// Create a new client instance
this.client = await this.createClient(this.options.vsCodeLmModelSelector || {})
// Best-effort prefetch of pricing and capabilities for the selected
// model. Failures are non-fatal: non-shofer setups simply leave
// these unset (consumers fall back to conservative defaults).
void this.refreshShoferPricing()
void this.refreshShoferCapabilities()
} catch (error) {
// Handle errors during client initialization
const errorMessage = error instanceof Error ? error.message : "Unknown error"
apiLog.error(`Shofer <Language Model API>: Client initialization failed: ${errorMessage}`)
throw new Error(`Shofer <Language Model API>: Failed to initialize client: ${errorMessage}`)
}
}
/**
* Look up pricing for the active client's model via the Shofer LLM
* Model Provider extension's well-known command. Tries the bare model id
* first and falls back to the slash-free `family` identifier so the
* provider can resolve either form. Sets `this.shoferPricing` on
* success; silently leaves it untouched on miss/failure.
*/
private async refreshShoferPricing(): Promise<void> {
if (!VsCodeLmHandler.isLlmProviderIntegrationEnabled()) return
if (!this.client) return
const candidates = [this.client.id, this.client.family].filter(
(s): s is string => typeof s === "string" && s.length > 0,
)
for (const candidate of candidates) {
try {
const pricing = await vscode.commands.executeCommand<
| { inputPrice: number; outputPrice: number; cacheReadsPrice?: number; cacheWritesPrice?: number }
| undefined
>("llmLocalRouter.getModelPricing", candidate)
if (pricing && (pricing.inputPrice > 0 || pricing.outputPrice > 0)) {
this.shoferPricing = pricing
return
}
} catch {
// Command not registered (no shofer extension) or threw — log once.
if (!VsCodeLmHandler._warnedMissingPricing) {
VsCodeLmHandler._warnedMissingPricing = true
apiLog.warn(
"[vscode-lm] llmLocalRouter.getModelPricing command not found — is the LLM Local Router extension installed and active?",
)
}
}
}
}
/**
* Look up capability flags for the active client's model via the
* LLM Local Router extension's well-known
* `llmLocalRouter.getModelCapabilities` command. Mirrors
* {@link refreshShoferPricing} in identifier-resolution strategy. Sets
* `this.shoferCapabilities` on success; silently leaves it untouched on
* miss/failure (e.g. when the shofer extension isn't installed).
*/
private async refreshShoferCapabilities(): Promise<void> {
if (!VsCodeLmHandler.isLlmProviderIntegrationEnabled()) return
if (!this.client) return
const candidates = [this.client.id, this.client.family].filter(
(s): s is string => typeof s === "string" && s.length > 0,
)
for (const candidate of candidates) {
try {
const caps = await vscode.commands.executeCommand<shoferLmCapabilities | undefined>(
"llmLocalRouter.getModelCapabilities",
candidate,
)
if (caps) {
this.shoferCapabilities = caps
return
}
} catch {
// Command not registered (no shofer extension) or threw — log once.
if (!VsCodeLmHandler._warnedMissingCapabilities) {
VsCodeLmHandler._warnedMissingCapabilities = true
apiLog.warn(
"[vscode-lm] llmLocalRouter.getModelCapabilities command not found — is the LLM Local Router extension installed and active?",
)
}
}
}
}
/**
* Pull the running USD cost for `this.taskId` from the Shofer
* LLM Model Provider extension via the well-known
* `llmLocalRouter.getRequestCost` command. Returns `undefined` when the
* command isn't registered (no shofer extension), when the provider
* has no cost data for this conversation (e.g. no completion has
* routed through a model whose pricing the router can compute), or on
* any error. Caller should treat `undefined` as "fall back to
* per-token math".
*
* This is the canonical cost source for composite (`shofer/*`)
* models, where the underlying serving model is picked at request
* time and `getModel().info.inputPrice` is therefore zero — making
* Shofer's downstream `calculateApiCostOpenAI` produce $0 and the cost
* row never render.
*/
private async fetchShoferRequestCost(): Promise<number | undefined> {
if (!VsCodeLmHandler.isLlmProviderIntegrationEnabled()) return undefined
if (!this.taskId) return undefined
try {
const cost = await vscode.commands.executeCommand<number | undefined>(
"llmLocalRouter.getRequestCost",
this.taskId,
)
if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) {
return cost
}
return undefined
} catch {
// Command not registered or threw — log once per session.
if (!VsCodeLmHandler._warnedMissingRequestCost) {
VsCodeLmHandler._warnedMissingRequestCost = true
apiLog.warn(
"[vscode-lm] llmLocalRouter.getRequestCost command not found — is the LLM Local Router extension installed and active?",
)
}
return undefined
}
}
/**
* Creates a language model chat client based on the provided selector.
*
* @param selector - Selector criteria to filter language model chat instances
* @returns Promise resolving to the first matching language model chat instance
* @throws Error when no matching models are found with the given selector
*
* @example
* const selector = { vendor: "copilot", family: "gpt-4o" };
* const chatClient = await createClient(selector);
*/
async createClient(selector: vscode.LanguageModelChatSelector): Promise<vscode.LanguageModelChat> {
try {
const models = await vscode.lm.selectChatModels(selector)
// Use first available model.
if (models && Array.isArray(models) && models.length > 0) {
return models[0]
}
// No model matched the selector. Returning a stub model here used to
// yield a canned "functionality is limited" message with no tool
// calls, which the agent loop then rejected forever ("you did not use
// a tool"). Surface the real problem instead so the cause is visible.
const selectorDesc = JSON.stringify(selector)
throw new Error(
`Shofer <Language Model API>: No language model matched selector ${selectorDesc}. ` +
"The model may not be registered (reload the VS Code window) or its provider may " +
"have no API key configured.",
)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
throw new Error(`Shofer <Language Model API>: Failed to select model: ${errorMessage}`)
}
}
/**
* Creates and streams a message using the VS Code Language Model API.
*
* @param systemPrompt - The system prompt to initialize the conversation context
* @param messages - An array of message parameters following the Anthropic message format
* @param metadata - Optional metadata for the message
*
* @yields {ApiStream} An async generator that yields either text chunks or tool calls from the model response
*
* @throws {Error} When vsCodeLmModelSelector option is not provided
* @throws {Error} When the response stream encounters an error
*
* @remarks
* This method handles the initialization of the VS Code LM client if not already created,
* converts the messages to VS Code LM format, and streams the response chunks.
* Tool calls handling is currently a work in progress.
*/
dispose(): void {
if (this.disposable) {
this.disposable.dispose()
}
if (this.currentRequestCancellation) {
this.currentRequestCancellation.cancel()
this.currentRequestCancellation.dispose()
}
}
/**
* Implements the ApiHandler countTokens interface method
* Provides token counting for Anthropic content blocks
*
* @param content The content blocks to count tokens for
* @returns A promise resolving to the token count
*/
override async countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number> {
// §4.5: build the text via a string-ref array + single join, not
// repeated `+=` reallocations. The array is short-lived and never
// surfaces past this function.
const parts: string[] = []
for (const block of content) {
if (block.type === "text") {
parts.push(block.text || "")
} else if (block.type === "image") {
// VSCode LM doesn't support images directly, so we'll just use a placeholder
parts.push("[IMAGE]")
}
}
return this.internalCountTokens(parts.join(""))
}
/**
* Private implementation of token counting used internally by VsCodeLmHandler
*/
private async internalCountTokens(text: string | vscode.LanguageModelChatMessage): Promise<number> {
// Check for required dependencies
if (!this.client) {
apiLog.warn("Shofer <Language Model API>: No client available for token counting")
return 0
}
// Validate input
if (!text) {
apiLog.info("Shofer <Language Model API>: Empty text provided for token counting")
return 0
}
// Create a temporary cancellation token if we don't have one (e.g., when called outside a request)
let cancellationToken: vscode.CancellationToken
let tempCancellation: vscode.CancellationTokenSource | null = null
if (this.currentRequestCancellation) {
cancellationToken = this.currentRequestCancellation.token
} else {
tempCancellation = new vscode.CancellationTokenSource()
cancellationToken = tempCancellation.token
}
try {
// Handle different input types
let tokenCount: number
if (typeof text === "string") {
tokenCount = await this.client.countTokens(text, cancellationToken)
} else if (text instanceof vscode.LanguageModelChatMessage) {
// For chat messages, ensure we have content
if (!text.content || (Array.isArray(text.content) && text.content.length === 0)) {
apiLog.info("Shofer <Language Model API>: Empty chat message content")
return 0
}
const countMessage = extractTextCountFromMessage(text)
tokenCount = await this.client.countTokens(countMessage, cancellationToken)
} else {
apiLog.warn("Shofer <Language Model API>: Invalid input type for token counting")
return 0
}
// Validate the result
if (typeof tokenCount !== "number") {
apiLog.warn(`Shofer <Language Model API>: Non-numeric token count received: ${tokenCount}`)
return 0
}
if (tokenCount < 0) {
apiLog.warn(`Shofer <Language Model API>: Negative token count received: ${tokenCount}`)
return 0
}
return tokenCount
} catch (error) {
// Handle specific error types
if (error instanceof vscode.CancellationError) {
apiLog.info("Shofer <Language Model API>: Token counting cancelled by user")
return 0
}
const errorMessage = error instanceof Error ? error.message : "Unknown error"
apiLog.warn(`Shofer <Language Model API>: Token counting failed: ${errorMessage}`)
// Log additional error details if available
if (error instanceof Error && error.stack) {
apiLog.info("Token counting error stack:", error.stack)
}
return 0 // Fallback to prevent stream interruption
} finally {
// Clean up temporary cancellation token
if (tempCancellation) {
tempCancellation.dispose()
}
}
}
private async calculateTotalInputTokens(vsCodeLmMessages: vscode.LanguageModelChatMessage[]): Promise<number> {
const messageTokens: number[] = await Promise.all(vsCodeLmMessages.map((msg) => this.internalCountTokens(msg)))
return messageTokens.reduce((sum: number, tokens: number): number => sum + tokens, 0)
}
private ensureCleanState(): void {
if (this.currentRequestCancellation) {
this.currentRequestCancellation.cancel()
this.currentRequestCancellation.dispose()
this.currentRequestCancellation = null
}
}
private async getClient(): Promise<vscode.LanguageModelChat> {
if (!this.client) {
apiLog.info("Shofer <Language Model API>: Getting client with options:", {
vsCodeLmModelSelector: this.options.vsCodeLmModelSelector,
hasOptions: !!this.options,
selectorKeys: this.options.vsCodeLmModelSelector ? Object.keys(this.options.vsCodeLmModelSelector) : [],
})
try {
// Use default empty selector if none provided to get all available models
const selector = this.options?.vsCodeLmModelSelector || {}
apiLog.info("Shofer <Language Model API>: Creating client with selector:", selector)
this.client = await this.createClient(selector)
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"
apiLog.error(`Shofer <Language Model API>: Client creation failed: ${message}`)
throw new Error(`Shofer <Language Model API>: Failed to create client: ${message}`)
}
}
return this.client
}
private cleanMessageContent(content: any): any {
if (!content) {
return content
}
if (typeof content === "string") {
return content
}
if (Array.isArray(content)) {
return content.map((item) => this.cleanMessageContent(item))
}
if (typeof content === "object") {
const cleaned: any = {}
for (const [key, value] of Object.entries(content)) {
cleaned[key] = this.cleanMessageContent(value)
}
return cleaned
}
return content
}
override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
// Ensure clean state before starting a new request
this.ensureCleanState()
const client: vscode.LanguageModelChat = await this.getClient()
// Process messages
const cleanedMessages = messages.map((msg) => ({
...msg,
content: this.cleanMessageContent(msg.content),
}))
// Convert Anthropic messages to VS Code LM messages
// Note: systemPrompt is passed via modelOptions since VS Code LM API lacks System role
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = convertToVsCodeLmMessages(cleanedMessages)
// Initialize cancellation token for the request
this.currentRequestCancellation = new vscode.CancellationTokenSource()
// Snapshot the per-conversation cumulative cost ledger BEFORE the
// request so we can yield a per-request delta after the stream
// completes. The Shofer LLM Model Provider's
// `llmLocalRouter.getRequestCost` returns a running cumulative across
// the whole conversation; if we yielded that as the chunk's
// `totalCost`, Shofer would then store it on each `apiReqInfo` message
// and re-sum across messages in `consolidateTokenUsage`, multiplying
// the spend by O(N²) and silently breaking the cost-cap math.
const conversationCostUsdBefore = await this.fetchShoferRequestCost()
// Calculate input tokens before starting the stream
const totalInputTokens: number = await this.calculateTotalInputTokens(vsCodeLmMessages)
// §4.5: collect emitted text + tool-call arguments into an array and join
// once at stream end, avoiding O(n²) string reallocation per chunk for
// long responses. Only used for final token counting and Xiaomi logging.
const accumulatedChunks: string[] = []
// Provider-reported token counts from the response_metadata marker
// emitted by shofer-router at stream end. These are authoritative
// (real upstream token counts) and replace the coarse char/4
// heuristic that internalCountTokens falls back to.
let metadataPromptTokens: number | undefined
let metadataCompletionTokens: number | undefined
try {
// Create the response stream with required options
// systemPrompt is passed in modelOptions for llm-provider to extract and forward
// as a proper System role message to llm-router
const { info: modelInfo } = this.getModel()
const maxTokens =
this.options.modelMaxTokens ||
(modelInfo.maxTokens && modelInfo.maxTokens > 0 ? modelInfo.maxTokens : undefined)
const requestOptions: vscode.LanguageModelChatRequestOptions = {
justification: `Shofer would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`,
tools: convertToVsCodeLmTools(metadata?.tools ?? []),
modelOptions: {
taskId: this.taskId,
...(this.parentTaskId && { parentTaskId: this.parentTaskId }),
...(this.rootTaskId && { rootTaskId: this.rootTaskId }),
systemPrompt,
...(maxTokens && { maxTokens }),
},
}
// Check if this is a Xiaomi model for enhanced logging
const isXiaomiModel =
client.name.toLowerCase().includes("mimo") || client.name.toLowerCase().includes("xiaomi")
if (isXiaomiModel) {
const logMsg = `[XIAOMI] Shofer sending request via vscode-lm: ${JSON.stringify(
{
model: client.name,
vendor: client.vendor,
maxTokens,
messages_count: vsCodeLmMessages.length,
tools_count: metadata?.tools?.length ?? 0,
systemPrompt_length: systemPrompt?.length ?? 0,
taskId: this.taskId,
},
null,
2,
)}`
apiLog.debug(logMsg)
}
const response: vscode.LanguageModelChatResponse = await client.sendRequest(
vsCodeLmMessages,
requestOptions,
this.currentRequestCancellation.token,
)
// Consume the stream and handle text, thinking, and tool call chunks
for await (const chunk of response.stream) {
// Log chunk type for debugging
if (isXiaomiModel) {
const chunkType =
chunk instanceof vscode.LanguageModelTextPart
? "LanguageModelTextPart"
: chunk instanceof vscode.LanguageModelToolCallPart
? "LanguageModelToolCallPart"
: "Unknown"
apiLog.debug(`[XIAOMI] [vscode-lm] Received chunk type: ${chunkType}`)
}
if (chunk instanceof vscode.LanguageModelTextPart) {
// Validate text part value
if (typeof chunk.value !== "string") {
apiLog.warn(`Shofer <Language Model API>: Invalid text part value received: ${chunk.value}`)
}
accumulatedChunks.push(chunk.value)
yield {
type: "text",
text: chunk.value,
}
} else if (chunk instanceof vscode.LanguageModelToolCallPart) {
try {
// Log tool call details for Xiaomi
if (isXiaomiModel) {
apiLog.debug(
`[XIAOMI] [vscode-lm] Received LanguageModelToolCallPart: name=${chunk.name}, callId=${chunk.callId}, input=${stringifyForLog(chunk.input)}`,
)
}
// Validate tool call parameters
if (!chunk.name || typeof chunk.name !== "string") {
apiLog.warn(`Shofer <Language Model API>: Invalid tool name received: ${chunk.name}`)
continue
}
if (!chunk.callId || typeof chunk.callId !== "string") {
apiLog.warn(`Shofer <Language Model API>: Invalid tool callId received: ${chunk.callId}`)
continue
}
// Ensure input is a valid object
if (!chunk.input || typeof chunk.input !== "object") {
apiLog.warn(`Shofer <Language Model API>: Invalid tool input received: ${chunk.input}`)
}
// Yield native tool_call chunk when tools are provided
if (metadata?.tools?.length) {
const argumentsString = JSON.stringify(chunk.input)
accumulatedChunks.push(argumentsString)
if (isXiaomiModel) {
apiLog.debug(
`[XIAOMI] [vscode-lm] Yielding tool_call: id=${chunk.callId}, name=${chunk.name}, args=${argumentsString}`,
)
}
yield {
type: "tool_call",
id: chunk.callId,
name: chunk.name,
arguments: argumentsString,
}
} else {
if (isXiaomiModel) {
apiLog.debug(`[XIAOMI] [vscode-lm] NOT yielding tool_call - no tools in metadata`)
}
}
} catch (error) {
apiLog.error(`Shofer <Language Model API>: Failed to process tool call: ${error}`)
// Continue processing other chunks even if one fails
continue
}
} else {
// Handle thinking/reasoning content from models like mimo-v2-pro.
// LanguageModelThinkingPart is not in current VSCode types; we treat
// any non-text, non-tool-call chunk as thinking content.
const value = (chunk as { value?: unknown }).value
if (typeof value === "string" && value.trim()) {
// Detect structured markers emitted by llm-provider.
// Null-byte delimiter prevents false positives from real thinking text.
// eslint-disable-next-line no-control-regex
const preparingMatch = value.match(/^\x00tool_preparing\x00([^\x00]+)\x00(\d+)\x00$/)
if (preparingMatch) {
yield {
type: "tool_preparing",
toolName: preparingMatch[1],
byteCount: parseInt(preparingMatch[2], 10),
}
// eslint-disable-next-line no-control-regex
} else if (/^\x00response_metadata\x00/.test(value)) {
// Response metadata marker from llm-provider.
// Format: \x00response_metadata\x00<json>\x00
// Contains: model, actualModel, ttfbMs, ttlbMs,
// promptTokens, completionTokens, costUsd, attempts.
// Emitted once at stream end; yielded as a chunk
// so Task.ts can surface it in the UI.
// eslint-disable-next-line no-control-regex
const jsonStr = value.replace(/^\x00response_metadata\x00/, "").replace(/\x00$/, "")
try {
const meta = JSON.parse(jsonStr)
// Capture provider-reported token counts from
// shofer-router so the final usage chunk
// carries authoritative numbers instead of
// the coarse char/4 heuristic.
if (typeof meta.promptTokens === "number" && meta.promptTokens > 0) {
metadataPromptTokens = meta.promptTokens
}
if (typeof meta.completionTokens === "number" && meta.completionTokens > 0) {
metadataCompletionTokens = meta.completionTokens
}
yield {
type: "response_metadata",
model: meta.model,
actualModel: meta.actualModel,
ttfbMs: meta.ttfbMs,
ttlbMs: meta.ttlbMs,
promptTokens: meta.promptTokens,
completionTokens: meta.completionTokens,
costUsd: meta.costUsd,
attempts: meta.attempts,
error: meta.error,
}
} catch {
// Malformed metadata — silently ignore.
}
} else {
yield {
type: "reasoning",
text: value,
}
}
} else {
apiLog.warn(
`Shofer <Language Model API>: Unknown chunk type received: ${stringifyForLog(chunk)}`,
)
}
}
}
// Count tokens in the accumulated text after stream completion
const accumulatedText = accumulatedChunks.join("")
const totalOutputTokens: number = await this.internalCountTokens(accumulatedText)
// Log complete stream summary for Xiaomi models
if (isXiaomiModel) {
const logMsg = `[XIAOMI] Shofer stream complete: ${JSON.stringify(
{
model: client.name,
total_input_tokens: totalInputTokens,
total_output_tokens: totalOutputTokens,
accumulated_text_length: accumulatedText.length,
accumulated_text_preview: accumulatedText.slice(0, 500),
},
null,
2,
)}`
apiLog.debug(logMsg)
}
// Pull the per-conversation USD cost computed by llm-router and
// accumulated by the LLM Local Router extension. This
// is the only reliable cost source for composite (`shofer/*`)
// models, where the underlying that served the request is
// selected at request time and `getModel().info.inputPrice` is
// therefore zero. We compare against the pre-request snapshot
// taken above and yield the DELTA as `totalCost` (= the
// per-request cost), so Shofer's per-message accounting and the
// consolidate-then-sum pipeline don't double-count.
const conversationCostUsdAfter = await this.fetchShoferRequestCost()
let perRequestCostUsd: number | undefined
if (conversationCostUsdAfter !== undefined) {
const before = conversationCostUsdBefore ?? 0
const delta = conversationCostUsdAfter - before
// Guard against ledger-eviction or out-of-order updates that
// could produce a negative delta; clamp to zero rather than
// emit a nonsensical refund.
perRequestCostUsd = delta >= 0 ? delta : 0
}
// Use provider-reported output tokens when available (from
// the response_metadata marker). Falls back to the char/4
// heuristic via internalCountTokens when the metadata chunk
// was missing or carried zero values.
const effectiveOutputTokens =
metadataCompletionTokens !== undefined && metadataCompletionTokens > 0
? metadataCompletionTokens
: totalOutputTokens
const effectiveInputTokens =
metadataPromptTokens !== undefined && metadataPromptTokens > 0 ? metadataPromptTokens : totalInputTokens
// Report final usage after stream completion
yield {
type: "usage",
inputTokens: effectiveInputTokens,
outputTokens: effectiveOutputTokens,
...(perRequestCostUsd !== undefined ? { totalCost: perRequestCostUsd } : {}),
}
} catch (error: unknown) {
this.ensureCleanState()
if (error instanceof vscode.CancellationError) {
throw new Error("Shofer <Language Model API>: Request cancelled by user")
}
if (error instanceof Error) {
apiLog.error(
`Shofer <Language Model API>: Stream error details: ${JSON.stringify({ message: error.message, stack: error.stack, name: error.name })}`,
)
// Return original error if it's already an Error instance
throw error
} else if (typeof error === "object" && error !== null) {
// Handle error-like objects
const errorDetails = JSON.stringify(error, null, 2)
apiLog.error(`Shofer <Language Model API>: Stream error object: ${errorDetails}`)
throw new Error(`Shofer <Language Model API>: Response stream error: ${errorDetails}`)
} else {
// Fallback for unknown error types
const errorMessage = String(error)
apiLog.error(`Shofer <Language Model API>: Unknown stream error: ${errorMessage}`)
throw new Error(`Shofer <Language Model API>: Response stream error: ${errorMessage}`)
}
}
}
// Return model information based on the current client state
override getModel(): { id: string; info: ModelInfo } {
if (this.client) {
// Validate client properties
const requiredProps = {
id: this.client.id,
vendor: this.client.vendor,
family: this.client.family,
version: this.client.version,
maxInputTokens: this.client.maxInputTokens,
}
// Log any missing properties for debugging
for (const [prop, value] of Object.entries(requiredProps)) {
if (!value && value !== 0) {
apiLog.warn(`Shofer <Language Model API>: Client missing ${prop} property`)
}
}
// Construct model ID using available information
const modelParts = [this.client.vendor, this.client.family, this.client.version].filter(Boolean)
const modelId = this.client.id || modelParts.join(SELECTOR_SEPARATOR)
// Build model info with conservative defaults for missing values
const modelInfo: ModelInfo = {
maxTokens: -1, // Unlimited tokens by default
// Context window must come from llm-router (via
// `client.maxInputTokens`). Falling back to a static default
// silently corrupts condensation/truncation math when the model
// supports far more (e.g. 1M-token deepseek-v4-pro), so leave it
// at 0 if the upstream value is missing — consumers will surface
// the misconfiguration instead of hiding it behind a 128K guess.
contextWindow:
typeof this.client.maxInputTokens === "number" ? Math.max(0, this.client.maxInputTokens) : 0,
// Capability flags are sourced from llm-router's model registry
// via the shofer side-channel. Conservative `false` default
// applies only when the side channel is unavailable.
supportsImages: this.shoferCapabilities?.imageInput ?? false,
supportsPromptCache: this.shoferCapabilities?.promptCache ?? false,
inputPrice: this.shoferPricing?.inputPrice ?? 0,
outputPrice: this.shoferPricing?.outputPrice ?? 0,
...(this.shoferPricing?.cacheReadsPrice !== undefined && {
cacheReadsPrice: this.shoferPricing.cacheReadsPrice,
}),
...(this.shoferPricing?.cacheWritesPrice !== undefined && {
cacheWritesPrice: this.shoferPricing.cacheWritesPrice,
}),
// Per-model native-tool preferences (integrator-owned), sourced from
// shofer-router's registry via the capabilities side-channel.
...(this.shoferCapabilities?.includedTools?.length && {
includedTools: this.shoferCapabilities.includedTools,
}),
...(this.shoferCapabilities?.excludedTools?.length && {
excludedTools: this.shoferCapabilities.excludedTools,
}),
description: `VSCode Language Model: ${modelId}`,
}
return { id: modelId, info: modelInfo }
}
// Fallback when no client is available
const fallbackId = this.options.vsCodeLmModelSelector
? stringifyVsCodeLmModelSelector(this.options.vsCodeLmModelSelector)
: "vscode-lm"
console.debug("Shofer <Language Model API>: No client available, using fallback model info")
return {
id: fallbackId,
info: {
...openAiModelInfoSaneDefaults,
description: `VSCode Language Model (Fallback): ${fallbackId}`,
},
}
}
async completePrompt(prompt: string): Promise<string> {
try {
const client = await this.getClient()
const response = await client.sendRequest(
[vscode.LanguageModelChatMessage.User(prompt)],
{
modelOptions: {
taskId: this.taskId,
...(this.parentTaskId && { parentTaskId: this.parentTaskId }),
...(this.rootTaskId && { rootTaskId: this.rootTaskId }),
},
},
new vscode.CancellationTokenSource().token,
)
// §4.5: chunks array + join() at end (one allocation) instead of
// per-chunk `result += ...` string reallocation.
const resultChunks: string[] = []
for await (const chunk of response.stream) {
if (chunk instanceof vscode.LanguageModelTextPart) {
resultChunks.push(chunk.value)
}
}
return resultChunks.join("")
} catch (error) {
if (error instanceof Error) {
throw new Error(`VSCode LM completion error: ${error.message}`)
}
throw error
}
}
}
// Static blacklist of VS Code Language Model IDs that should be excluded from the model list e.g. because they will never work
const VSCODE_LM_STATIC_BLACKLIST: string[] = ["claude-3.7-sonnet", "claude-3.7-sonnet-thought"]
/**
* Capability flags exposed by the LLM Local Router extension via
* the `llmLocalRouter.getModelCapabilities` side-channel command. Mirrors the
* shape of llm-router's `/v1/models` `capabilities` block.
*/
export interface shoferLmCapabilities {
imageInput: boolean
toolCalling: boolean
promptCache: boolean
// Per-model native-tool preferences (integrator-owned) carried over the
// side-channel from shofer-router's model registry. Mapped onto
// ModelInfo.includedTools / excludedTools in getModel().
includedTools?: string[]
excludedTools?: string[]
}
/**
* Pricing flags exposed by the LLM Local Router extension via the
* `llmLocalRouter.getModelPricing` side-channel command. USD per 1M tokens.
*/
export interface shoferLmPricing {
inputPrice: number
outputPrice: number
cacheReadsPrice?: number
cacheWritesPrice?: number
}
/**
* Shape returned to the webview for each VS Code LM model. We can't extend
* `vscode.LanguageModelChat` (it's a frozen interface), so we project the
* subset the UI needs and attach Shofer-only fields (`shoferCapabilities`,
* `shoferPricing`) sourced from the side-channel commands. The webview
* relies on these to render capability/pricing facts without hardcoded
* assumptions.