mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
cfb6041648
commit
f9abbd15df
5 changed files with 211 additions and 2 deletions
5
.changeset/warm-taxes-build.md
Normal file
5
.changeset/warm-taxes-build.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue