419 lines
16 KiB
Python
419 lines
16 KiB
Python
"""Transport-level checks: every model call must use /responses."""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
|
from pydantic import BaseModel, SecretStr, ValidationError
|
|
|
|
from artemis.config.settings import settings
|
|
from artemis.llm.messages import adapt_responses_messages
|
|
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
|
from artemis.services.llm import RobustChatModelWrapper
|
|
|
|
|
|
def response_body(output):
|
|
return {
|
|
"id": "resp_test",
|
|
"object": "response",
|
|
"created_at": 1,
|
|
"status": "completed",
|
|
"model": "test-model",
|
|
"output": output,
|
|
"usage": {"input_tokens": 10, "output_tokens": 3, "total_tokens": 13},
|
|
}
|
|
|
|
|
|
def text_output(text):
|
|
return [
|
|
{
|
|
"id": "msg_test",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"status": "completed",
|
|
"content": [{"type": "output_text", "text": text, "annotations": []}],
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def transport_model(monkeypatch):
|
|
from langchain_openai import ChatOpenAI
|
|
import openai
|
|
|
|
requests = []
|
|
outputs = []
|
|
|
|
def handle(request):
|
|
requests.append(request)
|
|
return httpx.Response(
|
|
200, json=response_body(outputs.pop(0) if outputs else text_output("OK"))
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
openai,
|
|
"DefaultAsyncHttpxClient",
|
|
lambda **kwargs: httpx.AsyncClient(transport=httpx.MockTransport(handle), **kwargs),
|
|
)
|
|
monkeypatch.setattr(settings, "OPENAI_API_KEY", SecretStr("test-secret"))
|
|
monkeypatch.setattr(settings, "OPENAI_BASE_URL", "https://responses.example/v1")
|
|
monkeypatch.delenv("ARTEMIS_FAKE_LLM", raising=False)
|
|
model = ModelFactory.create_model(ModelEndpoint(model_name="test-model", timeout_seconds=23))
|
|
assert isinstance(model, ChatOpenAI)
|
|
return model, requests, outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_and_image_use_responses_transport(transport_model):
|
|
model, requests, _ = transport_model
|
|
result = await model.ainvoke(
|
|
[
|
|
HumanMessage(
|
|
content=[
|
|
{"type": "text", "text": "Describe"},
|
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}},
|
|
]
|
|
)
|
|
]
|
|
)
|
|
assert requests[0].url == "https://responses.example/v1/responses"
|
|
payload = json.loads(requests[0].content)
|
|
assert payload["store"] is False
|
|
assert payload["input"][0]["content"][1]["type"] == "input_image"
|
|
assert "messages" not in payload
|
|
assert result.usage_metadata["total_tokens"] == 13
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("model_name", "reasoning_effort"),
|
|
[
|
|
("mimo-v2.6-pro", "high"),
|
|
("mimo-v2.6-flash", "high"),
|
|
("gpt-6-luna", "max"),
|
|
("gpt-6-luna", "high"),
|
|
],
|
|
)
|
|
async def test_selected_models_put_instructions_in_user_input(
|
|
transport_model, model_name, reasoning_effort
|
|
):
|
|
_, requests, _ = transport_model
|
|
endpoint = ModelEndpoint(
|
|
model_name=model_name,
|
|
reasoning_effort=reasoning_effort,
|
|
timeout_seconds=23,
|
|
max_retries=0,
|
|
)
|
|
model = ModelFactory.create_model(endpoint)
|
|
wrapper = RobustChatModelWrapper(model, endpoint=endpoint)
|
|
tool = {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "inspect_screen",
|
|
"description": "Inspect the current screen",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
await wrapper.bind_tools([tool]).ainvoke(
|
|
[
|
|
SystemMessage(content="Use the available tools."),
|
|
HumanMessage(
|
|
content=[
|
|
{"type": "text", "text": "Inspect this screen."},
|
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}},
|
|
]
|
|
),
|
|
]
|
|
)
|
|
|
|
payload = json.loads(requests[-1].content)
|
|
assert payload["model"] == model_name
|
|
assert payload["reasoning"]["effort"] == reasoning_effort
|
|
assert payload["input"][0]["role"] == "user"
|
|
assert payload["input"][0]["content"][0]["text"] == "Use the available tools."
|
|
assert payload["input"][0]["content"][2]["type"] == "input_image"
|
|
assert payload["tools"][0]["name"] == "inspect_screen"
|
|
assert all(item.get("role") != "system" for item in payload["input"])
|
|
|
|
|
|
@pytest.mark.parametrize("model_name", ["gpt-6-luna", "GPT-5.6-luna"])
|
|
def test_gpt_message_adaptation_keeps_history_and_images(model_name):
|
|
image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
|
|
original = [
|
|
SystemMessage(content="First instruction"),
|
|
SystemMessage(content="Second instruction"),
|
|
HumanMessage(content=[{"type": "text", "text": "Observation"}, image]),
|
|
SystemMessage(content="Follow-up instruction"),
|
|
]
|
|
adapted = adapt_responses_messages(original, model_name)
|
|
|
|
assert len(adapted) == 2
|
|
assert isinstance(adapted[0], HumanMessage)
|
|
assert adapted[0].content[0]["text"] == "First instruction"
|
|
assert adapted[0].content[1]["text"] == "Second instruction"
|
|
assert adapted[0].content[3] == image
|
|
assert isinstance(adapted[1], HumanMessage)
|
|
assert adapted[1].content == [{"type": "text", "text": "Follow-up instruction"}]
|
|
assert isinstance(original[0], SystemMessage)
|
|
assert len(original[2].content) == 2
|
|
|
|
|
|
@pytest.mark.parametrize("model_name", ["mimo-v2.6-pro", "mimo-v2.6-flash"])
|
|
def test_mimo_messages_use_same_user_role_format(model_name):
|
|
messages = [SystemMessage(content="Instructions"), HumanMessage(content="Task")]
|
|
adapted = adapt_responses_messages(messages, model_name)
|
|
assert len(adapted) == 1
|
|
assert isinstance(adapted[0], HumanMessage)
|
|
assert adapted[0].content[0]["text"] == "Instructions"
|
|
assert adapted[0].content[1]["text"] == "Task"
|
|
assert isinstance(messages[0], SystemMessage)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_reasoning_is_sent_unchanged_to_responses(transport_model):
|
|
model, requests, _ = transport_model
|
|
model.reasoning_effort = "max"
|
|
await model.ainvoke([HumanMessage(content="Check")])
|
|
assert requests[0].url.path == "/v1/responses"
|
|
assert json.loads(requests[0].content)["reasoning"]["effort"] == "max"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_call_roundtrip_uses_call_id(transport_model):
|
|
model, requests, outputs = transport_model
|
|
outputs.append(
|
|
[
|
|
{
|
|
"type": "function_call",
|
|
"name": "lookup",
|
|
"call_id": "call_1",
|
|
"id": "fc_1",
|
|
"arguments": '{"query":"hello"}',
|
|
"status": "completed",
|
|
}
|
|
]
|
|
)
|
|
tool = {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup",
|
|
"description": "Look up a value",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
}
|
|
messages = [HumanMessage(content="Look up hello")]
|
|
answer = await model.bind_tools([tool]).ainvoke(messages)
|
|
assert answer.tool_calls[0]["id"] == "call_1"
|
|
await model.ainvoke([*messages, answer, ToolMessage(content="found", tool_call_id="call_1")])
|
|
payload = json.loads(requests[1].content)
|
|
assert any(
|
|
item.get("type") == "function_call_output" and item["call_id"] == "call_1"
|
|
for item in payload["input"]
|
|
)
|
|
assert all(r.url.path == "/v1/responses" for r in requests)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_uses_responses_text_format(transport_model):
|
|
class Verdict(BaseModel):
|
|
ok: bool
|
|
|
|
model, requests, outputs = transport_model
|
|
outputs.append(text_output('{"ok":true}'))
|
|
result = await model.with_structured_output(Verdict, method="json_schema", strict=True).ainvoke(
|
|
"Check"
|
|
)
|
|
assert result.ok
|
|
payload = json.loads(requests[0].content)
|
|
assert payload["text"]["format"]["type"] == "json_schema"
|
|
assert "response_format" not in payload
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider", ["google", "gemini", "anthropic", "openrouter", "ollama", "custom"]
|
|
)
|
|
def test_other_provider_transports_are_rejected(provider):
|
|
with pytest.raises(ValueError, match="only OpenAI Responses"):
|
|
ModelProvider.from_string(provider)
|
|
with pytest.raises(ValidationError):
|
|
ModelEndpoint(provider=provider)
|
|
|
|
|
|
@pytest.mark.parametrize("max_retries", [0, 3])
|
|
def test_client_settings_and_cache_credentials(monkeypatch, max_retries):
|
|
monkeypatch.setattr(settings, "OPENAI_API_KEY", SecretStr("first"))
|
|
monkeypatch.setattr(settings, "OPENAI_MAX_RETRIES", max_retries)
|
|
endpoint = ModelEndpoint(api_key="test", model_name="gpt-5-mini", reasoning_effort="low")
|
|
model = ModelFactory.create_model(endpoint)
|
|
assert model.use_responses_api is True
|
|
assert model.max_retries == 0 # Gateway owns retries, never multiply at SDK layer.
|
|
assert model.reasoning_effort == "low"
|
|
dynamic = ModelEndpoint()
|
|
key = dynamic.cache_key()
|
|
monkeypatch.setattr(settings, "OPENAI_API_KEY", SecretStr("second"))
|
|
assert dynamic.cache_key() != key
|
|
assert "first" not in repr(key)
|
|
|
|
|
|
def test_web_search_uses_openai_tool():
|
|
base = MagicMock()
|
|
wrapper = RobustChatModelWrapper(base, endpoint=ModelEndpoint(enable_grounding=True))
|
|
wrapper.bind_tools([])
|
|
base.bind_tools.assert_called_once_with([{"type": "web_search"}])
|
|
|
|
|
|
def test_video_text_fallback_extracts_responses_blocks():
|
|
from langchain_core.messages import AIMessage
|
|
from artemis.agents.video_analyzer.universal_engine import _extract_submit_payload
|
|
|
|
result = AIMessage(
|
|
content=[
|
|
{"type": "reasoning", "summary": [], "id": "rs_1"},
|
|
{"type": "text", "text": "已完成视频画面检查。", "annotations": []},
|
|
]
|
|
)
|
|
summary, analysis, events = _extract_submit_payload(result)
|
|
assert summary == analysis == "已完成视频画面检查。"
|
|
assert events == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mode", ["parsed", "raw", "pipeline"])
|
|
async def test_wrapper_structured_output_uses_responses_function_call(
|
|
transport_model, monkeypatch, mode
|
|
):
|
|
class Verdict(BaseModel):
|
|
ok: bool
|
|
|
|
model, requests, outputs = transport_model
|
|
outputs.append(
|
|
[
|
|
{
|
|
"type": "function_call",
|
|
"name": "Verdict",
|
|
"call_id": "call_verdict",
|
|
"id": "fc_verdict",
|
|
"arguments": '{"ok":true}',
|
|
"status": "completed",
|
|
}
|
|
]
|
|
)
|
|
wrapper = RobustChatModelWrapper(model, endpoint=ModelEndpoint(model_name="test-model"))
|
|
metered = []
|
|
monkeypatch.setattr(
|
|
"artemis.services.llm.record_llm_usage",
|
|
lambda engine, response, **kw: metered.append(response),
|
|
)
|
|
structured = wrapper.with_structured_output(Verdict, include_raw=mode == "raw")
|
|
if mode == "pipeline":
|
|
from langchain_core.runnables import RunnableLambda
|
|
|
|
result = await (structured | RunnableLambda(lambda value: value.ok)).ainvoke("Check")
|
|
assert result is True
|
|
else:
|
|
result = await structured.complete("Check")
|
|
assert (result["parsed"] if mode == "raw" else result).ok is True
|
|
if mode == "raw":
|
|
assert result["parsing_error"] is None
|
|
assert result["raw"].tool_calls
|
|
payload = json.loads(requests[0].content)
|
|
assert requests[0].url.path == "/v1/responses"
|
|
assert payload["tools"][0]["name"] == "Verdict"
|
|
assert payload.get("text", {}).get("format", {}).get("type") != "json_schema"
|
|
from artemis.llm.usage import usage_from_message
|
|
|
|
assert len(metered) == 1
|
|
assert usage_from_message(metered[0])["total_tokens"] == 13
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_key_is_stable_for_exploration_but_separates_roles(
|
|
transport_model, monkeypatch
|
|
):
|
|
from types import SimpleNamespace
|
|
|
|
model, requests, _ = transport_model
|
|
engine = SimpleNamespace(current_session_id="run-1")
|
|
ctx = SimpleNamespace(data_engine=engine, exploration_id="exploration-1")
|
|
monkeypatch.setattr("artemis.services.llm.record_llm_usage", lambda *a, **k: None)
|
|
wrapper = RobustChatModelWrapper(
|
|
model, ctx, endpoint=ModelEndpoint(model_name="test-model"), role="operator"
|
|
)
|
|
await wrapper.ainvoke([HumanMessage(content="first")])
|
|
engine.current_session_id = "resumed-run-2"
|
|
await wrapper.ainvoke([HumanMessage(content="first"), HumanMessage(content="next")])
|
|
wrapper.role = "checker"
|
|
await wrapper.ainvoke([HumanMessage(content="verify")])
|
|
payloads = [json.loads(r.content) for r in requests]
|
|
assert payloads[0]["prompt_cache_key"] == payloads[1]["prompt_cache_key"]
|
|
assert payloads[1]["prompt_cache_key"] != payloads[2]["prompt_cache_key"]
|
|
assert "exploration-1" not in payloads[0]["prompt_cache_key"]
|
|
assert payloads[1]["input"][:1] == payloads[0]["input"]
|
|
for request, payload in zip(requests, payloads):
|
|
assert request.headers["thread-id"] == payload["prompt_cache_key"]
|
|
assert request.headers["session-id"] == payload["prompt_cache_key"]
|
|
assert request.headers["x-client-request-id"]
|
|
assert "extra_headers" not in payload
|
|
assert requests[0].headers["x-client-request-id"] != requests[1].headers["x-client-request-id"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("header", ["Thread-Id", "thread_id", "SESSION-ID", "Session_id"])
|
|
async def test_caller_affinity_header_reaches_wire_as_body_key(transport_model, header):
|
|
model, requests, _ = transport_model
|
|
wrapper = RobustChatModelWrapper(model, endpoint=ModelEndpoint(model_name="test-model"))
|
|
headers = {header: "caller-affinity"}
|
|
for _ in range(2):
|
|
await wrapper.ainvoke([HumanMessage(content="shared prefix")], extra_headers=headers)
|
|
for request in requests:
|
|
payload = json.loads(request.content)
|
|
assert payload["prompt_cache_key"] == "caller-affinity"
|
|
routing = {name.lower().replace("_", "-"): value for name, value in request.headers.items()}
|
|
assert routing["thread-id"] == routing["session-id"] == "caller-affinity"
|
|
assert "extra_headers" not in payload
|
|
assert requests[0].headers["x-client-request-id"] != requests[1].headers["x-client-request-id"]
|
|
assert headers == {header: "caller-affinity"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auxiliary_summary_calls_send_role_scoped_affinity_on_responses(transport_model):
|
|
from types import SimpleNamespace
|
|
|
|
from artemis.agents.flash.summarizer import VisualStepSummarizer
|
|
from artemis.memory.chunking import StepCapsuleLens
|
|
|
|
model, requests, outputs = transport_model
|
|
outputs.extend(
|
|
[text_output("我看到页面显示当前设置,尚未确认其他状态变化。") for _ in range(4)]
|
|
)
|
|
ctx = SimpleNamespace(data_engine=SimpleNamespace(current_session_id="summary-task"))
|
|
lens = StepCapsuleLens(model_name="test-model", llm=model, ctx=ctx)
|
|
for text in ("first chunk", "next chunk"):
|
|
await lens._invoke(model, [HumanMessage(content=text)], "test-model")
|
|
visual = VisualStepSummarizer(ctx, model_name="test-model")
|
|
visual._llm = model
|
|
for number in (1, 2):
|
|
visual._step_inputs[number] = {
|
|
"step_number": number,
|
|
"action_name": "press_key",
|
|
"action_args": {},
|
|
"pre_img_bytes": None,
|
|
"post_img_bytes": None,
|
|
"exec_outcome": "dispatched",
|
|
}
|
|
assert await visual._attempt(number)
|
|
keys = []
|
|
for request in requests:
|
|
payload = json.loads(request.content)
|
|
assert request.url.path == "/v1/responses"
|
|
key = payload["prompt_cache_key"]
|
|
assert request.headers["thread-id"] == request.headers["session-id"] == key
|
|
keys.append(key)
|
|
assert len(keys) == 4 and keys[0] == keys[1] and keys[2] == keys[3] and keys[0] != keys[2]
|