mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
77d5b2d573
commit
20502b407c
10 changed files with 402 additions and 0 deletions
153
tests/plugins/model_providers/test_upstage_profile.py
Normal file
153
tests/plugins/model_providers/test_upstage_profile.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue