diff --git a/core/llm/llms/OpenRouter.ts b/core/llm/llms/OpenRouter.ts index b2772824583..0c389f7bd70 100644 --- a/core/llm/llms/OpenRouter.ts +++ b/core/llm/llms/OpenRouter.ts @@ -1,5 +1,7 @@ import { ChatCompletionCreateParams } from "openai/resources/index"; +import { OPENROUTER_HEADERS } from "@continuedev/openai-adapters"; + import { LLMOptions } from "../../index.js"; import { osModelsEditPrompt } from "../templates/edit.js"; @@ -18,6 +20,19 @@ class OpenRouter extends OpenAI { useLegacyCompletionsEndpoint: false, }; + constructor(options: LLMOptions) { + super({ + ...options, + requestOptions: { + ...options.requestOptions, + headers: { + ...OPENROUTER_HEADERS, + ...options.requestOptions?.headers, + }, + }, + }); + } + private isAnthropicModel(model?: string): boolean { if (!model) return false; const modelLower = model.toLowerCase(); diff --git a/extensions/vscode/src/VsCodeIde.ts b/extensions/vscode/src/VsCodeIde.ts index 9e5853afa3b..c584d534c64 100644 --- a/extensions/vscode/src/VsCodeIde.ts +++ b/extensions/vscode/src/VsCodeIde.ts @@ -15,6 +15,7 @@ import { import { Repository } from "./otherExtensions/git"; import { SecretStorage } from "./stubs/SecretStorage"; import { VsCodeIdeUtils } from "./util/ideUtils"; +import { runCommandInTerminal } from "./util/runCommandInTerminal"; import { getExtensionVersion, isExtensionPrerelease } from "./util/util"; import { getExtensionUri, openEditorAndRevealRange } from "./util/vscode"; import { VsCodeWebviewProtocol } from "./webviewProtocol"; @@ -337,22 +338,7 @@ class VsCodeIde implements IDE { command: string, options: TerminalOptions = { reuseTerminal: true }, ): Promise { - let terminal: vscode.Terminal | undefined; - if (vscode.window.terminals.length && options.reuseTerminal) { - if (options.terminalName) { - terminal = vscode.window.terminals.find( - (t) => t?.name === options.terminalName, - ); - } else { - terminal = vscode.window.activeTerminal ?? vscode.window.terminals[0]; - } - } - - if (!terminal) { - terminal = vscode.window.createTerminal(options?.terminalName); - } - terminal.show(); - terminal.sendText(command, false); + await runCommandInTerminal(command, options); } async saveFile(fileUri: string): Promise { diff --git a/extensions/vscode/src/util/runCommandInTerminal.ts b/extensions/vscode/src/util/runCommandInTerminal.ts new file mode 100644 index 00000000000..6320fb2f2b0 --- /dev/null +++ b/extensions/vscode/src/util/runCommandInTerminal.ts @@ -0,0 +1,133 @@ +import type { TerminalOptions } from "core"; +import * as vscode from "vscode"; + +const REMOTE_TERMINAL_TIMEOUT_MS = 5000; + +const terminalCacheByName = new Map(); + +function getNewActiveTerminal( + existingTerminals: Set, +): vscode.Terminal | undefined { + const activeTerminal = vscode.window.activeTerminal; + if (!activeTerminal || existingTerminals.has(activeTerminal)) { + return undefined; + } + + return activeTerminal; +} + +function getReusableTerminal( + options: TerminalOptions, +): vscode.Terminal | undefined { + if (!vscode.window.terminals.length || !options.reuseTerminal) { + return undefined; + } + + if (options.terminalName) { + const cachedTerminal = terminalCacheByName.get(options.terminalName); + if (cachedTerminal && vscode.window.terminals.includes(cachedTerminal)) { + return cachedTerminal; + } + + terminalCacheByName.delete(options.terminalName); + return vscode.window.terminals.find( + (terminal) => terminal?.name === options.terminalName, + ); + } + + return vscode.window.activeTerminal ?? vscode.window.terminals[0]; +} + +async function createTerminal( + options: TerminalOptions, +): Promise { + if (!vscode.env.remoteName) { + return vscode.window.createTerminal(options.terminalName); + } + + const existingTerminals = new Set(vscode.window.terminals); + await vscode.commands.executeCommand("workbench.action.terminal.new"); + + const newActiveTerminal = getNewActiveTerminal(existingTerminals); + if (newActiveTerminal) { + return newActiveTerminal; + } + + return await new Promise((resolve, reject) => { + let settled = false; + let timeoutHandle: ReturnType | undefined; + + const cleanup = () => { + terminalOpenListener.dispose(); + activeTerminalListener.dispose(); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + }; + + const resolveIfNewActiveTerminalExists = () => { + const newTerminal = getNewActiveTerminal(existingTerminals); + if (!newTerminal) { + return false; + } + + settled = true; + cleanup(); + resolve(newTerminal); + return true; + }; + + const terminalOpenListener = vscode.window.onDidOpenTerminal(() => { + if (settled) { + return; + } + + resolveIfNewActiveTerminalExists(); + }); + + const activeTerminalListener = vscode.window.onDidChangeActiveTerminal( + (terminal) => { + if (settled || !terminal || existingTerminals.has(terminal)) { + return; + } + + settled = true; + cleanup(); + resolve(terminal); + }, + ); + + if (resolveIfNewActiveTerminalExists()) { + return; + } + + // `workbench.action.terminal.new` should focus the new terminal in remote + // workspaces. If another terminal opens concurrently, wait until VS Code + // actually switches the active terminal instead of grabbing the first one + // that appears. + timeoutHandle = setTimeout(() => { + if (settled) { + return; + } + + settled = true; + cleanup(); + reject(new Error("Timed out waiting for remote terminal to open")); + }, REMOTE_TERMINAL_TIMEOUT_MS); + }); +} + +export async function runCommandInTerminal( + command: string, + options: TerminalOptions = { reuseTerminal: true }, +): Promise { + const terminal = + getReusableTerminal(options) ?? (await createTerminal(options)); + + if (options.terminalName) { + terminalCacheByName.set(options.terminalName, terminal); + } + + terminal.show(); + terminal.sendText(command, true); +} diff --git a/extensions/vscode/src/util/runCommandInTerminal.vitest.ts b/extensions/vscode/src/util/runCommandInTerminal.vitest.ts new file mode 100644 index 00000000000..a66365957ab --- /dev/null +++ b/extensions/vscode/src/util/runCommandInTerminal.vitest.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type MockTerminal = { + name: string; + show: ReturnType; + sendText: ReturnType; +}; + +const terminalListeners = new Set<(terminal: MockTerminal) => void>(); +const activeTerminalListeners = new Set< + (terminal: MockTerminal | undefined) => void +>(); +const terminals: MockTerminal[] = []; + +const windowMock = { + terminals, + activeTerminal: undefined as MockTerminal | undefined, + createTerminal: vi.fn<(name?: string) => MockTerminal>(), + onDidOpenTerminal: vi.fn((listener: (terminal: MockTerminal) => void) => { + terminalListeners.add(listener); + return { + dispose: vi.fn(() => terminalListeners.delete(listener)), + }; + }), + onDidChangeActiveTerminal: vi.fn( + (listener: (terminal: MockTerminal | undefined) => void) => { + activeTerminalListeners.add(listener); + return { + dispose: vi.fn(() => activeTerminalListeners.delete(listener)), + }; + }, + ), +}; + +const commandsMock = { + executeCommand: vi.fn<(command: string) => Promise>(), +}; + +const envMock = { + remoteName: undefined as string | undefined, +}; + +vi.mock("vscode", () => ({ + window: windowMock, + commands: commandsMock, + env: envMock, +})); + +function createTerminal(name: string): MockTerminal { + return { + name, + show: vi.fn(), + sendText: vi.fn(), + }; +} + +function notifyTerminalOpened(terminal: MockTerminal) { + for (const listener of terminalListeners) { + listener(terminal); + } +} + +function setActiveTerminal(terminal: MockTerminal | undefined) { + windowMock.activeTerminal = terminal; + for (const listener of activeTerminalListeners) { + listener(terminal); + } +} + +describe("runCommandInTerminal", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + terminals.length = 0; + terminalListeners.clear(); + activeTerminalListeners.clear(); + setActiveTerminal(undefined); + envMock.remoteName = undefined; + + windowMock.createTerminal.mockImplementation((name?: string) => { + const terminal = createTerminal( + name ?? "Terminal " + (terminals.length + 1), + ); + terminals.push(terminal); + return terminal; + }); + + commandsMock.executeCommand.mockResolvedValue(); + }); + + it("reuses the active terminal and sends an executing command", async () => { + const terminal = createTerminal("Active"); + terminals.push(terminal); + setActiveTerminal(terminal); + + const { runCommandInTerminal } = await import("./runCommandInTerminal"); + + await runCommandInTerminal("echo hello"); + + expect(windowMock.createTerminal).not.toHaveBeenCalled(); + expect(commandsMock.executeCommand).not.toHaveBeenCalled(); + expect(terminal.show).toHaveBeenCalledOnce(); + expect(terminal.sendText).toHaveBeenCalledWith("echo hello", true); + }); + + it("creates a named local terminal when no reusable terminal exists", async () => { + const { runCommandInTerminal } = await import("./runCommandInTerminal"); + + await runCommandInTerminal("npm test", { + reuseTerminal: true, + terminalName: "Start Ollama", + }); + + expect(windowMock.createTerminal).toHaveBeenCalledWith("Start Ollama"); + expect(commandsMock.executeCommand).not.toHaveBeenCalled(); + + const createdTerminal = terminals[0]; + expect(createdTerminal.show).toHaveBeenCalledOnce(); + expect(createdTerminal.sendText).toHaveBeenCalledWith("npm test", true); + }); + + it("creates remote terminals through the remote-aware VS Code command", async () => { + envMock.remoteName = "ssh-remote"; + commandsMock.executeCommand.mockImplementation(async (command: string) => { + expect(command).toBe("workbench.action.terminal.new"); + const terminal = createTerminal("Remote Shell"); + terminals.push(terminal); + setActiveTerminal(terminal); + notifyTerminalOpened(terminal); + }); + + const { runCommandInTerminal } = await import("./runCommandInTerminal"); + + await runCommandInTerminal("pwd", { reuseTerminal: true }); + + expect(windowMock.createTerminal).not.toHaveBeenCalled(); + expect(commandsMock.executeCommand).toHaveBeenCalledWith( + "workbench.action.terminal.new", + ); + + const createdTerminal = terminals[0]; + expect(createdTerminal.show).toHaveBeenCalledOnce(); + expect(createdTerminal.sendText).toHaveBeenCalledWith("pwd", true); + }); + + it("reuses cached remote terminals for named commands", async () => { + envMock.remoteName = "dev-container"; + + let createdCount = 0; + commandsMock.executeCommand.mockImplementation(async () => { + createdCount += 1; + const terminal = createTerminal("Remote " + createdCount); + terminals.push(terminal); + setActiveTerminal(terminal); + notifyTerminalOpened(terminal); + }); + + const { runCommandInTerminal } = await import("./runCommandInTerminal"); + + await runCommandInTerminal("ollama serve", { + reuseTerminal: true, + terminalName: "Start Ollama", + }); + await runCommandInTerminal("ollama serve", { + reuseTerminal: true, + terminalName: "Start Ollama", + }); + + expect(commandsMock.executeCommand).toHaveBeenCalledTimes(1); + expect(terminals[0].sendText).toHaveBeenNthCalledWith( + 1, + "ollama serve", + true, + ); + expect(terminals[0].sendText).toHaveBeenNthCalledWith( + 2, + "ollama serve", + true, + ); + }); + + it("ignores unrelated remote terminals that open before the command terminal becomes active", async () => { + envMock.remoteName = "dev-container"; + commandsMock.executeCommand.mockImplementation(async () => { + const unrelatedTerminal = createTerminal("Unrelated"); + terminals.push(unrelatedTerminal); + notifyTerminalOpened(unrelatedTerminal); + + const commandTerminal = createTerminal("Remote Command"); + terminals.push(commandTerminal); + setActiveTerminal(commandTerminal); + notifyTerminalOpened(commandTerminal); + }); + + const { runCommandInTerminal } = await import("./runCommandInTerminal"); + + await runCommandInTerminal("pwd", { reuseTerminal: true }); + + expect(terminals[0].sendText).not.toHaveBeenCalled(); + expect(terminals[1].sendText).toHaveBeenCalledWith("pwd", true); + }); +}); diff --git a/packages/openai-adapters/src/apis/OpenRouter.ts b/packages/openai-adapters/src/apis/OpenRouter.ts index 7c45fddeed6..542699d20c3 100644 --- a/packages/openai-adapters/src/apis/OpenRouter.ts +++ b/packages/openai-adapters/src/apis/OpenRouter.ts @@ -10,9 +10,10 @@ export interface OpenRouterConfig extends OpenAIConfig { // TODO: Extract detailed error info from OpenRouter's error.metadata.raw to surface better messages -const OPENROUTER_HEADERS: Record = { +export const OPENROUTER_HEADERS: Record = { "HTTP-Referer": "https://www.continue.dev/", - "X-Title": "Continue", + "X-OpenRouter-Title": "Continue", + "X-OpenRouter-Categories": "ide-extension", }; export class OpenRouterApi extends OpenAIApi { diff --git a/packages/openai-adapters/src/index.ts b/packages/openai-adapters/src/index.ts index 467c7a71ae9..c9eb4da00fa 100644 --- a/packages/openai-adapters/src/index.ts +++ b/packages/openai-adapters/src/index.ts @@ -243,4 +243,5 @@ export { } from "./apis/AnthropicUtils.js"; export { isResponsesModel } from "./apis/openaiResponses.js"; +export { OPENROUTER_HEADERS } from "./apis/OpenRouter.js"; export { extractBase64FromDataUrl, parseDataUrl } from "./util/url.js";