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..79499dc0edc --- /dev/null +++ b/extensions/vscode/src/util/runCommandInTerminal.ts @@ -0,0 +1,118 @@ +import type { TerminalOptions } from "core"; +import * as vscode from "vscode"; + +const REMOTE_TERMINAL_TIMEOUT_MS = 5000; + +const terminalCacheByName = new Map(); + +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); + + return await new Promise((resolve, reject) => { + let settled = false; + let timeoutHandle: ReturnType | undefined; + + const cleanup = () => { + terminalListener.dispose(); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + }; + + const resolveIfNewTerminalExists = () => { + const newTerminal = vscode.window.terminals.find( + (terminal) => !existingTerminals.has(terminal), + ); + if (!newTerminal) { + return false; + } + + settled = true; + cleanup(); + resolve(newTerminal); + return true; + }; + + const terminalListener = vscode.window.onDidOpenTerminal((terminal) => { + if (settled || existingTerminals.has(terminal)) { + return; + } + + settled = true; + cleanup(); + resolve(terminal); + }); + + timeoutHandle = setTimeout(() => { + if (settled) { + return; + } + + settled = true; + cleanup(); + reject(new Error("Timed out waiting for remote terminal to open")); + }, REMOTE_TERMINAL_TIMEOUT_MS); + + if (resolveIfNewTerminalExists()) { + return; + } + + void vscode.commands.executeCommand("workbench.action.terminal.new").then( + () => { + resolveIfNewTerminalExists(); + }, + (error: unknown) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + reject(error); + }, + ); + }); +} + +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..b2848965984 --- /dev/null +++ b/extensions/vscode/src/util/runCommandInTerminal.vitest.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type MockTerminal = { + name: string; + show: ReturnType; + sendText: ReturnType; +}; + +const terminalListeners = new Set<(terminal: MockTerminal) => 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)), + }; + }), +}; + +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(), + }; +} + +describe("runCommandInTerminal", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + terminals.length = 0; + terminalListeners.clear(); + windowMock.activeTerminal = 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); + windowMock.activeTerminal = 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); + for (const listener of terminalListeners) { + listener(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); + for (const listener of terminalListeners) { + listener(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, + ); + }); +}); 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";