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, /