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,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