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 @@ -118,7 +118,7 @@ Full reference: `.env.template` and `config/config.yaml`
- **Storage:** `STORAGE_TYPE` (sqlite), `SQLITE_PATH` (data/gomodel.db), `POSTGRES_URL`, `MONGODB_URL`
- **Models:** `MODELS_ENABLED_BY_DEFAULT` (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.
- **Virtual models:** Redirects (aliases / load balancers) and access policies are managed in the admin dashboard and persisted to the `virtual_models` store. A redirect with one target is a plain alias; a redirect with several targets is load balanced by `strategy`: `round_robin` (default; rotates across targets, honoring per-target `weight`) or `cost` (always routes to the cheapest catalog-priced available target, falling back to the first target when none are priced). Unavailable targets are skipped, so a redirect works while any target is live. Virtual models can also be declared as infrastructure-as-code under `virtual_models:` in `config.yaml` or via the `VIRTUAL_MODELS` env var (a JSON array; env merges over YAML, winning per `source`). Declarative entries are validated at startup, override admin-store rows with the same `source`, and are read-only in the dashboard.
- **Tagging:** Every request can be labelled from configured HTTP headers. Rules are managed in the dashboard (Settings → "Tagging based on headers", persisted to the `tagging_settings` store) or declared as infrastructure-as-code under `tagging.headers:` in `config.yaml` / numbered env vars `TAGGING_HEADER_1=X-My-Tags` with optional `TAGGING_HEADER_1_PREFIX` (trimmed from each extracted label only), `TAGGING_HEADER_1_DONOTPASS` (default false: headers are forwarded as-is; true strips the header before provider forwarding on passthrough/realtime routes — translated routes never forward client headers), and `TAGGING_HEADER_1_DELIMITER` (default `,`; one header value can carry several labels). An env entry replaces the whole YAML entry with the same header name (unset companion vars reset fields to defaults rather than inheriting YAML values); declarative entries override admin-store rows and are read-only in the dashboard. Credential-bearing headers (`Authorization`, `Cookie`, API-key headers, …) are rejected as tagging sources. Labels are recorded on usage entries (`labels`) and audit log entries (`data.labels`). The dashboard usage page shows a by-label breakdown (`GET /admin/usage/labels`) and label chips with a label filter on the request log (`label` query param on `GET /admin/usage/log`).
- **Tagging:** Every request can be labelled from configured HTTP headers. Rules are managed in the dashboard (Settings → "Tagging based on headers", persisted to the `tagging_settings` store) or declared as infrastructure-as-code under `tagging.headers:` in `config.yaml` / numbered env vars `TAGGING_HEADER_1=X-My-Tags` with optional `TAGGING_HEADER_1_PREFIX` (trimmed from each extracted label only), `TAGGING_HEADER_1_DONOTPASS` (default false: headers are forwarded as-is; true strips the header before provider forwarding on passthrough/realtime routes — translated routes never forward client headers), and `TAGGING_HEADER_1_DELIMITER` (default `,`; one header value can carry several labels). An env entry replaces the whole YAML entry with the same header name (unset companion vars reset fields to defaults rather than inheriting YAML values); declarative entries override admin-store rows and are read-only in the dashboard. Credential-bearing headers (`Authorization`, `Cookie`, API-key headers, …) are rejected as tagging sources. Managed API keys can also carry labels (`labels` on `POST /admin/auth-keys`, replaceable later via `PUT /admin/auth-keys/{id}/labels` where `[]` clears, or API Keys → Create API Key / Edit Labels in the dashboard); every request authenticated with the key gets them, merged and de-duplicated with header-extracted labels. Labels are recorded on usage entries (`labels`) and audit log entries (`data.labels`). The dashboard usage page shows a by-label breakdown (`GET /admin/usage/labels`) and label chips with a label filter on the request log (`label` query param on `GET /admin/usage/log`).
- **Audit logging:** `LOGGING_ENABLED` (false), `LOGGING_LOG_BODIES` (false), `LOGGING_LOG_AUDIO_BODIES` (false: refines `LOGGING_LOG_BODIES` for audio endpoints — base64 audio for both `/v1/audio/speech` output and `/v1/audio/transcriptions` upload (≤8 MB each, else `too_large`) + dashboard playback, plus transcription upload metadata; 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:**
Expand Down
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"features/passthrough-api",
"features/budgets",
"features/cost-tracking",
"features/labelling",
"features/cache",
"features/failover"
]
Expand Down
107 changes: 107 additions & 0 deletions docs/features/labelling.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
title: "Labelling"
description: "Attach labels to requests from HTTP headers or API keys, and break down usage and audit logs by label."
icon: "tags"
---

## Overview

Labels tag every request for attribution: which team, feature, job, or
environment produced the traffic. A request can be labelled two ways, and both
can apply at once:

- **Tagging headers** — labels extracted from configured HTTP headers on each request.
- **API key labels** — labels bound to a managed API key, applied to every request authenticated with it.

Labels from both sources are merged and de-duplicated. They are recorded on
usage entries and audit log entries, and power the by-label usage breakdown in
the dashboard.

## API key labels

Assign labels when creating a key in the admin dashboard:

`API Keys -> Create API Key -> Labels`

Or via the admin API:

```bash
curl -X POST http://localhost:8080/admin/auth-keys \
-H "Authorization: Bearer $GOMODEL_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "team-a-batch", "labels": ["team-a", "batch-jobs"]}'
```

Every request authenticated with that key carries those labels — no client
changes needed. This is the simplest way to attribute traffic per team or
service: issue each consumer its own labelled key.

Labels can be changed later without rotating the key, in the dashboard
(`API Keys -> Edit Labels`) or via the admin API:

```bash
curl -X PUT http://localhost:8080/admin/auth-keys/<key-id>/labels \
-H "Authorization: Bearer $GOMODEL_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"labels": ["team-a", "realtime"]}'
```

The new list replaces the old one; send `[]` to remove all labels. Changes
apply to new requests immediately on the instance that served the update and
within about a minute on other replicas (keys refresh from storage in the
background).

## Tagging headers

Tagging rules name headers whose values carry labels. Manage them in the
dashboard (`Settings -> Tagging based on headers`) or declare them as
infrastructure-as-code:

```yaml config.yaml
tagging:
headers:
- header: X-My-Tags
prefix: "tag-" # optional: "tag-alpha, beta" → labels "alpha", "beta"
- header: X-Internal-Routing
do_not_pass: true # stripped before forwarding to the provider
delimiter: ";" # default: ","
```

Or with environment variables (an env entry replaces the whole YAML entry with
the same header name; unset companion vars reset to defaults):

```bash
TAGGING_HEADER_1=X-My-Tags
TAGGING_HEADER_1_PREFIX=tag- # optional, trimmed from each extracted label only
TAGGING_HEADER_1_DONOTPASS=true # optional, default false (headers are forwarded as-is)
TAGGING_HEADER_1_DELIMITER=";" # optional, default ","
```

A client then labels a request like this:

```http
X-My-Tags: tag-alpha, beta
```

Rules declared in `config.yaml` or env are read-only in the dashboard.
Credential-bearing headers (`Authorization`, `Cookie`, API-key headers) are
rejected as tagging sources.

`do_not_pass` strips the header before forwarding on passthrough and realtime
routes; translated routes never forward client headers anyway.

## Where labels show up

- **Usage dashboard** — the usage page shows a by-label breakdown
(`GET /admin/usage/labels`). A request with several labels counts once under
each of them.
- **Request log** — label chips per request, filterable with the `label` query
param on `GET /admin/usage/log`.
- **Audit logs** — recorded under `data.labels` on each entry (requires
`LOGGING_ENABLED=true`).
- **API keys page** — each key's labels are listed alongside its user path.

## Defaults

Labelling is on by default and costs nothing until you configure a tagging
rule or create a labelled key. Requests without labels are tracked as before.
7 changes: 7 additions & 0 deletions internal/admin/dashboard/static/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -3020,6 +3020,13 @@ textarea:focus {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--label-color, var(--accent)) 55%, transparent);
}

/* Read-only chip variant (e.g. API key labels) — same look, no affordance. */
.usage-label-chip-static,
.usage-label-chip-static:hover {
cursor: default;
background: color-mix(in srgb, var(--label-color, var(--accent)) 14%, var(--bg));
}

/* Audit Log Section */
.audit-log-section {
background: var(--bg-surface);
Expand Down
117 changes: 115 additions & 2 deletions internal/admin/dashboard/static/js/modules/auth-keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,31 @@
name: '',
description: '',
user_path: '',
labels: '',
expires_at: ''
},
authKeyLabelsEditor: {
open: false,
id: '',
name: '',
value: '',
submitting: false,
error: ''
},

defaultAuthKeyForm() {
return { name: '', description: '', user_path: '', expires_at: '' };
return { name: '', description: '', user_path: '', labels: '', expires_at: '' };
},

parseAuthKeyLabels(value) {
const labels = [];
for (const piece of String(value || '').split(',')) {
const label = piece.trim();
if (label && !labels.includes(label)) {
labels.push(label);
}
}
return labels;
},

authKeyUserPathValidationError(value) {
Expand Down Expand Up @@ -192,10 +212,12 @@
this.authKeyFormSubmitting = true;
const userPath = this.normalizeAuthKeyUserPath(this.authKeyForm.user_path);

const labels = this.parseAuthKeyLabels(this.authKeyForm.labels);
const payload = {
name,
description: String(this.authKeyForm.description || '').trim() || undefined,
user_path: userPath || undefined
user_path: userPath || undefined,
labels: labels.length ? labels : undefined
};
if (this.authKeyForm.expires_at) {
payload.expires_at = this.authKeyForm.expires_at + 'T23:59:59Z';
Expand Down Expand Up @@ -256,6 +278,97 @@
}
},

openAuthKeyLabelsEditor(key) {
if (!key || this.authKeyLabelsEditor.submitting) {
return;
}
this.authKeyLabelsEditor = {
open: true,
id: key.id,
name: key.name || '',
value: (key.labels || []).join(', '),
submitting: false,
error: ''
};
},

closeAuthKeyLabelsEditor() {
if (!this.authKeyLabelsEditor.open || this.authKeyLabelsEditor.submitting) {
return;
}
this.authKeyLabelsEditor = {
open: false,
id: '',
name: '',
value: '',
submitting: false,
error: ''
};
},

async submitAuthKeyLabelsEditor() {
const editor = this.authKeyLabelsEditor;
if (!editor.open || editor.submitting || !editor.id) {
return;
}
editor.submitting = true;
editor.error = '';
this.authKeyNotice = '';
const payload = { labels: this.parseAuthKeyLabels(editor.value) };

try {
const request = typeof this.requestOptions === 'function'
? this.requestOptions({
method: 'PUT',
body: JSON.stringify(payload)
})
: {
method: 'PUT',
headers: this.headers(),
body: JSON.stringify(payload)
};
const res = await fetch('/admin/auth-keys/' + encodeURIComponent(editor.id) + '/labels', request);
if (res.status === 503) {
this.authKeysAvailable = false;
editor.error = 'Auth keys feature is unavailable.';
return;
}
if (typeof this.handleFetchResponse === 'function') {
const handled = this.handleFetchResponse(res, 'update API key labels', request);
if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) {
return;
}
if (!handled) {
if (res.status === 401) {
editor.error = 'Authentication required.';
return;
}
editor.error = await this._authKeyResponseMessage(res, 'Failed to update labels.');
console.error('Failed to update auth key labels:', res.status, res.statusText, editor.error);
return;
}
} else if (res.status === 401) {
this.authError = true;
this.needsAuth = true;
editor.error = 'Authentication required.';
return;
} else if (res.status !== 200) {
editor.error = await this._authKeyResponseMessage(res, 'Failed to update labels.');
console.error('Failed to update auth key labels:', res.status, res.statusText, editor.error);
return;
}
await this.fetchAuthKeys();
this.authKeyNotice = 'Labels updated for key "' + editor.name + '".';
editor.submitting = false;
this.closeAuthKeyLabelsEditor();
} catch (e) {
console.error('Failed to update auth key labels:', e);
editor.error = 'Failed to update labels.';
} finally {
editor.submitting = false;
}
},
Comment on lines +281 to +370

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant submitting reset after editor is closed.

Line 362 sets editor.submitting = false before closeAuthKeyLabelsEditor() reassigns this.authKeyLabelsEditor to a fresh object; the finally block at Line 368 then mutates the now-orphaned old editor reference. Harmless today, but worth simplifying so the intent (why submitting must be cleared before close) is clearer to future readers.

♻️ Simplify submit/close ordering
-                    await this.fetchAuthKeys();
-                    this.authKeyNotice = 'Labels updated for key "' + editor.name + '".';
-                    editor.submitting = false;
-                    this.closeAuthKeyLabelsEditor();
+                    await this.fetchAuthKeys();
+                    const notice = 'Labels updated for key "' + editor.name + '".';
+                    editor.submitting = false;
+                    this.closeAuthKeyLabelsEditor();
+                    this.authKeyNotice = notice;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
openAuthKeyLabelsEditor(key) {
if (!key || this.authKeyLabelsEditor.submitting) {
return;
}
this.authKeyLabelsEditor = {
open: true,
id: key.id,
name: key.name || '',
value: (key.labels || []).join(', '),
submitting: false,
error: ''
};
},
closeAuthKeyLabelsEditor() {
if (!this.authKeyLabelsEditor.open || this.authKeyLabelsEditor.submitting) {
return;
}
this.authKeyLabelsEditor = {
open: false,
id: '',
name: '',
value: '',
submitting: false,
error: ''
};
},
async submitAuthKeyLabelsEditor() {
const editor = this.authKeyLabelsEditor;
if (!editor.open || editor.submitting || !editor.id) {
return;
}
editor.submitting = true;
editor.error = '';
this.authKeyNotice = '';
const payload = { labels: this.parseAuthKeyLabels(editor.value) };
try {
const request = typeof this.requestOptions === 'function'
? this.requestOptions({
method: 'PUT',
body: JSON.stringify(payload)
})
: {
method: 'PUT',
headers: this.headers(),
body: JSON.stringify(payload)
};
const res = await fetch('/admin/auth-keys/' + encodeURIComponent(editor.id) + '/labels', request);
if (res.status === 503) {
this.authKeysAvailable = false;
editor.error = 'Auth keys feature is unavailable.';
return;
}
if (typeof this.handleFetchResponse === 'function') {
const handled = this.handleFetchResponse(res, 'update API key labels', request);
if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) {
return;
}
if (!handled) {
if (res.status === 401) {
editor.error = 'Authentication required.';
return;
}
editor.error = await this._authKeyResponseMessage(res, 'Failed to update labels.');
console.error('Failed to update auth key labels:', res.status, res.statusText, editor.error);
return;
}
} else if (res.status === 401) {
this.authError = true;
this.needsAuth = true;
editor.error = 'Authentication required.';
return;
} else if (res.status !== 200) {
editor.error = await this._authKeyResponseMessage(res, 'Failed to update labels.');
console.error('Failed to update auth key labels:', res.status, res.statusText, editor.error);
return;
}
await this.fetchAuthKeys();
this.authKeyNotice = 'Labels updated for key "' + editor.name + '".';
editor.submitting = false;
this.closeAuthKeyLabelsEditor();
} catch (e) {
console.error('Failed to update auth key labels:', e);
editor.error = 'Failed to update labels.';
} finally {
editor.submitting = false;
}
},
openAuthKeyLabelsEditor(key) {
if (!key || this.authKeyLabelsEditor.submitting) {
return;
}
this.authKeyLabelsEditor = {
open: true,
id: key.id,
name: key.name || '',
value: (key.labels || []).join(', '),
submitting: false,
error: ''
};
},
closeAuthKeyLabelsEditor() {
if (!this.authKeyLabelsEditor.open || this.authKeyLabelsEditor.submitting) {
return;
}
this.authKeyLabelsEditor = {
open: false,
id: '',
name: '',
value: '',
submitting: false,
error: ''
};
},
async submitAuthKeyLabelsEditor() {
const editor = this.authKeyLabelsEditor;
if (!editor.open || editor.submitting || !editor.id) {
return;
}
editor.submitting = true;
editor.error = '';
this.authKeyNotice = '';
const payload = { labels: this.parseAuthKeyLabels(editor.value) };
try {
const request = typeof this.requestOptions === 'function'
? this.requestOptions({
method: 'PUT',
body: JSON.stringify(payload)
})
: {
method: 'PUT',
headers: this.headers(),
body: JSON.stringify(payload)
};
const res = await fetch('/admin/auth-keys/' + encodeURIComponent(editor.id) + '/labels', request);
if (res.status === 503) {
this.authKeysAvailable = false;
editor.error = 'Auth keys feature is unavailable.';
return;
}
if (typeof this.handleFetchResponse === 'function') {
const handled = this.handleFetchResponse(res, 'update API key labels', request);
if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) {
return;
}
if (!handled) {
if (res.status === 401) {
editor.error = 'Authentication required.';
return;
}
editor.error = await this._authKeyResponseMessage(res, 'Failed to update labels.');
console.error('Failed to update auth key labels:', res.status, res.statusText, editor.error);
return;
}
} else if (res.status === 401) {
this.authError = true;
this.needsAuth = true;
editor.error = 'Authentication required.';
return;
} else if (res.status !== 200) {
editor.error = await this._authKeyResponseMessage(res, 'Failed to update labels.');
console.error('Failed to update auth key labels:', res.status, res.statusText, editor.error);
return;
}
await this.fetchAuthKeys();
const notice = 'Labels updated for key "' + editor.name + '".';
editor.submitting = false;
this.closeAuthKeyLabelsEditor();
this.authKeyNotice = notice;
} catch (e) {
console.error('Failed to update auth key labels:', e);
editor.error = 'Failed to update labels.';
} finally {
editor.submitting = false;
}
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/admin/dashboard/static/js/modules/auth-keys.js` around lines 281 -
370, In submitAuthKeyLabelsEditor, the current ordering leaves a redundant
editor.submitting reset after closeAuthKeyLabelsEditor replaces
authKeyLabelsEditor with a new object. Simplify the submit/close flow by
clearing submitting in the active editor state only once, then closing the
editor, and avoid mutating the stale local editor reference in the finally
block. Keep the existing behavior in openAuthKeyLabelsEditor,
closeAuthKeyLabelsEditor, and submitAuthKeyLabelsEditor unchanged aside from
this cleanup.


async deactivateAuthKey(key) {
if (!key || !key.active) {
return;
Expand Down
Loading