mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
Union resolutions in the four files both sides touched; approval-card sizes settle on the type-scale tokens.
727 lines
26 KiB
Python
727 lines
26 KiB
Python
"""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
|
||
)
|