mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +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
|
|
@ -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
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
106
plugins/model-providers/upstage/__init__.py
Normal file
106
plugins/model-providers/upstage/__init__.py
Normal 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)
|
||||
5
plugins/model-providers/upstage/plugin.yaml
Normal file
5
plugins/model-providers/upstage/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
name: upstage-provider
|
||||
kind: model-provider
|
||||
version: 1.0.0
|
||||
description: Upstage (Solar API)
|
||||
author: Upstage AI
|
||||
95
tests/hermes_cli/test_upstage_provider.py
Normal file
95
tests/hermes_cli/test_upstage_provider.py
Normal 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"
|
||||
)
|
||||
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")
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue