Skip to content

Commit 9b6c70f

Browse files
author
Mateusz
committed
Retry transient HTTP/2 stream terminations
1 parent 7b80fb3 commit 9b6c70f

3 files changed

Lines changed: 357 additions & 85 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import asyncio
5+
import json
6+
import os
7+
import sys
8+
import time
9+
from dataclasses import asdict, dataclass
10+
from pathlib import Path
11+
from typing import Any
12+
from unittest.mock import MagicMock
13+
14+
import httpx
15+
16+
17+
def _ensure_repo_root_on_path() -> None:
18+
here = Path(__file__).resolve()
19+
for ancestor in here.parents:
20+
if (ancestor / "src" / "connectors" / "openai.py").is_file():
21+
root = str(ancestor)
22+
if root not in sys.path:
23+
sys.path.insert(0, root)
24+
return
25+
raise RuntimeError("Could not locate repo root containing src/connectors/openai.py")
26+
27+
28+
_ensure_repo_root_on_path()
29+
30+
from src.connectors.opencode_go import OpencodeGoBackend
31+
from src.core.config.app_config import AppConfig
32+
from src.core.domain.chat import CanonicalChatRequest, ChatMessage
33+
from src.core.services.translation_service import TranslationService
34+
35+
DEFAULT_BASE_URL = "https://opencode.ai/zen/go/v1"
36+
DEFAULT_MODEL = "opencode-go:kimi-k2.5"
37+
38+
39+
@dataclass(frozen=True)
40+
class ProbeResult:
41+
http2: bool
42+
iteration: int
43+
ok: bool
44+
chunks: int
45+
elapsed_ms: float
46+
error_type: str | None
47+
error: str | None
48+
49+
50+
def _build_config() -> AppConfig:
51+
config = MagicMock(spec=AppConfig)
52+
config.streaming_yield_interval = 0.0
53+
config.backends = MagicMock()
54+
return config
55+
56+
57+
def _build_request(model: str, prompt: str, max_tokens: int) -> CanonicalChatRequest:
58+
return CanonicalChatRequest(
59+
model=model,
60+
messages=[ChatMessage(role="user", content=prompt)],
61+
max_tokens=max_tokens,
62+
stream=True,
63+
)
64+
65+
66+
async def _run_once(
67+
client: httpx.AsyncClient,
68+
backend: OpencodeGoBackend,
69+
*,
70+
http2: bool,
71+
iteration: int,
72+
model: str,
73+
prompt: str,
74+
max_tokens: int,
75+
) -> ProbeResult:
76+
_ = client
77+
started = time.perf_counter()
78+
chunks = 0
79+
try:
80+
async for _chunk in backend.stream_completion(
81+
_build_request(model, prompt, max_tokens)
82+
):
83+
chunks += 1
84+
if chunks >= 6:
85+
break
86+
return ProbeResult(
87+
http2=http2,
88+
iteration=iteration,
89+
ok=True,
90+
chunks=chunks,
91+
elapsed_ms=(time.perf_counter() - started) * 1000,
92+
error_type=None,
93+
error=None,
94+
)
95+
except Exception as exc:
96+
return ProbeResult(
97+
http2=http2,
98+
iteration=iteration,
99+
ok=False,
100+
chunks=chunks,
101+
elapsed_ms=(time.perf_counter() - started) * 1000,
102+
error_type=type(exc).__name__,
103+
error=str(exc),
104+
)
105+
106+
107+
async def _run_series(
108+
*,
109+
http2: bool,
110+
api_key: str,
111+
base_url: str,
112+
model: str,
113+
prompt: str,
114+
max_tokens: int,
115+
iterations: int,
116+
) -> list[ProbeResult]:
117+
async with httpx.AsyncClient(
118+
http2=http2,
119+
timeout=httpx.Timeout(connect=10.0, read=60.0, write=60.0, pool=60.0),
120+
trust_env=False,
121+
) as client:
122+
backend = OpencodeGoBackend(
123+
client=client,
124+
config=_build_config(),
125+
translation_service=TranslationService(),
126+
)
127+
await backend.initialize(
128+
api_key=api_key,
129+
api_base_url=base_url,
130+
openai_api_base_url=base_url,
131+
anthropic_api_base_url=base_url,
132+
key_name="opencode-go",
133+
model_protocol_overrides={},
134+
)
135+
backend.disable_health_check()
136+
try:
137+
results: list[ProbeResult] = []
138+
for iteration in range(1, iterations + 1):
139+
results.append(
140+
await _run_once(
141+
client,
142+
backend,
143+
http2=http2,
144+
iteration=iteration,
145+
model=model,
146+
prompt=prompt,
147+
max_tokens=max_tokens,
148+
)
149+
)
150+
return results
151+
finally:
152+
await backend.close()
153+
154+
155+
async def amain() -> int:
156+
parser = argparse.ArgumentParser()
157+
parser.add_argument("--api-key", default=os.environ.get("OPENCODE_GO_API_KEY"))
158+
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
159+
parser.add_argument("--model", default=DEFAULT_MODEL)
160+
parser.add_argument("--prompt", default="Reply with exactly: ok")
161+
parser.add_argument("--max-tokens", type=int, default=32)
162+
parser.add_argument("--iterations", type=int, default=16)
163+
args = parser.parse_args()
164+
165+
if not args.api_key:
166+
raise SystemExit("OPENCODE_GO_API_KEY is required")
167+
168+
results: list[dict[str, Any]] = []
169+
for http2 in (True, False):
170+
series = await _run_series(
171+
http2=http2,
172+
api_key=args.api_key,
173+
base_url=args.base_url,
174+
model=args.model,
175+
prompt=args.prompt,
176+
max_tokens=args.max_tokens,
177+
iterations=args.iterations,
178+
)
179+
results.extend(asdict(result) for result in series)
180+
181+
print(json.dumps(results, indent=2))
182+
return 0
183+
184+
185+
if __name__ == "__main__":
186+
raise SystemExit(asyncio.run(amain()))

0 commit comments

Comments
 (0)