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:
XVVH
2026-06-17 17:33:32 -07:00
committed by Teknium
parent 4b7a186003
commit 6f89e17a33
7 changed files with 310 additions and 2 deletions
+110
View File
@@ -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 /
+1
View File
@@ -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
#