From af33f6c338d920133991a23d4e31e71ad82cb0f4 Mon Sep 17 00:00:00 2001 From: Alphaxalchemy Date: Sun, 12 Jul 2026 21:03:48 -0600 Subject: [PATCH] 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. --- docs/sleep/examples/runner.py | 100 ++++++++++++++++++++ docs/sleep/examples/watchdog.py | 56 +++++++++++ docs/sleep/openai-compatible-endpoints.md | 107 ++++++++++++++++++++++ skillopt_sleep/backend.py | 62 ++++++++++--- 4 files changed, 311 insertions(+), 14 deletions(-) create mode 100644 docs/sleep/examples/runner.py create mode 100644 docs/sleep/examples/watchdog.py create mode 100644 docs/sleep/openai-compatible-endpoints.md diff --git a/docs/sleep/examples/runner.py b/docs/sleep/examples/runner.py new file mode 100644 index 0000000..ecb2a4d --- /dev/null +++ b/docs/sleep/examples/runner.py @@ -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() diff --git a/docs/sleep/examples/watchdog.py b/docs/sleep/examples/watchdog.py new file mode 100644 index 0000000..d749c36 --- /dev/null +++ b/docs/sleep/examples/watchdog.py @@ -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() diff --git a/docs/sleep/openai-compatible-endpoints.md b/docs/sleep/openai-compatible-endpoints.md new file mode 100644 index 0000000..cdcfe82 --- /dev/null +++ b/docs/sleep/openai-compatible-endpoints.md @@ -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. diff --git a/skillopt_sleep/backend.py b/skillopt_sleep/backend.py index d7bb040..77fcae8 100644 --- a/skillopt_sleep/backend.py +++ b/skillopt_sleep/backend.py @@ -1236,7 +1236,15 @@ class AzureOpenAIBackend(CliBackend): 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 @@ -1250,14 +1258,29 @@ class AzureOpenAIBackend(CliBackend): 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. + 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 def _call(self, prompt: str, *, max_tokens: int = 1024, retries: int = 5) -> str: @@ -1277,11 +1300,19 @@ class AzureOpenAIBackend(CliBackend): last_exc = None for attempt in range(max(1, retries)): 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}], + } + # 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() try: u = resp.usage @@ -1295,6 +1326,9 @@ class AzureOpenAIBackend(CliBackend): 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: _t.sleep(min(8.0, (2 ** attempt) * 0.5) + _r.random() * 0.4)