Spaces:
Build error
Build error
Commit ·
73ff535
1
Parent(s): 37f81ac
test(openclaw): cover branch routing paths
Browse filesCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- e2e/wrap/run.py +6 -0
- headroom/cli/wrap.py +18 -13
- plugins/openclaw/test/engine.test.ts +76 -275
- tests/test_cli/test_wrap_openclaw.py +127 -0
- tests/test_openai_codex_routing.py +160 -1
e2e/wrap/run.py
CHANGED
|
@@ -355,6 +355,11 @@ def start_openclaw_gateway(env: dict[str, str], cwd: Path) -> subprocess.Popen[s
|
|
| 355 |
)
|
| 356 |
|
| 357 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
def verify_installs() -> None:
|
| 359 |
log("Verifying installed packages and binaries")
|
| 360 |
for tool in ("headroom", "codex", "aider", "openclaw"):
|
|
@@ -617,6 +622,7 @@ def verify_openclaw_wrap(
|
|
| 617 |
finally:
|
| 618 |
if gateway_proc is not None:
|
| 619 |
stop_process(gateway_proc)
|
|
|
|
| 620 |
|
| 621 |
|
| 622 |
def main() -> None:
|
|
|
|
| 355 |
)
|
| 356 |
|
| 357 |
|
| 358 |
+
def stop_openclaw_gateway(env: dict[str, str], cwd: Path) -> None:
|
| 359 |
+
log("Stopping OpenClaw gateway after e2e verification")
|
| 360 |
+
run(["openclaw", "gateway", "stop"], env=env, cwd=cwd, timeout=60)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
def verify_installs() -> None:
|
| 364 |
log("Verifying installed packages and binaries")
|
| 365 |
for tool in ("headroom", "codex", "aider", "openclaw"):
|
|
|
|
| 622 |
finally:
|
| 623 |
if gateway_proc is not None:
|
| 624 |
stop_process(gateway_proc)
|
| 625 |
+
stop_openclaw_gateway(base_env, project_dir)
|
| 626 |
|
| 627 |
|
| 628 |
def main() -> None:
|
headroom/cli/wrap.py
CHANGED
|
@@ -1132,6 +1132,24 @@ def openclaw(
|
|
| 1132 |
elif not local_source_mode and skip_build:
|
| 1133 |
click.echo(" Skipping build: npm install mode does not build local source.")
|
| 1134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1135 |
install_cmd = [
|
| 1136 |
openclaw_bin,
|
| 1137 |
"plugins",
|
|
@@ -1184,19 +1202,6 @@ def openclaw(
|
|
| 1184 |
elif verbose and install_result.stdout.strip():
|
| 1185 |
click.echo(install_result.stdout.strip())
|
| 1186 |
|
| 1187 |
-
existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom")
|
| 1188 |
-
entry = _build_openclaw_plugin_entry(
|
| 1189 |
-
existing_entry=existing_entry,
|
| 1190 |
-
proxy_port=proxy_port,
|
| 1191 |
-
startup_timeout_ms=startup_timeout_ms,
|
| 1192 |
-
python_path=python_path,
|
| 1193 |
-
no_auto_start=no_auto_start,
|
| 1194 |
-
gateway_provider_ids=gateway_provider_ids,
|
| 1195 |
-
enabled=True,
|
| 1196 |
-
)
|
| 1197 |
-
|
| 1198 |
-
click.echo(" Writing plugin configuration...")
|
| 1199 |
-
_write_openclaw_plugin_entry(openclaw_bin, entry)
|
| 1200 |
_set_openclaw_context_engine_slot(openclaw_bin, "headroom")
|
| 1201 |
_run_checked(
|
| 1202 |
[openclaw_bin, "config", "validate"],
|
|
|
|
| 1132 |
elif not local_source_mode and skip_build:
|
| 1133 |
click.echo(" Skipping build: npm install mode does not build local source.")
|
| 1134 |
|
| 1135 |
+
effective_python_path = python_path
|
| 1136 |
+
if effective_python_path is None and not no_auto_start and sys.executable:
|
| 1137 |
+
effective_python_path = sys.executable
|
| 1138 |
+
|
| 1139 |
+
existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom")
|
| 1140 |
+
entry = _build_openclaw_plugin_entry(
|
| 1141 |
+
existing_entry=existing_entry,
|
| 1142 |
+
proxy_port=proxy_port,
|
| 1143 |
+
startup_timeout_ms=startup_timeout_ms,
|
| 1144 |
+
python_path=effective_python_path,
|
| 1145 |
+
no_auto_start=no_auto_start,
|
| 1146 |
+
gateway_provider_ids=gateway_provider_ids,
|
| 1147 |
+
enabled=True,
|
| 1148 |
+
)
|
| 1149 |
+
|
| 1150 |
+
click.echo(" Writing plugin configuration...")
|
| 1151 |
+
_write_openclaw_plugin_entry(openclaw_bin, entry)
|
| 1152 |
+
|
| 1153 |
install_cmd = [
|
| 1154 |
openclaw_bin,
|
| 1155 |
"plugins",
|
|
|
|
| 1202 |
elif verbose and install_result.stdout.strip():
|
| 1203 |
click.echo(install_result.stdout.strip())
|
| 1204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1205 |
_set_openclaw_context_engine_slot(openclaw_bin, "headroom")
|
| 1206 |
_run_checked(
|
| 1207 |
[openclaw_bin, "config", "validate"],
|
plugins/openclaw/test/engine.test.ts
CHANGED
|
@@ -1,300 +1,101 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
const PROXY_URL = process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787";
|
| 17 |
|
| 18 |
afterEach(() => {
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
});
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
});
|
| 31 |
|
| 32 |
-
it("
|
| 33 |
-
const
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
content: [
|
| 37 |
-
{ type: "text", text: "Let me search" },
|
| 38 |
-
{ type: "tool_use", id: "tu_1", name: "search", input: { q: "test" } },
|
| 39 |
-
],
|
| 40 |
-
timestamp: Date.now(),
|
| 41 |
-
},
|
| 42 |
-
];
|
| 43 |
-
const openai = agentToOpenAI(agent);
|
| 44 |
-
expect(openai[0].role).toBe("assistant");
|
| 45 |
-
expect(openai[0].content).toBe("Let me search");
|
| 46 |
-
expect(openai[0].tool_calls).toHaveLength(1);
|
| 47 |
-
expect(openai[0].tool_calls![0].function.name).toBe("search");
|
| 48 |
-
});
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
role: "assistant",
|
| 54 |
-
content: [
|
| 55 |
-
{ type: "text", text: "Let me search" },
|
| 56 |
-
{ type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } },
|
| 57 |
-
],
|
| 58 |
-
timestamp: Date.now(),
|
| 59 |
-
},
|
| 60 |
-
];
|
| 61 |
-
const openai = agentToOpenAI(agent);
|
| 62 |
-
expect(openai[0].role).toBe("assistant");
|
| 63 |
-
expect(openai[0].content).toBe("Let me search");
|
| 64 |
-
expect(openai[0].tool_calls).toHaveLength(1);
|
| 65 |
-
expect(openai[0].tool_calls![0].id).toBe("call_1|fc_1");
|
| 66 |
-
expect(openai[0].tool_calls![0].function.name).toBe("search");
|
| 67 |
-
});
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
{
|
| 72 |
-
role: "toolResult",
|
| 73 |
-
content: '{"results": [1, 2, 3]}',
|
| 74 |
-
tool_use_id: "tu_1",
|
| 75 |
-
timestamp: Date.now(),
|
| 76 |
-
},
|
| 77 |
-
];
|
| 78 |
-
const openai = agentToOpenAI(agent);
|
| 79 |
-
expect(openai[0].role).toBe("tool");
|
| 80 |
-
expect(openai[0].content).toBe('{"results": [1, 2, 3]}');
|
| 81 |
-
expect(openai[0].tool_call_id).toBe("tu_1");
|
| 82 |
-
});
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
const openai = agentToOpenAI(original);
|
| 87 |
-
const back = openAIToAgent(openai);
|
| 88 |
-
expect(back[0].role).toBe("user");
|
| 89 |
-
expect(back[0].content).toBe("hello");
|
| 90 |
});
|
| 91 |
|
| 92 |
-
it("
|
| 93 |
-
const
|
| 94 |
-
{
|
| 95 |
-
role: "assistant",
|
| 96 |
-
content: [{ type: "text", text: "Hello there!" }],
|
| 97 |
-
timestamp: Date.now(),
|
| 98 |
-
},
|
| 99 |
-
];
|
| 100 |
-
const openai = agentToOpenAI(original);
|
| 101 |
-
const back = openAIToAgent(openai);
|
| 102 |
-
expect(back[0].role).toBe("assistant");
|
| 103 |
-
// OpenClaw requires content to ALWAYS be an array for assistant messages
|
| 104 |
-
const content = back[0].content;
|
| 105 |
-
expect(Array.isArray(content)).toBe(true);
|
| 106 |
-
expect(content[0]).toEqual({ type: "text", text: "Hello there!" });
|
| 107 |
-
});
|
| 108 |
|
| 109 |
-
|
| 110 |
-
const original = [
|
| 111 |
-
{
|
| 112 |
-
role: "assistant",
|
| 113 |
-
content: [
|
| 114 |
-
{ type: "text", text: "Searching..." },
|
| 115 |
-
{ type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } },
|
| 116 |
-
],
|
| 117 |
-
timestamp: Date.now(),
|
| 118 |
-
},
|
| 119 |
-
];
|
| 120 |
-
const openai = agentToOpenAI(original);
|
| 121 |
-
const back = openAIToAgent(openai);
|
| 122 |
-
expect(back[0].role).toBe("assistant");
|
| 123 |
-
const content = back[0].content;
|
| 124 |
-
expect(Array.isArray(content)).toBe(true);
|
| 125 |
-
expect(content).toContainEqual(expect.objectContaining({ type: "text", text: "Searching..." }));
|
| 126 |
-
expect(content).toContainEqual(
|
| 127 |
-
expect.objectContaining({ type: "toolCall", id: "call_1|fc_1", name: "search" }),
|
| 128 |
-
);
|
| 129 |
-
});
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
{
|
| 134 |
-
role: "toolResult",
|
| 135 |
-
content: '{"data": true}',
|
| 136 |
-
tool_use_id: "tu_1",
|
| 137 |
-
timestamp: Date.now(),
|
| 138 |
-
},
|
| 139 |
-
];
|
| 140 |
-
const openai = agentToOpenAI(original);
|
| 141 |
-
const back = openAIToAgent(openai);
|
| 142 |
-
expect(back[0].role).toBe("toolResult");
|
| 143 |
-
expect(back[0].content).toEqual([{ type: "text", text: '{"data": true}' }]);
|
| 144 |
-
expect(back[0].tool_use_id).toBe("tu_1");
|
| 145 |
});
|
| 146 |
-
});
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
const start = vi.fn(
|
| 151 |
-
() => new Promise<string>((resolve) => setTimeout(() => resolve("http://127.0.0.1:8787"), 50)),
|
| 152 |
-
);
|
| 153 |
-
vi.spyOn(ProxyManager.prototype, "start").mockImplementation(start);
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
sessionId: "test-session",
|
| 158 |
-
sessionFile: "/tmp/test-session.jsonl",
|
| 159 |
-
});
|
| 160 |
-
|
| 161 |
-
expect(result).toEqual({ bootstrapped: true, reason: "proxy startup scheduled" });
|
| 162 |
-
expect(start).toHaveBeenCalledTimes(1);
|
| 163 |
-
expect(engine.getProxyUrl()).toBeNull();
|
| 164 |
});
|
| 165 |
|
| 166 |
-
it("
|
| 167 |
-
const start = vi.fn(
|
| 168 |
-
() => new Promise<string>((resolve) => setTimeout(() => resolve("http://127.0.0.1:8787"), 50)),
|
| 169 |
-
);
|
| 170 |
-
vi.spyOn(ProxyManager.prototype, "start").mockImplementation(start);
|
| 171 |
-
|
| 172 |
const engine = new HeadroomContextEngine();
|
| 173 |
-
const messages = [{ role: "user", content: "hello"
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
messages,
|
| 178 |
-
|
| 179 |
});
|
| 180 |
-
|
| 181 |
-
expect(result).toEqual({ messages, estimatedTokens: 0 });
|
| 182 |
-
expect(start).toHaveBeenCalledTimes(1);
|
| 183 |
});
|
| 184 |
});
|
| 185 |
-
|
| 186 |
-
if (RUN) {
|
| 187 |
-
describe("ProxyManager", () => {
|
| 188 |
-
it("connects to configured proxy URL", { timeout: 30000 }, async () => {
|
| 189 |
-
const manager = new ProxyManager({ proxyUrl: PROXY_URL });
|
| 190 |
-
try {
|
| 191 |
-
const url = await manager.start();
|
| 192 |
-
expect(url).toMatch(/^http:\/\/(127\.0\.0\.1|localhost):\d+$/);
|
| 193 |
-
|
| 194 |
-
// Verify health
|
| 195 |
-
const resp = await fetch(`${url}/health`);
|
| 196 |
-
expect(resp.ok).toBe(true);
|
| 197 |
-
} finally {
|
| 198 |
-
await manager.stop();
|
| 199 |
-
}
|
| 200 |
-
});
|
| 201 |
-
});
|
| 202 |
-
|
| 203 |
-
describe("HeadroomContextEngine", () => {
|
| 204 |
-
let engine: HeadroomContextEngine;
|
| 205 |
-
|
| 206 |
-
beforeAll(async () => {
|
| 207 |
-
engine = new HeadroomContextEngine({ proxyUrl: PROXY_URL });
|
| 208 |
-
await engine.bootstrap({
|
| 209 |
-
sessionId: "test-session",
|
| 210 |
-
sessionFile: "/tmp/test-session.jsonl",
|
| 211 |
-
});
|
| 212 |
-
}, 30000);
|
| 213 |
-
|
| 214 |
-
afterAll(async () => {
|
| 215 |
-
await engine.dispose();
|
| 216 |
-
});
|
| 217 |
-
|
| 218 |
-
it("assemble() compresses tool outputs", { timeout: 15000 }, async () => {
|
| 219 |
-
// Simulate an OpenClaw agent conversation with large tool result
|
| 220 |
-
const serverData = Array.from({ length: 100 }, (_, i) => ({
|
| 221 |
-
id: i + 1,
|
| 222 |
-
name: `server-${i + 1}`,
|
| 223 |
-
status: i % 15 === 0 ? "critical" : i % 5 === 0 ? "warning" : "healthy",
|
| 224 |
-
cpu: Math.round(Math.random() * 100),
|
| 225 |
-
memory: Math.round(Math.random() * 100),
|
| 226 |
-
region: ["us-east-1", "eu-west-1", "ap-southeast-1"][i % 3],
|
| 227 |
-
description: `Production server ${i + 1} running service-${["auth", "payment", "user", "api"][i % 4]}`,
|
| 228 |
-
lastAlert: i % 15 === 0 ? `Disk usage at ${90 + (i % 10)}%` : null,
|
| 229 |
-
}));
|
| 230 |
-
|
| 231 |
-
const messages = [
|
| 232 |
-
{ role: "user", content: "Check the fleet status", timestamp: Date.now() },
|
| 233 |
-
{
|
| 234 |
-
role: "assistant",
|
| 235 |
-
content: [
|
| 236 |
-
{ type: "tool_use", id: "tu_fleet", name: "getFleetStatus", input: {} },
|
| 237 |
-
],
|
| 238 |
-
timestamp: Date.now(),
|
| 239 |
-
},
|
| 240 |
-
{
|
| 241 |
-
role: "toolResult",
|
| 242 |
-
content: JSON.stringify(serverData),
|
| 243 |
-
tool_use_id: "tu_fleet",
|
| 244 |
-
timestamp: Date.now(),
|
| 245 |
-
},
|
| 246 |
-
{ role: "user", content: "Which servers are critical?", timestamp: Date.now() },
|
| 247 |
-
];
|
| 248 |
-
|
| 249 |
-
const result = await engine.assemble({
|
| 250 |
-
sessionId: "test-session",
|
| 251 |
-
messages,
|
| 252 |
-
model: "claude-sonnet-4-5",
|
| 253 |
-
});
|
| 254 |
-
|
| 255 |
-
console.log(
|
| 256 |
-
` assemble(): estimatedTokens=${result.estimatedTokens}, ` +
|
| 257 |
-
`systemPrompt=${result.systemPromptAddition ? "yes" : "no"}`,
|
| 258 |
-
);
|
| 259 |
-
|
| 260 |
-
// Messages should be returned (compressed or not)
|
| 261 |
-
expect(result.messages.length).toBeGreaterThan(0);
|
| 262 |
-
// First and last messages should still be user messages
|
| 263 |
-
expect(result.messages[0].role).toBe("user");
|
| 264 |
-
expect(result.messages[result.messages.length - 1].role).toBe("user");
|
| 265 |
-
});
|
| 266 |
-
|
| 267 |
-
it("assemble() preserves small conversations", { timeout: 15000 }, async () => {
|
| 268 |
-
const messages = [
|
| 269 |
-
{ role: "user", content: "Hello", timestamp: Date.now() },
|
| 270 |
-
{ role: "assistant", content: "Hi there!", timestamp: Date.now() },
|
| 271 |
-
];
|
| 272 |
-
|
| 273 |
-
const result = await engine.assemble({
|
| 274 |
-
sessionId: "test-session",
|
| 275 |
-
messages,
|
| 276 |
-
});
|
| 277 |
-
|
| 278 |
-
expect(result.messages).toHaveLength(2);
|
| 279 |
-
expect(result.messages[0].content).toBe("Hello");
|
| 280 |
-
expect(result.messages[1].content).toBe("Hi there!");
|
| 281 |
-
});
|
| 282 |
-
|
| 283 |
-
it("compact() returns success (compression handled in assemble)", async () => {
|
| 284 |
-
const result = await engine.compact({
|
| 285 |
-
sessionId: "test-session",
|
| 286 |
-
sessionFile: "/tmp/test.jsonl",
|
| 287 |
-
});
|
| 288 |
-
|
| 289 |
-
expect(result.ok).toBe(true);
|
| 290 |
-
expect(result.compacted).toBe(true);
|
| 291 |
-
});
|
| 292 |
-
|
| 293 |
-
it("getStats() returns compression statistics", () => {
|
| 294 |
-
const stats = engine.getStats();
|
| 295 |
-
expect(stats).toHaveProperty("totalCompressions");
|
| 296 |
-
expect(stats).toHaveProperty("totalTokensSaved");
|
| 297 |
-
expect(stats.totalCompressions).toBeGreaterThanOrEqual(0);
|
| 298 |
-
});
|
| 299 |
-
});
|
| 300 |
-
}
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
|
| 3 |
+
const mocked = vi.hoisted(() => ({
|
| 4 |
+
start: vi.fn(async () => "http://127.0.0.1:8787"),
|
| 5 |
+
stop: vi.fn(async () => undefined),
|
| 6 |
+
logger: {
|
| 7 |
+
debug: vi.fn(),
|
| 8 |
+
error: vi.fn(),
|
| 9 |
+
info: vi.fn(),
|
| 10 |
+
warn: vi.fn(),
|
| 11 |
+
},
|
| 12 |
+
}));
|
| 13 |
+
|
| 14 |
+
vi.mock("headroom-ai", () => ({
|
| 15 |
+
compress: vi.fn(),
|
| 16 |
+
}));
|
| 17 |
+
|
| 18 |
+
vi.mock("../src/proxy-manager.js", () => ({
|
| 19 |
+
ProxyManager: class {
|
| 20 |
+
start = mocked.start;
|
| 21 |
+
stop = mocked.stop;
|
| 22 |
+
},
|
| 23 |
+
defaultLogger: mocked.logger,
|
| 24 |
+
}));
|
| 25 |
|
| 26 |
+
import { HeadroomContextEngine } from "../src/engine.js";
|
|
|
|
| 27 |
|
| 28 |
afterEach(() => {
|
| 29 |
+
mocked.start.mockReset();
|
| 30 |
+
mocked.start.mockResolvedValue("http://127.0.0.1:8787");
|
| 31 |
+
mocked.stop.mockClear();
|
| 32 |
+
mocked.logger.debug.mockClear();
|
| 33 |
+
mocked.logger.error.mockClear();
|
| 34 |
+
mocked.logger.info.mockClear();
|
| 35 |
+
mocked.logger.warn.mockClear();
|
| 36 |
});
|
| 37 |
|
| 38 |
+
describe("HeadroomContextEngine proxy startup helpers", () => {
|
| 39 |
+
it("bootstraps by scheduling proxy startup when enabled", async () => {
|
| 40 |
+
const engine = new HeadroomContextEngine();
|
| 41 |
|
| 42 |
+
await expect(
|
| 43 |
+
engine.bootstrap({
|
| 44 |
+
sessionId: "session-1",
|
| 45 |
+
sessionFile: "session.jsonl",
|
| 46 |
+
}),
|
| 47 |
+
).resolves.toEqual({
|
| 48 |
+
bootstrapped: true,
|
| 49 |
+
reason: "proxy startup scheduled",
|
| 50 |
+
});
|
| 51 |
+
expect(mocked.start).toHaveBeenCalledTimes(1);
|
| 52 |
});
|
| 53 |
|
| 54 |
+
it("removes unsubscribed proxy listeners before notifying readiness", async () => {
|
| 55 |
+
const engine = new HeadroomContextEngine();
|
| 56 |
+
const first = vi.fn();
|
| 57 |
+
const second = vi.fn();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
+
const unsubscribeFirst = engine.onProxyReady(first);
|
| 60 |
+
engine.onProxyReady(second);
|
| 61 |
+
unsubscribeFirst();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
+
engine.ensureProxyStarted();
|
| 64 |
+
await engine.ensureProxyUrl();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
+
expect(first).not.toHaveBeenCalled();
|
| 67 |
+
expect(second).toHaveBeenCalledWith("http://127.0.0.1:8787");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
});
|
| 69 |
|
| 70 |
+
it("returns the existing proxy URL without starting again", async () => {
|
| 71 |
+
const engine = new HeadroomContextEngine();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
+
(engine as { proxyUrl: string | null }).proxyUrl = "http://127.0.0.1:8787";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
+
await expect(engine.ensureProxyUrl()).resolves.toBe("http://127.0.0.1:8787");
|
| 76 |
+
expect(mocked.start).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
});
|
|
|
|
| 78 |
|
| 79 |
+
it("throws when proxy startup is disabled", async () => {
|
| 80 |
+
const engine = new HeadroomContextEngine({ enabled: false });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
+
await expect(engine.ensureProxyUrl()).rejects.toThrow("Headroom proxy startup is disabled");
|
| 83 |
+
expect(mocked.start).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
});
|
| 85 |
|
| 86 |
+
it("schedules startup and returns original messages when assembling before proxy readiness", async () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
const engine = new HeadroomContextEngine();
|
| 88 |
+
const messages = [{ role: "user", content: "hello" }];
|
| 89 |
+
|
| 90 |
+
await expect(
|
| 91 |
+
engine.assemble({
|
| 92 |
+
sessionId: "session-1",
|
| 93 |
+
messages,
|
| 94 |
+
}),
|
| 95 |
+
).resolves.toEqual({
|
| 96 |
messages,
|
| 97 |
+
estimatedTokens: 0,
|
| 98 |
});
|
| 99 |
+
expect(mocked.start).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
|
| 100 |
});
|
| 101 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_cli/test_wrap_openclaw.py
CHANGED
|
@@ -67,6 +67,18 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner)
|
|
| 67 |
assert ["openclaw", "gateway", "restart"] in cmds
|
| 68 |
assert ["openclaw", "plugins", "inspect", "headroom"] in cmds
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
# Verify plugin install in npm mode does not set cwd
|
| 71 |
install_call = next(
|
| 72 |
c
|
|
@@ -91,6 +103,7 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner)
|
|
| 91 |
assert payload["config"]["autoStart"] is True
|
| 92 |
assert payload["config"]["startupTimeoutMs"] == 20000
|
| 93 |
assert payload["config"]["gatewayProviderIds"] == ["openai-codex"]
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
def test_wrap_openclaw_skip_build_and_no_restart(runner: CliRunner, plugin_dir: Path) -> None:
|
|
@@ -330,6 +343,86 @@ def test_wrap_openclaw_accepts_repeatable_gateway_provider_ids(runner: CliRunner
|
|
| 330 |
assert payload["config"]["gatewayProviderIds"] == ["openai-codex", "anthropic"]
|
| 331 |
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
def test_wrap_openclaw_fails_for_npm_mode_hook_pack_bug_without_local_fallback(
|
| 334 |
runner: CliRunner,
|
| 335 |
) -> None:
|
|
@@ -571,3 +664,37 @@ def test_unwrap_openclaw_no_restart_skips_gateway_restart(runner: CliRunner) ->
|
|
| 571 |
|
| 572 |
assert result.exit_code == 0, result.output
|
| 573 |
assert ["openclaw", "gateway", "restart"] not in [c["cmd"] for c in calls]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
assert ["openclaw", "gateway", "restart"] in cmds
|
| 68 |
assert ["openclaw", "plugins", "inspect", "headroom"] in cmds
|
| 69 |
|
| 70 |
+
config_set_index = next(
|
| 71 |
+
i
|
| 72 |
+
for i, cmd in enumerate(cmds)
|
| 73 |
+
if cmd[:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]
|
| 74 |
+
)
|
| 75 |
+
install_index = next(
|
| 76 |
+
i
|
| 77 |
+
for i, cmd in enumerate(cmds)
|
| 78 |
+
if cmd[:4] == ["openclaw", "plugins", "install", "--dangerously-force-unsafe-install"]
|
| 79 |
+
)
|
| 80 |
+
assert config_set_index < install_index
|
| 81 |
+
|
| 82 |
# Verify plugin install in npm mode does not set cwd
|
| 83 |
install_call = next(
|
| 84 |
c
|
|
|
|
| 103 |
assert payload["config"]["autoStart"] is True
|
| 104 |
assert payload["config"]["startupTimeoutMs"] == 20000
|
| 105 |
assert payload["config"]["gatewayProviderIds"] == ["openai-codex"]
|
| 106 |
+
assert payload["config"]["pythonPath"] == wrap_cli.sys.executable
|
| 107 |
|
| 108 |
|
| 109 |
def test_wrap_openclaw_skip_build_and_no_restart(runner: CliRunner, plugin_dir: Path) -> None:
|
|
|
|
| 343 |
assert payload["config"]["gatewayProviderIds"] == ["openai-codex", "anthropic"]
|
| 344 |
|
| 345 |
|
| 346 |
+
def test_normalize_openclaw_gateway_provider_ids_dedupes_blanks_and_defaults() -> None:
|
| 347 |
+
assert wrap_cli._normalize_openclaw_gateway_provider_ids(
|
| 348 |
+
(" openai-codex ", "", "anthropic", "openai-codex", " ")
|
| 349 |
+
) == ["openai-codex", "anthropic"]
|
| 350 |
+
assert wrap_cli._normalize_openclaw_gateway_provider_ids(None) == ["openai-codex"]
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def test_read_openclaw_config_value_handles_missing_and_raw_strings() -> None:
|
| 354 |
+
missing = MagicMock(returncode=1, stdout="", stderr="missing")
|
| 355 |
+
raw_string = MagicMock(returncode=0, stdout="plain-text-value\n", stderr="")
|
| 356 |
+
|
| 357 |
+
with patch("headroom.cli.wrap.subprocess.run", side_effect=[missing, raw_string]):
|
| 358 |
+
assert wrap_cli._read_openclaw_config_value("openclaw", "plugins.entries.headroom") is None
|
| 359 |
+
assert (
|
| 360 |
+
wrap_cli._read_openclaw_config_value(
|
| 361 |
+
"openclaw", "plugins.entries.headroom.config.pythonPath"
|
| 362 |
+
)
|
| 363 |
+
== "plain-text-value"
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def test_build_openclaw_plugin_entry_sets_and_clears_python_path() -> None:
|
| 368 |
+
with_python = wrap_cli._build_openclaw_plugin_entry(
|
| 369 |
+
existing_entry={"config": {"customFlag": True}},
|
| 370 |
+
proxy_port=8787,
|
| 371 |
+
startup_timeout_ms=20000,
|
| 372 |
+
python_path="C:\\Python312\\python.exe",
|
| 373 |
+
no_auto_start=False,
|
| 374 |
+
gateway_provider_ids=("openai-codex",),
|
| 375 |
+
enabled=True,
|
| 376 |
+
)
|
| 377 |
+
assert with_python["config"]["pythonPath"] == "C:\\Python312\\python.exe"
|
| 378 |
+
|
| 379 |
+
without_python = wrap_cli._build_openclaw_plugin_entry(
|
| 380 |
+
existing_entry={"config": {"pythonPath": "C:\\Old\\python.exe", "customFlag": True}},
|
| 381 |
+
proxy_port=8787,
|
| 382 |
+
startup_timeout_ms=20000,
|
| 383 |
+
python_path=None,
|
| 384 |
+
no_auto_start=False,
|
| 385 |
+
gateway_provider_ids=("openai-codex",),
|
| 386 |
+
enabled=True,
|
| 387 |
+
)
|
| 388 |
+
assert "pythonPath" not in without_python["config"]
|
| 389 |
+
assert without_python["config"]["customFlag"] is True
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def test_wrap_openclaw_no_auto_start_does_not_default_python_path(
|
| 393 |
+
runner: CliRunner, plugin_dir: Path
|
| 394 |
+
) -> None:
|
| 395 |
+
calls: list[dict] = []
|
| 396 |
+
|
| 397 |
+
def which(name: str) -> str | None:
|
| 398 |
+
return {"openclaw": "openclaw", "npm": "npm"}.get(name)
|
| 399 |
+
|
| 400 |
+
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
|
| 401 |
+
with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)):
|
| 402 |
+
result = runner.invoke(
|
| 403 |
+
main,
|
| 404 |
+
[
|
| 405 |
+
"wrap",
|
| 406 |
+
"openclaw",
|
| 407 |
+
"--plugin-path",
|
| 408 |
+
str(plugin_dir),
|
| 409 |
+
"--skip-build",
|
| 410 |
+
"--no-auto-start",
|
| 411 |
+
"--no-restart",
|
| 412 |
+
],
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
assert result.exit_code == 0, result.output
|
| 416 |
+
set_entry = next(
|
| 417 |
+
c
|
| 418 |
+
for c in calls
|
| 419 |
+
if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]
|
| 420 |
+
)
|
| 421 |
+
payload = json.loads(set_entry["cmd"][4])
|
| 422 |
+
assert payload["config"]["autoStart"] is False
|
| 423 |
+
assert "pythonPath" not in payload["config"]
|
| 424 |
+
|
| 425 |
+
|
| 426 |
def test_wrap_openclaw_fails_for_npm_mode_hook_pack_bug_without_local_fallback(
|
| 427 |
runner: CliRunner,
|
| 428 |
) -> None:
|
|
|
|
| 664 |
|
| 665 |
assert result.exit_code == 0, result.output
|
| 666 |
assert ["openclaw", "gateway", "restart"] not in [c["cmd"] for c in calls]
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
def test_unwrap_openclaw_fails_when_openclaw_missing(runner: CliRunner) -> None:
|
| 670 |
+
with patch("headroom.cli.wrap.shutil.which", return_value=None):
|
| 671 |
+
result = runner.invoke(main, ["unwrap", "openclaw"])
|
| 672 |
+
|
| 673 |
+
assert result.exit_code != 0
|
| 674 |
+
assert "'openclaw' not found in PATH" in result.output
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
def test_unwrap_openclaw_verbose_prints_gateway_and_inspect_output(runner: CliRunner) -> None:
|
| 678 |
+
def which(name: str) -> str | None:
|
| 679 |
+
return {"openclaw": "openclaw"}.get(name)
|
| 680 |
+
|
| 681 |
+
def run(cmd, **kwargs): # noqa: ANN001
|
| 682 |
+
if cmd[:4] == ["openclaw", "config", "get", "plugins.entries.headroom"]:
|
| 683 |
+
return MagicMock(
|
| 684 |
+
returncode=0,
|
| 685 |
+
stdout=json.dumps({"enabled": True, "config": {"proxyPort": 8787}}),
|
| 686 |
+
stderr="",
|
| 687 |
+
)
|
| 688 |
+
if cmd[:3] == ["openclaw", "gateway", "restart"]:
|
| 689 |
+
return MagicMock(returncode=0, stdout="gateway-restarted", stderr="")
|
| 690 |
+
if cmd[:3] == ["openclaw", "plugins", "inspect"]:
|
| 691 |
+
return MagicMock(returncode=0, stdout="inspect-disabled", stderr="")
|
| 692 |
+
return MagicMock(returncode=0, stdout="", stderr="")
|
| 693 |
+
|
| 694 |
+
with patch("headroom.cli.wrap.shutil.which", side_effect=which):
|
| 695 |
+
with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
|
| 696 |
+
result = runner.invoke(main, ["unwrap", "openclaw", "--verbose"])
|
| 697 |
+
|
| 698 |
+
assert result.exit_code == 0, result.output
|
| 699 |
+
assert "gateway-restarted" in result.output
|
| 700 |
+
assert "inspect-disabled" in result.output
|
tests/test_openai_codex_routing.py
CHANGED
|
@@ -1,7 +1,17 @@
|
|
| 1 |
import base64
|
| 2 |
import json
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
def _jwt(payload: dict) -> str:
|
|
@@ -56,3 +66,152 @@ def test_resolve_codex_routing_leaves_regular_openai_bearer_tokens_unchanged():
|
|
| 56 |
|
| 57 |
assert is_chatgpt is False
|
| 58 |
assert "ChatGPT-Account-ID" not in headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import base64
|
| 2 |
import json
|
| 3 |
+
import sys
|
| 4 |
+
from types import SimpleNamespace
|
| 5 |
+
from unittest.mock import MagicMock, patch
|
| 6 |
|
| 7 |
+
import anyio
|
| 8 |
+
import pytest
|
| 9 |
+
from fastapi import Request
|
| 10 |
+
|
| 11 |
+
from headroom.proxy.handlers.openai import (
|
| 12 |
+
OpenAIHandlerMixin,
|
| 13 |
+
_resolve_codex_routing_headers,
|
| 14 |
+
)
|
| 15 |
|
| 16 |
|
| 17 |
def _jwt(payload: dict) -> str:
|
|
|
|
| 66 |
|
| 67 |
assert is_chatgpt is False
|
| 68 |
assert "ChatGPT-Account-ID" not in headers
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_resolve_codex_routing_returns_none_without_bearer_auth():
|
| 72 |
+
headers, is_chatgpt = _resolve_codex_routing_headers({})
|
| 73 |
+
|
| 74 |
+
assert is_chatgpt is False
|
| 75 |
+
assert headers == {}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_resolve_codex_routing_ignores_non_jwt_bearer_tokens():
|
| 79 |
+
headers, is_chatgpt = _resolve_codex_routing_headers(
|
| 80 |
+
{
|
| 81 |
+
"authorization": "Bearer not-a-jwt",
|
| 82 |
+
}
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
assert is_chatgpt is False
|
| 86 |
+
assert headers["authorization"] == "Bearer not-a-jwt"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_resolve_codex_routing_ignores_invalid_jwt_payloads():
|
| 90 |
+
invalid_payload = base64.urlsafe_b64encode(b"not-json").decode("ascii").rstrip("=")
|
| 91 |
+
token = f"test-header.{invalid_payload}.signature"
|
| 92 |
+
|
| 93 |
+
headers, is_chatgpt = _resolve_codex_routing_headers(
|
| 94 |
+
{
|
| 95 |
+
"authorization": f"Bearer {token}",
|
| 96 |
+
}
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
assert is_chatgpt is False
|
| 100 |
+
assert headers["authorization"] == f"Bearer {token}"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class _DummyMetrics:
|
| 104 |
+
async def record_request(self, **kwargs): # noqa: ANN003
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
async def record_failed(self):
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class _DummyTokenizer:
|
| 112 |
+
def count_messages(self, messages):
|
| 113 |
+
return len(messages)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class _ResponseStub:
|
| 117 |
+
def json(self):
|
| 118 |
+
return {"usage": {"input_tokens": 2, "output_tokens": 1}}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class _DummyOpenAIHandler(OpenAIHandlerMixin):
|
| 122 |
+
OPENAI_API_URL = "https://api.openai.com"
|
| 123 |
+
|
| 124 |
+
def __init__(self) -> None:
|
| 125 |
+
self.rate_limiter = None
|
| 126 |
+
self.metrics = _DummyMetrics()
|
| 127 |
+
self.config = SimpleNamespace(optimize=False)
|
| 128 |
+
self.usage_reporter = None
|
| 129 |
+
self.openai_provider = SimpleNamespace()
|
| 130 |
+
self.anthropic_backend = None
|
| 131 |
+
self.cost_tracker = None
|
| 132 |
+
self.captured_request: tuple[str, str, dict, dict] | None = None
|
| 133 |
+
|
| 134 |
+
async def _next_request_id(self) -> str:
|
| 135 |
+
return "req-1"
|
| 136 |
+
|
| 137 |
+
def _extract_tags(self, headers: dict[str, str]) -> list[str]:
|
| 138 |
+
return []
|
| 139 |
+
|
| 140 |
+
async def _retry_request(self, method: str, url: str, headers: dict, body: dict):
|
| 141 |
+
self.captured_request = (method, url, headers, body)
|
| 142 |
+
return _ResponseStub()
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _build_request(body: dict, headers: dict[str, str]) -> Request:
|
| 146 |
+
payload = json.dumps(body).encode("utf-8")
|
| 147 |
+
|
| 148 |
+
async def receive():
|
| 149 |
+
return {"type": "http.request", "body": payload, "more_body": False}
|
| 150 |
+
|
| 151 |
+
scope = {
|
| 152 |
+
"type": "http",
|
| 153 |
+
"asgi": {"version": "3.0"},
|
| 154 |
+
"http_version": "1.1",
|
| 155 |
+
"method": "POST",
|
| 156 |
+
"scheme": "https",
|
| 157 |
+
"path": "/v1/responses",
|
| 158 |
+
"raw_path": b"/v1/responses",
|
| 159 |
+
"query_string": b"",
|
| 160 |
+
"headers": [
|
| 161 |
+
(key.lower().encode("utf-8"), value.encode("utf-8")) for key, value in headers.items()
|
| 162 |
+
],
|
| 163 |
+
"client": ("127.0.0.1", 12345),
|
| 164 |
+
"server": ("testserver", 443),
|
| 165 |
+
}
|
| 166 |
+
return Request(scope, receive)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_handle_openai_responses_routes_chatgpt_auth_to_backend_api(monkeypatch):
|
| 170 |
+
token = _jwt(
|
| 171 |
+
{
|
| 172 |
+
"https://api.openai.com/auth": {
|
| 173 |
+
"chatgpt_account_id": "acct-from-jwt",
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
)
|
| 177 |
+
request = _build_request(
|
| 178 |
+
{"model": "gpt-5.4", "input": "hello"},
|
| 179 |
+
{"Authorization": f"Bearer {token}"},
|
| 180 |
+
)
|
| 181 |
+
handler = _DummyOpenAIHandler()
|
| 182 |
+
|
| 183 |
+
monkeypatch.setattr("headroom.tokenizers.get_tokenizer", lambda model: _DummyTokenizer())
|
| 184 |
+
|
| 185 |
+
anyio.run(handler.handle_openai_responses, request)
|
| 186 |
+
|
| 187 |
+
assert handler.captured_request is not None
|
| 188 |
+
method, url, headers, body = handler.captured_request
|
| 189 |
+
assert method == "POST"
|
| 190 |
+
assert url == "https://chatgpt.com/backend-api/codex/responses"
|
| 191 |
+
assert headers["ChatGPT-Account-ID"] == "acct-from-jwt"
|
| 192 |
+
assert body["input"] == "hello"
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class _DummyWebSocket:
|
| 196 |
+
def __init__(self, headers: dict[str, str]):
|
| 197 |
+
self.headers = headers
|
| 198 |
+
self.accepted_subprotocol = None
|
| 199 |
+
|
| 200 |
+
async def accept(self, subprotocol=None):
|
| 201 |
+
self.accepted_subprotocol = subprotocol
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def test_handle_openai_responses_ws_resolves_codex_routing_headers():
|
| 205 |
+
class SentinelError(RuntimeError):
|
| 206 |
+
pass
|
| 207 |
+
|
| 208 |
+
handler = _DummyOpenAIHandler()
|
| 209 |
+
websocket = _DummyWebSocket({"authorization": "Bearer token"})
|
| 210 |
+
|
| 211 |
+
with patch.dict(sys.modules, {"websockets": MagicMock()}):
|
| 212 |
+
with patch(
|
| 213 |
+
"headroom.proxy.handlers.openai._resolve_codex_routing_headers",
|
| 214 |
+
side_effect=SentinelError("resolved"),
|
| 215 |
+
):
|
| 216 |
+
with pytest.raises(SentinelError, match="resolved"):
|
| 217 |
+
anyio.run(handler.handle_openai_responses_ws, websocket)
|