feat(sleep): support OpenAI-compatible endpoints in azure_openai backend

Let AzureOpenAIBackend drive any OpenAI-compatible chat-completions server
(DeepSeek, self-hosted vLLM/Ollama, ...) alongside native Azure deployments.

- __init__ resolves the endpoint as: explicit arg > AZURE_OPENAI_ENDPOINT env
  > the built-in _AZURE_ENDPOINTS table (previously a non-Azure endpoint could
  not be supplied at all).
- _get_client() builds a plain openai.OpenAI(base_url=...) client when
  AZURE_OPENAI_AUTH_MODE=openai_compatible, matching the auth mode already
  supported by the sibling skillopt/model/azure_openai.py. This avoids the
  AzureOpenAI SDK rewriting request URLs with Azure-only ?api-version= query
  params and deployment path segments, which non-Azure servers reject with 404.
- _call() sends max_tokens + extra_body thinking flag for deepseek* models and
  records the last exception in self.last_call_error so a failed night is
  diagnosable instead of collapsing to a silent empty->0 score.

Adds docs/sleep/openai-compatible-endpoints.md and sanitized example
runner/watchdog scripts documenting an Antigravity + DeepSeek integration.
The default managed-identity Azure path is unchanged.
This commit is contained in:
Alphaxalchemy
2026-07-12 21:03:48 -06:00
parent 49a5b617c0
commit af33f6c338
4 changed files with 311 additions and 14 deletions
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Reference launcher for running SkillOpt-Sleep against an OpenAI-compatible
endpoint (DeepSeek shown here), plus an Antigravity `session-end` hook.
This is a *sanitized example*, not a supported entry point. Adapt the paths and
provider details to your environment. No API keys are hardcoded — the key is read
from an .env file or the process environment.
Usage:
python runner.py run # run a full sleep cycle against DeepSeek
python runner.py dry-run # harvest + replay, report only
python runner.py session-end # Antigravity Stop-hook: append rollout evidence
"""
import os
import re
import sys
import json
import subprocess
import datetime
from pathlib import Path
# --- Configure these for your environment -----------------------------------
# Path to a file containing your provider key as `sk-...` (kept out of source).
PROVIDER_ENV_FILE = Path(os.environ.get("SKILLOPT_PROVIDER_ENV_FILE", "provider.env"))
# Endpoint + model for the OpenAI-compatible provider.
PROVIDER_ENDPOINT = os.environ.get("SKILLOPT_PROVIDER_ENDPOINT", "https://api.deepseek.com")
PROVIDER_MODEL = os.environ.get("SKILLOPT_PROVIDER_MODEL", "deepseek-v4-pro")
# Project whose SKILL.md files the sleep cycle should evolve.
PROJECT_DIR = os.environ.get("SKILLOPT_PROJECT_DIR", os.getcwd())
# Where the session-end hook appends rollout evidence.
ROLLOUT_LOG = Path(os.environ.get("SKILLOPT_ROLLOUT_LOG", "brain/rollout-evidence.jsonl"))
# ----------------------------------------------------------------------------
def load_provider_key(env: dict) -> None:
"""Ensure DEEPSEEK_API_KEY is set, reading it from PROVIDER_ENV_FILE if needed."""
if env.get("DEEPSEEK_API_KEY"):
return
try:
text = PROVIDER_ENV_FILE.read_text(encoding="utf-8")
except OSError:
return
m = re.search(r"sk-[A-Za-z0-9]+", text)
if m:
env["DEEPSEEK_API_KEY"] = m.group(0)
def main() -> None:
if len(sys.argv) < 2:
print("Usage: runner.py [dry-run|run|status|adopt|session-end]")
sys.exit(1)
command = sys.argv[1]
# Antigravity Stop-hook: enrich future nights with task-outcome metadata.
if command == "session-end":
ROLLOUT_LOG.parent.mkdir(parents=True, exist_ok=True)
outcome = {
"timestamp": datetime.datetime.now().isoformat(),
"event": "SessionEnd",
"metadata": "Appended task outcome metadata",
}
with open(ROLLOUT_LOG, "a", encoding="utf-8") as f:
f.write(json.dumps(outcome) + "\n")
print("Rollout evidence metadata appended.")
return
env = os.environ.copy()
load_provider_key(env)
if env.get("DEEPSEEK_API_KEY"):
# OpenAI-compatible path — see docs/sleep/openai-compatible-endpoints.md
backend = "azure_openai"
env["PYTHONIOENCODING"] = "utf-8"
for prefix in ("", "OPTIMIZER_", "TARGET_"):
env[f"{prefix}AZURE_OPENAI_AUTH_MODE"] = "openai_compatible"
env[f"{prefix}AZURE_OPENAI_ENDPOINT"] = PROVIDER_ENDPOINT
env[f"{prefix}AZURE_OPENAI_API_KEY"] = env["DEEPSEEK_API_KEY"]
env["TARGET_DEPLOYMENT"] = PROVIDER_MODEL
env["OPTIMIZER_DEPLOYMENT"] = PROVIDER_MODEL
else:
# OPTIONAL, UNVERIFIED fallback: route the `claude` CLI backend through a
# local Anthropic-compatible proxy (e.g. LiteLLM) to reach Gemini. There
# is no native Gemini backend; this path was not validated. See the doc.
backend = "claude"
if "ANTHROPIC_API_KEY" not in env and "GEMINI_API_KEY" in env:
env["ANTHROPIC_API_KEY"] = env["GEMINI_API_KEY"]
env.setdefault("ANTHROPIC_BASE_URL", "http://127.0.0.1:4000")
args = ["skillopt-sleep", command]
if command in ("run", "dry-run"):
args = ["skillopt-sleep", command, "--backend", backend,
"--model", PROVIDER_MODEL, "--project", PROJECT_DIR]
print(f"Running: {' '.join(args)}")
subprocess.run(args, env=env, check=False)
if __name__ == "__main__":
main()
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Minimal supervisor that runs the SkillOpt-Sleep cycle on a fixed interval.
Sanitized example (see docs/sleep/openai-compatible-endpoints.md). On Windows,
register this under a Scheduled Task so it survives logout; on Linux/macOS a
systemd timer or cron entry serves the same purpose and is usually preferable to
a long-lived process.
"""
import os
import sys
import time
import subprocess
import datetime
import traceback
INTERVAL_SECONDS = int(os.environ.get("SKILLOPT_WATCHDOG_INTERVAL", str(4 * 3600)))
RUNNER = os.environ.get("SKILLOPT_RUNNER", os.path.join(os.path.dirname(__file__), "runner.py"))
LOG_FILE = os.environ.get("SKILLOPT_WATCHDOG_LOG", "brain/watchdog.log")
def log(msg: str) -> None:
os.makedirs(os.path.dirname(LOG_FILE) or ".", exist_ok=True)
line = f"[{datetime.datetime.now().isoformat()}] {msg}"
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(line + "\n")
print(line)
def run_once() -> None:
log("Invoking skillopt-sleep run via runner.py...")
try:
result = subprocess.run([sys.executable, RUNNER, "run"],
capture_output=True, text=True)
if result.returncode == 0:
log("Successfully completed run.")
else:
log(f"Run failed (exit {result.returncode}).")
log(f"STDERR: {result.stderr}")
except Exception as e:
log(f"Exception while running skillopt: {e}")
log(traceback.format_exc())
def main() -> None:
log(f"Watchdog started. Interval: {INTERVAL_SECONDS}s.")
while True:
try:
run_once()
except Exception as e:
log(f"Unexpected error in watchdog loop: {e}")
log(f"Sleeping for {INTERVAL_SECONDS}s...")
time.sleep(INTERVAL_SECONDS)
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
# OpenAI-compatible endpoints for SkillOpt-Sleep (DeepSeek, local vLLM, …)
This document describes a small enhancement to the `azure_openai` backend in
`skillopt_sleep/backend.py` that lets SkillOpt-Sleep drive **any
OpenAI-compatible chat-completions endpoint** — for example DeepSeek's hosted
API or a self-hosted vLLM/Ollama server — in addition to native Azure OpenAI
deployments. It also documents a concrete end-to-end integration: running the
nightly sleep cycle inside the Antigravity IDE against DeepSeek.
## What changed
Three focused changes in `AzureOpenAIBackend` (`skillopt_sleep/backend.py`),
all backward-compatible — the default managed-identity path is unchanged:
1. **Endpoint resolution honors `AZURE_OPENAI_ENDPOINT`.**
`__init__` now resolves the endpoint as `explicit arg``AZURE_OPENAI_ENDPOINT`
env → the built-in `_AZURE_ENDPOINTS` table. Previously a non-Azure endpoint
could not be supplied at all, so calls always went to a hardcoded
`*.openai.azure.com` host.
2. **`openai_compatible` auth mode.** When
`AZURE_OPENAI_AUTH_MODE=openai_compatible` (also accepts `compat`/`openai`),
`_get_client()` builds a plain `openai.OpenAI(base_url=…)` client instead of
an `AzureOpenAI` client. This mirrors the auth mode already supported by the
sibling `skillopt/model/azure_openai.py` module, so the two are consistent.
3. **DeepSeek request shape + error surfacing.** `_call()` sends `max_tokens`
plus `extra_body={"thinking": {"type": "enabled"}}` for `deepseek*`
deployments (the DeepSeek reasoning models expect this), and records the last
exception in `self.last_call_error` so a failed night is diagnosable instead
of collapsing to a silent empty response scored `0.0`.
### Why the previous behavior failed on non-Azure servers
The `AzureOpenAI` SDK client rewrites request URLs with Azure-only structure —
a `?api-version=…` query string and deployment path segments. A non-Azure
OpenAI-compatible server (DeepSeek, vLLM, …) does not recognize those routes and
responds `404 Resource not found`. SkillOpt-Sleep then receives an empty
response, the judge scores it `0.0`, and every night reports
`baseline 0.0 -> candidate 0.0` with no usable diagnostic. Selecting a plain
`OpenAI` client via `openai_compatible` mode avoids the Azure URL rewriting and
talks to the endpoint directly.
## How to use it
Set four environment variables and run the cycle with `--backend azure_openai`:
```bash
export AZURE_OPENAI_AUTH_MODE=openai_compatible
export AZURE_OPENAI_ENDPOINT=https://api.deepseek.com # no /v1, no trailing path
export AZURE_OPENAI_API_KEY=sk-... # your provider key
# optimizer/target dual-role overrides are also honored, e.g.
# OPTIMIZER_AZURE_OPENAI_AUTH_MODE / TARGET_AZURE_OPENAI_AUTH_MODE, etc.
skillopt-sleep run \
--backend azure_openai \
--model deepseek-v4-pro \
--project /path/to/your/project
```
The same pattern works for any OpenAI-compatible server — point
`AZURE_OPENAI_ENDPOINT` at it and set a matching `--model`.
## End-to-end integration: Antigravity + DeepSeek
The [`examples/`](examples/) directory contains a sanitized reference of how this
was wired into the [Antigravity](https://antigravity.google/) agent IDE so the
sleep cycle runs unattended:
- **`examples/runner.py`** — a thin launcher that loads a provider key from an
`.env` file, exports the four variables above, and invokes `skillopt-sleep run`
with the DeepSeek backend. It also implements a `session-end` hook that appends
task-outcome metadata to a rollout-evidence log (wired to Antigravity's `Stop`
hook) so future nights have richer sessions to mine.
- **`examples/watchdog.py`** — a minimal supervisor loop that invokes the runner
on a fixed interval (e.g. every 4 hours). On Windows this is registered as a
Scheduled Task so it survives logout; on Linux/macOS a `systemd` timer or cron
entry serves the same role.
### Verified result
On a Windows 11 host, driving the cycle against `deepseek-v4-pro` in
`openai_compatible` mode:
- A direct backend smoke test returns a live completion (no `404`,
`last_call_error` empty, client type `OpenAI`).
- A full nightly cycle mined tasks from real IDE sessions and the held-out
validation gate moved from `0.250 → 1.000`, **accepting** a DeepSeek-authored
skill edit (`accept_new_best`). `diagnostics.json` for that night reports
`"backend": "azure_openai"` with a non-empty token count and an empty
`call_error` — i.e. a genuine optimization night, versus the prior all-`0.0`
nights that the endpoint bug produced.
- A subsequent unattended night triggered by the watchdog completed the full
chain (watchdog → runner → `skillopt-sleep` → DeepSeek) and the gate correctly
**rejected** a non-improving proposal (`0.3 → 0.3`), confirming the validation
gate behaves normally on the new backend.
## A note on Gemini (optional, unverified fallback)
`examples/runner.py` also contains a fallback branch that, when only a Gemini key
is present, routes the **`claude` CLI backend** through a local
Anthropic-compatible proxy (e.g. [LiteLLM](https://github.com/BerriAI/litellm) on
`http://127.0.0.1:4000`) by setting `ANTHROPIC_BASE_URL`/`ANTHROPIC_API_KEY`.
There is **no native Gemini backend** in SkillOpt, and this proxy path was not
independently validated in this work — it is included only as a configuration
example. The verified, supported path in this document is DeepSeek via
`openai_compatible` mode. Treat the Gemini branch as illustrative, not tested.
+48 -14
View File
@@ -1236,7 +1236,15 @@ class AzureOpenAIBackend(CliBackend):
api_version: str = "2024-12-01-preview") -> None: api_version: str = "2024-12-01-preview") -> None:
super().__init__(model=deployment or "gpt-5.5", timeout=timeout) super().__init__(model=deployment or "gpt-5.5", timeout=timeout)
self.deployment = deployment or "gpt-5.5" 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.api_version = api_version
self.name = f"azure:{self.deployment}" self.name = f"azure:{self.deployment}"
self._client = None self._client = None
@@ -1250,14 +1258,29 @@ class AzureOpenAIBackend(CliBackend):
def _get_client(self): def _get_client(self):
if self._client is None: if self._client is None:
from azure.identity import ManagedIdentityCredential, get_bearer_token_provider # AZURE_OPENAI_AUTH_MODE=openai_compatible (matching the sibling
from openai import AzureOpenAI # skillopt.model.azure_openai module) selects a plain OpenAI client
cred = ManagedIdentityCredential(client_id=_AZURE_MI_CLIENT_ID) # against a raw base_url. This is what makes any OpenAI-compatible
tp = get_bearer_token_provider(cred, "https://cognitiveservices.azure.com/.default") # endpoint work: the AzureOpenAI client would otherwise rewrite the
self._client = AzureOpenAI( # URL with Azure-only query params (?api-version=...) and deployment
azure_endpoint=self.endpoint, azure_ad_token_provider=tp, # path segments, which non-Azure servers reject with a 404.
api_version=self.api_version, max_retries=4, auth_mode = os.environ.get("AZURE_OPENAI_AUTH_MODE", "").strip().lower()
) if auth_mode in {"openai_compatible", "compat", "openai"}:
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:
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 return self._client
def _call(self, prompt: str, *, max_tokens: int = 1024, retries: int = 5) -> str: def _call(self, prompt: str, *, max_tokens: int = 1024, retries: int = 5) -> str:
@@ -1277,11 +1300,19 @@ class AzureOpenAIBackend(CliBackend):
last_exc = None last_exc = None
for attempt in range(max(1, retries)): for attempt in range(max(1, retries)):
try: try:
resp = client.chat.completions.create( kwargs: Dict[str, Any] = {
model=self.deployment, "model": self.deployment,
messages=[{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
max_completion_tokens=16384, }
) # DeepSeek's reasoning models expect `max_tokens` and an
# extra_body flag to enable the thinking channel; the Azure
# gpt-5.x deployments use `max_completion_tokens`.
if "deepseek" in self.deployment.lower():
kwargs["max_tokens"] = 8192
kwargs["extra_body"] = {"thinking": {"type": "enabled"}}
else:
kwargs["max_completion_tokens"] = 16384
resp = client.chat.completions.create(**kwargs)
text = (resp.choices[0].message.content or "").strip() text = (resp.choices[0].message.content or "").strip()
try: try:
u = resp.usage u = resp.usage
@@ -1295,6 +1326,9 @@ class AzureOpenAIBackend(CliBackend):
last_exc = "empty-response" last_exc = "empty-response"
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
last_exc = e 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) # backoff before next try (skip after the final attempt)
if attempt < retries - 1: if attempt < retries - 1:
_t.sleep(min(8.0, (2 ** attempt) * 0.5) + _r.random() * 0.4) _t.sleep(min(8.0, (2 ** attempt) * 0.5) + _r.random() * 0.4)