diff --git a/CLAUDE.md b/CLAUDE.md index 53caf4f3..a3ed1f95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: `[_SUFFIX]_API_KEY_` env vars (numbered from 2; `_1` is accepted as a synonym for the unsuffixed key) or `providers..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..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..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`), `[_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..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. diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 176d8abd..154f6c0a 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -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. @@ -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. + 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 diff --git a/internal/admin/dashboard/dashboard_test.go b/internal/admin/dashboard/dashboard_test.go index e768dae8..be57e202 100644 --- a/internal/admin/dashboard/dashboard_test.go +++ b/internal/admin/dashboard/dashboard_test.go @@ -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&utm_medium=demo_banner&utm_campaign=public_demo" target="_blank" rel="noopener noreferrer">gomodel.enterpilot.io`) { + 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) { diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index b14137cb..1c04892d 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -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; @@ -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; @@ -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; @@ -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; @@ -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)); @@ -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); @@ -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; @@ -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; diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 9ea38b5b..8d889b99 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -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, @@ -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 @@ -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) { diff --git a/internal/admin/dashboard/static/js/modules/dashboard-display.test.cjs b/internal/admin/dashboard/static/js/modules/dashboard-display.test.cjs index a6feec6a..d1faee37 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-display.test.cjs +++ b/internal/admin/dashboard/static/js/modules/dashboard-display.test.cjs @@ -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(); @@ -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({ diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs index 3df4d8f8..532222e6 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs @@ -94,6 +94,42 @@ test("sidebar and main content share the flex layout without manual content offs assert.match(sidebarLogoRule, /color:\s*var\(--accent\)/); }); +test("demo notice scrolls normally while page date ranges stay sticky", () => { + const layout = readFixture("../../../templates/layout.html"); + const overview = readFixture("../../../templates/page-overview.html"); + const usage = readFixture("../../../templates/page-usage.html"); + const audit = readFixture("../../../templates/page-audit-logs.html"); + const css = readFixture("../../css/dashboard.css"); + + assert.match(layout, /