fix(desktop): custom endpoints pin an API mode and keep /v1/models alias metadata

Settings > Custom Endpoints assumed Chat Completions: the form, its types, the
update payload and _write_custom_endpoint carried no api_mode, so a
Responses-only (or Anthropic-compatible) host validated fine on /models and
then 404'd on every POST /chat/completions. Validation also flattened each
/v1/models row to a bare id, so a reasoning alias like gpt-5.6-sol-high
(canonical_model + reasoning_effort) was saved as a literal upstream model.

- Desktop form: API Mode segmented control (Auto-detect / Chat Completions /
  Responses API / Anthropic Messages — the same set `hermes model` offers);
  threaded through toPayload, hydrated from GET read-back.
- CustomEndpointUpdate.api_mode (Literal) persisted as providers.<id>.api_mode,
  the key the CLI writes and the runtime reads; None (older UI) leaves a
  hand-written mode alone, "" clears it. GET rows report api_mode.
- validate returns model_details (id / canonical_model / reasoning_effort)
  next to the unchanged string[] models; _parse_model_ids is now a projection
  of _parse_model_entries.
- Save keeps the alias metadata in providers.<id>.models and, when the picked
  default is an alias, persists the canonical model and pins its effort under
  agent.reasoning_overrides (the resolve_reasoning_config chokepoint).

Fixes #93622
Supersedes #69824 (@SacrEllfarch), #82148 (@JackLee992), #93693 (@fangliquanflq)
This commit is contained in:
teknium1
2026-09-19 00:17:19 -07:00
parent afaa53e5fe
commit 363b8a6fcb
8 changed files with 303 additions and 18 deletions

View File

@@ -6,6 +6,7 @@ import type { CustomEndpointsResponse } from '@/types/hermes'
const getCustomEndpoints = vi.fn()
const saveCustomEndpoint = vi.fn()
const validateCustomEndpoint = vi.fn()
const notify = vi.fn()
const notifyError = vi.fn()
const triggerHaptic = vi.fn()
@@ -16,7 +17,7 @@ vi.mock('@/hermes', async importOriginal => ({
deleteCustomEndpoint: vi.fn(),
getCustomEndpoints: (...args: unknown[]) => getCustomEndpoints(...args),
saveCustomEndpoint: (...args: unknown[]) => saveCustomEndpoint(...args),
validateCustomEndpoint: vi.fn()
validateCustomEndpoint: (...args: unknown[]) => validateCustomEndpoint(...args)
}))
vi.mock('./profile-scope', () => ({ ActiveProfileNote: () => null }))
vi.mock('@/lib/haptics', () => ({ triggerHaptic: (...args: unknown[]) => triggerHaptic(...args) }))
@@ -54,6 +55,61 @@ afterEach(() => {
})
describe('CustomEndpointsSettings', () => {
it('sends the chosen API mode and discovered alias metadata on Save (#93622)', async () => {
getCustomEndpoints.mockResolvedValue(emptyResponse)
validateCustomEndpoint.mockResolvedValue({
message: '',
model_details: [
{ id: 'gpt-5.6-sol' },
{ canonical_model: 'gpt-5.6-sol', id: 'gpt-5.6-sol-high', reasoning_effort: 'high' }
],
models: ['gpt-5.6-sol', 'gpt-5.6-sol-high'],
ok: true,
reachable: true
})
saveCustomEndpoint.mockResolvedValue(savedResponse)
const { CustomEndpointsSettings } = await import('./custom-endpoints-settings')
render(<CustomEndpointsSettings />)
await screen.findByText('No custom endpoints')
fireEvent.change(screen.getByPlaceholderText('Axet Proxy'), { target: { value: 'Responses gateway' } })
fireEvent.change(screen.getByPlaceholderText('http://127.0.0.1:8081/v1'), {
target: { value: 'https://responses-gateway.example.com/v1' }
})
fireEvent.click(screen.getByRole('button', { name: 'Responses API' }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Test' }))
})
fireEvent.change(screen.getByPlaceholderText('gpt-5.4'), { target: { value: 'gpt-5.6-sol-high' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(validateCustomEndpoint).toHaveBeenCalledWith(expect.objectContaining({ api_mode: 'codex_responses' }))
expect(saveCustomEndpoint).toHaveBeenCalledWith(
expect.objectContaining({
api_mode: 'codex_responses',
model: 'gpt-5.6-sol-high',
model_details: expect.arrayContaining([
expect.objectContaining({ canonical_model: 'gpt-5.6-sol', id: 'gpt-5.6-sol-high', reasoning_effort: 'high' })
]),
models: ['gpt-5.6-sol', 'gpt-5.6-sol-high']
})
)
})
it('hydrates the API mode from a saved endpoint', async () => {
getCustomEndpoints.mockResolvedValue({
...savedResponse,
endpoints: [{ ...savedResponse.endpoints[0], api_mode: 'anthropic_messages' }]
})
const { CustomEndpointsSettings } = await import('./custom-endpoints-settings')
render(<CustomEndpointsSettings />)
await screen.findByText('Profile A')
expect(screen.getByRole('button', { name: 'Anthropic Messages' }).getAttribute('aria-pressed')).toBe('true')
})
it('drops a pending save completion after its profile-scoped view unmounts', async () => {
let resolveSave!: (value: CustomEndpointsResponse) => void
saveCustomEndpoint.mockReturnValue(new Promise(resolve => (resolveSave = resolve)))

View File

@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
import {
activateCustomEndpoint,
deleteCustomEndpoint,
@@ -16,7 +17,12 @@ import { Check, Globe, Loader2, Plus, Save, Trash2, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { confirm } from '@/store/confirm'
import { notify, notifyError } from '@/store/notifications'
import type { CustomEndpoint, CustomEndpointUpdate } from '@/types/hermes'
import type {
CustomEndpoint,
CustomEndpointApiMode,
CustomEndpointModelDetail,
CustomEndpointUpdate
} from '@/types/hermes'
import { EmptyState, Pill, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
import { ActiveProfileNote } from './profile-scope'
@@ -28,6 +34,7 @@ interface CustomEndpointsSettingsProps {
interface EndpointForm {
apiKey: string
apiMode: CustomEndpointApiMode
baseUrl: string
contextLength: string
discoverModels: boolean
@@ -37,8 +44,18 @@ interface EndpointForm {
name: string
}
// Same choices as `hermes model`'s custom-provider setup; '' = runtime auto-detect.
// This panel is not internationalized — keep the literals it has.
const API_MODE_OPTIONS: readonly { id: CustomEndpointApiMode; label: string }[] = [
{ id: '', label: 'Auto-detect' },
{ id: 'chat_completions', label: 'Chat Completions' },
{ id: 'codex_responses', label: 'Responses API' },
{ id: 'anthropic_messages', label: 'Anthropic Messages' }
]
const EMPTY_FORM: EndpointForm = {
apiKey: '',
apiMode: '',
baseUrl: '',
contextLength: '',
discoverModels: true,
@@ -51,6 +68,7 @@ const EMPTY_FORM: EndpointForm = {
function formFromEndpoint(endpoint: CustomEndpoint): EndpointForm {
return {
apiKey: '',
apiMode: endpoint.api_mode ?? '',
baseUrl: endpoint.base_url,
contextLength: endpoint.context_length ? String(endpoint.context_length) : '',
discoverModels: endpoint.discover_models,
@@ -61,7 +79,11 @@ function formFromEndpoint(endpoint: CustomEndpoint): EndpointForm {
}
}
function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate {
function toPayload(
form: EndpointForm,
models?: string[],
modelDetails?: CustomEndpointModelDetail[]
): CustomEndpointUpdate {
const contextLength = Number.parseInt(form.contextLength, 10)
return {
@@ -70,10 +92,12 @@ function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate
base_url: form.baseUrl.trim(),
model: form.model.trim(),
api_key: form.apiKey.trim() || undefined,
api_mode: form.apiMode,
context_length: Number.isFinite(contextLength) && contextLength > 0 ? contextLength : undefined,
discover_models: form.discoverModels,
make_default: form.makeDefault,
models: models?.length ? models : undefined
models: models?.length ? models : undefined,
model_details: modelDetails?.length ? modelDetails : undefined
}
}
@@ -88,6 +112,9 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
const [endpoints, setEndpoints] = useState<CustomEndpoint[]>([])
const [form, setForm] = useState<EndpointForm>(EMPTY_FORM)
const [discoveredModels, setDiscoveredModels] = useState<string[]>([])
// Alias metadata from the last Test; the backend resolves a picked alias to its
// canonical model + reasoning effort on Save (#93622).
const [discoveredDetails, setDiscoveredDetails] = useState<CustomEndpointModelDetail[]>([])
async function refresh() {
const data = await getCustomEndpoints()
@@ -137,7 +164,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
async function handleSave() {
try {
setSaving(true)
const response = await saveCustomEndpoint(toPayload(form, discoveredModels))
const response = await saveCustomEndpoint(toPayload(form, discoveredModels, discoveredDetails))
if (!mounted.current) {
return
@@ -179,6 +206,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
}
setDiscoveredModels(response.models)
setDiscoveredDetails(response.model_details ?? [])
if (response.ok) {
if (!form.model && response.models[0]) {
@@ -256,6 +284,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
if (form.id === endpoint.id) {
setForm(EMPTY_FORM)
setDiscoveredModels([])
setDiscoveredDetails([])
}
onConfigSaved?.()
@@ -293,6 +322,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
onClick={() => {
setForm(formFromEndpoint(endpoint))
setDiscoveredModels(endpoint.models)
setDiscoveredDetails([])
}}
type="button"
>
@@ -377,6 +407,15 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
value={form.baseUrl}
/>
</label>
<fieldset className="grid min-w-0 gap-1.5 text-xs text-muted-foreground">
<legend className="mb-1.5">API Mode</legend>
<SegmentedControl
className="w-full max-w-full"
onChange={apiMode => setForm(current => ({ ...current, apiMode }))}
options={API_MODE_OPTIONS}
value={form.apiMode}
/>
</fieldset>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem]">
<label className="grid gap-1.5 text-xs text-muted-foreground">
Default Model
@@ -445,6 +484,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
onClick={() => {
setForm(EMPTY_FORM)
setDiscoveredModels([])
setDiscoveredDetails([])
}}
type="button"
variant="ghost"

View File

@@ -221,8 +221,21 @@ export interface MemoryProviderConfig {
name: string
}
/** Transport pinned on a custom endpoint; `''` = let the runtime auto-detect. Same
* choices as `hermes model`'s custom-provider setup (#93622). */
export type CustomEndpointApiMode = '' | 'anthropic_messages' | 'chat_completions' | 'codex_responses'
/** One `/v1/models` row; a gateway may advertise a reasoning alias
* (`gpt-5.6-sol-high` → `gpt-5.6-sol` @ `high`) that the bare id list flattens. */
export interface CustomEndpointModelDetail {
canonical_model?: null | string
id: string
reasoning_effort?: null | string
}
export interface CustomEndpoint {
api_key_preview?: null | string
api_mode?: CustomEndpointApiMode
base_url: string
context_length?: null | number
discover_models: boolean
@@ -248,18 +261,22 @@ export interface CustomEndpointsResponse {
export interface CustomEndpointUpdate {
api_key?: string
api_mode?: CustomEndpointApiMode
base_url: string
context_length?: number
discover_models?: boolean
id?: string
make_default?: boolean
model: string
model_details?: CustomEndpointModelDetail[]
models?: string[]
name: string
}
export interface CustomEndpointValidationResponse {
message: string
/** Older backends send only `models`. */
model_details?: CustomEndpointModelDetail[]
models: string[]
ok: boolean
reachable: boolean

View File

@@ -33,16 +33,27 @@ class MemoryProviderConfigUpdate(BaseModel):
class MemoryProviderSetupRequest(BaseModel):
values: Dict[str, Any] = {}
class CustomEndpointModelDetail(BaseModel):
"""One ``/v1/models`` row with the routing metadata a gateway may advertise on a
reasoning alias (``gpt-5.6-sol-high`` → ``gpt-5.6-sol`` @ ``high``). See #93622."""
id: str
canonical_model: Optional[str] = None
reasoning_effort: Optional[str] = None
class CustomEndpointUpdate(BaseModel):
id: str = ""
name: str
base_url: str
model: str
api_key: Optional[str] = None
# Same choices as the CLI's custom-provider setup; "" = auto-detect at runtime.
# None (older UI payload) leaves a hand-written api_mode alone.
api_mode: Optional[Literal["", "chat_completions", "codex_responses", "anthropic_messages"]] = None
context_length: Optional[int] = None
discover_models: bool = True
make_default: bool = False
models: Optional[List[str]] = None
model_details: Optional[List[CustomEndpointModelDetail]] = None
class MessagingPlatformUpdate(BaseModel):
enabled: Optional[bool] = None

View File

@@ -18,11 +18,11 @@ from hermes_cli.web_server_config import (
_validated_main_model_selection,
)
from hermes_cli.web_server_profiles import (
_approval_mode_of, _broadcast_gateway_session_info, _is_other_profile, _parse_model_ids,
_approval_mode_of, _broadcast_gateway_session_info, _is_other_profile, _parse_model_entries, _parse_model_ids,
)
from fastapi import HTTPException, Request
from hermes_cli.config import DEFAULT_CONFIG, OPTIONAL_ENV_VARS, read_raw_config, custom_endpoint_key_env, coerce_provider_id, find_provider_entry, get_compatible_custom_providers, redact_key, _deep_merge
from hermes_cli.config_providers import _custom_provider_entry_to_provider_config
from hermes_cli.config_providers import _canonical_api_mode, _custom_provider_entry_to_provider_config
from hermes_cli.web_models import ConfigUpdate, EnvVarUpdate, EnvVarDelete, EnvVarReveal, CustomEndpointUpdate
from typing import Any, Dict, List, Optional, Tuple
@@ -382,6 +382,18 @@ def _config_api_key_is_env_ref(endpoint_id: str) -> bool:
return bool(isinstance(raw_key, str) and re.search(r"\$\{[^}]+\}", raw_key))
_DESKTOP_API_MODES = {"chat_completions", "codex_responses", "anthropic_messages"}
def _endpoint_api_mode(entry: Dict[str, Any]) -> str:
"""The transport a providers entry pins (``api_mode``, or the v12 migration's ``transport``
spelling), canonicalized; ``""`` = runtime auto-detect. Mirrors the read order of
``runtime_provider_custom._get_named_custom_provider``."""
raw = str(entry.get("api_mode") or entry.get("transport") or "")
mode = _canonical_api_mode(raw).lower()
return mode if mode in _DESKTOP_API_MODES else ""
def _endpoint_row(
endpoint_id: str, name: str, base_url: str, model: str, models: List[str], context_length,
discover_models: bool, key_entry: Dict[str, Any], is_current: bool, source: str,
@@ -389,6 +401,7 @@ def _endpoint_row(
has_api_key, api_key_preview = _api_key_display(key_entry)
return {
"id": endpoint_id, "name": name, "base_url": base_url, "model": model, "models": models,
"api_mode": _endpoint_api_mode(key_entry),
"context_length": context_length, "discover_models": discover_models,
"has_api_key": has_api_key, "api_key_preview": api_key_preview,
"is_current": is_current, "source": source,
@@ -534,28 +547,65 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
# Merge onto the existing entry rather than replacing it: a providers.<name>
# block can carry hand-written keys the dashboard has no field for
# (``api_mode``, ``key_env``/``api_key_env``, ``extra_headers`` — possibly
# with credentials — ``request_overrides``); rebuilding from scratch
# silently dropped them on an unrelated edit.
# (``key_env``/``api_key_env``, ``extra_headers`` — possibly with
# credentials — ``request_overrides``); rebuilding from scratch silently
# dropped them on an unrelated edit.
entry: Dict[str, Any] = dict(existing)
entry.update({
"name": name, "base_url": base_url, "model": model,
"discover_models": bool(body.discover_models),
})
# A Responses-only or Anthropic-compatible host 404s on the runtime's
# Chat Completions default, so the panel pins the transport the same way
# ``hermes model`` does (``api_mode``; the runtime also reads the v12
# ``transport`` spelling, so drop it rather than let the two disagree).
# ``None`` = older UI payload: keep whatever is hand-written. See #93622.
if body.api_mode is not None:
entry.pop("transport", None)
if body.api_mode:
entry["api_mode"] = body.api_mode
else:
entry.pop("api_mode", None)
# Same for the model map, so existing models keep their context lengths.
# ``body.models`` is the catalogue the panel's Test button discovered;
# without it only the hand-typed model survived Save. A payload with no
# ``models`` (older UI) still ensures the named default is present.
# See #69988.
details = {d.id.strip(): d for d in (body.model_details or ()) if d.id.strip()}
existing_models = entry.get("models")
models_map: Dict[str, Any] = dict(existing_models) if isinstance(existing_models, dict) else {}
for candidate in (*(body.models or ()), model):
for candidate in (*(body.models or ()), *details, model):
model_id = str(candidate).strip()
if not model_id:
continue
current = models_map.get(model_id)
models_map[model_id] = dict(current) if isinstance(current, dict) else {}
row = dict(current) if isinstance(current, dict) else {}
detail = details.get(model_id)
if detail is not None:
# Keep the alias metadata ``/v1/models`` advertised so the catalogue
# still says what ``gpt-5.6-sol-high`` stands for after Save.
row.update({k: v.strip() for k, v in (("canonical_model", detail.canonical_model),
("reasoning_effort", detail.reasoning_effort)) if v and v.strip()})
models_map[model_id] = row
entry["models"] = models_map
# A reasoning alias is not a model the inference route accepts literally:
# persist the canonical model and pin its effort through the one runtime
# chokepoint (``agent.reasoning_overrides`` → ``resolve_reasoning_config``).
alias = details.get(model)
canonical = (alias.canonical_model or "").strip() if alias is not None else ""
if canonical and canonical != model:
from hermes_constants import parse_reasoning_effort
effort = (alias.reasoning_effort or "").strip().lower()
if parse_reasoning_effort(effort) is not None:
agent_cfg = cfg.get("agent") if isinstance(cfg.get("agent"), dict) else {}
overrides = agent_cfg.get("reasoning_overrides")
overrides = dict(overrides) if isinstance(overrides, dict) else {}
overrides[canonical] = effort
agent_cfg["reasoning_overrides"] = overrides
cfg["agent"] = agent_cfg
model = canonical
entry["model"] = model
models_map.setdefault(model, {})
if body.context_length and body.context_length > 0:
entry["context_length"] = int(body.context_length)
entry["models"][model]["context_length"] = int(body.context_length)
@@ -730,7 +780,11 @@ async def validate_custom_endpoint(body: CustomEndpointUpdate):
if not resp.is_success:
return {"ok": False, "reachable": True, "message": f"Endpoint returned HTTP {resp.status_code}.", "models": []}
return {"ok": True, "reachable": True, "message": "", "models": _parse_model_ids(resp)}
# ``models`` stays the bare id list older clients read; ``model_details`` keeps the
# alias metadata (``canonical_model`` / ``reasoning_effort``) the id list flattens.
entries = _parse_model_entries(resp)
return {"ok": True, "reachable": True, "message": "", "models": [e["id"] for e in entries],
"model_details": entries}
def _endpoint_probe_client(url: str, timeout: float):

View File

@@ -81,9 +81,15 @@ def _broadcast_gateway_session_info() -> None:
_log.exception("session.info broadcast after config save failed")
def _parse_model_ids(resp: "Any") -> List[str]:
"""Model ids from an OpenAI-compatible ``/v1/models`` response: ``{"data": [{"id": ..}]}``
or a bare ``{"data": ["id", ..]}``. ``[]`` on any parse/HTTP error so a slightly
_MODEL_ENTRY_METADATA = ("canonical_model", "reasoning_effort")
def _parse_model_entries(resp: "Any") -> List[Dict[str, str]]:
"""Model rows from an OpenAI-compatible ``/v1/models`` response as ``{"id": ..}`` dicts,
keeping the alias metadata a gateway may advertise (``canonical_model``,
``reasoning_effort``). Flattening to bare ids lost that, so Desktop stored a reasoning
alias as the literal upstream model (#93622). Accepts ``{"data": [{"id": ..}]}`` or a
bare ``{"data": ["id", ..]}``; ``[]`` on any parse/HTTP error so a slightly
non-standard endpoint never hard-blocks."""
try:
if not resp.is_success:
@@ -94,8 +100,24 @@ def _parse_model_ids(resp: "Any") -> List[str]:
data = payload.get("data") if isinstance(payload, dict) else payload
if not isinstance(data, list):
return []
ids = [str((item.get("id") if isinstance(item, dict) else item) or "").strip() for item in data]
return [mid for mid in ids if mid]
entries: List[Dict[str, str]] = []
for item in data:
model_id = str((item.get("id") if isinstance(item, dict) else item) or "").strip()
if not model_id:
continue
entry = {"id": model_id}
if isinstance(item, dict):
for key in _MODEL_ENTRY_METADATA:
value = str(item.get(key) or "").strip()
if value:
entry[key] = value
entries.append(entry)
return entries
def _parse_model_ids(resp: "Any") -> List[str]:
"""Bare model ids from a ``/v1/models`` response (see :func:`_parse_model_entries`)."""
return [entry["id"] for entry in _parse_model_entries(resp)]
def _fallback_profile_entry(profiles_mod, name: str, home: Path, *, is_default: bool,

View File

@@ -2061,6 +2061,89 @@ class TestWebServerEndpoints:
assert "sk-super-secret" not in yaml.safe_dump(cfg)
def test_custom_endpoint_save_pins_api_mode_and_resolves_reasoning_alias(self):
"""Desktop's Custom Endpoints form pins the transport and keeps alias metadata (#93622).
A Responses-only host 404s on the runtime's Chat Completions default, so the chosen
``api_mode`` must land on the providers entry and read back; a discovered reasoning
alias resolves to its canonical model + ``agent.reasoning_overrides`` instead of being
saved as a literal upstream model id.
"""
from hermes_cli.config import load_config
response = self.client.post(
"/api/providers/custom-endpoints",
json={
"id": "custom-responses", "name": "custom-responses",
"base_url": "https://responses-gateway.example.com/v1",
"model": "gpt-5.6-sol-high", "api_mode": "codex_responses", "make_default": True,
"models": ["gpt-5.6-sol", "gpt-5.6-sol-high"],
"model_details": [
{"id": "gpt-5.6-sol"},
{"id": "gpt-5.6-sol-high", "canonical_model": "gpt-5.6-sol", "reasoning_effort": "high"},
],
},
)
assert response.status_code == 200
row = next(e for e in response.json()["endpoints"] if e["id"] == "custom-responses")
assert row["api_mode"] == "codex_responses"
assert row["model"] == "gpt-5.6-sol"
cfg = load_config()
entry = cfg["providers"]["custom-responses"]
assert entry["api_mode"] == "codex_responses"
assert entry["model"] == "gpt-5.6-sol"
assert entry["models"]["gpt-5.6-sol-high"] == {"canonical_model": "gpt-5.6-sol", "reasoning_effort": "high"}
assert cfg["model"]["default"] == "gpt-5.6-sol"
assert cfg["agent"]["reasoning_overrides"]["gpt-5.6-sol"] == "high"
# An older UI payload (no api_mode) leaves the pinned transport alone; "" clears it.
self.client.post("/api/providers/custom-endpoints", json={
"id": "custom-responses", "name": "custom-responses",
"base_url": "https://responses-gateway.example.com/v1", "model": "gpt-5.6-sol"})
assert load_config()["providers"]["custom-responses"]["api_mode"] == "codex_responses"
self.client.post("/api/providers/custom-endpoints", json={
"id": "custom-responses", "name": "custom-responses", "api_mode": "",
"base_url": "https://responses-gateway.example.com/v1", "model": "gpt-5.6-sol"})
listed = self.client.get("/api/providers/custom-endpoints").json()["endpoints"]
assert next(e for e in listed if e["id"] == "custom-responses")["api_mode"] == ""
assert "api_mode" not in load_config()["providers"]["custom-responses"]
def test_custom_endpoint_validate_keeps_model_alias_metadata(self, monkeypatch):
"""``validate`` returns the bare id list older clients read AND ``model_details`` with
the ``canonical_model`` / ``reasoning_effort`` a gateway advertises (#93622)."""
import contextlib
from hermes_cli.web_routers import config_env
class FakeResp:
status_code = 200
is_success = True
def json(self):
return {"data": [
{"id": "gpt-5.6-sol", "object": "model"},
{"id": "gpt-5.6-sol-high", "canonical_model": "gpt-5.6-sol", "reasoning_effort": "high"},
]}
class FakeClient:
async def get(self, url, headers=None):
return FakeResp()
@contextlib.asynccontextmanager
async def fake_probe_client(url, timeout):
yield FakeClient()
monkeypatch.setattr(config_env, "_endpoint_probe_client", fake_probe_client)
body = self.client.post("/api/providers/custom-endpoints/validate", json={
"name": "x", "base_url": "https://responses-gateway.example.com/v1", "model": ""}).json()
assert body["ok"] is True
assert body["models"] == ["gpt-5.6-sol", "gpt-5.6-sol-high"]
assert body["model_details"] == [
{"id": "gpt-5.6-sol"},
{"id": "gpt-5.6-sol-high", "canonical_model": "gpt-5.6-sol", "reasoning_effort": "high"},
]
def test_custom_endpoint_save_leaves_a_hand_written_env_ref_alone(self, monkeypatch):
"""``api_key: ${MY_KEY}`` is already safe — don't copy it elsewhere.
@@ -5243,6 +5326,7 @@ class TestValidateProviderCredential:
"reachable": True,
"message": "",
"models": ["local-model"],
"model_details": [{"id": "local-model"}],
}
assert captured == {
"url": "http://localhost:8000/v1/models",

View File

@@ -194,6 +194,7 @@ Manage providers, models, tools, and credentials from a real UI instead of editi
- **Providers settings pane** — a dedicated place to manage inference providers, with an Accounts / API-keys UX for signing in and storing credentials per provider. Accounts and API keys share the Settings **Applies to** selection: credential reads and edits, OAuth account removal, and sign-in launched here target the selected profile, not the active chat profile. The sign-in flow keeps that target through credential saving and model selection. Changing **Applies to** discards unsaved credential drafts. Closing sign-in cancels polling and ignores late results; a credential write already sent may still finish in its original profile. Externally managed CLI credentials use their own CLI and are not covered by this profile selector. Its **Local Models** view installs and manages an on-device llama.cpp runtime — see [Local Models](./local-models.md).
- **Every provider and model in the menus** — the GUI surfaces the full provider list and every model that `hermes model` knows about, so you pick from the same catalog the CLI sees rather than a curated subset.
- **Custom endpoints with an API mode** — **Settings → Providers → Custom Endpoints** has an **API Mode** selector (**Auto-detect**, **Chat Completions**, **Responses API**, **Anthropic Messages**) — the same choice `hermes model` offers for a custom provider. It is saved as `providers.<id>.api_mode` in `config.yaml`, so a Responses-only or Anthropic-compatible host is no longer called on `/chat/completions`. **Test** also keeps the alias metadata a gateway advertises in `/v1/models` (`canonical_model`, `reasoning_effort`): picking an alias such as `gpt-5.6-sol-high` saves the canonical model and pins its effort under `agent.reasoning_overrides`.
- **xAI Grok OAuth** — Grok is a first-class OAuth provider in the launcher; sign in through the browser flow like the other OAuth providers.
- **Tool-backend installs from the GUI** — run a tool backend's post-setup install steps directly from the app instead of dropping to a terminal. In the terminal backend picker, selecting a backend marked **Needs setup** asks for confirmation first; declining leaves the current backend selected.
- **Terminal font picker** — choose an installed font in **Settings → Appearance**. Nerd Fonts such as `MesloLGS NF` render Powerlevel10k separators and icons in both interactive and agent terminals; the setting is saved per profile.