fix(xai): OAuth Responses native web_search, incomplete guard, grok-composer context
- model_metadata: grok-composer-2.5-fast → 262144 (OAuth slug not in /v1/models)
- codex transport: inject native {"type":"web_search"} for is_xai_responses;
drop client web_search to avoid duplicate-name 400s
- codex adapter: do not treat in-progress server-side *_call items as incomplete
- tests: adapter, transport build_kwargs, model_metadata, oauth recovery
This commit is contained in:
@@ -5,6 +5,7 @@ import pytest
|
||||
from agent.codex_responses_adapter import (
|
||||
_format_responses_error,
|
||||
_normalize_codex_response,
|
||||
_preflight_codex_api_kwargs,
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +69,115 @@ def test_normalize_codex_response_treats_summary_only_reasoning_as_incomplete():
|
||||
assert assistant_message.codex_reasoning_items is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server-side built-in tool calls (xAI native web_search, code interpreter,
|
||||
# etc.) come back as discrete ``*_call`` output items that xAI's
|
||||
# /v1/responses surface routinely leaves at ``status="in_progress"`` even
|
||||
# when the overall ``response.status == "completed"``. These must NOT mark
|
||||
# the turn incomplete — otherwise grok-composer-2.5-fast research queries
|
||||
# (which invoke server-side web_search) get misclassified as
|
||||
# ``finish_reason="incomplete"`` and burn 3 fruitless continuation retries
|
||||
# before failing with "Codex response remained incomplete after 3
|
||||
# continuation attempts". Observed live against grok-composer-2.5-fast on
|
||||
# SuperGrok OAuth (2026-06).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_codex_response_ignores_in_progress_server_side_tool_calls():
|
||||
"""A completed response with a final message + lingering in_progress
|
||||
server-side web_search_call items resolves to 'stop', not 'incomplete'."""
|
||||
response = SimpleNamespace(
|
||||
status="completed",
|
||||
incomplete_details=None,
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="reasoning",
|
||||
id="rs_1",
|
||||
encrypted_content="opaque",
|
||||
summary=[SimpleNamespace(text="researching blades")],
|
||||
),
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[SimpleNamespace(
|
||||
type="output_text",
|
||||
text="Milwaukee M18 blade 49-16-2734, ~$30 OEM.",
|
||||
)],
|
||||
),
|
||||
SimpleNamespace(type="web_search_call", status="in_progress"),
|
||||
SimpleNamespace(type="web_search_call", status="in_progress"),
|
||||
SimpleNamespace(type="web_search_call", status="in_progress"),
|
||||
],
|
||||
)
|
||||
|
||||
assistant_message, finish_reason = _normalize_codex_response(response)
|
||||
|
||||
assert finish_reason == "stop"
|
||||
assert assistant_message.content == "Milwaukee M18 blade 49-16-2734, ~$30 OEM."
|
||||
|
||||
|
||||
def test_normalize_codex_response_in_progress_message_still_incomplete():
|
||||
"""Guard scope: an in_progress *message* item (genuine model output that
|
||||
is still streaming) must still mark the turn incomplete — only
|
||||
server-side ``*_call`` items are exempted."""
|
||||
response = SimpleNamespace(
|
||||
status="completed",
|
||||
incomplete_details=None,
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
role="assistant",
|
||||
status="in_progress",
|
||||
content=[SimpleNamespace(type="output_text", text="partial...")],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
_assistant_message, finish_reason = _normalize_codex_response(response)
|
||||
|
||||
assert finish_reason == "incomplete"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _preflight_codex_api_kwargs — built-in (provider-executed) tools must pass
|
||||
# through validation. Regression guard for the xAI native web_search
|
||||
# injection: the preflight validator previously rejected any tool whose
|
||||
# ``type != "function"`` with "unsupported type", which would 400 every xAI
|
||||
# turn once the native web_search tool is declared.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_preflight_passes_native_web_search_tool_through():
|
||||
kwargs = {
|
||||
"model": "grok-composer-2.5-fast",
|
||||
"instructions": "You are helpful.",
|
||||
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
|
||||
"store": False,
|
||||
"tools": [
|
||||
{"type": "function", "name": "read_file", "description": "Read.",
|
||||
"parameters": {"type": "object", "properties": {}}},
|
||||
{"type": "web_search"},
|
||||
],
|
||||
}
|
||||
out = _preflight_codex_api_kwargs(kwargs, allow_stream=True)
|
||||
tools = out["tools"]
|
||||
assert {"type": "web_search"} in tools
|
||||
assert any(t.get("type") == "function" and t.get("name") == "read_file" for t in tools)
|
||||
|
||||
|
||||
def test_preflight_still_rejects_unknown_tool_type():
|
||||
kwargs = {
|
||||
"model": "grok-composer-2.5-fast",
|
||||
"instructions": "You are helpful.",
|
||||
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
|
||||
"store": False,
|
||||
"tools": [{"type": "totally_made_up_tool"}],
|
||||
}
|
||||
with pytest.raises(ValueError, match="unsupported type"):
|
||||
_preflight_codex_api_kwargs(kwargs, allow_stream=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_responses_error — adapted from anomalyco/opencode#28757.
|
||||
# Provider failures should surface BOTH the code (rate_limit_exceeded /
|
||||
|
||||
@@ -142,6 +142,7 @@ class TestDefaultContextLengths:
|
||||
("grok-4", 256000),
|
||||
("grok-4-0709", 256000),
|
||||
("grok-build-0.1", 256000),
|
||||
("grok-composer-2.5-fast", 262144),
|
||||
("grok-code-fast-1", 256000),
|
||||
("grok-3", 131072),
|
||||
("grok-3-mini", 131072),
|
||||
|
||||
@@ -263,6 +263,72 @@ class TestCodexBuildKwargs:
|
||||
# full history.
|
||||
assert "reasoning.encrypted_content" in kw.get("include", [])
|
||||
|
||||
def test_xai_injects_native_web_search_tool(self, transport):
|
||||
"""xAI path declares xAI's native server-side web_search built-in so
|
||||
grok server-side search runs to completion (otherwise the turn stalls
|
||||
as reasoning-with-no-answer -> false 'incomplete' -> 3 retries -> fail).
|
||||
"""
|
||||
messages = [{"role": "user", "content": "Find current prices."}]
|
||||
kw = transport.build_kwargs(
|
||||
model="grok-composer-2.5-fast", messages=messages,
|
||||
tools=[{"type": "function", "function": {
|
||||
"name": "read_file", "description": "Read a file.",
|
||||
"parameters": {"type": "object",
|
||||
"properties": {"path": {"type": "string"}}}}}],
|
||||
is_xai_responses=True,
|
||||
)
|
||||
tool_types = [t.get("type") for t in kw.get("tools", [])]
|
||||
assert "web_search" in tool_types, kw.get("tools")
|
||||
# Non-conflicting client-side tools are preserved.
|
||||
names = [t.get("name") for t in kw.get("tools", []) if t.get("type") == "function"]
|
||||
assert "read_file" in names
|
||||
|
||||
def test_xai_drops_clientside_web_search_to_avoid_duplicate(self, transport):
|
||||
"""When the client registers its own 'web_search' function, the xAI
|
||||
path must drop it and rely on the native built-in — otherwise xAI
|
||||
returns HTTP 400 'Duplicate tool names: web_search'."""
|
||||
messages = [{"role": "user", "content": "Search the web."}]
|
||||
kw = transport.build_kwargs(
|
||||
model="grok-composer-2.5-fast", messages=messages,
|
||||
tools=[{"type": "function", "function": {
|
||||
"name": "web_search", "description": "Search the web.",
|
||||
"parameters": {"type": "object",
|
||||
"properties": {"query": {"type": "string"}}}}}],
|
||||
is_xai_responses=True,
|
||||
)
|
||||
tools = kw.get("tools", [])
|
||||
# Exactly one tool named/typed web_search, and it is the native built-in.
|
||||
web_search_entries = [
|
||||
t for t in tools
|
||||
if t.get("name") == "web_search" or t.get("type") == "web_search"
|
||||
]
|
||||
assert len(web_search_entries) == 1
|
||||
assert web_search_entries[0] == {"type": "web_search"}
|
||||
# No client-side function form of web_search survives.
|
||||
assert not any(
|
||||
t.get("type") == "function" and t.get("name") == "web_search"
|
||||
for t in tools
|
||||
)
|
||||
|
||||
def test_non_xai_path_does_not_inject_native_web_search(self, transport):
|
||||
"""Native web_search injection is scoped to xAI — Codex/GitHub paths
|
||||
keep the client-side web_search function untouched."""
|
||||
messages = [{"role": "user", "content": "Search."}]
|
||||
kw = transport.build_kwargs(
|
||||
model="gpt-5.4", messages=messages,
|
||||
tools=[{"type": "function", "function": {
|
||||
"name": "web_search", "description": "Search the web.",
|
||||
"parameters": {"type": "object",
|
||||
"properties": {"query": {"type": "string"}}}}}],
|
||||
is_xai_responses=False,
|
||||
)
|
||||
tools = kw.get("tools", [])
|
||||
assert not any(t.get("type") == "web_search" for t in tools)
|
||||
assert any(
|
||||
t.get("type") == "function" and t.get("name") == "web_search"
|
||||
for t in tools
|
||||
)
|
||||
|
||||
def test_xai_reasoning_disabled_no_reasoning_key(self, transport):
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
kw = transport.build_kwargs(
|
||||
|
||||
@@ -949,6 +949,26 @@ def test_grok_4_still_resolves_to_256k():
|
||||
assert DEFAULT_CONTEXT_LENGTHS[matched_key] == 256_000
|
||||
|
||||
|
||||
def test_grok_composer_context_length_is_262k():
|
||||
"""grok-composer-2.5-fast is OAuth-only and missing from /v1/models.
|
||||
|
||||
Without a specific entry it fell through to the generic ``grok`` 131k
|
||||
catch-all, under-reporting ~262k enforced on /v1/responses.
|
||||
"""
|
||||
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
|
||||
|
||||
assert DEFAULT_CONTEXT_LENGTHS["grok-composer"] == 262_144
|
||||
slug = "grok-composer-2.5-fast"
|
||||
matched_key = max(
|
||||
(k for k in DEFAULT_CONTEXT_LENGTHS if k in slug.lower()),
|
||||
key=len,
|
||||
)
|
||||
assert matched_key == "grok-composer", (
|
||||
f"Expected longest-first match on grok-composer for {slug}, got {matched_key}"
|
||||
)
|
||||
assert DEFAULT_CONTEXT_LENGTHS[matched_key] == 262_144
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-issuer reasoning replay guard
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user