Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/guide/new-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,59 @@

SkillOpt supports multiple LLM backends. This guide shows how to add your own.

## Built-in: the generic OpenAI-compatible backend

Before writing a new backend, check whether your provider already speaks the
OpenAI Chat Completions protocol. Most do — in which case you can use the
built-in **`openai_compatible`** backend
(`skillopt/model/openai_compatible_backend.py`) with no code changes.

A single `base_url` + `api_key` pair lets you point SkillOpt at, for example:

| Provider | `base_url` | Example model |
|---|---|---|
| DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat` |
| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` |
| Together AI | `https://api.together.xyz/v1` | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
| Ollama (local) | `http://localhost:11434/v1` | `qwen2.5:7b` |
| vLLM / SGLang / TGI | `http://localhost:8000/v1` | your served model |
| LiteLLM proxy | `http://localhost:4000` | any proxied model |
| OpenRouter / Fireworks / xAI / … | provider base URL | provider model id |

Select it as the optimizer and/or target backend:

```python
import skillopt.model as model

# Shorthand: use it for both optimizer and target.
model.set_backend("openai_compatible")

# Point it at a provider (shared, or per-role with optimizer_*/target_*).
model.configure_openai_compatible(
base_url="https://api.deepseek.com/v1",
api_key="sk-...",
model="deepseek-chat",
)
```

Or configure it entirely through environment variables (role-specific
`OPTIMIZER_*` / `TARGET_*` variants override the shared ones):

```bash
export TARGET_BACKEND=openai_compatible
export OPENAI_COMPATIBLE_BASE_URL="https://api.groq.com/openai/v1"
export OPENAI_COMPATIBLE_API_KEY="gsk_..."
export OPENAI_COMPATIBLE_MODEL="llama-3.3-70b-versatile"
# Optional: OPENAI_COMPATIBLE_TEMPERATURE, _MAX_TOKENS, _TIMEOUT_SECONDS
```

The backend uses the official `openai` SDK, records token usage through the
shared tracker, supports tool/function calling via
`chat_target_messages(..., tools=...)`, and exposes
`count_tokens()` (tiktoken with a character-based fallback for non-OpenAI
models). Only write a brand-new backend if your provider is *not*
OpenAI-compatible.

## Backend Architecture

```
Expand Down
97 changes: 97 additions & 0 deletions skillopt/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from skillopt.model import azure_openai as _openai
from skillopt.model import claude_backend as _claude
from skillopt.model import minimax_backend as _minimax
from skillopt.model import openai_compatible_backend as _openai_compat
from skillopt.model import qwen_backend as _qwen
from skillopt.model.backend_config import ( # noqa: F401
configure_claude_code_exec,
Expand Down Expand Up @@ -55,6 +56,10 @@ def set_backend(name: str | None) -> str:
set_optimizer_backend("openai_chat")
set_target_backend("minimax_chat")
return "minimax_chat"
if normalized in {"openai_compatible", "openai_compatible_chat", "openai-compatible", "compat"}:
set_optimizer_backend("openai_compatible")
set_target_backend("openai_compatible")
return "openai_compatible"
raise ValueError(f"Unsupported legacy backend: {name!r}")


Expand All @@ -74,6 +79,8 @@ def get_backend_name() -> str:
return "qwen_chat"
if optimizer == "openai_chat" and target == "minimax_chat":
return "minimax_chat"
if optimizer == "openai_compatible" and target == "openai_compatible":
return "openai_compatible"
return f"{optimizer}+{target}"


Expand Down Expand Up @@ -105,6 +112,16 @@ def chat_optimizer(
reasoning_effort=reasoning_effort,
timeout=timeout,
)
if get_optimizer_backend() == "openai_compatible":
return _openai_compat.chat_optimizer(
system=system,
user=user,
max_completion_tokens=max_completion_tokens,
retries=retries,
stage=stage,
reasoning_effort=reasoning_effort,
timeout=timeout,
)
return _openai.chat_optimizer(
system=system,
user=user,
Expand Down Expand Up @@ -153,6 +170,16 @@ def chat_target(
stage=stage,
reasoning_effort=reasoning_effort,
)
if get_target_backend() == "openai_compatible":
return _openai_compat.chat_target(
system=system,
user=user,
max_completion_tokens=max_completion_tokens,
retries=retries,
stage=stage,
reasoning_effort=reasoning_effort,
timeout=timeout,
)
if not is_target_chat_backend():
raise NotImplementedError(
"chat_target is only supported with target_backend=openai_chat, claude_chat, qwen_chat, or minimax_chat. "
Expand Down Expand Up @@ -204,6 +231,18 @@ def chat_optimizer_messages(
return_message=return_message,
timeout=timeout,
)
if get_optimizer_backend() == "openai_compatible":
return _openai_compat.chat_optimizer_messages(
messages=messages,
max_completion_tokens=max_completion_tokens,
retries=retries,
stage=stage,
reasoning_effort=reasoning_effort,
tools=tools,
tool_choice=tool_choice,
return_message=return_message,
timeout=timeout,
)
return _openai.chat_optimizer_messages(
messages=messages,
max_completion_tokens=max_completion_tokens,
Expand Down Expand Up @@ -263,6 +302,18 @@ def chat_target_messages(
tool_choice=tool_choice,
return_message=return_message,
)
if get_target_backend() == "openai_compatible":
return _openai_compat.chat_target_messages(
messages=messages,
max_completion_tokens=max_completion_tokens,
retries=retries,
stage=stage,
reasoning_effort=reasoning_effort,
tools=tools,
tool_choice=tool_choice,
return_message=return_message,
timeout=timeout,
)
if not is_target_chat_backend():
raise NotImplementedError(
"chat_target_messages is only supported with target_backend=openai_chat, claude_chat, qwen_chat, or minimax_chat. "
Expand Down Expand Up @@ -365,6 +416,17 @@ def get_token_summary() -> dict:
summary[stage]["prompt_tokens"] += values["prompt_tokens"]
summary[stage]["completion_tokens"] += values["completion_tokens"]
summary[stage]["total_tokens"] += values["total_tokens"]
openai_compat_summary = _openai_compat.get_token_summary()
for stage, values in openai_compat_summary.items():
if stage == "_total":
continue
if stage not in summary:
summary[stage] = values
continue
summary[stage]["calls"] += values["calls"]
summary[stage]["prompt_tokens"] += values["prompt_tokens"]
summary[stage]["completion_tokens"] += values["completion_tokens"]
summary[stage]["total_tokens"] += values["total_tokens"]
total = {
"calls": 0,
"prompt_tokens": 0,
Expand All @@ -387,6 +449,7 @@ def reset_token_tracker() -> None:
_claude.reset_token_tracker()
_qwen.reset_token_tracker()
_minimax.reset_token_tracker()
_openai_compat.reset_token_tracker()


def configure_azure_openai(
Expand Down Expand Up @@ -494,21 +557,55 @@ def configure_minimax_chat(
)


def configure_openai_compatible(
*,
base_url: str | None = None,
api_key: str | None = None,
model: str | None = None,
temperature: float | str | None = None,
timeout_seconds: float | str | None = None,
max_tokens: int | str | None = None,
optimizer_base_url: str | None = None,
optimizer_api_key: str | None = None,
optimizer_model: str | None = None,
target_base_url: str | None = None,
target_api_key: str | None = None,
target_model: str | None = None,
) -> None:
_openai_compat.configure_openai_compatible(
base_url=base_url,
api_key=api_key,
model=model,
temperature=temperature,
timeout_seconds=timeout_seconds,
max_tokens=max_tokens,
optimizer_base_url=optimizer_base_url,
optimizer_api_key=optimizer_api_key,
optimizer_model=optimizer_model,
target_base_url=target_base_url,
target_api_key=target_api_key,
target_model=target_model,
)


def set_reasoning_effort(effort: str | None) -> None:
_openai.set_reasoning_effort(effort)
_claude.set_reasoning_effort(effort)
_qwen.set_reasoning_effort(effort)
_minimax.set_reasoning_effort(effort)
_openai_compat.set_reasoning_effort(effort)


def set_target_deployment(deployment: str) -> None:
_openai.set_target_deployment(deployment)
_claude.set_target_deployment(deployment)
_qwen.set_target_deployment(deployment)
_minimax.set_target_deployment(deployment)
_openai_compat.set_target_deployment(deployment)


def set_optimizer_deployment(deployment: str) -> None:
_openai.set_optimizer_deployment(deployment)
_claude.set_optimizer_deployment(deployment)
_qwen.set_optimizer_deployment(deployment)
_openai_compat.set_optimizer_deployment(deployment)
12 changes: 6 additions & 6 deletions skillopt/model/backend_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ def _parse_int(value: str | None, default: int) -> int:
def set_optimizer_backend(backend: str) -> None:
global OPTIMIZER_BACKEND
OPTIMIZER_BACKEND = normalize_backend_name(backend or "openai_chat")
if OPTIMIZER_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat"}:
if OPTIMIZER_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "openai_compatible"}:
raise ValueError(
f"Unsupported optimizer backend: {OPTIMIZER_BACKEND!r}. "
"Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', and 'minimax_chat'."
"Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', 'minimax_chat', and 'openai_compatible'."
)
os.environ["OPTIMIZER_BACKEND"] = OPTIMIZER_BACKEND

Expand All @@ -64,10 +64,10 @@ def get_optimizer_backend() -> str:
def set_target_backend(backend: str) -> None:
global TARGET_BACKEND
TARGET_BACKEND = normalize_backend_name(backend or "openai_chat")
if TARGET_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "codex_exec", "claude_code_exec"}:
if TARGET_BACKEND not in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "openai_compatible", "codex_exec", "claude_code_exec"}:
raise ValueError(
f"Unsupported target backend: {TARGET_BACKEND!r}. "
"Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', 'minimax_chat', 'codex_exec', and 'claude_code_exec'."
"Supported values are 'openai_chat', 'claude_chat', 'qwen_chat', 'minimax_chat', 'openai_compatible', 'codex_exec', and 'claude_code_exec'."
)
os.environ["TARGET_BACKEND"] = TARGET_BACKEND

Expand All @@ -81,11 +81,11 @@ def is_target_exec_backend() -> bool:


def is_optimizer_chat_backend() -> bool:
return OPTIMIZER_BACKEND in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat"}
return OPTIMIZER_BACKEND in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "openai_compatible"}


def is_target_chat_backend() -> bool:
return TARGET_BACKEND in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat"}
return TARGET_BACKEND in {"openai_chat", "claude_chat", "qwen_chat", "minimax_chat", "openai_compatible"}


def configure_codex_exec(
Expand Down
5 changes: 5 additions & 0 deletions skillopt/model/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"claude_code_exec": "claude-sonnet-4-6",
"qwen_chat": "Qwen/Qwen3.5-4B",
"minimax_chat": "MiniMax-M2.7",
"openai_compatible": "gpt-4o-mini",
}

_BACKEND_ALIASES = {
Expand All @@ -44,6 +45,10 @@
"qwen_chat": "qwen_chat",
"minimax": "minimax_chat",
"minimax_chat": "minimax_chat",
"openai_compatible": "openai_compatible",
"openai_compatible_chat": "openai_compatible",
"openai-compatible": "openai_compatible",
"compat": "openai_compatible",
}


Expand Down
Loading