Files
openworker/tests/test_openai_responses.py
T
Rohit C Prasad 87b4eebc15 Merge main into feature/permission-modes
Union resolutions in the four files both sides touched; approval-card sizes settle on the type-scale tokens.
2026-08-23 01:01:57 -07:00

727 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OpenAI Responses provider — message/tool conversion, complete(), stream(), sidecar
replay, param-fix retries. SDK-free: the fake client mimics the OpenAI SDK's
`responses.create` surface with dicts/SimpleNamespace objects, the same pattern the
Gemini/Anthropic provider tests use."""
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from coworker.providers.openai_responses import (
OpenAIResponsesProvider,
_param_fix_retry,
convert_messages,
convert_tools,
)
def test_responses_custom_base_url_reaches_sdk(monkeypatch):
captured: dict = {}
def fake_openai(**kwargs):
captured.update(kwargs)
return SimpleNamespace()
monkeypatch.setattr("openai.OpenAI", fake_openai)
provider = OpenAIResponsesProvider(
api_key="ark-key",
base_url="https://ark.example/api/v3/",
)
provider._ensure_client()
assert captured == {
"api_key": "ark-key",
"base_url": "https://ark.example/api/v3",
}
def test_stock_openai_responses_path_unchanged(monkeypatch):
"""Lockdown: stock OpenAI must not receive a vendor base URL."""
captured: dict = {}
def fake_openai(**kwargs):
captured.update(kwargs)
return SimpleNamespace()
monkeypatch.setattr("openai.OpenAI", fake_openai)
provider = OpenAIResponsesProvider(api_key="openai-key")
provider._ensure_client()
assert captured == {"api_key": "openai-key"}
# -- fakes ------------------------------------------------------------------------
class _FakeClient:
"""Records the kwargs passed to responses.create; raises queued errors first (to
exercise the param-fix retries), then returns the canned response — or, when the
request asked for stream=True, an iterator of canned events."""
def __init__(self, response=None, events=None, errors=None):
self.kwargs: dict = {}
self.calls: list[dict] = []
errors = list(errors or [])
def create(**kwargs):
self.kwargs = kwargs
self.calls.append(kwargs)
if errors:
raise errors.pop(0)
if kwargs.get("stream"):
return iter(events or [])
return response
self.responses = SimpleNamespace(create=create)
def _response(output, status="completed", incomplete_details=None):
return SimpleNamespace(
output=output, status=status, incomplete_details=incomplete_details
)
def _message_item(text):
return {
"type": "message",
"id": "msg_1",
"role": "assistant",
"content": [{"type": "output_text", "text": text}],
}
def _reasoning_item(summaries, encrypted="enc-blob"):
item = {
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": s} for s in summaries],
}
if encrypted:
item["encrypted_content"] = encrypted
return item
def _call_item(call_id, name, arguments):
return {
"type": "function_call",
"id": f"fc_{call_id}",
"call_id": call_id,
"name": name,
"arguments": arguments,
}
# -- message conversion -------------------------------------------------------------
def test_convert_extracts_leading_system_as_instructions():
instructions, items = convert_messages(
[
{"role": "system", "content": "be helpful"},
{"role": "system", "content": "be brief"},
{"role": "user", "content": "hi"},
]
)
assert instructions == "be helpful\n\nbe brief"
assert items == [{"role": "user", "content": "hi"}]
def test_convert_mid_thread_system_stays_a_message():
_, items = convert_messages(
[
{"role": "user", "content": "hi"},
{"role": "system", "content": "steering"},
]
)
assert items[1] == {"role": "system", "content": "steering"}
def test_convert_user_parts_to_input_parts():
_, items = convert_messages(
[
{
"role": "user",
"content": [
{"type": "text", "text": "what is this"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,iVBORw0KGgo="},
},
{
"type": "file",
"file": {
"filename": "report.pdf",
"file_data": "data:application/pdf;base64,JVBERi0=",
},
},
],
}
]
)
assert items[0]["content"] == [
{"type": "input_text", "text": "what is this"},
{"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo="},
{
"type": "input_file",
"filename": "report.pdf",
"file_data": "data:application/pdf;base64,JVBERi0=",
},
]
def test_convert_synthesizes_assistant_and_tool_items():
# No `_openai` sidecar (history from another provider): items are rebuilt from the
# canonical fields, and foreign toolu_ ids still pair call → output.
_, items = convert_messages(
[
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "on it",
"tool_calls": [
{
"id": "toolu_abc",
"type": "function",
"function": {"name": "f", "arguments": '{"x": 1}'},
}
],
},
{"role": "tool", "tool_call_id": "toolu_abc", "content": '{"ok": true}'},
]
)
assert items[1] == {"role": "assistant", "content": "on it"}
assert items[2] == {
"type": "function_call",
"call_id": "toolu_abc",
"name": "f",
"arguments": '{"x": 1}',
}
assert items[3] == {
"type": "function_call_output",
"call_id": "toolu_abc",
"output": '{"ok": true}',
}
def test_convert_replays_openai_sidecar_verbatim():
sidecar_items = [
_reasoning_item(["thinking"], encrypted="blob"),
_message_item("on it"),
_call_item("call_1", "f", "{}"),
]
_, items = convert_messages(
[
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "on it",
"tool_calls": [
{"id": "call_1", "function": {"name": "f", "arguments": "{}"}}
],
"_openai": {"items": sidecar_items},
},
{"role": "tool", "tool_call_id": "call_1", "content": "done"},
]
)
# The sidecar items go in verbatim — no synthesized duplicates alongside.
assert items[1:4] == sidecar_items
assert items[4]["type"] == "function_call_output"
def test_convert_ignores_foreign_sidecars():
_, items = convert_messages(
[
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "hi",
"_gemini": {"text_sig": "abc"},
},
]
)
assert items[1] == {"role": "assistant", "content": "hi"}
def test_convert_empty_assistant_tool_turn_emits_no_message_item():
_, items = convert_messages(
[
{"role": "user", "content": "go"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "c1", "function": {"name": "f", "arguments": "{}"}}
],
},
]
)
assert [i.get("type") for i in items[1:]] == ["function_call"]
# -- tool schema conversion ----------------------------------------------------------
def test_convert_tools_flattens_function_schemas():
tools = convert_tools(
[
{"type": "function", "function": {"name": "bare"}},
{
"type": "function",
"function": {
"name": "full",
"description": "does things",
"parameters": {
"type": "object",
"properties": {"x": {"type": "integer"}},
},
},
},
]
)
assert tools[0] == {"type": "function", "name": "bare"}
assert tools[1]["name"] == "full" and "function" not in tools[1]
assert tools[1]["parameters"]["properties"] == {"x": {"type": "integer"}}
assert convert_tools(None) == []
# -- complete() ----------------------------------------------------------------------
def test_complete_default_request_shape_PathsUnchanged():
fake = _FakeClient(response=_response([_message_item("hello")]))
provider = OpenAIResponsesProvider(client=fake)
turn = provider.complete(
model="gpt-5.6-sol",
messages=[
{"role": "system", "content": "sys"},
{"role": "user", "content": "hi"},
],
)
assert turn.text == "hello" and turn.finish_reason == "stop"
assert not turn.has_tool_calls and turn.extras == {}
assert fake.kwargs["model"] == "gpt-5.6-sol"
assert fake.kwargs["instructions"] == "sys"
assert fake.kwargs["store"] is False
assert fake.kwargs["include"] == ["reasoning.encrypted_content"]
assert fake.kwargs["reasoning"] == {"summary": "auto"}
def test_complete_extracts_usage_with_cache_split():
# OPE-101: the Responses API reports `input_tokens` INCLUSIVE of the cached share;
# normalized like every other adapter — fresh input = input cached, cache_read
# carries the cached share. Before this, the field was dropped entirely and every
# Responses-routed model metered as 0 tokens.
resp = _response([_message_item("hello")])
resp.usage = SimpleNamespace(
input_tokens=1500,
output_tokens=80,
input_tokens_details=SimpleNamespace(cached_tokens=1400),
)
provider = OpenAIResponsesProvider(client=_FakeClient(response=resp))
turn = provider.complete(model="m", messages=[{"role": "user", "content": "hi"}])
assert turn.usage is not None
assert (turn.usage.input, turn.usage.output, turn.usage.cache_read) == (100, 80, 1400)
def test_complete_usage_degrades_on_partial_or_missing_fields():
# Compat/older servers may omit `input_tokens_details` or the whole usage object —
# never a crash, and absence stays None (not a fake zero-usage).
resp = _response([_message_item("x")])
resp.usage = SimpleNamespace(input_tokens=500, output_tokens=20) # no details
provider = OpenAIResponsesProvider(client=_FakeClient(response=resp))
turn = provider.complete(model="m", messages=[{"role": "user", "content": "hi"}])
assert (turn.usage.input, turn.usage.output, turn.usage.cache_read) == (500, 20, 0)
bare = _response([_message_item("y")]) # SimpleNamespace without a usage attr at all
turn2 = OpenAIResponsesProvider(client=_FakeClient(response=bare)).complete(
model="m", messages=[{"role": "user", "content": "hi"}]
)
assert turn2.usage is None
def test_complete_can_omit_reasoning_summary_but_keep_encrypted_content():
"""BytePlus accepts encrypted reasoning output but rejects reasoning.summary."""
fake = _FakeClient(response=_response([_message_item("hello")]))
provider = OpenAIResponsesProvider(client=fake, reasoning_summary=False)
provider.complete(model="m", messages=[{"role": "user", "content": "hi"}])
assert "reasoning" not in fake.kwargs
assert fake.kwargs["include"] == ["reasoning.encrypted_content"]
def test_reasoning_summary_capability_rejects_unknown_mode():
with pytest.raises(TypeError, match="reasoning_summary must be a bool"):
OpenAIResponsesProvider(client=SimpleNamespace(), reasoning_summary="auto")
def test_complete_parses_function_calls_with_call_ids():
fake = _FakeClient(
response=_response(
[
_message_item("on it"),
_call_item("call_a", "write_file", '{"path": "a.txt"}'),
_call_item("call_b", "read_file", "not json"),
]
)
)
provider = OpenAIResponsesProvider(client=fake)
turn = provider.complete(model="m", messages=[{"role": "user", "content": "go"}])
assert turn.text == "on it" and turn.finish_reason == "tool_calls"
assert [(c.id, c.name) for c in turn.tool_calls] == [
("call_a", "write_file"),
("call_b", "read_file"),
]
assert turn.tool_calls[0].arguments == {"path": "a.txt"}
assert turn.tool_calls[1].arguments == {"_raw": "not json"}
def test_complete_surfaces_reasoning_summary_and_sidecar():
items = [
_reasoning_item(["plan a", " then b"], encrypted="blob"),
_message_item("answer"),
_call_item("call_1", "f", "{}"),
]
provider = OpenAIResponsesProvider(client=_FakeClient(response=_response(items)))
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
assert turn.reasoning == "plan a then b"
assert turn.extras["_openai"]["items"] == items
def test_complete_drops_unresolvable_reasoning_from_sidecar():
# No encrypted_content (e.g. `include` got param-fix-dropped): replaying the item
# under store:false would 400, so it must not enter the sidecar.
items = [
_reasoning_item(["hmm"], encrypted=None),
_call_item("call_1", "f", "{}"),
]
provider = OpenAIResponsesProvider(client=_FakeClient(response=_response(items)))
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
assert turn.reasoning == "hmm" # still displayed…
kinds = [i["type"] for i in turn.extras["_openai"]["items"]]
assert kinds == ["function_call"] # …but never replayed
def test_complete_plain_text_has_no_sidecar():
provider = OpenAIResponsesProvider(
client=_FakeClient(response=_response([_message_item("plain")]))
)
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
assert turn.extras == {}
def test_complete_maps_incomplete_max_tokens_to_length():
provider = OpenAIResponsesProvider(
client=_FakeClient(
response=_response(
[_message_item("truncat")],
status="incomplete",
incomplete_details=SimpleNamespace(reason="max_output_tokens"),
)
)
)
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
assert turn.finish_reason == "length"
def test_complete_filters_and_aliases_settings():
fake = _FakeClient(response=_response([_message_item("x")]))
provider = OpenAIResponsesProvider(client=fake)
provider.complete(
model="m",
messages=[{"role": "user", "content": "x"}],
temperature=0.2,
max_tokens=512, # chat alias → max_output_tokens
frequency_penalty=0.5, # not a Responses param → dropped
reasoning_effort="high", # no effort knob in v1 → dropped
)
assert fake.kwargs["temperature"] == 0.2
assert fake.kwargs["max_output_tokens"] == 512
assert "max_tokens" not in fake.kwargs
assert "frequency_penalty" not in fake.kwargs
assert "reasoning_effort" not in fake.kwargs
def test_complete_passes_flat_tools():
fake = _FakeClient(response=_response([_message_item("x")]))
provider = OpenAIResponsesProvider(client=fake)
provider.complete(
model="m",
messages=[{"role": "user", "content": "x"}],
tools=[{"type": "function", "function": {"name": "f"}}],
)
assert fake.kwargs["tools"] == [{"type": "function", "name": "f"}]
def test_complete_parses_attr_style_sdk_objects():
# The real SDK returns typed objects, not dicts — the parser must getattr its way in.
response = SimpleNamespace(
output=[
SimpleNamespace(
type="message",
id="msg_1",
role="assistant",
content=[SimpleNamespace(type="output_text", text="hi", annotations=None)],
),
SimpleNamespace(
type="function_call",
id="fc_1",
call_id="call_1",
name="f",
arguments='{"a": 1}',
),
],
status="completed",
incomplete_details=None,
)
provider = OpenAIResponsesProvider(client=_FakeClient(response=response))
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
assert turn.text == "hi"
assert turn.tool_calls[0].id == "call_1"
assert turn.tool_calls[0].arguments == {"a": 1}
# -- param-fix retries ---------------------------------------------------------------
def test_param_fix_drops_named_parameter():
kwargs = {"model": "m", "input": [], "temperature": 0.2}
fixed = _param_fix_retry(
kwargs, Exception("Unsupported parameter: 'temperature' is not supported")
)
assert "temperature" not in fixed and kwargs["temperature"] == 0.2 # copy, not mutate
def test_param_fix_dotted_name_drops_top_level():
fixed = _param_fix_retry(
{"model": "m", "input": [], "reasoning": {"summary": "auto"}},
Exception("Unsupported parameter: 'reasoning.summary'"),
)
assert "reasoning" not in fixed
def test_param_fix_reraises_unknown_errors():
with pytest.raises(Exception, match="rate limit"):
_param_fix_retry({"model": "m", "input": []}, Exception("rate limit exceeded"))
def test_complete_retries_dropping_rejected_params():
# A non-reasoning model rejecting `reasoning` then `include` — both retried away.
fake = _FakeClient(
response=_response([_message_item("ok")]),
errors=[
Exception("Unsupported parameter: 'reasoning'"),
Exception("Unsupported value: 'include[0]'"),
],
)
provider = OpenAIResponsesProvider(client=fake)
turn = provider.complete(model="gpt-4.1", messages=[{"role": "user", "content": "x"}])
assert turn.text == "ok"
assert len(fake.calls) == 3
assert "reasoning" not in fake.kwargs and "include" not in fake.kwargs
# -- stream() ------------------------------------------------------------------------
def test_stream_yields_deltas_then_final_turn_from_completed_event():
final = _response(
[
_reasoning_item(["mull it over"], encrypted="blob"),
_message_item("hello"),
]
)
events = [
SimpleNamespace(type="response.created"),
SimpleNamespace(type="response.reasoning_summary_text.delta", delta="mull "),
SimpleNamespace(type="response.reasoning_summary_text.delta", delta="it over"),
SimpleNamespace(type="response.output_text.delta", delta="hel"),
SimpleNamespace(type="response.output_text.delta", delta="lo"),
SimpleNamespace(type="response.completed", response=final),
]
provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
out = list(provider.stream(model="m", messages=[{"role": "user", "content": "x"}]))
assert [c.reasoning_delta for c in out if c.reasoning_delta] == ["mull ", "it over"]
assert [c.text_delta for c in out if c.text_delta] == ["hel", "lo"]
turn = out[-1].turn
assert turn.text == "hello" and turn.reasoning == "mull it over"
assert turn.finish_reason == "stop"
# Encrypted reasoning is replay-worthy even without function calls.
assert [i["type"] for i in turn.extras["_openai"]["items"]] == [
"reasoning",
"message",
]
def test_stream_final_turn_carries_tool_calls_and_sidecar():
final = _response(
[
_reasoning_item(["plan"], encrypted="blob"),
_call_item("call_1", "f", '{"x": 1}'),
]
)
events = [SimpleNamespace(type="response.completed", response=final)]
provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
turn = list(
provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
)[-1].turn
assert turn.finish_reason == "tool_calls"
assert turn.tool_calls[0].arguments == {"x": 1}
assert [i["type"] for i in turn.extras["_openai"]["items"]] == [
"reasoning",
"function_call",
]
def test_stream_rebuilds_turn_when_terminal_output_is_empty():
"""The subscription backend leaves `output` EMPTY on response.completed — items only
ever stream. The turn must be rebuilt from the output_item.done events, or text and
tool calls silently vanish (live bug: a turn produced deltas, then persisted empty)."""
events = [
SimpleNamespace(type="response.output_text.delta", delta="pong"),
SimpleNamespace(
type="response.output_item.done", item=_message_item("pong")
),
SimpleNamespace(
type="response.output_item.done", item=_call_item("call_1", "f", '{"x": 1}')
),
SimpleNamespace(type="response.completed", response=_response([])),
]
provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
turn = list(
provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
)[-1].turn
assert turn.text == "pong"
assert turn.finish_reason == "tool_calls"
assert turn.tool_calls[0].arguments == {"x": 1}
def test_stream_falls_back_to_deltas_when_no_items_repeat_anywhere():
events = [
SimpleNamespace(type="response.output_text.delta", delta="po"),
SimpleNamespace(type="response.output_text.delta", delta="ng"),
SimpleNamespace(type="response.completed", response=_response([])),
]
provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
turn = list(
provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
)[-1].turn
assert turn.text == "pong" and turn.finish_reason == "stop"
def test_stream_without_terminal_event_keeps_accumulated_text():
events = [SimpleNamespace(type="response.output_text.delta", delta="partial")]
provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
turn = list(
provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
)[-1].turn
assert turn.text == "partial" and turn.finish_reason is None
assert turn.usage is None # nothing terminal arrived — no usage to invent
def test_stream_terminal_event_carries_usage():
# OPE-101 streaming path: usage rides the terminal `response.completed` event's full
# response object, which the stream parses whole — same extraction as complete().
final = _response([_message_item("done")])
final.usage = SimpleNamespace(
input_tokens=1430,
output_tokens=65,
input_tokens_details=SimpleNamespace(cached_tokens=1408),
)
events = [
SimpleNamespace(type="response.output_text.delta", delta="done"),
SimpleNamespace(type="response.completed", response=final),
]
provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
turn = list(
provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
)[-1].turn
assert turn.usage is not None
assert (turn.usage.input, turn.usage.output, turn.usage.cache_read) == (22, 65, 1408)
def test_stream_requests_stream_flag():
fake = _FakeClient(events=[])
provider = OpenAIResponsesProvider(client=fake)
list(provider.stream(model="m", messages=[{"role": "user", "content": "x"}]))
assert fake.kwargs["stream"] is True
# -- round trip ----------------------------------------------------------------------
def test_sidecar_round_trip_replays_what_complete_stored():
"""A tool loop: turn 1's sidecar items must be exactly what turn 2's request replays."""
items = [
_reasoning_item(["plan"], encrypted="blob"),
_call_item("call_1", "f", "{}"),
]
provider = OpenAIResponsesProvider(client=_FakeClient(response=_response(items)))
turn = provider.complete(model="m", messages=[{"role": "user", "content": "go"}])
# The engine persists canonical fields + extras (engine._assistant_message):
assistant_message = {
"role": "assistant",
"content": turn.text or "",
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.name, "arguments": json.dumps(tc.arguments)},
}
for tc in turn.tool_calls
],
**turn.extras,
}
fake2 = _FakeClient(response=_response([_message_item("done")]))
provider2 = OpenAIResponsesProvider(client=fake2)
provider2.complete(
model="m",
messages=[
{"role": "user", "content": "go"},
assistant_message,
{"role": "tool", "tool_call_id": "call_1", "content": "ok"},
],
)
sent = fake2.kwargs["input"]
assert sent[1:3] == items # replayed verbatim, reasoning first
assert sent[3] == {
"type": "function_call_output",
"call_id": "call_1",
"output": "ok",
}
def test_ensure_client_without_key_raises(monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
with pytest.raises(RuntimeError, match="No model API key"):
OpenAIResponsesProvider()._ensure_client()
# -- registry routing ----------------------------------------------------------------
def test_registry_routes_blank_endpoint_to_responses():
from coworker.providers import OpenAIProvider
from coworker.providers.registry import build_provider_client
assert isinstance(
build_provider_client("openai", {}, None), OpenAIResponsesProvider
)
assert isinstance(
build_provider_client("openai", {"base_url": " "}, None),
OpenAIResponsesProvider,
)
# A custom endpoint (Azure, vLLM, any compat gateway) keeps Chat Completions…
custom = build_provider_client(
"openai", {"base_url": "https://my.azure.example/openai/v1"}, None
)
assert isinstance(custom, OpenAIProvider)
# …and so do Ollama and every compat vendor (their own descriptors).
assert isinstance(build_provider_client("ollama", {}, None), OpenAIProvider)
assert isinstance(
build_provider_client("deepseek", {"api_key": "sk-x"}, None), OpenAIProvider
)