Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,6 @@ Full reference: `.env.template` and `config/config.yaml`
- **Metrics:** `METRICS_ENABLED` (false), `METRICS_ENDPOINT` (/metrics)
- **Guardrails:** Definitions are persisted in the `guardrail_definitions` store and managed via the admin API/dashboard; `config/config.yaml` entries are validated and upserted into that store at startup (a seed, not the source of truth). `GUARDRAILS_ENABLED` env var gates the feature.
- **Provider API key rotation:** Any API-key provider accepts several keys: `<PROVIDER>[_SUFFIX]_API_KEY_<n>` env vars (numbered from 2; `_1` is accepted as a synonym for the unsuffixed key) or `providers.<name>.api_keys` in `config.yaml` (merged after `api_key`, de-duplicated, unresolved `${...}` entries dropped; env replaces the whole YAML list). Two or more keys turn on round-robin rotation, drawn per outbound HTTP request — including retries, so a 429'd request retries under the next key. Realtime websocket sessions pick a key per session. Counters are in-memory per instance. The trailing number names a key, not a provider: `OPENAI_API_KEY_2` is key 2 of `openai`, while `OPENAI_REGION_2_API_KEY` is the sole key of provider `openai-region-2`. **Rotation defeats provider prompt caching** (providers scope the cache to the key that filled it); use it to lift per-key rate limits, not to save cost. Keyless (Ollama, vLLM) and non-API-key providers (Vertex, Bedrock) are unaffected.
- **Provider credentials without env vars:** Every provider below can instead be configured from the admin dashboard's Providers page (or `/admin/provider-credentials` GET/PUT/DELETE), persisted to the `provider_credentials` store — the same declarative-shadows-store precedence as MCP servers: a provider name declared via env vars/`config.yaml` is read-only in the dashboard (`managed: true`), and a store row upsert/delete hot-registers or unregisters the provider into the live registry immediately, no restart. `GOMODEL` boots fine with zero providers configured (empty catalog) so this is a complete alternative to env-var credentials, not just a supplement. API keys (`api_keys`, an ordered rotation list, same semantics as `providers.<name>.api_keys`) and service-account secrets are redacted as `***` on read; an upsert echoing `***` at a position preserves the stored value there (rejected if that position was never set). Disabling a row (`enabled: false`) unregisters it from routing without deleting the stored credentials.
- **Provider credentials without env vars:** Every provider below can instead be configured from the admin dashboard's Providers page (or `/admin/provider-credentials` GET/PUT/DELETE), persisted to the `provider_credentials` store — the same declarative-shadows-store precedence as MCP servers: a provider name declared via env vars/`config.yaml` is read-only in the dashboard (`managed: true`), and a store row upsert/delete hot-registers or unregisters the provider into the live registry immediately, no restart. `GOMODEL` boots fine with zero providers configured (empty catalog) so this is a complete alternative to env-var credentials, not just a supplement. API keys (`api_keys`, an ordered rotation list, same semantics as `providers.<name>.api_keys`) and service-account secrets are redacted as `***********` on read; an upsert echoing any all-asterisk mask of at least three characters at a position preserves the stored value there (rejected if that position was never set). Disabling a row (`enabled: false`) unregisters it from routing without deleting the stored credentials.
- **Providers:** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_MAX_TOKENS` (optional default `max_tokens` for Anthropic-translated requests that omit it; default 4096), `GEMINI_API_KEY`, `USE_GOOGLE_GEMINI_NATIVE_API` (true by default; false uses Gemini's OpenAI-compatible chat API), `XAI_API_KEY`, `GROQ_API_KEY`, `FIREWORKS_API_KEY`, `FIREWORKS_BASE_URL` (optional Fireworks AI endpoint override; default `https://api.fireworks.ai/inference/v1`), `META_API_KEY`, `META_BASE_URL` (optional Meta Model API endpoint override; default `https://api.meta.ai/v1`; Muse Spark models, e.g. `muse-spark-1.1`), `OPENROUTER_API_KEY`, `OPENROUTER_SITE_URL`/`OPENROUTER_APP_NAME` (optional OpenRouter attribution headers), `ZAI_API_KEY`, `ZAI_BASE_URL` (optional Z.ai endpoint override), `MINIMAX_API_KEY`, `MINIMAX_BASE_URL` (optional MiniMax endpoint override), `XIAOMI_API_KEY`, `XIAOMI_BASE_URL` (optional Xiaomi MiMo endpoint override), `OPENCODE_GO_API_KEY`, `OPENCODE_GO_BASE_URL` (optional OpenCode Go/Zen endpoint override; default `https://opencode.ai/zen/go/v1`), `OPENCODE_GO_MESSAGES_MODELS` (optional comma-separated model IDs routed to the Anthropic-native `/messages` endpoint instead of `/chat/completions`; default `qwen3.7-max`), `BAILIAN_API_KEY`, `BAILIAN_BASE_URL` (optional Bailian base URL for region switching; default `https://dashscope.aliyuncs.com/compatible-mode/v1`), `AZURE_API_KEY`, `AZURE_BASE_URL` (Azure OpenAI deployment base URL), `AZURE_API_VERSION` (optional Azure API version), `ORACLE_API_KEY` (Oracle API key), `ORACLE_BASE_URL` (Oracle OpenAI-compatible base URL), `BEDROCK_BASE_URL` (Bedrock Runtime region or endpoint), `BEDROCK_MANTLE_API_KEY`, `BEDROCK_MANTLE_BASE_URL` (Mantle region or endpoint), `BEDROCK_MANTLE_API_MODE` (`auto`, `openai`, or `standard`), `<PROVIDER>[_SUFFIX]_MODELS` (comma-separated configured model list for any provider type), `OLLAMA_BASE_URL`, `VLLM_BASE_URL`, `VLLM_API_KEY` (optional upstream vLLM bearer token)
- **Provider model metadata:** `providers.<name>.models` accepts either model IDs (strings) or `{id, metadata}` objects. When `metadata` is supplied (`display_name`, `context_window`, `max_output_tokens`, `modes`, `capabilities`, `pricing`, …) it is merged onto the remote ai-model-list entry during enrichment, with operator values winning per-field. Primary use case: advertising context windows, capabilities, and pricing for local models (Ollama) and other custom endpoints whose IDs are not in the upstream registry.
13 changes: 13 additions & 0 deletions docs/advanced/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ warning at the top of the dashboard, and exposes `DEMO_MODE=on` through the
allowlisted `/admin/runtime/config` response. Demo mode does not reset storage;
schedule that separately in the deployment.

Demo mode also does not bypass authentication. A startup log mode of
`managed_keys` means the configured storage still contains at least one managed
gateway API key, so requests require a bearer token even when
`GOMODEL_MASTER_KEY` is empty. Disabled or expired managed keys keep
authentication enabled as a fail-closed safety measure. Delete every managed
key to return to unauthenticated mode, or configure a master key for recovery.

#### MCP Gateway

See [MCP Gateway](/features/mcp-gateway) for the full feature guide.
Expand Down Expand Up @@ -124,6 +131,12 @@ Storage is shared by audit logging, usage tracking, and future features like IAM
| `LOGGING_FLUSH_INTERVAL` | Flush interval in seconds | `5` |
| `LOGGING_RETENTION_DAYS` | Auto-delete after N days (0 = forever) | `30` |

Realtime dashboard previews and persisted audit logs are separate features.
With `DASHBOARD_LIVE_LOGS_ENABLED=true` and `LOGGING_ENABLED=false`, requests
can appear live but are not written to storage and disappear after a page
refresh. When audit logging is enabled, writes are asynchronous and can take up
to `LOGGING_FLUSH_INTERVAL` seconds to appear through the stored-log API.

<Warning>
When `LOGGING_LOG_BODIES` is enabled, request and response bodies are stored
in full. These may contain sensitive data such as PII or API keys embedded in
Expand Down
11 changes: 11 additions & 0 deletions internal/admin/dashboard/dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ func TestIndex_DemoModeShowsWarning(t *testing.T) {
if !strings.Contains(body, "Do not enter sensitive or personal data. Demo data is reset regularly.") {
t.Error("expected demo mode data warning in page HTML")
}
if !strings.Contains(body, `href="https://gomodel.enterpilot.io/?utm_source=gomodel_dashboard&amp;utm_medium=demo_banner&amp;utm_campaign=public_demo" target="_blank" rel="noopener noreferrer">gomodel.enterpilot.io</a>`) {
t.Error("expected demo mode website link in page HTML")
}
for _, unwanted := range []string{
`href="https://gomodel.enterpilot.io/docs"`,
`href="https://github.com/ENTERPILOT/GoModel"`,
} {
if strings.Contains(body, unwanted) {
t.Errorf("unexpected demo mode link %s in page HTML", unwanted)
}
}
}

func TestIndex_StandardModeHidesDemoWarning(t *testing.T) {
Expand Down
110 changes: 103 additions & 7 deletions internal/admin/dashboard/static/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -668,10 +668,8 @@ body.dashboard-modal-open {
}

.demo-mode-banner {
position: sticky;
top: 16px;
z-index: 8;
display: flex;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
margin-bottom: 24px;
Expand All @@ -690,7 +688,7 @@ body.dashboard-modal-open {
color: var(--warning);
}

.demo-mode-banner div {
.demo-mode-banner-copy {
display: flex;
align-items: baseline;
gap: 8px;
Expand All @@ -706,6 +704,39 @@ body.dashboard-modal-open {
text-transform: uppercase;
}

.demo-mode-banner-links {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}

.demo-mode-banner-links a {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 8px;
border: 1px solid color-mix(in srgb, var(--warning) 42%, var(--border));
border-radius: var(--radius);
color: var(--text);
font-size: 12px;
font-weight: 600;
line-height: 1;
text-decoration: none;
white-space: nowrap;
}

.demo-mode-banner-links a:hover {
border-color: var(--warning);
background: color-mix(in srgb, var(--warning) 16%, transparent);
color: var(--text);
}

.demo-mode-banner-links a:focus-visible {
outline: 2px solid color-mix(in srgb, var(--warning) 42%, transparent);
outline-offset: 2px;
}

.page-header {
display: flex;
align-items: center;
Expand Down Expand Up @@ -975,6 +1006,29 @@ body.dashboard-modal-open {
gap: 12px;
}

.page-with-sticky-date {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
column-gap: 12px;
}

.page-with-sticky-date > * {
grid-column: 1 / -1;
}

.page-with-sticky-date > .date-range-page-header {
grid-column: 1;
}

.page-with-sticky-date > .sticky-date-range {
position: sticky;
top: 16px;
z-index: 8;
grid-column: 2;
justify-self: end;
}

/* Interval Picker */
.interval-picker {
display: inline-flex;
Expand Down Expand Up @@ -1078,6 +1132,10 @@ body.dashboard-modal-open {
grid-column: span 2;
}

.provider-status-overview-card {
grid-column: span 1;
}

.provider-status-flag.is-healthy {
border-color: color-mix(in srgb, var(--success) 45%, var(--border));
background: color-mix(in srgb, var(--success) 10%, var(--bg-surface));
Expand Down Expand Up @@ -2047,6 +2105,20 @@ td.col-price {
animation: loading-spin 0.8s linear infinite;
}

.models-loading-state {
position: sticky;
top: 16px;
z-index: 7;
width: fit-content;
min-height: 0;
margin: 0 auto 16px;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-surface);
box-shadow: 0 8px 24px color-mix(in srgb, var(--bg) 70%, transparent);
}

@keyframes loading-spin {
to {
transform: rotate(360deg);
Expand Down Expand Up @@ -5413,13 +5485,18 @@ body.conversation-drawer-open {
padding: 20px;
}
.demo-mode-banner {
top: 10px;
align-items: flex-start;
grid-template-columns: auto minmax(0, 1fr);
}
.demo-mode-banner div {
.demo-mode-banner-copy {
display: grid;
gap: 2px;
}
.demo-mode-banner-links {
grid-column: 2;
justify-content: flex-start;
flex-wrap: wrap;
}
.auth-dialog-shell {
align-items: end;
padding: 12px;
Expand Down Expand Up @@ -5475,6 +5552,25 @@ body.conversation-drawer-open {
justify-content: space-between;
flex-wrap: wrap;
}
.page-with-sticky-date {
grid-template-columns: minmax(0, 1fr);
}
.page-with-sticky-date > .date-range-page-header,
.page-with-sticky-date > .sticky-date-range {
grid-column: 1;
}
.page-with-sticky-date > .date-range-page-header {
margin-bottom: 12px;
}
.page-with-sticky-date > .sticky-date-range {
top: 10px;
width: 100%;
margin-bottom: 24px;
}
.sticky-date-range .date-picker-trigger {
width: 100%;
justify-content: space-between;
}

.settings-panel-title-row {
gap: 8px;
Expand Down
46 changes: 42 additions & 4 deletions internal/admin/dashboard/static/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ function dashboard() {
providers: [],
},
models: [],
modelsLoading: false,
// Start busy so a direct visit to the Models route can paint its loader
// before init() starts the first inventory request.
modelsLoading: true,
categories: [],
activeCategory: "all",
hasCalendarModule: calendarModuleFactory !== null,
Expand Down Expand Up @@ -245,6 +247,16 @@ function dashboard() {
},

_applyRoute(page, sub) {
// Prepare the first bounded Models-page batch before Alpine mounts the
// page template. Otherwise navigating from another dashboard page would
// synchronously create every model row before a loader can paint.
if (
page === "models" &&
this.page !== "models" &&
typeof this.restartModelRendering === "function"
) {
this.restartModelRendering();
}
this.page = page;

// Live token throughput is overview-only; tear it down when navigating
Expand Down Expand Up @@ -1122,9 +1134,35 @@ function dashboard() {
},

formatTokensShort(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return String(n);
if (n == null || n === "") return "-";
const value = Number(n);
if (!Number.isFinite(value)) return "-";
const absolute = Math.abs(value);
const units = [
{ threshold: 1000000000, suffix: "B" },
{ threshold: 1000000, suffix: "M" },
{ threshold: 1000, suffix: "K" },
];
for (let index = 0; index < units.length; index += 1) {
let unit = units[index];
if (absolute >= unit.threshold) {
let compact = value / unit.threshold;
if (Math.abs(Number(compact.toFixed(1))) >= 1000 && index > 0) {
unit = units[index - 1];
compact = value / unit.threshold;
}
return (
compact.toFixed(1).replace(/\.0$/, "") + unit.suffix
);
}
}
return String(value);
},

tokenCountTitle(label, n) {
const value = n == null || n === "" ? NaN : Number(n);
const exact = Number.isFinite(value) ? this.formatNumber(value) : "-";
return String(label || "Tokens") + ": " + exact;
},

formatTimestamp(ts) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ function loadDashboardApp(overrides = {}) {
return context.dashboard();
}

test('Models route starts bounded rendering before Alpine mounts the page', () => {
const app = loadDashboardApp();
const pagesSeen = [];
app.page = 'overview';
app.restartModelRendering = function() {
pagesSeen.push(this.page);
};

app._applyRoute('models', null);

assert.deepEqual(pagesSeen, ['overview']);
assert.equal(app.page, 'models');
});

test('qualifiedModelDisplay keeps provider identity for nested provider model IDs', () => {
const app = loadDashboardApp();

Expand Down Expand Up @@ -165,6 +179,29 @@ test('formatCost uses data placeholder for missing values', () => {
assert.equal(app.formatCost('0.25'), '$0.25');
});

test('formatTokensShort uses K, M, and B suffixes for large token counts', () => {
const app = loadDashboardApp();

assert.equal(app.formatTokensShort(999), '999');
assert.equal(app.formatTokensShort(1000), '1K');
assert.equal(app.formatTokensShort(1250), '1.3K');
assert.equal(app.formatTokensShort(999950), '1M');
assert.equal(app.formatTokensShort(56672513), '56.7M');
assert.equal(app.formatTokensShort(12072437), '12.1M');
assert.equal(app.formatTokensShort(999950000), '1B');
assert.equal(app.formatTokensShort(2500000000), '2.5B');
assert.equal(app.formatTokensShort(null), '-');
assert.equal(app.formatTokensShort('not-a-number'), '-');
});

test('tokenCountTitle exposes the exact localized count and token type', () => {
const app = loadDashboardApp();

assert.equal(app.tokenCountTitle('Input tokens', 56672513), 'Input tokens: ' + (56672513).toLocaleString());
assert.equal(app.tokenCountTitle('Output tokens', 12072437), 'Output tokens: ' + (12072437).toLocaleString());
assert.equal(app.tokenCountTitle('Total tokens', null), 'Total tokens: -');
});

test('system theme media changes rerender all dashboard charts', () => {
let mediaChangeHandler = null;
const app = loadDashboardApp({
Expand Down
Loading