diff --git a/CLAUDE.md b/CLAUDE.md index be6362eb5..9eeb5e78c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:** diff --git a/docs/docs.json b/docs/docs.json index b50b40488..2909f70e1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -56,6 +56,7 @@ "features/passthrough-api", "features/budgets", "features/cost-tracking", + "features/labelling", "features/cache", "features/failover" ] diff --git a/docs/features/labelling.mdx b/docs/features/labelling.mdx new file mode 100644 index 000000000..74c090fe1 --- /dev/null +++ b/docs/features/labelling.mdx @@ -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//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. diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index da2e6a5d9..ed1fbe51f 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -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); diff --git a/internal/admin/dashboard/static/js/modules/auth-keys.js b/internal/admin/dashboard/static/js/modules/auth-keys.js index 1bb70dc7d..370c0fa07 100644 --- a/internal/admin/dashboard/static/js/modules/auth-keys.js +++ b/internal/admin/dashboard/static/js/modules/auth-keys.js @@ -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) { @@ -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'; @@ -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; + } + }, + async deactivateAuthKey(key) { if (!key || !key.active) { return; diff --git a/internal/admin/dashboard/static/js/modules/auth-keys.test.cjs b/internal/admin/dashboard/static/js/modules/auth-keys.test.cjs index 05e731ef6..1c992ec81 100644 --- a/internal/admin/dashboard/static/js/modules/auth-keys.test.cjs +++ b/internal/admin/dashboard/static/js/modules/auth-keys.test.cjs @@ -113,6 +113,118 @@ test('submitAuthKeyForm normalizes user paths before sending them', async () => ); }); +test('submitAuthKeyForm parses comma-separated labels and omits them when empty', async () => { + const requests = []; + const module = createAuthKeysModule({ + fetch: async (url, options) => { + requests.push({ url, options }); + return { + status: 201, + async json() { + return { value: 'sk_gom_test' }; + } + }; + } + }); + + module.headers = () => ({ 'Content-Type': 'application/json' }); + module.fetchAuthKeys = async () => {}; + module.authKeyForm = { + name: 'ci-deploy', + description: '', + user_path: '', + labels: ' team-a , batch,, team-a ', + expires_at: '' + }; + + await module.submitAuthKeyForm(); + + module.authKeyIssuedValue = ''; + module.authKeyForm = { + name: 'ci-deploy', + description: '', + user_path: '', + labels: ' , ', + expires_at: '' + }; + + await module.submitAuthKeyForm(); + + assert.equal(requests.length, 2); + assert.deepEqual( + JSON.parse(requests[0].options.body).labels, + ['team-a', 'batch'] + ); + assert.equal(JSON.parse(requests[1].options.body).labels, undefined); +}); + +test('submitAuthKeyLabelsEditor PUTs parsed labels and closes the editor on success', async () => { + const requests = []; + const module = createAuthKeysModule({ + fetch: async (url, options) => { + requests.push({ url, options }); + return { + status: 200, + async json() { + return { id: 'key_123', labels: ['team-a', 'batch'] }; + } + }; + } + }); + + module.headers = () => ({ 'Content-Type': 'application/json' }); + module.fetchAuthKeys = async () => {}; + module.openAuthKeyLabelsEditor({ id: 'key_123', name: 'ci-deploy', labels: ['old'] }); + assert.equal(module.authKeyLabelsEditor.value, 'old'); + + module.authKeyLabelsEditor.value = ' team-a , batch,, team-a '; + await module.submitAuthKeyLabelsEditor(); + + assert.equal(requests.length, 1); + assert.equal(requests[0].url, '/admin/auth-keys/key_123/labels'); + assert.equal(requests[0].options.method, 'PUT'); + assert.deepEqual(JSON.parse(requests[0].options.body).labels, ['team-a', 'batch']); + assert.equal(module.authKeyLabelsEditor.open, false); + assert.equal(module.authKeyNotice, 'Labels updated for key "ci-deploy".'); +}); + +test('submitAuthKeyLabelsEditor sends an empty list to clear labels and surfaces HTTP errors', async () => { + const requests = []; + let status = 200; + const module = createAuthKeysModule({ + console: { + error() {} + }, + fetch: async (url, options) => { + requests.push({ url, options }); + return { + status, + statusText: 'status', + async json() { + return status === 200 + ? { id: 'key_123' } + : { error: { message: 'key vanished' } }; + } + }; + } + }); + + module.headers = () => ({ 'Content-Type': 'application/json' }); + module.fetchAuthKeys = async () => {}; + + module.openAuthKeyLabelsEditor({ id: 'key_123', name: 'ci-deploy', labels: ['old'] }); + module.authKeyLabelsEditor.value = ' , '; + await module.submitAuthKeyLabelsEditor(); + assert.deepEqual(JSON.parse(requests[0].options.body).labels, []); + assert.equal(module.authKeyLabelsEditor.open, false); + + status = 404; + module.openAuthKeyLabelsEditor({ id: 'key_123', name: 'ci-deploy', labels: [] }); + await module.submitAuthKeyLabelsEditor(); + assert.equal(module.authKeyLabelsEditor.open, true); + assert.equal(module.authKeyLabelsEditor.error, 'key vanished'); +}); + test('submitAuthKeyForm rejects invalid user paths before sending the request', async () => { let called = false; const module = createAuthKeysModule({ diff --git a/internal/admin/dashboard/templates/page-auth-keys.html b/internal/admin/dashboard/templates/page-auth-keys.html index abbd9c5f3..7bc8c1fc1 100644 --- a/internal/admin/dashboard/templates/page-auth-keys.html +++ b/internal/admin/dashboard/templates/page-auth-keys.html @@ -100,6 +100,17 @@

Create API Key

+
+
+
+ + {{template "inline-help-toggle" .}} +
+

+
+ +