Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,18 @@ export const globalSettingsSchema = z.object({
* Tools in this list will be excluded from prompt generation and rejected at execution time.
*/
disabledTools: z.array(toolNamesSchema).optional(),

/**
* FIM (Fill-in-the-Middle) inline code completion settings.
* These are configured separately from the chat model to allow using
* a cheap/fast FIM-specialized model (e.g., DeepSeek Coder, Codestral).
*/
fimEnabled: z.boolean().optional(),
fimProvider: z.enum(["openai-compatible", "deepseek", "mistral", "ollama"]).optional(),
fimModelId: z.string().optional(),
fimBaseUrl: z.string().optional(),
fimDebounceMs: z.number().min(0).optional(),
fimMaxTokens: z.number().min(1).optional(),
})

export type GlobalSettings = z.infer<typeof globalSettingsSchema>
Expand Down Expand Up @@ -285,6 +297,7 @@ export const SECRET_STATE_KEYS = [
// Global secrets that are part of GlobalSettings (not ProviderSettings)
export const GLOBAL_SECRET_KEYS = [
"openRouterImageApiKey", // For image generation
"fimApiKey", // For FIM inline code completion
] as const

// Type for the actual secret storage keys
Expand Down
16 changes: 16 additions & 0 deletions src/__mocks__/vscode.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const languages = {
clear: () => {},
dispose: () => {},
}),
registerInlineCompletionItemProvider: () => mockDisposable,
}

export const extensions = {
Expand Down Expand Up @@ -152,6 +153,19 @@ export const CodeActionKind = {

export const EventEmitter = mockEventEmitter

export const InlineCompletionTriggerKind = {
Invoke: 0,
Automatic: 1,
}

export const InlineCompletionItem = class {
constructor(insertText, range, command) {
this.insertText = insertText
this.range = range
this.command = command
}
}

export default {
workspace,
window,
Expand All @@ -171,4 +185,6 @@ export default {
EventEmitter,
CodeAction,
CodeActionKind,
InlineCompletionTriggerKind,
InlineCompletionItem,
}
23 changes: 23 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth"
import { McpServerManager } from "./services/mcp/McpServerManager"
import { CodeIndexManager } from "./services/code-index/manager"
import { MdmService } from "./services/mdm/MdmService"
import { FimService } from "./services/fim"
import { migrateSettings } from "./utils/migrateSettings"
import { autoImportSettings } from "./utils/autoImportSettings"
import { API } from "./extension/api"
Expand Down Expand Up @@ -168,6 +169,28 @@ export async function activate(context: vscode.ExtensionContext) {

const contextProxy = await ContextProxy.getInstance(context)

// Initialize FIM (Fill-in-the-Middle) inline completion service.
const fimService = new FimService(outputChannel)
context.subscriptions.push(fimService)

// Initialize FIM with current settings
const initFimSettings = async () => {
const globalSettings = contextProxy.getGlobalSettings()
const fimApiKey = await context.secrets.get("fimApiKey")
fimService.updateSettings(globalSettings, fimApiKey)
}

void initFimSettings()

// Listen for secret storage changes to update FIM API key
context.secrets.onDidChange(async (e: vscode.SecretStorageChangeEvent) => {
if (e.key === "fimApiKey") {
const globalSettings = contextProxy.getGlobalSettings()
const fimApiKey = await context.secrets.get("fimApiKey")
fimService.updateSettings(globalSettings, fimApiKey)
}
})

// Initialize code index managers for all workspace folders.
const codeIndexManagers: CodeIndexManager[] = []

Expand Down
153 changes: 153 additions & 0 deletions src/services/fim/FimApiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Lightweight API client for FIM (Fill-in-the-Middle) completion requests.
*
* Supports the `/v1/completions` endpoint used by OpenAI-compatible servers,
* DeepSeek, Ollama, and similar providers. This is the legacy completions
* endpoint (not chat completions), which is better suited for raw FIM prompts.
*/

import { formatFimPrompt } from "./FimTokenFormatter"

export interface FimRequestOptions {
/** The FIM provider type */
provider: "openai-compatible" | "deepseek" | "mistral" | "ollama"
/** Base URL for the API endpoint */
baseUrl: string
/** API key for authentication */
apiKey?: string
/** Model ID to use */
modelId: string
/** Text before the cursor */
prefix: string
/** Text after the cursor */
suffix: string
/** Maximum tokens to generate */
maxTokens: number
/** Abort signal for cancellation */
signal?: AbortSignal
}

export interface FimResponse {
/** The generated completion text */
completion: string
}

/**
* Normalize a base URL by removing trailing slashes.
*/
function normalizeBaseUrl(url: string): string {
return url.replace(/\/+$/, "")
}

/**
* Build the API endpoint URL based on the provider type.
*/
function buildEndpointUrl(provider: string, baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl)

switch (provider) {
case "ollama":
return `${normalized}/api/generate`
case "mistral":
return `${normalized}/v1/fim/completions`
default:
// openai-compatible and deepseek use /v1/completions
return `${normalized}/v1/completions`
}
}

/**
* Build the request body based on the provider type.
*/
function buildRequestBody(options: FimRequestOptions): Record<string, unknown> {
const { provider, modelId, prefix, suffix, maxTokens } = options

switch (provider) {
case "ollama":
return {
model: modelId,
prompt: prefix,
suffix: suffix,
stream: false,
options: {
num_predict: maxTokens,
temperature: 0.2,
top_p: 0.9,
},
}
case "mistral":
return {
model: modelId,
prompt: prefix,
suffix: suffix,
max_tokens: maxTokens,
temperature: 0.2,
top_p: 0.9,
stop: ["\n\n"],
}
default: {
// openai-compatible and deepseek: format the FIM prompt with special tokens
const prompt = formatFimPrompt(modelId, prefix, suffix)
return {
model: modelId,
prompt,
max_tokens: maxTokens,
temperature: 0.2,
top_p: 0.9,
stop: ["\n\n", "<|fim", "<fim_", "[/MIDDLE]"],
}
}
}
}

/**
* Extract the completion text from the provider response.
*/
function extractCompletion(provider: string, data: Record<string, unknown>): string {
switch (provider) {
case "ollama": {
return (data.response as string) ?? ""
}
default: {
// OpenAI-compatible response format
const choices = data.choices as Array<{ text?: string; message?: { content?: string } }> | undefined
if (!choices || choices.length === 0) {
return ""
}
return choices[0].text ?? choices[0].message?.content ?? ""
}
}
}

/**
* Send a FIM completion request to the configured provider.
*/
export async function requestFimCompletion(options: FimRequestOptions): Promise<FimResponse> {
const url = buildEndpointUrl(options.provider, options.baseUrl)
const body = buildRequestBody(options)

const headers: Record<string, string> = {
"Content-Type": "application/json",
}

if (options.apiKey) {
headers["Authorization"] = `Bearer ${options.apiKey}`
}

const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: options.signal,
})

if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error")
throw new Error(`FIM API request failed (${response.status}): ${errorText}`)
}

const data = (await response.json()) as Record<string, unknown>
const completion = extractCompletion(options.provider, data)

return { completion }
}
Loading
Loading