The distributed worker pool stacks on exactly two open pull requests, in this order: PR 14 supplies the broker-only SCM boundary the mediators route through and the hermes-scm-boundary-v2 ConfigMap they mount, and PR 15 supplies the cli_lane_* decomposition -- including canonical_run_id and the eligibility predicate on claim_ready -- that the coordinator depends on. Neither can be dropped without breaking a fixed P0 boundary, so both are carried here as prerequisites and this branch must not merge before them. PR 16 (agent image release lane) and PR 19 (full-handoff acceptance harness) are NOT prerequisites and are deliberately absent, so reviewing this branch no longer means approving them. PR 14 and PR 15 conflict with each other in nine paths. Each is resolved to the resolution already reviewed on this branch at 4d4cf1bd.
454 lines
16 KiB
Python
454 lines
16 KiB
Python
"""Provider subscription and OAuth contracts for Hermes chat."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
|
|
import pytest
|
|
|
|
from testing.tests.test_hermes_chat_support import (
|
|
_load_broker_module,
|
|
)
|
|
|
|
|
|
def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch):
|
|
"""The relay is bounded, stateless, and rejects unapproved models."""
|
|
module = _load_broker_module("hermes_codex_broker", "codex_broker.py", monkeypatch)
|
|
monkeypatch.setattr(module, "TOKEN", "relay-secret")
|
|
|
|
assert module._authorized("Bearer relay-secret") is True
|
|
assert module._authorized("Bearer wrong") is False
|
|
assert module._real_model("route/codex/gpt-5.6-sol/xhigh") == "gpt-5.6-sol"
|
|
payload = module._validate_payload(
|
|
{
|
|
"model": "gpt-5.6-terra",
|
|
"input": "route this chat turn",
|
|
"store": True,
|
|
"stream": False,
|
|
"max_output_tokens": 96,
|
|
"max_completion_tokens": 96,
|
|
"max_tokens": 96,
|
|
"temperature": 0.7,
|
|
"top_p": 0.9,
|
|
}
|
|
)
|
|
assert payload["store"] is False
|
|
assert payload["stream"] is True
|
|
assert "max_output_tokens" not in payload
|
|
assert "max_completion_tokens" not in payload
|
|
assert "max_tokens" not in payload
|
|
assert "temperature" not in payload
|
|
assert "top_p" not in payload
|
|
assert payload["input"] == [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "route this chat turn"}],
|
|
}
|
|
]
|
|
response_item = {
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "keep this item"}],
|
|
}
|
|
assert module._validate_payload({"model": "gpt-5.6-terra", "input": response_item})[
|
|
"input"
|
|
] == [response_item]
|
|
response_items = [response_item]
|
|
assert (
|
|
module._validate_payload({"model": "gpt-5.6-terra", "input": response_items})[
|
|
"input"
|
|
]
|
|
is response_items
|
|
)
|
|
image_items = [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "input_text", "text": "What color is this?"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": "data:image/png;base64,cHJpdmF0ZQ==",
|
|
"detail": "high",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
]
|
|
assert module._validate_payload({"model": "gpt-5.6-terra", "input": image_items})[
|
|
"input"
|
|
][0]["content"][1] == {
|
|
"type": "input_image",
|
|
"image_url": "data:image/png;base64,cHJpdmF0ZQ==",
|
|
"detail": "high",
|
|
}
|
|
switchyard_image_items = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "input_text", "text": "What color is this?"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": "data:image/png;base64,cHJpdmF0ZQ==",
|
|
"detail": "auto",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
]
|
|
assert module._validate_payload(
|
|
{"model": "gpt-5.6-terra", "input": switchyard_image_items}
|
|
)["input"][0]["content"][1] == {
|
|
"type": "input_image",
|
|
"image_url": "data:image/png;base64,cHJpdmF0ZQ==",
|
|
"detail": "auto",
|
|
}
|
|
switchyard_base64_items = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "input_text", "text": "What color is this?"},
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "image/png",
|
|
"data": "cHJpdmF0ZQ==",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
]
|
|
normalized_base64 = module._validate_payload(
|
|
{"model": "gpt-5.6-terra", "input": switchyard_base64_items}
|
|
)["input"][0]["content"][1]
|
|
assert normalized_base64 == {
|
|
"type": "input_image",
|
|
"image_url": "data:image/png;base64,cHJpdmF0ZQ==",
|
|
}
|
|
switchyard_enum_items = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "input_image",
|
|
"image_url": {
|
|
"type": "url",
|
|
"data": {
|
|
"url": "data:image/png;base64,cHJpdmF0ZQ==",
|
|
"detail": "high",
|
|
},
|
|
},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
nested_image = module._validate_payload(
|
|
{"model": "gpt-5.6-terra", "input": switchyard_enum_items}
|
|
)["input"][0]["content"][0]
|
|
assert nested_image["image_url"] == "data:image/png;base64,cHJpdmF0ZQ=="
|
|
assert nested_image["detail"] == "high"
|
|
with pytest.raises(ValueError, match=r"non-empty Responses image URL.*str\[4\]"):
|
|
module._validate_payload(
|
|
{
|
|
"model": "gpt-5.6-terra",
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "input_image",
|
|
"image_url": {"detail": "high"},
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
)
|
|
routed = module._validate_payload(
|
|
{
|
|
"model": "route/codex/gpt-5.6-luna/low",
|
|
"input": "use the low route",
|
|
"stream": False,
|
|
}
|
|
)
|
|
assert routed["model"] == "gpt-5.6-luna"
|
|
with pytest.raises(ValueError, match="unsupported Codex model"):
|
|
module._validate_payload({"model": "unapproved-model", "input": "hello"})
|
|
with pytest.raises(ValueError, match="non-empty Responses input"):
|
|
module._validate_payload({"model": "gpt-5.6-terra", "input": ""})
|
|
with pytest.raises(ValueError, match="non-empty Responses input list"):
|
|
module._validate_payload({"model": "gpt-5.6-terra", "input": []})
|
|
|
|
completed = {
|
|
"id": "resp_test",
|
|
"object": "response",
|
|
"status": "completed",
|
|
"output": [],
|
|
}
|
|
completed_item = {
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"status": "completed",
|
|
"content": [{"type": "output_text", "text": "done"}],
|
|
}
|
|
assert module._completed_response(
|
|
[
|
|
"event: response.created",
|
|
'data: {"type":"response.created","response":{}}',
|
|
"event: response.output_item.done",
|
|
"data: "
|
|
+ json.dumps(
|
|
{
|
|
"type": "response.output_item.done",
|
|
"output_index": 0,
|
|
"item": completed_item,
|
|
}
|
|
),
|
|
"event: response.completed",
|
|
"data: "
|
|
+ json.dumps({"type": "response.completed", "response": completed}),
|
|
"data: [DONE]",
|
|
]
|
|
)["output"] == [completed_item]
|
|
raw_stream = (
|
|
"event: response.output_item.done\n"
|
|
"data: "
|
|
+ json.dumps(
|
|
{
|
|
"type": "response.output_item.done",
|
|
"output_index": 0,
|
|
"item": completed_item,
|
|
}
|
|
)
|
|
+ "\n\nevent: response.completed\ndata: "
|
|
+ json.dumps({"type": "response.completed", "response": completed})
|
|
+ "\n\n"
|
|
).encode()
|
|
normalized = module._normalized_stream(
|
|
raw_stream, {**completed, "output": [completed_item]}
|
|
).decode()
|
|
terminal_data = next(
|
|
line for line in normalized.splitlines() if '"response.completed"' in line
|
|
)
|
|
assert json.loads(terminal_data.removeprefix("data: "))["response"]["output"] == [
|
|
completed_item
|
|
]
|
|
assert normalized.endswith("\n\n")
|
|
streamed_function_item = {
|
|
"type": "function_call",
|
|
"name": "read_file",
|
|
"status": "completed",
|
|
"arguments": '{"path":"/tmp"}',
|
|
}
|
|
streamed_function_body = (
|
|
"event: response.function_call_arguments.delta\n"
|
|
'data: {"type":"response.function_call_arguments.delta",'
|
|
'"item_id":"call_1","delta":"{\\"path\\":\\"/tmp\\"}"}\n\n'
|
|
"event: response.function_call_arguments.done\n"
|
|
'data: {"type":"response.function_call_arguments.done",'
|
|
'"item_id":"call_1","arguments":"{\\"path\\":\\"/tmp\\"}"}\n\n'
|
|
"event: response.output_item.done\n"
|
|
'data: {"type":"response.output_item.done","output_index":0,'
|
|
'"item":{"type":"function_call","name":"read_file",'
|
|
'"arguments":"{\\"path\\":\\"/tmp\\"}"}}\n\n'
|
|
"event: response.completed\n"
|
|
"data: "
|
|
+ json.dumps(
|
|
{
|
|
"type": "response.completed",
|
|
"response": {**completed, "output": [streamed_function_item]},
|
|
}
|
|
)
|
|
+ "\n\n"
|
|
).encode()
|
|
normalized_function_stream = module._normalized_stream(
|
|
streamed_function_body, {**completed, "output": [streamed_function_item]}
|
|
).decode()
|
|
assert "response.function_call_arguments.delta" in normalized_function_stream
|
|
assert "response.function_call_arguments.done" not in normalized_function_stream
|
|
assert "response.output_item.done" not in normalized_function_stream
|
|
normalized_terminal = next(
|
|
line
|
|
for line in normalized_function_stream.splitlines()
|
|
if '"response.completed"' in line
|
|
)
|
|
assert (
|
|
json.loads(normalized_terminal.removeprefix("data: "))["response"]["output"]
|
|
== []
|
|
)
|
|
with pytest.raises(RuntimeError, match="retryable incomplete response"):
|
|
module._completed_response(
|
|
[
|
|
"event: response.incomplete",
|
|
'data: {"type":"response.incomplete","response":'
|
|
'{"status":"incomplete","incomplete_details":'
|
|
'{"reason":"max_output_tokens"}}}',
|
|
]
|
|
)
|
|
with pytest.raises(RuntimeError, match="provider unavailable"):
|
|
module._completed_response(
|
|
[
|
|
"event: error",
|
|
'data: {"type":"error","error":{"message":"provider unavailable"}}',
|
|
]
|
|
)
|
|
malformed_tool_item = {
|
|
"type": "function_call",
|
|
"name": "search_files",
|
|
"status": "completed",
|
|
"arguments": '{"path":"","offset":',
|
|
}
|
|
with pytest.raises(RuntimeError, match="malformed function arguments"):
|
|
module._completed_response(
|
|
[
|
|
"event: response.output_item.done",
|
|
"data: "
|
|
+ json.dumps(
|
|
{
|
|
"type": "response.output_item.done",
|
|
"output_index": 0,
|
|
"item": malformed_tool_item,
|
|
}
|
|
),
|
|
"event: response.completed",
|
|
"data: "
|
|
+ json.dumps({"type": "response.completed", "response": completed}),
|
|
]
|
|
)
|
|
valid_tool_item = {
|
|
**malformed_tool_item,
|
|
"arguments": '{"path":"","offset":0}',
|
|
}
|
|
assert module._completed_response(
|
|
[
|
|
"event: response.output_item.done",
|
|
"data: "
|
|
+ json.dumps(
|
|
{
|
|
"type": "response.output_item.done",
|
|
"output_index": 0,
|
|
"item": valid_tool_item,
|
|
}
|
|
),
|
|
"event: response.completed",
|
|
"data: "
|
|
+ json.dumps({"type": "response.completed", "response": completed}),
|
|
]
|
|
)["output"] == [valid_tool_item]
|
|
with pytest.raises(RuntimeError, match="malformed function arguments"):
|
|
module._completed_response(
|
|
[
|
|
"event: response.function_call_arguments.delta",
|
|
'data: {"type":"response.function_call_arguments.delta",'
|
|
'"item_id":"call_1","output_index":0,'
|
|
'"delta":"{\\"path\\":\\"/tmp\\",\\"offset\\":"}',
|
|
"event: response.completed",
|
|
"data: "
|
|
+ json.dumps({"type": "response.completed", "response": completed}),
|
|
]
|
|
)
|
|
streamed_tool = module._completed_response(
|
|
[
|
|
"event: response.function_call_arguments.delta",
|
|
'data: {"type":"response.function_call_arguments.delta",'
|
|
'"item_id":"call_2","output_index":0,'
|
|
'"delta":"{\\"path\\":\\"/tmp\\",\\"offset\\":"}',
|
|
"event: response.function_call_arguments.done",
|
|
'data: {"type":"response.function_call_arguments.done",'
|
|
'"item_id":"call_2","output_index":0,'
|
|
'"arguments":"{\\"path\\":\\"/tmp\\",\\"offset\\":0}"}',
|
|
"event: response.completed",
|
|
"data: "
|
|
+ json.dumps({"type": "response.completed", "response": completed}),
|
|
]
|
|
)
|
|
assert streamed_tool["status"] == "completed"
|
|
|
|
auth_dir = tmp_path / ".codex"
|
|
auth_dir.mkdir()
|
|
# The token payload need only prove the broker reads CODEX_HOME directly.
|
|
encoded = (
|
|
base64.urlsafe_b64encode(json.dumps({"exp": time.time() + 3600}).encode())
|
|
.decode()
|
|
.rstrip("=")
|
|
)
|
|
(auth_dir / "auth.json").write_text(
|
|
json.dumps({"tokens": {"access_token": f"header.{encoded}.signature"}})
|
|
)
|
|
monkeypatch.setenv("CODEX_HOME", str(auth_dir))
|
|
assert module._access_token().startswith("header.")
|
|
|
|
|
|
def test_codex_broker_refreshes_and_persists_first_party_oauth(
|
|
tmp_path: Path, monkeypatch
|
|
):
|
|
"""Expired ChatGPT OAuth refreshes in the canonical Codex CLI store."""
|
|
module = _load_broker_module(
|
|
"hermes_codex_refresh_broker", "codex_broker.py", monkeypatch
|
|
)
|
|
auth_dir = tmp_path / ".codex"
|
|
auth_dir.mkdir()
|
|
|
|
def jwt(expires_at: float) -> str:
|
|
payload = (
|
|
base64.urlsafe_b64encode(json.dumps({"exp": expires_at}).encode())
|
|
.decode()
|
|
.rstrip("=")
|
|
)
|
|
return f"header.{payload}.signature"
|
|
|
|
expired = jwt(time.time() - 60)
|
|
live = jwt(time.time() + 3600)
|
|
auth_path = auth_dir / "auth.json"
|
|
auth_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"auth_mode": "chatgpt",
|
|
"tokens": {
|
|
"access_token": expired,
|
|
"refresh_token": "refresh-old",
|
|
},
|
|
}
|
|
)
|
|
)
|
|
calls = []
|
|
auth_module = ModuleType("hermes_cli.auth")
|
|
|
|
def refresh(access_token, refresh_token, *, timeout_seconds):
|
|
calls.append((access_token, refresh_token, timeout_seconds))
|
|
return {
|
|
"access_token": live,
|
|
"refresh_token": "refresh-new",
|
|
"last_refresh": "2026-08-12T20:00:00Z",
|
|
}
|
|
|
|
auth_module.refresh_codex_oauth_pure = refresh
|
|
package = ModuleType("hermes_cli")
|
|
package.auth = auth_module
|
|
monkeypatch.setitem(sys.modules, "hermes_cli", package)
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.auth", auth_module)
|
|
monkeypatch.setenv("CODEX_HOME", str(auth_dir))
|
|
|
|
assert module._access_token() == live
|
|
persisted = json.loads(auth_path.read_text())
|
|
assert persisted["tokens"]["access_token"] == live
|
|
assert persisted["tokens"]["refresh_token"] == "refresh-new"
|
|
assert persisted["last_refresh"] == "2026-08-12T20:00:00Z"
|
|
assert calls == [(expired, "refresh-old", 30.0)]
|
|
assert auth_path.stat().st_mode & 0o777 == 0o600
|
|
|
|
# A healthy token is reused, so repeated routed turns do not spend a
|
|
# refresh token or create a second billing/authentication path.
|
|
assert module._access_token() == live
|
|
assert len(calls) == 1
|