feat: API server model name derived from profile name (#6857)

* feat: API server model name derived from profile name

For multi-user setups (e.g. OpenWebUI), each profile's API server now
advertises a distinct model name on /v1/models:

- Profile 'lucas' -> model ID 'lucas'
- Profile 'admin' -> model ID 'admin'
- Default profile -> 'hermes-agent' (unchanged)

Explicit override via API_SERVER_MODEL_NAME env var or
platforms.api_server.model_name config for custom names.

Resolves friction where OpenWebUI couldn't distinguish multiple
hermes-agent connections all advertising the same model name.

* docs: multi-user setup with profiles for API server + Open WebUI

- api-server.md: added Multi-User Setup section, API_SERVER_MODEL_NAME
  to config table, updated /v1/models description
- open-webui.md: added Multi-User Setup with Profiles section with
  step-by-step guide, updated model name references
- environment-variables.md: added API_SERVER_MODEL_NAME entry
This commit is contained in:
Teknium 2026-04-09 17:07:29 -07:00 committed by GitHub
parent 2d0d05a337
commit 9634e20e15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 152 additions and 9 deletions

View file

@ -901,6 +901,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
pass pass
if api_server_host: if api_server_host:
config.platforms[Platform.API_SERVER].extra["host"] = api_server_host config.platforms[Platform.API_SERVER].extra["host"] = api_server_host
api_server_model_name = os.getenv("API_SERVER_MODEL_NAME", "")
if api_server_model_name:
config.platforms[Platform.API_SERVER].extra["model_name"] = api_server_model_name
# Webhook platform # Webhook platform
webhook_enabled = os.getenv("WEBHOOK_ENABLED", "").lower() in ("true", "1", "yes") webhook_enabled = os.getenv("WEBHOOK_ENABLED", "").lower() in ("true", "1", "yes")

View file

@ -299,6 +299,9 @@ class APIServerAdapter(BasePlatformAdapter):
self._cors_origins: tuple[str, ...] = self._parse_cors_origins( self._cors_origins: tuple[str, ...] = self._parse_cors_origins(
extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")), extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")),
) )
self._model_name: str = self._resolve_model_name(
extra.get("model_name", os.getenv("API_SERVER_MODEL_NAME", "")),
)
self._app: Optional["web.Application"] = None self._app: Optional["web.Application"] = None
self._runner: Optional["web.AppRunner"] = None self._runner: Optional["web.AppRunner"] = None
self._site: Optional["web.TCPSite"] = None self._site: Optional["web.TCPSite"] = None
@ -324,6 +327,26 @@ class APIServerAdapter(BasePlatformAdapter):
return tuple(str(item).strip() for item in items if str(item).strip()) return tuple(str(item).strip() for item in items if str(item).strip())
@staticmethod
def _resolve_model_name(explicit: str) -> str:
"""Derive the advertised model name for /v1/models.
Priority:
1. Explicit override (config extra or API_SERVER_MODEL_NAME env var)
2. Active profile name (so each profile advertises a distinct model)
3. Fallback: "hermes-agent"
"""
if explicit and explicit.strip():
return explicit.strip()
try:
from hermes_cli.profiles import get_active_profile_name
profile = get_active_profile_name()
if profile and profile not in ("default", "custom"):
return profile
except Exception:
pass
return "hermes-agent"
def _cors_headers_for_origin(self, origin: str) -> Optional[Dict[str, str]]: def _cors_headers_for_origin(self, origin: str) -> Optional[Dict[str, str]]:
"""Return CORS headers for an allowed browser origin.""" """Return CORS headers for an allowed browser origin."""
if not origin or not self._cors_origins: if not origin or not self._cors_origins:
@ -468,12 +491,12 @@ class APIServerAdapter(BasePlatformAdapter):
"object": "list", "object": "list",
"data": [ "data": [
{ {
"id": "hermes-agent", "id": self._model_name,
"object": "model", "object": "model",
"created": int(time.time()), "created": int(time.time()),
"owned_by": "hermes", "owned_by": "hermes",
"permission": [], "permission": [],
"root": "hermes-agent", "root": self._model_name,
"parent": None, "parent": None,
} }
], ],
@ -546,7 +569,7 @@ class APIServerAdapter(BasePlatformAdapter):
# history already set from request body above # history already set from request body above
completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}" completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
model_name = body.get("model", "hermes-agent") model_name = body.get("model", self._model_name)
created = int(time.time()) created = int(time.time())
if stream: if stream:
@ -923,7 +946,7 @@ class APIServerAdapter(BasePlatformAdapter):
"object": "response", "object": "response",
"status": "completed", "status": "completed",
"created_at": created_at, "created_at": created_at,
"model": body.get("model", "hermes-agent"), "model": body.get("model", self._model_name),
"output": output_items, "output": output_items,
"usage": { "usage": {
"input_tokens": usage.get("input_tokens", 0), "input_tokens": usage.get("input_tokens", 0),
@ -1653,8 +1676,8 @@ class APIServerAdapter(BasePlatformAdapter):
self._mark_connected() self._mark_connected()
logger.info( logger.info(
"[%s] API server listening on http://%s:%d", "[%s] API server listening on http://%s:%d (model: %s)",
self.name, self._host, self._port, self.name, self._host, self._port, self._model_name,
) )
return True return True

View file

@ -1216,6 +1216,14 @@ OPTIONAL_ENV_VARS = {
"category": "messaging", "category": "messaging",
"advanced": True, "advanced": True,
}, },
"API_SERVER_MODEL_NAME": {
"description": "Model name advertised on /v1/models. Defaults to the profile name (or 'hermes-agent' for the default profile). Useful for multi-user setups with OpenWebUI.",
"prompt": "API server model name",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"WEBHOOK_ENABLED": { "WEBHOOK_ENABLED": {
"description": "Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.", "description": "Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.",
"prompt": "Enable webhooks (true/false)", "prompt": "Enable webhooks (true/false)",

View file

@ -294,6 +294,40 @@ class TestModelsEndpoint:
assert data["data"][0]["id"] == "hermes-agent" assert data["data"][0]["id"] == "hermes-agent"
assert data["data"][0]["owned_by"] == "hermes" assert data["data"][0]["owned_by"] == "hermes"
@pytest.mark.asyncio
async def test_models_returns_profile_name(self):
"""When running under a named profile, /v1/models advertises the profile name."""
with patch("gateway.platforms.api_server.APIServerAdapter._resolve_model_name", return_value="lucas"):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/v1/models")
assert resp.status == 200
data = await resp.json()
assert data["data"][0]["id"] == "lucas"
assert data["data"][0]["root"] == "lucas"
@pytest.mark.asyncio
async def test_models_returns_explicit_model_name(self):
"""Explicit model_name in config overrides profile name."""
extra = {"model_name": "my-custom-agent"}
config = PlatformConfig(enabled=True, extra=extra)
adapter = APIServerAdapter(config)
assert adapter._model_name == "my-custom-agent"
def test_resolve_model_name_explicit(self):
assert APIServerAdapter._resolve_model_name("my-bot") == "my-bot"
def test_resolve_model_name_default_profile(self):
"""Default profile falls back to 'hermes-agent'."""
with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
assert APIServerAdapter._resolve_model_name("") == "hermes-agent"
def test_resolve_model_name_named_profile(self):
"""Named profile uses the profile name as model name."""
with patch("hermes_cli.profiles.get_active_profile_name", return_value="lucas"):
assert APIServerAdapter._resolve_model_name("") == "lucas"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_models_requires_auth(self, auth_adapter): async def test_models_requires_auth(self, auth_adapter):
app = _create_app(auth_adapter) app = _create_app(auth_adapter)

View file

@ -261,6 +261,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
| `API_SERVER_CORS_ORIGINS` | Comma-separated browser origins allowed to call the API server directly (for example `http://localhost:3000,http://127.0.0.1:3000`). Default: disabled. | | `API_SERVER_CORS_ORIGINS` | Comma-separated browser origins allowed to call the API server directly (for example `http://localhost:3000,http://127.0.0.1:3000`). Default: disabled. |
| `API_SERVER_PORT` | Port for the API server (default: `8642`) | | `API_SERVER_PORT` | Port for the API server (default: `8642`) |
| `API_SERVER_HOST` | Host/bind address for the API server (default: `127.0.0.1`). Use `0.0.0.0` for network access only with `API_SERVER_KEY` and a narrow `API_SERVER_CORS_ORIGINS` allowlist. | | `API_SERVER_HOST` | Host/bind address for the API server (default: `127.0.0.1`). Use `0.0.0.0` for network access only with `API_SERVER_KEY` and a narrow `API_SERVER_CORS_ORIGINS` allowlist. |
| `API_SERVER_MODEL_NAME` | Model name advertised on `/v1/models`. Defaults to the profile name (or `hermes-agent` for the default profile). Useful for multi-user setups where frontends like Open WebUI need distinct model names per connection. |
| `MESSAGING_CWD` | Working directory for terminal commands in messaging mode (default: `~`) | | `MESSAGING_CWD` | Working directory for terminal commands in messaging mode (default: `~`) |
| `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms | | `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms |
| `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlists (`true`/`false`, default: `false`) | | `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlists (`true`/`false`, default: `false`) |

View file

@ -152,7 +152,7 @@ Delete a stored response.
### GET /v1/models ### GET /v1/models
Lists `hermes-agent` as an available model. Required by most frontends for model discovery. Lists the agent as an available model. The advertised model name defaults to the [profile](/docs/user-guide/features/profiles) name (or `hermes-agent` for the default profile). Required by most frontends for model discovery.
### GET /health ### GET /health
@ -193,6 +193,7 @@ The default bind address (`127.0.0.1`) is for local-only use. Browser access is
| `API_SERVER_HOST` | `127.0.0.1` | Bind address (localhost only by default) | | `API_SERVER_HOST` | `127.0.0.1` | Bind address (localhost only by default) |
| `API_SERVER_KEY` | _(none)_ | Bearer token for auth | | `API_SERVER_KEY` | _(none)_ | Bearer token for auth |
| `API_SERVER_CORS_ORIGINS` | _(none)_ | Comma-separated allowed browser origins | | `API_SERVER_CORS_ORIGINS` | _(none)_ | Comma-separated allowed browser origins |
| `API_SERVER_MODEL_NAME` | _(profile name)_ | Model name on `/v1/models`. Defaults to profile name, or `hermes-agent` for default profile. |
### config.yaml ### config.yaml
@ -242,6 +243,36 @@ Any frontend that supports the OpenAI API format works. Tested/documented integr
| OpenAI Python SDK | — | `OpenAI(base_url="http://localhost:8642/v1")` | | OpenAI Python SDK | — | `OpenAI(base_url="http://localhost:8642/v1")` |
| curl | — | Direct HTTP requests | | curl | — | Direct HTTP requests |
## Multi-User Setup with Profiles
To give multiple users their own isolated Hermes instance (separate config, memory, skills), use [profiles](/docs/user-guide/features/profiles):
```bash
# Create a profile per user
hermes profile create alice
hermes profile create bob
# Configure each profile's API server on a different port
hermes -p alice config set API_SERVER_ENABLED true
hermes -p alice config set API_SERVER_PORT 8643
hermes -p alice config set API_SERVER_KEY alice-secret
hermes -p bob config set API_SERVER_ENABLED true
hermes -p bob config set API_SERVER_PORT 8644
hermes -p bob config set API_SERVER_KEY bob-secret
# Start each profile's gateway
hermes -p alice gateway &
hermes -p bob gateway &
```
Each profile's API server automatically advertises the profile name as the model ID:
- `http://localhost:8643/v1/models` → model `alice`
- `http://localhost:8644/v1/models` → model `bob`
In Open WebUI, add each as a separate connection. The model dropdown shows `alice` and `bob` as distinct models, each backed by a fully isolated Hermes instance. See the [Open WebUI guide](/docs/user-guide/messaging/open-webui#multi-user-setup-with-profiles) for details.
## Limitations ## Limitations
- **Response storage** — stored responses (for `previous_response_id`) are persisted in SQLite and survive gateway restarts. Max 100 stored responses (LRU eviction). - **Response storage** — stored responses (for `previous_response_id`) are persisted in SQLite and survive gateway restarts. Max 100 stored responses (LRU eviction).

View file

@ -60,7 +60,7 @@ docker run -d -p 3000:8080 \
### 4. Open the UI ### 4. Open the UI
Go to **http://localhost:3000**. Create your admin account (the first user becomes admin). You should see **hermes-agent** in the model dropdown. Start chatting! Go to **http://localhost:3000**. Create your admin account (the first user becomes admin). You should see your agent in the model dropdown (named after your profile, or **hermes-agent** for the default profile). Start chatting!
## Docker Compose Setup ## Docker Compose Setup
@ -106,7 +106,7 @@ If you prefer to configure the connection through the UI instead of environment
7. Click the **checkmark** to verify the connection 7. Click the **checkmark** to verify the connection
8. **Save** 8. **Save**
The **hermes-agent** model should now appear in the model dropdown. Your agent model should now appear in the model dropdown (named after your profile, or **hermes-agent** for the default profile).
:::warning :::warning
Environment variables only take effect on Open WebUI's **first launch**. After that, connection settings are stored in its internal database. To change them later, use the Admin UI or delete the Docker volume and start fresh. Environment variables only take effect on Open WebUI's **first launch**. After that, connection settings are stored in its internal database. To change them later, use the Admin UI or delete the Docker volume and start fresh.
@ -196,6 +196,49 @@ Hermes Agent may be executing multiple tool calls (reading files, running comman
Make sure your `OPENAI_API_KEY` in Open WebUI matches the `API_SERVER_KEY` in Hermes Agent. Make sure your `OPENAI_API_KEY` in Open WebUI matches the `API_SERVER_KEY` in Hermes Agent.
## Multi-User Setup with Profiles
To run separate Hermes instances per user — each with their own config, memory, and skills — use [profiles](/docs/user-guide/features/profiles). Each profile runs its own API server on a different port and automatically advertises the profile name as the model in Open WebUI.
### 1. Create profiles and configure API servers
```bash
hermes profile create alice
hermes -p alice config set API_SERVER_ENABLED true
hermes -p alice config set API_SERVER_PORT 8643
hermes -p alice config set API_SERVER_KEY alice-secret
hermes profile create bob
hermes -p bob config set API_SERVER_ENABLED true
hermes -p bob config set API_SERVER_PORT 8644
hermes -p bob config set API_SERVER_KEY bob-secret
```
### 2. Start each gateway
```bash
hermes -p alice gateway &
hermes -p bob gateway &
```
### 3. Add connections in Open WebUI
In **Admin Settings****Connections****OpenAI API****Manage**, add one connection per profile:
| Connection | URL | API Key |
|-----------|-----|---------|
| Alice | `http://host.docker.internal:8643/v1` | `alice-secret` |
| Bob | `http://host.docker.internal:8644/v1` | `bob-secret` |
The model dropdown will show `alice` and `bob` as distinct models. You can assign models to Open WebUI users via the admin panel, giving each user their own isolated Hermes agent.
:::tip Custom Model Names
The model name defaults to the profile name. To override it, set `API_SERVER_MODEL_NAME` in the profile's `.env`:
```bash
hermes -p alice config set API_SERVER_MODEL_NAME "Alice's Agent"
```
:::
## Linux Docker (no Docker Desktop) ## Linux Docker (no Docker Desktop)
On Linux without Docker Desktop, `host.docker.internal` doesn't resolve by default. Options: On Linux without Docker Desktop, `host.docker.internal` doesn't resolve by default. Options: