diff --git a/.env.template b/.env.template index ced73b36c..aa3309224 100644 --- a/.env.template +++ b/.env.template @@ -206,6 +206,14 @@ # WARNING: May contain PII, API keys in prompts, or sensitive data # LOGGING_LOG_BODIES=false +# Log audio endpoint inputs/outputs: /v1/audio/speech text input + binary audio +# output (stored as base64 so the dashboard can play it back) and +# /v1/audio/transcriptions upload metadata. Requires LOGGING_LOG_BODIES=true (the +# master body-logging switch); when bodies are logged but this is off, audio +# responses are recorded as a lightweight placeholder instead of the full bytes. +# WARNING: stores full audio in the audit log and grows storage quickly (default: false) +# LOGGING_LOG_AUDIO_BODIES=false + # Log request/response headers (default: false) # Sensitive headers (Authorization, Cookie, etc.) are automatically redacted # LOGGING_LOG_HEADERS=false diff --git a/CLAUDE.md b/CLAUDE.md index fc3f69424..684329bd3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ Full reference: `.env.template` and `config/config.yaml` - `ENABLED_PASSTHROUGH_PROVIDERS` (openai,anthropic,openrouter,zai,vllm: Comma-separated list of enabled passthrough providers) - **Storage:** `STORAGE_TYPE` (sqlite), `SQLITE_PATH` (data/gomodel.db), `POSTGRES_URL`, `MONGODB_URL` - **Models:** `MODELS_ENABLED_BY_DEFAULT` (true), `MODEL_OVERRIDES_ENABLED` (true), `KEEP_ONLY_ALIASES_AT_MODELS_ENDPOINT` (false), `CONFIGURED_PROVIDER_MODELS_MODE` (`fallback` or `allowlist`, default `fallback`; `allowlist` skips upstream `/models` for providers with configured lists); persisted overrides restrict/allow selectors with `user_paths`. When alias-only models listing is enabled, `GET /v1/models` returns only model aliases, not full concrete model specs, to operators. -- **Audit logging:** `LOGGING_ENABLED` (false), `LOGGING_LOG_BODIES` (false), `LOGGING_LOG_HEADERS` (false), `LOGGING_RETENTION_DAYS` (30) +- **Audit logging:** `LOGGING_ENABLED` (false), `LOGGING_LOG_BODIES` (false), `LOGGING_LOG_AUDIO_BODIES` (false: refines `LOGGING_LOG_BODIES` for audio endpoints — base64 audio for `/v1/audio/speech` (≤8 MB, else `too_large`) + dashboard playback, upload metadata for transcriptions; no effect unless `LOGGING_LOG_BODIES` is on, in which case audio-off records a placeholder), `LOGGING_LOG_HEADERS` (false), `LOGGING_RETENTION_DAYS` (30) - **Usage tracking:** `USAGE_ENABLED` (true), `ENFORCE_RETURNING_USAGE_DATA` (true), `USAGE_RETENTION_DAYS` (90) - **Dashboard live logs:** - `DASHBOARD_LIVE_LOGS_ENABLED` (true): keep enabled for low-latency dashboard previews; set false only when live streams are not needed or memory/socket usage must be minimized. diff --git a/config/logging.go b/config/logging.go index ea110ae2b..c54350326 100644 --- a/config/logging.go +++ b/config/logging.go @@ -11,6 +11,16 @@ type LogConfig struct { // Default: true LogBodies bool `yaml:"log_bodies" env:"LOGGING_LOG_BODIES"` + // LogAudioBodies refines LogBodies for audio endpoints: when both are + // enabled, the /v1/audio/speech JSON input and binary audio output are + // stored (audio as base64 for playback) and /v1/audio/transcriptions upload + // metadata is recorded. Requires LogBodies (the master body-logging switch); + // when LogBodies is on but this is off, audio responses are recorded as a + // lightweight placeholder instead of the full bytes. + // WARNING: stores full audio in the audit log; grows storage quickly. + // Default: false + LogAudioBodies bool `yaml:"log_audio_bodies" env:"LOGGING_LOG_AUDIO_BODIES"` + // LogHeaders enables logging of request/response headers // Sensitive headers (Authorization, Cookie, etc.) are auto-redacted // Default: true diff --git a/docs/advanced/audio-api.mdx b/docs/advanced/audio-api.mdx index 883891e04..ebd9a0c72 100644 --- a/docs/advanced/audio-api.mdx +++ b/docs/advanced/audio-api.mdx @@ -82,3 +82,20 @@ through the full inference orchestrator**. Compared with `/v1/chat/completions`: For a provider whose native audio API differs from OpenAI's, use the [passthrough API](/features/passthrough-api) (`/p/{provider}/v1/audio/...`) to forward bytes verbatim to that upstream. + +## Audit logging + +Audio requests appear in the audit log like any other model interaction. Because +audio payloads are binary and large, their bodies are gated by a dedicated +setting, [`LOGGING_LOG_AUDIO_BODIES`](/advanced/configuration#audit-logging) +(default `false`), which **refines** `LOGGING_LOG_BODIES` — it has no effect +unless body logging is enabled: + +- **Body logging off** (`LOGGING_LOG_BODIES=false`) — no audio body is stored, + regardless of this setting. +- **Body logging on, audio off** (the default) — the audio response is recorded + as a lightweight `{__audio__, content_type, bytes, stored: false}` placeholder; no audio bytes are stored. +- **Body logging on, audio on** — `/v1/audio/speech` stores its text input and the + generated audio (base64, capped at 8 MB) so the **dashboard renders an inline + player**, and `/v1/audio/transcriptions` stores upload metadata (filename, model, + params) but never the raw uploaded audio bytes. diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 603f5bef2..fede48bff 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -92,6 +92,7 @@ Storage is shared by audit logging, usage tracking, and future features like IAM | --------------------------------- | ------------------------------------------ | ------- | | `LOGGING_ENABLED` | Enable audit logging | `false` | | `LOGGING_LOG_BODIES` | Log request/response bodies | `true` | +| `LOGGING_LOG_AUDIO_BODIES` | Log audio endpoint inputs/outputs | `false` | | `LOGGING_LOG_HEADERS` | Log headers (sensitive ones auto-redacted) | `true` | | `LOGGING_ONLY_MODEL_INTERACTIONS` | Only log AI model endpoints | `true` | | `LOGGING_BUFFER_SIZE` | In-memory buffer before flush | `1000` | @@ -104,6 +105,17 @@ Storage is shared by audit logging, usage tracking, and future features like IAM prompts. + + `LOGGING_LOG_AUDIO_BODIES` refines `LOGGING_LOG_BODIES` for audio endpoints — + it has no effect unless body logging is enabled. With both on, `/v1/audio/speech` + stores its text input and the generated audio (base64, capped at 8 MB) so the + dashboard can play it back, and `/v1/audio/transcriptions` stores upload metadata + (never the raw audio bytes). It defaults to `false` because audio payloads are + large and grow the audit store quickly. When body logging is on but this is off, + audio responses are recorded as a lightweight `{__audio__, content_type, bytes, stored: false}` placeholder; + when body logging is off, no audio body is stored at all. + + #### Token Usage Tracking | Variable | Description | Default | diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index 6e99a4048..b577191cd 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -2880,6 +2880,39 @@ textarea:focus { overflow-wrap: normal; } +.audit-audio { + display: flex; + flex-direction: column; + gap: 8px; + white-space: normal; +} + +.audit-audio-player { + width: 100%; + max-width: 420px; + height: 36px; +} + +.audit-audio-meta { + font-size: 12px; + color: var(--text-muted); +} + +.audit-audio-empty { + align-items: flex-start; + padding: 4px 0; +} + +.audit-audio-icon { + font-size: 20px; + line-height: 1; +} + +.audit-audio-note { + font-size: 12px; + color: var(--text-muted); +} + .conversation-body-highlight { display: inline; border-left: 2px solid color-mix(in srgb, var(--accent) 70%, var(--border)); diff --git a/internal/admin/dashboard/static/js/modules/audit-list.js b/internal/admin/dashboard/static/js/modules/audit-list.js index 153fa0f1f..513fe1af9 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.js +++ b/internal/admin/dashboard/static/js/modules/audit-list.js @@ -508,10 +508,19 @@ const copyBodyState = createCopyState(); const copyHeadersState = createCopyState(); + const helpers = global.DashboardConversationHelpers; + const computeRenderedBody = (p) => { + if (!p || !p.showBody) return ''; + if (helpers && typeof helpers.isAudioBody === 'function' && helpers.isAudioBody(p.body)) { + return helpers.renderAudioBody(p.body); + } + return renderBody(p.entry, p.body, { promptCacheHighlight: p.promptCacheHighlight }); + }; + return { pane, formattedHeaders: pane && pane.showHeaders ? formatJSON(pane.headers) : '', - renderedBody: pane && pane.showBody ? renderBody(pane.entry, pane.body, { promptCacheHighlight: pane.promptCacheHighlight }) : '', + renderedBody: computeRenderedBody(pane), copyBodyState, copyHeadersState, copyState: copyBodyState, @@ -527,9 +536,7 @@ syncPane(nextPane) { this.pane = nextPane; this.formattedHeaders = nextPane && nextPane.showHeaders ? formatJSON(nextPane.headers) : ''; - this.renderedBody = nextPane && nextPane.showBody - ? renderBody(nextPane.entry, nextPane.body, { promptCacheHighlight: nextPane.promptCacheHighlight }) - : ''; + this.renderedBody = computeRenderedBody(nextPane); } }; } diff --git a/internal/admin/dashboard/static/js/modules/audit-list.test.cjs b/internal/admin/dashboard/static/js/modules/audit-list.test.cjs index e56b8c6c8..567444a62 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.test.cjs +++ b/internal/admin/dashboard/static/js/modules/audit-list.test.cjs @@ -599,6 +599,92 @@ test('auditPaneState syncs pane content when live detail data arrives', () => { assert.equal(renderCalls, 1); }); +test('isAudioBody detects the audio body marker', () => { + const helpers = loadConversationHelpers(); + assert.equal(helpers.isAudioBody({ __audio__: true, content_type: 'audio/mpeg' }), true); + assert.equal(helpers.isAudioBody({ model: 'gpt-5' }), false); + assert.equal(helpers.isAudioBody(null), false); + assert.equal(helpers.isAudioBody('audio'), false); +}); + +test('renderAudioBody renders a player with a data URL when audio bytes are stored', () => { + const helpers = loadConversationHelpers(); + const html = helpers.renderAudioBody({ + __audio__: true, + content_type: 'audio/mpeg', + bytes: 2048, + encoding: 'base64', + data: 'QUJD', + stored: true + }); + assert.match(html, /]+controls/); + assert.match(html, /src="data:audio\/mpeg;base64,QUJD"/); + assert.match(html, /2\.0 KB/); +}); + +test('renderAudioBody sanitizes content type and strips non-base64 characters', () => { + const helpers = loadConversationHelpers(); + const html = helpers.renderAudioBody({ + __audio__: true, + content_type: 'audio/mpeg" onerror=alert(1)', + bytes: 10, + encoding: 'base64', + data: 'AB">