feat(agent): add Upstage Solar as a model provider

Adds Upstage Solar as a bundled model-provider plugin. Solar exposes an
OpenAI-compatible chat-completions endpoint at https://api.upstage.ai/v1, so
the generic chat_completions transport handles request/response/streaming/tool
calls — the profile is the core integration.

Provider registration (Upstage isn't in models.dev, so each registry that does
not auto-wire from the plugin layer needs an explicit entry — same pattern as
nvidia/gmi):
- plugins/model-providers/upstage/: UpstageProfile + plugin.yaml. Picker default
  and offline catalog list only the agentic Solar Pro models, led by `solar-pro`
  (rolling alias for the latest Pro). default_aux_model empty so aux tasks use
  the main model. `solar` alias. UPSTAGE_BASE_URL overrides the host.
- hermes_cli/providers.py: HERMES_OVERLAYS + label + `solar` alias, so
  resolve_provider_full('upstage') resolves (without this, an explicit
  `provider: upstage` in config was dropped and fell through to auto-detect).
- hermes_cli/auth.py: PROVIDER_REGISTRY entry + `solar` alias, so `hermes
  doctor` / resolve_provider recognise upstage (the static-registry path the
  lazy profile-extension doesn't reliably cover at validation time).
- hermes_cli/models.py: CANONICAL_PROVIDERS entry places Upstage Solar in the
  curated picker order (above the auto-appended `custom`).
- agent/model_metadata.py: context-window fallbacks (/v1/models omits
  context_length); `solar-pro` carries the 128K Pro context as the catch-all.

Reasoning: UpstageProfile.build_api_kwargs_extras wires Solar's top-level
`reasoning_effort` (low|medium|high; xhigh/max→high). Reasoning-capable families
are solar-pro* and solar-open*; solar-mini/syn-pro never receive it. Defaults ON
at medium when unset (matches the /reasoning "medium (default)" label);
`/reasoning none` disables; explicit/saved settings are honored. No
reasoning_content echo handling needed (unlike DeepSeek/Kimi).

Web dashboard:
- web/src/pages/EnvPage.tsx: add an "Upstage Solar" provider group so
  UPSTAGE_API_KEY / UPSTAGE_BASE_URL appear under LLM Providers (not "Other").

Docs/tests:
- .env.example: documents UPSTAGE_API_KEY / UPSTAGE_BASE_URL.
- tests: profile wiring, reasoning_effort mapping (pro/open/mini, efforts,
  disabled, default-on), provider-resolver regression (resolve_provider_full /
  get_provider / solar alias / overlay), `solar-pro` default.

Testing: pytest tests/providers tests/plugins/model_providers
tests/hermes_cli/test_upstage_provider.py tests/run_agent/test_provider_parity.py
tests/hermes_cli/test_api_key_providers.py; ruff clean. Verified end-to-end:
`hermes doctor` shows "Upstage Solar", and live chat works via both
`--provider upstage` and `--provider solar`. Reasoning wire format per
https://console.upstage.ai/api/docs/for-agents/raw. Platforms tested: macOS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Changhyun Min 2026-06-08 11:27:55 +09:00 committed by kshitij
parent 77d5b2d573
commit 20502b407c
10 changed files with 402 additions and 0 deletions

View file

@ -136,6 +136,15 @@
# Optional base URL override:
# XIAOMI_BASE_URL=https://api.xiaomimimo.com/v1
# =============================================================================
# LLM PROVIDER (Upstage Solar)
# =============================================================================
# Upstage provides access to Upstage Solar models.
# Get your key at: https://console.upstage.ai/api-keys
# UPSTAGE_API_KEY=your_key_here
# Optional base URL override:
# UPSTAGE_BASE_URL=https://api.upstage.ai/v1
# =============================================================================
# TOOL API KEYS
# =============================================================================

View file

@ -318,6 +318,19 @@ DEFAULT_CONTEXT_LENGTHS = {
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
# Kimi
"kimi": 262144,
# Upstage Solar — api.upstage.ai/v1/models does not return context_length,
# so these fallbacks keep token budgeting / compression from probing down
# to the 128k default. Substring matching is longest-first, so the versioned
# ids win over the "solar-pro" rolling-alias entry, which in turn covers
# future solar-pro* releases at the Pro context size.
# Sources: Solar Pro 3 = 128K, Solar Pro 2 = 64K, Solar Mini = 32K,
# Solar Open 2 = 256K.
"solar-open2-preview": 262144, # 256K (longest-first: wins over solar-open2)
"solar-open2": 262144, # 256K
"solar-pro3": 131072,
"solar-pro": 131072, # rolling alias → latest Solar Pro (currently pro3)
"solar-pro2": 65536,
"solar-mini": 32768,
# Tencent — Hy3 Preview (Hunyuan) with 256K context window.
# OpenRouter live metadata reports 262144 (256 × 1024); align the
# static fallback so cache and offline both agree (issue #22268).

View file

@ -290,6 +290,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
api_key_env_vars=("GMI_API_KEY",),
base_url_env_var="GMI_BASE_URL",
),
"upstage": ProviderConfig(
id="upstage",
name="Upstage Solar",
auth_type="api_key",
inference_base_url="https://api.upstage.ai/v1",
api_key_env_vars=("UPSTAGE_API_KEY",),
base_url_env_var="UPSTAGE_BASE_URL",
),
"minimax": ProviderConfig(
id="minimax",
name="MiniMax",
@ -1677,6 +1685,7 @@ def resolve_provider(
"step": "stepfun", "stepfun-coding-plan": "stepfun",
"arcee-ai": "arcee", "arceeai": "arcee",
"gmi-cloud": "gmi", "gmicloud": "gmi",
"solar": "upstage",
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
"minimax-portal": "minimax-oauth", "minimax-global": "minimax-oauth", "minimax_oauth": "minimax-oauth",
"alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan",

View file

@ -1088,6 +1088,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek; IAM or API key)"),
ProviderEntry("azure-foundry", "Azure Foundry", "Azure Foundry (OpenAI-style or Anthropic-style endpoint, your Azure AI deployment)"),
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (Reuses local Qwen CLI login)"),
ProviderEntry("upstage", "Upstage Solar", "Upstage (Solar API)"),
]
# Auto-extend CANONICAL_PROVIDERS with any provider registered in providers/

View file

@ -201,6 +201,12 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
extra_env_vars=("FIREWORKS_API_KEY",),
base_url_override="https://api.fireworks.ai/inference/v1",
),
"upstage": HermesOverlay(
transport="openai_chat",
extra_env_vars=("UPSTAGE_API_KEY",),
base_url_override="https://api.upstage.ai/v1",
base_url_env_var="UPSTAGE_BASE_URL",
),
"ollama-cloud": HermesOverlay(
transport="openai_chat",
base_url_override="https://ollama.com/v1",
@ -352,6 +358,9 @@ ALIASES: Dict[str, str] = {
"fireworks-ai": "fireworks",
"fw": "fireworks",
# upstage
"solar": "upstage",
# Local server aliases → virtual "local" concept (resolved via user config)
"lmstudio": "lmstudio",
"lm-studio": "lmstudio",
@ -376,6 +385,7 @@ _LABEL_OVERRIDES: Dict[str, str] = {
"stepfun": "StepFun Step Plan",
"xiaomi": "Xiaomi MiMo",
"gmi": "GMI Cloud",
"upstage": "Upstage Solar",
"tencent-tokenhub": "Tencent TokenHub",
"lmstudio": "LM Studio",
"local": "Local endpoint",

View file

@ -0,0 +1,106 @@
"""Upstage Solar provider profile."""
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
# Model-name markers for Solar families that accept ``reasoning_effort``.
# Substring match (not startswith) so dated variants like
# ``solar-pro3-250127`` and ``solar-open-...`` are covered too.
_REASONING_MODEL_MARKERS = ("solar-pro", "solar-open")
# When the user hasn't picked a reasoning effort, Hermes passes
# reasoning_config=None. Solar's own server default is "minimal" (reasoning
# off), which is the wrong default for an agentic workload. We default reasoning
# ON at this effort — matching the "medium (default)" that Hermes' /reasoning
# panel shows for an unset config, so the displayed default and the real wire
# value agree. An explicit saved setting or a `/reasoning <level>` change is
# always honored over this default; `/reasoning none` disables it.
_DEFAULT_REASONING_EFFORT = "medium"
def _model_supports_reasoning(model: str | None) -> bool:
"""Solar reasoning-capable models.
The Solar Pro family (``solar-pro``, ``solar-pro2``, ``solar-pro3`` and
dated variants like ``solar-pro3-250127``) and the Solar Open family
(``solar-open*``) accept ``reasoning_effort``. ``solar-mini`` / ``syn-pro``
ignore the parameter, so we don't send it.
"""
m = (model or "").strip().lower()
return any(marker in m for marker in _REASONING_MODEL_MARKERS)
class UpstageProfile(ProviderProfile):
"""Upstage Solar — top-level ``reasoning_effort`` control.
Solar Pro/Open expose reasoning through a top-level ``reasoning_effort``
field (``minimal`` | ``low`` | ``medium`` | ``high``), mirroring OpenAI's
shape. Unlike DeepSeek/Kimi it does NOT require echoing ``reasoning_content``
back on later turns, so only the request field needs wiring. We emit at most
``low`` | ``medium`` | ``high`` the explicit values both Solar Pro 2 and
Pro 3 accept.
Default-on: Solar's own server default is ``minimal`` (off), but for an
agentic workload we default reasoning ON (``_DEFAULT_REASONING_EFFORT``)
when the user hasn't picked an effort. The user can still set any level or
turn it off with ``/reasoning none``.
"""
def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
) -> tuple[dict[str, Any], dict[str, Any]]:
top_level: dict[str, Any] = {}
# solar-mini / syn-pro ignore reasoning_effort — send nothing.
if not _model_supports_reasoning(model):
return {}, top_level
# Unset (reasoning_config is None) → default reasoning ON for agents.
if not reasoning_config or not isinstance(reasoning_config, dict):
return {}, {"reasoning_effort": _DEFAULT_REASONING_EFFORT}
# Explicitly disabled (`/reasoning none`) → omit the field so Solar
# applies its own default (minimal = off).
if reasoning_config.get("enabled") is False:
return {}, top_level
# Map Hermes' effort vocabulary onto Solar's accepted set. xhigh/max
# collapse to high (Solar's strongest). minimal → off (omit). An
# enabled request with no recognised effort uses the default effort.
effort = (reasoning_config.get("effort") or "").strip().lower()
mapped = {
"minimal": None,
"low": "low",
"medium": "medium",
"high": "high",
"xhigh": "high",
"max": "high",
}.get(effort, _DEFAULT_REASONING_EFFORT)
if mapped:
top_level["reasoning_effort"] = mapped
return {}, top_level
upstage = UpstageProfile(
name="upstage",
aliases=("solar",),
display_name="Upstage Solar",
description="Upstage (Solar API)",
signup_url="https://console.upstage.ai/api-keys",
env_vars=("UPSTAGE_API_KEY", "UPSTAGE_BASE_URL"),
base_url="https://api.upstage.ai/v1",
auth_type="api_key",
# default_aux_model left empty → auxiliary side tasks use the main model.
# entry [0] is the setup default. solar-pro is a rolling alias for the
# latest Solar Pro, so the default tracks the current flagship.
fallback_models=(
"solar-pro",
"solar-pro3",
),
)
register_provider(upstage)

View file

@ -0,0 +1,5 @@
name: upstage-provider
kind: model-provider
version: 1.0.0
description: Upstage (Solar API)
author: Upstage AI

View file

@ -0,0 +1,95 @@
"""Focused tests for Upstage Solar first-class provider wiring.
Regression guard for the bug where `hermes model` saved `provider: upstage`
correctly but, on re-entry, showed a different provider as active. Root cause:
`hermes_cli/providers.py` (the resolver behind `resolve_provider_full`) had no
`upstage` overlay, so `resolve_provider_full("upstage")` returned None, the
config provider was discarded, and resolution fell through to env auto-detect.
"""
from __future__ import annotations
import sys
import types
import pytest
if "dotenv" not in sys.modules:
fake_dotenv = types.ModuleType("dotenv")
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
sys.modules["dotenv"] = fake_dotenv
class TestUpstageResolver:
"""The providers.py resolver must recognise upstage (the actual bug)."""
def test_resolve_provider_full_recognizes_upstage(self):
from hermes_cli.providers import resolve_provider_full
pdef = resolve_provider_full("upstage", {}, [])
assert pdef is not None, (
"resolve_provider_full('upstage') returned None — config "
"`provider: upstage` would be discarded and auto-detect would win"
)
assert pdef.id == "upstage"
assert pdef.base_url == "https://api.upstage.ai/v1"
assert "UPSTAGE_API_KEY" in pdef.api_key_env_vars
def test_get_provider_returns_upstage_def(self):
from hermes_cli.providers import get_provider
pdef = get_provider("upstage")
assert pdef is not None and pdef.id == "upstage"
assert pdef.transport == "openai_chat"
def test_solar_alias_normalizes_to_upstage(self):
from hermes_cli.providers import normalize_provider, resolve_provider_full
assert normalize_provider("solar") == "upstage"
pdef = resolve_provider_full("solar", {}, [])
assert pdef is not None and pdef.id == "upstage"
class TestUpstageOverlay:
def test_overlay_exists(self):
from hermes_cli.providers import HERMES_OVERLAYS
assert "upstage" in HERMES_OVERLAYS
overlay = HERMES_OVERLAYS["upstage"]
assert overlay.transport == "openai_chat"
assert overlay.extra_env_vars == ("UPSTAGE_API_KEY",)
assert overlay.base_url_override == "https://api.upstage.ai/v1"
assert overlay.base_url_env_var == "UPSTAGE_BASE_URL"
assert not overlay.is_aggregator
def test_provider_label(self):
from hermes_cli.providers import get_label
assert get_label("upstage") == "Upstage Solar"
class TestUpstageConfigProviderWins:
"""End-to-end: an explicit config provider must beat env auto-detect.
Mirrors the display logic in `hermes_cli/main.py` (cmd_model): read
`model.provider`, resolve it, and only fall back to auto-detect when that
resolution fails. With a stray DEEPSEEK_API_KEY present (the user's case),
upstage must still win because it is configured explicitly.
"""
def test_explicit_upstage_beats_stray_deepseek_key(self, monkeypatch):
from hermes_cli.providers import resolve_provider_full
monkeypatch.setenv("DEEPSEEK_API_KEY", "junk")
monkeypatch.setenv("UPSTAGE_API_KEY", "up-test-key")
config_provider = "upstage" # from config model.provider
active = ""
if config_provider and config_provider != "auto":
adef = resolve_provider_full(config_provider, {}, [])
active = adef.id if adef is not None else ""
assert active == "upstage", (
"explicit config provider should resolve to upstage, not fall "
"through to deepseek auto-detect"
)

View file

@ -0,0 +1,153 @@
"""Unit tests for the Upstage Solar provider profile.
Upstage Solar is a plain OpenAI-compatible api-key provider, so this verifies
the profile is registered correctly and wires the expected identity, endpoint,
auth, and catalog fields the contract every downstream layer (auth, models,
doctor, runtime_provider, transport) reads from.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def upstage_profile():
"""Resolve the registered Upstage profile via the provider registry.
Importing ``model_tools`` triggers plugin discovery, which registers the
Upstage profile. Going through ``get_provider_profile`` keeps the test
honest about the actual registration path (name + alias resolution).
"""
import model_tools # noqa: F401
import providers
profile = providers.get_provider_profile("upstage")
assert profile is not None, "upstage provider profile must be registered"
return profile
class TestUpstageProfile:
def test_identity_and_endpoint(self, upstage_profile):
assert upstage_profile.name == "upstage"
assert upstage_profile.api_mode == "chat_completions"
assert upstage_profile.auth_type == "api_key"
assert upstage_profile.base_url == "https://api.upstage.ai/v1"
assert upstage_profile.get_hostname() == "api.upstage.ai"
def test_solar_alias_resolves(self):
import model_tools # noqa: F401
import providers
assert providers.get_provider_profile("solar") is upstage_profile_singleton()
def test_env_vars(self, upstage_profile):
# API key first, optional base-url override second (priority order).
assert upstage_profile.env_vars == ("UPSTAGE_API_KEY", "UPSTAGE_BASE_URL")
def test_fallback_models_are_agentic_pro_only(self, upstage_profile):
# Only the agentic, tool-calling Solar Pro models belong in the offline
# catalog — Mini is capable but not agentic, so it's never promoted as a
# default. Live /v1/models still surfaces everything when a key is set.
assert upstage_profile.fallback_models == (
"solar-pro",
"solar-pro3",
)
def test_default_model_is_solar_pro(self, upstage_profile):
# Entry [0] is the setup default (get_default_model_for_provider).
assert upstage_profile.fallback_models[0] == "solar-pro"
def test_aux_model_left_empty(self, upstage_profile):
# Unset → auxiliary side tasks fall back to the user's main model.
assert upstage_profile.default_aux_model == ""
class TestUpstageReasoning:
"""``build_api_kwargs_extras`` wires Solar's top-level ``reasoning_effort``.
Solar Pro accepts ``reasoning_effort`` (minimal|low|medium|high, default
minimal=off) and never requires echoing ``reasoning_content`` back, so only
the request field is emitted always top-level, never in extra_body.
"""
@pytest.mark.parametrize("effort", ["low", "medium", "high"])
def test_pro_explicit_effort_passes_through(self, upstage_profile, effort):
extra_body, top_level = upstage_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort}, model="solar-pro3"
)
assert extra_body == {}
assert top_level == {"reasoning_effort": effort}
@pytest.mark.parametrize("effort", ["xhigh", "max"])
def test_pro_strong_efforts_collapse_to_high(self, upstage_profile, effort):
_, top_level = upstage_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort}, model="solar-pro2"
)
assert top_level == {"reasoning_effort": "high"}
def test_pro_enabled_without_effort_defaults_on(self, upstage_profile):
_, top_level = upstage_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True}, model="solar-pro3"
)
assert top_level == {"reasoning_effort": "medium"}
def test_pro_minimal_effort_is_omitted(self, upstage_profile):
# Explicit minimal == reasoning off → omit so Solar applies its default.
_, top_level = upstage_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "minimal"}, model="solar-pro3"
)
assert top_level == {}
def test_disabled_omits_field(self, upstage_profile):
# `/reasoning none` → enabled False → explicitly off.
_, top_level = upstage_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False, "effort": "high"}, model="solar-pro3"
)
assert top_level == {}
@pytest.mark.parametrize("model", ["solar-pro3", "solar-pro", "solar-open2"])
def test_no_config_defaults_reasoning_on(self, upstage_profile, model):
# Unset reasoning_config → default ON at medium (matches the /reasoning
# "medium (default)" label), not Solar's server default of minimal/off.
_, top_level = upstage_profile.build_api_kwargs_extras(model=model)
assert top_level == {"reasoning_effort": "medium"}
@pytest.mark.parametrize("model", ["solar-mini", "syn-pro"])
def test_no_config_non_pro_still_omits(self, upstage_profile, model):
# Default-on must not leak to non-reasoning models.
_, top_level = upstage_profile.build_api_kwargs_extras(model=model)
assert top_level == {}
@pytest.mark.parametrize(
"model",
[
"solar-pro3-250127",
"solar-open",
"solar-open-250127",
"solar-open2",
"solar-open2-260528",
],
)
def test_pro_and_open_variants_support_reasoning(self, upstage_profile, model):
# Both the Solar Pro and Solar Open families (incl. dated variants)
# accept reasoning_effort.
_, top_level = upstage_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"}, model=model
)
assert top_level == {"reasoning_effort": "high"}
@pytest.mark.parametrize("model", ["solar-mini", "syn-pro"])
def test_non_pro_models_never_send_reasoning(self, upstage_profile, model):
# solar-mini / syn-pro ignore reasoning_effort, so never send it.
extra_body, top_level = upstage_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"}, model=model
)
assert extra_body == {}
assert top_level == {}
def upstage_profile_singleton():
import providers
return providers.get_provider_profile("upstage")

View file

@ -65,6 +65,7 @@ const PROVIDER_GROUPS: { prefix: string; name: string; priority: number }[] = [
{ prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
{ prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
{ prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
{ prefix: "UPSTAGE_", name: "Upstage Solar", priority: 14 },
];
function getProviderGroup(key: string): string {