Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warm-taxes-build.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion src/api/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -542,9 +543,11 @@ export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiH
}

const config: Record<string, any> = {}
// Interpolate variables in custom headers (e.g., ${workspaceFolderBasename}, ${env:VAR_NAME})
const interpolatedHeaders = interpolateHeaders(openAiHeaders)
const headers: Record<string, string> = {
...DEFAULT_HEADERS,
...(openAiHeaders || {}),
...(interpolatedHeaders || {}),
}

if (apiKey) {
Expand Down
119 changes: 118 additions & 1 deletion src/utils/__tests__/config.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
})
})
})
77 changes: 77 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import * as vscode from "vscode"
import * as path from "path"

export type InjectableConfigType =
| string
| {
Expand Down Expand Up @@ -64,3 +67,77 @@ export async function injectVariables<C extends InjectableConfigType>(

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, undefined | null | string | Record<string, undefined | null | string>>,
): 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<string, string> | undefined): Record<string, string> | 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<string, string>,
}

const result: Record<string, string> = {}
for (const [headerKey, headerValue] of Object.entries(headers)) {
result[headerKey] = injectVariablesSync(headerValue, variables)
}

return result
}
Loading