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:
@@ -0,0 +1,105 @@
|
||||
#!/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"
|
||||
env["AZURE_OPENAI_AUTH_MODE"] = "openai_compatible"
|
||||
env["AZURE_OPENAI_ENDPOINT"] = PROVIDER_ENDPOINT
|
||||
env["AZURE_OPENAI_API_KEY"] = env["DEEPSEEK_API_KEY"]
|
||||
# Provider-specific request fields are opt-in, never inferred from the
|
||||
# model name. For DeepSeek reasoning models, enable the thinking channel:
|
||||
env.setdefault("SKILLOPT_SLEEP_CHAT_EXTRA_BODY",
|
||||
json.dumps({"thinking": {"type": "enabled"}}))
|
||||
env.setdefault("SKILLOPT_SLEEP_COMPAT_MAX_TOKENS", "8192")
|
||||
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)}")
|
||||
# Propagate the child's exit code so supervisors (watchdog.py, systemd,
|
||||
# Task Scheduler) see a failed sleep run as a failure, not a success.
|
||||
proc = subprocess.run(args, env=env, check=False)
|
||||
sys.exit(proc.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,136 @@
|
||||
# OpenAI-compatible endpoints for SkillOpt-Sleep (DeepSeek, local vLLM, …)
|
||||
|
||||
This document describes an 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
|
||||
|
||||
All changes are backward-compatible — the default managed-identity Azure path
|
||||
is unchanged:
|
||||
|
||||
1. **CLI acceptance.** `skillopt-sleep run --backend azure_openai` is now an
|
||||
accepted choice in `skillopt_sleep/__main__.py` (it was previously rejected
|
||||
by argparse even though `get_backend()` understood the name).
|
||||
|
||||
2. **Endpoint resolution honors `AZURE_OPENAI_ENDPOINT`.**
|
||||
`AzureOpenAIBackend.__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.
|
||||
|
||||
3. **`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 with
|
||||
`AZURE_OPENAI_API_KEY` instead of an `AzureOpenAI` client. This mirrors the
|
||||
auth mode already supported by the sibling `skillopt/model/azure_openai.py`
|
||||
module. (The `AzureOpenAI` client rewrites request URLs with Azure-only
|
||||
`?api-version=…` query params and deployment path segments, which non-Azure
|
||||
servers reject with `404 Resource not found` — the sleep cycle then scores
|
||||
every rollout `0.0` with no diagnostic.)
|
||||
|
||||
4. **Managed-identity credential guard.** The managed-identity path attaches an
|
||||
Azure AD bearer token to every request. If a custom endpoint outside
|
||||
`*.openai.azure.com` / `*.cognitiveservices.azure.com` is configured without
|
||||
explicit compat auth, the backend now raises a clear `ValueError` instead of
|
||||
sending Azure credentials to an arbitrary host.
|
||||
|
||||
5. **Provider-neutral request shape.** In compat mode the backend sends only the
|
||||
standard OpenAI-compatible contract (`model`, `messages`, `max_tokens`).
|
||||
Provider-specific request fields are **opt-in** via environment variables
|
||||
(below) — nothing is inferred from model-name substrings.
|
||||
|
||||
6. **Reliable error state.** `_call()` records the last exception in
|
||||
`self.last_call_error` (surfaced in `diagnostics.json`), clears it when a
|
||||
retry recovers, and sets an explicit `"empty response on all N attempts"`
|
||||
diagnostic when every attempt returns empty text.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
SkillOpt-Sleep's `azure_openai` backend reads these environment variables
|
||||
(unprefixed only — the `OPTIMIZER_*`/`TARGET_*` dual-role variables belong to
|
||||
the separate `skillopt.model.azure_openai` module and are **not** used by the
|
||||
sleep cycle):
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `AZURE_OPENAI_AUTH_MODE` | `openai_compatible` (or `compat`/`openai`) selects the plain OpenAI client. Unset/other = Azure managed identity (default). |
|
||||
| `AZURE_OPENAI_ENDPOINT` | Base URL of the server, e.g. `https://api.deepseek.com`. |
|
||||
| `AZURE_OPENAI_API_KEY` | API key sent by the compat client. |
|
||||
| `SKILLOPT_SLEEP_COMPAT_MAX_TOKENS` | Optional int (default `8192`): `max_tokens` sent in compat mode. |
|
||||
| `SKILLOPT_SLEEP_CHAT_EXTRA_BODY` | Optional JSON object passed as `extra_body` for provider-specific fields. |
|
||||
|
||||
## How to use it
|
||||
|
||||
```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
|
||||
|
||||
# DeepSeek reasoning models: enable the thinking channel (opt-in, not inferred)
|
||||
export SKILLOPT_SLEEP_CHAT_EXTRA_BODY='{"thinking": {"type": "enabled"}}'
|
||||
export SKILLOPT_SLEEP_COMPAT_MAX_TOKENS=8192
|
||||
|
||||
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, set a matching `--model`, and omit
|
||||
`SKILLOPT_SLEEP_CHAT_EXTRA_BODY` unless your provider needs extra request
|
||||
fields.
|
||||
|
||||
## 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 variables above, invokes `skillopt-sleep run` with
|
||||
the DeepSeek backend, and **exits with the child's return code** so
|
||||
supervisors see failures as failures. 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) and logs non-zero exits as failures.
|
||||
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.
|
||||
|
||||
Deterministic no-network coverage for the new behavior lives in
|
||||
`tests/test_azure_openai_compat.py` (CLI acceptance, client selection,
|
||||
endpoint/auth guard, request kwargs, retry error-state, empty-response
|
||||
diagnostics, and runner exit-code propagation).
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user