diff --git a/.changeset/warm-taxes-build.md b/.changeset/warm-taxes-build.md new file mode 100644 index 0000000000..27aebc3ee4 --- /dev/null +++ b/.changeset/warm-taxes-build.md @@ -0,0 +1,5 @@ +--- +"roo-code": minor +--- + +Added support for VS Code variable interpolation in Provider Custom Headers. Users can now use `${workspaceFolderBasename}`, `${workspaceFolder}`, and `${env:VAR_NAME}` patterns in custom header values, enabling per-project tracking and routing without creating multiple provider profiles. diff --git a/src/api/index.ts b/src/api/index.ts index 30119b7dc7..a79ad1c899 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,6 +3,8 @@ import OpenAI from "openai" import type { ProviderSettings, ModelInfo } from "@roo-code/types" +import { interpolateHeaders } from "../utils/config" + import { ApiStream } from "./transform/stream" import { @@ -122,6 +124,11 @@ export interface ApiHandler { export function buildApiHandler(configuration: ProviderSettings): ApiHandler { const { apiProvider, ...options } = configuration + // Interpolate variables in custom headers (e.g., ${workspaceFolderBasename}, ${env:VAR_NAME}) + if (options.openAiHeaders) { + options.openAiHeaders = interpolateHeaders(options.openAiHeaders) + } + switch (apiProvider) { case "anthropic": return new AnthropicHandler(options) diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 87589b9396..70d626833e 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -13,6 +13,7 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { TagMatcher } from "../../utils/tag-matcher" +import { interpolateHeaders } from "../../utils/config" import { convertToOpenAiMessages } from "../transform/openai-format" import { convertToR1Format } from "../transform/r1-format" @@ -542,9 +543,11 @@ export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiH } const config: Record = {} + // Interpolate variables in custom headers (e.g., ${workspaceFolderBasename}, ${env:VAR_NAME}) + const interpolatedHeaders = interpolateHeaders(openAiHeaders) const headers: Record = { ...DEFAULT_HEADERS, - ...(openAiHeaders || {}), + ...(interpolatedHeaders || {}), } if (apiKey) { diff --git a/src/utils/__tests__/config.spec.ts b/src/utils/__tests__/config.spec.ts index a5155c1688..dd9a0bbc41 100644 --- a/src/utils/__tests__/config.spec.ts +++ b/src/utils/__tests__/config.spec.ts @@ -1,6 +1,19 @@ // npx vitest utils/__tests__/config.spec.ts -import { injectEnv, injectVariables } from "../config" +import { injectEnv, injectVariables, interpolateHeaders } from "../config" + +// Mock vscode module for interpolateHeaders tests +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: [ + { + uri: { + fsPath: "/home/user/projects/my-awesome-app", + }, + }, + ], + }, +})) describe("injectEnv", () => { const originalEnv = process.env @@ -253,3 +266,107 @@ describe("injectVariables", () => { // Variable maps are already tested by `injectEnv` tests above. }) + +describe("interpolateHeaders", () => { + const originalEnv = process.env + + beforeEach(() => { + vitest.resetModules() + process.env = { ...originalEnv } + }) + + afterAll(() => { + process.env = originalEnv + }) + + it("should return undefined for undefined headers", () => { + const result = interpolateHeaders(undefined) + expect(result).toBeUndefined() + }) + + it("should return empty object for empty headers", () => { + const result = interpolateHeaders({}) + expect(result).toEqual({}) + }) + + it("should pass through headers without variables unchanged", () => { + const headers = { + "Content-Type": "application/json", + "X-Custom-Header": "static-value", + } + const result = interpolateHeaders(headers) + expect(result).toEqual(headers) + }) + + it("should interpolate ${workspaceFolderBasename} variable", () => { + const headers = { + "X-App-ID": "${workspaceFolderBasename}", + "X-Other": "static", + } + const result = interpolateHeaders(headers) + expect(result).toEqual({ + "X-App-ID": "my-awesome-app", + "X-Other": "static", + }) + }) + + it("should interpolate ${workspaceFolder} variable", () => { + const headers = { + "X-Workspace": "${workspaceFolder}", + } + const result = interpolateHeaders(headers) + expect(result).toEqual({ + "X-Workspace": "/home/user/projects/my-awesome-app", + }) + }) + + it("should interpolate ${env:VAR_NAME} variables", () => { + process.env.MY_API_KEY = "secret-key-123" + const headers = { + Authorization: "Bearer ${env:MY_API_KEY}", + } + const result = interpolateHeaders(headers) + expect(result).toEqual({ + Authorization: "Bearer secret-key-123", + }) + }) + + it("should handle mixed variables in headers", () => { + process.env.TENANT_ID = "tenant-001" + const headers = { + "X-App-ID": "${workspaceFolderBasename}", + "X-Tenant-ID": "${env:TENANT_ID}", + "X-Static": "no-variables", + } + const result = interpolateHeaders(headers) + expect(result).toEqual({ + "X-App-ID": "my-awesome-app", + "X-Tenant-ID": "tenant-001", + "X-Static": "no-variables", + }) + }) + + it("should handle missing env variables by keeping the original pattern", () => { + const consoleWarnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}) + const headers = { + "X-Missing": "${env:NON_EXISTENT_VAR}", + } + const result = interpolateHeaders(headers) + expect(result).toEqual({ + "X-Missing": "${env:NON_EXISTENT_VAR}", + }) + expect(consoleWarnSpy).toHaveBeenCalled() + consoleWarnSpy.mockRestore() + }) + + it("should interpolate multiple variables in a single header value", () => { + process.env.PREFIX = "prod" + const headers = { + "X-Identifier": "${env:PREFIX}-${workspaceFolderBasename}", + } + const result = interpolateHeaders(headers) + expect(result).toEqual({ + "X-Identifier": "prod-my-awesome-app", + }) + }) +}) diff --git a/src/utils/config.ts b/src/utils/config.ts index 9f750bb915..58ebfb18b6 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,3 +1,6 @@ +import * as vscode from "vscode" +import * as path from "path" + export type InjectableConfigType = | string | { @@ -64,3 +67,77 @@ export async function injectVariables( return (isObject ? JSON.parse(configString) : configString) as C extends string ? string : C } + +/** + * Synchronously injects variables into a string value. + * Used for interpolating individual header values. + * + * @param value The string value to interpolate + * @param variables The variables to inject + * @returns The interpolated string + */ +function injectVariablesSync( + value: string, + variables: Record>, +): string { + let result = value + + for (const [key, varValue] of Object.entries(variables)) { + if (varValue == null) continue + + if (typeof varValue === "string") { + // Normalize paths to forward slashes for cross-platform compatibility + result = result.replace(new RegExp(`\\$\\{${key}\\}`, "g"), varValue.toPosix()) + } else { + // Handle nested variables (e.g., ${env:VAR_NAME}) + result = result.replace(new RegExp(`\\$\\{${key}:([\\w]+)\\}`, "g"), (match, name) => { + const nestedValue = varValue[name] + + if (nestedValue == null) { + console.warn(`[injectVariablesSync] variable "${name}" referenced but not found in "${key}"`) + return match + } + + // Normalize paths for string values + return typeof nestedValue === "string" ? nestedValue.toPosix() : nestedValue + }) + } + } + + return result +} + +/** + * Synchronously interpolates VS Code-style variables in custom headers. + * + * Supports the following variables: + * - ${workspaceFolder} - Full path to the workspace root + * - ${workspaceFolderBasename} - Just the folder name (e.g., "my-repo-name") + * - ${env:VAR_NAME} - Environment variables + * + * This function is synchronous to be compatible with provider constructors. + * + * @param headers The headers object to interpolate (can be undefined) + * @returns The interpolated headers object, or undefined if input was undefined + */ +export function interpolateHeaders(headers: Record | undefined): Record | undefined { + if (!headers || Object.keys(headers).length === 0) { + return headers + } + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? "" + const workspaceFolderBasename = workspaceFolder ? path.basename(workspaceFolder) : "" + + const variables = { + workspaceFolder, + workspaceFolderBasename, + env: process.env as Record, + } + + const result: Record = {} + for (const [headerKey, headerValue] of Object.entries(headers)) { + result[headerKey] = injectVariablesSync(headerValue, variables) + } + + return result +}