diff --git a/README.md b/README.md index 5be089fd11..9658cc336e 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,44 @@ Zoo Code builds on the foundation created by Roo Code and continues to expand it - **More dependable terminal and editing workflows** — fixes for premature terminal completion, task-state races, context management, diff editing, and provider-specific tool use. - **More control over your workspace** — rules management, per-mode MCP restrictions, multi-root path controls, model reasoning options, and completion change review actions. +## GitHub Copilot + +Select **GitHub Copilot** in Zoo Code's provider settings and click **Sign in to GitHub**. GitHub +Copilot Chat must be installed and enabled. Zoo Code shows the signed-in account and refreshes the +model list as Copilot registers models. Select a model and save the profile. VS Code may ask you to +allow Zoo Code to use the model on the first request. + +Signing in proves a GitHub identity, not a Copilot subscription. An account without Copilot access +signs in successfully and simply sees no models. + +- **Reconnect GitHub** requests a fresh authorization instead of reusing the existing session. +- **Manage GitHub account** opens VS Code's own account menu. VS Code offers extensions no way to + sign out on a user's behalf, so signing out happens there. It affects Copilot and every other + extension using the account. + +VS Code manages the GitHub session; Zoo Code never displays or stores the access token. + +### Capabilities + +Capabilities come from what VS Code reports for each Copilot model, so new models work without code +changes, and nothing about a model is saved into your settings where it could go stale. + +- **Context window:** the model's reported input limit, capped by Zoo Code's curated limit where one + is known. Before the host reports, the curated limit is shown, matching what Zoo Code enforces. +- **Images:** read from the capability the host reports for each live model. A model that reports + nothing shows **Image support not reported** instead of being assumed unsupported or borrowed from + Zoo Code's curated catalog, which only describes a model the host has not listed yet. Images are + sent as image data, not text placeholders, and need a VS Code version that supports image parts; + Zoo Code's minimum supported VS Code version stays 1.100. +- **Tools:** VS Code tells extensions only whether a model supports tools, not how many it accepts, + so Zoo Code applies the same advisory it shows for any provider when many MCP tools are enabled. + GitHub plan limits, premium-request allowances, organization policies, and backend limits still apply. +- **Prompt caching:** Copilot owns any backend caching. The public VS Code Language Model API exposes + no cache-control directives or cache-hit usage, so Zoo Code neither enables caching nor reports + savings; the provider-managed label states this and does not promise a cache hit. + +The existing **VS Code LM API** provider remains available for models from other extensions. + ## What's New in v3.86.0 - ✨ **New model support:** Use GPT-6.1 Sol, get the corrected Claude Opus 5.5 output budget, and select DeepSeek V4.1 Flash on OpenCode Go. diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index b78ab0e5b5..e429627243 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -91,6 +91,7 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { case retiredProviderIdentifiers.ioIntelligence: case retiredProviderIdentifiers.roo: case providerIdentifiers.vscodeLm: + case providerIdentifiers.githubCopilot: case providerIdentifiers.fakeAi: case undefined: return config.apiModelId diff --git a/packages/types/src/__tests__/provider-default-model.test.ts b/packages/types/src/__tests__/provider-default-model.test.ts index 9957603dda..7578391bac 100644 --- a/packages/types/src/__tests__/provider-default-model.test.ts +++ b/packages/types/src/__tests__/provider-default-model.test.ts @@ -34,6 +34,10 @@ describe("getProviderDefaultModelId", () => { expect(getProviderDefaultModelId(providerIdentifiers.vscodeLm)).toBe(vscodeLlmDefaultModelId) }) + it("gives GitHub Copilot the same default as the VS Code LM provider", () => { + expect(getProviderDefaultModelId(providerIdentifiers.githubCopilot)).toBe(vscodeLlmDefaultModelId) + }) + it("uses the canonical OpenAI Native default model", () => { expect(getProviderDefaultModelId(providerIdentifiers.openaiNative)).toBe(openAiNativeDefaultModelId) }) diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index d9ba4fb799..bba6571b05 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -38,6 +38,7 @@ const expectedProviderIdentifiers = [ "ollama", "lmstudio", "vscode-lm", + "github-copilot", "openai", "fake-ai", "anthropic", @@ -114,7 +115,7 @@ describe("provider identifiers", () => { providerIdentifiers.kimiCode, ]) expect(localProviders).toEqual([providerIdentifiers.ollama, providerIdentifiers.lmstudio]) - expect(internalProviders).toEqual([providerIdentifiers.vscodeLm]) + expect(internalProviders).toEqual([providerIdentifiers.vscodeLm, providerIdentifiers.githubCopilot]) expect(customProviders).toEqual([providerIdentifiers.openai]) expect(fauxProviders).toEqual([providerIdentifiers.fakeAi]) }) diff --git a/packages/types/src/__tests__/provider-model-id.test.ts b/packages/types/src/__tests__/provider-model-id.test.ts index a623b24b27..e25341e4c3 100644 --- a/packages/types/src/__tests__/provider-model-id.test.ts +++ b/packages/types/src/__tests__/provider-model-id.test.ts @@ -1,5 +1,11 @@ import { retiredProviderIdentifiers } from "../provider-identifiers.js" -import { getModelId, modelIdKeys, providerIdentifiers, type ProviderSettings } from "../index.js" +import { + getModelId, + modelIdKeys, + providerIdentifiers, + providerSettingsSchema, + type ProviderSettings, +} from "../index.js" const expectedModelIdKeys = [ "apiModelId", @@ -54,6 +60,40 @@ describe("getModelId", () => { expect(getModelId(settings)).toBe("vscode-model") }) + it("preserves the exact Copilot model ID through the provider settings schema", () => { + const settings: ProviderSettings = { + apiProvider: providerIdentifiers.githubCopilot, + vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4o", id: "copilot-model", version: "1" }, + } + expect(getModelId(settings)).toBe("copilot-model") + }) + + it("persists only the selector's identity, never live model capabilities", () => { + const saved = providerSettingsSchema.parse({ + apiProvider: providerIdentifiers.githubCopilot, + vsCodeLmModelSelector: { vendor: "copilot", id: "dynamic-model", family: "dynamic-model", version: "1" }, + }) + expect(saved.vsCodeLmModelSelector).toEqual({ + vendor: "copilot", + id: "dynamic-model", + family: "dynamic-model", + version: "1", + }) + }) + + it("drops a stale capability snapshot from profiles saved by earlier builds", () => { + const saved = providerSettingsSchema.parse({ + apiProvider: providerIdentifiers.githubCopilot, + vsCodeLmModelSelector: { + vendor: "copilot", + id: "old-model", + modelInfo: { contextWindow: 1, supportsImages: false, supportsPromptCache: false }, + }, + }) + expect(getModelId(saved)).toBe("old-model") + expect(saved.vsCodeLmModelSelector).not.toHaveProperty("modelInfo") + }) + it("uses openAiModelId for OpenAI Compatible", () => { const settings: ProviderSettings = { apiProvider: providerIdentifiers.openai, diff --git a/packages/types/src/provider-identifiers.ts b/packages/types/src/provider-identifiers.ts index fdf507bb6b..55bbac0ae2 100644 --- a/packages/types/src/provider-identifiers.ts +++ b/packages/types/src/provider-identifiers.ts @@ -18,6 +18,7 @@ export const providerIdentifiers = { ollama: "ollama", lmstudio: "lmstudio", vscodeLm: "vscode-lm", + githubCopilot: "github-copilot", openai: "openai", fakeAi: "fake-ai", anthropic: "anthropic", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index b243971cc3..55d0e78a3a 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -101,7 +101,7 @@ export const isLocalProvider = (key: string): key is LocalProvider => localProvi * model list. */ -export const internalProviders = [providerIdentifiers.vscodeLm] as const +export const internalProviders = [providerIdentifiers.vscodeLm, providerIdentifiers.githubCopilot] as const export type InternalProvider = (typeof internalProviders)[number] @@ -491,6 +491,11 @@ export const MODELS_BY_PROVIDER: Record< label: "VS Code LM API", models: Object.keys(vscodeLlmModels), }, + [providerIdentifiers.githubCopilot]: { + id: providerIdentifiers.githubCopilot, + label: "GitHub Copilot", + models: Object.keys(vscodeLlmModels), + }, [providerIdentifiers.xai]: { id: providerIdentifiers.xai, label: "xAI (Grok)", models: Object.keys(xaiModels) }, [providerIdentifiers.zai]: { id: providerIdentifiers.zai, diff --git a/packages/types/src/provider-settings/index.ts b/packages/types/src/provider-settings/index.ts index 5cdadb4b2c..6252199725 100644 --- a/packages/types/src/provider-settings/index.ts +++ b/packages/types/src/provider-settings/index.ts @@ -4,7 +4,7 @@ import { bedrockProviderDefinition } from "./bedrock.js" import { vertexProviderDefinition } from "./vertex.js" import { openAiProviderDefinition } from "./openai.js" import { ollamaProviderDefinition } from "./ollama.js" -import { vsCodeLmProviderDefinition } from "./vscode-lm.js" +import { githubCopilotProviderDefinition, vsCodeLmProviderDefinition } from "./vscode-lm.js" import { lmStudioProviderDefinition } from "./lm-studio.js" import { geminiProviderDefinition } from "./gemini.js" import { geminiCliProviderDefinition } from "./gemini-cli.js" @@ -56,6 +56,7 @@ export const providerDefinitionList = [ openAiProviderDefinition, ollamaProviderDefinition, vsCodeLmProviderDefinition, + githubCopilotProviderDefinition, lmStudioProviderDefinition, geminiProviderDefinition, geminiCliProviderDefinition, diff --git a/packages/types/src/provider-settings/vscode-lm.ts b/packages/types/src/provider-settings/vscode-lm.ts index 8579d69a5b..f666e6c8b3 100644 --- a/packages/types/src/provider-settings/vscode-lm.ts +++ b/packages/types/src/provider-settings/vscode-lm.ts @@ -3,18 +3,26 @@ import { z } from "zod" import { providerIdentifiers } from "../provider-identifiers.js" import { baseProviderSettingsShape, createProviderDefinition } from "./common.js" +const schema = { + ...baseProviderSettingsShape, + vsCodeLmModelSelector: z + .object({ + vendor: z.string().optional(), + family: z.string().optional(), + version: z.string().optional(), + id: z.string().optional(), + }) + .optional(), +} + export const vsCodeLmProviderDefinition = createProviderDefinition({ apiProvider: providerIdentifiers.vscodeLm, getModelId: (settings) => settings.vsCodeLmModelSelector?.id, - schema: { - ...baseProviderSettingsShape, - vsCodeLmModelSelector: z - .object({ - vendor: z.string().optional(), - family: z.string().optional(), - version: z.string().optional(), - id: z.string().optional(), - }) - .optional(), - }, + schema, +}) + +export const githubCopilotProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.githubCopilot, + getModelId: (settings) => settings.vsCodeLmModelSelector?.id, + schema, }) diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index ed0c583912..da0558ddb5 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -116,6 +116,7 @@ export function getProviderDefaultModelId( case providerIdentifiers.lmstudio: return NO_DEFAULT_MODEL_ID case providerIdentifiers.vscodeLm: + case providerIdentifiers.githubCopilot: return vscodeLlmDefaultModelId case providerIdentifiers.sambanova: return sambaNovaDefaultModelId diff --git a/packages/types/src/providers/vscode-llm.ts b/packages/types/src/providers/vscode-llm.ts index 7069f49f54..813efe7b0a 100644 --- a/packages/types/src/providers/vscode-llm.ts +++ b/packages/types/src/providers/vscode-llm.ts @@ -1,8 +1,17 @@ import { z } from "zod" import type { ModelInfo } from "../model.js" +import { openAiModelInfoSaneDefaults } from "./openai.js" -export const vsCodeLmModelsMessageTypes = ["requestVsCodeLmModels", "vsCodeLmModels"] as const +export const vsCodeLmModelsMessageTypes = [ + "requestVsCodeLmModels", + "vsCodeLmModels", + "githubCopilotSignIn", + "githubCopilotReconnect", + "githubCopilotManageAccount", + "githubCopilotSignInResult", + "githubCopilotModels", +] as const export const vsCodeLmModelsMessageTypeSchema = z.enum(vsCodeLmModelsMessageTypes) @@ -10,6 +19,32 @@ export const VsCodeLmModelsMessageType = vsCodeLmModelsMessageTypeSchema.enum export type VsCodeLmModelsMessageType = z.infer +const githubCopilotVendor = "copilot" + +/** Identifiers for the language models and sign-in flow GitHub Copilot contributes to VS Code. */ +export const githubCopilotLanguageModel = { + vendor: githubCopilotVendor, + /** Selector matching every model the Copilot extension contributes. */ + selector: { vendor: githubCopilotVendor }, + authProviderId: "github", + /** In Copilot Chat's own preference order, so Zoo recognizes the session Copilot actually uses. */ + authScopeSets: [["user:email"], ["read:user"]], + chatExtensionId: "GitHub.copilot-chat", + /** Public Command Palette command; VS Code offers no API to sign out, so Zoo defers to this UI. */ + manageAccountsCommandId: "workbench.action.manageAccounts", +} as const + +/** + * Baseline for a model whose capabilities are not yet known. Unlike `openAiModelInfoSaneDefaults` it + * leaves vision unset, so a capability nobody reported is not mistaken for one that is unsupported. + */ +export const vscodeLlmBaselineModelInfo: ModelInfo = { + contextWindow: openAiModelInfoSaneDefaults.contextWindow, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, +} + export type VscodeLlmModelId = keyof typeof vscodeLlmModels export const vscodeLlmDefaultModelId: VscodeLlmModelId = "claude-sonnet-4.5" diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c0e8509105..f83800774b 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -12,7 +12,7 @@ import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, import type { SerializedCustomToolDefinition } from "./custom-tool.js" import type { GitCommit } from "./git.js" import type { McpServer } from "./mcp.js" -import { RouterModelsMessageType, type ModelRecord, type RouterModels } from "./model.js" +import { RouterModelsMessageType, type ModelInfo, type ModelRecord, type RouterModels } from "./model.js" import { LmStudioModelsMessageType } from "./providers/lm-studio.js" import { OllamaModelsMessageType } from "./providers/ollama.js" import { OpenAiModelsMessageType } from "./providers/openai.js" @@ -48,6 +48,8 @@ export interface ExtensionMessage { | typeof OllamaModelsMessageType.ollamaModels | typeof LmStudioModelsMessageType.lmStudioModels | typeof VsCodeLmModelsMessageType.vsCodeLmModels + | typeof VsCodeLmModelsMessageType.githubCopilotModels + | typeof VsCodeLmModelsMessageType.githubCopilotSignInResult | "vsCodeLmApiAvailable" | "updatePrompt" | "systemPrompt" @@ -146,7 +148,16 @@ export interface ExtensionMessage { openAiModels?: string[] ollamaModels?: ModelRecord lmStudioModels?: ModelRecord - vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[] + vsCodeLmModels?: { + vendor?: string + family?: string + version?: string + id?: string + name?: string + maxInputTokens?: number + modelInfo?: ModelInfo + }[] + githubCopilotAccount?: string | null mcpServers?: McpServer[] commits?: GitCommit[] listApiConfig?: ProviderSettingsEntry[] @@ -499,6 +510,9 @@ export interface WebviewMessage { | typeof LmStudioModelsMessageType.requestLmStudioModels | "requestRooModels" | typeof VsCodeLmModelsMessageType.requestVsCodeLmModels + | typeof VsCodeLmModelsMessageType.githubCopilotSignIn + | typeof VsCodeLmModelsMessageType.githubCopilotReconnect + | typeof VsCodeLmModelsMessageType.githubCopilotManageAccount | "openImage" | "saveImage" | "openFile" diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index 2fe940f53a..60fa480493 100644 --- a/src/api/__tests__/index.spec.ts +++ b/src/api/__tests__/index.spec.ts @@ -61,6 +61,7 @@ import { VercelAiGatewayHandler, VertexHandler, VsCodeLmHandler, + GitHubCopilotHandler, XAIHandler, ZAiHandler, ZooGatewayHandler, @@ -86,6 +87,7 @@ const expectedHandlers = { [providerIdentifiers.moonshot]: MoonshotHandler, [providerIdentifiers.kimiCode]: KimiCodeHandler, [providerIdentifiers.vscodeLm]: VsCodeLmHandler, + [providerIdentifiers.githubCopilot]: GitHubCopilotHandler, [providerIdentifiers.mistral]: MistralHandler, [providerIdentifiers.requesty]: RequestyHandler, [providerIdentifiers.unbound]: UnboundHandler, @@ -118,6 +120,10 @@ describe("buildApiHandler", () => { expect(handler).toBeInstanceOf(Handler) }) + it("keeps the generic VS Code LM provider free of Copilot-specific behavior", () => { + expect(buildApiHandler({ apiProvider: providerIdentifiers.vscodeLm })).not.toBeInstanceOf(GitHubCopilotHandler) + }) + it.each([ ["an unspecified model", undefined, VertexHandler], ["non-Claude models", "non-claude-test-model", VertexHandler], diff --git a/src/api/index.ts b/src/api/index.ts index e662c78386..849ba4871c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -29,6 +29,7 @@ import { KimiCodeHandler, MistralHandler, VsCodeLmHandler, + GitHubCopilotHandler, RequestyHandler, UnboundHandler, FakeAIHandler, @@ -204,6 +205,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new KimiCodeHandler(options) case providerIdentifiers.vscodeLm: return new VsCodeLmHandler(options) + case providerIdentifiers.githubCopilot: + return new GitHubCopilotHandler(options) case providerIdentifiers.mistral: return new MistralHandler(options) case providerIdentifiers.requesty: diff --git a/src/api/providers/__tests__/github-copilot.spec.ts b/src/api/providers/__tests__/github-copilot.spec.ts new file mode 100644 index 0000000000..ba40d3059b --- /dev/null +++ b/src/api/providers/__tests__/github-copilot.spec.ts @@ -0,0 +1,564 @@ +import type { Anthropic } from "@anthropic-ai/sdk" +import { openAiModelInfoSaneDefaults, vscodeLlmModels } from "@roo-code/types" + +// Mocks must come first, before imports +vi.mock("vscode", () => { + class MockLanguageModelTextPart { + type = "text" + constructor(public value: string) {} + } + + class MockLanguageModelToolCallPart { + type = "tool_call" + constructor( + public callId: string, + public name: string, + public input: object, + ) {} + } + + class MockLanguageModelToolResultPart { + type = "tool_result" + constructor( + public callId: string, + public content: unknown[], + ) {} + } + + return { + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn((key: string, defaultValue: unknown) => defaultValue), + })), + onDidChangeConfiguration: vi.fn((_callback) => ({ + dispose: vi.fn(), + })), + }, + CancellationTokenSource: vi.fn(function () { + return { + token: { + isCancellationRequested: false, + onCancellationRequested: vi.fn(), + }, + cancel: vi.fn(), + dispose: vi.fn(), + } + }), + CancellationError: class CancellationError extends Error { + constructor() { + super("Operation cancelled") + this.name = "CancellationError" + } + }, + LanguageModelChatMessage: { + Assistant: vi.fn((content) => ({ + role: "assistant", + content: Array.isArray(content) ? content : [new MockLanguageModelTextPart(content)], + })), + User: vi.fn((content) => ({ + role: "user", + content: Array.isArray(content) ? content : [new MockLanguageModelTextPart(content)], + })), + }, + LanguageModelTextPart: MockLanguageModelTextPart, + LanguageModelToolCallPart: MockLanguageModelToolCallPart, + LanguageModelToolResultPart: MockLanguageModelToolResultPart, + LanguageModelDataPart: class { + constructor( + public data: Uint8Array, + public mimeType: string, + ) {} + static image(data: Uint8Array, mimeType: string) { + return new this(data, mimeType) + } + }, + lm: { + selectChatModels: vi.fn(), + }, + authentication: { + getSession: vi.fn(), + }, + extensions: { + getExtension: vi.fn(), + }, + commands: { + getCommands: vi.fn(), + executeCommand: vi.fn(), + }, + } +}) + +import * as vscode from "vscode" +import { + GitHubCopilotHandler, + connectGitHubCopilot, + getGitHubCopilotAccount, + openGitHubAccountManagement, +} from "../github-copilot" +import { getVsCodeLmModelInfo } from "../vscode-lm-capabilities" +import type { ApiHandlerOptions } from "../../../shared/api" +import { clearAllMocks } from "../../../test-utils/reset" +import { checkContextWindowExceededError } from "../../../core/context/context-management/context-error-handling" +import { collectStream } from "../../../test-utils/stream" + +/** The stable typings this extension builds against predate image parts, so reach them by name. */ +const dataPart = () => + Reflect.get(vscode, "LanguageModelDataPart") as { image?: unknown; new (...args: never[]): object } + +const mockLanguageModelChat = { + id: "test-model", + name: "Test Model", + vendor: "test-vendor", + family: "test-family", + version: "1.0", + maxInputTokens: 4096, + sendRequest: vi.fn(), + countTokens: vi.fn(), +} + +describe("connectGitHubCopilot", () => { + beforeEach(() => vi.clearAllMocks()) + + it("authenticates through VS Code and selects only Copilot models", async () => { + vi.mocked(vscode.authentication.getSession).mockResolvedValue({ + id: "test-session", + accessToken: "test-token", + scopes: ["user:email"], + account: { id: "test-account", label: "Test User" }, + }) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + + const onAuthenticated = vi.fn(async (account: string) => { + expect(account).toBe("Test User") + expect(vscode.lm.selectChatModels).not.toHaveBeenCalled() + }) + await expect(connectGitHubCopilot(onAuthenticated)).resolves.toEqual({ account: "Test User", models: [] }) + expect(onAuthenticated).toHaveBeenCalledOnce() + + expect(vscode.authentication.getSession).toHaveBeenCalledWith("github", ["user:email"], { createIfNone: true }) + expect(vscode.lm.selectChatModels).toHaveBeenCalledWith({ vendor: "copilot" }) + }) + + it("rejects an incomplete sign-in rather than reporting a connected account", async () => { + vi.mocked(vscode.authentication.getSession).mockResolvedValueOnce(undefined) + await expect(connectGitHubCopilot()).rejects.toThrow("GitHub sign-in did not complete") + expect(vscode.lm.selectChatModels).not.toHaveBeenCalled() + }) + + it("requests a fresh account session when reconnecting", async () => { + vi.mocked(vscode.authentication.getSession).mockResolvedValueOnce({ + id: "fresh-session", + accessToken: "test-token", + scopes: ["user:email"], + account: { id: "account", label: "Test User" }, + }) + await connectGitHubCopilot(undefined, true) + expect(vscode.authentication.getSession).toHaveBeenCalledWith("github", ["user:email"], { + forceNewSession: true, + clearSessionPreference: true, + }) + }) + + describe("account", () => { + const session = (label: string) => ({ + id: "session", + accessToken: "test-token", + scopes: [], + account: { id: "account", label }, + }) + + it("recognizes the session Copilot itself uses, whichever supported scope set holds it", async () => { + vi.mocked(vscode.authentication.getSession).mockImplementation(async (_provider, scopes) => + (scopes as readonly string[]).includes("read:user") ? session("Legacy User") : undefined, + ) + await expect(getGitHubCopilotAccount()).resolves.toBe("Legacy User") + expect(vscode.authentication.getSession).toHaveBeenCalledWith("github", ["user:email"], { silent: true }) + expect(vscode.authentication.getSession).toHaveBeenCalledWith("github", ["read:user"], { silent: true }) + }) + + it("stops at the first scope set that has a session", async () => { + vi.mocked(vscode.authentication.getSession).mockResolvedValue(session("Current User")) + await expect(getGitHubCopilotAccount()).resolves.toBe("Current User") + expect(vscode.authentication.getSession).toHaveBeenCalledTimes(1) + }) + + it("reports no account rather than failing when the lookup throws", async () => { + vi.mocked(vscode.authentication.getSession).mockRejectedValue(new Error("auth unavailable")) + await expect(getGitHubCopilotAccount()).resolves.toBeUndefined() + }) + + it("opens VS Code's own account management instead of signing out on its behalf", async () => { + await openGitHubAccountManagement() + expect(vscode.commands.executeCommand).toHaveBeenCalledExactlyOnceWith("workbench.action.manageAccounts") + }) + }) + + it("shares one sign-in between concurrent requests", async () => { + let finish: (value: ReturnType) => void = () => {} + const session = (label: string) => ({ + id: "session", + accessToken: "test-token", + scopes: [], + account: { id: "account", label }, + }) + vi.mocked(vscode.authentication.getSession).mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)) as never, + ) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + + const first = connectGitHubCopilot() + const second = connectGitHubCopilot() + finish(session("Test User")) + + await expect(Promise.all([first, second])).resolves.toEqual([ + { account: "Test User", models: [] }, + { account: "Test User", models: [] }, + ]) + expect(vscode.authentication.getSession).toHaveBeenCalledTimes(1) + }) + + describe("overlapping sign-in requests with different intent", () => { + const session = (label: string) => ({ id: "s", accessToken: "t", scopes: [], account: { id: "a", label } }) + const pendingSession = () => { + let finish: (value: ReturnType) => void = () => {} + const promise = new Promise((resolve) => (finish = resolve)) as never + return { promise, finish: (label: string) => finish(session(label)) } + } + + it("gives a Reconnect its own forced session instead of the pending plain sign-in's", async () => { + const plain = pendingSession() + const fresh = pendingSession() + vi.mocked(vscode.authentication.getSession) + .mockImplementationOnce(() => plain.promise) + .mockImplementationOnce(() => fresh.promise) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + const onFresh = vi.fn() + + const first = connectGitHubCopilot() + const second = connectGitHubCopilot(onFresh, true) + plain.finish("Plain User") + fresh.finish("Fresh User") + + await expect(first).resolves.toMatchObject({ account: "Plain User" }) + await expect(second).resolves.toMatchObject({ account: "Fresh User" }) + expect(vscode.authentication.getSession).toHaveBeenCalledTimes(2) + expect(vscode.authentication.getSession).toHaveBeenLastCalledWith("github", ["user:email"], { + forceNewSession: true, + clearSessionPreference: true, + }) + expect(onFresh).toHaveBeenCalledWith("Fresh User") + }) + + it("still shares one attempt between concurrent Reconnect requests", async () => { + const fresh = pendingSession() + vi.mocked(vscode.authentication.getSession).mockImplementationOnce(() => fresh.promise) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + + const results = [connectGitHubCopilot(undefined, true), connectGitHubCopilot(undefined, true)] + fresh.finish("Fresh User") + + await Promise.all(results) + expect(vscode.authentication.getSession).toHaveBeenCalledTimes(1) + }) + + it("lets a failed attempt of one kind be retried without disturbing the other", async () => { + vi.mocked(vscode.authentication.getSession).mockRejectedValueOnce(new Error("cancelled")) + await expect(connectGitHubCopilot(undefined, true)).rejects.toThrow("cancelled") + + vi.mocked(vscode.authentication.getSession).mockResolvedValueOnce(session("Retry User")) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + await expect(connectGitHubCopilot(undefined, true)).resolves.toMatchObject({ account: "Retry User" }) + }) + }) + + it("allows a new sign-in once the previous one has settled", async () => { + vi.mocked(vscode.authentication.getSession).mockRejectedValueOnce(new Error("Sign-in cancelled")) + await expect(connectGitHubCopilot()).rejects.toThrow("Sign-in cancelled") + + vi.mocked(vscode.authentication.getSession).mockResolvedValueOnce({ + id: "session", + accessToken: "test-token", + scopes: [], + account: { id: "account", label: "Test User" }, + }) + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + await expect(connectGitHubCopilot()).resolves.toEqual({ account: "Test User", models: [] }) + }) + it("propagates authentication failures without attempting model discovery", async () => { + vi.mocked(vscode.authentication.getSession).mockRejectedValueOnce(new Error("Sign-in cancelled")) + vi.mocked(vscode.lm.selectChatModels).mockClear() + + await expect(connectGitHubCopilot()).rejects.toThrow("Sign-in cancelled") + expect(vscode.lm.selectChatModels).not.toHaveBeenCalled() + }) +}) + +describe("Copilot model capabilities", () => { + const curatedWithVision = Object.entries(vscodeLlmModels).find(([, entry]) => entry.supportsImages) + if (!curatedWithVision) throw new Error("expected the curated catalog to contain a vision-capable model") + const [curatedFamily, curatedEntry] = curatedWithVision + + const liveModel = (overrides: Record = {}) => ({ + ...mockLanguageModelChat, + vendor: "copilot", + id: "synthetic-model", + family: "synthetic-family", + maxInputTokens: 16000, + ...overrides, + }) + + const imageSource = { type: "base64" as const, media_type: "image/png" as const, data: "aW1hZ2U=" } + const imageMessages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "image", source: imageSource }] }, + ] + const toolResultImageMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "tool-1", content: [{ type: "image", source: imageSource }] }, + ], + }, + ] + + const send = async ( + model: ReturnType, + messages: Anthropic.Messages.MessageParam[], + metadata?: Parameters[2], + ) => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([model]) + const handler = new GitHubCopilotHandler({}) + try { + await handler.initializeClient() + await collectStream(handler.createMessage("", messages, metadata)) + } finally { + handler.dispose() + } + } + + beforeEach(() => { + vi.clearAllMocks() + mockLanguageModelChat.countTokens.mockResolvedValue(10) + mockLanguageModelChat.sendRequest.mockImplementation(async () => ({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("Done") + })(), + })) + }) + + describe("vision resolution", () => { + it.each([true, false])("treats the host-reported vision flag (%s) as authoritative", (reported) => { + const info = getVsCodeLmModelInfo(liveModel({ capabilities: { supportsImageToText: reported } })) + expect(info.supportsImages).toBe(reported) + }) + + it("accepts the provider-side capability spelling when the consumer-side one is absent", () => { + expect(getVsCodeLmModelInfo(liveModel({ capabilities: { imageInput: true } })).supportsImages).toBe(true) + }) + + it("lets an explicit host report override the curated catalog", () => { + const model = liveModel({ family: curatedFamily, capabilities: { supportsImageToText: false } }) + expect(getVsCodeLmModelInfo(model).supportsImages).toBe(false) + }) + + it("does not borrow the catalog's vision flag for a live model the host reports nothing about", () => { + expect(getVsCodeLmModelInfo(liveModel({ family: curatedFamily })).supportsImages).toBeUndefined() + }) + + it("leaves vision unset rather than guessing when no source states it", () => { + expect(getVsCodeLmModelInfo(liveModel()).supportsImages).toBeUndefined() + }) + + it("reports no vision when this host cannot construct image parts", () => { + const parts = dataPart() + const original = parts.image + Object.assign(parts, { image: undefined }) + try { + expect( + getVsCodeLmModelInfo(liveModel({ capabilities: { supportsImageToText: true } })).supportsImages, + ).toBe(false) + } finally { + Object.assign(parts, { image: original }) + } + }) + + it("keeps unknown vision unset through the no-client fallback instead of defaulting it to unsupported", () => { + const handler = new GitHubCopilotHandler({ + vsCodeLmModelSelector: { vendor: "copilot", family: "synthetic-family" }, + }) + try { + expect(handler.getModel().info.supportsImages).toBeUndefined() + } finally { + handler.dispose() + } + }) + }) + + describe("context window", () => { + it("uses the live input limit when the catalog has no entry", () => { + expect(getVsCodeLmModelInfo(liveModel({ maxInputTokens: 921793 })).contextWindow).toBe(921793) + }) + + it("honors a live limit smaller than the curated limit", () => { + const model = liveModel({ family: curatedFamily, maxInputTokens: curatedEntry.maxInputTokens - 1 }) + expect(getVsCodeLmModelInfo(model).contextWindow).toBe(curatedEntry.maxInputTokens - 1) + }) + + it("caps an inflated live limit at the curated limit", () => { + const model = liveModel({ family: curatedFamily, maxInputTokens: curatedEntry.maxInputTokens * 10 }) + expect(getVsCodeLmModelInfo(model).contextWindow).toBe(curatedEntry.maxInputTokens) + }) + + it("falls back to a sane default when the host reports no usable limit", () => { + expect(getVsCodeLmModelInfo(liveModel({ maxInputTokens: 0 })).contextWindow).toBe( + openAiModelInfoSaneDefaults.contextWindow, + ) + }) + }) + + describe("requests", () => { + const visionModel = () => liveModel({ capabilities: { supportsImageToText: true } }) + + it("transmits image bytes and counts the real message when vision is reported", async () => { + await send(visionModel(), imageMessages) + const messages = mockLanguageModelChat.sendRequest.mock.calls[0][0] + expect(messages[1].content[0]).toBeInstanceOf(dataPart()) + expect(messages[1].content[0].data).toEqual(Buffer.from("image")) + expect(mockLanguageModelChat.countTokens).toHaveBeenCalledWith(messages[1], expect.anything()) + }) + + it("transmits image bytes nested inside tool results", async () => { + await send(visionModel(), toolResultImageMessages) + const toolResult = mockLanguageModelChat.sendRequest.mock.calls[0][0][1].content[0] + expect(toolResult.content[0]).toBeInstanceOf(dataPart()) + }) + + it.each([ + ["reported unsupported", { capabilities: { supportsImageToText: false } }], + ["not reported", {}], + ])("rejects images before sending when vision is %s", async (_label, overrides) => { + await expect(send(liveModel(overrides), imageMessages)).rejects.toThrow( + "does not report image input support", + ) + await expect(send(liveModel(overrides), toolResultImageMessages)).rejects.toThrow( + "does not report image input support", + ) + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + }) + + it("forwards every offered tool without imposing a client-side cap", async () => { + const tools = Array.from({ length: 130 }, (_, index) => ({ + type: "function" as const, + function: { + name: `tool_${index}`, + description: "A tool", + parameters: { type: "object", properties: {} }, + }, + })) + await send(liveModel(), [], { taskId: "tool-passthrough", tools }) + expect(mockLanguageModelChat.sendRequest.mock.calls[0][1].tools).toHaveLength(130) + }) + }) +}) + +describe("GitHubCopilotHandler", () => { + const defaultOptions: ApiHandlerOptions = { + vsCodeLmModelSelector: { vendor: "test-vendor", family: "test-family" }, + } + + beforeEach(() => { + clearAllMocks() + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([{ ...mockLanguageModelChat }] as never) + }) + + it("forces the Copilot vendor even when the saved selector names another vendor", async () => { + const copilotHandler = new GitHubCopilotHandler(defaultOptions) + try { + await copilotHandler.initializeClient() + expect(vscode.lm.selectChatModels).toHaveBeenCalledWith({ vendor: "copilot", family: "test-family" }) + } finally { + copilotHandler.dispose() + } + }) + + it("rejects unavailable Copilot models instead of generating placeholder output", async () => { + const copilotHandler = new GitHubCopilotHandler(defaultOptions) + try { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValueOnce([]) + await expect(copilotHandler.createClient({ vendor: "copilot", id: "missing-model" })).rejects.toThrow( + "No matching GitHub Copilot model", + ) + } finally { + copilotHandler.dispose() + } + }) + + describe("input limits", () => { + const copilotModel = (overrides: Record = {}) => ({ + ...mockLanguageModelChat, + vendor: "copilot", + id: "limited", + family: "limited-family", + maxInputTokens: 16000, + ...overrides, + }) + + const run = async (perMessageTokens: number) => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([copilotModel()] as never) + mockLanguageModelChat.countTokens.mockResolvedValue(perMessageTokens) + mockLanguageModelChat.sendRequest.mockImplementation(async () => ({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("ok") + })(), + })) + const handler = new GitHubCopilotHandler({}) + try { + await collectStream(handler.createMessage("system", [{ role: "user", content: "hello" }])) + } finally { + handler.dispose() + } + } + + it("refuses a request measured over the model's window, as an error Zoo recovers from by condensing", async () => { + const failure = await run(9000).catch((error: unknown) => error) + + expect(checkContextWindowExceededError(failure)).toBe(true) + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + }) + + it("admits a request that exactly fills the window", async () => { + // System prompt and user message are counted separately: 2 x 8000 = 16000. + await expect(run(8000)).resolves.toBeUndefined() + expect(mockLanguageModelChat.sendRequest).toHaveBeenCalledTimes(1) + }) + }) + + describe("countTokens", () => { + beforeEach(() => mockLanguageModelChat.countTokens.mockResolvedValue(42)) + + it("measures the content as a user message through the host's counter", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([ + { ...mockLanguageModelChat, vendor: "copilot" }, + ] as never) + const handler = new GitHubCopilotHandler({}) + try { + await expect(handler.countTokens([{ type: "text", text: "hello" }])).resolves.toBe(42) + expect(mockLanguageModelChat.countTokens).toHaveBeenCalledTimes(1) + expect(mockLanguageModelChat.countTokens.mock.calls[0][0]).toMatchObject({ role: "user" }) + } finally { + handler.dispose() + } + }) + + it("fails clearly, rather than returning a made-up count, when no Copilot model is available", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + const handler = new GitHubCopilotHandler({}) + try { + await expect(handler.countTokens([{ type: "text", text: "hello" }])).rejects.toThrow( + "No matching GitHub Copilot model", + ) + } finally { + handler.dispose() + } + }) + }) +}) diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 86a9a30c38..78051782c2 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -62,15 +62,35 @@ vi.mock("vscode", () => { LanguageModelTextPart: MockLanguageModelTextPart, LanguageModelToolCallPart: MockLanguageModelToolCallPart, LanguageModelToolResultPart: MockLanguageModelToolResultPart, + LanguageModelDataPart: class { + constructor( + public data: Uint8Array, + public mimeType: string, + ) {} + static image(data: Uint8Array, mimeType: string) { + return new this(data, mimeType) + } + }, lm: { selectChatModels: vi.fn(), }, + authentication: { + getSession: vi.fn(), + }, + extensions: { + getExtension: vi.fn(), + }, + commands: { + getCommands: vi.fn(), + executeCommand: vi.fn(), + }, } }) import * as vscode from "vscode" import { VsCodeLmHandler, + getVsCodeLmModels, extractLeakedToolCalls, trailingPartialToolMarkerLength, middleOutTruncate, @@ -170,6 +190,28 @@ describe("VsCodeLmHandler", () => { expect(client.vendor).toBe("vscode") }) + it("gives the placeholder client a working stream, text and token counter", async () => { + ;(vscode.lm.selectChatModels as Mock).mockResolvedValueOnce([]) + const client = await handler["createClient"]({}) + + const response = await client.sendRequest([], {}, new vscode.CancellationTokenSource().token) + const streamed: unknown[] = [] + for await (const part of response.stream) streamed.push(part) + const text: string[] = [] + for await (const chunk of response.text) text.push(chunk) + + expect(streamed).toHaveLength(1) + expect(streamed[0]).toBeInstanceOf(vscode.LanguageModelTextPart) + expect(text.join("")).toContain("functionality is limited") + await expect(client.countTokens("anything")).resolves.toBe(0) + }) + + it("counts nothing for input that is neither text nor a chat message", async () => { + handler["client"] = mockLanguageModelChat + await expect(handler["internalCountTokens"]({} as never)).resolves.toBe(0) + expect(mockLanguageModelChat.countTokens).not.toHaveBeenCalled() + }) + it("should throw a Zoo Code branded error when selectChatModels fails", async () => { ;(vscode.lm.selectChatModels as Mock).mockRejectedValueOnce(new Error("network down")) @@ -3081,3 +3123,57 @@ describe("context-window tool_result truncation", () => { }) }) }) + +describe("getVsCodeLmModels", () => { + const liveModel = (overrides: Record = {}) => ({ + ...mockLanguageModelChat, + id: "listed-model", + vendor: "copilot", + family: "listed-family", + ...overrides, + }) + + beforeEach(() => vi.clearAllMocks()) + + it("describes each model by identity and by the capabilities the host reports", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([ + liveModel({ maxInputTokens: 50000, capabilities: { supportsImageToText: true } }), + ] as never) + + const [listed] = await getVsCodeLmModels({ vendor: "copilot" }) + + expect(listed).toMatchObject({ + id: "listed-model", + vendor: "copilot", + family: "listed-family", + maxInputTokens: 50000, + modelInfo: { contextWindow: 50000, supportsImages: true }, + }) + }) + + it("passes the selector through to the host", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([]) + await getVsCodeLmModels({ vendor: "copilot" }) + expect(vscode.lm.selectChatModels).toHaveBeenCalledWith({ vendor: "copilot" }) + }) + + it("omits models that can never work", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue([ + liveModel({ id: "claude-3.7-sonnet" }), + liveModel({ id: "kept" }), + ] as never) + + expect((await getVsCodeLmModels()).map((model) => model.id)).toEqual(["kept"]) + }) + + it("reports a host that has no models as an empty list", async () => { + vi.mocked(vscode.lm.selectChatModels).mockResolvedValue(undefined as never) + await expect(getVsCodeLmModels()).resolves.toEqual([]) + }) + + it("surfaces a failed lookup instead of passing it off as 'no models'", async () => { + vi.mocked(vscode.lm.selectChatModels).mockRejectedValue(new Error("host unavailable")) + await expect(getVsCodeLmModels({ vendor: "copilot" })).rejects.toThrow("host unavailable") + await expect(getVsCodeLmModels()).rejects.toThrow("host unavailable") + }) +}) diff --git a/src/api/providers/github-copilot.ts b/src/api/providers/github-copilot.ts new file mode 100644 index 0000000000..7ab4860561 --- /dev/null +++ b/src/api/providers/github-copilot.ts @@ -0,0 +1,147 @@ +import * as vscode from "vscode" +import { Anthropic } from "@anthropic-ai/sdk" + +import { type ModelInfo, githubCopilotLanguageModel, vscodeLlmBaselineModelInfo } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" +import { CONTEXT_WINDOW_EXCEEDED_STATUS } from "../../core/context/context-management/context-error-handling" + +import { convertToVsCodeLmMessages } from "../transform/vscode-lm-format" + +import { VsCodeLmHandler, containsImageBlock, getVsCodeLmModels } from "./vscode-lm" +import { getVsCodeLmModelInfo } from "./vscode-lm-capabilities" + +/** + * GitHub Copilot as an API provider. + * + * Copilot's models are contributed to VS Code by the Copilot extension, so requests still travel + * through the VS Code Language Model API. What differs from the generic provider is that the models + * are never interchangeable with a stub, their capabilities are reported by the host, and a request + * that exceeds a model's window must fail in a way Zoo can recover from by condensing. + */ +export class GitHubCopilotHandler extends VsCodeLmHandler { + constructor(options: ApiHandlerOptions) { + super({ + ...options, + vsCodeLmModelSelector: { ...options.vsCodeLmModelSelector, vendor: githubCopilotLanguageModel.vendor }, + }) + } + + protected override createFallbackClient(): vscode.LanguageModelChat { + throw new Error( + "No matching GitHub Copilot model is available. Sign in to GitHub and enable the model in Copilot Chat.", + ) + } + + protected override deriveModelInfo(client: vscode.LanguageModelChat): ModelInfo { + return getVsCodeLmModelInfo(client) + } + + protected override getFallbackModelInfo(): ModelInfo { + return vscodeLlmBaselineModelInfo + } + + protected override supportsImageInput(info: ModelInfo): boolean { + return info.supportsImages === true + } + + protected override prepareMessageForCounting(message: vscode.LanguageModelChatMessage) { + // Image parts only count when the message reaches the counter intact. + return message + } + + protected override assertRequestSupported(messages: Anthropic.Messages.MessageParam[], info: ModelInfo): void { + if (!info.supportsImages && messages.some((message) => containsImageBlock(message.content))) { + throw new Error( + "The selected Copilot model does not report image input support. Select a model that supports images, or remove the image.", + ) + } + } + + protected override assertWithinContextWindow(inputTokens: number, info: ModelInfo): void { + if (inputTokens > info.contextWindow) { + this.ensureCleanState() + throw Object.assign( + new Error( + "The request exceeds the selected Copilot model's input context window. Condense the conversation or start a new task.", + ), + { status: CONTEXT_WINDOW_EXCEEDED_STATUS }, + ) + } + } + + /** Copilot reports each model's usable input window, so the live value is authoritative. */ + override getCondenseContextWindow(): number { + return this.getModel().info.contextWindow + } + + override async countTokens(content: Array): Promise { + await this.getClient() + const info = this.getModel().info + const messages = convertToVsCodeLmMessages([{ role: "user", content }], this.supportsImageInput(info)) + return this.calculateTotalInputTokens(messages) + } +} + +// Sign-in ----------------------------------------------------------------------------------------- + +function getGitHubSession(options: vscode.AuthenticationGetSessionOptions) { + const { authProviderId, authScopeSets } = githubCopilotLanguageModel + return authScopeSets.reduce>( + async (found, scopes) => (await found) ?? vscode.authentication.getSession(authProviderId, scopes, options), + Promise.resolve(undefined), + ) +} + +type SignInResult = { account: string; models: Awaited> } + +// Keyed by whether a fresh session was requested, so a Reconnect is never answered by a plain sign-in. +const signInsInFlight = new Map>() + +/** + * Signs in to GitHub and lists the Copilot models that become available. Concurrent calls with the + * same `forceNewSession` share one attempt, so a double click cannot open two sign-in prompts. + * + * Authenticating does not prove a Copilot entitlement: an account without one signs in successfully + * and simply sees no models, which callers must present as such. + */ +export function connectGitHubCopilot(onAuthenticated?: (account: string) => Promise, forceNewSession = false) { + const pending = signInsInFlight.get(forceNewSession) + if (pending) return pending + + const attempt = (async (): Promise => { + try { + const { authProviderId, authScopeSets, chatExtensionId, selector } = githubCopilotLanguageModel + const session = await vscode.authentication.getSession( + authProviderId, + authScopeSets[0], + forceNewSession ? { forceNewSession: true, clearSessionPreference: true } : { createIfNone: true }, + ) + if (!session) throw new Error("GitHub sign-in did not complete. Please try again.") + await onAuthenticated?.(session.account.label) + await vscode.extensions.getExtension(chatExtensionId)?.activate() + return { account: session.account.label, models: await getVsCodeLmModels(selector) } + } finally { + signInsInFlight.delete(forceNewSession) + } + })() + signInsInFlight.set(forceNewSession, attempt) + return attempt +} + +/** The signed-in account, or undefined when there is none or the lookup is not possible. */ +export async function getGitHubCopilotAccount() { + try { + return (await getGitHubSession({ silent: true }))?.account.label + } catch { + return undefined + } +} + +/** + * Opens VS Code's own account management, where the user can sign out. VS Code exposes no API to + * sign out on an extension's behalf, so Zoo defers to the host's UI instead of reaching for internals. + */ +export function openGitHubAccountManagement() { + return vscode.commands.executeCommand(githubCopilotLanguageModel.manageAccountsCommandId) +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 8ba1eae382..00a719725e 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -23,6 +23,7 @@ export { SambaNovaHandler } from "./sambanova" export { UnboundHandler } from "./unbound" export { VertexHandler } from "./vertex" export { VsCodeLmHandler } from "./vscode-lm" +export { GitHubCopilotHandler } from "./github-copilot" export { XAIHandler } from "./xai" export { ZAiHandler } from "./zai" export { FireworksHandler } from "./fireworks" diff --git a/src/api/providers/vscode-lm-capabilities.ts b/src/api/providers/vscode-lm-capabilities.ts new file mode 100644 index 0000000000..4bc795ac38 --- /dev/null +++ b/src/api/providers/vscode-lm-capabilities.ts @@ -0,0 +1,57 @@ +import * as vscode from "vscode" + +import { + type ModelInfo, + openAiModelInfoSaneDefaults, + vscodeLlmBaselineModelInfo, + vscodeLlmModels, +} from "@roo-code/types" + +import { canCreateImageParts } from "../transform/vscode-lm-image-part" + +/** + * Vision as the host reports it. + * + * VS Code exposes this to consumers as `capabilities.supportsImageToText` (a proposed API surface, + * `vscode.proposed.languageModelCapabilities`) and to providers as `capabilities.imageInput`. Both are + * read by name because the stable typings declare neither; a host that reports neither yields `undefined` + * so the caller can tell "unsupported" apart from "unknown". + */ +export function reportedImageSupport(model: vscode.LanguageModelChat): boolean | undefined { + const capabilities: unknown = Reflect.get(model, "capabilities") + if (typeof capabilities !== "object" || capabilities === null) { + return undefined + } + for (const key of ["supportsImageToText", "imageInput"]) { + const reported: unknown = Reflect.get(capabilities, key) + if (typeof reported === "boolean") { + return reported + } + } + return undefined +} + +/** + * Derives Zoo's model info from what VS Code reports for a live model. The curated catalog only fills + * gaps in everything but vision, and anything neither source states stays unset rather than guessed. + */ +export function getVsCodeLmModelInfo(model: vscode.LanguageModelChat): ModelInfo { + const curated = vscodeLlmModels[model.family as keyof typeof vscodeLlmModels] + const liveInputLimit = + Number.isFinite(model.maxInputTokens) && model.maxInputTokens > 0 + ? model.maxInputTokens + : openAiModelInfoSaneDefaults.contextWindow + const curatedInputLimit = curated?.maxInputTokens + + return { + ...vscodeLlmBaselineModelInfo, + ...curated, + contextWindow: + curatedInputLimit && curatedInputLimit > 0 ? Math.min(liveInputLimit, curatedInputLimit) : liveInputLimit, + // A live model's vision is the host's word alone; unreported stays unset rather than borrowing the catalog's. + supportsImages: canCreateImageParts() ? reportedImageSupport(model) : false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + } +} diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 2771227680..ed77b09cc8 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -23,6 +23,7 @@ import { sanitizeSurrogatesDeep, sanitizeToolNameSurrogates, } from "../transform/vscode-lm-format" +import { getVsCodeLmModelInfo } from "./vscode-lm-capabilities" import { CONTEXT_WINDOW_EXCEEDED_STATUS } from "../../core/context/context-management/context-error-handling" @@ -51,6 +52,22 @@ function convertToVsCodeLmTools(tools: OpenAI.Chat.ChatCompletionTool[]): vscode })) } +/** Structural check, so it holds for any host that supplies chat messages rather than one specific class. */ +function isChatMessage(value: unknown): value is vscode.LanguageModelChatMessage { + return typeof value === "object" && value !== null && "role" in value && "content" in value +} + +/** True when any block, including those nested in tool results, carries an image. */ +export function containsImageBlock(content: unknown): boolean { + return ( + Array.isArray(content) && + content.some( + (block: { type?: string; content?: unknown }) => + block.type === "image" || (block.type === "tool_result" && containsImageBlock(block.content)), + ) + ) +} + /** * Handles interaction with VS Code's Language Model API for chat-based operations. * This handler extends BaseProvider to provide VS Code LM specific functionality. @@ -728,7 +745,7 @@ export function extractLeakedToolCalls( export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: vscode.LanguageModelChat | null + protected client: vscode.LanguageModelChat | null private disposable: vscode.Disposable | null private currentRequestCancellation: vscode.CancellationTokenSource | null @@ -751,7 +768,12 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } }) - this.initializeClient() + void this.initializeClient().catch((error) => { + console.debug( + "Zoo Code : Initial client setup failed; retrying on first use:", + error, + ) + }) } catch (error) { // Ensure cleanup if constructor fails this.dispose() @@ -805,35 +827,78 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan return models[0] } - // Create a minimal model if no models are available - return { - id: "default-lm", - name: "Default Language Model", - vendor: "vscode", - family: "lm", - version: "1.0", - maxInputTokens: 8192, - sendRequest: async (_messages, _options, _token) => { - // Provide a minimal implementation - return { - stream: (async function* () { - yield new vscode.LanguageModelTextPart( - "Language model functionality is limited. Please check VS Code configuration.", - ) - })(), - text: (async function* () { - yield "Language model functionality is limited. Please check VS Code configuration." - })(), - } - }, - countTokens: async () => 0, - } + return this.createFallbackClient() } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error" throw new Error(`Zoo Code : Failed to select model: ${errorMessage}`) } } + /** + * Client used when no installed model matches the selector. Subclasses whose models are not + * interchangeable with a stub may refuse instead. + */ + protected createFallbackClient(): vscode.LanguageModelChat { + return { + id: "default-lm", + name: "Default Language Model", + vendor: "vscode", + family: "lm", + version: "1.0", + maxInputTokens: 8192, + sendRequest: async () => ({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart( + "Language model functionality is limited. Please check VS Code configuration.", + ) + })(), + text: (async function* () { + yield "Language model functionality is limited. Please check VS Code configuration." + })(), + }), + countTokens: async () => 0, + } + } + + /** Model info for a live client. */ + protected deriveModelInfo(client: vscode.LanguageModelChat, modelId: string): ModelInfo { + return { + maxTokens: -1, // Unlimited tokens by default + contextWindow: + typeof client.maxInputTokens === "number" + ? Math.max(0, client.maxInputTokens) + : openAiModelInfoSaneDefaults.contextWindow, + supportsImages: false, // VSCode Language Model API historically had no image input + supportsPromptCache: true, + inputPrice: 0, + outputPrice: 0, + description: `VSCode Language Model: ${modelId}`, + } + } + + /** Model info reported before any client exists. */ + protected getFallbackModelInfo(): ModelInfo { + return openAiModelInfoSaneDefaults + } + + /** Whether image parts are sent to the model. */ + protected supportsImageInput(_info: ModelInfo): boolean { + return false + } + + /** Rejects a request the selected model cannot serve before anything is sent. */ + protected assertRequestSupported(_messages: Anthropic.Messages.MessageParam[], _info: ModelInfo): void {} + + /** Rejects a request whose measured input exceeds what the model accepts. */ + protected assertWithinContextWindow(_inputTokens: number, _info: ModelInfo): void {} + + /** The form in which a chat message is handed to the host's token counter. */ + protected prepareMessageForCounting( + message: vscode.LanguageModelChatMessage, + ): string | vscode.LanguageModelChatMessage { + return extractTextCountFromMessage(message) + } + /** * Creates and streams a message using the VS Code Language Model API. * @@ -918,13 +983,13 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan if (typeof text === "string") { tokenCount = await this.client.countTokens(text, cancellationToken) - } else if (text instanceof vscode.LanguageModelChatMessage) { + } else if (isChatMessage(text)) { // For chat messages, ensure we have content if (!text.content || (Array.isArray(text.content) && text.content.length === 0)) { console.debug("Zoo Code : Empty chat message content") return 0 } - const countMessage = extractTextCountFromMessage(text) + const countMessage = this.prepareMessageForCounting(text) tokenCount = await this.client.countTokens(countMessage, cancellationToken) } else { console.warn("Zoo Code : Invalid input type for token counting") @@ -967,13 +1032,13 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } - private async calculateTotalInputTokens(vsCodeLmMessages: vscode.LanguageModelChatMessage[]): Promise { + protected async calculateTotalInputTokens(vsCodeLmMessages: vscode.LanguageModelChatMessage[]): Promise { const messageTokens: number[] = await Promise.all(vsCodeLmMessages.map((msg) => this.internalCountTokens(msg))) return messageTokens.reduce((sum: number, tokens: number): number => sum + tokens, 0) } - private ensureCleanState(): void { + protected ensureCleanState(): void { if (this.currentRequestCancellation) { this.currentRequestCancellation.cancel() this.currentRequestCancellation.dispose() @@ -981,7 +1046,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } - private async getClient(): Promise { + protected async getClient(): Promise { if (!this.client) { console.debug("Zoo Code : Getting client with options:", { vsCodeLmModelSelector: this.options.vsCodeLmModelSelector, @@ -1042,12 +1107,14 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Ensure clean state before starting a new request this.ensureCleanState() const client: vscode.LanguageModelChat = await this.getClient() + const modelInfo = this.getModel().info // Process messages const cleanedMessages = messages.map((msg) => ({ ...msg, content: this.cleanMessageContent(msg.content), })) + this.assertRequestSupported(cleanedMessages, modelInfo) // Keep context-window trimming on OUR side. Copilot's backend trims an over-window request // without preserving tool_use/tool_result pairing, which orphans a tool_result and triggers a @@ -1091,7 +1158,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Convert Anthropic messages to VS Code LM messages const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [ vscode.LanguageModelChatMessage.Assistant(sanitizeSurrogates(systemPrompt)), - ...convertToVsCodeLmMessages(cleanedMessages), + ...convertToVsCodeLmMessages(cleanedMessages, this.supportsImageInput(modelInfo)), ] // Initialize cancellation token for the request @@ -1099,6 +1166,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Calculate input tokens before starting the stream const totalInputTokens: number = await this.calculateTotalInputTokens(vsCodeLmMessages) + this.assertWithinContextWindow(totalInputTokens, modelInfo) // Accumulate the text and count at the end of the stream to reduce token counting overhead. let accumulatedText: string = "" @@ -1109,7 +1177,6 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan justification: `Zoo Code would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`, tools: convertToVsCodeLmTools(metadata?.tools ?? []), } - const response: vscode.LanguageModelChatResponse = await client.sendRequest( vsCodeLmMessages, requestOptions, @@ -1240,22 +1307,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan const modelParts = [this.client.vendor, this.client.family, this.client.version].filter(Boolean) const modelId = this.client.id || modelParts.join(SELECTOR_SEPARATOR) - - // Build model info with conservative defaults for missing values - const modelInfo: ModelInfo = { - maxTokens: -1, // Unlimited tokens by default - contextWindow: - typeof this.client.maxInputTokens === "number" - ? Math.max(0, this.client.maxInputTokens) - : openAiModelInfoSaneDefaults.contextWindow, - supportsImages: false, // VSCode Language Model API currently doesn't support image inputs - supportsPromptCache: true, - inputPrice: 0, - outputPrice: 0, - description: `VSCode Language Model: ${modelId}`, - } - - return { id: modelId, info: modelInfo } + return { id: modelId, info: this.deriveModelInfo(this.client, modelId) } } // Fallback when no client is available @@ -1268,7 +1320,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan return { id: fallbackId, info: { - ...openAiModelInfoSaneDefaults, + ...this.getFallbackModelInfo(), description: `VSCode Language Model (Fallback): ${fallbackId}`, }, } @@ -1321,14 +1373,21 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Static blacklist of VS Code Language Model IDs that should be excluded from the model list e.g. because they will never work const VSCODE_LM_STATIC_BLACKLIST: string[] = ["claude-3.7-sonnet", "claude-3.7-sonnet-thought"] -export async function getVsCodeLmModels() { - try { - const models = (await vscode.lm.selectChatModels({})) || [] - return models.filter((model) => !VSCODE_LM_STATIC_BLACKLIST.includes(model.id)) - } catch (error) { - console.error( - `Error fetching VS Code LM models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, - ) - return [] - } +/** + * Lists the language models the host currently offers. A failure is surfaced to the caller rather + * than reported as an empty list: "no models" and "could not ask" call for different handling. + */ +export async function getVsCodeLmModels(selector: vscode.LanguageModelChatSelector = {}) { + const models = (await vscode.lm.selectChatModels(selector)) || [] + return models + .filter((model) => !VSCODE_LM_STATIC_BLACKLIST.includes(model.id)) + .map((model) => ({ + id: model.id, + vendor: model.vendor, + family: model.family, + version: model.version, + name: model.name, + maxInputTokens: model.maxInputTokens, + modelInfo: getVsCodeLmModelInfo(model), + })) } diff --git a/src/api/transform/__tests__/vscode-lm-format.spec.ts b/src/api/transform/__tests__/vscode-lm-format.spec.ts index f55bf0356a..f3dc99a4a7 100644 --- a/src/api/transform/__tests__/vscode-lm-format.spec.ts +++ b/src/api/transform/__tests__/vscode-lm-format.spec.ts @@ -93,10 +93,106 @@ vitest.mock("vscode", () => { LanguageModelTextPart: MockLanguageModelTextPart, LanguageModelToolCallPart: MockLanguageModelToolCallPart, LanguageModelToolResultPart: MockLanguageModelToolResultPart, + // In VS Code this is a class whose static `image` builds the part. + LanguageModelDataPart: class { + static image = vitest.fn((data: Uint8Array, mimeType: string) => ({ data, mimeType })) + }, } }) describe("convertToVsCodeLmMessages", () => { + it("sends decoded image bytes when image input is enabled", () => { + const result = convertToVsCodeLmMessages( + [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" }, + }, + ], + }, + ], + true, + ) + expect(Reflect.get(Reflect.get(vscode, "LanguageModelDataPart"), "image")).toHaveBeenCalledWith( + Buffer.from("image"), + "image/png", + ) + expect(result[0].content[0]).toEqual({ data: Buffer.from("image"), mimeType: "image/png" }) + }) + + it("refuses an image on a host that cannot carry image data, instead of dropping it silently", () => { + const hostImagePart = Reflect.get(vscode, "LanguageModelDataPart") + const factory = hostImagePart.image + Object.assign(hostImagePart, { image: undefined }) + try { + expect(() => + convertToVsCodeLmMessages( + [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" }, + }, + ], + }, + ], + true, + ), + ).toThrow("Image input requires a newer version of VS Code") + } finally { + Object.assign(hostImagePart, { image: factory }) + } + }) + + it("preserves image bytes within tool results", () => { + const result = convertToVsCodeLmMessages( + [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "image-tool", + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/jpeg", data: "aW1hZ2U=" }, + }, + ], + }, + ], + }, + ], + true, + ) + const part = result[0].content[0] as vscode.LanguageModelToolResultPart + expect(part.content[0]).toEqual({ data: Buffer.from("image"), mimeType: "image/jpeg" }) + }) + + it("does not silently replace unsupported image URLs when image input is enabled", () => { + expect(() => + convertToVsCodeLmMessages( + [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "url", url: "https://example.com/image.png" }, + }, + ], + }, + ], + true, + ), + ).toThrow("requires base64 image data") + }) + it("should convert simple string messages", () => { const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: "Hello" }, diff --git a/src/api/transform/__tests__/vscode-lm-image-part.spec.ts b/src/api/transform/__tests__/vscode-lm-image-part.spec.ts new file mode 100644 index 0000000000..4c964862b2 --- /dev/null +++ b/src/api/transform/__tests__/vscode-lm-image-part.spec.ts @@ -0,0 +1,53 @@ +import * as vscode from "vscode" + +import { canCreateImageParts, createImagePart } from "../vscode-lm-image-part" + +vi.mock("vscode", () => ({ LanguageModelChatMessage: { User: vi.fn() }, LanguageModelDataPart: undefined })) + +// The stable typings predate image parts, so tests install and remove the host's class by name. +const setHostImagePart = (value: unknown) => Object.assign(vscode, { LanguageModelDataPart: value }) + +describe("image part support detection", () => { + afterEach(() => setHostImagePart(undefined)) + + it("builds image parts through the host's factory", () => { + const image = vi.fn((data: Uint8Array, mimeType: string) => ({ data, mimeType })) + setHostImagePart( + class { + static image = image + }, + ) + const bytes = new Uint8Array([1, 2]) + + expect(canCreateImageParts()).toBe(true) + expect(createImagePart(bytes, "image/png")).toEqual({ data: bytes, mimeType: "image/png" }) + expect(image).toHaveBeenCalledWith(bytes, "image/png") + }) + + it.each([ + ["a host without image parts", undefined], + ["a host whose image part has no factory", class {}], + ["a host that exposes something other than a class", { image: () => ({}) }], + [ + "a host whose factory is not callable", + class { + static image = "nope" + }, + ], + ])("cannot create image parts on %s", (_label, hostValue) => { + setHostImagePart(hostValue) + + expect(canCreateImageParts()).toBe(false) + expect(createImagePart(new Uint8Array(), "image/png")).toBeUndefined() + }) + + it("re-checks the host on every call, since the host and not this bundle decides support", () => { + expect(canCreateImageParts()).toBe(false) + setHostImagePart( + class { + static image = () => ({}) + }, + ) + expect(canCreateImageParts()).toBe(true) + }) +}) diff --git a/src/api/transform/vscode-lm-format.ts b/src/api/transform/vscode-lm-format.ts index c20a108788..8e56d7a60f 100644 --- a/src/api/transform/vscode-lm-format.ts +++ b/src/api/transform/vscode-lm-format.ts @@ -1,6 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import * as vscode from "vscode" +import { createImagePart, createUserMessage, type ImagePart } from "./vscode-lm-image-part" import { HAS_LONE_SURROGATE, LONE_SURROGATE, @@ -92,8 +93,27 @@ export function decodeToolNameSurrogates(name: string): string { return decoded } +function convertImageToVsCodeLmPart(image: Anthropic.Messages.ImageBlockParam, supportsImages: boolean) { + if (supportsImages) { + if (image.source.type !== "base64") { + throw new Error("GitHub Copilot image input requires base64 image data, not an image URL.") + } + const part = createImagePart(Buffer.from(image.source.data, "base64"), image.source.media_type) + if (!part) { + throw new Error("Image input requires a newer version of VS Code that can send image data.") + } + return part + } + return new vscode.LanguageModelTextPart( + image.source.type === "base64" + ? `[Image (base64): ${image.source.media_type} not supported by VSCode LM API]` + : `[Image (${image.source.type}): not supported by VSCode LM API]`, + ) +} + export function convertToVsCodeLmMessages( anthropicMessages: Anthropic.Messages.MessageParam[], + supportsImages = false, ): vscode.LanguageModelChatMessage[] { const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [] @@ -132,19 +152,12 @@ export function convertToVsCodeLmMessages( // Convert tool messages to ToolResultParts ...toolMessages.map((toolMessage) => { // Process tool result content into TextParts - const toolContentParts: vscode.LanguageModelTextPart[] = + const toolContentParts: Array = typeof toolMessage.content === "string" ? [new vscode.LanguageModelTextPart(sanitizeSurrogates(toolMessage.content))] : (toolMessage.content?.map((part) => { if (part.type === "image") { - if (part.source.type === "base64") { - return new vscode.LanguageModelTextPart( - `[Image (base64): ${part.source.media_type} not supported by VSCode LM API]`, - ) - } - return new vscode.LanguageModelTextPart( - `[Image (${part.source.type}): not supported by VSCode LM API]`, - ) + return convertImageToVsCodeLmPart(part, supportsImages) } if (part.type === "text") { return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text)) @@ -161,21 +174,14 @@ export function convertToVsCodeLmMessages( // Convert non-tool messages to TextParts after tool messages ...nonToolMessages.map((part) => { if (part.type === "image") { - if (part.source.type === "base64") { - return new vscode.LanguageModelTextPart( - `[Image (base64): ${part.source.media_type} not supported by VSCode LM API]`, - ) - } - return new vscode.LanguageModelTextPart( - `[Image (${part.source.type}): not supported by VSCode LM API]`, - ) + return convertImageToVsCodeLmPart(part, supportsImages) } return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text)) }), ] // Add single user message with all content parts - vsCodeLmMessages.push(vscode.LanguageModelChatMessage.User(contentParts)) + vsCodeLmMessages.push(createUserMessage(contentParts)) break } diff --git a/src/api/transform/vscode-lm-image-part.ts b/src/api/transform/vscode-lm-image-part.ts new file mode 100644 index 0000000000..b831255a2d --- /dev/null +++ b/src/api/transform/vscode-lm-image-part.ts @@ -0,0 +1,36 @@ +import * as vscode from "vscode" + +/** Shape of `vscode.LanguageModelDataPart`, whose typings postdate our minimum VS Code version. */ +export interface ImagePart { + readonly mimeType: string + readonly data: Uint8Array +} + +export type UserMessagePart = vscode.LanguageModelTextPart | vscode.LanguageModelToolResultPart | ImagePart + +type ImagePartFactory = (data: Uint8Array, mimeType: string) => ImagePart + +/** Resolved per call because the host, not this bundle, decides whether image parts exist. */ +function resolveImagePartFactory(): ImagePartFactory | undefined { + const dataPart: unknown = Reflect.get(vscode, "LanguageModelDataPart") + const image: unknown = typeof dataPart === "function" ? Reflect.get(dataPart, "image") : undefined + return typeof image === "function" + ? (data, mimeType) => Reflect.apply(image, dataPart, [data, mimeType]) + : undefined +} + +export function canCreateImageParts(): boolean { + return resolveImagePartFactory() !== undefined +} + +/** Returns undefined on hosts that cannot carry image data. */ +export function createImagePart(data: Uint8Array, mimeType: string): ImagePart | undefined { + return resolveImagePartFactory()?.(data, mimeType) +} + +export function createUserMessage(parts: UserMessagePart[]): vscode.LanguageModelChatMessage { + // Hosts that support image parts accept them as content; the older typings just do not declare them. + return vscode.LanguageModelChatMessage.User( + parts as Exclude[0], string>, + ) +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4b4139ccb9..3faf317b7d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -346,7 +346,6 @@ export class ClineProvider ) ClineProvider.activeInstances.add(this) - this.mdmService = mdmService void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index dcd70f92f1..287e13f62f 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -70,6 +70,8 @@ vi.mock("@roo-code/telemetry", () => ({ })) import type { ModelRecord } from "@roo-code/types" +import * as vsCodeLmProvider from "../../../api/providers/vscode-lm" +import * as copilotProvider from "../../../api/providers/github-copilot" import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" @@ -125,6 +127,226 @@ const mockClineProvider = { cwd: "/mock/workspace", } as unknown as ClineProvider +describe("webviewMessageHandler - Copilot authentication", () => { + beforeEach(() => { + vi.clearAllMocks() + mockClineProvider.getState = vi.fn().mockResolvedValue({ apiConfiguration: {} }) + }) + afterEach(() => vi.restoreAllMocks()) + + it("reports the account before models finish loading without exposing a token", async () => { + vi.spyOn(copilotProvider, "connectGitHubCopilot").mockImplementation( + async (onAuthenticated?: (account: string) => Promise) => { + await onAuthenticated?.("Test User") + return { account: "Test User", models: [] } + }, + ) + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenNthCalledWith(1, { + type: "githubCopilotModels", + githubCopilotAccount: "Test User", + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenNthCalledWith(2, { + type: "githubCopilotSignInResult", + githubCopilotAccount: "Test User", + vsCodeLmModels: [], + }) + expect(JSON.stringify(vi.mocked(mockClineProvider.postMessageToWebview).mock.calls)).not.toContain( + "accessToken", + ) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + }) + + it("returns sign-in errors to the requesting control", async () => { + vi.spyOn(copilotProvider, "connectGitHubCopilot").mockRejectedValue(new Error("Sign-in cancelled")) + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotSignInResult", + error: "Sign-in cancelled", + }) + }) + + it("uses a fresh session for the reconnect command", async () => { + const connect = vi + .spyOn(copilotProvider, "connectGitHubCopilot") + .mockResolvedValue({ account: "New User", models: [] }) + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotReconnect" }) + expect(connect).toHaveBeenCalledWith(expect.any(Function), true) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotSignInResult", + githubCopilotAccount: "New User", + vsCodeLmModels: [], + }) + }) + + it("drops a stale sign-in that completes after a newer reconnect", async () => { + let finishSignIn!: (value: { account: string; models: [] }) => void + let finishReconnect!: (value: { account: string; models: [] }) => void + vi.spyOn(copilotProvider, "connectGitHubCopilot") + .mockImplementationOnce(() => new Promise((resolve) => (finishSignIn = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (finishReconnect = resolve))) + + const signIn = webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + const reconnect = webviewMessageHandler(mockClineProvider, { type: "githubCopilotReconnect" }) + + // The newer request finishes first, then the older one arrives late. + finishReconnect({ account: "New User", models: [] }) + await reconnect + finishSignIn({ account: "Old User", models: [] }) + await signIn + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledTimes(1) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotSignInResult", + githubCopilotAccount: "New User", + vsCodeLmModels: [], + }) + }) + + it("drops a stale sign-in failure that arrives after a newer success", async () => { + let failSignIn!: (reason: Error) => void + let finishReconnect!: (value: { account: string; models: [] }) => void + vi.spyOn(copilotProvider, "connectGitHubCopilot") + .mockImplementationOnce(() => new Promise((_resolve, reject) => (failSignIn = reject))) + .mockImplementationOnce(() => new Promise((resolve) => (finishReconnect = resolve))) + + const signIn = webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + const reconnect = webviewMessageHandler(mockClineProvider, { type: "githubCopilotReconnect" }) + + finishReconnect({ account: "New User", models: [] }) + await reconnect + failSignIn(new Error("Sign-in cancelled")) + await signIn + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledTimes(1) + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalledWith( + expect.objectContaining({ error: expect.anything() }), + ) + }) + + it("answers every view that signs in, since only a view's own newer request makes its older one stale", async () => { + const otherView = { + ...mockClineProvider, + postMessageToWebview: vi.fn(), + } as unknown as ClineProvider + let finishFirst!: (value: { account: string; models: [] }) => void + let finishSecond!: (value: { account: string; models: [] }) => void + vi.spyOn(copilotProvider, "connectGitHubCopilot") + .mockImplementationOnce(() => new Promise((resolve) => (finishFirst = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (finishSecond = resolve))) + + const first = webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + const second = webviewMessageHandler(otherView, { type: "githubCopilotSignIn" }) + finishSecond({ account: "Second User", models: [] }) + finishFirst({ account: "First User", models: [] }) + await Promise.all([first, second]) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "githubCopilotSignInResult", githubCopilotAccount: "First User" }), + ) + expect(otherView.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "githubCopilotSignInResult", githubCopilotAccount: "Second User" }), + ) + }) + + it("opens VS Code's account management and never signs out itself", async () => { + const open = vi.spyOn(copilotProvider, "openGitHubAccountManagement").mockResolvedValue(undefined) + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotManageAccount" }) + expect(open).toHaveBeenCalledOnce() + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("logs, rather than surfacing, a failure to open account management", async () => { + vi.spyOn(copilotProvider, "openGitHubAccountManagement").mockRejectedValue(new Error("command missing")) + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotManageAccount" }) + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("command missing")) + }) + + it("reports a non-Error sign-in failure as text", async () => { + vi.spyOn(copilotProvider, "connectGitHubCopilot").mockRejectedValue("denied") + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotSignIn" }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotSignInResult", + error: "denied", + }) + }) + + it("logs a non-Error failure to open account management", async () => { + vi.spyOn(copilotProvider, "openGitHubAccountManagement").mockRejectedValue("no such command") + await webviewMessageHandler(mockClineProvider, { type: "githubCopilotManageAccount" }) + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("no such command")) + }) + + describe("legacy VS Code LM model requests", () => { + it("lists every model unscoped and never looks up a GitHub account", async () => { + const account = vi.spyOn(copilotProvider, "getGitHubCopilotAccount") + const models = vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + + await webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels" }) + + expect(models).toHaveBeenCalledWith({}) + expect(account).not.toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "vsCodeLmModels", + vsCodeLmModels: [], + }) + }) + + it("reports a failed lookup to the legacy list without touching the Copilot one", async () => { + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockRejectedValue("host busy") + + await webviewMessageHandler(mockClineProvider, { type: "requestVsCodeLmModels" }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "vsCodeLmModels", + error: "host busy", + }) + }) + }) + + it("restores a signed-in account during passive model refresh", async () => { + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + await webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + expect(vsCodeLmProvider.getVsCodeLmModels).toHaveBeenCalledWith({ vendor: "copilot" }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotModels", + githubCopilotAccount: "Test User", + vsCodeLmModels: [], + }) + }) + + it("reports no account as null, so the webview clears a stale sign-in rather than keeping it", async () => { + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue(undefined) + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockResolvedValue([]) + await webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotModels", + githubCopilotAccount: null, + vsCodeLmModels: [], + }) + }) + + it("shows model discovery errors instead of silently returning an empty list", async () => { + vi.spyOn(copilotProvider, "getGitHubCopilotAccount").mockResolvedValue("Test User") + vi.spyOn(vsCodeLmProvider, "getVsCodeLmModels").mockRejectedValue(new Error("Model access denied")) + await webviewMessageHandler(mockClineProvider, { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "githubCopilotModels", + error: "Model access denied", + }) + }) +}) + describe("webviewMessageHandler - theme fixture probes", () => { const originalProbeSetting = process.env.ROO_CODE_THEME_FIXTURE_PROBE const themeFixture = { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 193540455b..3d75d01499 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -23,6 +23,7 @@ import { checkoutRestorePayloadSchema, getCompletionCheckpoint, providerIdentifiers, + githubCopilotLanguageModel, retiredProviderIdentifiers, LmStudioModelsMessageType, OllamaModelsMessageType, @@ -80,6 +81,11 @@ import { searchCommits } from "../../utils/git" import { exportSettings, importSettingsWithFeedback } from "../config/importExport" import { getOpenAiModels } from "../../api/providers/openai" import { getVsCodeLmModels } from "../../api/providers/vscode-lm" +import { + connectGitHubCopilot, + getGitHubCopilotAccount, + openGitHubAccountManagement, +} from "../../api/providers/github-copilot" import { openMention } from "../mentions" import { resolveImageMentions } from "../mentions/resolveImageMentions" import { RooIgnoreController } from "../ignore/RooIgnoreController" @@ -119,6 +125,9 @@ import { handleCheckoutBranch, } from "./worktree" +/** The newest GitHub Copilot sign-in request per view, so a slower, older one cannot overwrite its result. */ +const githubCopilotSignInRequestIds = new WeakMap() + export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, @@ -1480,11 +1489,66 @@ export const webviewMessageHandler = async ( } break - case VsCodeLmModelsMessageType.requestVsCodeLmModels: - const vsCodeLmModels = await getVsCodeLmModels() - // TODO: Cache like we do for OpenRouter, etc? - await provider.postMessageToWebview({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels }) + case VsCodeLmModelsMessageType.githubCopilotSignIn: + case VsCodeLmModelsMessageType.githubCopilotReconnect: { + // Plain sign-in and Reconnect run as separate attempts, so an older one can finish after a newer one. + // Only the latest request may publish, or its account and model list would overwrite newer state. + const requestId = (githubCopilotSignInRequestIds.get(provider) ?? 0) + 1 + githubCopilotSignInRequestIds.set(provider, requestId) + const isLatest = () => githubCopilotSignInRequestIds.get(provider) === requestId + try { + const { models, account } = await connectGitHubCopilot(async (githubCopilotAccount: string) => { + if (!isLatest()) return + await provider.postMessageToWebview({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + githubCopilotAccount, + }) + }, message.type === VsCodeLmModelsMessageType.githubCopilotReconnect) + if (!isLatest()) break + await provider.postMessageToWebview({ + type: VsCodeLmModelsMessageType.githubCopilotSignInResult, + vsCodeLmModels: models, + githubCopilotAccount: account, + }) + } catch (error) { + if (!isLatest()) break + await provider.postMessageToWebview({ + type: VsCodeLmModelsMessageType.githubCopilotSignInResult, + error: error instanceof Error ? error.message : String(error), + }) + } break + } + case VsCodeLmModelsMessageType.githubCopilotManageAccount: + try { + await openGitHubAccountManagement() + } catch (error) { + provider.log( + `Could not open VS Code account management: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + case VsCodeLmModelsMessageType.requestVsCodeLmModels: { + const isCopilot = message.apiConfiguration?.apiProvider === providerIdentifiers.githubCopilot + const type = isCopilot + ? VsCodeLmModelsMessageType.githubCopilotModels + : VsCodeLmModelsMessageType.vsCodeLmModels + try { + const githubCopilotAccount = isCopilot ? await getGitHubCopilotAccount() : undefined + const vsCodeLmModels = await getVsCodeLmModels(isCopilot ? githubCopilotLanguageModel.selector : {}) + await provider.postMessageToWebview({ + type, + vsCodeLmModels, + ...(isCopilot ? { githubCopilotAccount: githubCopilotAccount ?? null } : {}), + }) + } catch (error) { + await provider.postMessageToWebview({ + type, + error: error instanceof Error ? error.message : String(error), + }) + } + break + } case "openImage": await openImage(message.text!, { values: message.values }) break diff --git a/src/extension.ts b/src/extension.ts index aa82c5388f..61e9a88eab 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,6 +28,7 @@ import { Package } from "./shared/package" import { formatLanguage } from "./shared/language" import { ContextProxy } from "./core/config/ContextProxy" import { ClineProvider } from "./core/webview/ClineProvider" +import { registerCopilotModelBroadcast } from "./services/language-models/copilotModelBroadcast" import { ClineProviderFactory } from "./core/webview/ClineProviderFactory" import { WebviewFocusTracker } from "./core/webview/WebviewFocusTracker" import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" @@ -198,6 +199,13 @@ export async function activate(context: vscode.ExtensionContext) { }), ) + context.subscriptions.push( + registerCopilotModelBroadcast({ + getTargets: () => ClineProvider.getAllInstances(), + log: (message) => outputChannel.appendLine(message), + }), + ) + const codeIndexScope = new CodeIndexScope(context) context.subscriptions.push(codeIndexScope) try { diff --git a/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts new file mode 100644 index 0000000000..78121e50ea --- /dev/null +++ b/src/services/language-models/__tests__/copilotModelBroadcast.spec.ts @@ -0,0 +1,217 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest" +import * as vscode from "vscode" + +import { VsCodeLmModelsMessageType, githubCopilotLanguageModel } from "@roo-code/types" + +import { registerCopilotModelBroadcast, type CopilotModelBroadcastTarget } from "../copilotModelBroadcast" + +vi.mock("vscode", () => ({ lm: { onDidChangeChatModels: vi.fn() } })) +vi.mock("../../../api/providers/vscode-lm", () => ({ getVsCodeLmModels: vi.fn() })) + +type Models = Awaited> + +const model = (id: string) => ({ id, vendor: "copilot", family: id, version: "1", name: id, maxInputTokens: 1 }) + +function setup(overrides: { targets?: CopilotModelBroadcastTarget[]; fetchModels?: ReturnType } = {}) { + let notify: () => void = () => {} + const subscription = { dispose: vi.fn() } + const target = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const targets = overrides.targets ?? [target] + const fetchModels = overrides.fetchModels ?? vi.fn().mockResolvedValue([model("a")] as Models) + const log = vi.fn() + const registration = registerCopilotModelBroadcast({ + getTargets: () => targets, + log, + debounceMs: 100, + subscribe: (listener) => { + notify = listener + return subscription as never + }, + fetchModels: fetchModels as never, + }) + return { notify: () => notify(), registration, subscription, target, targets, fetchModels, log } +} + +describe("registerCopilotModelBroadcast", () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it("looks models up once for the Copilot vendor and posts them to every view", async () => { + const second = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const first = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const { notify, fetchModels } = setup({ targets: [first, second] }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(fetchModels).toHaveBeenCalledTimes(1) + expect(fetchModels).toHaveBeenCalledWith(githubCopilotLanguageModel.selector) + for (const view of [first, second]) { + expect(view.postMessageToWebview).toHaveBeenCalledWith({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [model("a")], + }) + } + }) + + it("collapses a burst of change events into one refresh", async () => { + const { notify, fetchModels } = setup() + + notify() + notify() + await vi.advanceTimersByTimeAsync(50) + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(fetchModels).toHaveBeenCalledTimes(1) + }) + + it("does nothing before the debounce interval has elapsed", async () => { + const { notify, fetchModels } = setup() + + notify() + await vi.advanceTimersByTimeAsync(99) + + expect(fetchModels).not.toHaveBeenCalled() + }) + + it("includes views that open after registration", async () => { + const targets: CopilotModelBroadcastTarget[] = [] + const { notify } = setup({ targets }) + const late = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + targets.push(late) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(late.postMessageToWebview).toHaveBeenCalledTimes(1) + }) + + it("logs a failed lookup and posts nothing, so views keep their last known list", async () => { + const { notify, target, log } = setup({ fetchModels: vi.fn().mockRejectedValue(new Error("host busy")) }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(target.postMessageToWebview).not.toHaveBeenCalled() + expect(log).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledWith("Failed to refresh GitHub Copilot models: host busy") + }) + + it("posts an empty list when the host genuinely reports no models", async () => { + const { notify, target } = setup({ fetchModels: vi.fn().mockResolvedValue([]) }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(target.postMessageToWebview).toHaveBeenCalledWith({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [], + }) + }) + + it("discards a lookup that a newer change has superseded", async () => { + let resolveFirst: (models: Models) => void = () => {} + const fetchModels = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) + .mockResolvedValueOnce([model("fresh")] as Models) + const { notify, target } = setup({ fetchModels }) + + notify() + await vi.advanceTimersByTimeAsync(100) + notify() + await vi.advanceTimersByTimeAsync(100) + resolveFirst([model("stale")] as Models) + await vi.advanceTimersByTimeAsync(0) + + expect(target.postMessageToWebview).toHaveBeenCalledTimes(1) + expect(target.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ vsCodeLmModels: [model("fresh")] }), + ) + }) + + it("stops listening and cancels a pending refresh when disposed", async () => { + const { notify, registration, subscription, fetchModels } = setup() + + notify() + registration.dispose() + await vi.advanceTimersByTimeAsync(500) + + expect(subscription.dispose).toHaveBeenCalledTimes(1) + expect(fetchModels).not.toHaveBeenCalled() + }) + + it("does not post a lookup that finishes after disposal", async () => { + let resolve: (models: Models) => void = () => {} + const fetchModels = vi.fn().mockImplementation(() => new Promise((r) => (resolve = r))) + const { notify, registration, target } = setup({ fetchModels }) + + notify() + await vi.advanceTimersByTimeAsync(100) + registration.dispose() + resolve([model("a")] as Models) + await vi.advanceTimersByTimeAsync(0) + + expect(target.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("tolerates a host that has no model-change event", () => { + const registration = registerCopilotModelBroadcast({ + getTargets: () => [], + log: vi.fn(), + subscribe: () => undefined, + }) + expect(() => registration.dispose()).not.toThrow() + }) + + it("logs a failure that is not an Error as text", async () => { + const { notify, log } = setup({ fetchModels: vi.fn().mockRejectedValue("plain failure") }) + + notify() + await vi.advanceTimersByTimeAsync(100) + + expect(log).toHaveBeenCalledWith("Failed to refresh GitHub Copilot models: plain failure") + }) + + describe("default host wiring", () => { + const hostEvent = vi.mocked(vscode.lm.onDidChangeChatModels) + + it("listens to the host's model-change event and releases it on dispose", () => { + const hostSubscription = { dispose: vi.fn() } + hostEvent.mockReturnValue(hostSubscription as never) + + const registration = registerCopilotModelBroadcast({ getTargets: () => [], log: vi.fn() }) + expect(hostEvent).toHaveBeenCalledWith(expect.any(Function)) + + registration.dispose() + expect(hostSubscription.dispose).toHaveBeenCalledTimes(1) + }) + + it("refreshes when the host raises the event", async () => { + let raise: () => void = () => {} + hostEvent.mockImplementation(((listener: () => void) => { + raise = listener + return { dispose: vi.fn() } + }) as never) + const fetchModels = vi.fn().mockResolvedValue([]) + + registerCopilotModelBroadcast({ + getTargets: () => [], + log: vi.fn(), + fetchModels: fetchModels as never, + debounceMs: 10, + }) + raise() + await vi.advanceTimersByTimeAsync(10) + + expect(fetchModels).toHaveBeenCalledTimes(1) + }) + + it("tolerates a host that does not expose the event", () => { + hostEvent.mockReturnValue(undefined as never) + const registration = registerCopilotModelBroadcast({ getTargets: () => [], log: vi.fn() }) + expect(() => registration.dispose()).not.toThrow() + }) + }) +}) diff --git a/src/services/language-models/copilotModelBroadcast.ts b/src/services/language-models/copilotModelBroadcast.ts new file mode 100644 index 0000000000..90fbcf5388 --- /dev/null +++ b/src/services/language-models/copilotModelBroadcast.ts @@ -0,0 +1,78 @@ +import * as vscode from "vscode" + +import { VsCodeLmModelsMessageType, githubCopilotLanguageModel } from "@roo-code/types" + +import { getVsCodeLmModels } from "../../api/providers/vscode-lm" + +export interface CopilotModelBroadcastTarget { + postMessageToWebview(message: { + type: typeof VsCodeLmModelsMessageType.githubCopilotModels + vsCodeLmModels: Awaited> + }): Promise +} + +export interface CopilotModelBroadcastOptions { + /** The webviews to keep current; resolved on every broadcast so views opened later are included. */ + getTargets: () => readonly CopilotModelBroadcastTarget[] + log: (message: string) => void + /** Collapses the burst of events VS Code raises while models register into one refresh. */ + debounceMs?: number + subscribe?: (listener: () => void) => vscode.Disposable | undefined + fetchModels?: typeof getVsCodeLmModels +} + +const DEFAULT_DEBOUNCE_MS = 250 + +/** + * Keeps every open webview's Copilot model list current as the host registers or removes models + * (sign-in, sign-out, entitlement changes). + * + * One subscription serves all views, so a change costs one lookup however many views exist. A failed + * lookup is logged and leaves each view's last known list in place: reporting failure as "no models" + * would wrongly present a transient error as a lost entitlement. + */ +export function registerCopilotModelBroadcast(options: CopilotModelBroadcastOptions): vscode.Disposable { + const { + getTargets, + log, + debounceMs = DEFAULT_DEBOUNCE_MS, + subscribe = (listener) => ("lm" in vscode ? vscode.lm.onDidChangeChatModels?.(listener) : undefined), + fetchModels = getVsCodeLmModels, + } = options + + let timer: ReturnType | undefined + let latestRequest = 0 + let disposed = false + + const refresh = async () => { + const request = ++latestRequest + try { + const vsCodeLmModels = await fetchModels(githubCopilotLanguageModel.selector) + // A newer change superseded this lookup, or the extension is shutting down. + if (disposed || request !== latestRequest) return + await Promise.all( + getTargets().map((target) => + target.postMessageToWebview({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels, + }), + ), + ) + } catch (error) { + log(`Failed to refresh GitHub Copilot models: ${error instanceof Error ? error.message : String(error)}`) + } + } + + const subscription = subscribe(() => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => void refresh(), debounceMs) + }) + + return { + dispose() { + disposed = true + if (timer) clearTimeout(timer) + subscription?.dispose() + }, + } +} diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index 923582bd06..e44fbbd2f5 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -70,6 +70,7 @@ export class ProfileValidator { case providerIdentifiers.lmstudio: return profile.lmStudioModelId case providerIdentifiers.vscodeLm: + case providerIdentifiers.githubCopilot: // We probably need something more flexible for this one, if we need to really support it here. return profile.vsCodeLmModelSelector?.id case providerIdentifiers.openrouter: diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index 865fa5cf51..e999d3890b 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -296,6 +296,21 @@ describe("ProfileValidator", () => { expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) }) + it.each([ + ["allows an allow-listed model", "copilot-gpt-3.5", true], + ["blocks a model outside the allow-list", "copilot-other", false], + ])("enforces the organization allow-list for the GitHub Copilot provider: %s", (_label, id, allowed) => { + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { [providerIdentifiers.githubCopilot]: { allowAll: false, models: ["copilot-gpt-3.5"] } }, + } + const profile: ProviderSettings = { + apiProvider: providerIdentifiers.githubCopilot, + vsCodeLmModelSelector: { vendor: "copilot", id }, + } + expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(allowed) + }) + it("should extract lmStudioModelId for lmstudio provider", () => { const allowList: OrganizationAllowList = { allowAll: false, diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 97c91d1423..ecaa3340dc 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -218,6 +218,34 @@ export const stories: Record = { ) }, + "github-copilot-settings": async () => { + const [{ AppProviders }, { VSCodeLM }, { providerIdentifiers }] = await Promise.all([ + import("../AppProviders"), + import("@/components/settings/providers/VSCodeLM"), + import("@roo-code/types"), + ]) + function CopilotSettings() { + const [config, setConfig] = useState({ + apiProvider: providerIdentifiers.githubCopilot, + vsCodeLmModelSelector: { vendor: "copilot", id: "gpt-5.5", family: "gpt-5.5", version: "1" }, + }) + return ( +
+ + setConfig((previous) => ({ ...previous, [field]: value })) + } + /> +
+ ) + } + return ( + + + + ) + }, "ui-settings": async () => { const { UISettingsStory } = await import("@/components/settings/__tests__/UISettings.visual.fixture") return diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 714b507774..fa31fccc71 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -242,8 +242,17 @@ const ApiOptions = ({ }) } else if (selectedProvider === providerIdentifiers.lmstudio) { requestLmStudioModels(apiConfiguration?.lmStudioBaseUrl) - } else if (selectedProvider === providerIdentifiers.vscodeLm) { - vscode.postMessage({ type: VsCodeLmModelsMessageType.requestVsCodeLmModels }) + } else if ( + selectedProvider === providerIdentifiers.vscodeLm || + selectedProvider === providerIdentifiers.githubCopilot + ) { + vscode.postMessage({ + type: VsCodeLmModelsMessageType.requestVsCodeLmModels, + // Only Copilot needs scoping; the legacy provider keeps its original unscoped request. + ...(selectedProvider === providerIdentifiers.githubCopilot && { + apiConfiguration: { apiProvider: selectedProvider }, + }), + }) } else if (selectedProvider === providerIdentifiers.litellm) { vscode.postMessage({ type: RouterModelsMessageType.requestRouterModels, @@ -620,8 +629,10 @@ const ApiOptions = ({ )} - {selectedProvider === providerIdentifiers.vscodeLm && ( + {(selectedProvider === providerIdentifiers.vscodeLm || + selectedProvider === providerIdentifiers.githubCopilot) && ( diff --git a/webview-ui/src/components/settings/ModelInfoView.tsx b/webview-ui/src/components/settings/ModelInfoView.tsx index 34feecb2bb..c0c6b9fdbf 100644 --- a/webview-ui/src/components/settings/ModelInfoView.tsx +++ b/webview-ui/src/components/settings/ModelInfoView.tsx @@ -6,6 +6,7 @@ import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { formatPrice } from "@src/utils/formatPrice" import { cn } from "@src/lib/utils" import { useAppTranslation } from "@src/i18n/TranslationContext" +import { StandardTooltip } from "@src/components/ui" import { ModelDescriptionMarkdown } from "./ModelDescriptionMarkdown" @@ -69,16 +70,34 @@ export const ModelInfoView = ({ {modelInfo.maxTokens?.toLocaleString()} tokens ), - , - , + apiProvider === providerIdentifiers.githubCopilot && modelInfo?.supportsImages === undefined ? ( +
+
+ ) : ( + + ), + apiProvider === providerIdentifiers.githubCopilot ? ( + +
+
+
+ ) : ( + + ), apiProvider === providerIdentifiers.gemini && ( {selectedModelId.includes("pro-preview") diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx index e96340ad4f..c60d15eb9a 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.interactions.spec.tsx @@ -228,6 +228,14 @@ describe("ApiOptions interactions", () => { configuration: {}, expectedMessage: { type: "requestVsCodeLmModels" }, }, + { + provider: providerIdentifiers.githubCopilot, + configuration: {}, + expectedMessage: { + type: "requestVsCodeLmModels", + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }, + }, { provider: providerIdentifiers.litellm, configuration: { litellmBaseUrl: "http://litellm:4000", litellmApiKey: "litellm-key" }, diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx index 58e23511a7..48ed8b4a8b 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx @@ -130,6 +130,15 @@ describe("ApiOptions Provider Filtering", () => { expect(useRouterModels).toHaveBeenCalledWith({ enabled: false, provider: undefined }) }) + it("shows GitHub Copilot in the provider dropdown", () => { + vi.mocked(useExtensionState).mockReturnValue({ + ...useExtensionState(), + organizationAllowList: { allowAll: true, providers: {} }, + }) + renderWithProviders() + expect(screen.getByTestId(`option-${providerIdentifiers.githubCopilot}`)).toHaveTextContent("GitHub Copilot") + }) + it("requests router models only for the selected dynamic provider", () => { vi.mocked(useSelectedModel).mockReturnValue({ provider: providerIdentifiers.kenari, diff --git a/webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx b/webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx new file mode 100644 index 0000000000..248ff0818a --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/GitHubCopilotSettings.visual.tsx @@ -0,0 +1,62 @@ +import type { Page } from "@playwright/test" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../playwright/mounted-story" + +const send = (page: Page, data: object) => + page.evaluate((message) => window.dispatchEvent(new MessageEvent("message", { data: message })), data) + +// The host reports context but says nothing about vision, which the UI must show as unreported. +const model = { id: "gpt-5.5", vendor: "copilot", family: "gpt-5.5", version: "1", name: "GPT-5.5" } +const reportedModel = { ...model, modelInfo: { contextWindow: 268_426, supportsPromptCache: false } } + +const mountSettings = async (mount: (name: string) => Promise, page: Page) => { + const component = mountedStory(await mount("github-copilot-settings")) + // The full provider bundle leaves a bare Zod reference after gallery tree-shaking. + await page.evaluate(() => Object.assign(globalThis, { z: undefined })) + return component +} + +const settle = (component: import("@playwright/test").Locator) => + component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + +test("renders a signed-in account with host-reported capabilities in the VS Code dark theme", async ({ + mount, + page, +}) => { + const component = await mountSettings(mount, page) + await send(page, { + type: "githubCopilotModels", + githubCopilotAccount: "octocat", + vsCodeLmModels: [reportedModel], + }) + + await expect(component.getByRole("status")).toContainText("octocat") + await expect(component.getByText("Image support not reported")).toBeVisible() + await settle(component) + + await expect(component).toHaveScreenshot("github-copilot-signed-in-dark.png") +}) + +test("renders the empty state for an account with no Copilot models", async ({ mount, page }) => { + const component = await mountSettings(mount, page) + await send(page, { type: "githubCopilotModels", githubCopilotAccount: "octocat", vsCodeLmModels: [] }) + + await expect(component.getByText(/Signing in does not confirm a Copilot subscription/)).toBeVisible() + await settle(component) + + await expect(component).toHaveScreenshot("github-copilot-no-models-dark.png") +}) + +test("renders a model discovery error", async ({ mount, page }) => { + const component = await mountSettings(mount, page) + await send(page, { type: "githubCopilotModels", error: "Model access denied" }) + + await expect(component.getByRole("alert")).toContainText("Model access denied") + await settle(component) + + await expect(component).toHaveScreenshot("github-copilot-error-dark.png") +}) diff --git a/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx b/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx index 1e66b03255..1dce6ff5bb 100644 --- a/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx @@ -8,6 +8,11 @@ vi.mock("@src/i18n/TranslationContext", () => ({ useAppTranslation: () => ({ t: (key: string) => ({ + "settings:modelInfo.imageSupportUnknown": "Image support not reported", + "settings:modelInfo.supportsImages": "Supports images", + "settings:modelInfo.noImages": "Does not support images", + "settings:modelInfo.noPromptCache": "Does not support prompt caching", + "settings:modelInfo.promptCacheManagedByCopilot": "Prompt caching managed by GitHub Copilot", "settings:serviceTier.pricingTableTitle": "Service tier pricing", "settings:serviceTier.columns.tier": "Tier", "settings:serviceTier.columns.input": "Input", @@ -43,6 +48,73 @@ const getPricingRowValues = (tier: string) => { } describe("ModelInfoView service tier pricing", () => { + it("reports prompt caching as unsupported for other providers when the model says nothing about it", () => { + render( + , + ) + expect(screen.getByText("Does not support prompt caching")).toBeInTheDocument() + }) + + it("shows unknown Copilot image capability without claiming the model is text-only", () => { + render( + , + ) + expect(screen.getByText("Image support not reported")).toBeInTheDocument() + expect(screen.queryByText("Does not support images")).not.toBeInTheDocument() + expect(screen.queryByText("Supports images")).not.toBeInTheDocument() + }) + + it("continues to show explicit Copilot image restrictions as unsupported", () => { + render( + , + ) + expect(screen.getByText("Does not support images")).toBeInTheDocument() + expect(screen.queryByText("Image support not reported")).not.toBeInTheDocument() + }) + + it("shows vision and provider-managed caching for Copilot without claiming caching is unsupported", () => { + render( + , + ) + expect(screen.getByText("Supports images")).toBeInTheDocument() + expect(screen.getByText("Prompt caching managed by GitHub Copilot")).toBeInTheDocument() + expect(screen.getByText("Prompt caching managed by GitHub Copilot")).toHaveAttribute("tabindex", "0") + expect(screen.queryByText("Does not support prompt caching")).not.toBeInTheDocument() + }) + + it("keeps unsupported caching labels for providers whose models do not support caching", () => { + render( + , + ) + expect(screen.getByText("Does not support images")).toBeInTheDocument() + expect(screen.getByText("Does not support prompt caching")).toBeInTheDocument() + }) it("uses the canonical gemini provider identifier", () => { expect(providerIdentifiers.gemini).toBe("gemini") }) diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-error-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-error-dark.png new file mode 100644 index 0000000000..d460f221bc Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-error-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-no-models-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-no-models-dark.png new file mode 100644 index 0000000000..36ed45e0d7 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-no-models-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-signed-in-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-signed-in-dark.png new file mode 100644 index 0000000000..a8514c4782 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/github-copilot-signed-in-dark.png differ diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 8c51aca2fe..468010ef6b 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -57,6 +57,7 @@ export const PROVIDERS: Array<{ value: string; label: string; proxy: boolean }> { value: providerIdentifiers.vertex, label: "GCP Vertex AI", proxy: false }, { value: providerIdentifiers.bedrock, label: "Amazon Bedrock", proxy: false }, { value: providerIdentifiers.vscodeLm, label: "VS Code LM API", proxy: false }, + { value: providerIdentifiers.githubCopilot, label: "GitHub Copilot", proxy: false }, { value: providerIdentifiers.mistral, label: "Mistral", proxy: false }, { value: providerIdentifiers.lmstudio, label: "LM Studio", proxy: true }, { value: providerIdentifiers.ollama, label: "Ollama", proxy: true }, diff --git a/webview-ui/src/components/settings/providers/VSCodeLM.tsx b/webview-ui/src/components/settings/providers/VSCodeLM.tsx index 4b8d66d165..f44008890d 100644 --- a/webview-ui/src/components/settings/providers/VSCodeLM.tsx +++ b/webview-ui/src/components/settings/providers/VSCodeLM.tsx @@ -1,40 +1,74 @@ import { useState, useCallback, useMemo } from "react" import { useEvent } from "react-use" import { LanguageModelChatSelector } from "vscode" +import { LoaderCircle, LogIn, RefreshCw, UserCog } from "lucide-react" import { type ProviderSettings, type ExtensionMessage, type ModelInfo, VsCodeLmModelsMessageType, + providerIdentifiers, } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Button, StandardTooltip } from "@src/components/ui" +import { vscode } from "@src/utils/vscode" +import { useGitHubCopilotModels } from "@src/components/ui/hooks/useGitHubCopilotModels" import { ModelPicker } from "../ModelPicker" type VSCodeLMProps = { apiConfiguration: ProviderSettings - setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void + setApiConfigurationField: ( + field: keyof ProviderSettings, + value: ProviderSettings[keyof ProviderSettings], + isUserAction?: boolean, + ) => void } +// A stable empty list: a fresh `[]` each render would defeat the memoization below. +const NO_MODELS: NonNullable = [] + export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeLMProps) => { const { t } = useAppTranslation() + const isCopilot = apiConfiguration.apiProvider === providerIdentifiers.githubCopilot - const [vsCodeLmModels, setVsCodeLmModels] = useState([]) + // The legacy provider is fed by messages into local state; Copilot's list is the shared, extension-pushed one. + const [legacyModels, setLegacyModels] = useState>([]) + const copilotModels = useGitHubCopilotModels(isCopilot) + const vsCodeLmModels = (isCopilot ? copilotModels.data : legacyModels) ?? NO_MODELS + const [isConnecting, setIsConnecting] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) + const [error, setError] = useState() + const [account, setAccount] = useState() + const authenticationLabel = t( + isConnecting + ? "settings:providers.githubCopilot.connecting" + : account + ? "settings:providers.githubCopilot.reconnect" + : "settings:providers.githubCopilot.signIn", + ) - const onMessage = useCallback((event: MessageEvent) => { - const message: ExtensionMessage = event.data + const onMessage = useCallback( + (event: MessageEvent) => { + const message: ExtensionMessage = event.data + const expectedType = isCopilot + ? VsCodeLmModelsMessageType.githubCopilotModels + : VsCodeLmModelsMessageType.vsCodeLmModels + const isSignInResult = isCopilot && message.type === VsCodeLmModelsMessageType.githubCopilotSignInResult + if (message.type !== expectedType && !isSignInResult) return - switch (message.type) { - case VsCodeLmModelsMessageType.vsCodeLmModels: - { - const newModels = message.vsCodeLmModels ?? [] - setVsCodeLmModels(newModels) - } - break - } - }, []) + if (message.type === expectedType && (message.vsCodeLmModels !== undefined || message.error)) { + setIsRefreshing(false) + } + if (isSignInResult) setIsConnecting(false) + if (message.githubCopilotAccount !== undefined) setAccount(message.githubCopilotAccount ?? undefined) + if (isSignInResult || message.error) setError(message.error) + if (!isCopilot && !message.error && message.vsCodeLmModels) setLegacyModels(message.vsCodeLmModels) + }, + [isCopilot], + ) useEvent("message", onMessage) @@ -42,12 +76,14 @@ export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeL const modelsRecord = useMemo((): Record => { return vsCodeLmModels.reduce( (acc, model) => { - const modelId = `${model.vendor}/${model.family}` + const modelId = model.id ?? `${model.vendor}/${model.family}` acc[modelId] = { maxTokens: 0, - contextWindow: 0, + contextWindow: model.maxInputTokens ?? 0, supportsPromptCache: false, + displayName: model.name, description: `${model.vendor} - ${model.family}`, + ...model.modelInfo, } return acc }, @@ -56,20 +92,118 @@ export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeL }, [vsCodeLmModels]) // Transform string model ID to { vendor, family } object for storage - const valueTransform = useCallback((modelId: string) => { - const [vendor, family] = modelId.split("/") - return { vendor, family } - }, []) + const valueTransform = useCallback( + (modelId: string) => { + const model = vsCodeLmModels.find((candidate) => candidate.id === modelId) + if (model) { + return { + id: model.id, + vendor: model.vendor, + family: model.family, + version: model.version, + } + } + const [vendor, family] = modelId.split("/") + return { vendor, family } + }, + [vsCodeLmModels], + ) // Transform stored { vendor, family } object back to display string - const displayTransform = useCallback((value: unknown) => { - if (!value) return "" - const selector = value as { vendor?: string; family?: string } - return selector.vendor && selector.family ? `${selector.vendor}/${selector.family}` : "" - }, []) + const displayTransform = useCallback( + (value: unknown) => { + if (!value) return "" + const selector = value as LanguageModelChatSelector + if (selector.id && vsCodeLmModels.some((model) => model.id === selector.id)) return selector.id + const model = vsCodeLmModels.find( + (candidate) => candidate.vendor === selector.vendor && candidate.family === selector.family, + ) + if (model?.id) return model.id + return selector.vendor && selector.family ? `${selector.vendor}/${selector.family}` : "" + }, + [vsCodeLmModels], + ) return ( <> + {isCopilot && account && ( +
+ {t("settings:providers.githubCopilot.signedIn", { account })} +
+ )} + {isCopilot && ( +
+ + + + + + + {account && ( + + + + )} +
+ )} + {error && ( +
+ {error} +
+ )} {vsCodeLmModels.length > 0 ? (
- {t("settings:providers.vscodeLmDescription")} + {t( + isCopilot + ? "settings:providers.githubCopilot.noModels" + : "settings:providers.vscodeLmDescription", + )}
)} diff --git a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx index e355baa08c..c467b2e9fe 100644 --- a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx @@ -1,38 +1,533 @@ -import { fireEvent, render, screen } from "@testing-library/react" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { QueryClientProvider } from "@tanstack/react-query" -import { providerIdentifiers, type OrganizationAllowList, type RouterModels } from "@roo-code/types" +import { + providerIdentifiers, + VsCodeLmModelsMessageType, + type OrganizationAllowList, + type RouterModels, +} from "@roo-code/types" import { vscode } from "@src/utils/vscode" +import { createTestQueryClient } from "@src/utils/test-utils" import { Unbound } from "../Unbound" import { VercelAiGateway } from "../VercelAiGateway" +import { VSCodeLM } from "../VSCodeLM" +import { PROVIDERS_WITH_CUSTOM_MODEL_UI } from "../../utils/providerModelConfig" -const { modelPickerMock } = vi.hoisted(() => ({ modelPickerMock: vi.fn(() => null) })) +const { modelPickerMock } = vi.hoisted(() => ({ + modelPickerMock: vi.fn((_props: React.ComponentProps) => null), +})) vi.mock("@src/i18n/TranslationContext", () => ({ - useAppTranslation: () => ({ t: (key: string) => key }), + useAppTranslation: () => ({ + t: (key: string, options?: { account?: string }) => + key === "settings:providers.githubCopilot.signedIn" ? `Signed in as ${options?.account}` : key, + }), })) vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeTextField: ({ children }: { children: React.ReactNode }) =>
{children}
, })) -vi.mock("@src/components/ui", () => ({ - Button: ({ children, onClick }: React.ComponentProps<"button">) => , -})) +vi.mock("@src/components/ui", async () => { + const { Button } = await import("@src/components/ui/button") + return { + Button, + StandardTooltip: ({ children, content }: { children: React.ReactNode; content: React.ReactNode }) => ( + + {children} + + ), + } +}) vi.mock("../../ModelPicker", () => ({ ModelPicker: modelPickerMock })) vi.mock("@src/components/common/VSCodeButtonLink", () => ({ VSCodeButtonLink: () => null })) +// The Copilot model list lives in the shared query cache, so every render needs a client. +const renderWithQuery = (ui: React.ReactElement) => + render({ui}) + describe("provider model routing", () => { const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } beforeEach(() => vi.clearAllMocks()) + it("uses only the custom model picker for GitHub Copilot", () => { + expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).toContain(providerIdentifiers.githubCopilot) + }) + + it("uses compact icon-only buttons with accessible names, stable dimensions, and shared tooltips", () => { + renderWithQuery( + , + ) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }, + }), + ), + ) + const controls = [ + screen.getByRole("button", { name: "settings:providers.githubCopilot.reconnect" }), + screen.getByRole("button", { name: "settings:providers.refreshModels.label" }), + screen.getByRole("button", { name: "settings:providers.githubCopilot.manageAccount" }), + ] + for (const control of controls) { + expect(control).toHaveClass("h-7", "w-7", "focus-visible:ring-1") + expect(control.textContent).toBe("") + expect(control).not.toHaveAttribute("title") + expect(control.querySelector("svg")).toHaveAttribute("aria-hidden", "true") + expect(control.closest('[data-testid="action-tooltip"]')).toHaveAttribute("data-tooltip") + } + expect( + screen.getByRole("group", { name: "settings:providers.githubCopilot.accountActions" }), + ).toBeInTheDocument() + }) + + it("supports keyboard tab navigation and manage-account activation", async () => { + const frame = document.createElement("iframe") + frame.hidden = true + document.body.appendChild(frame) + const nativeFocus = frame.contentDocument?.createElement("button").focus + const originalFocus = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "focus") + if (!nativeFocus || !originalFocus) throw new Error("Native DOM focus is unavailable") + Object.defineProperty(HTMLElement.prototype, "focus", { + configurable: true, + writable: true, + value: nativeFocus, + }) + try { + const user = userEvent.setup() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + renderWithQuery( + , + ) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotModels, + githubCopilotAccount: "Test User", + }, + }), + ), + ) + await user.tab() + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.reconnect" })).toHaveFocus() + await user.tab() + expect(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })).toHaveFocus() + await user.tab() + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.manageAccount" })).toHaveFocus() + await user.keyboard("{Enter}") + expect(postMessage).toHaveBeenCalledWith({ type: VsCodeLmModelsMessageType.githubCopilotManageAccount }) + } finally { + Object.defineProperty(HTMLElement.prototype, "focus", originalFocus) + frame.remove() + } + }) + + it("blocks overlapping actions during refresh and releases them after a discovery error", async () => { + const user = userEvent.setup() + renderWithQuery( + , + ) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }, + }), + ), + ) + await user.click(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })) + expect(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })).toHaveAttribute( + "aria-busy", + "true", + ) + for (const button of screen.getAllByRole("button")) expect(button).toBeDisabled() + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: VsCodeLmModelsMessageType.githubCopilotModels, error: "Model discovery failed" }, + }), + ), + ) + expect(screen.getByRole("alert")).toHaveTextContent("Model discovery failed") + for (const button of screen.getAllByRole("button")) expect(button).toBeEnabled() + expect(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })).toHaveAttribute( + "aria-busy", + "false", + ) + }) + + it("shows completed authentication before model discovery and restores it on refresh", () => { + renderWithQuery( + , + ) + fireEvent.click(screen.getByRole("button", { name: "settings:providers.githubCopilot.signIn" })) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotModels, + githubCopilotAccount: "Test User", + }, + }), + ), + ) + expect(screen.getByRole("status")).toHaveTextContent("Signed in as Test User") + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.connecting" })).toBeDisabled() + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotSignInResult, + githubCopilotAccount: "Test User", + vsCodeLmModels: [], + }, + }), + ), + ) + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.reconnect" })).toBeEnabled() + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotModels, + githubCopilotAccount: null, + vsCodeLmModels: [], + }, + }), + ), + ) + expect(screen.queryByRole("status")).not.toBeInTheDocument() + }) + + it("reconnects using a fresh-session message rather than repeating passive sign-in", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + renderWithQuery( + , + ) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }, + }), + ), + ) + fireEvent.click(screen.getByRole("button", { name: "settings:providers.githubCopilot.reconnect" })) + expect(postMessage).toHaveBeenCalledWith({ type: VsCodeLmModelsMessageType.githubCopilotReconnect }) + }) + + it("opens VS Code's account management without signing out or changing the connected account", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + renderWithQuery( + , + ) + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }, + }), + ), + ) + + fireEvent.click(screen.getByRole("button", { name: "settings:providers.githubCopilot.manageAccount" })) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ type: VsCodeLmModelsMessageType.githubCopilotManageAccount }) + // Only the host knows whether the user signed out; the connected account stays until it says otherwise. + expect(screen.getByRole("status")).toHaveTextContent("Signed in as Test User") + }) + + it("offers account management only once an account is connected", () => { + renderWithQuery( + , + ) + expect( + screen.queryByRole("button", { name: "settings:providers.githubCopilot.manageAccount" }), + ).not.toBeInTheDocument() + }) + + it("drops the account when the host reports it is gone, for example after signing out in VS Code", () => { + renderWithQuery( + , + ) + const send = (data: object) => act(() => void window.dispatchEvent(new MessageEvent("message", { data }))) + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }) + expect(screen.getByRole("status")).toBeInTheDocument() + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: null, vsCodeLmModels: [] }) + + expect(screen.queryByRole("status")).not.toBeInTheDocument() + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.signIn" })).toBeEnabled() + }) + + it("shows live model capabilities without writing them into saved settings", async () => { + const setField = vi.fn() + renderWithQuery( + , + ) + const modelInfo = { contextWindow: 260000, supportsImages: true, supportsPromptCache: false } + + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [ + { vendor: "copilot", id: "model", family: "dynamic-vision", version: "1", modelInfo }, + ], + }, + }), + ), + ) + + await waitFor(() => expect(modelPickerMock.mock.lastCall![0].models?.model).toMatchObject(modelInfo)) + expect(setField).not.toHaveBeenCalled() + }) + + it("keeps the last known models when a mid-sign-in update carries only the account", async () => { + renderWithQuery( + , + ) + const send = (data: object) => act(() => void window.dispatchEvent(new MessageEvent("message", { data }))) + send({ + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [{ vendor: "copilot", id: "kept", family: "kept", version: "1" }], + }) + await waitFor(() => expect(modelPickerMock.mock.lastCall![0].models).toHaveProperty("kept")) + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }) + // The account arrives; the account-only update must not have emptied the list. + await screen.findByRole("status") + + expect(modelPickerMock.mock.lastCall![0].models).toHaveProperty("kept") + }) + + it("keeps sign-in pending during background discovery and displays cancellation", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + renderWithQuery( + , + ) + + fireEvent.click(screen.getByRole("button", { name: "settings:providers.githubCopilot.signIn" })) + expect(postMessage).toHaveBeenCalledWith({ type: VsCodeLmModelsMessageType.githubCopilotSignIn }) + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.connecting" })).toBeDisabled() + + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [], + }, + }), + ), + ) + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.connecting" })).toBeDisabled() + + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotSignInResult, + error: "Sign-in cancelled", + }, + }), + ), + ) + expect(screen.getByRole("alert")).toHaveTextContent("Sign-in cancelled") + expect(screen.getByRole("button", { name: "settings:providers.githubCopilot.signIn" })).toBeEnabled() + }) + + it("refreshes only Copilot models and preserves distinct model variants", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + renderWithQuery( + , + ) + fireEvent.click(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })) + expect(postMessage).toHaveBeenCalledWith({ + type: VsCodeLmModelsMessageType.requestVsCodeLmModels, + apiConfiguration: { apiProvider: providerIdentifiers.githubCopilot }, + }) + + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.vsCodeLmModels, + vsCodeLmModels: [{ id: "other-model", vendor: "other", family: "gpt-4o" }], + }, + }), + ), + ) + expect(modelPickerMock).not.toHaveBeenCalled() + + const model = { + id: "gpt-4o-version-2", + vendor: "copilot", + family: "gpt-4o", + version: "2", + maxInputTokens: 32000, + } + act(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.githubCopilotModels, + vsCodeLmModels: [{ ...model, id: "gpt-4o-version-1", version: "1" }, model], + }, + }), + ), + ) + const props = modelPickerMock.mock.lastCall![0] + expect(Object.keys(props.models ?? {})).toEqual(["gpt-4o-version-1", "gpt-4o-version-2"]) + expect(props.valueTransform?.(model.id)).toEqual({ + id: model.id, + vendor: model.vendor, + family: model.family, + version: model.version, + }) + expect(props.displayTransform?.({ vendor: model.vendor, family: model.family, id: model.id })).toBe(model.id) + }) + + describe("model picker wiring", () => { + const send = (data: object) => act(() => void window.dispatchEvent(new MessageEvent("message", { data }))) + const pickerProps = () => modelPickerMock.mock.lastCall![0] + const mount = (provider: typeof providerIdentifiers.githubCopilot | typeof providerIdentifiers.vscodeLm) => + renderWithQuery( + , + ) + + const listed = [ + { id: "m1", vendor: "copilot", family: "fam", version: "1" }, + { vendor: "other", family: "no-id" }, + ] + + it("lists the legacy provider's models from its own messages and names its service accordingly", () => { + mount(providerIdentifiers.vscodeLm) + + send({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels: listed }) + + expect(pickerProps().serviceName).toBe("VS Code LM") + expect(Object.keys(pickerProps().models ?? {})).toEqual(["m1", "other/no-id"]) + }) + + it("keeps each provider's list separate: the legacy picker ignores Copilot messages", () => { + mount(providerIdentifiers.vscodeLm) + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: listed }) + + expect(modelPickerMock).not.toHaveBeenCalled() + }) + + it("ignores a legacy list that arrives with an error", () => { + mount(providerIdentifiers.vscodeLm) + + send({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels: listed, error: "partial" }) + + expect(modelPickerMock).not.toHaveBeenCalled() + }) + + describe("stored value mapping", () => { + beforeEach(() => { + mount(providerIdentifiers.vscodeLm) + send({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels: listed }) + }) + + it("stores a listed model by its identity, with no capability snapshot", () => { + expect(pickerProps().valueTransform?.("m1")).toEqual({ + id: "m1", + vendor: "copilot", + family: "fam", + version: "1", + }) + }) + + it("stores an unlisted value as vendor and family", () => { + expect(pickerProps().valueTransform?.("acme/model-x")).toEqual({ vendor: "acme", family: "model-x" }) + }) + + it.each([ + ["nothing stored", undefined, ""], + ["a stored id that is listed", { id: "m1" }, "m1"], + ["a stored vendor and family whose model has an id", { vendor: "copilot", family: "fam" }, "m1"], + [ + "a stored vendor and family whose model has no id", + { vendor: "other", family: "no-id" }, + "other/no-id", + ], + [ + "a stored vendor and family that is no longer listed", + { vendor: "gone", family: "model" }, + "gone/model", + ], + ["a stored selector too incomplete to name a model", { vendor: "copilot" }, ""], + ])("shows %s as the right label", (_label, stored, expected) => { + expect(pickerProps().displayTransform?.(stored)).toBe(expected) + }) + }) + }) + + it("keeps the legacy VS Code LM provider free of Copilot login controls", () => { + renderWithQuery( + , + ) + expect( + screen.queryByRole("button", { name: "settings:providers.githubCopilot.signIn" }), + ).not.toBeInTheDocument() + }) + it("requests fresh Unbound models when the refresh button is clicked", () => { const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) - render( + renderWithQuery( { ) as RouterModels routerModels[providerIdentifiers.vercelAiGateway] = models - render( + renderWithQuery( { }) it("passes an empty model set when Vercel AI Gateway models are unavailable", () => { - render( + renderWithQuery( ({ id, vendor: "copilot", family: id, version: "1" }) + +const send = (data: object) => act(() => void window.dispatchEvent(new MessageEvent("message", { data }))) + +const setup = (enabled = true) => { + const client = createTestQueryClient() + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return renderHook(({ on }) => useGitHubCopilotModels(on), { wrapper, initialProps: { on: enabled } }) +} + +describe("useGitHubCopilotModels", () => { + it("has no models until the extension reports some", () => { + const { result } = setup() + expect(result.current.data).toBeUndefined() + }) + + it("holds the list the extension pushes", async () => { + const { result } = setup() + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [model("a"), model("b")] }) + + await waitFor(() => expect(result.current.data?.map((m) => m.id)).toEqual(["a", "b"])) + }) + + it("takes the list delivered with a completed sign-in", async () => { + const { result } = setup() + + send({ + type: VsCodeLmModelsMessageType.githubCopilotSignInResult, + githubCopilotAccount: "Test User", + vsCodeLmModels: [model("signed-in")], + }) + + await waitFor(() => expect(result.current.data?.map((m) => m.id)).toEqual(["signed-in"])) + }) + + it("treats an empty list as the truth, since an account may genuinely have no models", async () => { + const { result } = setup() + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [model("a")] }) + await waitFor(() => expect(result.current.data).toHaveLength(1)) + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [] }) + + await waitFor(() => expect(result.current.data).toEqual([])) + }) + + it("keeps the last list through an error, an account-only update, or an unrelated message", async () => { + const { result } = setup() + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [model("kept")] }) + await waitFor(() => expect(result.current.data).toHaveLength(1)) + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, error: "host busy" }) + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, githubCopilotAccount: "Test User" }) + send({ type: VsCodeLmModelsMessageType.vsCodeLmModels, vsCodeLmModels: [model("legacy")] }) + send({ type: VsCodeLmModelsMessageType.githubCopilotSignInResult, error: "cancelled" }) + + expect(result.current.data?.map((m) => m.id)).toEqual(["kept"]) + }) + + it("ignores messages while Copilot is not the active provider", async () => { + const { result } = setup(false) + + send({ type: VsCodeLmModelsMessageType.githubCopilotModels, vsCodeLmModels: [model("a")] }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(result.current.data).toBeUndefined() + }) + + it("stops listening when disabled or unmounted", async () => { + const remove = vi.spyOn(window, "removeEventListener") + const { rerender, unmount } = setup() + + rerender({ on: false }) + expect(remove).toHaveBeenCalledWith("message", expect.any(Function)) + + remove.mockClear() + rerender({ on: true }) + unmount() + expect(remove).toHaveBeenCalledWith("message", expect.any(Function)) + remove.mockRestore() + }) +}) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 1b01719dc9..549d1497c0 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -28,6 +28,7 @@ import { deepSeekModels, openRouterDefaultModelId, vscodeLlmModels, + githubCopilotLanguageModel, vscodeLlmDefaultModelId, moonshotDefaultModelId, moonshotModels, @@ -45,16 +46,19 @@ import { useRouterModels } from "../useRouterModels" import { useOpenRouterModelProviders } from "../useOpenRouterModelProviders" import { useLmStudioModels } from "../useLmStudioModels" import { useOllamaModels } from "../useOllamaModels" +import { useGitHubCopilotModels } from "../useGitHubCopilotModels" vi.mock("../useRouterModels") vi.mock("../useOpenRouterModelProviders") vi.mock("../useLmStudioModels") vi.mock("../useOllamaModels") +vi.mock("../useGitHubCopilotModels") const mockUseRouterModels = useRouterModels as Mock const mockUseOpenRouterModelProviders = useOpenRouterModelProviders as Mock const mockUseLmStudioModels = useLmStudioModels as Mock const mockUseOllamaModels = useOllamaModels as Mock +const mockUseGitHubCopilotModels = useGitHubCopilotModels as Mock type TestRouterModels = Partial> type QueryResultOptions = { isLoading?: boolean; isError?: boolean } @@ -100,6 +104,7 @@ describe("useSelectedModel", () => { mockUseOllamaModels.mockClear() mockUseLmStudioModels.mockReturnValue(createLocalModelsResult({})) mockUseOllamaModels.mockReturnValue(createLocalModelsResult({})) + mockUseGitHubCopilotModels.mockReturnValue({ data: undefined } as ReturnType) }) const dynamicProviderCases = [ @@ -1271,6 +1276,146 @@ describe("useSelectedModel", () => { }) describe("vscode-lm provider", () => { + const reportedModels = (...models: Array<{ id: string; family: string; modelInfo?: ModelInfo }>) => + mockUseGitHubCopilotModels.mockReturnValue({ + data: models.map((model) => ({ vendor: githubCopilotLanguageModel.vendor, version: "1", ...model })), + } as unknown as ReturnType) + + const selectCopilot = (selector: NonNullable) => + renderHook(() => + useSelectedModel({ apiProvider: providerIdentifiers.githubCopilot, vsCodeLmModelSelector: selector }), + ).result.current + + it("only subscribes to the Copilot list while Copilot is the active provider", () => { + renderHook(() => useSelectedModel({ apiProvider: providerIdentifiers.vscodeLm })) + expect(mockUseGitHubCopilotModels).toHaveBeenLastCalledWith(false) + + selectCopilot({ vendor: githubCopilotLanguageModel.vendor, id: "any" }) + expect(mockUseGitHubCopilotModels).toHaveBeenLastCalledWith(true) + }) + + it("describes a model from the curated catalog until the host has reported it", () => { + const [curatedFamily] = Object.entries(vscodeLlmModels).find(([, entry]) => entry.supportsImages) ?? [] + expect(curatedFamily).toBeDefined() + + expect( + selectCopilot({ vendor: githubCopilotLanguageModel.vendor, family: curatedFamily }).info + ?.supportsImages, + ).toBe(true) + }) + + it("keeps missing Copilot image capability unknown instead of setting it to false", () => { + expect( + selectCopilot({ vendor: "copilot", id: "unknown-model", family: "unknown-model" }).info?.supportsImages, + ).toBeUndefined() + }) + + it("uses what the host reports for the selected model: context and vision", () => { + reportedModels({ + id: "dynamic-vision", + family: "dynamic-vision", + modelInfo: { contextWindow: 260000, supportsImages: true, supportsPromptCache: false }, + }) + + const selected = selectCopilot({ vendor: "copilot", id: "dynamic-vision", family: "dynamic-vision" }) + + expect(selected.id).toBe("dynamic-vision") + expect(selected.info).toMatchObject({ + contextWindow: 260000, + supportsImages: true, + supportsPromptCache: false, + }) + }) + + it("lets a host report of no vision override the curated catalog", () => { + const [curatedFamily] = Object.entries(vscodeLlmModels).find(([, entry]) => entry.supportsImages) ?? [] + reportedModels({ + id: "text-only", + family: curatedFamily!, + modelInfo: { contextWindow: 1000, supportsImages: false, supportsPromptCache: false }, + }) + + expect( + selectCopilot({ vendor: "copilot", id: "text-only", family: curatedFamily }).info?.supportsImages, + ).toBe(false) + }) + + it("finds the reported model by family when the saved selector has no id", () => { + reportedModels( + { id: "other", family: "other", modelInfo: { contextWindow: 1, supportsPromptCache: false } }, + { + id: "by-family", + family: "wanted-family", + modelInfo: { contextWindow: 555, supportsPromptCache: false }, + }, + ) + + const selected = selectCopilot({ vendor: "copilot", family: "wanted-family" }) + + expect(selected.info?.contextWindow).toBe(555) + expect(selected.id).toBe("copilot/wanted-family") + }) + + it("falls back to the default model id when nothing is selected yet", () => { + const { result } = renderHook(() => useSelectedModel({ apiProvider: providerIdentifiers.githubCopilot })) + expect(result.current.id).toBe(vscodeLlmDefaultModelId) + }) + + it("shows the window the extension enforces, not a larger curated one, before the host reports", () => { + const [family, entry] = + Object.entries(vscodeLlmModels).find(([, e]) => e.contextWindow !== e.maxInputTokens) ?? [] + expect(family).toBeDefined() + + const selected = selectCopilot({ vendor: githubCopilotLanguageModel.vendor, family }) + + expect(selected.info?.contextWindow).toBe(entry!.maxInputTokens) + expect(selected.info?.contextWindow).toBeLessThan(entry!.contextWindow) + }) + + it("does not let a reported model that says nothing about vision inherit the catalog's flag", () => { + const [family] = Object.entries(vscodeLlmModels).find(([, e]) => e.supportsImages) ?? [] + // An unreported capability arrives with its key absent, as it does after crossing the message boundary. + reportedModels({ + id: "silent", + family: family!, + modelInfo: { contextWindow: 1000, supportsPromptCache: false }, + }) + + expect(selectCopilot({ vendor: "copilot", id: "silent", family }).info?.supportsImages).toBeUndefined() + }) + + it("keeps the catalog's vision flag only for a model the host has not reported", () => { + const [family] = Object.entries(vscodeLlmModels).find(([, e]) => e.supportsImages) ?? [] + reportedModels({ id: "someone-else", family: "someone-else" }) + + expect(selectCopilot({ vendor: "copilot", id: "not-listed", family }).info?.supportsImages).toBe(true) + }) + + it("picks the reported model matching the saved id, not merely the first one", () => { + reportedModels( + { id: "other", family: "other", modelInfo: { contextWindow: 1, supportsPromptCache: false } }, + { id: "wanted", family: "wanted", modelInfo: { contextWindow: 777, supportsPromptCache: false } }, + ) + + expect(selectCopilot({ vendor: "copilot", id: "wanted" }).info?.contextWindow).toBe(777) + }) + + it("follows a refreshed report for the same selection", () => { + reportedModels({ id: "m", family: "m", modelInfo: { contextWindow: 100, supportsPromptCache: false } }) + const { result, rerender } = renderHook(() => + useSelectedModel({ + apiProvider: providerIdentifiers.githubCopilot, + vsCodeLmModelSelector: { vendor: "copilot", id: "m" }, + }), + ) + expect(result.current.info?.contextWindow).toBe(100) + + reportedModels({ id: "m", family: "m", modelInfo: { contextWindow: 200, supportsPromptCache: false } }) + rerender() + + expect(result.current.info?.contextWindow).toBe(200) + }) + beforeEach(() => { mockUseRouterModels.mockReturnValue(createRouterModelsResult({ openrouter: {}, requesty: {}, litellm: {} })) diff --git a/webview-ui/src/components/ui/hooks/useGitHubCopilotModels.ts b/webview-ui/src/components/ui/hooks/useGitHubCopilotModels.ts new file mode 100644 index 0000000000..c8afc0a3fb --- /dev/null +++ b/webview-ui/src/components/ui/hooks/useGitHubCopilotModels.ts @@ -0,0 +1,43 @@ +import { useEffect } from "react" +import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query" + +import { type ExtensionMessage, VsCodeLmModelsMessageType } from "@roo-code/types" + +export type GitHubCopilotModels = NonNullable + +const GITHUB_COPILOT_MODELS_QUERY_KEY = ["githubCopilotModels"] as const + +/** + * The Copilot models VS Code currently offers, with the capabilities VS Code reports for each. + * + * The extension is the source of truth and pushes this list: when the view opens, after sign-in, + * on a manual refresh, and whenever the host registers or removes models. The hook only holds the + * latest push in the shared query cache, so the model picker and model selection read one list, and + * nothing about a model is ever copied into saved settings where it could go stale. + * + * A message that carries an error, or no list at all (the account-only update sent mid sign-in), + * leaves the last known list in place rather than reading as "no models". + */ +export const useGitHubCopilotModels = (enabled: boolean) => { + const queryClient = useQueryClient() + + useEffect(() => { + if (!enabled) return + + const handler = (event: MessageEvent) => { + const message: ExtensionMessage = event.data + const carriesModels = + message.type === VsCodeLmModelsMessageType.githubCopilotModels || + message.type === VsCodeLmModelsMessageType.githubCopilotSignInResult + if (carriesModels && !message.error && message.vsCodeLmModels) { + queryClient.setQueryData(GITHUB_COPILOT_MODELS_QUERY_KEY, message.vsCodeLmModels) + } + } + + window.addEventListener("message", handler) + return () => window.removeEventListener("message", handler) + }, [enabled, queryClient]) + + // `skipToken`: this query never fetches; its data only ever arrives from the extension. + return useQuery({ queryKey: GITHUB_COPILOT_MODELS_QUERY_KEY, queryFn: skipToken }) +} diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index e4a863088d..4e7a847de0 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -38,12 +38,15 @@ import { isRetiredProvider, getProviderDefaultModelId, providerIdentifiers, + githubCopilotLanguageModel, + vscodeLlmBaselineModelInfo, } from "@roo-code/types" import { useRouterModels } from "./useRouterModels" import { useOpenRouterModelProviders } from "./useOpenRouterModelProviders" import { useLmStudioModels } from "./useLmStudioModels" import { useOllamaModels } from "./useOllamaModels" +import { useGitHubCopilotModels, type GitHubCopilotModels } from "./useGitHubCopilotModels" /** * Helper to get a validated model ID for dynamic providers. @@ -88,6 +91,8 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { const openRouterModelProviders = useOpenRouterModelProviders(openRouterModelId) const lmStudioModels = useLmStudioModels(lmStudioModelId) const ollamaModels = useOllamaModels(ollamaModelId) + // Not part of readiness: until the host reports, the curated catalog describes the model. + const copilotModels = useGitHubCopilotModels(activeProvider === providerIdentifiers.githubCopilot) // Compute readiness only for the data actually needed for the selected provider const needRouterModels = shouldFetchRouterModels @@ -125,6 +130,7 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { openRouterModelProviders: (openRouterModelProviders.data || {}) as Record, lmStudioModels: (lmStudioModels.data || undefined) as ModelRecord | undefined, ollamaModels: (ollamaModels.data || undefined) as ModelRecord | undefined, + copilotModels: copilotModels.data, }) : activeProvider === providerIdentifiers.kimiCode && apiConfiguration ? { @@ -157,6 +163,7 @@ function getSelectedModel({ openRouterModelProviders, lmStudioModels, ollamaModels, + copilotModels, }: { provider: ProviderName apiConfiguration: ProviderSettings @@ -164,6 +171,7 @@ function getSelectedModel({ openRouterModelProviders: Record lmStudioModels: ModelRecord | undefined ollamaModels: ModelRecord | undefined + copilotModels?: GitHubCopilotModels }): { id: string; info: ModelInfo | undefined } { // the `undefined` case are used to show the invalid selection to prevent // users from seeing the default model if their selection is invalid @@ -372,6 +380,35 @@ function getSelectedModel({ info: modelInfo ? { ...lMStudioDefaultModelInfo, ...modelInfo } : undefined, } } + case providerIdentifiers.githubCopilot: { + const selector = apiConfiguration.vsCodeLmModelSelector + // What the host reports now wins; the curated catalog only describes a model it has not reported. + const reported = selector?.id + ? copilotModels?.find((model) => model.id === selector.id) + : selector?.family + ? copilotModels?.find((model) => model.family === selector.family) + : undefined + const knownModel = selector?.family + ? vscodeLlmModels[selector.family as keyof typeof vscodeLlmModels] + : undefined + return { + id: + selector?.id ?? + (selector?.family + ? `${githubCopilotLanguageModel.vendor}/${selector.family}` + : vscodeLlmDefaultModelId), + info: { + ...vscodeLlmBaselineModelInfo, + ...knownModel, + // The extension enforces the curated input limit, so the UI must not show the larger window. + ...(knownModel && { contextWindow: knownModel.maxInputTokens }), + ...reported?.modelInfo, + // A reported model's vision is the host's alone; an omitted key must not fall back to the catalog. + ...(reported && { supportsImages: reported.modelInfo?.supportsImages }), + supportsPromptCache: false, + }, + } + } case providerIdentifiers.vscodeLm: { const id = apiConfiguration?.vsCodeLmModelSelector ? `${apiConfiguration.vsCodeLmModelSelector.vendor}/${apiConfiguration.vsCodeLmModelSelector.family}` diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 71cd18cecb..c62e0c5072 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "Habilitar emmagatzematge en caché de prompts", "enablePromptCachingTitle": "Habilitar l'emmagatzematge en caché de prompts per millorar el rendiment i reduir els costos per als models compatibles.", "cacheUsageNote": "Nota: Si no veieu l'ús de la caché, proveu de seleccionar un model diferent i després tornar a seleccionar el model desitjat.", + "githubCopilot": { + "accountActions": "Accions del compte de GitHub Copilot", + "signIn": "Inicia la sessió a GitHub", + "reconnect": "Torna a connectar GitHub", + "signedIn": "Sessió iniciada com a {{account}}", + "connecting": "Connectant...", + "noModels": "No hi ha models de Copilot disponibles. Instal·la o activa GitHub Copilot Chat, inicia la sessió amb un compte de GitHub elegible i actualitza. Iniciar la sessió no confirma una subscripció a Copilot. S'apliquen els límits del teu pla de GitHub, de sol·licituds premium i de taxa.", + "manageAccount": "Gestiona el compte de GitHub", + "manageAccountDescription": "Obre el menú de comptes de VS Code, on pots tancar la sessió de GitHub. Tancar la sessió afecta Copilot i altres extensions que utilitzen aquest compte." + }, "vscodeLmModel": "Model de llenguatge", "vscodeLmWarning": "Nota: Els models accessibles a través de l’API VS Code Language Model poden estar encapsulats o ajustats pel proveïdor; per tant, el comportament pot diferir de l’ús directe del mateix model des d’un proveïdor o enrutador típic. Per utilitzar un model del desplegable «Language Model», primer canvia a aquest model i després fes clic a «Acceptar» a l’avís de Copilot Chat; en cas contrari pots veure un error com 400 «The requested model is not supported».", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "Valors més alts fan que la sortida sigui més aleatòria, valors més baixos la fan més determinista." }, "modelInfo": { + "imageSupportUnknown": "Compatibilitat amb imatges no informada", + "promptCacheManagedByCopilot": "La memòria cau de prompts la gestiona GitHub Copilot", + "promptCacheCopilotDetails": "La memòria cau depèn del model i del backend de GitHub Copilot. Zoo Code no pot configurar la retenció de la memòria cau ni observar-ne els encerts a través de l'API de VS Code.", "supportsImages": "Suporta imatges", "noImages": "No suporta imatges", "supportsPromptCache": "Suporta emmagatzematge en caché de prompts", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 4403a19dbb..b06f60a278 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "Prompt-Caching aktivieren", "enablePromptCachingTitle": "Prompt-Caching aktivieren, um die Leistung zu verbessern und Kosten für unterstützte Modelle zu reduzieren.", "cacheUsageNote": "Hinweis: Wenn Sie keine Cache-Nutzung sehen, versuchen Sie ein anderes Modell auszuwählen und dann Ihr gewünschtes Modell erneut auszuwählen.", + "githubCopilot": { + "accountActions": "Aktionen für das GitHub-Copilot-Konto", + "signIn": "Bei GitHub anmelden", + "reconnect": "GitHub erneut verbinden", + "signedIn": "Angemeldet als {{account}}", + "connecting": "Verbindung wird hergestellt...", + "noModels": "Keine Copilot-Modelle verfügbar. Installiere oder aktiviere GitHub Copilot Chat, melde dich mit einem berechtigten GitHub-Konto an und aktualisiere dann. Die Anmeldung bestätigt kein Copilot-Abonnement. Es gelten die Grenzen deines GitHub-Tarifs sowie für Premium-Anfragen und Anfragehäufigkeit.", + "manageAccount": "GitHub-Konto verwalten", + "manageAccountDescription": "Öffnet das Kontomenü von VS Code, in dem du dich von GitHub abmelden kannst. Das Abmelden betrifft Copilot und andere Erweiterungen, die dieses Konto verwenden." + }, "vscodeLmModel": "Sprachmodell", "vscodeLmWarning": "Hinweis: Über die VS Code Language Model API abgerufene Modelle können vom Anbieter ummantelt oder feinabgestimmt sein. Daher kann sich ihr Verhalten von der direkten Nutzung desselben Modells bei einem typischen Anbieter oder Router unterscheiden. Um ein Modell aus der Auswahlliste „Language Model“ zu verwenden, wechsle zunächst zu diesem Modell und klicke dann im Copilot‑Chat auf „Akzeptieren“; andernfalls kann ein Fehler wie 400 „The requested model is not supported“ auftreten.", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "Höhere Werte machen die Ausgabe zufälliger, niedrigere Werte machen sie deterministischer." }, "modelInfo": { + "imageSupportUnknown": "Bildunterstützung nicht gemeldet", + "promptCacheManagedByCopilot": "Prompt-Caching wird von GitHub Copilot verwaltet", + "promptCacheCopilotDetails": "Das Caching hängt vom Modell und vom GitHub-Copilot-Backend ab. Zoo Code kann die Cache-Aufbewahrung nicht konfigurieren und Cache-Treffer über die VS-Code-API nicht beobachten.", "supportsImages": "Unterstützt Bilder", "noImages": "Unterstützt keine Bilder", "supportsPromptCache": "Unterstützt Prompt-Cache", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 2e4ff9298d..2acb6c5329 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -654,6 +654,16 @@ "enablePromptCaching": "Enable prompt caching", "enablePromptCachingTitle": "Enable prompt caching to improve performance and reduce costs for supported models.", "cacheUsageNote": "Note: If you don't see cache usage, try selecting a different model and then selecting your desired model again.", + "githubCopilot": { + "accountActions": "GitHub Copilot account actions", + "signIn": "Sign in to GitHub", + "reconnect": "Reconnect GitHub", + "signedIn": "Signed in as {{account}}", + "connecting": "Connecting...", + "noModels": "No Copilot models available. Install or enable GitHub Copilot Chat, sign in with an eligible GitHub account, then refresh. Signing in does not confirm a Copilot subscription. GitHub plan, premium-request, and rate limits apply.", + "manageAccount": "Manage GitHub account", + "manageAccountDescription": "Opens VS Code's account menu, where you can sign out of GitHub. Signing out affects Copilot and other extensions using this account." + }, "vscodeLmModel": "Language Model", "vscodeLmWarning": "Note: Models accessed via the VS Code Language Model API may be wrapped or fine-tuned by the provider, so behavior can differ from using the same model directly from a typical provider or router. To use a model from the Language Model dropdown, first switch to that model and then click Accept in the Copilot Chat prompt; otherwise you may see an error such as 400 \"The requested model is not supported\".", "googleCloudSetup": { @@ -1073,6 +1083,9 @@ "rangeDescription": "Higher values make output more random, lower values make it more deterministic." }, "modelInfo": { + "imageSupportUnknown": "Image support not reported", + "promptCacheManagedByCopilot": "Prompt caching managed by GitHub Copilot", + "promptCacheCopilotDetails": "Caching depends on the model and GitHub Copilot backend. Zoo Code cannot configure cache retention or observe cache hits through the VS Code API.", "supportsImages": "Supports images", "noImages": "Does not support images", "supportsPromptCache": "Supports prompt caching", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 22788f89f9..5601d41c69 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "Habilitar caché de prompts", "enablePromptCachingTitle": "Habilitar el caché de prompts para mejorar el rendimiento y reducir costos para modelos compatibles.", "cacheUsageNote": "Nota: Si no ve el uso del caché, intente seleccionar un modelo diferente y luego seleccionar nuevamente su modelo deseado.", + "githubCopilot": { + "accountActions": "Acciones de la cuenta de GitHub Copilot", + "signIn": "Iniciar sesión en GitHub", + "reconnect": "Volver a conectar GitHub", + "signedIn": "Sesión iniciada como {{account}}", + "connecting": "Conectando...", + "noModels": "No hay modelos de Copilot disponibles. Instala o activa GitHub Copilot Chat, inicia sesión con una cuenta de GitHub elegible y actualiza. Iniciar sesión no confirma una suscripción a Copilot. Se aplican los límites de tu plan de GitHub, de solicitudes premium y de frecuencia.", + "manageAccount": "Administrar cuenta de GitHub", + "manageAccountDescription": "Abre el menú de cuentas de VS Code, donde puedes cerrar la sesión de GitHub. Cerrar sesión afecta a Copilot y a otras extensiones que usan esta cuenta." + }, "vscodeLmModel": "Modelo de lenguaje", "vscodeLmWarning": "Nota: Los modelos a los que se accede a través de la API de modelos de lenguaje de VS Code pueden estar envueltos o ajustados por el proveedor, por lo que su comportamiento puede diferir del uso directo del mismo modelo desde un proveedor o enrutador típico. Para usar un modelo del menú desplegable «Language Model», primero cambia a ese modelo y luego haz clic en «Aceptar» en el aviso de Copilot Chat; de lo contrario, puedes ver un error como 400 «The requested model is not supported».", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "Valores más altos hacen que la salida sea más aleatoria, valores más bajos la hacen más determinista." }, "modelInfo": { + "imageSupportUnknown": "Compatibilidad con imágenes no informada", + "promptCacheManagedByCopilot": "El almacenamiento en caché de prompts lo gestiona GitHub Copilot", + "promptCacheCopilotDetails": "El almacenamiento en caché depende del modelo y del backend de GitHub Copilot. Zoo Code no puede configurar la retención de la caché ni observar sus aciertos a través de la API de VS Code.", "supportsImages": "Soporta imágenes", "noImages": "No soporta imágenes", "supportsPromptCache": "Soporta caché de prompts", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index f57562ebf2..1ba50ff522 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "Activer la mise en cache des prompts", "enablePromptCachingTitle": "Activer la mise en cache des prompts pour améliorer les performances et réduire les coûts pour les modèles pris en charge.", "cacheUsageNote": "Remarque : Si vous ne voyez pas l'utilisation du cache, essayez de sélectionner un modèle différent puis de sélectionner à nouveau votre modèle souhaité.", + "githubCopilot": { + "accountActions": "Actions du compte GitHub Copilot", + "signIn": "Se connecter à GitHub", + "reconnect": "Reconnecter GitHub", + "signedIn": "Connecté en tant que {{account}}", + "connecting": "Connexion...", + "noModels": "Aucun modèle Copilot disponible. Installez ou activez GitHub Copilot Chat, connectez-vous avec un compte GitHub éligible, puis actualisez. La connexion ne confirme pas un abonnement Copilot. Les limites de votre forfait GitHub, des requêtes premium et de débit s'appliquent.", + "manageAccount": "Gérer le compte GitHub", + "manageAccountDescription": "Ouvre le menu des comptes de VS Code, où vous pouvez vous déconnecter de GitHub. La déconnexion affecte Copilot et les autres extensions qui utilisent ce compte." + }, "vscodeLmModel": "Modèle de langage", "vscodeLmWarning": "Remarque : Les modèles accessibles via l’API VS Code Language Model peuvent être encapsulés ou ajustés par le fournisseur ; leur comportement peut donc différer de l’utilisation directe du même modèle auprès d’un fournisseur ou routeur classique. Pour utiliser un modèle depuis la liste « Language Model », bascule d’abord sur ce modèle puis clique sur « Accepter » dans l’invite de Copilot Chat ; sinon, une erreur telle que 400 « The requested model is not supported » peut apparaître.", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "Des valeurs plus élevées rendent la sortie plus aléatoire, des valeurs plus basses la rendent plus déterministe." }, "modelInfo": { + "imageSupportUnknown": "Prise en charge des images non signalée", + "promptCacheManagedByCopilot": "Le cache des prompts est géré par GitHub Copilot", + "promptCacheCopilotDetails": "La mise en cache dépend du modèle et du backend de GitHub Copilot. Zoo Code ne peut ni configurer la rétention du cache ni observer les succès de cache via l'API de VS Code.", "supportsImages": "Prend en charge les images", "noImages": "Ne prend pas en charge les images", "supportsPromptCache": "Prend en charge la mise en cache des prompts", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 52a29ff287..90862b7c95 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "प्रॉम्प्ट कैशिंग सक्षम करें", "enablePromptCachingTitle": "समर्थित मॉडल के लिए प्रदर्शन में सुधार और लागत को कम करने के लिए प्रॉम्प्ट कैशिंग सक्षम करें।", "cacheUsageNote": "नोट: यदि आप कैश उपयोग नहीं देखते हैं, तो एक अलग मॉडल चुनने का प्रयास करें और फिर अपने वांछित मॉडल को पुनः चुनें।", + "githubCopilot": { + "accountActions": "GitHub Copilot खाते की क्रियाएँ", + "signIn": "GitHub में साइन इन करें", + "reconnect": "GitHub फिर से कनेक्ट करें", + "signedIn": "{{account}} के रूप में साइन इन किया गया", + "connecting": "कनेक्ट हो रहा है...", + "noModels": "कोई Copilot मॉडल उपलब्ध नहीं है। GitHub Copilot Chat इंस्टॉल या सक्षम करें, किसी पात्र GitHub खाते से साइन इन करें, फिर रिफ्रेश करें। साइन इन करने से Copilot सदस्यता की पुष्टि नहीं होती। आपके GitHub प्लान, प्रीमियम-अनुरोध और दर सीमाएँ लागू होती हैं।", + "manageAccount": "GitHub खाता प्रबंधित करें", + "manageAccountDescription": "VS Code का खाता मेनू खोलता है, जहाँ आप GitHub से साइन आउट कर सकते हैं। साइन आउट करने से Copilot और इस खाते का उपयोग करने वाले अन्य एक्सटेंशन प्रभावित होते हैं।" + }, "vscodeLmModel": "भाषा मॉडल", "vscodeLmWarning": "नोट: VS Code Language Model API के माध्यम से उपलब्ध मॉडल प्रदाता द्वारा रैप या फाइन‑ट्यून किए जा सकते हैं, इसलिए इनका व्यवहार किसी सामान्य प्रदाता या राउटर से सीधे उसी मॉडल का उपयोग करने की तुलना में अलग हो सकता है। «Language Model» ड्रॉपडाउन से मॉडल उपयोग करने के लिए पहले उसी मॉडल पर स्विच करें और फिर Copilot Chat प्रॉम्प्ट में «Accept» पर क्लिक करें; अन्यथा 400 \"The requested model is not supported\" जैसी त्रुटि दिखाई दे सकती है।", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "उच्च मान आउटपुट को अधिक यादृच्छिक बनाते हैं, निम्न मान इसे अधिक निर्धारित बनाते हैं।" }, "modelInfo": { + "imageSupportUnknown": "छवि समर्थन की जानकारी नहीं दी गई", + "promptCacheManagedByCopilot": "प्रॉम्प्ट कैशिंग GitHub Copilot द्वारा प्रबंधित है", + "promptCacheCopilotDetails": "कैशिंग मॉडल और GitHub Copilot बैकएंड पर निर्भर करती है। Zoo Code VS Code API के माध्यम से कैश प्रतिधारण को कॉन्फ़िगर नहीं कर सकता और न ही कैश हिट देख सकता है।", "supportsImages": "छवियों का समर्थन करता है", "noImages": "छवियों का समर्थन नहीं करता है", "supportsPromptCache": "प्रॉम्प्ट कैशिंग का समर्थन करता है", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index e3ae8c18fc..4513e8e6fa 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "Aktifkan prompt caching", "enablePromptCachingTitle": "Aktifkan prompt caching untuk meningkatkan performa dan mengurangi biaya untuk model yang didukung.", "cacheUsageNote": "Catatan: Jika kamu tidak melihat penggunaan cache, coba pilih model yang berbeda lalu pilih model yang kamu inginkan lagi.", + "githubCopilot": { + "accountActions": "Tindakan akun GitHub Copilot", + "signIn": "Masuk ke GitHub", + "reconnect": "Hubungkan ulang GitHub", + "signedIn": "Masuk sebagai {{account}}", + "connecting": "Menghubungkan...", + "noModels": "Tidak ada model Copilot yang tersedia. Instal atau aktifkan GitHub Copilot Chat, masuk dengan akun GitHub yang memenuhi syarat, lalu segarkan. Masuk tidak mengonfirmasi langganan Copilot. Batas paket GitHub, permintaan premium, dan laju permintaan berlaku.", + "manageAccount": "Kelola akun GitHub", + "manageAccountDescription": "Membuka menu akun VS Code, tempat Anda dapat keluar dari GitHub. Keluar memengaruhi Copilot dan ekstensi lain yang menggunakan akun ini." + }, "vscodeLmModel": "Model Bahasa", "vscodeLmWarning": "Catatan: Model yang diakses melalui VS Code Language Model API dapat dibungkus atau disetel‑halus oleh penyedia, sehingga perilakunya dapat berbeda dibandingkan menggunakan model yang sama secara langsung dari penyedia atau router tipikal. Untuk menggunakan model dari menu tarik‑turun «Language Model», pertama beralihlah ke model tersebut lalu klik «Terima» pada prompt Copilot Chat; jika tidak, Anda mungkin melihat kesalahan seperti 400 «The requested model is not supported».", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "Nilai yang lebih tinggi membuat output lebih acak, nilai yang lebih rendah membuatnya lebih deterministik." }, "modelInfo": { + "imageSupportUnknown": "Dukungan gambar tidak dilaporkan", + "promptCacheManagedByCopilot": "Prompt caching dikelola oleh GitHub Copilot", + "promptCacheCopilotDetails": "Caching bergantung pada model dan backend GitHub Copilot. Zoo Code tidak dapat mengonfigurasi retensi cache atau mengamati cache hit melalui API VS Code.", "supportsImages": "Mendukung gambar", "noImages": "Tidak mendukung gambar", "supportsPromptCache": "Mendukung prompt caching", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index c6ddf94a03..9ad9a295b3 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "Abilita cache dei prompt", "enablePromptCachingTitle": "Abilita la cache dei prompt per migliorare le prestazioni e ridurre i costi per i modelli supportati.", "cacheUsageNote": "Nota: Se non vedi l'utilizzo della cache, prova a selezionare un modello diverso e poi seleziona nuovamente il modello desiderato.", + "githubCopilot": { + "accountActions": "Azioni dell'account GitHub Copilot", + "signIn": "Accedi a GitHub", + "reconnect": "Riconnetti GitHub", + "signedIn": "Accesso eseguito come {{account}}", + "connecting": "Connessione in corso...", + "noModels": "Nessun modello Copilot disponibile. Installa o abilita GitHub Copilot Chat, accedi con un account GitHub idoneo e poi aggiorna. L'accesso non conferma un abbonamento a Copilot. Si applicano i limiti del tuo piano GitHub, delle richieste premium e della frequenza.", + "manageAccount": "Gestisci account GitHub", + "manageAccountDescription": "Apre il menu degli account di VS Code, dove puoi disconnetterti da GitHub. La disconnessione riguarda Copilot e le altre estensioni che usano questo account." + }, "vscodeLmModel": "Modello linguistico", "vscodeLmWarning": "Nota: I modelli accessibili tramite la VS Code Language Model API possono essere incapsulati o perfezionati dal provider, quindi il comportamento può differire dall’uso diretto dello stesso modello presso un provider o router tipico. Per usare un modello dal menu a discesa «Language Model», passa prima a quel modello e poi fai clic su «Accetta» nell’avviso di Copilot Chat; in caso contrario potresti visualizzare un errore come 400 «The requested model is not supported».", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "Valori più alti rendono l'output più casuale, valori più bassi lo rendono più deterministico." }, "modelInfo": { + "imageSupportUnknown": "Supporto per le immagini non segnalato", + "promptCacheManagedByCopilot": "La cache dei prompt è gestita da GitHub Copilot", + "promptCacheCopilotDetails": "La memorizzazione nella cache dipende dal modello e dal backend di GitHub Copilot. Zoo Code non può configurare la conservazione della cache né osservarne gli hit tramite l'API di VS Code.", "supportsImages": "Supporta immagini", "noImages": "Non supporta immagini", "supportsPromptCache": "Supporta cache dei prompt", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index e07b01c510..39d59955e1 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "プロンプトキャッシュを有効化", "enablePromptCachingTitle": "サポートされているモデルのパフォーマンスを向上させ、コストを削減するためにプロンプトキャッシュを有効化します。", "cacheUsageNote": "注意:キャッシュの使用が表示されない場合は、別のモデルを選択してから希望のモデルを再度選択してみてください。", + "githubCopilot": { + "accountActions": "GitHub Copilot アカウントの操作", + "signIn": "GitHub にサインイン", + "reconnect": "GitHub に再接続", + "signedIn": "{{account}} としてサインイン中", + "connecting": "接続中...", + "noModels": "利用可能な Copilot モデルがありません。GitHub Copilot Chat をインストールまたは有効化し、対象の GitHub アカウントでサインインしてから更新してください。サインインしても Copilot のサブスクリプションが確認されるわけではありません。GitHub のプラン、プレミアムリクエスト、レート制限が適用されます。", + "manageAccount": "GitHub アカウントを管理", + "manageAccountDescription": "VS Code のアカウントメニューを開きます。ここから GitHub からサインアウトできます。サインアウトすると、Copilot とこのアカウントを使用する他の拡張機能に影響します。" + }, "vscodeLmModel": "言語モデル", "vscodeLmWarning": "注意: VS Code Language Model API を通じて利用されるモデルは、プロバイダーによってラップまたは微調整されている場合があります。したがって、一般的なプロバイダーやルーターから同じモデルを直接使用する場合と挙動が異なることがあります。『Language Model』ドロップダウンのモデルを使用するには、まずそのモデルに切り替え、Copilot Chat のプロンプトで『承認』をクリックしてください。そうしないと、400『The requested model is not supported』などのエラーが表示されることがあります。", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "高い値は出力をよりランダムに、低い値はより決定論的にします。" }, "modelInfo": { + "imageSupportUnknown": "画像サポートが報告されていません", + "promptCacheManagedByCopilot": "プロンプトキャッシュは GitHub Copilot が管理します", + "promptCacheCopilotDetails": "キャッシュの動作はモデルと GitHub Copilot のバックエンドに依存します。Zoo Code は VS Code API を通じて、キャッシュの保持期間を設定したりキャッシュヒットを確認したりすることはできません。", "supportsImages": "画像をサポート", "noImages": "画像をサポートしていません", "supportsPromptCache": "プロンプトキャッシュをサポート", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 49efb31701..1522fd98a2 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "프롬프트 캐시 활성화", "enablePromptCachingTitle": "지원되는 모델의 성능을 향상시키고 비용을 절감하기 위해 프롬프트 캐시를 활성화합니다.", "cacheUsageNote": "참고: 캐시 사용이 표시되지 않는 경우, 다른 모델을 선택한 다음 원하는 모델을 다시 선택해 보세요.", + "githubCopilot": { + "accountActions": "GitHub Copilot 계정 작업", + "signIn": "GitHub에 로그인", + "reconnect": "GitHub 다시 연결", + "signedIn": "{{account}}(으)로 로그인됨", + "connecting": "연결 중...", + "noModels": "사용 가능한 Copilot 모델이 없습니다. GitHub Copilot Chat을 설치하거나 활성화하고, 자격이 있는 GitHub 계정으로 로그인한 후 새로고침하세요. 로그인만으로는 Copilot 구독이 확인되지 않습니다. GitHub 요금제, 프리미엄 요청 및 속도 제한이 적용됩니다.", + "manageAccount": "GitHub 계정 관리", + "manageAccountDescription": "VS Code의 계정 메뉴를 엽니다. 여기에서 GitHub에서 로그아웃할 수 있습니다. 로그아웃하면 Copilot과 이 계정을 사용하는 다른 확장 프로그램에 영향을 줍니다." + }, "vscodeLmModel": "언어 모델", "vscodeLmWarning": "참고: VS Code Language Model API를 통해 액세스되는 모델은 공급자가 래핑하거나 미세 조정했을 수 있어, 일반적인 공급자나 라우터에서 동일한 모델을 직접 사용할 때와 동작이 다를 수 있습니다. ‘Language Model’ 드롭다운의 모델을 사용하려면 먼저 해당 모델로 전환한 다음 Copilot Chat 프롬프트에서 ‘허용(수락)’을 클릭하세요. 그렇지 않으면 400 ‘The requested model is not supported’와 같은 오류가 발생할 수 있습니다.", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "높은 값은 출력을 더 무작위하게, 낮은 값은 더 결정적으로 만듭니다." }, "modelInfo": { + "imageSupportUnknown": "이미지 지원 여부가 보고되지 않음", + "promptCacheManagedByCopilot": "프롬프트 캐싱은 GitHub Copilot이 관리합니다", + "promptCacheCopilotDetails": "캐싱은 모델과 GitHub Copilot 백엔드에 따라 달라집니다. Zoo Code는 VS Code API를 통해 캐시 보존 기간을 설정하거나 캐시 적중 여부를 확인할 수 없습니다.", "supportsImages": "이미지 지원", "noImages": "이미지 지원 안 함", "supportsPromptCache": "프롬프트 캐시 지원", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 54e76a1a00..67d5e52bd7 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "Prompt caching inschakelen", "enablePromptCachingTitle": "Schakel prompt caching in om de prestaties te verbeteren en de kosten te verlagen voor ondersteunde modellen.", "cacheUsageNote": "Let op: als je geen cachegebruik ziet, probeer dan een ander model te selecteren en vervolgens weer je gewenste model.", + "githubCopilot": { + "accountActions": "Acties voor het GitHub Copilot-account", + "signIn": "Aanmelden bij GitHub", + "reconnect": "GitHub opnieuw verbinden", + "signedIn": "Aangemeld als {{account}}", + "connecting": "Verbinden...", + "noModels": "Geen Copilot-modellen beschikbaar. Installeer of activeer GitHub Copilot Chat, meld je aan met een geschikt GitHub-account en ververs daarna. Aanmelden bevestigt geen Copilot-abonnement. De limieten van je GitHub-abonnement, premiumverzoeken en aanvraagfrequentie zijn van toepassing.", + "manageAccount": "GitHub-account beheren", + "manageAccountDescription": "Opent het accountmenu van VS Code, waar je je kunt afmelden bij GitHub. Afmelden heeft gevolgen voor Copilot en andere extensies die dit account gebruiken." + }, "vscodeLmModel": "Taalmodel", "vscodeLmWarning": "Let op: Modellen die via de VS Code Language Model API worden benaderd kunnen door de provider worden verpakt of fijn‑afgesteld, waardoor het gedrag kan afwijken van het rechtstreeks gebruiken van hetzelfde model bij een typische provider of router. Om een model uit de keuzelijst ‘Language Model’ te gebruiken, schakel eerst naar dat model en klik vervolgens op ‘Accepteren’ in de Copilot Chat‑prompt; anders kun je een fout zien zoals 400 ‘The requested model is not supported’.", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "Hogere waarden maken de output willekeuriger, lagere waarden maken deze deterministischer." }, "modelInfo": { + "imageSupportUnknown": "Ondersteuning voor afbeeldingen niet gemeld", + "promptCacheManagedByCopilot": "Prompt-caching wordt beheerd door GitHub Copilot", + "promptCacheCopilotDetails": "Caching hangt af van het model en de GitHub Copilot-backend. Zoo Code kan de cachebewaartermijn niet instellen en cachetreffers niet waarnemen via de VS Code API.", "supportsImages": "Ondersteunt afbeeldingen", "noImages": "Ondersteunt geen afbeeldingen", "supportsPromptCache": "Ondersteunt prompt caching", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 411362c9b6..af82afd79a 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "Włącz buforowanie podpowiedzi", "enablePromptCachingTitle": "Włącz buforowanie podpowiedzi, aby poprawić wydajność i zmniejszyć koszty dla obsługiwanych modeli.", "cacheUsageNote": "Uwaga: Jeśli nie widzisz użycia bufora, spróbuj wybrać inny model, a następnie ponownie wybrać żądany model.", + "githubCopilot": { + "accountActions": "Akcje konta GitHub Copilot", + "signIn": "Zaloguj się do GitHub", + "reconnect": "Połącz ponownie z GitHub", + "signedIn": "Zalogowano jako {{account}}", + "connecting": "Łączenie...", + "noModels": "Brak dostępnych modeli Copilot. Zainstaluj lub włącz GitHub Copilot Chat, zaloguj się na uprawnione konto GitHub, a następnie odśwież. Zalogowanie nie potwierdza subskrypcji Copilot. Obowiązują limity planu GitHub, żądań premium i częstotliwości żądań.", + "manageAccount": "Zarządzaj kontem GitHub", + "manageAccountDescription": "Otwiera menu kont VS Code, w którym możesz wylogować się z GitHub. Wylogowanie wpływa na Copilot i inne rozszerzenia korzystające z tego konta." + }, "vscodeLmModel": "Model językowy", "vscodeLmWarning": "Uwaga: Modele dostępne przez interfejs VS Code Language Model API mogą być opakowane lub dostrojone przez dostawcę, dlatego ich działanie może różnić się od bezpośredniego użycia tego samego modelu u typowego dostawcy lub routera. Aby użyć modelu z listy «Language Model», najpierw przełącz się na ten model, a następnie kliknij «Akceptuj» w monicie Copilot Chat; w przeciwnym razie możesz zobaczyć błąd, np. 400 „The requested model is not supported”.", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "Wyższe wartości sprawiają, że wyjście jest bardziej losowe, niższe wartości czynią je bardziej deterministycznym." }, "modelInfo": { + "imageSupportUnknown": "Obsługa obrazów nie została zgłoszona", + "promptCacheManagedByCopilot": "Buforowaniem promptów zarządza GitHub Copilot", + "promptCacheCopilotDetails": "Buforowanie zależy od modelu i zaplecza GitHub Copilot. Zoo Code nie może skonfigurować czasu przechowywania pamięci podręcznej ani obserwować trafień w pamięci podręcznej przez API VS Code.", "supportsImages": "Obsługuje obrazy", "noImages": "Nie obsługuje obrazów", "supportsPromptCache": "Obsługuje buforowanie podpowiedzi", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index a65d642a68..fd6dbe6d4a 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "Ativar cache de prompts", "enablePromptCachingTitle": "Ativar cache de prompts para melhorar o desempenho e reduzir custos para modelos suportados.", "cacheUsageNote": "Nota: Se você não vir o uso do cache, tente selecionar um modelo diferente e depois selecionar novamente o modelo desejado.", + "githubCopilot": { + "accountActions": "Ações da conta do GitHub Copilot", + "signIn": "Entrar no GitHub", + "reconnect": "Reconectar ao GitHub", + "signedIn": "Conectado como {{account}}", + "connecting": "Conectando...", + "noModels": "Nenhum modelo do Copilot disponível. Instale ou ative o GitHub Copilot Chat, entre com uma conta do GitHub elegível e atualize. Entrar não confirma uma assinatura do Copilot. Aplicam-se os limites do seu plano do GitHub, de solicitações premium e de taxa.", + "manageAccount": "Gerenciar conta do GitHub", + "manageAccountDescription": "Abre o menu de contas do VS Code, onde você pode sair do GitHub. Sair afeta o Copilot e outras extensões que usam esta conta." + }, "vscodeLmModel": "Modelo de Linguagem", "vscodeLmWarning": "Observação: Modelos acessados pela VS Code Language Model API podem ser encapsulados ou ajustados pelo provedor, portanto o comportamento pode diferir do uso direto do mesmo modelo em um provedor ou roteador típico. Para usar um modelo no menu suspenso «Language Model», primeiro altere para esse modelo e depois clique em «Aceitar» no prompt do Copilot Chat; caso contrário, você pode ver um erro como 400 «The requested model is not supported».", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "Valores mais altos tornam a saída mais aleatória, valores mais baixos a tornam mais determinística." }, "modelInfo": { + "imageSupportUnknown": "Suporte a imagens não informado", + "promptCacheManagedByCopilot": "O cache de prompts é gerenciado pelo GitHub Copilot", + "promptCacheCopilotDetails": "O cache depende do modelo e do backend do GitHub Copilot. O Zoo Code não pode configurar a retenção do cache nem observar acertos de cache pela API do VS Code.", "supportsImages": "Suporta imagens", "noImages": "Não suporta imagens", "supportsPromptCache": "Suporta cache de prompts", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 177aaa870e..3745a9ee4f 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "Включить кэширование подсказок", "enablePromptCachingTitle": "Включить кэширование подсказок для повышения производительности и снижения затрат для поддерживаемых моделей.", "cacheUsageNote": "Примечание: если вы не видите использование кэша, попробуйте выбрать другую модель, а затем вернуться к нужной.", + "githubCopilot": { + "accountActions": "Действия с учётной записью GitHub Copilot", + "signIn": "Войти в GitHub", + "reconnect": "Переподключить GitHub", + "signedIn": "Выполнен вход как {{account}}", + "connecting": "Подключение...", + "noModels": "Модели Copilot недоступны. Установите или включите GitHub Copilot Chat, войдите с подходящей учётной записью GitHub и обновите список. Вход не подтверждает наличие подписки на Copilot. Действуют ограничения вашего тарифа GitHub, премиум-запросов и частоты запросов.", + "manageAccount": "Управление учётной записью GitHub", + "manageAccountDescription": "Открывает меню учётных записей VS Code, где можно выйти из GitHub. Выход затрагивает Copilot и другие расширения, использующие эту учётную запись." + }, "vscodeLmModel": "Языковая модель", "vscodeLmWarning": "Внимание: Модели, доступные через API VS Code Language Model, могут быть обёрнуты или дополнительно дообучены поставщиком, поэтому их поведение может отличаться от прямого использования той же модели у типичного провайдера или роутера. Чтобы использовать модель из выпадающего списка «Language Model», сначала переключитесь на эту модель, затем нажмите «Принять» в запросе Copilot Chat; в противном случае возможна ошибка, например 400 «The requested model is not supported».", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "Более высокие значения делают ответы более случайными, низкие — более детерминированными." }, "modelInfo": { + "imageSupportUnknown": "Поддержка изображений не сообщена", + "promptCacheManagedByCopilot": "Кэшированием промптов управляет GitHub Copilot", + "promptCacheCopilotDetails": "Кэширование зависит от модели и серверной части GitHub Copilot. Zoo Code не может настроить срок хранения кэша или отслеживать попадания в кэш через API VS Code.", "supportsImages": "Поддерживает изображения", "noImages": "Не поддерживает изображения", "supportsPromptCache": "Поддерживает кэширование подсказок", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index a3bbceb764..8011cfd5e0 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "İstem önbelleğini etkinleştir", "enablePromptCachingTitle": "Desteklenen modeller için performansı artırmak ve maliyetleri azaltmak için istem önbelleğini etkinleştir.", "cacheUsageNote": "Not: Önbellek kullanımını görmüyorsanız, farklı bir model seçip ardından istediğiniz modeli tekrar seçmeyi deneyin.", + "githubCopilot": { + "accountActions": "GitHub Copilot hesap eylemleri", + "signIn": "GitHub'da oturum aç", + "reconnect": "GitHub'a yeniden bağlan", + "signedIn": "{{account}} olarak oturum açıldı", + "connecting": "Bağlanılıyor...", + "noModels": "Kullanılabilir Copilot modeli yok. GitHub Copilot Chat'i yükleyin veya etkinleştirin, uygun bir GitHub hesabıyla oturum açın ve ardından yenileyin. Oturum açmak bir Copilot aboneliğini doğrulamaz. GitHub planınızın, premium isteklerin ve istek hızının sınırları geçerlidir.", + "manageAccount": "GitHub hesabını yönet", + "manageAccountDescription": "VS Code'un hesap menüsünü açar; GitHub oturumunu buradan kapatabilirsiniz. Oturumu kapatmak Copilot'u ve bu hesabı kullanan diğer uzantıları etkiler." + }, "vscodeLmModel": "Dil Modeli", "vscodeLmWarning": "Not: VS Code Language Model API üzerinden erişilen modeller sağlayıcı tarafından sarılmış veya ince ayarlanmış olabilir; bu nedenle davranış, aynı modelin tipik bir sağlayıcı ya da yönlendirici üzerinden doğrudan kullanılmasından farklı olabilir. «Language Model» açılır menüsünden bir model kullanmak için önce o modele geçin ve ardından Copilot Chat isteminde «Kabul Et»e tıklayın; aksi takdirde 400 «The requested model is not supported» gibi bir hata görebilirsiniz.", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "Daha yüksek değerler çıktıyı daha rastgele yapar, daha düşük değerler daha deterministik hale getirir." }, "modelInfo": { + "imageSupportUnknown": "Görsel desteği bildirilmedi", + "promptCacheManagedByCopilot": "İstem önbelleğini GitHub Copilot yönetir", + "promptCacheCopilotDetails": "Önbellekleme modele ve GitHub Copilot arka ucuna bağlıdır. Zoo Code, VS Code API'si üzerinden önbellek saklama süresini yapılandıramaz veya önbellek isabetlerini gözlemleyemez.", "supportsImages": "Görüntüleri destekler", "noImages": "Görüntüleri desteklemez", "supportsPromptCache": "İstem önbelleğini destekler", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 4f25fcceb6..719446dbf1 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "Bật bộ nhớ đệm lời nhắc", "enablePromptCachingTitle": "Bật bộ nhớ đệm lời nhắc để cải thiện hiệu suất và giảm chi phí cho các mô hình được hỗ trợ.", "cacheUsageNote": "Lưu ý: Nếu bạn không thấy việc sử dụng bộ nhớ đệm, hãy thử chọn một mô hình khác và sau đó chọn lại mô hình mong muốn của bạn.", + "githubCopilot": { + "accountActions": "Thao tác tài khoản GitHub Copilot", + "signIn": "Đăng nhập GitHub", + "reconnect": "Kết nối lại GitHub", + "signedIn": "Đã đăng nhập với tên {{account}}", + "connecting": "Đang kết nối...", + "noModels": "Không có mô hình Copilot nào khả dụng. Hãy cài đặt hoặc bật GitHub Copilot Chat, đăng nhập bằng tài khoản GitHub đủ điều kiện, rồi làm mới. Đăng nhập không xác nhận bạn có gói đăng ký Copilot. Áp dụng giới hạn của gói GitHub, yêu cầu cao cấp và tần suất yêu cầu.", + "manageAccount": "Quản lý tài khoản GitHub", + "manageAccountDescription": "Mở menu tài khoản của VS Code, nơi bạn có thể đăng xuất khỏi GitHub. Đăng xuất ảnh hưởng đến Copilot và các tiện ích mở rộng khác dùng tài khoản này." + }, "vscodeLmModel": "Mô hình ngôn ngữ", "vscodeLmWarning": "Lưu ý: Các mô hình truy cập qua VS Code Language Model API có thể được nhà cung cấp bao bọc hoặc tinh chỉnh, vì vậy hành vi có thể khác so với khi dùng trực tiếp cùng mô hình từ nhà cung cấp hoặc router thông thường. Để dùng một mô hình trong menu «Language Model», trước tiên hãy chuyển sang mô hình đó rồi nhấp «Chấp nhận» trong lời nhắc Copilot Chat; nếu không bạn có thể gặp lỗi như 400 «The requested model is not supported».", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "Giá trị cao hơn làm cho đầu ra ngẫu nhiên hơn, giá trị thấp hơn làm cho nó xác định hơn." }, "modelInfo": { + "imageSupportUnknown": "Chưa báo cáo khả năng hỗ trợ hình ảnh", + "promptCacheManagedByCopilot": "Bộ nhớ đệm prompt do GitHub Copilot quản lý", + "promptCacheCopilotDetails": "Bộ nhớ đệm phụ thuộc vào mô hình và backend của GitHub Copilot. Zoo Code không thể cấu hình thời gian lưu bộ nhớ đệm hay quan sát lượt trúng bộ nhớ đệm qua API của VS Code.", "supportsImages": "Hỗ trợ hình ảnh", "noImages": "Không hỗ trợ hình ảnh", "supportsPromptCache": "Hỗ trợ bộ nhớ đệm lời nhắc", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index fee64e4f54..93c0c1dc68 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -574,6 +574,16 @@ "enablePromptCaching": "启用提示缓存", "enablePromptCachingTitle": "开启提示缓存可提升性能并节省成本", "cacheUsageNote": "提示:若未显示缓存使用情况,请切换模型后重新选择", + "githubCopilot": { + "accountActions": "GitHub Copilot 账户操作", + "signIn": "登录 GitHub", + "reconnect": "重新连接 GitHub", + "signedIn": "已以 {{account}} 身份登录", + "connecting": "正在连接...", + "noModels": "没有可用的 Copilot 模型。请安装或启用 GitHub Copilot Chat,使用符合条件的 GitHub 账户登录,然后刷新。登录并不能确认你拥有 Copilot 订阅。GitHub 套餐、高级请求和速率限制均适用。", + "manageAccount": "管理 GitHub 账户", + "manageAccountDescription": "打开 VS Code 的账户菜单,你可以在其中退出 GitHub 登录。退出登录会影响 Copilot 以及使用该账户的其他扩展。" + }, "vscodeLmModel": "VSCode LM 模型", "vscodeLmWarning": "注意:通过 VS Code Language Model API 访问的模型可能由提供商进行封装或微调,因此其行为可能与直接从常见提供商或路由器使用同一模型时不同。要使用「Language Model」下拉列表中的模型,请先切换到该模型,然后在 Copilot Chat 提示中点击「接受」;否则可能会出现 400「The requested model is not supported」等错误。", "googleCloudSetup": { @@ -993,6 +1003,9 @@ "rangeDescription": "值越高回答越多样,值越低越保守" }, "modelInfo": { + "imageSupportUnknown": "未报告图像支持情况", + "promptCacheManagedByCopilot": "提示词缓存由 GitHub Copilot 管理", + "promptCacheCopilotDetails": "缓存行为取决于模型和 GitHub Copilot 后端。Zoo Code 无法通过 VS Code API 配置缓存保留时间,也无法观察缓存命中情况。", "supportsImages": "支持图像", "noImages": "不支持图像", "supportsPromptCache": "支持提示缓存", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index eefd6012cf..9e52551119 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -601,6 +601,16 @@ "enablePromptCaching": "啟用提示快取", "enablePromptCachingTitle": "啟用提示快取以提升支援的模型效能並降低成本。", "cacheUsageNote": "注意:如果您沒有看到快取使用情況,請嘗試選擇其他模型,然後重新選擇您想要的模型。", + "githubCopilot": { + "accountActions": "GitHub Copilot 帳戶動作", + "signIn": "登入 GitHub", + "reconnect": "重新連線 GitHub", + "signedIn": "已以 {{account}} 身分登入", + "connecting": "連線中...", + "noModels": "沒有可用的 Copilot 模型。請安裝或啟用 GitHub Copilot Chat,使用符合資格的 GitHub 帳戶登入,然後重新整理。登入並不能確認你擁有 Copilot 訂閱。GitHub 方案、進階要求與速率限制皆適用。", + "manageAccount": "管理 GitHub 帳戶", + "manageAccountDescription": "開啟 VS Code 的帳戶選單,你可以在其中登出 GitHub。登出會影響 Copilot 以及使用此帳戶的其他擴充功能。" + }, "vscodeLmModel": "語言模型", "vscodeLmWarning": "注意:透過 VS Code Language Model API 存取的模型可能由供應商封裝或微調,因此其行為可能與直接從一般供應商或路由器使用相同模型時不同。要使用「Language Model」下拉式選單中的模型,請先切換到該模型,然後在 Copilot Chat 提示中點選「接受」;否則可能會出現 400「The requested model is not supported」等錯誤。", "googleCloudSetup": { @@ -1020,6 +1030,9 @@ "rangeDescription": "較高值使輸出更隨機,較低值更確定" }, "modelInfo": { + "imageSupportUnknown": "未回報圖片支援情況", + "promptCacheManagedByCopilot": "提示詞快取由 GitHub Copilot 管理", + "promptCacheCopilotDetails": "快取行為取決於模型與 GitHub Copilot 後端。Zoo Code 無法透過 VS Code API 設定快取保留時間,也無法觀察快取命中情況。", "supportsImages": "支援影像", "noImages": "不支援影像", "supportsPromptCache": "支援提示快取", diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index a1936b89d7..7d089ecaa3 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -178,6 +178,26 @@ describe("Model Validation Functions", () => { }) }) + describe("validateApiConfiguration for GitHub Copilot", () => { + const validate = (vsCodeLmModelSelector: ProviderSettings["vsCodeLmModelSelector"]) => + validateApiConfiguration({ apiProvider: providerIdentifiers.githubCopilot, vsCodeLmModelSelector }) + + it.each([ + ["a model id", { vendor: "copilot", id: "gpt-x" }], + ["a model family", { vendor: "copilot", family: "gpt-x" }], + ])("accepts a selection that names %s", (_label, selector) => { + expect(validate(selector)).toBeUndefined() + }) + + it.each([ + ["no selection", undefined], + ["a selection from another vendor", { vendor: "other", id: "gpt-x" }], + ["a Copilot selection naming neither id nor family", { vendor: "copilot" }], + ])("requires a model for %s", (_label, selector) => { + expect(validate(selector)).toBe("settings:validation.modelSelector") + }) + }) + describe("validateApiConfigurationExcludingModelErrors", () => { it("returns undefined when configuration is valid", () => { const config: ProviderSettings = { diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 3e32116374..18d4a78f22 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -8,6 +8,7 @@ import { isDynamicProvider, parseOpenAiExtraBody, providerIdentifiers, + githubCopilotLanguageModel, } from "@roo-code/types" type OpenAiExtraBodyParseResult = ReturnType @@ -147,6 +148,14 @@ function validateModelsAndKeysProvided( return i18next.t("settings:validation.modelSelector") } break + case providerIdentifiers.githubCopilot: + if ( + apiConfiguration.vsCodeLmModelSelector?.vendor !== githubCopilotLanguageModel.vendor || + (!apiConfiguration.vsCodeLmModelSelector.id && !apiConfiguration.vsCodeLmModelSelector.family) + ) { + return i18next.t("settings:validation.modelSelector") + } + break case providerIdentifiers.fireworks: if (!apiConfiguration.fireworksApiKey) { return i18next.t("settings:validation.apiKey")