Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,26 @@ Use `custom_openai` for OpenAI-compatible Chat Completions endpoints. If an endp
}
```

## Multiple System Messages

Custom OpenAI-compatible endpoints (`custom_openai`, `openrouter`, `cerebras`, and Zhipu `zai_coding`/`zai_api`) default to merging consecutive leading `system` messages into one. Strict backends (SGLang, some vLLM deployments) reject more than one leading system message — this surfaces after auto-compact, which inserts a compaction-summary system message alongside the agent's own system instructions, as a `400: System message must be at the beginning.`

The merge is harmless for endpoints that *do* support multiple system messages. If you know your endpoint handles them, opt out per model with `"supports_multiple_system_messages": true` (a JSON boolean):

```json
{
"my_model": {
"type": "custom_openai",
"name": "qwen3-sglang",
"supports_multiple_system_messages": true,
"custom_endpoint": {
"url": "http://localhost:30000/v1",
"api_key": "$API_KEY"
}
}
}
```

## Custom Model Timeouts

For custom model endpoints (`custom_openai`, `custom_anthropic`, `custom_gemini`, `cerebras`), you can configure custom timeout values to handle slow or unreliable endpoints. The default timeout for these custom endpoint models is 180 seconds.
Expand Down
56 changes: 52 additions & 4 deletions code_puppy/model_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,46 @@ def _thinking_tags_profile(
return OpenAIModelProfile(thinking_tags=tags)


def _strict_openai_profile(
model_name: str,
model_config: dict[str, Any],
*,
extra: OpenAIModelProfile | None = None,
) -> OpenAIModelProfile:
"""Build a profile for custom OpenAI-compatible endpoints (SGLang, vLLM, etc.).

Strict backends reject more than one leading system message with
``System message must be at the beginning.`` After compaction the wire
format has two: the compaction summary ``SystemPromptPart`` and the
agent's per-turn ``instruction_parts``. Setting
``openai_chat_supports_multiple_system_messages=False`` makes
pydantic-ai's ``_merge_leading_system_messages`` concatenate them into
one, which every backend accepts.

Merging is harmless for endpoints that *do* support multiple system
messages (the content is identical, just joined with ``\n\n``), so the
safe default is ``False``. Users who know their endpoint handles
multiple system messages can opt out with
``"supports_multiple_system_messages": true`` in the model config.
"""
base = _thinking_tags_profile(model_name, model_config) or {}
merged = OpenAIModelProfile(base)
if extra:
merged.update(extra)
# Config override trumps the safe default. Fail fast on non-bool
# values: a JSON string "false" would silently invert the user's intent
# (it is truthy in Python, and we cannot ``bool()``-coerce because
# ``bool("false")`` is ``True``).
supports = model_config.get("supports_multiple_system_messages", False)
if not isinstance(supports, bool):
raise TypeError(
"supports_multiple_system_messages must be a JSON boolean "
f"(true/false), got {type(supports).__name__}: {supports!r}"
)
merged["openai_chat_supports_multiple_system_messages"] = supports
return merged


def _merge_dotted_key(target: dict, dotted_key: str, value: Any) -> None:
"""Merge ``value`` into ``target`` at the path described by ``dotted_key``.

Expand Down Expand Up @@ -884,7 +924,7 @@ def get_model(model_name: str, config: Dict[str, Any]) -> Any:
return OpenAIChatModel(
model_name=model_config["name"],
provider=provider,
profile=_thinking_tags_profile(model_name, model_config),
profile=_strict_openai_profile(model_name, model_config),
)
elif model_type == "zai_coding":
api_key = get_api_key("ZAI_API_KEY")
Expand All @@ -901,6 +941,7 @@ def get_model(model_name: str, config: Dict[str, Any]) -> Any:
return ZaiChatModel(
model_name=model_config["name"],
provider=provider,
profile=_strict_openai_profile(model_name, model_config),
)
elif model_type == "zai_api":
api_key = get_api_key("ZAI_API_KEY")
Expand All @@ -917,6 +958,7 @@ def get_model(model_name: str, config: Dict[str, Any]) -> Any:
return ZaiChatModel(
model_name=model_config["name"],
provider=provider,
profile=_strict_openai_profile(model_name, model_config),
)

elif model_type == "custom_gemini":
Expand Down Expand Up @@ -974,8 +1016,14 @@ def get_model(model_name: str, config: Dict[str, Any]) -> Any:

# Cerebras rejects mixed 'strict' tool values; disable strict defs so
# pydantic-ai never sends that field (avoids wrong_api_format errors).
profile = OpenAIModelProfile(
openai_supports_strict_tool_definition=False,
# Route through _strict_openai_profile to apply the same safe
# system-message merge default and any configured thinking_tags
# (the latter was previously missed for Cerebras because the old
# bare profile skipped _thinking_tags_profile).
profile = _strict_openai_profile(
model_name,
model_config,
extra=OpenAIModelProfile(openai_supports_strict_tool_definition=False),
)

return OpenAIChatModel(
Expand Down Expand Up @@ -1016,7 +1064,7 @@ def get_model(model_name: str, config: Dict[str, Any]) -> Any:
return OpenAIChatModel(
model_name=model_config["name"],
provider=provider,
profile=_thinking_tags_profile(model_name, model_config),
profile=_strict_openai_profile(model_name, model_config),
)

# NOTE: 'chatgpt_oauth' model type is now handled by the chatgpt_oauth plugin
Expand Down
226 changes: 226 additions & 0 deletions tests/test_model_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,232 @@ def test_custom_timeout_config(monkeypatch, env_var, model_type, model_name):
assert model is not None


# --- Regression tests: 'System message must be at the beginning.' after auto-compact.
# Strict OpenAI-compatible backends (SGLang, vLLM) reject >1 leading system
# message. After SummarizingCompaction the wire format has two: the
# compaction-summary SystemPromptPart + the agent's per-turn
# instruction_parts. The profile must set
# openai_chat_supports_multiple_system_messages=False so pydantic-ai's
# _merge_leading_system_messages joins them into one.


def test_custom_openai_merges_system_messages(monkeypatch):
"""custom_openai defaults to merging leading system messages."""
monkeypatch.setenv("OPENAI_API_KEY", "ok")
config = {
"custom": {
"type": "custom_openai",
"name": "qwen-sglang",
"custom_endpoint": {
"url": "https://fake.url",
"api_key": "$OPENAI_API_KEY",
},
}
}
model = ModelFactory.get_model("custom", config)
assert model is not None
assert model.profile.get("openai_chat_supports_multiple_system_messages") is False


def test_custom_openai_multiple_system_messages_override(monkeypatch):
"""Users can opt out with ``supports_multiple_system_messages: true``."""
monkeypatch.setenv("OPENAI_API_KEY", "ok")
config = {
"custom": {
"type": "custom_openai",
"name": "qwen-sglang",
"supports_multiple_system_messages": True,
"custom_endpoint": {
"url": "https://fake.url",
"api_key": "$OPENAI_API_KEY",
},
}
}
model = ModelFactory.get_model("custom", config)
assert model is not None
assert model.profile.get("openai_chat_supports_multiple_system_messages") is True


def test_custom_openai_explicit_false_stays_false():
"""A JSON ``false`` (Python ``False``) correctly produces ``False``."""
from code_puppy.model_factory import _strict_openai_profile

profile = _strict_openai_profile("m", {"supports_multiple_system_messages": False})
assert profile.get("openai_chat_supports_multiple_system_messages") is False


def test_openrouter_merges_system_messages(monkeypatch):
"""OpenRouter routes to various backends, so merge by default too."""
monkeypatch.setenv("OPENROUTER_API_KEY", "ok")
config = {
"or": {
"type": "openrouter",
"name": "openai/test-model",
},
}
model = ModelFactory.get_model("or", config)
assert model is not None
assert model.profile.get("openai_chat_supports_multiple_system_messages") is False


def test_cerebras_profile_has_both_flags(monkeypatch):
"""Cerebras keeps strict-tool-def=False AND gains system-message merge."""
monkeypatch.setenv("CEREBRAS_API_KEY", "ok")
config = {
"cb": {
"type": "cerebras",
"name": "llama-4-scout",
},
}
model = ModelFactory.get_model("cb", config)
assert model is not None
assert model.profile.get("openai_supports_strict_tool_definition") is False
assert model.profile.get("openai_chat_supports_multiple_system_messages") is False


def test_zai_coding_merges_system_messages(monkeypatch):
"""ZAI coding endpoint gets the same safe default."""
monkeypatch.setenv("ZAI_API_KEY", "ok")
config = {
"zai": {
"type": "zai_coding",
"name": "glm-4.6",
},
}
model = ModelFactory.get_model("zai", config)
assert model is not None
assert model.profile.get("openai_chat_supports_multiple_system_messages") is False


def test_zai_api_merges_system_messages(monkeypatch):
"""ZAI API endpoint gets the same safe default (symmetry with zai_coding)."""
monkeypatch.setenv("ZAI_API_KEY", "ok")
config = {
"zai": {
"type": "zai_api",
"name": "glm-4.6",
},
}
model = ModelFactory.get_model("zai", config)
assert model is not None
assert model.profile.get("openai_chat_supports_multiple_system_messages") is False


def test_strict_openai_profile_helper():
"""_strict_openai_profile merges thinking tags + multiple-system-messages setting."""
from code_puppy.model_factory import _strict_openai_profile
from pydantic_ai.profiles.openai import OpenAIModelProfile

# Default: merge is on (False means merge)
profile = _strict_openai_profile("test-model", {})
assert profile.get("openai_chat_supports_multiple_system_messages") is False

# Override via config
profile = _strict_openai_profile(
"test-model", {"supports_multiple_system_messages": True}
)
assert profile.get("openai_chat_supports_multiple_system_messages") is True

# Extra profile settings are preserved alongside the merge flag
extra = OpenAIModelProfile(openai_supports_strict_tool_definition=False)
profile = _strict_openai_profile("test-model", {}, extra=extra)
assert profile.get("openai_supports_strict_tool_definition") is False
assert profile.get("openai_chat_supports_multiple_system_messages") is False

# Thinking-tags config + extra + merge flag all coexist (cerebras-style triple-merge)
profile = _strict_openai_profile(
"minimax-m3",
{"provider": "lilac", "name": "minimax-m3"},
extra=OpenAIModelProfile(openai_supports_strict_tool_definition=False),
)
assert profile.get("openai_chat_supports_multiple_system_messages") is False
assert profile.get("openai_supports_strict_tool_definition") is False
# Unconditional: the lilac/minimax-m3 config must resolve custom thinking
# tags, and they must survive the extra-merge.
from code_puppy.model_utils import get_thinking_tags

expected_tags = get_thinking_tags(
"minimax-m3", {"provider": "lilac", "name": "minimax-m3"}
)
assert expected_tags is not None
assert profile["thinking_tags"] == expected_tags


def test_strict_openai_profile_rejects_non_bool():
"""A non-bool ``supports_multiple_system_messages`` fails fast with TypeError.

A JSON string like ``"false"`` would otherwise silently invert the user's
intent (it is truthy); ``bool()``-coercion is no cure since
``bool("false")`` is ``True``.
"""
from code_puppy.model_factory import _strict_openai_profile

with pytest.raises(TypeError, match="must be a JSON boolean"):
_strict_openai_profile("m", {"supports_multiple_system_messages": "false"})


@pytest.mark.asyncio
async def test_wire_format_merges_leading_system_messages():
"""Integration test: after compaction, the wire format has exactly one system message.

This directly proves the bug is fixed — two leading SystemPromptParts +
instruction_parts produce one merged system message on the wire, not two.
"""
import httpx
from pydantic_ai.messages import (
InstructionPart,
ModelRequest,
ModelResponse,
SystemPromptPart,
TextPart,
UserPromptPart,
)
from pydantic_ai.models import ModelRequestParameters
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider

from code_puppy.model_factory import _strict_openai_profile

async with httpx.AsyncClient(base_url="http://localhost:30000") as client:
provider = OpenAIProvider(api_key="dummy", http_client=client)
model = OpenAIChatModel(
model_name="qwen-sglang",
provider=provider,
profile=_strict_openai_profile("qwen-sglang", {}),
)

# Simulate a post-compaction history: summary + preserved turns
messages = [
ModelRequest(
parts=[SystemPromptPart(content="[compaction-summary] Previous work.")]
),
ModelRequest(parts=[UserPromptPart(content="What is 2+2?")]),
ModelResponse(parts=[TextPart(content="4")], model_name="qwen-sglang"),
ModelRequest(
parts=[UserPromptPart(content="continue")],
instructions="You are a helpful assistant.",
),
]

prepared = model.prepare_messages(messages, None)
mrp = ModelRequestParameters(
function_tools=[],
output_mode="text",
output_object=None,
output_tools=[],
instruction_parts=[InstructionPart(content="You are a helpful assistant.")],
)
openai_messages = await model._map_messages(prepared, mrp, model_settings=None)

system_roles = [m for m in openai_messages if m.get("role") == "system"]
assert len(system_roles) == 1, (
f"Expected exactly 1 system message after merge, got {len(system_roles)}"
)
content = system_roles[0].get("content", "")
assert "[compaction-summary]" in content
assert "You are a helpful assistant." in content


def test_custom_anthropic_timeout_config(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "ok")
config = {
Expand Down
Loading