Merge pull request #129 from Alphaxalchemy/feat/openai-compatible-endpoints

feat(sleep): support OpenAI-compatible endpoints (DeepSeek, vLLM) in azure_openai backend
This commit is contained in:
Yifan Yang
2026-07-14 18:10:17 +09:00
committed by GitHub
6 changed files with 681 additions and 18 deletions
+2 -1
View File
@@ -70,7 +70,8 @@ def _add_common(p: argparse.ArgumentParser) -> None:
p.add_argument("--project", default="")
p.add_argument("--scope", default="", choices=["", "all", "invoked"])
p.add_argument("--backend", default="",
choices=["", "mock", "claude", "codex", "copilot", "handoff"])
choices=["", "mock", "claude", "codex", "copilot", "handoff",
"azure_openai"])
p.add_argument("--model", default="")
p.add_argument("--codex-path", default="", help="path to the real @openai/codex binary")
p.add_argument("--claude-home", default="", help="override ~/.claude (also isolates state)")
+134 -17
View File
@@ -1222,24 +1222,52 @@ _AZURE_MI_CLIENT_ID = "8cafa2b1-a2a7-4ad9-814a-ffe4aed7e800"
class AzureOpenAIBackend(CliBackend):
"""Drives Azure OpenAI gpt-5.x deployments via managed identity.
"""Drives Azure OpenAI gpt-5.x deployments via managed identity, or any
OpenAI-compatible chat-completions server via explicit compat auth.
Mirrors the intern's blog_1 setup (avail_api.md): managed-identity auth, the
same endpoints/deployments. Reuses CliBackend's attempt/judge/reflect prompts
and JSON parsing; only _call() differs. openai + azure-identity are lazy
imported so the mock/CLI paths stay dependency-free.
OpenAI-compatible mode (opt-in, matching skillopt.model.azure_openai):
* AZURE_OPENAI_AUTH_MODE=openai_compatible selects a plain ``openai.OpenAI``
client with AZURE_OPENAI_API_KEY against AZURE_OPENAI_ENDPOINT.
* SKILLOPT_SLEEP_COMPAT_MAX_TOKENS (int, default 8192) caps completion
length via the standard ``max_tokens`` parameter in compat mode.
* SKILLOPT_SLEEP_CHAT_EXTRA_BODY (JSON object) is passed as ``extra_body``
for provider-specific request fields (e.g. DeepSeek's
``{"thinking": {"type": "enabled"}}``). Nothing provider-specific is
inferred from model names.
"""
name = "azure"
_COMPAT_MODES = {"openai_compatible", "compat", "openai"}
# Hosts the managed-identity (AAD bearer token) path may talk to. A custom
# endpoint outside these domains must use explicit compat auth — we never
# send Azure credentials to an arbitrary host.
_AZURE_HOST_SUFFIXES = (".openai.azure.com", ".cognitiveservices.azure.com")
def __init__(self, deployment: str = "", endpoint: str = "", timeout: int = 180,
api_version: str = "2024-12-01-preview") -> None:
super().__init__(model=deployment or "gpt-5.5", timeout=timeout)
self.deployment = deployment or "gpt-5.5"
self.endpoint = endpoint or self._endpoint_for(self.deployment)
# Endpoint resolution order: explicit arg > AZURE_OPENAI_ENDPOINT env >
# the built-in Azure endpoint table. Honoring the env var lets callers
# point this backend at any OpenAI-compatible server (DeepSeek, a local
# vLLM, etc.) without editing the hardcoded _AZURE_ENDPOINTS map.
self.endpoint = (
endpoint
or os.environ.get("AZURE_OPENAI_ENDPOINT", "").strip()
or self._endpoint_for(self.deployment)
)
self.api_version = api_version
self.name = f"azure:{self.deployment}"
self._client = None
# Opt-in request knobs (read once; see class docstring).
self.compat_max_tokens = self._read_compat_max_tokens()
self.chat_extra_body = self._read_chat_extra_body()
@staticmethod
def _endpoint_for(deployment: str) -> str:
@@ -1248,16 +1276,79 @@ class AzureOpenAIBackend(CliBackend):
return ep
return "https://oaidr9.openai.azure.com/"
@classmethod
def _compat_mode(cls) -> bool:
return os.environ.get("AZURE_OPENAI_AUTH_MODE", "").strip().lower() in cls._COMPAT_MODES
@staticmethod
def _read_compat_max_tokens() -> int:
raw = os.environ.get("SKILLOPT_SLEEP_COMPAT_MAX_TOKENS", "").strip()
try:
val = int(raw) if raw else 8192
return val if val > 0 else 8192
except ValueError:
import logging
logging.getLogger("skillopt_sleep").warning(
"ignoring non-integer SKILLOPT_SLEEP_COMPAT_MAX_TOKENS=%r", raw)
return 8192
@staticmethod
def _read_chat_extra_body() -> Optional[Dict[str, Any]]:
raw = os.environ.get("SKILLOPT_SLEEP_CHAT_EXTRA_BODY", "").strip()
if not raw:
return None
try:
parsed = json.loads(raw)
except ValueError:
parsed = None
if isinstance(parsed, dict) and parsed:
return parsed
import logging
logging.getLogger("skillopt_sleep").warning(
"ignoring SKILLOPT_SLEEP_CHAT_EXTRA_BODY: not a non-empty JSON object: %r",
raw[:100])
return None
def _is_azure_host(self) -> bool:
from urllib.parse import urlparse
host = (urlparse(self.endpoint).hostname or "").lower()
return host.endswith(self._AZURE_HOST_SUFFIXES)
def _get_client(self):
if self._client is None:
from azure.identity import ManagedIdentityCredential, get_bearer_token_provider
from openai import AzureOpenAI
cred = ManagedIdentityCredential(client_id=_AZURE_MI_CLIENT_ID)
tp = get_bearer_token_provider(cred, "https://cognitiveservices.azure.com/.default")
self._client = AzureOpenAI(
azure_endpoint=self.endpoint, azure_ad_token_provider=tp,
api_version=self.api_version, max_retries=4,
)
# AZURE_OPENAI_AUTH_MODE=openai_compatible (matching the sibling
# skillopt.model.azure_openai module) selects a plain OpenAI client
# against a raw base_url. This is what makes any OpenAI-compatible
# endpoint work: the AzureOpenAI client would otherwise rewrite the
# URL with Azure-only query params (?api-version=...) and deployment
# path segments, which non-Azure servers reject with a 404.
if self._compat_mode():
from openai import OpenAI
self._client = OpenAI(
base_url=self.endpoint.rstrip("/"),
api_key=os.environ.get("AZURE_OPENAI_API_KEY"),
max_retries=4,
)
else:
# Security guard: the managed-identity path attaches an Azure AD
# bearer token to every request. Refuse to do that for a custom
# non-Azure endpoint — leaking a live AAD token to an arbitrary
# host must be impossible by (mis)configuration.
if not self._is_azure_host():
raise ValueError(
"azure_openai backend: refusing to send Azure managed-identity "
f"credentials to non-Azure endpoint {self.endpoint!r}. For "
"OpenAI-compatible servers set AZURE_OPENAI_AUTH_MODE="
"openai_compatible and AZURE_OPENAI_API_KEY."
)
from azure.identity import ManagedIdentityCredential, get_bearer_token_provider
from openai import AzureOpenAI
cred = ManagedIdentityCredential(client_id=_AZURE_MI_CLIENT_ID)
tp = get_bearer_token_provider(cred, "https://cognitiveservices.azure.com/.default")
self._client = AzureOpenAI(
azure_endpoint=self.endpoint, azure_ad_token_provider=tp,
api_version=self.api_version, max_retries=4,
)
return self._client
def _call(self, prompt: str, *, max_tokens: int = 1024, retries: int = 5) -> str:
@@ -1275,13 +1366,26 @@ class AzureOpenAIBackend(CliBackend):
client = self._get_client()
last_exc = None
for attempt in range(max(1, retries)):
n_attempts = max(1, retries)
for attempt in range(n_attempts):
try:
resp = client.chat.completions.create(
model=self.deployment,
messages=[{"role": "user", "content": prompt}],
max_completion_tokens=16384,
)
kwargs: Dict[str, Any] = {
"model": self.deployment,
"messages": [{"role": "user", "content": prompt}],
}
# Provider-neutral request shape: compat mode speaks the
# standard OpenAI-compatible contract (`max_tokens`); the Azure
# gpt-5.x deployments require `max_completion_tokens`. Any
# provider-specific body fields are opt-in via
# SKILLOPT_SLEEP_CHAT_EXTRA_BODY (see class docstring) — never
# inferred from the model name.
if self._compat_mode():
kwargs["max_tokens"] = self.compat_max_tokens
else:
kwargs["max_completion_tokens"] = 16384
if self.chat_extra_body:
kwargs["extra_body"] = self.chat_extra_body
resp = client.chat.completions.create(**kwargs)
text = (resp.choices[0].message.content or "").strip()
try:
u = resp.usage
@@ -1289,15 +1393,28 @@ class AzureOpenAIBackend(CliBackend):
except Exception:
pass
if text:
# A recovered retry must not leave a stale error behind:
# last_call_error always reflects the LATEST outcome.
self.last_call_error = ""
return text
# empty but no exception: model genuinely returned nothing — one
# quick retry can help (reasoning models occasionally yield empty)
last_exc = "empty-response"
except Exception as e: # noqa: BLE001
last_exc = e
# Surface the error so a 0.0 night is diagnosable (e.g. a 404
# from a mis-pointed endpoint) instead of a silent empty->0.
self.last_call_error = str(e)[:500]
# backoff before next try (skip after the final attempt)
if attempt < retries - 1:
if attempt < n_attempts - 1:
_t.sleep(min(8.0, (2 ** attempt) * 0.5) + _r.random() * 0.4)
if last_exc == "empty-response":
# All attempts "succeeded" HTTP-wise but carried no text — say so,
# otherwise a run of empty completions is indistinguishable from a
# never-called backend in diagnostics.
self.last_call_error = (
f"{self.deployment}: empty response on all {n_attempts} attempts"
)
return ""