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:
@@ -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)))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user