diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adccee2..b7d679a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,45 @@ jobs: path: artifacts/*.vsix if-no-files-found: error retention-days: 14 + + jetbrains: + name: JetBrains build, test, verify + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + project: [jetbrains, jetbrains-apiclient] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ matrix.project }}-${{ hashFiles(format('apps/{0}/**/*.gradle.kts', matrix.project), format('apps/{0}/gradle.properties', matrix.project), format('apps/{0}/gradle/wrapper/gradle-wrapper.properties', matrix.project)) }} + restore-keys: | + gradle-${{ runner.os }}-${{ matrix.project }}- + gradle-${{ runner.os }}- + + - name: Compile, test, build, and verify plugin + working-directory: apps/${{ matrix.project }} + run: ./gradlew compileKotlin compileTestKotlin test buildPlugin verifyPlugin --no-daemon + + - name: Upload plugin zip (smoke build) + uses: actions/upload-artifact@v4 + with: + name: codesetu-${{ matrix.project }}-zip + path: apps/${{ matrix.project }}/build/distributions/*.zip + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release-jetbrains.yml b/.github/workflows/release-jetbrains.yml index 3082b9f..d44ebc8 100644 --- a/.github/workflows/release-jetbrains.yml +++ b/.github/workflows/release-jetbrains.yml @@ -48,9 +48,9 @@ jobs: echo "tag=jetbrains-v${VERSION}" >> "$GITHUB_OUTPUT" echo "Computed version: ${VERSION}" - - name: Build plugin + - name: Build and verify plugin working-directory: apps/jetbrains - run: ./gradlew -PpluginVersion=${{ steps.version.outputs.version }} buildPlugin --no-daemon + run: ./gradlew -PpluginVersion=${{ steps.version.outputs.version }} buildPlugin verifyPlugin --no-daemon - name: Verify plugin zip exists run: | diff --git a/.github/workflows/release-vscode.yml b/.github/workflows/release-vscode.yml index fdd2e5a..e1a845e 100644 --- a/.github/workflows/release-vscode.yml +++ b/.github/workflows/release-vscode.yml @@ -15,6 +15,10 @@ jobs: release: name: Build and publish runs-on: ubuntu-latest + # Job-level env so the Open VSX step's `if` can read it (step-level env is + # not visible in that same step's `if` condition). + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} steps: - name: Check out repository @@ -72,8 +76,6 @@ jobs: - name: Publish to Open VSX if: ${{ !inputs.dry-run && env.OVSX_PAT != '' }} - env: - OVSX_PAT: ${{ secrets.OVSX_PAT }} run: | VSIX=$(ls artifacts/codesetu-*.vsix | head -n1) pnpm dlx ovsx publish "$VSIX" --pat "$OVSX_PAT" diff --git a/README.md b/README.md index d0128f6..472c35f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/sjVKU8cpC6) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) -A **Copilot / Cursor alternative** designed for Indian developers, enterprises, public sector teams, and air-gapped deployments. Bring your own model — CodeSetu works with **Sarvam, OpenAI-compatible APIs (Ollama, vLLM, OpenRouter, SGLang)**, and local self-hosted deployments. AI chat, repo-aware context, selected-code actions, inline FIM completions, tool-calling, and an extensible plugin and skill system across VSCode and JetBrains. +A **Copilot / Cursor alternative** designed for Indian developers, enterprises, public sector teams, and air-gapped deployments. Bring your own model — CodeSetu works with **Sarvam, Hugging Face (any served chat model, dedicated endpoints, or self-hosted TGI), OpenAI-compatible APIs (Ollama, vLLM, OpenRouter, SGLang)**, and local self-hosted deployments. AI chat, repo-aware context, selected-code actions, inline FIM completions, tool-calling, and an extensible plugin and skill system across VSCode and JetBrains. **Highlights**: AI chat in IDE · Repo-aware context · Selected-code actions · Inline (FIM) code completions · Provider setup and diagnostics · Workspace skills/checks · Air-gapped friendly · Hindi / Indic-aware · Plugin + skill SDK · 100% open-source (Apache 2.0) @@ -27,7 +27,7 @@ This repository is a pnpm + Gradle monorepo organized as: - Node.js 18+ - pnpm 9+ -- A provider — Sarvam API key, OpenRouter key, or a local OpenAI-compatible endpoint (Ollama, vLLM, SGLang) +- A provider — Sarvam API key, a Hugging Face token (`hf_…`), OpenRouter key, or a local OpenAI-compatible endpoint (Ollama, vLLM, SGLang) ## Setup @@ -50,10 +50,10 @@ Marketplace, Open VSX, and private VSIX hosting, see The VSCode extension reads these settings: -- `codesetu.provider` - `sarvam` or `openai-compatible` -- `codesetu.apiKey` - optional provider API key -- `codesetu.baseUrl` - optional OpenAI-compatible base URL -- `codesetu.model` - optional model name +- `codesetu.provider` - `sarvam`, `openai-compatible`, or `huggingface` +- `codesetu.baseUrl` - optional base URL (e.g. `https://router.huggingface.co/v1`, a dedicated HF endpoint, or a local server) +- `codesetu.model` - optional model name (for Hugging Face, the model repo id, e.g. `meta-llama/Llama-3.3-70B-Instruct`) +- API key — set via the **CodeSetu: Setup Provider** command (stored in the OS secret store), or the `SARVAM_API_KEY` / `HF_TOKEN` / `CODESETU_API_KEY` environment variables - `codesetu.inlineCompletions.enabled` - enable FIM inline completions - `codesetu.chat.maxTokens` / `codesetu.chat.temperature` diff --git a/apps/jetbrains-apiclient/.gradle/8.10/executionHistory/executionHistory.bin b/apps/jetbrains-apiclient/.gradle/8.10/executionHistory/executionHistory.bin index cda5a8b..aa5f98b 100644 Binary files a/apps/jetbrains-apiclient/.gradle/8.10/executionHistory/executionHistory.bin and b/apps/jetbrains-apiclient/.gradle/8.10/executionHistory/executionHistory.bin differ diff --git a/apps/jetbrains-apiclient/.gradle/8.10/executionHistory/executionHistory.lock b/apps/jetbrains-apiclient/.gradle/8.10/executionHistory/executionHistory.lock index d6d2cb0..1029854 100644 Binary files a/apps/jetbrains-apiclient/.gradle/8.10/executionHistory/executionHistory.lock and b/apps/jetbrains-apiclient/.gradle/8.10/executionHistory/executionHistory.lock differ diff --git a/apps/jetbrains-apiclient/.gradle/8.10/fileHashes/fileHashes.bin b/apps/jetbrains-apiclient/.gradle/8.10/fileHashes/fileHashes.bin index bc88768..3c64138 100644 Binary files a/apps/jetbrains-apiclient/.gradle/8.10/fileHashes/fileHashes.bin and b/apps/jetbrains-apiclient/.gradle/8.10/fileHashes/fileHashes.bin differ diff --git a/apps/jetbrains-apiclient/.gradle/8.10/fileHashes/fileHashes.lock b/apps/jetbrains-apiclient/.gradle/8.10/fileHashes/fileHashes.lock index 4cb8e53..45bb9fd 100644 Binary files a/apps/jetbrains-apiclient/.gradle/8.10/fileHashes/fileHashes.lock and b/apps/jetbrains-apiclient/.gradle/8.10/fileHashes/fileHashes.lock differ diff --git a/apps/jetbrains-apiclient/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/apps/jetbrains-apiclient/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 6698e11..674f7a9 100644 Binary files a/apps/jetbrains-apiclient/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/apps/jetbrains-apiclient/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/apps/jetbrains-apiclient/.gradle/file-system.probe b/apps/jetbrains-apiclient/.gradle/file-system.probe index 7cd72bd..bf30ca7 100644 Binary files a/apps/jetbrains-apiclient/.gradle/file-system.probe and b/apps/jetbrains-apiclient/.gradle/file-system.probe differ diff --git a/apps/jetbrains-apiclient/.intellijPlatform/self-update.lock b/apps/jetbrains-apiclient/.intellijPlatform/self-update.lock index 75e1797..6c71513 100644 --- a/apps/jetbrains-apiclient/.intellijPlatform/self-update.lock +++ b/apps/jetbrains-apiclient/.intellijPlatform/self-update.lock @@ -1 +1 @@ -2026-05-27 \ No newline at end of file +2026-05-28 \ No newline at end of file diff --git a/apps/jetbrains-apiclient/build.gradle.kts b/apps/jetbrains-apiclient/build.gradle.kts index 60d21ec..261e4c8 100644 --- a/apps/jetbrains-apiclient/build.gradle.kts +++ b/apps/jetbrains-apiclient/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType + plugins { // Kotlin version must be >= the version bundled in the target IntelliJ Platform. // IDEA 2025.2 (IC-252.x) ships with Kotlin 2.2.x. @@ -36,6 +38,8 @@ dependencies { intellijIdeaCommunity("2025.2.5") } instrumentationTools() + // CLI used by the `verifyPlugin` task (JetBrains Plugin Verifier). + pluginVerifier() } } @@ -61,6 +65,14 @@ intellijPlatform { } } buildSearchableOptions = false + // `verifyPlugin` runs the JetBrains Plugin Verifier. Pin it to the build + // target so it's deterministic and always resolvable (recommended() can pick + // an unreleased IDE that fails to download). + pluginVerification { + ides { + ide(IntelliJPlatformType.IntellijIdeaCommunity, "2025.2.5") + } + } publishing { token = providers.environmentVariable("JETBRAINS_MARKETPLACE_TOKEN") } diff --git a/apps/jetbrains/build.gradle.kts b/apps/jetbrains/build.gradle.kts index 2cb58b0..4bd0a8c 100644 --- a/apps/jetbrains/build.gradle.kts +++ b/apps/jetbrains/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType + plugins { // Kotlin version must be >= the version bundled in the target IntelliJ Platform. // IDEA 2025.2 (IC-252.x) ships with Kotlin 2.2.x. @@ -35,6 +37,8 @@ dependencies { intellijIdeaCommunity("2025.2.5") } instrumentationTools() + // CLI used by the `verifyPlugin` task (JetBrains Plugin Verifier). + pluginVerifier() } } @@ -65,6 +69,14 @@ intellijPlatform { // compute a settings-search index; our scaffold has no settings panel, so // it's pure overhead. Re-enable once we add settings UI. buildSearchableOptions = false + // `verifyPlugin` runs the JetBrains Plugin Verifier. Pin it to the build + // target so it's deterministic and always resolvable (recommended() can pick + // an unreleased IDE that fails to download). + pluginVerification { + ides { + ide(IntelliJPlatformType.IntellijIdeaCommunity, "2025.2.5") + } + } publishing { token = providers.environmentVariable("JETBRAINS_MARKETPLACE_TOKEN") } diff --git a/apps/jetbrains/gradle.properties b/apps/jetbrains/gradle.properties index 19eaedf..3623439 100644 --- a/apps/jetbrains/gradle.properties +++ b/apps/jetbrains/gradle.properties @@ -6,7 +6,7 @@ org.gradle.parallel=true # Plugin version baseline. CI overrides with -PpluginVersion=... # Bump the major.minor here when you want a semantic version bump; CI keeps the # patch incrementing automatically. -pluginVersion=0.1.0 +pluginVersion=0.2.0 # Tell Gradle's toolchain detection where to find JDK 21 installed via Homebrew. # Falls back to other auto-detected JDKs if this path doesn't exist on a contributor's machine. diff --git a/apps/jetbrains/package.json b/apps/jetbrains/package.json index e88576b..a65df52 100644 --- a/apps/jetbrains/package.json +++ b/apps/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "@codesetu/jetbrains", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "JetBrains plugin for CodeSetu. This Kotlin/Gradle project is integrated into the pnpm workspace via thin script delegations — actual build/test runs through Gradle.", "license": "Apache-2.0", diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/model/CodeSetuModels.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/model/CodeSetuModels.kt index 550bc8f..46eaa0c 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/model/CodeSetuModels.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/model/CodeSetuModels.kt @@ -5,7 +5,8 @@ import kotlinx.serialization.Serializable enum class ProviderKind(val id: String) { SARVAM("sarvam"), - OPENAI_COMPATIBLE("openai-compatible"); + OPENAI_COMPATIBLE("openai-compatible"), + HUGGING_FACE("huggingface"); companion object { fun fromId(id: String): ProviderKind = diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/prompts/PromptBuilder.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/prompts/PromptBuilder.kt index 71edb61..c50dee6 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/prompts/PromptBuilder.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/prompts/PromptBuilder.kt @@ -4,13 +4,27 @@ import ai.codesetu.model.IdeActionId import ai.codesetu.model.IdeContextPayload import ai.codesetu.model.WorkspaceInstruction -fun buildSystemMessage(instructions: List): String = - if (instructions.isEmpty()) { - "You are CodeSetu, an AI coding assistant for Indian developers. Be concise, correct, practical, and privacy-aware." - } else { - "You are CodeSetu, an AI coding assistant for Indian developers. Be concise, correct, practical, and privacy-aware.\n\nFollow applicable workspace guidance when it helps the user's request." +fun buildSystemMessage(instructions: List): String { + val parts = mutableListOf( + "You are CodeSetu, an AI coding assistant for Indian developers. Be concise, correct, practical, and privacy-aware.", + "Use the supplied IDE context as the source of truth. Ask for missing context when needed.", + ) + + if (instructions.isNotEmpty()) { + parts.add(formatWorkspaceInstructions(instructions)) } + return parts.joinToString("\n\n") +} + +private fun formatWorkspaceInstructions(instructions: List): String { + val rendered = instructions.joinToString("\n\n") { instruction -> + listOf("${instruction.name} (${instruction.id})", instruction.description, instruction.body) + .joinToString("\n") + } + return "Workspace instructions\n\n$rendered" +} + fun buildActionInstruction(actionId: IdeActionId): String = when (actionId) { IdeActionId.EXPLAIN -> "Explain the selected code clearly and concisely. Include key control flow, inputs, outputs, and risks." diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/provider/CodeSetuProviderClient.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/provider/CodeSetuProviderClient.kt index 7a987ab..61209b6 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/provider/CodeSetuProviderClient.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/provider/CodeSetuProviderClient.kt @@ -4,6 +4,7 @@ import ai.codesetu.model.ChatCompletionRequest import ai.codesetu.model.ChatCompletionResponse import ai.codesetu.model.ChatCompletionChunk import ai.codesetu.model.ChatMessage +import ai.codesetu.model.ProviderKind import ai.codesetu.settings.CodeSetuSettingsState import ai.codesetu.settings.resolveCodeSetuModel import java.net.URI @@ -24,12 +25,12 @@ class CodeSetuProviderClient( messages = messages, maxTokens = maxTokens, temperature = temperature, - reasoningEffort = "low", + reasoningEffort = reasoningEffortFor(state.provider), json = json, ) val request = HttpRequest.newBuilder() .uri(URI.create(state.baseUrl.trimEnd('/') + "/chat/completions")) - .header("Authorization", "Bearer ${state.apiKey}") + .header("Authorization", "Bearer ${CodeSetuSettingsState.getInstance().getApiKey()}") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build() @@ -54,13 +55,13 @@ class CodeSetuProviderClient( messages = messages, maxTokens = maxTokens, temperature = temperature, - reasoningEffort = "low", + reasoningEffort = reasoningEffortFor(state.provider), stream = true, json = json, ) val request = HttpRequest.newBuilder() .uri(URI.create(state.baseUrl.trimEnd('/') + "/chat/completions")) - .header("Authorization", "Bearer ${state.apiKey}") + .header("Authorization", "Bearer ${CodeSetuSettingsState.getInstance().getApiKey()}") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build() @@ -87,7 +88,12 @@ class CodeSetuProviderClient( break } - val text = getAssistantChunkText(json.decodeFromString(data)) + // Skip malformed/partial SSE payloads instead of aborting the whole stream. + val text = try { + getAssistantChunkText(json.decodeFromString(data)) + } catch (error: Exception) { + "" + } if (text.isNotEmpty()) { assistantText.append(text) @@ -100,6 +106,11 @@ class CodeSetuProviderClient( } } +// Sarvam needs a low reasoning effort to avoid exhausting its token budget; +// other providers (OpenAI-compatible, Hugging Face) may reject an unknown field. +private fun reasoningEffortFor(providerId: String): String? = + if (ProviderKind.fromId(providerId) == ProviderKind.SARVAM) "low" else null + fun getAssistantText(response: ChatCompletionResponse): String { val message = response.choices.firstOrNull()?.message return message?.content ?: message?.refusal.orEmpty() diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuApiKeyStore.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuApiKeyStore.kt new file mode 100644 index 0000000..d0d3d62 --- /dev/null +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuApiKeyStore.kt @@ -0,0 +1,26 @@ +package ai.codesetu.settings + +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.credentialStore.generateServiceName +import com.intellij.ide.passwordSafe.PasswordSafe + +/** + * Stores the CodeSetu provider API key in the IDE's [PasswordSafe] (OS keychain + * / encrypted credential store) instead of the plaintext settings XML. + */ +object CodeSetuApiKeyStore { + private val attributes: CredentialAttributes = + CredentialAttributes(generateServiceName("CodeSetu", "apiKey")) + + fun get(): String = PasswordSafe.instance.getPassword(attributes).orEmpty() + + fun set(value: String) { + val trimmed = value.trim() + if (trimmed.isEmpty()) { + PasswordSafe.instance.set(attributes, null) + } else { + PasswordSafe.instance.set(attributes, Credentials(null, trimmed)) + } + } +} diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuModelCatalog.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuModelCatalog.kt new file mode 100644 index 0000000..e109b34 --- /dev/null +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuModelCatalog.kt @@ -0,0 +1,22 @@ +package ai.codesetu.settings + +import ai.codesetu.model.ProviderKind + +/** + * Curated, reliably-served chat models offered in the model picker. Users can + * still type any other model id (Hub repo id, dedicated endpoint model, etc.). + */ +object CodeSetuModelCatalog { + val HUGGINGFACE_MODELS: List = listOf( + "meta-llama/Llama-3.3-70B-Instruct", + "Qwen/Qwen2.5-72B-Instruct", + "Qwen/Qwen2.5-Coder-32B-Instruct", + "deepseek-ai/DeepSeek-V3-0324", + "meta-llama/Llama-3.1-8B-Instruct", + "google/gemma-2-27b-it", + "mistralai/Mistral-Small-24B-Instruct-2501", + ) + + fun suggestionsFor(providerId: String): List = + if (ProviderKind.fromId(providerId) == ProviderKind.HUGGING_FACE) HUGGINGFACE_MODELS else emptyList() +} diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuProviderDefaults.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuProviderDefaults.kt index f3d9bec..f57f372 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuProviderDefaults.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuProviderDefaults.kt @@ -1,7 +1,20 @@ package ai.codesetu.settings +import ai.codesetu.model.ProviderKind + const val DEFAULT_CODESETU_BASE_URL = "https://api.sarvam.ai/v1" const val DEFAULT_CODESETU_MODEL = "sarvam-30b" fun resolveCodeSetuModel(model: String): String = model.ifBlank { DEFAULT_CODESETU_MODEL } + +data class ProviderDefaults(val baseUrl: String, val model: String) + +fun providerDefaults(providerId: String): ProviderDefaults = + when (ProviderKind.fromId(providerId)) { + ProviderKind.SARVAM -> ProviderDefaults("https://api.sarvam.ai/v1", "sarvam-30b") + ProviderKind.OPENAI_COMPATIBLE -> + ProviderDefaults("http://localhost:11434/v1", "qwen2.5-coder:7b") + ProviderKind.HUGGING_FACE -> + ProviderDefaults("https://router.huggingface.co/v1", "meta-llama/Llama-3.3-70B-Instruct") + } diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsConfigurable.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsConfigurable.kt index b70a5e9..7442a7d 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsConfigurable.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsConfigurable.kt @@ -10,7 +10,7 @@ class CodeSetuSettingsConfigurable : Configurable { private var provider = settings.state.provider private var baseUrl = settings.state.baseUrl private var model = settings.state.model - private var apiKey = settings.state.apiKey + private var apiKey = settings.getApiKey() override fun getDisplayName(): String = "CodeSetu" @@ -25,19 +25,19 @@ class CodeSetuSettingsConfigurable : Configurable { provider != settings.state.provider || baseUrl != settings.state.baseUrl || model != settings.state.model || - apiKey != settings.state.apiKey + apiKey != settings.getApiKey() override fun apply() { settings.state.provider = provider.trim() settings.state.baseUrl = baseUrl.trim() settings.state.model = model.trim() - settings.state.apiKey = apiKey.trim() + settings.setApiKey(apiKey) } override fun reset() { provider = settings.state.provider baseUrl = settings.state.baseUrl model = settings.state.model - apiKey = settings.state.apiKey + apiKey = settings.getApiKey() } } diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsState.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsState.kt index aec25b9..54eb015 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsState.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsState.kt @@ -14,6 +14,8 @@ class CodeSetuSettingsState : PersistentStateComponent " $name: $value;" } + return ":root {\n$body\n }" + } + + private fun hex(color: Color): String = "#%02x%02x%02x".format(color.red, color.green, color.blue) +} diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/toolwindow/ChatWebviewHtml.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/toolwindow/ChatWebviewHtml.kt new file mode 100644 index 0000000..db32012 --- /dev/null +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/toolwindow/ChatWebviewHtml.kt @@ -0,0 +1,28 @@ +package ai.codesetu.toolwindow + +import java.nio.charset.StandardCharsets + +/** + * Loads the shared chat webview template from resources and substitutes the + * theme, model label, and the JCEF post-bridge snippet. + */ +object ChatWebviewHtml { + private val template: String by lazy { + ChatWebviewHtml::class.java.getResourceAsStream("/webview/chat.html") + ?.use { String(it.readAllBytes(), StandardCharsets.UTF_8) } + ?: error("Missing /webview/chat.html resource") + } + + fun render(modelLabel: String, bridgePostJs: String): String = + template + .replace("__THEME_CSS__", ChatTheme.rootCss()) + .replace("__MODEL_LABEL__", escapeHtml(modelLabel)) + .replace("__BRIDGE_POST__", bridgePostJs) + + private fun escapeHtml(value: String): String = + value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) +} diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/toolwindow/CodeSetuToolWindowFactory.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/toolwindow/CodeSetuToolWindowFactory.kt index 23d8413..932224e 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/toolwindow/CodeSetuToolWindowFactory.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/toolwindow/CodeSetuToolWindowFactory.kt @@ -4,124 +4,322 @@ import ai.codesetu.context.collectIdeContext import ai.codesetu.instructions.loadWorkspaceInstructions import ai.codesetu.model.ChatMessage import ai.codesetu.model.IdeContextPayload +import ai.codesetu.model.WorkspaceInstruction import ai.codesetu.provider.CodeSetuProviderClient import ai.codesetu.prompts.buildContextMarkdown import ai.codesetu.prompts.buildSystemMessage +import ai.codesetu.settings.CodeSetuModelCatalog +import ai.codesetu.settings.CodeSetuSettingsState +import ai.codesetu.settings.providerDefaults +import ai.codesetu.settings.resolveCodeSetuModel +import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ReadAction import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.content.ContentFactory -import java.awt.BorderLayout -import javax.swing.JButton -import javax.swing.JPanel -import javax.swing.JScrollPane -import javax.swing.JTextArea +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +import javax.swing.JComponent +import javax.swing.JLabel +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put class CodeSetuToolWindowFactory : ToolWindowFactory { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val contentFactory = ContentFactory.getInstance() + + if (!JBCefApp.isSupported()) { + val fallback = JLabel("CodeSetu chat requires JCEF, which is unavailable in this IDE.") + toolWindow.contentManager.addContent(contentFactory.createContent(fallback, "", false)) + return + } + val panel = CodeSetuChatPanel(project) CodeSetuChatService.getInstance(project).register(panel) - val content = ContentFactory.getInstance().createContent(panel.component, "", false) + val content = contentFactory.createContent(panel.component, "", false) + content.setDisposer(panel) toolWindow.contentManager.addContent(content) } } -class CodeSetuChatPanel(private val project: Project) { - val component: JPanel = JPanel(BorderLayout()) - private val transcript = JTextArea() - private val input = JTextArea(4, 40) - private val send = JButton("Send") +/** + * JCEF-backed chat panel that renders the shared CodeSetu chat design (the same + * markup, CSS, and markdown rendering as the VS Code webview). Communication + * uses a JBCefJSQuery bridge: the page posts sendMessage/selectModel/ready, and + * the host pushes streamed deltas, the model label, busy state, and errors back. + */ +class CodeSetuChatPanel(private val project: Project) : Disposable { + private val browser = JBCefBrowser() + private val jsQuery = JBCefJSQuery.create(browser as JBCefBrowserBase) private val client = CodeSetuProviderClient() + private val history = mutableListOf() + + // EDT-only state: outgoing messages buffered until the page signals "ready". + private val pending = mutableListOf() + private var ready = false + private var inFlight = false + + val component: JComponent + get() = browser.component init { - transcript.isEditable = false - component.add(JScrollPane(transcript), BorderLayout.CENTER) - component.add(JScrollPane(input), BorderLayout.SOUTH) - component.add(send, BorderLayout.EAST) - send.addActionListener { sendMessage(input.text) } + jsQuery.addHandler { request -> + handlePost(request) + null + } + browser.loadHTML(ChatWebviewHtml.render(modelLabelText(), jsQuery.inject("payload"))) } + /** Entry point for editor actions (Explain/Refactor/...) with pre-captured context. */ fun sendMessage(text: String, capturedIdeContext: IdeContextPayload? = null) { + runChat(text, includeContext = true, captured = capturedIdeContext) + } + + private fun handlePost(request: String) { + val obj = try { + Json.parseToJsonElement(request).jsonObject + } catch (error: Exception) { + return + } + + when (obj["type"]?.jsonPrimitive?.contentOrNull) { + "ready" -> onReady() + "selectModel" -> showModelPicker() + "sendMessage" -> { + val text = obj["text"]?.jsonPrimitive?.contentOrNull ?: return + val include = obj["includeIdeContext"]?.jsonPrimitive?.booleanOrNull ?: true + runChat(text, include, null) + } + } + } + + private fun runChat(text: String, includeContext: Boolean, captured: IdeContextPayload?) { val trimmed = text.trim() if (trimmed.isEmpty()) return - append("You", trimmed) - input.text = "" - send.isEnabled = false + ApplicationManager.getApplication().invokeLater { + if (inFlight) return@invokeLater + inFlight = true - ApplicationManager.getApplication().executeOnPooledThread { - val instructions = loadWorkspaceInstructions(project) - val ideContext = buildContextMarkdown(capturedIdeContext ?: collectIdeContext(project)) - val userMessage = if (ideContext.isBlank()) { - trimmed - } else { - "$trimmed\n\nCurrent IDE context:\n\n$ideContext" + push(message("userMessage") { put("text", trimmed) }) + push(busy(true)) + + // Capture editor context on the EDT before going to a background thread. + val ideContext = captured ?: if (includeContext) collectIdeContext(project) else IdeContextPayload() + + ApplicationManager.getApplication().executeOnPooledThread { + runRequest(trimmed, ideContext) } - val messages = listOf( - ChatMessage("system", buildSystemMessage(instructions)), - ChatMessage("user", userMessage), - ) - var receivedChunk = false - val response = try { - client.streamChat(messages) { chunk -> - if (!receivedChunk) { - receivedChunk = true - ApplicationManager.getApplication().invokeLater { - beginAppend("CodeSetu") - appendChunk(chunk) - } - } else { - ApplicationManager.getApplication().invokeLater { - appendChunk(chunk) - } - } - } - } catch (error: Exception) { - if (receivedChunk) { - val message = "\n\nCodeSetu could not complete that request: ${error.message ?: error}" - ApplicationManager.getApplication().invokeLater { - appendChunk(message) - endAppend() - send.isEnabled = true - } - return@executeOnPooledThread - } + } + } - try { - client.chat(messages) - } catch (fallbackError: Exception) { - "CodeSetu could not complete that request: ${fallbackError.message ?: fallbackError}" + private fun runRequest(userText: String, ideContext: IdeContextPayload) { + val instructions = ReadAction.compute, RuntimeException> { + loadWorkspaceInstructions(project) + } + val contextMarkdown = buildContextMarkdown(ideContext) + val userMessage = if (contextMarkdown.isBlank()) { + userText + } else { + "$userText\n\nCurrent IDE context:\n\n$contextMarkdown" + } + history.add(ChatMessage("user", userMessage)) + val messages = listOf(ChatMessage("system", buildSystemMessage(instructions))) + history + + var started = false + val response = try { + client.streamChat(messages) { chunk -> + if (!started) { + started = true + push(message("assistantMessageStart")) } + push(message("assistantMessageDelta") { put("text", chunk) }) + } + } catch (error: Exception) { + if (started) { + history.removeLastOrNull() + push( + message("assistantMessageDelta") { + put("text", "\n\nCodeSetu could not complete that request: ${error.message ?: error}") + }, + ) + push(message("assistantMessageDone")) + finish() + return + } + + try { + client.chat(messages) + } catch (fallbackError: Exception) { + history.removeLastOrNull() + push( + message("error") { + put("text", "CodeSetu could not complete that request: ${fallbackError.message ?: fallbackError}") + }, + ) + finish() + return + } + } + + if (response.isNotBlank()) { + history.add(ChatMessage("assistant", response)) + } else { + history.removeLastOrNull() + } + + if (started) { + if (response.isBlank()) { + push(message("assistantMessageDelta") { put("text", "CodeSetu did not return any text.") }) } + push(message("assistantMessageDone")) + } else { + push(message("assistantMessage") { put("text", response.ifBlank { "CodeSetu did not return any text." }) }) + } + finish() + } - ApplicationManager.getApplication().invokeLater { - if (receivedChunk) { - if (response.isBlank()) { - appendChunk("CodeSetu did not return any text.") + private fun showModelPicker() { + ApplicationManager.getApplication().invokeLater { + val state = CodeSetuSettingsState.getInstance().state + val current = resolveCodeSetuModel(state.model) + val configure = "⚙ Configure provider / endpoint…" + val custom = "Enter a custom model id…" + val items = + (listOf(configure, custom, current) + CodeSetuModelCatalog.suggestionsFor(state.provider)) + .distinct() + + JBPopupFactory.getInstance() + .createPopupChooserBuilder(items) + .setTitle("CodeSetu model") + .setItemChosenCallback { choice -> + when (choice) { + configure -> configureProvider() + custom -> applyModel(Messages.showInputDialog(project, "Model id", "Select Model", null, current, null)) + else -> applyModel(choice) } - endAppend() - } else { - append("CodeSetu", response.ifBlank { "CodeSetu did not return any text." }) } - send.isEnabled = true + .createPopup() + .showInFocusCenter() + } + } + + private fun applyModel(model: String?) { + val trimmed = model?.trim().orEmpty() + if (trimmed.isEmpty()) return + CodeSetuSettingsState.getInstance().state.model = trimmed + pushModelLabel() + } + + // Lets the user switch provider (Sarvam / OpenAI-compatible (Ollama, local) / + // Hugging Face) and set its base URL, model, and API key from the chat. + private fun configureProvider() { + val providers = listOf( + "Sarvam" to "sarvam", + "OpenAI-compatible (Ollama, vLLM, local)" to "openai-compatible", + "Hugging Face" to "huggingface", + ) + + JBPopupFactory.getInstance() + .createPopupChooserBuilder(providers.map { it.first }) + .setTitle("Configure provider") + .setItemChosenCallback { label -> + providers.firstOrNull { it.first == label }?.let { applyProvider(it.second) } } + .createPopup() + .showInFocusCenter() + } + + private fun applyProvider(providerId: String) { + val state = CodeSetuSettingsState.getInstance().state + val defaults = providerDefaults(providerId) + val baseUrlSeed = + if (state.provider == providerId && state.baseUrl.isNotBlank()) state.baseUrl else defaults.baseUrl + val modelSeed = + if (state.provider == providerId && state.model.isNotBlank()) state.model else defaults.model + + val baseUrl = + Messages.showInputDialog(project, "Base URL", "Configure Provider", null, baseUrlSeed, null) + ?: return + val model = + Messages.showInputDialog(project, "Model id", "Configure Provider", null, modelSeed, null) + ?: return + val token = + Messages.showPasswordDialog( + project, + "API key / token (leave blank to keep the current one)", + "Configure Provider", + null, + ) + + state.provider = providerId + state.baseUrl = baseUrl.trim().ifBlank { defaults.baseUrl } + state.model = model.trim().ifBlank { defaults.model } + if (!token.isNullOrBlank()) { + CodeSetuSettingsState.getInstance().setApiKey(token) } + pushModelLabel() + } + + private fun pushModelLabel() { + push(message("modelLabel") { put("text", modelLabelText()) }) } - private fun append(role: String, text: String) { - transcript.append("$role:\n$text\n\n") + private fun modelLabelText(): String { + val state = CodeSetuSettingsState.getInstance().state + return "${state.provider} · ${resolveCodeSetuModel(state.model)}" } - private fun beginAppend(role: String) { - transcript.append("$role:\n") + private fun finish() { + ApplicationManager.getApplication().invokeLater { inFlight = false } + push(busy(false)) } - private fun appendChunk(text: String) { - transcript.append(text) + private fun push(json: String) { + ApplicationManager.getApplication().invokeLater { + if (ready) { + executeJs(json) + } else { + pending.add(json) + } + } } - private fun endAppend() { - transcript.append("\n\n") + private fun onReady() { + ApplicationManager.getApplication().invokeLater { + ready = true + pending.forEach { executeJs(it) } + pending.clear() + } + } + + private fun executeJs(json: String) { + browser.cefBrowser.executeJavaScript("window.__codesetuReceive($json)", browser.cefBrowser.url ?: "", 0) + } + + private fun message(type: String, build: JsonObjectBuilder.() -> Unit = {}): String = + buildJsonObject { + put("type", type) + build() + }.toString() + + private fun busy(value: Boolean): String = message("busy") { put("value", value) } + + override fun dispose() { + Disposer.dispose(jsQuery) + Disposer.dispose(browser) } } diff --git a/apps/jetbrains/src/main/resources/META-INF/plugin.xml b/apps/jetbrains/src/main/resources/META-INF/plugin.xml index 9a6e7d7..7c991c4 100644 --- a/apps/jetbrains/src/main/resources/META-INF/plugin.xml +++ b/apps/jetbrains/src/main/resources/META-INF/plugin.xml @@ -12,6 +12,12 @@ ]]> 0.2.0 +
    +
  • Chat tool window rendered with the CodeSetu webview design (markdown replies, model chip)
  • +
  • Hugging Face provider support, plus in-chat provider/model switching (Sarvam, OpenAI-compatible, Hugging Face)
  • +
  • API key stored securely in PasswordSafe; multi-turn chat history
  • +

0.1.0

  • Initial JetBrains plugin scaffold
  • diff --git a/apps/jetbrains/src/main/resources/webview/chat.html b/apps/jetbrains/src/main/resources/webview/chat.html new file mode 100644 index 0000000..e22233f --- /dev/null +++ b/apps/jetbrains/src/main/resources/webview/chat.html @@ -0,0 +1,657 @@ + + + + + + + + + + CodeSetu + + + +
    +
    +
    +
    + +
    +
    + +
    +
    + + +
    +
    +
    + +
    +
    + + + diff --git a/apps/jetbrains/src/test/kotlin/ai/codesetu/ModelCatalogTest.kt b/apps/jetbrains/src/test/kotlin/ai/codesetu/ModelCatalogTest.kt new file mode 100644 index 0000000..cf3426d --- /dev/null +++ b/apps/jetbrains/src/test/kotlin/ai/codesetu/ModelCatalogTest.kt @@ -0,0 +1,22 @@ +package ai.codesetu + +import ai.codesetu.settings.CodeSetuModelCatalog +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ModelCatalogTest { + @Test + fun suggestsHuggingFaceModels() { + val models = CodeSetuModelCatalog.suggestionsFor("huggingface") + + assertTrue(models.isNotEmpty()) + assertTrue(models.contains("meta-llama/Llama-3.3-70B-Instruct")) + } + + @Test + fun offersNoCuratedListForOtherProviders() { + assertEquals(emptyList(), CodeSetuModelCatalog.suggestionsFor("sarvam")) + assertEquals(emptyList(), CodeSetuModelCatalog.suggestionsFor("openai-compatible")) + } +} diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 47d779d..c3e042a 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.0 + +- Store the provider API key in the OS secret store instead of settings.json. +- Add Hugging Face provider support and in-chat provider/model switching. +- Render assistant replies as markdown; refine the chat composer. + ## 0.0.0 - Initial VS Code extension scaffold. diff --git a/apps/vscode/package.json b/apps/vscode/package.json index ea2986a..e953d99 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -2,7 +2,7 @@ "name": "codesetu", "displayName": "CodeSetu", "description": "Open-source AI coding assistant for VSCode with multi-provider chat and inline completions. Works with Sarvam, OpenAI-compatible APIs, and local models.", - "version": "0.1.2", + "version": "0.2.0", "publisher": "codesetu", "license": "Apache-2.0", "repository": { @@ -73,6 +73,11 @@ "title": "CodeSetu: Diagnose Provider", "category": "CodeSetu" }, + { + "command": "codesetu.selectModel", + "title": "CodeSetu: Select Model", + "category": "CodeSetu" + }, { "command": "codesetu.explainSelection", "title": "Explain with CodeSetu", @@ -160,7 +165,13 @@ "type": "string", "enum": [ "sarvam", - "openai-compatible" + "openai-compatible", + "huggingface" + ], + "enumDescriptions": [ + "Sarvam hosted or compatible endpoint.", + "Any OpenAI-compatible API (Ollama, vLLM, SGLang, OpenRouter, local).", + "Hugging Face Inference — the hosted router, a dedicated Inference Endpoint, or self-hosted TGI." ], "default": "sarvam", "description": "Model provider used by CodeSetu." @@ -168,7 +179,8 @@ "codesetu.apiKey": { "type": "string", "default": "", - "description": "API key for the configured provider. If empty, CodeSetu reads provider environment variables." + "markdownDeprecationMessage": "Storing the API key in settings is insecure — it is saved in plaintext and synced by Settings Sync. CodeSetu now keeps it in the OS secret store. Run **CodeSetu: Setup Provider**; any value left here is migrated to secure storage and cleared automatically.", + "description": "Deprecated. Use the CodeSetu: Setup Provider command (the key is stored in the OS secret store). If empty, CodeSetu reads provider environment variables." }, "codesetu.baseUrl": { "type": "string", diff --git a/apps/vscode/src/chatPanel.ts b/apps/vscode/src/chatPanel.ts index 9926c49..e9ba688 100644 --- a/apps/vscode/src/chatPanel.ts +++ b/apps/vscode/src/chatPanel.ts @@ -20,6 +20,11 @@ import type { ChatMessage, IdeContextPayload } from "@codesetu/core"; import * as vscode from "vscode"; import { renderChatPanelHtml } from "./chatPanelHtml"; +import { summarizeCodeSetuConfiguration } from "./configuration"; + +// Cap the rolling transcript sent to the provider so long sessions don't +// overflow the context window. The most recent turns are always kept. +const MAX_HISTORY_CHARS = 100_000; export interface ChatResponderContext { ideContext?: IdeContextPayload; @@ -104,7 +109,18 @@ export class ChatPanel { await this.submitMessage(text, options); } + public static refreshModelLabel(): void { + ChatPanel.currentPanel?.postModelLabel(); + } + private async handleMessage(message: unknown): Promise { + if (isSelectModelRequest(message)) { + // The command updates codesetu.model and calls refreshModelLabel(), which + // posts the new label back to this webview. + await vscode.commands.executeCommand("codesetu.selectModel"); + return; + } + if (!isSendMessageRequest(message) || this.inFlight) { return; } @@ -112,6 +128,14 @@ export class ChatPanel { await this.submitMessage(message.text, { includeIdeContext: message.includeIdeContext }); } + private postModelLabel(): void { + const summary = summarizeCodeSetuConfiguration(); + void this.panel.webview.postMessage({ + type: "modelLabel", + text: `${summary.provider} · ${summary.model ?? "default"}`, + }); + } + private async submitMessage( rawText: string, options: SendUserMessageOptions = {}, @@ -130,6 +154,7 @@ export class ChatPanel { void this.panel.webview.postMessage({ type: "busy", value: true }); void this.panel.webview.postMessage({ type: "userMessage", text }); this.history.push({ role: "user", content: text }); + this.trimHistory(); try { let isStreamingAssistantMessage = false; @@ -152,6 +177,11 @@ export class ChatPanel { : { type: "assistantMessage", text: response }, ); } catch (error: unknown) { + // Drop the optimistic user turn so a retry doesn't stack two user + // messages with no assistant reply between them. + if (this.history[this.history.length - 1]?.role === "user") { + this.history.pop(); + } this.outputChannel.appendLine(`Chat request failed: ${formatErrorMessage(error)}`); void this.panel.webview.postMessage({ type: "error", @@ -163,14 +193,43 @@ export class ChatPanel { } } + private trimHistory(): void { + let total = this.history.reduce((sum, message) => sum + messageLength(message), 0); + + while (this.history.length > 1 && total > MAX_HISTORY_CHARS) { + const removed = this.history.shift(); + + if (removed === undefined) { + break; + } + + total -= messageLength(removed); + } + } + private renderHtml(webview: vscode.Webview): string { + const summary = summarizeCodeSetuConfiguration(); + return renderChatPanelHtml({ cspSource: webview.cspSource, nonce: crypto.randomUUID(), + modelLabel: `${summary.provider} · ${summary.model ?? "default"}`, }); } } +function messageLength(message: ChatMessage): number { + return typeof message.content === "string" ? message.content.length : 0; +} + +function isSelectModelRequest(message: unknown): boolean { + return ( + typeof message === "object" && + message !== null && + (message as { type?: unknown }).type === "selectModel" + ); +} + function isSendMessageRequest(message: unknown): message is SendMessageRequest { if (typeof message !== "object" || message === null) { return false; diff --git a/apps/vscode/src/chatPanelHtml.ts b/apps/vscode/src/chatPanelHtml.ts index 9bf585a..6d2952c 100644 --- a/apps/vscode/src/chatPanelHtml.ts +++ b/apps/vscode/src/chatPanelHtml.ts @@ -17,9 +17,21 @@ export interface RenderChatPanelHtmlOptions { cspSource: string; nonce: string; + /** Human-readable "provider · model" shown in the composer (real, configured values). */ + modelLabel: string; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); } export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string { + const modelLabel = escapeHtml(options.modelLabel); + return /* html */ ` @@ -70,6 +82,7 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string line-height: 1.5; padding: 11px 14px; white-space: pre-wrap; + overflow-wrap: anywhere; } .user { @@ -80,6 +93,38 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string .assistant { background: var(--vscode-editor-inactiveSelectionBackground); border: 1px solid rgba(127, 127, 127, 0.08); + white-space: normal; + } + + .assistant > :first-child { + margin-top: 0; + } + + .assistant pre { + margin: 8px 0; + padding: 10px 12px; + overflow-x: auto; + border-radius: 8px; + background: var(--vscode-textCodeBlock-background, rgba(127, 127, 127, 0.12)); + white-space: pre; + } + + .assistant pre code { + padding: 0; + background: none; + } + + .assistant code { + font-family: var(--vscode-editor-font-family, monospace); + font-size: 0.95em; + padding: 1px 4px; + border-radius: 4px; + background: var(--vscode-textCodeBlock-background, rgba(127, 127, 127, 0.12)); + } + + .assistant ul { + margin: 6px 0; + padding-left: 22px; } .error { @@ -97,7 +142,7 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string display: flex; flex-direction: column; gap: 10px; - min-height: 132px; + min-height: 120px; padding: 16px 18px 14px; border: 1px solid var(--vscode-input-border, var(--vscode-widget-border)); border-radius: 20px; @@ -136,13 +181,6 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string color: var(--vscode-input-placeholderForeground); } - .composer-toolbar, - .toolbar-group { - display: flex; - align-items: center; - gap: 10px; - } - .composer-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -152,6 +190,9 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string } .toolbar-group { + display: flex; + align-items: center; + gap: 10px; min-width: 0; } @@ -164,11 +205,8 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string } .icon-button, - .pill-button, .send-button, - .local-button, - .menu-row, - .menu-button { + .menu-row { border: 0; color: var(--vscode-foreground); background: transparent; @@ -190,19 +228,13 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string } .icon-button:hover, - .pill-button:hover, - .local-button:hover, - .menu-row:hover, - .menu-button:hover { + .menu-row:hover { background: var(--vscode-toolbar-hoverBackground); } .icon-button:focus-visible, - .pill-button:focus-visible, .send-button:focus-visible, - .local-button:focus-visible, - .menu-row:focus-visible, - .menu-button:focus-visible { + .menu-row:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 2px; } @@ -211,25 +243,39 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string outline: none; } - .pill-button, - .local-button { - min-height: 34px; - padding: 0 9px; - border-radius: 9px; - } - - .pill-button { + .model-chip { display: inline-flex; align-items: center; - gap: 8px; - min-width: 0; + gap: 6px; + min-height: 30px; + padding: 0 8px; + border: 0; + border-radius: 9px; color: var(--vscode-descriptionForeground); + background: transparent; white-space: nowrap; + cursor: pointer; + font: inherit; } - .pill-button strong { - color: var(--vscode-foreground); - font-weight: 500; + .model-chip:hover { + background: var(--vscode-toolbar-hoverBackground); + } + + .model-chip:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; + } + + .model-chip:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + .chevron { + width: 14px; + height: 14px; + opacity: 0.8; } .send-button { @@ -245,22 +291,11 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string .send-button:disabled, textarea:disabled, - .pill-button:disabled, .icon-button:disabled { cursor: not-allowed; opacity: 0.55; } - .local-button { - justify-self: start; - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 30px; - padding-inline: 8px; - color: var(--vscode-descriptionForeground); - } - .composer-icon { width: 18px; height: 18px; @@ -283,12 +318,6 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string stroke-width: 2.4; } - .chevron { - width: 14px; - height: 14px; - opacity: 0.86; - } - .menu { position: absolute; z-index: 10; @@ -310,33 +339,11 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string bottom: 54px; } - .plugins-menu { - left: 254px; - bottom: 56px; - min-width: 220px; - } - - .model-menu { - right: 50px; - bottom: 54px; - min-width: 280px; - } - - .menu-title { - padding: 7px 10px; - color: var(--vscode-descriptionForeground); - } - - .menu-divider { - height: 1px; - margin: 6px 8px; - background: var(--vscode-widget-border); - } - - .menu-row, - .menu-button { + .menu-row { display: flex; align-items: center; + justify-content: space-between; + gap: 16px; width: 100%; min-height: 36px; padding: 7px 10px; @@ -344,21 +351,6 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string text-align: left; } - .menu-row { - justify-content: space-between; - gap: 16px; - } - - .menu-button { - justify-content: space-between; - gap: 12px; - } - - .menu-row[aria-disabled="true"], - .menu-button[aria-disabled="true"] { - opacity: 0.52; - } - .menu-leading { display: inline-flex; align-items: center; @@ -417,10 +409,6 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string transform: translateX(16px); } - .checkmark { - color: var(--vscode-button-background); - } - @media (max-width: 520px) { body { padding: 12px; @@ -435,35 +423,9 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string } .composer-shell { - min-height: 136px; + min-height: 124px; border-radius: 14px; } - - .composer-toolbar { - display: flex; - align-items: stretch; - flex-direction: column; - } - - .toolbar-group { - justify-content: space-between; - } - - .toolbar-group.secondary { - justify-self: stretch; - } - - .pill-button { - min-width: 0; - } - - .model-menu, - .plugins-menu { - left: 0; - right: auto; - bottom: 54px; - width: min(100%, 300px); - } } CodeSetu @@ -489,28 +451,10 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string -
    -
    - - - @@ -664,61 +495,133 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string const transcript = document.getElementById("transcript"); const composerMenuToggle = document.getElementById("composer-menu-toggle"); const composerMenu = document.getElementById("composer-menu"); - const pluginsMenuToggle = document.getElementById("plugins-menu-toggle"); - const pluginsMenu = document.getElementById("plugins-menu"); - const modelMenuToggle = document.getElementById("model-menu-toggle"); - const modelMenu = document.getElementById("model-menu"); - const modelLabel = document.getElementById("model-label"); const includeContext = document.getElementById("include-context"); - const reasoningOptions = [...document.querySelectorAll(".reasoning-option")]; + const modelChip = document.getElementById("model-chip"); + const modelLabel = document.getElementById("model-label"); let activeAssistantMessage; + let activeAssistantRaw = ""; + + const NEWLINE = String.fromCharCode(10); + const FENCE = String.fromCharCode(96, 96, 96); + const TICK = String.fromCharCode(96); + + function escapeHtml(value) { + return String(value) + .split("&").join("&") + .split("<").join("<") + .split(">").join(">"); + } + + function renderInline(escaped) { + let bolded = ""; + let rest = escaped; + while (true) { + const open = rest.indexOf("**"); + if (open === -1) { bolded += rest; break; } + const close = rest.indexOf("**", open + 2); + if (close === -1) { bolded += rest; break; } + bolded += rest.slice(0, open) + "" + rest.slice(open + 2, close) + ""; + rest = rest.slice(close + 2); + } + + let out = ""; + let seg = bolded; + while (true) { + const start = seg.indexOf(TICK); + if (start === -1) { out += seg; break; } + const end = seg.indexOf(TICK, start + 1); + if (end === -1) { out += seg; break; } + out += seg.slice(0, start) + "" + seg.slice(start + 1, end) + ""; + seg = seg.slice(end + 1); + } + return out; + } + + function renderProse(escaped) { + if (escaped.split(NEWLINE).join("").trim() === "") { + return ""; + } + const lines = escaped.split(NEWLINE); + let out = ""; + let inList = false; + for (const line of lines) { + const trimmed = line.trimStart(); + const isItem = trimmed.indexOf("- ") === 0 || trimmed.indexOf("* ") === 0; + if (isItem) { + if (!inList) { out += "
      "; inList = true; } + out += "
    • " + renderInline(trimmed.slice(2)) + "
    • "; + } else { + if (inList) { out += "
    "; inList = false; } + out += line.trim() === "" ? "
    " : renderInline(line) + "
    "; + } + } + if (inList) { out += "
"; } + return out; + } + + // Renders a safe subset of markdown. All model text is HTML-escaped before + // any tags are introduced, so this never injects untrusted markup. + function renderMarkdown(raw) { + const parts = String(raw).split(FENCE); + let html = ""; + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 1) { + let block = parts[i]; + const firstNewline = block.indexOf(NEWLINE); + let code = firstNewline === -1 ? block : block.slice(firstNewline + 1); + if (code.length > 0 && code.charAt(code.length - 1) === NEWLINE) { + code = code.slice(0, code.length - 1); + } + html += "
" + escapeHtml(code) + "
"; + } else { + html += renderProse(escapeHtml(parts[i])); + } + } + return html; + } function appendMessage(kind, text) { const message = document.createElement("article"); message.className = "message " + kind; - message.textContent = text; + if (kind === "assistant") { + message.innerHTML = renderMarkdown(text); + } else { + message.textContent = text; + } transcript.appendChild(message); message.scrollIntoView({ block: "end", behavior: "smooth" }); return message; } + function startAssistantMessage() { + activeAssistantRaw = ""; + activeAssistantMessage = appendMessage("assistant", ""); + return activeAssistantMessage; + } + function appendAssistantDelta(text) { if (!activeAssistantMessage) { - activeAssistantMessage = appendMessage("assistant", ""); + startAssistantMessage(); } - activeAssistantMessage.textContent += text; + activeAssistantRaw += text; + activeAssistantMessage.innerHTML = renderMarkdown(activeAssistantRaw); activeAssistantMessage.scrollIntoView({ block: "end", behavior: "smooth" }); } - function setMenuOpen(menu, toggle, isOpen) { - menu.hidden = !isOpen; - toggle.setAttribute("aria-expanded", String(isOpen)); - } - - function closeMenus() { - setMenuOpen(composerMenu, composerMenuToggle, false); - setMenuOpen(pluginsMenu, pluginsMenuToggle, false); - setMenuOpen(modelMenu, modelMenuToggle, false); + function setMenuOpen(isOpen) { + composerMenu.hidden = !isOpen; + composerMenuToggle.setAttribute("aria-expanded", String(isOpen)); } composerMenuToggle.addEventListener("click", (event) => { event.stopPropagation(); - const isOpen = composerMenu.hidden; - closeMenus(); - setMenuOpen(composerMenu, composerMenuToggle, isOpen); + setMenuOpen(composerMenu.hidden); }); - pluginsMenuToggle.addEventListener("click", (event) => { - event.stopPropagation(); - setMenuOpen(pluginsMenu, pluginsMenuToggle, pluginsMenu.hidden); - }); - - modelMenuToggle.addEventListener("click", (event) => { - event.stopPropagation(); - const isOpen = modelMenu.hidden; - closeMenus(); - setMenuOpen(modelMenu, modelMenuToggle, isOpen); + modelChip.addEventListener("click", () => { + setMenuOpen(false); + vscode.postMessage({ type: "selectModel" }); }); document.addEventListener("click", (event) => { @@ -728,30 +631,11 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string return; } - if (!target.closest(".menu") && !target.closest(".pill-button") && !target.closest(".icon-button")) { - closeMenus(); + if (!target.closest(".menu") && !target.closest(".icon-button")) { + setMenuOpen(false); } }); - reasoningOptions.forEach((option) => { - option.addEventListener("click", () => { - const reasoning = option.dataset.reasoning; - modelLabel.innerHTML = "5.5  " + reasoning; - reasoningOptions.forEach((candidate) => { - const marker = candidate.querySelector(".checkmark"); - if (marker) { - marker.remove(); - } - }); - const marker = document.createElement("span"); - marker.className = "checkmark"; - marker.setAttribute("aria-hidden", "true"); - marker.textContent = "\\u2713"; - option.appendChild(marker); - closeMenus(); - }); - }); - form.addEventListener("submit", (event) => { event.preventDefault(); const text = textarea.value.trim(); @@ -761,7 +645,7 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string } textarea.value = ""; - closeMenus(); + setMenuOpen(false); vscode.postMessage({ type: "sendMessage", text, @@ -774,16 +658,17 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string if (message.type === "assistantMessage") { if (activeAssistantMessage) { - activeAssistantMessage.textContent = message.text; + activeAssistantMessage.innerHTML = renderMarkdown(message.text); activeAssistantMessage.scrollIntoView({ block: "end", behavior: "smooth" }); activeAssistantMessage = undefined; + activeAssistantRaw = ""; } else { appendMessage("assistant", message.text); } } if (message.type === "assistantMessageStart") { - activeAssistantMessage = appendMessage("assistant", ""); + startAssistantMessage(); } if (message.type === "assistantMessageDelta") { @@ -792,6 +677,7 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string if (message.type === "assistantMessageDone") { activeAssistantMessage = undefined; + activeAssistantRaw = ""; } if (message.type === "userMessage") { @@ -802,12 +688,16 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string appendMessage("error", message.text); } + if (message.type === "modelLabel") { + modelLabel.textContent = message.text; + } + if (message.type === "busy") { const isBusy = Boolean(message.value); send.disabled = isBusy; textarea.disabled = isBusy; composerMenuToggle.disabled = isBusy; - modelMenuToggle.disabled = isBusy; + modelChip.disabled = isBusy; } }); diff --git a/apps/vscode/src/configuration.ts b/apps/vscode/src/configuration.ts index 94f6cbd..5125996 100644 --- a/apps/vscode/src/configuration.ts +++ b/apps/vscode/src/configuration.ts @@ -15,6 +15,8 @@ */ import { + DEFAULT_HUGGINGFACE_BASE_URL, + DEFAULT_HUGGINGFACE_MODEL, DEFAULT_OPENAI_COMPATIBLE_BASE_URL, DEFAULT_OPENAI_COMPATIBLE_MODEL, DEFAULT_SARVAM_BASE_URL, @@ -49,7 +51,9 @@ export function readCodeSetuConfiguration(): CodeSetuConfiguration { return { providerOptions: { provider: readProvider(configuration), - apiKey: readOptionalString(configuration, "apiKey"), + // The API key is intentionally NOT read from settings. It lives in the OS + // secret store and is injected by the extension host (see secretStorage.ts). + // The core provider falls back to env vars when apiKey is undefined. baseURL: readOptionalString(configuration, "baseUrl"), model: readOptionalString(configuration, "model"), }, @@ -67,10 +71,30 @@ export function readCodeSetuConfiguration(): CodeSetuConfiguration { }; } -export function summarizeCodeSetuConfiguration(): CodeSetuConfigurationSummary { +export function summarizeCodeSetuConfiguration( + secretApiKey?: string, +): CodeSetuConfigurationSummary { const configuration = readCodeSetuConfiguration(); - const provider = - configuration.providerOptions.provider === "openai-compatible" ? "openai-compatible" : "sarvam"; + const provider = normalizeProvider(configuration.providerOptions.provider); + + if (provider === "huggingface") { + return { + provider, + baseURL: + firstConfigValue( + configuration.providerOptions.baseURL, + process.env.HF_BASE_URL, + DEFAULT_HUGGINGFACE_BASE_URL, + ) ?? DEFAULT_HUGGINGFACE_BASE_URL, + model: + firstConfigValue( + configuration.providerOptions.model, + process.env.HF_MODEL, + DEFAULT_HUGGINGFACE_MODEL, + ) ?? DEFAULT_HUGGINGFACE_MODEL, + hasApiKey: hasConfigValue(secretApiKey, process.env.HF_TOKEN, process.env.CODESETU_API_KEY), + }; + } if (provider === "sarvam") { return { @@ -90,7 +114,7 @@ export function summarizeCodeSetuConfiguration(): CodeSetuConfigurationSummary { DEFAULT_SARVAM_MODEL, ) ?? DEFAULT_SARVAM_MODEL, hasApiKey: hasConfigValue( - configuration.providerOptions.apiKey, + secretApiKey, process.env.SARVAM_API_KEY, process.env.CODESETU_API_KEY, ), @@ -111,14 +135,16 @@ export function summarizeCodeSetuConfiguration(): CodeSetuConfigurationSummary { process.env.CODESETU_MODEL, DEFAULT_OPENAI_COMPATIBLE_MODEL, ) ?? DEFAULT_OPENAI_COMPATIBLE_MODEL, - hasApiKey: hasConfigValue(configuration.providerOptions.apiKey, process.env.CODESETU_API_KEY), + hasApiKey: hasConfigValue(secretApiKey, process.env.CODESETU_API_KEY), }; } function readProvider(configuration: vscode.WorkspaceConfiguration): ProviderId { - const provider = configuration.get("provider", "sarvam"); + return normalizeProvider(configuration.get("provider", "sarvam")); +} - if (provider === "openai-compatible") { +function normalizeProvider(provider: string | undefined): ProviderId { + if (provider === "openai-compatible" || provider === "huggingface") { return provider; } diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 3cca501..6582762 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -21,6 +21,7 @@ import { type ChatCompletionRequest, type ChatMessage, type IdeContextPayload, + type ProviderFactoryOptions, type WorkspaceInstruction, } from "@codesetu/core"; import * as vscode from "vscode"; @@ -32,11 +33,13 @@ import { registerCodeSetuEditorActions } from "./codeActions"; import { CodeSetuInlineCompletionProvider } from "./completionProvider"; import { readCodeSetuConfiguration, summarizeCodeSetuConfiguration } from "./configuration"; import { collectVSCodeContext } from "./ideContext"; +import { selectCodeSetuModel } from "./modelPicker"; import { formatChatProviderLine, runCodeSetuProviderDiagnostics } from "./providerDiagnostics"; import { setupCodeSetuProvider } from "./providerSetup"; +import { getStoredApiKey, migrateApiKeyFromConfiguration } from "./secretStorage"; import { loadWorkspaceInstructions } from "./workspaceInstructions"; -export function activate(context: vscode.ExtensionContext): void { +export async function activate(context: vscode.ExtensionContext): Promise { const outputChannel = vscode.window.createOutputChannel("CodeSetu"); const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); statusBarItem.text = "CodeSetu: Ready"; @@ -44,10 +47,30 @@ export function activate(context: vscode.ExtensionContext): void { statusBarItem.command = "codesetu.openChat"; statusBarItem.show(); + // The API key lives in the OS secret store. Migrate any legacy plaintext value + // out of settings.json, then keep an in-memory copy refreshed on change so the + // synchronous provider-creation hot paths (inline completions) stay fast. + await migrateApiKeyFromConfiguration(context.secrets); + let apiKey = await getStoredApiKey(context.secrets); + context.subscriptions.push( + context.secrets.onDidChange((event) => { + if (event.key === "codesetu.apiKey") { + void getStoredApiKey(context.secrets).then((value) => { + apiKey = value; + }); + } + }), + ); + + const buildProviderOptions = (): ProviderFactoryOptions => ({ + ...readCodeSetuConfiguration().providerOptions, + apiKey, + }); + const inlineCompletionProvider = vscode.languages.registerInlineCompletionItemProvider( { scheme: "file" }, new CodeSetuInlineCompletionProvider({ - createProvider: () => createProvider(readCodeSetuConfiguration().providerOptions), + createProvider: () => createProvider(buildProviderOptions()), getConfiguration: readCodeSetuConfiguration, outputChannel, }), @@ -61,6 +84,7 @@ export function activate(context: vscode.ExtensionContext): void { const responder: ChatResponder = async (messages, requestContext) => sendChatRequest( messages, + buildProviderOptions(), statusBarItem, outputChannel, await loadInstructions(), @@ -72,13 +96,16 @@ export function activate(context: vscode.ExtensionContext): void { const openChatCommand = vscode.commands.registerCommand("codesetu.openChat", () => { ChatPanel.createOrShow(context.extensionUri, responder, outputChannel); }); - const setupProviderCommand = vscode.commands.registerCommand( - "codesetu.setupProvider", - setupCodeSetuProvider, + const setupProviderCommand = vscode.commands.registerCommand("codesetu.setupProvider", () => + setupCodeSetuProvider(context.secrets), ); const diagnoseProviderCommand = vscode.commands.registerCommand("codesetu.diagnoseProvider", () => - runCodeSetuProviderDiagnostics(outputChannel), + runCodeSetuProviderDiagnostics(outputChannel, apiKey), ); + const selectModelCommand = vscode.commands.registerCommand("codesetu.selectModel", async () => { + await selectCodeSetuModel(); + ChatPanel.refreshModelLabel(); + }); const editorActions = registerCodeSetuEditorActions({ context, @@ -98,6 +125,7 @@ export function activate(context: vscode.ExtensionContext): void { openChatCommand, setupProviderCommand, diagnoseProviderCommand, + selectModelCommand, ...editorActions, homeView, ); @@ -109,6 +137,7 @@ export function deactivate(): void { async function sendChatRequest( messages: ChatMessage[], + providerOptions: ProviderFactoryOptions, statusBarItem: vscode.StatusBarItem, outputChannel: vscode.OutputChannel, instructions: readonly WorkspaceInstruction[] = [], @@ -116,8 +145,10 @@ async function sendChatRequest( onChunk?: (chunk: string) => void, ): Promise { const configuration = readCodeSetuConfiguration(); - outputChannel.appendLine(formatChatProviderLine(summarizeCodeSetuConfiguration())); - const provider = createProvider(configuration.providerOptions); + outputChannel.appendLine( + formatChatProviderLine(summarizeCodeSetuConfiguration(providerOptions.apiKey)), + ); + const provider = createProvider(providerOptions); statusBarItem.text = "CodeSetu: Thinking"; const contextualMessages = hasIdeContext(ideContext) ? [ diff --git a/apps/vscode/src/ideContext.ts b/apps/vscode/src/ideContext.ts index 2c93eee..e13e07b 100644 --- a/apps/vscode/src/ideContext.ts +++ b/apps/vscode/src/ideContext.ts @@ -78,7 +78,8 @@ async function collectWorkspaceSnippets( ): Promise { const files = await vscode.workspace.findFiles( "**/*.{ts,tsx,js,jsx,py,java,kt,go,rs,md,json,yml,yaml}", - "{**/node_modules/**,**/dist/**,**/build/**,**/.git/**}", + // Skip build output and likely-secret files so they aren't auto-sent to the provider. + "{**/node_modules/**,**/dist/**,**/build/**,**/.git/**,**/.env*,**/*.pem,**/*.key,**/*.pfx,**/*.p12,**/secrets/**,**/.aws/**,**/id_rsa*}", 8, ); const snippets: WorkspaceSnippet[] = []; diff --git a/apps/vscode/src/modelPicker.ts b/apps/vscode/src/modelPicker.ts new file mode 100644 index 0000000..abe8549 --- /dev/null +++ b/apps/vscode/src/modelPicker.ts @@ -0,0 +1,91 @@ +/** + * Copyright 2026 CodeSetu Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as vscode from "vscode"; + +import { summarizeCodeSetuConfiguration } from "./configuration"; + +const CONFIGURE_PROVIDER_LABEL = "$(gear) Configure provider (base URL, API key)…"; +const CUSTOM_ENTRY_LABEL = "$(edit) Enter a custom model id…"; + +// A short, hand-picked set of chat models that the Hugging Face router reliably +// serves. Users can always pick "Enter a custom model id…" for anything else +// (any Hub repo id, a dedicated endpoint's model, etc.). +const HUGGINGFACE_MODELS = [ + "meta-llama/Llama-3.3-70B-Instruct", + "Qwen/Qwen2.5-72B-Instruct", + "Qwen/Qwen2.5-Coder-32B-Instruct", + "deepseek-ai/DeepSeek-V3-0324", + "meta-llama/Llama-3.1-8B-Instruct", + "google/gemma-2-27b-it", + "mistralai/Mistral-Small-24B-Instruct-2501", +]; + +export async function selectCodeSetuModel(): Promise { + const summary = summarizeCodeSetuConfiguration(); + const current = summary.model; + const suggestions = summary.provider === "huggingface" ? HUGGINGFACE_MODELS : []; + const ordered = dedupeWithCurrentFirst(suggestions, current); + + const items: vscode.QuickPickItem[] = [ + { label: CONFIGURE_PROVIDER_LABEL, alwaysShow: true }, + { label: CUSTOM_ENTRY_LABEL, alwaysShow: true }, + ...ordered.map( + (model): vscode.QuickPickItem => + model === current ? { label: model, description: "current" } : { label: model }, + ), + ]; + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: `Select a model for ${summary.provider}${current ? ` (current: ${current})` : ""}`, + matchOnDescription: true, + }); + + if (picked === undefined) { + return; + } + + if (picked.label === CONFIGURE_PROVIDER_LABEL) { + // Hand off to the full provider setup (provider, base URL, model, token). + await vscode.commands.executeCommand("codesetu.setupProvider"); + return; + } + + const model = + picked.label === CUSTOM_ENTRY_LABEL + ? await vscode.window.showInputBox({ + prompt: summary.provider === "huggingface" ? "Hugging Face model repo id" : "Model id", + value: current ?? "", + ignoreFocusOut: true, + }) + : picked.label; + + const trimmed = model?.trim(); + + if (trimmed === undefined || trimmed.length === 0) { + return; + } + + await vscode.workspace + .getConfiguration("codesetu") + .update("model", trimmed, vscode.ConfigurationTarget.Global); + void vscode.window.showInformationMessage(`CodeSetu model set to ${trimmed}.`); +} + +function dedupeWithCurrentFirst(models: string[], current: string | undefined): string[] { + const withCurrent = current !== undefined && current.length > 0 ? [current, ...models] : models; + return [...new Set(withCurrent)]; +} diff --git a/apps/vscode/src/providerDiagnostics.ts b/apps/vscode/src/providerDiagnostics.ts index f96323d..7c75973 100644 --- a/apps/vscode/src/providerDiagnostics.ts +++ b/apps/vscode/src/providerDiagnostics.ts @@ -40,14 +40,15 @@ export function formatChatProviderLine(summary: CodeSetuConfigurationSummary): s export async function runCodeSetuProviderDiagnostics( outputChannel: vscodeTypes.OutputChannel, + secretApiKey?: string, ): Promise { const vscode: VSCodeApi = await import("vscode"); const { readCodeSetuConfiguration, summarizeCodeSetuConfiguration } = await import("./configuration"); const configuration = readCodeSetuConfiguration(); - const summary = summarizeCodeSetuConfiguration(); + const summary = summarizeCodeSetuConfiguration(secretApiKey); const result = await diagnoseProvider({ - providerOptions: configuration.providerOptions, + providerOptions: { ...configuration.providerOptions, apiKey: secretApiKey }, createProvider: (providerOptions) => createProvider(providerOptions), }); diff --git a/apps/vscode/src/providerSetup.ts b/apps/vscode/src/providerSetup.ts index 116f932..94bff6d 100644 --- a/apps/vscode/src/providerSetup.ts +++ b/apps/vscode/src/providerSetup.ts @@ -10,9 +10,35 @@ import * as vscode from "vscode"; -const DEFAULT_SARVAM_CHAT_MODEL = "sarvam-30b"; +import { storeApiKey } from "./secretStorage"; -export async function setupCodeSetuProvider(): Promise { +interface ProviderSetupDefaults { + baseUrl: string; + model: string; + apiKeyPrompt: string; +} + +const SARVAM_DEFAULTS: ProviderSetupDefaults = { + baseUrl: "https://api.sarvam.ai/v1", + model: "sarvam-30b", + apiKeyPrompt: "Sarvam API key", +}; + +const PROVIDER_DEFAULTS: Record = { + sarvam: SARVAM_DEFAULTS, + "openai-compatible": { + baseUrl: "http://localhost:11434/v1", + model: "qwen2.5-coder:7b", + apiKeyPrompt: "API key (use 'ollama' for a local Ollama server)", + }, + huggingface: { + baseUrl: "https://router.huggingface.co/v1", + model: "meta-llama/Llama-3.3-70B-Instruct", + apiKeyPrompt: "Hugging Face token (hf_...)", + }, +}; + +export async function setupCodeSetuProvider(secrets: vscode.SecretStorage): Promise { const provider = await vscode.window.showQuickPick( [ { label: "sarvam", description: "Sarvam hosted or compatible endpoint" }, @@ -20,6 +46,10 @@ export async function setupCodeSetuProvider(): Promise { label: "openai-compatible", description: "Ollama, vLLM, SGLang, OpenRouter, or compatible API", }, + { + label: "huggingface", + description: "Hugging Face router, a dedicated Inference Endpoint, or self-hosted TGI", + }, ], { placeHolder: "Choose a CodeSetu provider" }, ); @@ -28,9 +58,11 @@ export async function setupCodeSetuProvider(): Promise { return; } + const defaults = PROVIDER_DEFAULTS[provider.label] ?? SARVAM_DEFAULTS; + const baseUrl = await vscode.window.showInputBox({ prompt: "Base URL", - value: provider.label === "sarvam" ? "https://api.sarvam.ai/v1" : "http://localhost:11434/v1", + value: defaults.baseUrl, }); if (baseUrl === undefined) { @@ -38,8 +70,8 @@ export async function setupCodeSetuProvider(): Promise { } const model = await vscode.window.showInputBox({ - prompt: "Model id", - value: provider.label === "openai-compatible" ? "qwen2.5-coder:7b" : DEFAULT_SARVAM_CHAT_MODEL, + prompt: provider.label === "huggingface" ? "Model id (Hugging Face repo id)" : "Model id", + value: defaults.model, }); if (model === undefined) { @@ -48,7 +80,7 @@ export async function setupCodeSetuProvider(): Promise { const apiKey = await vscode.window.showInputBox({ password: true, - prompt: "API key", + prompt: defaults.apiKeyPrompt, value: provider.label === "openai-compatible" && baseUrl.includes("localhost") ? "ollama" : "", }); @@ -60,7 +92,8 @@ export async function setupCodeSetuProvider(): Promise { await configuration.update("provider", provider.label, vscode.ConfigurationTarget.Global); await configuration.update("baseUrl", baseUrl.trim(), vscode.ConfigurationTarget.Global); await configuration.update("model", model.trim(), vscode.ConfigurationTarget.Global); - await configuration.update("apiKey", apiKey.trim(), vscode.ConfigurationTarget.Global); + // The API key goes to the OS secret store, never to settings.json. + await storeApiKey(secrets, apiKey); void vscode.window.showInformationMessage("CodeSetu provider settings updated."); } diff --git a/apps/vscode/src/secretStorage.ts b/apps/vscode/src/secretStorage.ts new file mode 100644 index 0000000..b9abbc9 --- /dev/null +++ b/apps/vscode/src/secretStorage.ts @@ -0,0 +1,92 @@ +/** + * Copyright 2026 CodeSetu Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as vscode from "vscode"; + +/** Key under which the provider API key is stored in {@link vscode.SecretStorage}. */ +export const API_KEY_SECRET = "codesetu.apiKey"; + +/** Reads the stored API key, returning `undefined` when none is set. */ +export async function getStoredApiKey(secrets: vscode.SecretStorage): Promise { + const value = (await secrets.get(API_KEY_SECRET))?.trim(); + return value === undefined || value.length === 0 ? undefined : value; +} + +/** Stores (or clears, when blank) the provider API key. */ +export async function storeApiKey(secrets: vscode.SecretStorage, apiKey: string): Promise { + const trimmed = apiKey.trim(); + + if (trimmed.length === 0) { + await secrets.delete(API_KEY_SECRET); + return; + } + + await secrets.store(API_KEY_SECRET, trimmed); +} + +/** + * Moves any API key left in the (now deprecated) `codesetu.apiKey` setting into + * the OS secret store, then clears the plaintext copy. Safe to call on every + * activation; it is a no-op once the setting is empty. + */ +export async function migrateApiKeyFromConfiguration(secrets: vscode.SecretStorage): Promise { + const configuration = vscode.workspace.getConfiguration("codesetu"); + const inspected = configuration.inspect("apiKey"); + const legacyValue = ( + inspected?.workspaceFolderValue ?? + inspected?.workspaceValue ?? + inspected?.globalValue ?? + "" + ).trim(); + + if (legacyValue.length === 0) { + return; + } + + if ((await getStoredApiKey(secrets)) === undefined) { + await storeApiKey(secrets, legacyValue); + } + + await clearConfigurationApiKey(configuration, inspected); +} + +async function clearConfigurationApiKey( + configuration: vscode.WorkspaceConfiguration, + inspected: ReturnType, +): Promise { + const targets: vscode.ConfigurationTarget[] = []; + + if (inspected?.globalValue !== undefined) { + targets.push(vscode.ConfigurationTarget.Global); + } + + if (inspected?.workspaceValue !== undefined) { + targets.push(vscode.ConfigurationTarget.Workspace); + } + + if (inspected?.workspaceFolderValue !== undefined) { + targets.push(vscode.ConfigurationTarget.WorkspaceFolder); + } + + for (const target of targets) { + // Best-effort: clearing a workspace-scoped value fails when no workspace is + // open, which is fine — the value simply stays until a workspace is loaded. + await Promise.resolve(configuration.update("apiKey", undefined, target)).then( + undefined, + () => undefined, + ); + } +} diff --git a/apps/vscode/test/chatPanelHtml.test.ts b/apps/vscode/test/chatPanelHtml.test.ts index d25046a..27fedd4 100644 --- a/apps/vscode/test/chatPanelHtml.test.ts +++ b/apps/vscode/test/chatPanelHtml.test.ts @@ -19,26 +19,69 @@ import { describe, expect, it } from "vitest"; import { renderChatPanelHtml } from "../src/chatPanelHtml"; describe("renderChatPanelHtml", () => { - it("renders the polished composer controls", () => { + it("renders the composer with the real configured model and the IDE-context toggle", () => { const html = renderChatPanelHtml({ cspSource: "vscode-resource:", nonce: "test-nonce", + modelLabel: "sarvam · sarvam-30b", }); expect(html).toContain('class="composer-shell"'); expect(html).toContain('id="composer-menu-toggle"'); expect(html).toContain("Include IDE context"); expect(html).toContain('id="include-context"'); - expect(html).toContain('id="model-menu-toggle"'); - expect(html).toContain("Extra High"); - expect(html).toContain("Work locally"); + expect(html).toContain('id="model-chip"'); + expect(html).toContain('aria-label="Select model"'); + expect(html).toContain("sarvam · sarvam-30b"); expect(html).toContain('aria-label="Send message"'); }); + it("makes the model chip request a model switch", () => { + const html = renderChatPanelHtml({ + cspSource: "vscode-resource:", + nonce: "test-nonce", + modelLabel: "huggingface · meta-llama/Llama-3.3-70B-Instruct", + }); + + expect(html).toContain('type: "selectModel"'); + expect(html).toContain('id="model-label"'); + expect(html).toContain('message.type === "modelLabel"'); + }); + + it("does not advertise non-functional or fictional controls", () => { + const html = renderChatPanelHtml({ + cspSource: "vscode-resource:", + nonce: "test-nonce", + modelLabel: "sarvam · sarvam-30b", + }); + + expect(html).not.toContain("Extra High"); + expect(html).not.toContain("GPT-5.5"); + expect(html).not.toContain("Work locally"); + expect(html).not.toContain("Default permissions"); + expect(html).not.toContain("Plan mode"); + expect(html).not.toContain('id="model-menu-toggle"'); + expect(html).not.toContain('id="plugins-menu-toggle"'); + }); + + it("escapes the model label and renders assistant text through safe markdown", () => { + const html = renderChatPanelHtml({ + cspSource: "vscode-resource:", + nonce: "test-nonce", + modelLabel: "", + }); + + expect(html).not.toContain(""); + expect(html).toContain("<script>evil</script>"); + expect(html).toContain("function renderMarkdown"); + expect(html).toContain(".assistant pre"); + }); + it("uses the composer shell as the focus surface with real icon markup", () => { const html = renderChatPanelHtml({ cspSource: "vscode-resource:", nonce: "test-nonce", + modelLabel: "sarvam · sarvam-30b", }); expect(html).toContain(".composer-shell:focus-within"); @@ -46,8 +89,5 @@ describe("renderChatPanelHtml", () => { expect(html).toContain("outline: none;"); expect(html).toContain('data-icon="plus"'); expect(html).toContain('data-icon="send"'); - expect(html).toContain('data-icon="chevron-down"'); - expect(html).not.toContain(''); - expect(html).not.toContain(''); }); }); diff --git a/apps/vscode/test/packageCommands.test.ts b/apps/vscode/test/packageCommands.test.ts index 060ef91..7842070 100644 --- a/apps/vscode/test/packageCommands.test.ts +++ b/apps/vscode/test/packageCommands.test.ts @@ -33,6 +33,7 @@ describe("VS Code command contributions", () => { "codesetu.openChat", "codesetu.setupProvider", "codesetu.diagnoseProvider", + "codesetu.selectModel", "codesetu.explainSelection", "codesetu.refactorSelection", "codesetu.writeTestsForSelection", diff --git a/packages/core/src/ide/diagnostics.ts b/packages/core/src/ide/diagnostics.ts index 681e594..e74e9a2 100644 --- a/packages/core/src/ide/diagnostics.ts +++ b/packages/core/src/ide/diagnostics.ts @@ -14,6 +14,11 @@ * limitations under the License. */ +import { + DEFAULT_HUGGINGFACE_BASE_URL, + DEFAULT_HUGGINGFACE_MODEL, + DEFAULT_HUGGINGFACE_PROVIDER, +} from "../providers/huggingface.js"; import { DEFAULT_OPENAI_COMPATIBLE_BASE_URL, DEFAULT_OPENAI_COMPATIBLE_MODEL, @@ -124,6 +129,24 @@ function resolveDiagnosticMetadata(providerOptions: { }; } + if (provider === DEFAULT_HUGGINGFACE_PROVIDER) { + return { + provider, + baseURL: + firstConfigValue( + providerOptions.baseURL, + process.env.HF_BASE_URL, + DEFAULT_HUGGINGFACE_BASE_URL, + ) ?? DEFAULT_HUGGINGFACE_BASE_URL, + model: resolveModel(providerOptions.model, process.env.HF_MODEL, DEFAULT_HUGGINGFACE_MODEL), + hasApiKey: hasConfigValue( + providerOptions.apiKey, + process.env.HF_TOKEN, + process.env.CODESETU_API_KEY, + ), + }; + } + return { provider, baseURL: firstConfigValue(providerOptions.baseURL, process.env.CODESETU_BASE_URL) ?? "", @@ -151,6 +174,12 @@ function getMissingConfigMessage(metadata: DiagnosticMetadata): string | undefin } } + if (provider === DEFAULT_HUGGINGFACE_PROVIDER) { + if (!metadata.hasApiKey) { + return "A Hugging Face token is required before CodeSetu can create the provider."; + } + } + return undefined; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 77ba7ce..b54b082 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,14 @@ export type { FimCompletionRequest, LlmProvider, } from "./providers/base.js"; +export { + DEFAULT_HUGGINGFACE_BASE_URL, + DEFAULT_HUGGINGFACE_MODEL, + DEFAULT_HUGGINGFACE_PROVIDER, + HuggingFaceProvider, + type HuggingFaceOpenAIClient, + type HuggingFaceProviderOptions, +} from "./providers/huggingface.js"; export { DEFAULT_OPENAI_COMPATIBLE_BASE_URL, DEFAULT_OPENAI_COMPATIBLE_MODEL, diff --git a/packages/core/src/providers/huggingface.ts b/packages/core/src/providers/huggingface.ts new file mode 100644 index 0000000..74eadb9 --- /dev/null +++ b/packages/core/src/providers/huggingface.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2026 CodeSetu Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OpenAICompatibleProvider, type OpenAICompatibleClient } from "./openaiCompatible.js"; + +export const DEFAULT_HUGGINGFACE_PROVIDER = "huggingface"; +// Hugging Face Inference Providers expose an OpenAI-compatible router. Users can +// also point this at a dedicated Inference Endpoint or a self-hosted TGI server +// by overriding the base URL. +export const DEFAULT_HUGGINGFACE_BASE_URL = "https://router.huggingface.co/v1"; +export const DEFAULT_HUGGINGFACE_MODEL = "meta-llama/Llama-3.3-70B-Instruct"; + +export type HuggingFaceOpenAIClient = OpenAICompatibleClient; + +export interface HuggingFaceProviderOptions { + apiKey?: string; + baseURL?: string; + model?: string; + client?: HuggingFaceOpenAIClient; +} + +export class HuggingFaceProvider extends OpenAICompatibleProvider { + public constructor(options: HuggingFaceProviderOptions = {}) { + super({ + providerId: DEFAULT_HUGGINGFACE_PROVIDER, + apiKey: options.apiKey, + apiKeyEnvVar: "HF_TOKEN", + baseURL: options.baseURL, + baseURLEnvVar: "HF_BASE_URL", + defaultBaseURL: DEFAULT_HUGGINGFACE_BASE_URL, + model: options.model, + modelEnvVar: "HF_MODEL", + defaultModel: DEFAULT_HUGGINGFACE_MODEL, + client: options.client, + }); + } +} diff --git a/packages/core/src/providers/registry.ts b/packages/core/src/providers/registry.ts index a0329dc..6f65b04 100644 --- a/packages/core/src/providers/registry.ts +++ b/packages/core/src/providers/registry.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { DEFAULT_HUGGINGFACE_PROVIDER, HuggingFaceProvider } from "./huggingface.js"; import { DEFAULT_OPENAI_COMPATIBLE_BASE_URL, DEFAULT_OPENAI_COMPATIBLE_MODEL, @@ -24,7 +25,10 @@ import { SarvamProvider } from "./sarvam.js"; export const DEFAULT_PROVIDER_ID = "sarvam"; -export type ProviderId = typeof DEFAULT_PROVIDER_ID | typeof DEFAULT_OPENAI_COMPATIBLE_PROVIDER; +export type ProviderId = + | typeof DEFAULT_PROVIDER_ID + | typeof DEFAULT_OPENAI_COMPATIBLE_PROVIDER + | typeof DEFAULT_HUGGINGFACE_PROVIDER; export interface ProviderFactoryOptions { provider?: string; @@ -33,9 +37,13 @@ export interface ProviderFactoryOptions { model?: string; } -export type ConfiguredProvider = SarvamProvider | OpenAICompatibleProvider; +export type ConfiguredProvider = SarvamProvider | OpenAICompatibleProvider | HuggingFaceProvider; -const providerIds = [DEFAULT_PROVIDER_ID, DEFAULT_OPENAI_COMPATIBLE_PROVIDER] as const; +const providerIds = [ + DEFAULT_PROVIDER_ID, + DEFAULT_OPENAI_COMPATIBLE_PROVIDER, + DEFAULT_HUGGINGFACE_PROVIDER, +] as const; export function listProviderIds(): ProviderId[] { return [...providerIds]; @@ -59,6 +67,14 @@ export function createProvider(options: ProviderFactoryOptions = {}): Configured }); } + if (provider === DEFAULT_HUGGINGFACE_PROVIDER) { + return new HuggingFaceProvider({ + apiKey: options.apiKey, + baseURL: options.baseURL, + model: options.model, + }); + } + throw new Error( `Unsupported provider "${provider}". Supported providers: ${listProviderIds().join(", ")}.`, ); diff --git a/packages/core/test/huggingface.test.ts b/packages/core/test/huggingface.test.ts new file mode 100644 index 0000000..497717b --- /dev/null +++ b/packages/core/test/huggingface.test.ts @@ -0,0 +1,189 @@ +/** + * Copyright 2026 CodeSetu Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from "vitest"; +import type { + ChatCompletion, + ChatCompletionChunk, + ChatCompletionCreateParamsNonStreaming, + ChatCompletionCreateParamsStreaming, +} from "openai/resources/chat/completions"; +import type { Completion, CompletionCreateParamsNonStreaming } from "openai/resources/completions"; + +import { + DEFAULT_HUGGINGFACE_BASE_URL, + DEFAULT_HUGGINGFACE_MODEL, + HuggingFaceProvider, + type HuggingFaceOpenAIClient, +} from "../src/providers/huggingface.js"; + +const chatResponse: ChatCompletion = { + id: "chatcmpl-test", + object: "chat.completion", + created: 0, + model: DEFAULT_HUGGINGFACE_MODEL, + choices: [], +}; + +const fimResponse: Completion = { + id: "cmpl-test", + object: "text_completion", + created: 0, + model: DEFAULT_HUGGINGFACE_MODEL, + choices: [], +}; + +function createMockClient(): { + client: HuggingFaceOpenAIClient; + chatCalls: Array; + completionCalls: CompletionCreateParamsNonStreaming[]; +} { + const chatCalls: Array< + ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming + > = []; + const completionCalls: CompletionCreateParamsNonStreaming[] = []; + + const client: HuggingFaceOpenAIClient = { + chat: { + completions: { + create: (( + params: ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming, + ) => { + chatCalls.push(params); + if (params.stream === true) { + return Promise.resolve(toAsyncIterable([chatChunk("Hello"), chatChunk(" from HF")])); + } + return Promise.resolve(chatResponse); + }) as HuggingFaceOpenAIClient["chat"]["completions"]["create"], + }, + }, + completions: { + create: (params) => { + completionCalls.push(params); + return Promise.resolve(fimResponse); + }, + }, + }; + + return { client, chatCalls, completionCalls }; +} + +async function* toAsyncIterable(chunks: ChatCompletionChunk[]): AsyncIterable { + await Promise.resolve(); + + for (const chunk of chunks) { + yield chunk; + } +} + +function chatChunk(content: string): ChatCompletionChunk { + return { + id: "chatcmpl-test", + object: "chat.completion.chunk", + created: 0, + model: DEFAULT_HUGGINGFACE_MODEL, + choices: [ + { + index: 0, + delta: { content, role: "assistant" }, + finish_reason: null, + logprobs: null, + }, + ], + }; +} + +describe("HuggingFaceProvider", () => { + it("defaults to the Hugging Face router and a served chat model", () => { + const { client } = createMockClient(); + const provider = new HuggingFaceProvider({ client }); + + expect(provider.providerId).toBe("huggingface"); + expect(provider.baseURL).toBe(DEFAULT_HUGGINGFACE_BASE_URL); + expect(provider.model).toBe(DEFAULT_HUGGINGFACE_MODEL); + }); + + it("targets a dedicated endpoint when given a base URL and model", () => { + const { client } = createMockClient(); + const provider = new HuggingFaceProvider({ + client, + baseURL: "https://abc123.endpoints.huggingface.cloud/v1", + model: "Qwen/Qwen2.5-Coder-32B-Instruct", + }); + + expect(provider.baseURL).toBe("https://abc123.endpoints.huggingface.cloud/v1"); + expect(provider.model).toBe("Qwen/Qwen2.5-Coder-32B-Instruct"); + }); + + it("requires a Hugging Face token when constructing the real client", () => { + const originalToken = process.env.HF_TOKEN; + const originalFallback = process.env.CODESETU_API_KEY; + delete process.env.HF_TOKEN; + delete process.env.CODESETU_API_KEY; + + try { + expect(() => new HuggingFaceProvider()).toThrow("HF_TOKEN"); + } finally { + restoreEnv("HF_TOKEN", originalToken); + restoreEnv("CODESETU_API_KEY", originalFallback); + } + }); + + it("does not send the Sarvam-only reasoning_effort field", async () => { + const { client, chatCalls } = createMockClient(); + const provider = new HuggingFaceProvider({ client }); + + await provider.chat({ + messages: [{ role: "user", content: "Hello there." }], + maxTokens: 128, + temperature: 0.2, + }); + + expect(chatCalls).toEqual([ + { + model: DEFAULT_HUGGINGFACE_MODEL, + messages: [{ role: "user", content: "Hello there." }], + max_tokens: 128, + temperature: 0.2, + }, + ]); + expect(chatCalls[0]).not.toHaveProperty("reasoning_effort"); + }); + + it("streams chat completion text chunks", async () => { + const { client } = createMockClient(); + const provider = new HuggingFaceProvider({ client }); + const chunks: string[] = []; + + for await (const chunk of provider.streamChat({ + messages: [{ role: "user", content: "Say hello." }], + maxTokens: 64, + temperature: 0.1, + })) { + chunks.push(chunk); + } + + expect(chunks).toEqual(["Hello", " from HF"]); + }); +}); + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} diff --git a/packages/core/test/providerFactory.test.ts b/packages/core/test/providerFactory.test.ts index 65d1a89..b6719be 100644 --- a/packages/core/test/providerFactory.test.ts +++ b/packages/core/test/providerFactory.test.ts @@ -18,9 +18,12 @@ import { describe, expect, it } from "vitest"; import type { ChatCompletion } from "openai/resources/chat/completions"; import { + DEFAULT_HUGGINGFACE_BASE_URL, + DEFAULT_HUGGINGFACE_MODEL, DEFAULT_OPENAI_COMPATIBLE_PROVIDER, DEFAULT_SARVAM_BASE_URL, DEFAULT_SARVAM_MODEL, + HuggingFaceProvider, OpenAICompatibleProvider, SarvamProvider, createProvider, @@ -50,7 +53,7 @@ const assistantResponse: ChatCompletion = { describe("provider factory", () => { it("lists built-in provider ids", () => { - expect(listProviderIds()).toEqual(["sarvam", "openai-compatible"]); + expect(listProviderIds()).toEqual(["sarvam", "openai-compatible", "huggingface"]); }); it("creates Sarvam by default", () => { @@ -75,6 +78,28 @@ describe("provider factory", () => { expect(provider.baseURL).toBe("http://localhost:8000/v1"); }); + it("creates a Hugging Face provider with router defaults", () => { + const provider = createProvider({ provider: "huggingface", apiKey: "hf_test-token" }); + + expect(provider).toBeInstanceOf(HuggingFaceProvider); + expect(provider.providerId).toBe("huggingface"); + expect(provider.baseURL).toBe(DEFAULT_HUGGINGFACE_BASE_URL); + expect(provider.model).toBe(DEFAULT_HUGGINGFACE_MODEL); + }); + + it("lets a Hugging Face provider target a dedicated endpoint and model", () => { + const provider = createProvider({ + provider: "huggingface", + apiKey: "hf_test-token", + baseURL: "https://abc123.endpoints.huggingface.cloud/v1", + model: "Qwen/Qwen2.5-Coder-32B-Instruct", + }); + + expect(provider).toBeInstanceOf(HuggingFaceProvider); + expect(provider.baseURL).toBe("https://abc123.endpoints.huggingface.cloud/v1"); + expect(provider.model).toBe("Qwen/Qwen2.5-Coder-32B-Instruct"); + }); + it("rejects unknown providers", () => { expect(() => createProvider({