feat: support workspace variable interpolation in Provider Custom Headers

- Add interpolateHeaders function to src/utils/config.ts
- Supports ${workspaceFolder}, ${workspaceFolderBasename}, and ${env:VAR_NAME}
- Integrate into buildApiHandler for all API providers
- Update getOpenAiModels to interpolate headers when fetching models
- Add comprehensive tests for the new functionality

Closes #11165
This commit is contained in:
Roo Code 2026-02-02 20:41:57 +00:00
parent cfb6041648
commit f9abbd15df
5 changed files with 211 additions and 2 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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<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) {

View file

@ -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",
})
})
})

View file

@ -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<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
}