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
8 changes: 8 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions config/logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs/advanced/audio-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 12 additions & 0 deletions docs/advanced/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -104,6 +105,17 @@ Storage is shared by audit logging, usage tracking, and future features like IAM
prompts.
</Warning>

<Note>
`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.
</Note>

#### Token Usage Tracking

| Variable | Description | Default |
Expand Down
33 changes: 33 additions & 0 deletions internal/admin/dashboard/static/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

.conversation-body-highlight {
display: inline;
border-left: 2px solid color-mix(in srgb, var(--accent) 70%, var(--border));
Expand Down
15 changes: 11 additions & 4 deletions internal/admin/dashboard/static/js/modules/audit-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
};
}
Expand Down
86 changes: 86 additions & 0 deletions internal/admin/dashboard/static/js/modules/audit-list.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<audio[^>]+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"><script>CD',
stored: true
});
assert.ok(!html.includes('onerror'), 'content type must be sanitized');
assert.ok(!html.includes('<script>'), 'base64 payload must be sanitized');
// Dangerous characters (<, >, ") are stripped from the data URL; only the
// valid base64 alphabet survives (the letters of "script" are harmless).
assert.match(html, /src="data:audio\/mpeg;base64,ABscriptCD"/);
});

test('renderAudioBody renders a placeholder when audio is not stored', () => {
const helpers = loadConversationHelpers();
const html = helpers.renderAudioBody({
__audio__: true,
content_type: 'audio/mpeg',
bytes: 61056,
stored: false
});
assert.ok(!html.includes('<audio'), 'no player when bytes are absent');
assert.match(html, /LOGGING_LOG_AUDIO_BODIES/);
assert.match(html, /59\.6 KB/);
});

test('renderAudioBody notes when audio was too large to store', () => {
const helpers = loadConversationHelpers();
const html = helpers.renderAudioBody({
__audio__: true,
content_type: 'audio/wav',
bytes: 99999999,
stored: false,
too_large: true
});
assert.ok(!html.includes('<audio'));
assert.match(html, /too large/i);
});

test('auditPaneState renders audio bodies through the audio helper', () => {
const helpers = loadConversationHelpers();
const module = createAuditListModule({
window: { DashboardConversationHelpers: helpers }
});
const paneState = module.auditPaneState({
entry: { id: 'audit-1' },
showBody: true,
body: {
__audio__: true,
content_type: 'audio/mpeg',
bytes: 1024,
encoding: 'base64',
data: 'QUJD',
stored: true
}
});
assert.match(paneState.renderedBody, /<audio[^>]+src="data:audio\/mpeg;base64,QUJD"/);
});

test('auditPaneState copies the formatted body and resets success feedback', async () => {
let resetCallback = null;
const writes = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,53 @@
.replaceAll("'", '&#39;');
}

// isAudioBody detects the audit value produced for audio endpoint bodies
// (see auditlog.AudioBodyLog): an object carrying the "__audio__" marker.
function isAudioBody(value) {
return !!(value && typeof value === 'object' && value.__audio__ === true);
}

function formatByteSize(bytes) {
const n = Number(bytes || 0);
if (!Number.isFinite(n) || n <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let size = n;
while (size >= 1024 && i < units.length - 1) {
size /= 1024;
i++;
}
return (i === 0 ? String(size) : size.toFixed(1)) + ' ' + units[i];
}

function sanitizeAudioContentType(value) {
const ct = String(value || '').trim();
return /^audio\/[a-zA-Z0-9.+-]+$/.test(ct) ? ct : 'audio/mpeg';
}

// renderAudioBody renders an audio body as a player when the audio bytes
// were captured (base64), otherwise a labeled placeholder explaining why.
function renderAudioBody(value) {
const contentType = sanitizeAudioContentType(value.content_type);
const metaLabel = escapeHTML(contentType + ' · ' + formatByteSize(value.bytes));
if (value.stored && value.encoding === 'base64' && value.data) {
const b64 = String(value.data).replace(/[^A-Za-z0-9+/=]/g, '');
const src = 'data:' + contentType + ';base64,' + b64;
return '<div class="audit-audio">'
+ '<audio class="audit-audio-player" controls preload="none" src="' + src + '"></audio>'
+ '<div class="audit-audio-meta mono">' + metaLabel + '</div>'
+ '</div>';
}
const reason = value.too_large
? 'Audio too large to store.'
: 'Audio not logged. Set LOGGING_LOG_AUDIO_BODIES=true to capture playable audio.';
return '<div class="audit-audio audit-audio-empty">'
+ '<div class="audit-audio-icon" aria-hidden="true">🔊</div>'
+ '<div class="audit-audio-meta mono">' + metaLabel + '</div>'
+ '<div class="audit-audio-note">' + escapeHTML(reason) + '</div>'
+ '</div>';
}

function jsonStringContent(value) {
try {
return JSON.stringify(String(value)).slice(1, -1);
Expand Down Expand Up @@ -465,6 +512,8 @@
isConversationalPath,
isConversationExcludedPath,
canShowConversation,
renderBodyWithConversationHighlights
renderBodyWithConversationHighlights,
isAudioBody,
renderAudioBody
};
})(window);
1 change: 1 addition & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,7 @@ func (a *App) logStartupInfo() {
if cfg.Logging.Enabled {
slog.Info("audit logging enabled",
"log_bodies", cfg.Logging.LogBodies,
"log_audio_bodies", cfg.Logging.LogAudioBodies,
"log_headers", cfg.Logging.LogHeaders,
"retention_days", cfg.Logging.RetentionDays,
)
Expand Down
57 changes: 57 additions & 0 deletions internal/auditlog/audio_body.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package auditlog

import (
"encoding/base64"
"strings"
)

// audioBodyMaxBytes caps how much *raw* audio is embedded as base64 in an audit
// log entry; larger payloads are recorded as metadata-only placeholders so the
// audit store does not balloon on long generations. The stored base64 is ~4/3 of
// this (≈10.7 MB at the cap), deliberately kept well under document-store
// per-record ceilings (e.g. MongoDB's 16 MB BSON limit) so a near-cap clip plus
// the rest of the entry (request text, headers, workflow metadata) and encoding
// overhead cannot push the document over the limit and fail the audit insert.
const audioBodyMaxBytes = 8 * 1024 * 1024

// AudioBodyLog is the audit representation of an audio request/response body.
// The "__audio__" marker lets the dashboard detect audio payloads and render a
// player (when Data is present) or a labeled placeholder. When Data is set it
// holds the base64-encoded audio, suitable for a data: URL of ContentType.
type AudioBodyLog struct {
Audio bool `json:"__audio__" bson:"__audio__"`
ContentType string `json:"content_type,omitempty" bson:"content_type,omitempty"`
Bytes int `json:"bytes" bson:"bytes"`
Encoding string `json:"encoding,omitempty" bson:"encoding,omitempty"`
Data string `json:"data,omitempty" bson:"data,omitempty"`
Stored bool `json:"stored" bson:"stored"`
TooLarge bool `json:"too_large,omitempty" bson:"too_large,omitempty"`
}

// IsAudioContentType reports whether a Content-Type denotes an audio payload.
func IsAudioContentType(contentType string) bool {
mediaType := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
return strings.HasPrefix(mediaType, "audio/")
}

// BuildAudioResponseBody builds the audit value for a binary audio response.
// When storeBytes is true and the payload fits within audioBodyMaxBytes the
// audio is embedded as base64 for playback; otherwise only metadata is kept.
func BuildAudioResponseBody(contentType string, data []byte, storeBytes bool) AudioBodyLog {
body := AudioBodyLog{
Audio: true,
ContentType: strings.TrimSpace(contentType),
Bytes: len(data),
}
if !storeBytes || len(data) == 0 {
return body
}
if len(data) > audioBodyMaxBytes {
body.TooLarge = true
return body
}
body.Encoding = "base64"
body.Data = base64.StdEncoding.EncodeToString(data)
body.Stored = true
return body
}
Loading