Skip to content
Merged
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
36 changes: 30 additions & 6 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,17 @@ Configuration is **declarative JSON** you can extend without touching code.
"name": "Nebius Token Factory", // human-readable display name
"client_kind": "openai", // anthropic | openai | google
"base_url": "https://api.tokenfactory.us-central1.nebius.com/v1/",
"api_key_env": "NEBIUS_API_KEY"
"api_key_env": "NEBIUS_API_KEY",
"litellm_prefix": "openrouter" // optional; see below
}
```

`litellm_prefix` (optional, `openai` by default) names the litellm provider the `openhands` agent
impl routes through. Most OpenAI-compatible endpoints want the default. Set it when litellm models
the gateway as a first-class provider — OpenRouter does — because the generic OpenAI transform
**strips `cache_control` from every message**, silently disabling prompt caching, and cannot report
usage or cost. Naming the provider fixes all three.

Bundled providers: `anthropic`, `gemini`, `openai`, `together`, `nebius`, `openrouter`.

### Profiles — one per agent role
Expand All @@ -72,10 +79,18 @@ A **meta-agent profile** bundles `(agent_impl, model, provider)`:
"name": "Kimi K2.6 on Nebius", // human-readable display name
"agent_impl": "openhands", // claude | openhands | pydantic-ai
"model": "moonshotai/Kimi-K2.6",
"provider_id": "nebius" // references a provider by its provider_id
"provider_id": "nebius", // references a provider by its provider_id
"model_canonical_name": null // optional; see below
}
```

`model_canonical_name` (optional) is the vendor's own model id, used **only** for SDK capability
lookups — prompt caching, context window, cost — while `model` stays the routing id the gateway
expects. Set it when the two differ: OpenRouter serves Anthropic's `claude-haiku-4-5` as
`anthropic/claude-haiku-4.5`, and capability tables key on the hyphenated form, so without it
caching stays off and the context window is undetected. **Change it whenever you change `model`** —
a stale value applies the wrong model's capabilities, token limits and pricing.

A **target-agent profile** bundles `(model, provider, agent_reference)` — no agent impl, because
SIA never runs the target as an engine; it generates and improves the code:

Expand Down Expand Up @@ -173,12 +188,21 @@ sia run --task gpqa \
Both bundled OpenRouter profiles use `anthropic/claude-haiku-4.5`. To swap models, copy them into
`./profiles/` and change `model` to any [OpenRouter model id](https://openrouter.ai/models) —
the id must keep its vendor namespace (`openai/gpt-oss-120b`, `google/gemini-3-flash-preview`,
`qwen/qwen3-235b-a22b-2507`), since that is how OpenRouter routes.
`qwen/qwen3-235b-a22b-2507`), since that is how OpenRouter routes. When you change `model` on the
**meta** profile, update `model_canonical_name` to match the vendor's own id for that model, or
delete it — leaving the old value applies the wrong model's capabilities and pricing.

The meta agent cannot use the `claude` agent impl here (see below): OpenRouter is an
OpenAI-compatible provider, so `openrouter-meta` uses `openhands`. LiteLLM prints a
`Cost calculation failed: This model isn't mapped yet` warning and reports `$0.00` per turn —
harmless, and consistent with every other non-native provider. Track real spend on the
OpenAI-compatible provider, so `openrouter-meta` uses `openhands`.

**Prompt caching is model-gated, not provider-gated.** OpenRouter only accepts Anthropic-style
cache breakpoints for a subset of models — ids containing `claude`, `gemini`, `glm`, `minimax` or
`z-ai`. Swapping the meta model to, say, `openai/gpt-oss-120b` routes and runs fine but caches
nothing, so a long meta-agent loop re-sends its whole prefix every turn.

Cost reporting reflects the **native vendor's** published prices, looked up via
`model_canonical_name` — not OpenRouter's, which adds a fee and varies by route. Treat per-turn
figures as an estimate and track real spend on the
[OpenRouter activity page](https://openrouter.ai/activity).

### Pointing the meta/feedback agent at another provider
Expand Down
12 changes: 10 additions & 2 deletions sia/agent_impls/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from __future__ import annotations

from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from sia.logging_setup import get_logger

Expand Down Expand Up @@ -53,6 +53,7 @@ async def run_agent(
agent_working_directory: str,
agent_impl: str = "claude",
provider: Provider | None = None,
model_canonical_name: str | None = None,
) -> None:
"""Dispatch to the named agent impl.

Expand All @@ -63,6 +64,13 @@ async def run_agent(
agent_working_directory: Working directory for the agent.
agent_impl: Which registered impl to use (e.g. "claude", "openhands", "pydantic-ai").
provider: Optional endpoint/credentials for the model (api_key_env, base_url).
model_canonical_name: Optional canonical model id for SDK capability lookups. Only
forwarded when set, so runners registered against the older signature -- including
third-party impls -- keep working; when it is set and the runner cannot accept it,
the resulting TypeError is loud rather than a silently ignored capability.
"""
logger.info(f"Using {agent_impl} agent impl")
await get_agent_impl(agent_impl)(model_name, max_turns, prompt, agent_working_directory, provider=provider)
kwargs: dict[str, Any] = {"provider": provider}
if model_canonical_name is not None:
kwargs["model_canonical_name"] = model_canonical_name
await get_agent_impl(agent_impl)(model_name, max_turns, prompt, agent_working_directory, **kwargs)
39 changes: 32 additions & 7 deletions sia/agent_impls/openhands.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,29 @@ def _resolve_model(model_name, provider=None):

litellm derives the provider from the model string's prefix. For an
OpenAI-compatible endpoint (a provider with ``client_kind == "openai"`` and a
``base_url``), the model must carry an explicit ``openai/`` prefix so litellm
routes to that ``base_url`` instead of trying to parse the model's own namespace
(e.g. ``moonshotai/Kimi-K2.6``) as a provider. Already-prefixed and native
(anthropic) specs pass through unchanged.
``base_url``), the model must carry an explicit routing prefix so litellm routes to
that ``base_url`` instead of trying to parse the model's own namespace (e.g.
``moonshotai/Kimi-K2.6``) as a provider. Already-prefixed and native (anthropic)
specs pass through unchanged.

The prefix comes from the provider's ``litellm_prefix`` and defaults to ``openai``,
the generic OpenAI-compatible route. A gateway that litellm models as a first-class
provider should name it (``openrouter``) so litellm applies that provider's own
request transform: the generic OpenAI transform strips ``cache_control`` from every
message, which silently disables prompt caching.
"""
if provider is None or not isinstance(model_name, str):
return model_name
if provider.client_kind == "openai" and provider.base_url and not model_name.startswith("openai/"):
return f"openai/{model_name}"
if provider.client_kind == "openai" and provider.base_url:
prefix = provider.litellm_prefix or "openai"
if not model_name.startswith(f"{prefix}/"):
return f"{prefix}/{model_name}"
return model_name


async def run_agent_openhands(model_name, max_turns, prompt, agent_working_directory, provider=None):
async def run_agent_openhands(
model_name, max_turns, prompt, agent_working_directory, provider=None, model_canonical_name=None
):
"""Run agent using OpenHands SDK"""
try:
from openhands.sdk import LLM, Agent, Conversation, Tool
Expand Down Expand Up @@ -58,11 +68,26 @@ async def run_agent_openhands(model_name, max_turns, prompt, agent_working_direc

# Create LLM instance. litellm needs an explicit provider prefix to route to a
# custom OpenAI-compatible base_url (see _resolve_model).
#
# reasoning_effort is pinned to None deliberately. The SDK defaults it to "high",
# and litellm only forwards it for providers it reports as reasoning-capable -- so
# merely naming a gateway's litellm provider would silently switch the meta agent
# to extended thinking. Opting into that is a separate, measurable decision.
llm = LLM(
model=_resolve_model(model_name, provider),
model_canonical_name=model_canonical_name,
api_key=api_key,
base_url=base_url,
reasoning_effort=None,
)
# The LLM model ignores unknown fields, so an SDK predating model_canonical_name
# would drop it and leave capability lookups (prompt caching, context window, cost)
# silently degraded rather than failing.
if model_canonical_name and getattr(llm, "model_canonical_name", None) != model_canonical_name:
logger.warning(
f"The installed OpenHands SDK ignored model_canonical_name={model_canonical_name!r}; "
"prompt caching and context-window detection will be degraded. Upgrade openhands-ai."
)

# Create agent with available tools
agent = Agent(
Expand Down
1 change: 1 addition & 0 deletions sia/defaults/profiles/openrouter-meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"name": "OpenRouter meta agent (Claude Haiku 4.5, OpenHands)",
"agent_impl": "openhands",
"model": "anthropic/claude-haiku-4.5",
"model_canonical_name": "claude-haiku-4-5",
"provider_id": "openrouter"
}
3 changes: 2 additions & 1 deletion sia/defaults/providers/openrouter.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"name": "OpenRouter",
"client_kind": "openai",
"base_url": "https://openrouter.ai/api/v1",
"api_key_env": "OPENROUTER_API_KEY"
"api_key_env": "OPENROUTER_API_KEY",
"litellm_prefix": "openrouter"
}
2 changes: 2 additions & 0 deletions sia/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,7 @@ def _run_feedback_agent(
agent_working_directory=next_gen_dir,
agent_impl=meta_profile.agent_impl,
provider=meta_profile.provider,
model_canonical_name=meta_profile.model_canonical_name,
)
)

Expand Down Expand Up @@ -909,6 +910,7 @@ def main():
agent_working_directory=run_setup.meta_agent_working_directory,
agent_impl=agent_impl,
provider=meta_profile.provider,
model_canonical_name=meta_profile.model_canonical_name,
)
)

Expand Down
8 changes: 8 additions & 0 deletions sia/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ class MetaAgentProfile:
agent_impl: str # a registered agent impl (claude / openhands / pydantic-ai)
model: str
provider: Provider
# Canonical model id used only for SDK capability lookups (prompt caching, context window,
# cost), separate from ``model``, which is the routing id the gateway expects. Set this when
# the gateway's id differs from the vendor's own -- e.g. OpenRouter serves Anthropic's
# "claude-haiku-4-5" as "anthropic/claude-haiku-4.5", and capability tables key on the former.
# Change it whenever you change ``model``: a stale value applies the wrong model's
# capabilities, token limits and pricing.
model_canonical_name: str | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -87,6 +94,7 @@ def load_meta_agent_profile(name_or_path: str) -> MetaAgentProfile:
agent_impl=data["agent_impl"],
model=data["model"],
provider=provider,
model_canonical_name=data.get("model_canonical_name"),
)
_validate_meta(profile, source)
return profile
Expand Down
7 changes: 7 additions & 0 deletions sia/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ class Provider:
client_kind: str # "anthropic" | "openai" | "google"
base_url: str | None # None for native endpoints; set for OpenAI-compatible providers
api_key_env: str
# litellm routing prefix the openhands agent impl should use for this endpoint. Defaults to
# "openai" (the generic OpenAI-compatible route). Gateways with a first-class litellm provider
# -- e.g. "openrouter" -- should name it here: litellm then applies that provider's own request
# transform instead of the generic one, which preserves prompt-cache breakpoints and returns
# usage/cost data. See sia.agent_impls.openhands._resolve_model.
litellm_prefix: str | None = None


def available_providers() -> list[str]:
Expand Down Expand Up @@ -64,4 +70,5 @@ def load_provider(name_or_path: str) -> Provider:
client_kind=client_kind,
base_url=data.get("base_url"),
api_key_env=data["api_key_env"],
litellm_prefix=data.get("litellm_prefix"),
)
116 changes: 116 additions & 0 deletions tests/test_agent_impls.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,119 @@ async def fake_runner(model, max_turns, prompt, cwd, provider=None):
nebius = load_provider("nebius")
asyncio.run(base.run_agent("m", "5", "p", "/tmp", agent_impl="capture-test", provider=nebius))
assert captured["provider"] is nebius


def test_openhands_model_uses_provider_declared_litellm_prefix():
"""A provider naming its own litellm provider is routed there, not via the generic openai one."""
from sia.agent_impls.openhands import _resolve_model
from sia.providers import load_provider

openrouter = load_provider("openrouter")
assert openrouter.litellm_prefix == "openrouter"
assert _resolve_model("anthropic/claude-haiku-4.5", openrouter) == "openrouter/anthropic/claude-haiku-4.5"
# Already-prefixed specs are not double-prefixed.
assert (
_resolve_model("openrouter/anthropic/claude-haiku-4.5", openrouter) == "openrouter/anthropic/claude-haiku-4.5"
)
# A gateway model id that merely starts with "openai/" is a vendor namespace, not a route:
# it must still be prefixed (the old hardcoded guard skipped it and left it misrouted).
assert _resolve_model("openai/gpt-oss-120b", openrouter) == "openrouter/openai/gpt-oss-120b"

# Providers that declare no prefix keep the generic openai route, unchanged.
nebius = load_provider("nebius")
assert nebius.litellm_prefix is None
assert _resolve_model("moonshotai/Kimi-K2.6", nebius) == "openai/moonshotai/Kimi-K2.6"


def test_run_agent_forwards_model_canonical_name_only_when_set():
"""The canonical name reaches the impl when set, and is omitted otherwise.

Omitting it keeps runners registered against the older signature -- including third-party
impls -- working, since ``register()`` is a public extension point.
"""
import asyncio

from sia.agent_impls import base

captured = {}

async def canonical_runner(model, max_turns, prompt, cwd, provider=None, model_canonical_name=None):
captured["canonical"] = model_canonical_name

base.register("canonical-test", canonical_runner)
asyncio.run(
base.run_agent("m", "5", "p", "/tmp", agent_impl="canonical-test", model_canonical_name="claude-haiku-4-5")
)
assert captured["canonical"] == "claude-haiku-4-5"

# Unset -> the kwarg is not passed at all, so a legacy runner signature still accepts the call.
legacy = {}

async def legacy_runner(model, max_turns, prompt, cwd, provider=None):
legacy["called"] = True

base.register("legacy-test", legacy_runner)
asyncio.run(base.run_agent("m", "5", "p", "/tmp", agent_impl="legacy-test"))
assert legacy["called"] is True


def test_openrouter_prefix_preserves_cache_control_on_the_wire(monkeypatch):
"""The generic openai route silently strips cache_control; the openrouter route keeps it.

This is the whole point of provider-declared prefixes: OpenHands emits the breakpoints either
way, so a capability flag alone proves nothing -- only the serialised body does.
"""
pytest.importorskip("openhands")
import json

import httpx
from openhands.sdk import LLM
from openhands.sdk.llm import Message, TextContent

captured = {}

def fake_send(self, request, **kwargs):
captured["body"] = json.loads(request.content)
return httpx.Response(
200,
request=request,
json={
"id": "x",
"object": "chat.completion",
"created": 0,
"model": "anthropic/claude-haiku-4.5",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12},
},
)

monkeypatch.setattr(httpx.Client, "send", fake_send)

def capture(model_spec):
captured.clear()
llm = LLM(
model=model_spec,
model_canonical_name="claude-haiku-4-5",
api_key="sk-not-a-real-key",
base_url="https://openrouter.ai/api/v1",
reasoning_effort=None,
usage_id=model_spec,
)
messages = [
Message(role="system", content=[TextContent(text="SYS")]),
Message(role="user", content=[TextContent(text="hi")]),
]
llm.completion(messages=messages)
return llm, captured["body"]

llm, body = capture("openrouter/anthropic/claude-haiku-4.5")
assert "cache_control" in json.dumps(body["messages"]), "cache_control must survive to the wire"
# reasoning_effort is pinned off, so naming a litellm provider cannot silently enable thinking.
assert "reasoning_effort" not in body
# Capability lookup via the canonical name also repairs context-window detection.
assert llm.max_input_tokens and llm.max_output_tokens

# The generic openai route strips the markers, which is the bug being fixed. Note the
# capability gate reports caching active in BOTH cases -- only the payload differs.
_, generic_body = capture("openai/anthropic/claude-haiku-4.5")
assert "cache_control" not in json.dumps(generic_body["messages"])
22 changes: 22 additions & 0 deletions tests/test_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,25 @@ def test_target_profile_file_reference(tmp_path):
assert profile.agent_reference.kind == "file"
assert profile.agent_reference.source is not None
assert profile.agent_reference.source.name == "my_agent.py"


def test_model_canonical_name_is_optional(tmp_path):
"""A meta profile without model_canonical_name loads with None (today's behaviour)."""
path = _write_profile(
tmp_path,
{
"profile_id": "p",
"name": "P",
"agent_impl": "openhands",
"model": "anthropic/claude-haiku-4.5",
"provider_id": "openrouter",
},
)
assert load_meta_agent_profile(path).model_canonical_name is None


def test_model_canonical_name_is_read_when_present():
"""The bundled OpenRouter meta profile carries the vendor's canonical id."""
profile = load_meta_agent_profile("openrouter-meta")
assert profile.model == "anthropic/claude-haiku-4.5"
assert profile.model_canonical_name == "claude-haiku-4-5"
Loading
Loading