mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description Hardens the bundled OpenClaw plugin so configured proxy routing is fail-closed and `autoStart` is opt-in. Closes: N/A This follow-up is intentionally separate from the ContentRouter cache fix because it changes plugin/gateway behavior rather than core compression routing. The plugin should not mutate upstream provider routing unless a configured proxy URL is reachable and looks like Headroom. It should also avoid unhandled startup promise rejections when proxy startup is fire-and-forget. Why this shape: - `autoStart: false` by default matches deployments where Headroom is supervised externally, for example by systemd. The plugin should not silently start or assume ownership of a proxy unless the operator opted in. - Provider routing is fail-closed: a configured URL must first respond like Headroom, not merely expose a generic liveness endpoint. This prevents accidentally routing model traffic through the wrong local service. - `/readyz` is treated as liveness, not identity. Identity comes from Headroom-shaped stats endpoints (`/v1/retrieve/stats` or `/stats`) because those are harder for unrelated services to satisfy by accident. - Startup remains asynchronous, but errors are captured and exposed instead of becoming unhandled promise rejections. - This is a separate PR because the core cache fix is about compression correctness, while this patch is about integration safety around OpenClaw gateway routing. ## Type of Change - [x] Bug fix (non-breaking change fixes issue) - [ ] New feature (non-breaking change adds functionality) - [ ] Breaking change (fix or feature would cause existing functionality change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Make proxy `autoStart` opt-in (`default: false`). - Probe configured `proxyUrl` before applying provider routing. - Treat `/readyz` as liveness only; require Headroom-shaped `/v1/retrieve/stats` or `/stats` for identity. - Observe fire-and-forget startup promise rejection and expose startup error for callers. - Isolate proxy-ready listener failures. - Keep provider routing deferred when no active/probed Headroom proxy exists. - Register retrieve tool with explicit `headroom_retrieve` name. - Extend plugin/unit tests for configured proxy failures, generic non-Headroom endpoints, path collisions, and routing behavior. Changed files: - `plugins/openclaw/README.md` - `plugins/openclaw/openclaw.plugin.json` - `plugins/openclaw/src/engine.ts` - `plugins/openclaw/src/plugin/index.ts` - `plugins/openclaw/src/proxy-manager.ts` - `plugins/openclaw/test/engine.test.ts` - `plugins/openclaw/test/gateway-config.test.ts` - `plugins/openclaw/test/plugin-runtime-routing.test.ts` - `plugins/openclaw/test/proxy-manager.test.ts` ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added new functionality - [x] Manual testing performed ### Test Output ```text $ npm test Test Files 6 passed (6) Tests 74 passed (74) $ npm run typecheck tsc --noEmit $ npm run build tsup && node prepare-dist.mjs Build success ``` ## Real Behavior Proof - Environment: local OpenClaw plugin package in the Headroom repo. - Exact command / steps: - Run plugin test suite. - Run TypeScript typecheck. - Run plugin build. - Observed result: - Tests passed: `74/74`. - Typecheck passed. - Build passed. - Not tested: - Full OpenClaw Gateway integration as part of this standalone PR prep. ## Review Readiness - [x] I performed self-review - [x] This PR ready for human review ## Checklist - [x] My code follows project's style guidelines - [x] I performed self-review my code - [ ] I commented my code, particularly in hard-to-understand areas - [x] I made corresponding changes documentation - [x] My changes generate no new warnings - [x] I added tests prove fix is effective or feature works - [x] New and existing unit tests pass locally my changes - [ ] I updated CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes Checklist items left unchecked intentionally: - No CHANGELOG update included. - No extra comments were needed beyond existing code structure. Co-authored-by: Björn-Christian Bönkost <bjoern@v2202603344248440850.hotsrv.de>
337 lines
9.3 KiB
TypeScript
337 lines
9.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
applyGatewayProviderBaseUrls,
|
|
applyGatewayProviderBaseUrlsInPlace,
|
|
resolveGatewayProviderIds,
|
|
} from "../src/gateway-config.js";
|
|
|
|
describe("resolveGatewayProviderIds", () => {
|
|
it("routes openai-codex by default", () => {
|
|
expect(resolveGatewayProviderIds(undefined)).toEqual(["openai-codex"]);
|
|
});
|
|
|
|
it("allows an explicit provider list to override the default", () => {
|
|
expect(
|
|
resolveGatewayProviderIds({
|
|
gatewayProviderIds: ["anthropic", "github-copilot", "minimax-portal"],
|
|
}),
|
|
).toEqual(["anthropic", "github-copilot", "minimax-portal"]);
|
|
});
|
|
|
|
it("normalizes explicit provider ids and friendly aliases", () => {
|
|
expect(
|
|
resolveGatewayProviderIds({
|
|
gatewayProviderIds: [" claude ", "", "copilot", "codex", "gemini", "anthropic"],
|
|
}),
|
|
).toEqual(["anthropic", "github-copilot", "openai-codex", "google"]);
|
|
});
|
|
|
|
it("allows routing to be disabled", () => {
|
|
expect(resolveGatewayProviderIds({ routeCodexViaProxy: false })).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("applyGatewayProviderBaseUrls", () => {
|
|
it("creates an openai-codex provider config when missing", () => {
|
|
const result = applyGatewayProviderBaseUrls({}, "http://127.0.0.1:8787", ["openai-codex"]);
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect((result.config as any).models.providers["openai-codex"]).toEqual({
|
|
baseUrl: "http://127.0.0.1:8787/backend-api",
|
|
models: [],
|
|
});
|
|
});
|
|
|
|
it("creates provider configs for multiple configured provider ids", () => {
|
|
const result = applyGatewayProviderBaseUrls(
|
|
{},
|
|
"http://127.0.0.1:8787",
|
|
["anthropic", "openrouter", "google", "minimax-portal"],
|
|
);
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect((result.config as any).models.providers).toEqual({
|
|
anthropic: {
|
|
baseUrl: "http://127.0.0.1:8787",
|
|
models: [],
|
|
},
|
|
openrouter: {
|
|
baseUrl: "http://127.0.0.1:8787",
|
|
models: [],
|
|
},
|
|
google: {
|
|
baseUrl: "http://127.0.0.1:8787",
|
|
models: [],
|
|
},
|
|
"minimax-portal": {
|
|
baseUrl: "http://127.0.0.1:8787",
|
|
models: [],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("preserves existing provider config fields", () => {
|
|
const result = applyGatewayProviderBaseUrls(
|
|
{
|
|
models: {
|
|
providers: {
|
|
"openai-codex": {
|
|
api: "openai-codex-responses",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"http://127.0.0.1:8787",
|
|
["openai-codex"],
|
|
);
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect((result.config as any).models.providers["openai-codex"]).toEqual({
|
|
api: "openai-codex-responses",
|
|
baseUrl: "http://127.0.0.1:8787/backend-api",
|
|
models: [],
|
|
});
|
|
});
|
|
|
|
it("is a no-op when the provider already points at headroom", () => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
"openai-codex": {
|
|
baseUrl: "http://127.0.0.1:8787/backend-api",
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = applyGatewayProviderBaseUrls(cfg, "http://127.0.0.1:8787", ["openai-codex"]);
|
|
|
|
expect(result.changed).toBe(false);
|
|
expect(result.config).toEqual(cfg);
|
|
});
|
|
|
|
it("preserves upstream path segments when routing through the proxy", () => {
|
|
const result = applyGatewayProviderBaseUrls(
|
|
{
|
|
models: {
|
|
providers: {
|
|
anthropic: {
|
|
baseUrl: "https://api.anthropic.com/v1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"http://127.0.0.1:8787",
|
|
["anthropic"],
|
|
);
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect((result.config as any).models.providers.anthropic).toEqual({
|
|
baseUrl: "http://127.0.0.1:8787/v1",
|
|
models: [],
|
|
});
|
|
});
|
|
|
|
it("preserves protocol-specific GitHub Copilot OpenAI-family paths", () => {
|
|
const result = applyGatewayProviderBaseUrls(
|
|
{
|
|
models: {
|
|
providers: {
|
|
"github-copilot": {
|
|
baseUrl: "https://api.githubcopilot.com/v1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"http://127.0.0.1:8787",
|
|
["github-copilot"],
|
|
);
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect((result.config as any).models.providers["github-copilot"]).toEqual({
|
|
baseUrl: "http://127.0.0.1:8787/v1",
|
|
models: [],
|
|
});
|
|
});
|
|
|
|
it("preserves protocol-specific GitHub Copilot Claude-family paths", () => {
|
|
const result = applyGatewayProviderBaseUrls(
|
|
{
|
|
models: {
|
|
providers: {
|
|
"github-copilot": {
|
|
baseUrl: "https://api.githubcopilot.com/anthropic",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"http://127.0.0.1:8787",
|
|
["github-copilot"],
|
|
);
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect((result.config as any).models.providers["github-copilot"]).toEqual({
|
|
baseUrl: "http://127.0.0.1:8787/anthropic",
|
|
models: [],
|
|
});
|
|
});
|
|
|
|
it("preserves OpenAI-compatible /api/v1 paths", () => {
|
|
const result = applyGatewayProviderBaseUrls(
|
|
{
|
|
models: {
|
|
providers: {
|
|
openrouter: {
|
|
baseUrl: "https://openrouter.ai/api/v1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"http://127.0.0.1:8787",
|
|
["openrouter"],
|
|
);
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect((result.config as any).models.providers.openrouter).toEqual({
|
|
baseUrl: "http://127.0.0.1:8787/api/v1",
|
|
models: [],
|
|
});
|
|
});
|
|
|
|
it("preserves Gemini /v1beta paths", () => {
|
|
const result = applyGatewayProviderBaseUrls(
|
|
{
|
|
models: {
|
|
providers: {
|
|
google: {
|
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"http://127.0.0.1:8787",
|
|
["google"],
|
|
);
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect((result.config as any).models.providers.google).toEqual({
|
|
baseUrl: "http://127.0.0.1:8787/v1beta",
|
|
models: [],
|
|
});
|
|
});
|
|
|
|
it("does not invent a GitHub Copilot proxy baseUrl without an upstream baseUrl", () => {
|
|
const result = applyGatewayProviderBaseUrls({}, "http://127.0.0.1:8787", ["github-copilot"]);
|
|
|
|
expect(result.changed).toBe(false);
|
|
expect((result.config as any).models?.providers?.["github-copilot"]).toBeUndefined();
|
|
});
|
|
|
|
it("documents the Gate-D risk: anthropic without an explicit baseUrl routes to the bare proxy origin", () => {
|
|
const result = applyGatewayProviderBaseUrls({}, "http://127.0.0.1:8787", ["anthropic"]);
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect((result.config as any).models.providers.anthropic).toEqual({
|
|
baseUrl: "http://127.0.0.1:8787",
|
|
models: [],
|
|
});
|
|
});
|
|
|
|
it("documents the multi-provider risk: providers sharing /v1 collapse to the same proxy path", () => {
|
|
const result = applyGatewayProviderBaseUrls(
|
|
{
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
baseUrl: "https://api.openai.com/v1",
|
|
},
|
|
"github-copilot": {
|
|
baseUrl: "https://api.githubcopilot.com/v1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"http://127.0.0.1:8787",
|
|
["openai", "github-copilot"],
|
|
);
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect((result.config as any).models.providers.openai.baseUrl).toBe(
|
|
"http://127.0.0.1:8787/v1",
|
|
);
|
|
expect((result.config as any).models.providers["github-copilot"].baseUrl).toBe(
|
|
"http://127.0.0.1:8787/v1",
|
|
);
|
|
});
|
|
|
|
it("re-points an already routed provider to a new proxy origin without duplicating paths", () => {
|
|
const result = applyGatewayProviderBaseUrls(
|
|
{
|
|
models: {
|
|
providers: {
|
|
"openai-codex": {
|
|
baseUrl: "http://127.0.0.1:8787/backend-api",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"http://localhost:8787",
|
|
["openai-codex"],
|
|
);
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect((result.config as any).models.providers["openai-codex"]).toEqual({
|
|
baseUrl: "http://localhost:8787/backend-api",
|
|
models: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("applyGatewayProviderBaseUrlsInPlace", () => {
|
|
it("updates the live config object in place", () => {
|
|
const cfg: any = { models: { providers: {} } };
|
|
|
|
const changed = applyGatewayProviderBaseUrlsInPlace(
|
|
cfg,
|
|
"http://127.0.0.1:8787",
|
|
["openai-codex"],
|
|
);
|
|
|
|
expect(changed).toBe(true);
|
|
expect(cfg.models.providers["openai-codex"]).toEqual({
|
|
baseUrl: "http://127.0.0.1:8787/backend-api",
|
|
models: [],
|
|
});
|
|
});
|
|
|
|
it("does not clobber existing provider logic when changing only the base URL", () => {
|
|
const cfg: any = {
|
|
models: {
|
|
providers: {
|
|
"openai-codex": {
|
|
api: "openai-codex-responses",
|
|
baseUrl: "https://chatgpt.com/backend-api",
|
|
envKey: "OPENAI_API_KEY",
|
|
models: ["gpt-5.3-codex"],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const changed = applyGatewayProviderBaseUrlsInPlace(
|
|
cfg,
|
|
"http://127.0.0.1:8787",
|
|
["openai-codex"],
|
|
);
|
|
|
|
expect(changed).toBe(true);
|
|
expect(cfg.models.providers["openai-codex"]).toEqual({
|
|
api: "openai-codex-responses",
|
|
envKey: "OPENAI_API_KEY",
|
|
baseUrl: "http://127.0.0.1:8787/backend-api",
|
|
models: ["gpt-5.3-codex"],
|
|
});
|
|
});
|
|
});
|