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

@ -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")