Skip to content

Commit a3dc83b

Browse files
authored
Merge pull request #364 from CyberAgentAILab/fix/assert-genai-edit-square-output
Add OpenRouter GPT-5 structured output e2e tests and bump to 0.3.1-beta
2 parents 56ccd01 + 000964b commit a3dc83b

5 files changed

Lines changed: 245 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 0.3.1-beta (2025-11-04)
4+
5+
### Added
6+
- Added end-to-end OpenRouter GPT-5 structured output tests covering both JSON schema metadata checks and BaseModel response_format usage.
7+
38
## 0.3.0-beta (2025-06-25)
49

510
### Breaking Changes
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""End-to-end test: GPT-5 structured output using a Pydantic BaseModel schema."""
2+
3+
import json
4+
5+
import pytest
6+
from httpx import AsyncClient
7+
from pinjected import design, injected
8+
from pinjected.test import injected_pytest
9+
from pydantic import BaseModel
10+
11+
from packages.openai_support.conftest import apikey_skip_if_needed
12+
from pinjected_openai.openrouter.util import build_openrouter_response_format
13+
14+
15+
pytestmark = pytest.mark.e2e
16+
apikey_skip_if_needed()
17+
18+
19+
class SimpleResponse(BaseModel):
20+
"""Minimal schema for GPT-5 structured output checks."""
21+
22+
answer: str
23+
confidence: float
24+
25+
26+
@pytest.mark.asyncio
27+
@injected_pytest(design(openrouter_api_key=injected("openrouter_api_key__personal")))
28+
async def test_openrouter_gpt5_structured_with_basemodel(
29+
openrouter_api_key: str,
30+
logger,
31+
/,
32+
) -> None:
33+
"""Send a BaseModel-derived response_format to OpenRouter GPT-5."""
34+
headers = {
35+
"Authorization": f"Bearer {openrouter_api_key}",
36+
"Content-Type": "application/json",
37+
}
38+
39+
# Build response_format from the BaseModel using the same helper as production code
40+
response_format = build_openrouter_response_format(SimpleResponse)
41+
42+
payload = {
43+
"model": "openai/gpt-5-nano",
44+
"messages": [
45+
{
46+
"role": "user",
47+
"content": (
48+
"Return the capital city of France as JSON with a confidence score."
49+
),
50+
}
51+
],
52+
"max_completion_tokens": 512,
53+
"temperature": 0,
54+
"response_format": response_format,
55+
}
56+
57+
async with AsyncClient(timeout=60.0) as client:
58+
completion_resp = await client.post(
59+
"https://openrouter.ai/api/v1/chat/completions",
60+
headers=headers,
61+
json=payload,
62+
)
63+
completion_resp.raise_for_status()
64+
body = completion_resp.json()
65+
logger.info(
66+
"OpenRouter GPT-5 BaseModel response metadata: %s"
67+
% json.dumps(
68+
{
69+
"id": body.get("id"),
70+
"provider": body.get("provider"),
71+
"usage": body.get("usage"),
72+
}
73+
)
74+
)
75+
76+
if "error" in body:
77+
error = body["error"]
78+
if (
79+
isinstance(error, dict)
80+
and error.get("metadata", {}).get("raw", {}).get("code")
81+
== "insufficient_quota"
82+
):
83+
pytest.fail("GPT-5 structured BaseModel request failed: insufficient quota")
84+
pytest.fail(f"OpenRouter returned error: {json.dumps(error, indent=2)}")
85+
86+
choices = body.get("choices", [])
87+
if not choices:
88+
pytest.fail("No choices returned from GPT-5 structured BaseModel call")
89+
90+
choice = choices[0]
91+
message = choice.get("message", {})
92+
93+
if "error" in choice:
94+
raw = choice["error"].get("metadata", {}).get("raw", {})
95+
if raw.get("code") == "insufficient_quota":
96+
pytest.fail("GPT-5 structured BaseModel choice failed: insufficient quota")
97+
pytest.fail(f"Error in choice payload: {json.dumps(choice, indent=2)}")
98+
99+
content = message.get("content", "")
100+
if not content.strip():
101+
pytest.fail("GPT-5 returned empty content for structured BaseModel output")
102+
103+
parsed = SimpleResponse.model_validate_json(content)
104+
assert "paris" in parsed.answer.lower(), parsed
105+
assert parsed.confidence >= 0, parsed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""End-to-end verification that GPT-5 models accept structured output via OpenRouter."""
2+
3+
import json
4+
5+
import pytest
6+
from httpx._client import (
7+
AsyncClient as RealAsyncClient,
8+
) # bypass test-time httpx patching
9+
from pinjected import design, injected
10+
from pinjected.test import injected_pytest
11+
from pydantic import BaseModel
12+
13+
from packages.openai_support.conftest import apikey_skip_if_needed
14+
15+
16+
pytestmark = pytest.mark.e2e
17+
apikey_skip_if_needed()
18+
19+
20+
class SimpleResponse(BaseModel):
21+
"""Minimal schema to validate structured JSON output."""
22+
23+
answer: str
24+
confidence: float
25+
26+
27+
@pytest.mark.asyncio
28+
@injected_pytest(design(openrouter_api_key=injected("openrouter_api_key__company")))
29+
async def test_openrouter_gpt5_structured_response(
30+
openrouter_api_key: str,
31+
logger,
32+
/,
33+
) -> None:
34+
"""Ensure GPT-5 models report and honour structured output support."""
35+
headers = {
36+
"Authorization": f"Bearer {openrouter_api_key}",
37+
"Content-Type": "application/json",
38+
}
39+
40+
async with RealAsyncClient(timeout=60.0) as client:
41+
# Check capability metadata first
42+
models_resp = await client.get("https://openrouter.ai/api/v1/models")
43+
models_resp.raise_for_status()
44+
models_data = models_resp.json().get("data", [])
45+
46+
gpt5_models = {
47+
m["id"]: m.get("supported_parameters", [])
48+
for m in models_data
49+
if m.get("id", "").startswith("openai/gpt-5")
50+
}
51+
assert gpt5_models, "No GPT-5 models returned from OpenRouter /models endpoint"
52+
53+
for model_id, params in gpt5_models.items():
54+
assert "response_format" in params, (
55+
f"{model_id} missing response_format support flag"
56+
)
57+
assert "structured_outputs" in params, (
58+
f"{model_id} missing structured_outputs support flag"
59+
)
60+
61+
# Run a live structured-output request against the smallest GPT-5 tier
62+
payload = {
63+
"model": "openai/gpt-5-nano",
64+
"messages": [
65+
{
66+
"role": "user",
67+
"content": (
68+
"Return the capital city of France and your confidence as JSON."
69+
" Ensure the answer field contains 'Paris'."
70+
),
71+
}
72+
],
73+
"max_completion_tokens": 512,
74+
"temperature": 0,
75+
"response_format": {
76+
"type": "json_schema",
77+
"json_schema": {
78+
"name": "SimpleResponse",
79+
"schema": {
80+
"type": "object",
81+
"properties": {
82+
"answer": {"type": "string"},
83+
"confidence": {"type": "number"},
84+
},
85+
"required": ["answer", "confidence"],
86+
"additionalProperties": False,
87+
},
88+
},
89+
},
90+
}
91+
92+
completion_resp = await client.post(
93+
"https://openrouter.ai/api/v1/chat/completions",
94+
headers=headers,
95+
json=payload,
96+
)
97+
completion_resp.raise_for_status()
98+
body = completion_resp.json()
99+
logger.info(
100+
"OpenRouter GPT-5 structured response metadata: %s"
101+
% json.dumps(
102+
{
103+
"id": body.get("id"),
104+
"provider": body.get("provider"),
105+
"usage": body.get("usage"),
106+
}
107+
)
108+
)
109+
110+
# Handle top-level errors
111+
if "error" in body:
112+
pytest.fail(f"OpenRouter returned error: {body['error']}")
113+
114+
choices = body.get("choices", [])
115+
assert choices, (
116+
f"No choices in OpenRouter response: {json.dumps(body, indent=2)}"
117+
)
118+
choice = choices[0]
119+
120+
# If provider reports insufficient quota, mark the test as skipped (environmental)
121+
if "error" in choice:
122+
raw = choice["error"].get("metadata", {}).get("raw", {})
123+
if raw.get("code") == "insufficient_quota":
124+
pytest.fail("OpenAI quota exhausted for GPT-5 structured output test")
125+
pytest.fail(f"Error in choice payload: {json.dumps(choice, indent=2)}")
126+
127+
content = choice.get("message", {}).get("content", "")
128+
if not content.strip():
129+
pytest.fail("GPT-5 returned empty content for structured output")
130+
131+
parsed = SimpleResponse.model_validate_json(content)
132+
assert "paris" in parsed.answer.lower(), parsed
133+
assert parsed.confidence >= 0, parsed

pinjected/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
# I want to use IProxy() as constructor. and also type check. what can i do?
2626

27-
__version__ = "0.3.0-beta"
27+
__version__ = "0.3.1-beta"
2828

2929
__all__ = [
3030
"AsyncResolver",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "pinjected"
7-
version = "0.3.0-beta"
7+
version = "0.3.1-beta"
88
description = "Immutable Dependency Injection for Python."
99
authors = [
1010
{ name = "proboscis", email = "nameissoap@gmail.com" }

0 commit comments

Comments
 (0)