chore: import upstream snapshot with attribution
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
// Dedicated entry for OpenCode's plugin loader.
|
||||
//
|
||||
// OpenCode loads a plugin module and treats its exports as plugin factories —
|
||||
// it rejects the module if a non-function export is present ("Plugin export is
|
||||
// not a function"). The library barrel (index.ts) re-exports helpers/constants,
|
||||
// so it cannot be loaded directly. This entry exports ONLY the plugin function.
|
||||
export { HeadroomPlugin as default } from "./plugin.js";
|
||||
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_MODELS,
|
||||
buildOpencodeConfigContent,
|
||||
buildOpencodeConfigContentJson,
|
||||
createHeadroomProvider,
|
||||
} from "./provider.js";
|
||||
export type {
|
||||
HeadroomModelMapping,
|
||||
HeadroomProvider,
|
||||
HeadroomProviderOptions,
|
||||
} from "./provider.js";
|
||||
export {
|
||||
compressWithHeadroom,
|
||||
createHeadroomRetrieveTool,
|
||||
getDefaultProxyUrl,
|
||||
setDefaultProxyUrl,
|
||||
} from "./retrieve.js";
|
||||
export type { RetrieveToolConfig } from "./retrieve.js";
|
||||
export { HeadroomPlugin, default } from "./plugin.js";
|
||||
export type { HeadroomOpenCodePluginOptions } from "./plugin.js";
|
||||
|
||||
export { installHeadroomTransport } from "./transport.js";
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HeadroomPlugin } from "./plugin.js";
|
||||
|
||||
function pluginInput() {
|
||||
return {
|
||||
client: {},
|
||||
project: { id: "project-1" },
|
||||
directory: "/repo",
|
||||
worktree: "/repo",
|
||||
experimental_workspace: {
|
||||
register: vi.fn(),
|
||||
},
|
||||
$: {},
|
||||
} as never;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("HeadroomPlugin", () => {
|
||||
it("adds only Headroom metadata to shell env", async () => {
|
||||
const plugin = await HeadroomPlugin(pluginInput(), {
|
||||
proxyUrl: "http://127.0.0.1:8787/",
|
||||
backend: "litellm",
|
||||
});
|
||||
const output = {
|
||||
env: {
|
||||
OPENAI_BASE_URL: "https://deepseek.example/v1",
|
||||
ANTHROPIC_BASE_URL: "https://anthropic.example",
|
||||
},
|
||||
};
|
||||
|
||||
await plugin["shell.env"]?.({ cwd: "/repo" }, output);
|
||||
|
||||
expect(output.env).toMatchObject({
|
||||
HEADROOM_ACTIVE: "1",
|
||||
HEADROOM_PROXY_URL: "http://127.0.0.1:8787",
|
||||
HEADROOM_PROJECT: "project-1",
|
||||
HEADROOM_BACKEND: "litellm",
|
||||
OPENAI_BASE_URL: "https://deepseek.example/v1",
|
||||
ANTHROPIC_BASE_URL: "https://anthropic.example",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes a headroom_retrieve tool backed by the proxy", async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => "original content",
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const plugin = await HeadroomPlugin(pluginInput(), {
|
||||
proxyUrl: "http://127.0.0.1:8787",
|
||||
});
|
||||
const result = await plugin.tool?.headroom_retrieve.execute(
|
||||
{ hash: "0123456789abcdef01234567" },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result).toBe("original content");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:8787/v1/retrieve/0123456789abcdef01234567",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createHeadroomRetrieveTool, getDefaultProxyUrl } from "./retrieve.js";
|
||||
import { installHeadroomTransport } from "./transport.js";
|
||||
|
||||
export interface HeadroomOpenCodePluginOptions {
|
||||
proxyUrl?: string;
|
||||
project?: string;
|
||||
backend?: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
function normalizeProxyUrl(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function resolveProxyUrl(options?: HeadroomOpenCodePluginOptions): string {
|
||||
return normalizeProxyUrl(
|
||||
options?.proxyUrl ??
|
||||
process.env.HEADROOM_PROXY_URL ??
|
||||
process.env.HEADROOM_BASE_URL ??
|
||||
getDefaultProxyUrl(),
|
||||
);
|
||||
}
|
||||
|
||||
export const HeadroomPlugin: Plugin = async (input, options = {}) => {
|
||||
const pluginOptions = options as HeadroomOpenCodePluginOptions;
|
||||
const proxyUrl = resolveProxyUrl(pluginOptions);
|
||||
const retrieveTool = createHeadroomRetrieveTool({ proxyBaseUrl: proxyUrl });
|
||||
const uninstallTransport = installHeadroomTransport({
|
||||
proxyUrl,
|
||||
debug: pluginOptions.debug,
|
||||
});
|
||||
|
||||
return {
|
||||
dispose: async () => {
|
||||
uninstallTransport();
|
||||
},
|
||||
tool: {
|
||||
headroom_retrieve: tool({
|
||||
description: retrieveTool.description,
|
||||
args: {
|
||||
hash: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{24}$/i, "Expected 24-character hex hash"),
|
||||
},
|
||||
async execute(args) {
|
||||
return retrieveTool.execute(args);
|
||||
},
|
||||
}),
|
||||
},
|
||||
"shell.env": async (_input, output) => {
|
||||
output.env.HEADROOM_ACTIVE = "1";
|
||||
output.env.HEADROOM_PROXY_URL = proxyUrl;
|
||||
output.env.HEADROOM_PROJECT =
|
||||
pluginOptions.project ??
|
||||
(input.project as { id?: string }).id ??
|
||||
input.directory;
|
||||
if (pluginOptions.backend) {
|
||||
output.env.HEADROOM_BACKEND = pluginOptions.backend;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default HeadroomPlugin;
|
||||
@@ -0,0 +1,89 @@
|
||||
export interface HeadroomModelMapping {
|
||||
name: string;
|
||||
limit: {
|
||||
context: number;
|
||||
output: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HeadroomProviderOptions {
|
||||
proxyBaseUrl?: string;
|
||||
proxyPort?: number;
|
||||
defaultModel?: string;
|
||||
models?: Record<string, HeadroomModelMapping>;
|
||||
}
|
||||
|
||||
export const DEFAULT_MODELS: Record<string, HeadroomModelMapping> = {
|
||||
"claude-sonnet-4-6": {
|
||||
name: "Claude Sonnet 4.6",
|
||||
limit: { context: 200000, output: 16384 },
|
||||
},
|
||||
"claude-opus-4-6": {
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200000, output: 16384 },
|
||||
},
|
||||
"claude-haiku-4-5-20251001": {
|
||||
name: "Claude Haiku 4.5",
|
||||
limit: { context: 200000, output: 8192 },
|
||||
},
|
||||
"gpt-4o": {
|
||||
name: "GPT-4o",
|
||||
limit: { context: 128000, output: 16384 },
|
||||
},
|
||||
"gpt-4.1": {
|
||||
name: "GPT-4.1",
|
||||
limit: { context: 1048576, output: 32768 },
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_MODEL = "claude-sonnet-4-6";
|
||||
|
||||
function resolveBaseUrl(options: HeadroomProviderOptions): string {
|
||||
if (options.proxyBaseUrl) return options.proxyBaseUrl.replace(/\/+$/, "");
|
||||
const port = options.proxyPort ?? 8787;
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
export interface HeadroomProvider {
|
||||
npm: string;
|
||||
name: string;
|
||||
options: {
|
||||
baseURL: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
models: Record<string, HeadroomModelMapping>;
|
||||
}
|
||||
|
||||
export function createHeadroomProvider(
|
||||
options: HeadroomProviderOptions = {},
|
||||
): HeadroomProvider {
|
||||
const baseUrl = resolveBaseUrl(options);
|
||||
const models = options.models ?? DEFAULT_MODELS;
|
||||
|
||||
return {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Headroom Proxy",
|
||||
options: { baseURL: `${baseUrl}/v1` },
|
||||
// OpenCode namespaces model ids by provider key, so entries must be bare
|
||||
// ids ("claude-sonnet-4-6"), referenced as "headroom/<id>".
|
||||
models: { ...models },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpencodeConfigContent(
|
||||
options: HeadroomProviderOptions = {},
|
||||
): Record<string, unknown> {
|
||||
const defaultModel = options.defaultModel ?? DEFAULT_MODEL;
|
||||
const provider = createHeadroomProvider(options);
|
||||
|
||||
return {
|
||||
provider: { headroom: provider },
|
||||
model: `headroom/${defaultModel}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpencodeConfigContentJson(
|
||||
options: HeadroomProviderOptions = {},
|
||||
): string {
|
||||
return JSON.stringify(buildOpencodeConfigContent(options));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { CompressResult } from "headroom-ai";
|
||||
import { compress } from "headroom-ai";
|
||||
|
||||
let _proxyUrlCache: string | null = null;
|
||||
|
||||
export function setDefaultProxyUrl(url: string): void {
|
||||
_proxyUrlCache = url;
|
||||
}
|
||||
|
||||
export function getDefaultProxyUrl(): string {
|
||||
return _proxyUrlCache ?? process.env.HEADROOM_BASE_URL ?? "http://localhost:8787";
|
||||
}
|
||||
|
||||
export interface RetrieveToolConfig {
|
||||
proxyBaseUrl: string;
|
||||
}
|
||||
|
||||
export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
|
||||
const origin = config.proxyBaseUrl.replace(/\/+$/, "");
|
||||
|
||||
return {
|
||||
name: "headroom_retrieve",
|
||||
description:
|
||||
"Retrieve original uncompressed content from Headroom's compression store. " +
|
||||
"Use when compressed context mentions a hash and you need the full details. " +
|
||||
"Pass the hash from the compression marker (24 hex characters). " +
|
||||
"Retrieval is by hash and always returns the full original content.",
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
hash: {
|
||||
type: "string",
|
||||
description: "The 24-character hex hash from the compression marker",
|
||||
},
|
||||
},
|
||||
required: ["hash"],
|
||||
},
|
||||
execute: async (args: { hash: string }): Promise<string> => {
|
||||
const { hash } = args;
|
||||
|
||||
if (!/^[a-f0-9]{24}$/i.test(hash)) {
|
||||
return JSON.stringify({
|
||||
error: "Invalid hash format. Expected 24 hex characters.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${origin}/v1/retrieve/${hash}`;
|
||||
|
||||
const resp = await fetch(url, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => "");
|
||||
return JSON.stringify({
|
||||
error: `Retrieval failed: HTTP ${resp.status}`,
|
||||
details: body,
|
||||
});
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
return typeof data === "string" ? data : JSON.stringify(data);
|
||||
} catch (error) {
|
||||
return JSON.stringify({
|
||||
error: `Retrieval failed: ${error}`,
|
||||
hint: "The compressed content may have expired (default TTL: 5 minutes)",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function compressWithHeadroom(
|
||||
messages: unknown[],
|
||||
options: {
|
||||
model?: string;
|
||||
tokenBudget?: number;
|
||||
proxyUrl?: string;
|
||||
} = {},
|
||||
): Promise<CompressResult> {
|
||||
return compress(messages, {
|
||||
baseUrl: options.proxyUrl ?? getDefaultProxyUrl(),
|
||||
model: options.model ?? "gpt-4o",
|
||||
tokenBudget: options.tokenBudget,
|
||||
stack: "opencode",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import childProcess from "node:child_process";
|
||||
import http from "node:http";
|
||||
import http2 from "node:http2";
|
||||
import https from "node:https";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { installHeadroomTransport, uninstallHeadroomTransport } from "./transport.js";
|
||||
|
||||
afterEach(() => {
|
||||
uninstallHeadroomTransport();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
type FetchCall = [RequestInfo | URL, RequestInit?];
|
||||
|
||||
type SeenRequest = {
|
||||
method: string | undefined;
|
||||
url: string | undefined;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function proxyServer(pathPrefix: string = "/v1"): Promise<{
|
||||
url: string;
|
||||
seen: SeenRequest[];
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const seen: SeenRequest[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
seen.push({ method: req.method, url: req.url, headers: req.headers, body });
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end("{\"ok\":true}");
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("Expected TCP server address"));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${address.port}${pathPrefix}`,
|
||||
seen,
|
||||
close: () => new Promise((done) => server.close(() => done())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("Headroom OpenCode transport", () => {
|
||||
it("routes fetch chat paths through /v1/chat/completions with proxy base and normalized-path header", async () => {
|
||||
const proxyTargets = ["http://127.0.0.1:8787", "http://127.0.0.1:8787/v1"];
|
||||
const upstreamPath = "/api/coding/paas/v4/chat/completions";
|
||||
for (const proxyUrl of proxyTargets) {
|
||||
const proxyOrigin = new URL(proxyUrl).origin;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
installHeadroomTransport({ proxyUrl });
|
||||
|
||||
await fetch(`https://open.bigmodel.cn${upstreamPath}`, { method: "POST", headers: { "content-type": "application/json" } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toEqual(new URL(`${proxyOrigin}/v1/chat/completions`));
|
||||
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
|
||||
expect(headers.get("x-headroom-base-url")).toBe("https://open.bigmodel.cn");
|
||||
expect(headers.get("x-headroom-original-path")).toBe(upstreamPath);
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
uninstallHeadroomTransport();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes fetch responses paths through /v1/responses with proxy base and normalized-path header", async () => {
|
||||
const proxyTargets = ["http://127.0.0.1:8787", "http://127.0.0.1:8787/v1"];
|
||||
const upstreamPath = "/api/coding/paas/v4/responses";
|
||||
for (const proxyUrl of proxyTargets) {
|
||||
const proxyOrigin = new URL(proxyUrl).origin;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
installHeadroomTransport({ proxyUrl });
|
||||
|
||||
await fetch(`https://open.bigmodel.cn${upstreamPath}`, { method: "POST", headers: { "content-type": "application/json" } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toEqual(new URL(`${proxyOrigin}/v1/responses`));
|
||||
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
|
||||
expect(headers.get("x-headroom-base-url")).toBe("https://open.bigmodel.cn");
|
||||
expect(headers.get("x-headroom-original-path")).toBe(upstreamPath);
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
uninstallHeadroomTransport();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes external fetch calls through the proxy without pre-registering providers", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
|
||||
await fetch("https://api.deepseek.com/v1/chat/completions?x=1", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
await fetch("https://new-provider.example/base/v1/messages", { method: "POST" });
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
new URL("http://127.0.0.1:8787/v1/chat/completions?x=1"),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("x-headroom-base-url")).toBe(
|
||||
"https://api.deepseek.com",
|
||||
);
|
||||
expect(fetchMock.mock.calls[1][0]).toEqual(new URL("http://127.0.0.1:8787/base/v1/messages"));
|
||||
expect(new Headers(fetchMock.mock.calls[1][1]?.headers).get("x-headroom-base-url")).toBe(
|
||||
"https://new-provider.example",
|
||||
);
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("preserves non-prefix paths like /base/v1/messages", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
|
||||
await fetch("https://example.test/base/v1/messages", { method: "POST" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toEqual(new URL("http://127.0.0.1:8787/base/v1/messages"));
|
||||
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("x-headroom-original-path")).toBeNull();
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("bypasses local, OpenCode, and Headroom proxy fetch URLs", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (..._args: FetchCall) => new Response("ok"));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
|
||||
await fetch("http://127.0.0.1:8787/v1/retrieve");
|
||||
await fetch("http://localhost:4096/config");
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:8787/v1/retrieve");
|
||||
expect(fetchMock.mock.calls[1][0]).toBe("http://localhost:4096/config");
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("routes external https.request calls through the proxy", async () => {
|
||||
const proxy = await proxyServer();
|
||||
installHeadroomTransport({ proxyUrl: proxy.url });
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = https.request(
|
||||
"https://api.anthropic.com/v1/messages?beta=1",
|
||||
{ method: "POST", headers: { authorization: "Bearer test" } },
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", resolve);
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end("{\"model\":\"claude\"}");
|
||||
});
|
||||
|
||||
expect(proxy.seen).toHaveLength(1);
|
||||
expect(proxy.seen[0]).toMatchObject({ method: "POST", url: "/v1/messages?beta=1" });
|
||||
expect(proxy.seen[0].headers["x-headroom-base-url"]).toBe("https://api.anthropic.com");
|
||||
expect(proxy.seen[0].headers.host).toMatch(/^127\.0\.0\.1:/);
|
||||
expect(proxy.seen[0].body).toBe("{\"model\":\"claude\"}");
|
||||
|
||||
await proxy.close();
|
||||
});
|
||||
|
||||
it("normalizes Node HTTP(S) requests for /chat/completions and /responses", async () => {
|
||||
const proxy = await proxyServer("");
|
||||
installHeadroomTransport({ proxyUrl: proxy.url });
|
||||
const httpChatPath = "/api/coding/paas/v4/chat/completions";
|
||||
const httpResponsesPath = "/api/coding/paas/v4/responses";
|
||||
const httpsChatPath = "/v4/openai/chat/completions";
|
||||
const httpsResponsesPath = "/v4/openai/responses";
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
`http://open.bigmodel.cn${httpChatPath}`,
|
||||
{ method: "POST", headers: { authorization: "Bearer test" } },
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", resolve);
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end("{\"model\":\"gpt-4\"}");
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
`http://open.bigmodel.cn${httpResponsesPath}`,
|
||||
{ method: "POST", headers: { authorization: "Bearer test" } },
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", resolve);
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end("{\"model\":\"gpt-4\"}");
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = https.request(
|
||||
`https://api.deepseek.com${httpsChatPath}`,
|
||||
{ method: "POST", headers: { authorization: "Bearer test" } },
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", resolve);
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end("{\"model\":\"gpt-4\"}");
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = https.request(
|
||||
`https://api.deepseek.com${httpsResponsesPath}`,
|
||||
{ method: "POST", headers: { authorization: "Bearer test" } },
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", resolve);
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end("{\"model\":\"gpt-4\"}");
|
||||
});
|
||||
|
||||
expect(proxy.seen[0]).toMatchObject({
|
||||
method: "POST",
|
||||
url: "/v1/chat/completions",
|
||||
headers: expect.objectContaining({
|
||||
"x-headroom-base-url": "http://open.bigmodel.cn",
|
||||
"x-headroom-original-path": httpChatPath,
|
||||
}),
|
||||
});
|
||||
expect(proxy.seen[1]).toMatchObject({
|
||||
method: "POST",
|
||||
url: "/v1/responses",
|
||||
headers: expect.objectContaining({
|
||||
"x-headroom-base-url": "http://open.bigmodel.cn",
|
||||
"x-headroom-original-path": httpResponsesPath,
|
||||
}),
|
||||
});
|
||||
expect(proxy.seen[2]).toMatchObject({
|
||||
method: "POST",
|
||||
url: "/v1/chat/completions",
|
||||
headers: expect.objectContaining({
|
||||
"x-headroom-base-url": "https://api.deepseek.com",
|
||||
"x-headroom-original-path": httpsChatPath,
|
||||
}),
|
||||
});
|
||||
expect(proxy.seen[3]).toMatchObject({
|
||||
method: "POST",
|
||||
url: "/v1/responses",
|
||||
headers: expect.objectContaining({
|
||||
"x-headroom-base-url": "https://api.deepseek.com",
|
||||
"x-headroom-original-path": httpsResponsesPath,
|
||||
}),
|
||||
});
|
||||
|
||||
await proxy.close();
|
||||
});
|
||||
|
||||
it("blocks external http2 connections instead of leaking them", () => {
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
|
||||
expect(() => http2.connect("https://api.openai.com")).toThrow(
|
||||
/blocked direct HTTP\/2 connection to https:\/\/api\.openai\.com/,
|
||||
);
|
||||
});
|
||||
|
||||
it("preloads the Headroom shim into child Node processes", () => {
|
||||
const originalNodeOptions = process.env.NODE_OPTIONS;
|
||||
const originalProxyUrl = process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
|
||||
|
||||
try {
|
||||
process.env.NODE_OPTIONS = "--trace-warnings";
|
||||
delete process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
|
||||
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
|
||||
expect(process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL).toBe("http://127.0.0.1:8787/v1");
|
||||
expect(process.env.NODE_OPTIONS).toContain("--trace-warnings");
|
||||
expect(process.env.NODE_OPTIONS).toContain("--import=file:");
|
||||
expect(process.env.NODE_OPTIONS).toContain("/hook-shim/handler.js");
|
||||
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
expect(process.env.NODE_OPTIONS?.match(/hook-shim\/handler\.js/g)).toHaveLength(1);
|
||||
} finally {
|
||||
if (originalNodeOptions === undefined) {
|
||||
delete process.env.NODE_OPTIONS;
|
||||
} else {
|
||||
process.env.NODE_OPTIONS = originalNodeOptions;
|
||||
}
|
||||
if (originalProxyUrl === undefined) {
|
||||
delete process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL;
|
||||
} else {
|
||||
process.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL = originalProxyUrl;
|
||||
}
|
||||
uninstallHeadroomTransport();
|
||||
}
|
||||
});
|
||||
|
||||
it("injects the Headroom shim into child processes with custom env", () => {
|
||||
const originalSpawn = childProcess.spawn;
|
||||
const spawnMock = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
killed: false,
|
||||
pid: 123,
|
||||
}));
|
||||
childProcess.spawn = spawnMock as unknown as typeof childProcess.spawn;
|
||||
|
||||
try {
|
||||
installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
childProcess.spawn("node", ["agent.js"], { env: { PATH: "/bin", NODE_OPTIONS: "--trace-warnings" } });
|
||||
|
||||
const options = (spawnMock.mock.calls[0] as unknown[])[2] as { env: NodeJS.ProcessEnv };
|
||||
expect(options.env.PATH).toBe("/bin");
|
||||
expect(options.env.HEADROOM_OPENCODE_TRANSPORT_PROXY_URL).toBe("http://127.0.0.1:8787/v1");
|
||||
expect(options.env.NODE_OPTIONS).toContain("--trace-warnings");
|
||||
expect(options.env.NODE_OPTIONS).toContain("--import=file:");
|
||||
expect(options.env.NODE_OPTIONS).toContain("/hook-shim/handler.js");
|
||||
} finally {
|
||||
uninstallHeadroomTransport();
|
||||
childProcess.spawn = originalSpawn;
|
||||
}
|
||||
});
|
||||
|
||||
it("restores patched transports only after the final disposer", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalHttpRequest = http.request;
|
||||
const originalHttpsRequest = https.request;
|
||||
const firstDispose = installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8787/v1" });
|
||||
const secondDispose = installHeadroomTransport({ proxyUrl: "http://127.0.0.1:8788/v1" });
|
||||
|
||||
expect(globalThis.fetch).not.toBe(originalFetch);
|
||||
expect(http.request).not.toBe(originalHttpRequest);
|
||||
expect(https.request).not.toBe(originalHttpsRequest);
|
||||
|
||||
firstDispose();
|
||||
expect(globalThis.fetch).not.toBe(originalFetch);
|
||||
expect(http.request).not.toBe(originalHttpRequest);
|
||||
|
||||
secondDispose();
|
||||
expect(globalThis.fetch).toBe(originalFetch);
|
||||
expect(http.request).toBe(originalHttpRequest);
|
||||
expect(https.request).toBe(originalHttpsRequest);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,479 @@
|
||||
import { createRequire, syncBuiltinESMExports } from "node:module";
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
const http = nodeRequire("node:http") as typeof import("node:http");
|
||||
const https = nodeRequire("node:https") as typeof import("node:https");
|
||||
const http2 = nodeRequire("node:http2") as typeof import("node:http2");
|
||||
const childProcess = nodeRequire("node:child_process") as typeof import("node:child_process");
|
||||
|
||||
const BASE_URL_HEADER = "x-headroom-base-url";
|
||||
const ORIGINAL_PATH_HEADER = "x-headroom-original-path";
|
||||
const PROXY_ENV = "HEADROOM_OPENCODE_TRANSPORT_PROXY_URL";
|
||||
const STATE_KEY = Symbol.for("headroom.opencode.transport");
|
||||
|
||||
type FetchArgs = Parameters<typeof fetch>;
|
||||
type HttpRequest = typeof http.request;
|
||||
type HttpGet = typeof http.get;
|
||||
type HttpsRequest = typeof https.request;
|
||||
type HttpsGet = typeof https.get;
|
||||
type Http2Connect = typeof http2.connect;
|
||||
type ChildSpawn = typeof childProcess.spawn;
|
||||
type ChildExec = typeof childProcess.exec;
|
||||
type ChildExecFile = typeof childProcess.execFile;
|
||||
type ChildFork = typeof childProcess.fork;
|
||||
|
||||
interface InstallOptions {
|
||||
proxyUrl: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
interface TransportState {
|
||||
refs: number;
|
||||
proxyUrl: string;
|
||||
debug: boolean;
|
||||
originalFetch: typeof fetch;
|
||||
originalHttpRequest: HttpRequest;
|
||||
originalHttpGet: HttpGet;
|
||||
originalHttpsRequest: HttpsRequest;
|
||||
originalHttpsGet: HttpsGet;
|
||||
originalHttp2Connect: Http2Connect;
|
||||
originalChildSpawn: ChildSpawn;
|
||||
originalChildExec: ChildExec;
|
||||
originalChildExecFile: ChildExecFile;
|
||||
originalChildFork: ChildFork;
|
||||
}
|
||||
|
||||
interface GlobalWithHeadroomTransport {
|
||||
[STATE_KEY]?: TransportState;
|
||||
}
|
||||
|
||||
interface NodeRequestParts {
|
||||
url?: URL;
|
||||
options: Record<string, unknown>;
|
||||
callback?: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function getState(): TransportState | undefined {
|
||||
return (globalThis as GlobalWithHeadroomTransport)[STATE_KEY];
|
||||
}
|
||||
|
||||
function setState(state: TransportState | undefined): void {
|
||||
(globalThis as GlobalWithHeadroomTransport)[STATE_KEY] = state;
|
||||
}
|
||||
|
||||
function shimImportSpecifier(): string {
|
||||
return new URL("../hook-shim/handler.js", import.meta.url).href;
|
||||
}
|
||||
|
||||
function withNodeImportOption(existing: string | undefined, shim: string): string {
|
||||
const parts = existing?.trim() ? existing.trim().split(/\s+/) : [];
|
||||
const alreadyPresent = parts.some((part, index) => {
|
||||
return part === `--import=${shim}` || (part === "--import" && parts[index + 1] === shim);
|
||||
});
|
||||
if (!alreadyPresent) {
|
||||
parts.push(`--import=${shim}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function withShimEnv(env: NodeJS.ProcessEnv | Record<string, unknown> | undefined, proxyUrl: string): NodeJS.ProcessEnv {
|
||||
const nextEnv = { ...(env ?? process.env) } as NodeJS.ProcessEnv;
|
||||
nextEnv[PROXY_ENV] = proxyUrl;
|
||||
nextEnv.NODE_OPTIONS = withNodeImportOption(nextEnv.NODE_OPTIONS, shimImportSpecifier());
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
function installProcessEnv(proxyUrl: string): void {
|
||||
process.env[PROXY_ENV] = proxyUrl;
|
||||
process.env.NODE_OPTIONS = withNodeImportOption(process.env.NODE_OPTIONS, shimImportSpecifier());
|
||||
}
|
||||
|
||||
function isOptions(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value) && !(value instanceof URL);
|
||||
}
|
||||
|
||||
function injectOptionsEnv(args: unknown[], optionIndex: number, proxyUrl: string): unknown[] {
|
||||
const nextArgs = [...args];
|
||||
const callback = typeof nextArgs.at(-1) === "function" ? nextArgs.pop() : undefined;
|
||||
const existing = isOptions(nextArgs[optionIndex]) ? { ...(nextArgs[optionIndex] as Record<string, unknown>) } : {};
|
||||
existing.env = withShimEnv(existing.env as NodeJS.ProcessEnv | undefined, proxyUrl);
|
||||
|
||||
if (isOptions(nextArgs[optionIndex])) {
|
||||
nextArgs[optionIndex] = existing;
|
||||
} else {
|
||||
nextArgs.splice(optionIndex, 0, existing);
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
nextArgs.push(callback);
|
||||
}
|
||||
return nextArgs;
|
||||
}
|
||||
|
||||
function wrapSpawn(originalSpawn: ChildSpawn): ChildSpawn {
|
||||
return function headroomSpawn(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalSpawn, this, args);
|
||||
}
|
||||
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
|
||||
return Reflect.apply(originalSpawn, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
|
||||
} as ChildSpawn;
|
||||
}
|
||||
|
||||
function wrapExec(originalExec: ChildExec): ChildExec {
|
||||
return function headroomExec(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalExec, this, args);
|
||||
}
|
||||
return Reflect.apply(originalExec, this, injectOptionsEnv(args, 1, state.proxyUrl));
|
||||
} as ChildExec;
|
||||
}
|
||||
|
||||
function wrapExecFile(originalExecFile: ChildExecFile): ChildExecFile {
|
||||
return function headroomExecFile(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalExecFile, this, args);
|
||||
}
|
||||
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
|
||||
return Reflect.apply(originalExecFile, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
|
||||
} as ChildExecFile;
|
||||
}
|
||||
|
||||
function wrapFork(originalFork: ChildFork): ChildFork {
|
||||
return function headroomFork(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalFork, this, args);
|
||||
}
|
||||
const optionIndex = Array.isArray(args[1]) ? 2 : 1;
|
||||
return Reflect.apply(originalFork, this, injectOptionsEnv(args, optionIndex, state.proxyUrl));
|
||||
} as ChildFork;
|
||||
}
|
||||
|
||||
function normalizeProxyUrl(proxyUrl: string): URL {
|
||||
return new URL(proxyUrl);
|
||||
}
|
||||
|
||||
function isLoopback(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||
}
|
||||
|
||||
function shouldRoute(url: URL, proxy: URL): boolean {
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
if (isLoopback(url.hostname)) {
|
||||
return false;
|
||||
}
|
||||
if (url.origin === proxy.origin) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function routedUrl(upstream: URL, proxy: URL): URL {
|
||||
return new URL(`${upstream.pathname}${upstream.search}`, proxy.origin);
|
||||
}
|
||||
|
||||
function normalizedOpenAiProxyPath(pathname: string): string | undefined {
|
||||
if (pathname.endsWith("/chat/completions")) {
|
||||
return "/v1/chat/completions";
|
||||
}
|
||||
if (pathname.endsWith("/responses")) {
|
||||
return "/v1/responses";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function routedUrlForOpenCode(upstream: URL, proxy: URL): { url: URL; originalPath: string | undefined } {
|
||||
const normalizedPath = normalizedOpenAiProxyPath(upstream.pathname);
|
||||
if (!normalizedPath) {
|
||||
return {
|
||||
url: routedUrl(upstream, proxy),
|
||||
originalPath: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: new URL(`${normalizedPath}${upstream.search}`, proxy.origin),
|
||||
originalPath: upstream.pathname,
|
||||
};
|
||||
}
|
||||
|
||||
function requestUrl(input: RequestInfo | URL): URL {
|
||||
if (input instanceof Request) {
|
||||
return new URL(input.url);
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input;
|
||||
}
|
||||
return new URL(String(input));
|
||||
}
|
||||
|
||||
function mergeFetchHeaders(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
upstream: URL | undefined,
|
||||
originalPath: string | undefined = undefined,
|
||||
): Headers {
|
||||
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
||||
if (init?.headers) {
|
||||
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
||||
}
|
||||
if (upstream) {
|
||||
headers.set(BASE_URL_HEADER, upstream.origin);
|
||||
headers.delete("host");
|
||||
}
|
||||
if (originalPath) {
|
||||
headers.set(ORIGINAL_PATH_HEADER, originalPath);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function withRoutedFetchInput(input: RequestInfo | URL, init: RequestInit | undefined, proxy: URL): FetchArgs {
|
||||
const upstream = requestUrl(input);
|
||||
if (!shouldRoute(upstream, proxy)) {
|
||||
return [input, init];
|
||||
}
|
||||
|
||||
const { url: nextUrl, originalPath } = routedUrlForOpenCode(upstream, proxy);
|
||||
const nextInit = {
|
||||
...init,
|
||||
headers: mergeFetchHeaders(input, init, upstream, originalPath),
|
||||
};
|
||||
|
||||
if (input instanceof Request) {
|
||||
return [new Request(nextUrl, input), nextInit];
|
||||
}
|
||||
return [nextUrl, nextInit];
|
||||
}
|
||||
|
||||
function splitNodeArgs(args: unknown[]): NodeRequestParts {
|
||||
const callback = typeof args.at(-1) === "function" ? (args.at(-1) as (...args: unknown[]) => unknown) : undefined;
|
||||
const withoutCallback = callback ? args.slice(0, -1) : args;
|
||||
const [first, second] = withoutCallback;
|
||||
const options = typeof second === "object" && second !== null ? { ...(second as Record<string, unknown>) } : {};
|
||||
|
||||
if (first instanceof URL) {
|
||||
return { url: first, options, callback };
|
||||
}
|
||||
if (typeof first === "string") {
|
||||
try {
|
||||
return { url: new URL(first), options, callback };
|
||||
} catch {
|
||||
return { options, callback };
|
||||
}
|
||||
}
|
||||
if (typeof first === "object" && first !== null) {
|
||||
const requestOptions = { ...(first as Record<string, unknown>), ...options };
|
||||
return { url: urlFromRequestOptions(requestOptions), options: requestOptions, callback };
|
||||
}
|
||||
return { options, callback };
|
||||
}
|
||||
|
||||
function urlFromRequestOptions(options: Record<string, unknown>): URL | undefined {
|
||||
const protocol = String(options.protocol ?? "http:");
|
||||
if (protocol !== "http:" && protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hostValue = options.hostname ?? options.host;
|
||||
if (!hostValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hostname = String(hostValue).replace(/:\d+$/, "");
|
||||
const port = options.port ? `:${String(options.port)}` : "";
|
||||
const path = String(options.path ?? "/");
|
||||
try {
|
||||
return new URL(`${protocol}//${hostname}${port}${path}`);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function headersForNodeRequest(
|
||||
options: Record<string, unknown>,
|
||||
upstream: URL,
|
||||
originalPath: string | undefined,
|
||||
): Record<string, string> {
|
||||
const headers = new Headers(options.headers as HeadersInit | undefined);
|
||||
headers.set(BASE_URL_HEADER, upstream.origin);
|
||||
if (originalPath) {
|
||||
headers.set(ORIGINAL_PATH_HEADER, originalPath);
|
||||
}
|
||||
headers.delete("host");
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function routedNodeOptions(parts: NodeRequestParts, proxy: URL): Record<string, unknown> | undefined {
|
||||
if (!parts.url || !shouldRoute(parts.url, proxy)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { url: nextUrl, originalPath } = routedUrlForOpenCode(parts.url, proxy);
|
||||
const {
|
||||
agent: _agent,
|
||||
auth: _auth,
|
||||
createConnection: _createConnection,
|
||||
defaultPort: _defaultPort,
|
||||
family: _family,
|
||||
headers: _headers,
|
||||
host: _host,
|
||||
hostname: _hostname,
|
||||
href: _href,
|
||||
lookup: _lookup,
|
||||
path: _path,
|
||||
pathname: _pathname,
|
||||
port: _port,
|
||||
protocol: _protocol,
|
||||
search: _search,
|
||||
servername: _servername,
|
||||
setHost: _setHost,
|
||||
...rest
|
||||
} = parts.options;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
protocol: nextUrl.protocol,
|
||||
hostname: nextUrl.hostname,
|
||||
port: nextUrl.port || undefined,
|
||||
path: `${nextUrl.pathname}${nextUrl.search}`,
|
||||
headers: headersForNodeRequest(parts.options, parts.url, originalPath),
|
||||
};
|
||||
}
|
||||
|
||||
function wrapRequest(
|
||||
originalHttpRequest: HttpRequest,
|
||||
originalHttpsRequest: HttpsRequest,
|
||||
originalRequest: HttpRequest | HttpsRequest,
|
||||
): HttpRequest | HttpsRequest {
|
||||
return function headroomRequest(this: unknown, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return Reflect.apply(originalRequest, this, args);
|
||||
}
|
||||
|
||||
const proxy = normalizeProxyUrl(state.proxyUrl);
|
||||
const parts = splitNodeArgs(args);
|
||||
const nextOptions = routedNodeOptions(parts, proxy);
|
||||
if (!nextOptions) {
|
||||
return Reflect.apply(originalRequest, this, args);
|
||||
}
|
||||
|
||||
const targetRequest = proxy.protocol === "https:" ? originalHttpsRequest : originalHttpRequest;
|
||||
const nextArgs = parts.callback ? [nextOptions, parts.callback] : [nextOptions];
|
||||
return Reflect.apply(targetRequest, this, nextArgs);
|
||||
} as HttpRequest | HttpsRequest;
|
||||
}
|
||||
|
||||
function wrapGet(request: HttpRequest | HttpsRequest): HttpGet | HttpsGet {
|
||||
return function headroomGet(this: unknown, ...args: unknown[]) {
|
||||
const req = Reflect.apply(request, this, args);
|
||||
req.end();
|
||||
return req;
|
||||
} as HttpGet | HttpsGet;
|
||||
}
|
||||
|
||||
function wrapHttp2Connect(originalConnect: Http2Connect): Http2Connect {
|
||||
return function headroomHttp2Connect(this: unknown, authority: string | URL, ...args: unknown[]) {
|
||||
const state = getState();
|
||||
if (state) {
|
||||
const proxy = normalizeProxyUrl(state.proxyUrl);
|
||||
const upstream = authority instanceof URL ? authority : new URL(String(authority));
|
||||
if (shouldRoute(upstream, proxy)) {
|
||||
throw new Error(
|
||||
`Headroom OpenCode wrap blocked direct HTTP/2 connection to ${upstream.origin}. ` +
|
||||
"Use fetch, http, or https so traffic can be routed through Headroom.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return Reflect.apply(originalConnect, this, [authority, ...args]);
|
||||
} as Http2Connect;
|
||||
}
|
||||
|
||||
export function installHeadroomTransport(options: InstallOptions): () => void {
|
||||
const existing = getState();
|
||||
if (existing) {
|
||||
existing.refs += 1;
|
||||
existing.proxyUrl = options.proxyUrl;
|
||||
existing.debug = Boolean(options.debug);
|
||||
installProcessEnv(options.proxyUrl);
|
||||
return () => uninstallHeadroomTransport();
|
||||
}
|
||||
|
||||
const state: TransportState = {
|
||||
refs: 1,
|
||||
proxyUrl: options.proxyUrl,
|
||||
debug: Boolean(options.debug),
|
||||
originalFetch: globalThis.fetch,
|
||||
originalHttpRequest: http.request,
|
||||
originalHttpGet: http.get,
|
||||
originalHttpsRequest: https.request,
|
||||
originalHttpsGet: https.get,
|
||||
originalHttp2Connect: http2.connect,
|
||||
originalChildSpawn: childProcess.spawn,
|
||||
originalChildExec: childProcess.exec,
|
||||
originalChildExecFile: childProcess.execFile,
|
||||
originalChildFork: childProcess.fork,
|
||||
};
|
||||
|
||||
setState(state);
|
||||
installProcessEnv(options.proxyUrl);
|
||||
globalThis.fetch = async (...args: FetchArgs) => {
|
||||
const current = getState();
|
||||
if (!current) {
|
||||
return state.originalFetch(...args);
|
||||
}
|
||||
const proxy = normalizeProxyUrl(current.proxyUrl);
|
||||
const [nextInput, nextInit] = withRoutedFetchInput(args[0], args[1], proxy);
|
||||
return state.originalFetch(nextInput, nextInit);
|
||||
};
|
||||
|
||||
http.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpRequest) as HttpRequest;
|
||||
https.request = wrapRequest(state.originalHttpRequest, state.originalHttpsRequest, state.originalHttpsRequest) as HttpsRequest;
|
||||
http.get = wrapGet(http.request) as HttpGet;
|
||||
https.get = wrapGet(https.request) as HttpsGet;
|
||||
http2.connect = wrapHttp2Connect(state.originalHttp2Connect);
|
||||
childProcess.spawn = wrapSpawn(state.originalChildSpawn);
|
||||
childProcess.exec = wrapExec(state.originalChildExec);
|
||||
childProcess.execFile = wrapExecFile(state.originalChildExecFile);
|
||||
childProcess.fork = wrapFork(state.originalChildFork);
|
||||
syncBuiltinESMExports();
|
||||
|
||||
return () => uninstallHeadroomTransport();
|
||||
}
|
||||
|
||||
export function uninstallHeadroomTransport(): void {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.refs -= 1;
|
||||
if (state.refs > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.fetch = state.originalFetch;
|
||||
http.request = state.originalHttpRequest;
|
||||
http.get = state.originalHttpGet;
|
||||
https.request = state.originalHttpsRequest;
|
||||
https.get = state.originalHttpsGet;
|
||||
http2.connect = state.originalHttp2Connect;
|
||||
childProcess.spawn = state.originalChildSpawn;
|
||||
childProcess.exec = state.originalChildExec;
|
||||
childProcess.execFile = state.originalChildExecFile;
|
||||
childProcess.fork = state.originalChildFork;
|
||||
syncBuiltinESMExports();
|
||||
setState(undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user