Add Qwen retry and probe command

This commit is contained in:
Daniel Maddern 2026-08-16 15:52:46 +07:00
parent 0ed706dda1
commit 44c5ba4291
4 changed files with 187 additions and 13 deletions

View file

@ -0,0 +1,82 @@
from __future__ import annotations
import json
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from django.core.management.base import BaseCommand, CommandError
from control_plane.resources.models import Resource
from model_router.providers import QwenProvider
from model_router.router import ModelCapability, ModelRequestContract
class Command(BaseCommand):
help = "Probe Qwen/local_inference health and bounded concurrent completion capacity."
def add_arguments(self, parser):
parser.add_argument("--requests", type=int, default=1)
parser.add_argument("--concurrency", type=int, default=1)
parser.add_argument("--prompt", default="Return only valid JSON: {\"ok\": true, \"model\": \"qwen\"}.")
parser.add_argument("--token-budget", type=int, default=256)
def handle(self, *args, **options):
resource = Resource.objects.filter(provider="local_inference", is_active=True).first()
if resource is None:
raise CommandError("No Qwen/local_inference resource configured. Run seed_spark_resources first.")
provider = QwenProvider(resource)
before_health = provider.health()
total = max(1, int(options["requests"]))
concurrency = max(1, int(options["concurrency"]))
started = time.monotonic()
def call(index: int) -> dict[str, object]:
call_started = time.monotonic()
try:
response = provider.complete(
ModelRequestContract(
purpose=ModelCapability.REASONING,
prompt=f"{options['prompt']} Request index: {index}",
token_budget=int(options["token_budget"]),
)
)
return {
"index": index,
"status": "COMPLETE",
"latency_ms": int((time.monotonic() - call_started) * 1000),
"content_chars": len(response.content),
"model": response.model,
"attempts": response.metadata.get("attempts"),
}
except Exception as exc:
return {
"index": index,
"status": "FAILED",
"latency_ms": int((time.monotonic() - call_started) * 1000),
"failure": str(exc),
}
results = []
with ThreadPoolExecutor(max_workers=concurrency) as executor:
futures = {executor.submit(call, index): index for index in range(1, total + 1)}
for future in as_completed(futures):
results.append(future.result())
results.sort(key=lambda row: row["index"])
after_health = provider.health()
completed = sum(1 for row in results if row["status"] == "COMPLETE")
failed = total - completed
latencies = [int(row["latency_ms"]) for row in results]
output = {
"before_health": before_health,
"after_health": after_health,
"requests": total,
"concurrency": concurrency,
"completed": completed,
"failed": failed,
"runtime_seconds": round(time.monotonic() - started, 2),
"latency_ms": {"min": min(latencies), "max": max(latencies), "avg": round(sum(latencies) / len(latencies), 1)},
"results": results,
}
self.stdout.write(json.dumps(output, indent=2))
if failed:
raise CommandError(f"Qwen probe failed {failed}/{total} requests")

View file

@ -89,6 +89,8 @@ class Command(BaseCommand):
"endpoint_url": os.environ.get("ARTIFEX_QWEN_ENDPOINT_URL", "http://192.168.1.162:8002/v1/chat/completions"), "endpoint_url": os.environ.get("ARTIFEX_QWEN_ENDPOINT_URL", "http://192.168.1.162:8002/v1/chat/completions"),
"health_url": os.environ.get("ARTIFEX_QWEN_HEALTH_URL", "http://192.168.1.162:8002/health"), "health_url": os.environ.get("ARTIFEX_QWEN_HEALTH_URL", "http://192.168.1.162:8002/health"),
"model": os.environ.get("ARTIFEX_QWEN_MODEL", "qwen38"), "model": os.environ.get("ARTIFEX_QWEN_MODEL", "qwen38"),
"retry_attempts": int(os.environ.get("ARTIFEX_QWEN_RETRY_ATTEMPTS", "3")),
"retry_backoff_seconds": float(os.environ.get("ARTIFEX_QWEN_RETRY_BACKOFF_SECONDS", "2")),
"extra_body": {"chat_template_kwargs": {"enable_thinking": False}}, "extra_body": {"chat_template_kwargs": {"enable_thinking": False}},
}, },
}, },

View file

@ -5,6 +5,7 @@ import os
import re import re
import shlex import shlex
import subprocess import subprocess
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
@ -113,6 +114,8 @@ class QwenProvider:
config = self.resource.config config = self.resource.config
url = str(config.get("endpoint_url", "http://localhost:8000/v1/chat/completions")) url = str(config.get("endpoint_url", "http://localhost:8000/v1/chat/completions"))
timeout = int(config.get("timeout_seconds", 120)) timeout = int(config.get("timeout_seconds", 120))
attempts = max(1, int(config.get("retry_attempts", 1)))
backoff = float(config.get("retry_backoff_seconds", 1.0))
body = { body = {
"model": config.get("model", self.resource.name), "model": config.get("model", self.resource.name),
"messages": [{"role": "user", "content": request.prompt}], "messages": [{"role": "user", "content": request.prompt}],
@ -123,17 +126,23 @@ class QwenProvider:
body["response_format"] = config["response_format"] body["response_format"] = config["response_format"]
if config.get("extra_body"): if config.get("extra_body"):
body.update(config["extra_body"]) body.update(config["extra_body"])
http_request = urllib.request.Request( failures = []
url, for attempt in range(1, attempts + 1):
data=json.dumps(body).encode("utf-8"), http_request = urllib.request.Request(
headers={"Content-Type": "application/json"}, url,
method="POST", data=json.dumps(body).encode("utf-8"),
) headers={"Content-Type": "application/json"},
try: method="POST",
with urllib.request.urlopen(http_request, timeout=timeout) as response: )
data = json.loads(response.read().decode("utf-8")) try:
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: with urllib.request.urlopen(http_request, timeout=timeout) as response:
raise ProviderError(f"Qwen provider failed: {exc}") from exc data = json.loads(response.read().decode("utf-8"))
break
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
failures.append(f"attempt {attempt}/{attempts}: {exc}")
if attempt == attempts:
raise ProviderError("Qwen provider failed after retries: " + " | ".join(failures)) from exc
time.sleep(backoff * attempt)
choices = data.get("choices", []) choices = data.get("choices", [])
content = "" content = ""
if choices: if choices:
@ -144,7 +153,11 @@ class QwenProvider:
return ModelResponseContract( return ModelResponseContract(
model=str(data.get("model", self.resource.name)), model=str(data.get("model", self.resource.name)),
content=content, content=content,
metadata={"provider": self.provider_name, "usage": data.get("usage", {})}, metadata={
"provider": self.provider_name,
"usage": data.get("usage", {}),
"attempts": len(failures) + 1,
},
) )
def health(self) -> str: def health(self) -> str:

View file

@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
import io
import urllib.error
from agents.providers import DeterministicCodingProvider, DeterministicSolProvider from agents.providers import DeterministicCodingProvider, DeterministicSolProvider
from control_plane.resources.models import ModelRequest, Resource, ResourceKind from control_plane.resources.models import ModelRequest, Resource, ResourceKind
from model_router.providers import SolProvider, providers_from_resources from model_router.providers import ProviderError, QwenProvider, SolProvider, providers_from_resources
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
@ -50,3 +53,77 @@ def test_model_router_prefers_exact_model_key_resource_for_persisted_requests()
request = ModelRequest.objects.get() request = ModelRequest.objects.get()
assert request.model == "Terra" assert request.model == "Terra"
def test_qwen_provider_retries_transient_http_failures(monkeypatch) -> None:
resource = Resource.objects.create(
name="Qwen Test",
kind=ResourceKind.MODEL,
provider="local_inference",
roles=["REASONING"],
config={
"endpoint_url": "http://qwen.test/v1/chat/completions",
"model": "qwen38",
"retry_attempts": 2,
"retry_backoff_seconds": 0,
},
)
calls = {"count": 0}
class Response:
status = 200
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return b'{"model":"qwen38","choices":[{"message":{"content":"ok"}}],"usage":{"total_tokens":1}}'
def fake_urlopen(request, timeout):
calls["count"] += 1
if calls["count"] == 1:
raise urllib.error.HTTPError(request.full_url, 500, "Internal Server Error", {}, io.BytesIO())
return Response()
monkeypatch.setattr("model_router.providers.urllib.request.urlopen", fake_urlopen)
monkeypatch.setattr("model_router.providers.time.sleep", lambda seconds: None)
response = QwenProvider(resource).complete(ModelRequestContract(purpose=ModelCapability.REASONING, prompt="hello"))
assert response.content == "ok"
assert response.metadata["attempts"] == 2
assert calls["count"] == 2
def test_qwen_provider_reports_retry_exhaustion(monkeypatch) -> None:
resource = Resource.objects.create(
name="Qwen Test",
kind=ResourceKind.MODEL,
provider="local_inference",
roles=["REASONING"],
config={
"endpoint_url": "http://qwen.test/v1/chat/completions",
"retry_attempts": 2,
"retry_backoff_seconds": 0,
},
)
def fake_urlopen(request, timeout):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr("model_router.providers.urllib.request.urlopen", fake_urlopen)
monkeypatch.setattr("model_router.providers.time.sleep", lambda seconds: None)
try:
QwenProvider(resource).complete(ModelRequestContract(purpose=ModelCapability.REASONING, prompt="hello"))
except ProviderError as exc:
message = str(exc)
else:
raise AssertionError("expected ProviderError")
assert "Qwen provider failed after retries" in message
assert "attempt 1/2" in message
assert "attempt 2/2" in message