From 915b6c50795fe811ea015d09b3e36d83eaf5f516 Mon Sep 17 00:00:00 2001 From: Paolo Calvi Date: Fri, 22 May 2026 01:18:12 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(track-a):=20foundation=20=E2=80=94=20h?= =?UTF-8?q?eader=20contract=20+=20httpx-sse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog.d/track-a-foundation.md | 3 + docs/plugin-header-contract.md | 100 ++++++++++++++++++++++++++++++ pyproject.toml | 1 + uv.lock | 2 + 4 files changed, 106 insertions(+) create mode 100644 changelog.d/track-a-foundation.md create mode 100644 docs/plugin-header-contract.md diff --git a/changelog.d/track-a-foundation.md b/changelog.d/track-a-foundation.md new file mode 100644 index 000000000..ec352396c --- /dev/null +++ b/changelog.d/track-a-foundation.md @@ -0,0 +1,3 @@ +# Track A Foundation + +Adds plugin header contract documentation and `httpx-sse` dependency for upcoming multi-provider passthrough routes. diff --git a/docs/plugin-header-contract.md b/docs/plugin-header-contract.md new file mode 100644 index 000000000..008549107 --- /dev/null +++ b/docs/plugin-header-contract.md @@ -0,0 +1,100 @@ +# Plugin Header Contract + +> **Version**: v1.0 +> **Status**: Active (Track A) +> **Related**: [opencode-luthien plugin](https://github.com/LuthienResearch/opencode-luthien), [Track A plan](.sisyphus/plans/track-a-opencode-luthien-bridge.md) + +This document defines the canonical set of HTTP headers injected by the `opencode-luthien` plugin into every proxied request. The gateway reads these headers to populate observability columns in `request_logs`. + +--- + +## Headers + +### `x-luthien-session-id` + +| Field | Value | +|---|---| +| **Source** | OpenCode session ID (UUIDv4, provided by OpenCode runtime) | +| **Type** | String (UUID format) | +| **Required** | No — absent when plugin is not loaded or proxy is unreachable | +| **Semantics** | Identifies the OpenCode session that originated the request. Unique per OpenCode process invocation. Shared across all requests within a single session. | +| **Example** | `x-luthien-session-id: 550e8400-e29b-41d4-a716-446655440000` | +| **Persisted to** | `request_logs.session_id` (dedicated column) | + +### `x-luthien-agent` + +| Field | Value | +|---|---| +| **Source** | OpenCode agent name (e.g., `build`, `test`, `review`) | +| **Type** | String | +| **Required** | No — defaults to `"unknown"` when agent name is unavailable | +| **Semantics** | Identifies which OpenCode agent mode was active when the request was made. Useful for filtering logs by agent type. | +| **Example** | `x-luthien-agent: build` | +| **Persisted to** | `request_logs.agent` (dedicated column, added by migration 018) | + +### `x-luthien-provider` + +| Field | Value | +|---|---| +| **Source** | Plugin — derived from the AI SDK provider ID | +| **Type** | String (one of: `anthropic`, `openai`, `google`) | +| **Required** | No | +| **Semantics** | Identifies which AI provider the request targets. Redundant with the URL prefix (`/openai/`, `/gemini/`, `/anthropic/`) but included for convenience. | +| **Example** | `x-luthien-provider: openai` | +| **Persisted to** | `request_logs.request_headers` JSONB (not a dedicated column — derivable from `endpoint` URL prefix at query time) | + +### `x-luthien-model` + +| Field | Value | +|---|---| +| **Source** | Plugin — derived from the AI SDK model ID | +| **Type** | String | +| **Required** | No | +| **Semantics** | Identifies the specific model requested (e.g., `gpt-4o`, `claude-3-5-sonnet-20241022`, `gemini-1.5-flash`). | +| **Example** | `x-luthien-model: gpt-4o` | +| **Persisted to** | `request_logs.model` (existing column) | + +### `x-luthien-plugin-version` + +| Field | Value | +|---|---| +| **Source** | Plugin — hardcoded to the plugin's npm package version | +| **Type** | String (semver) | +| **Required** | No | +| **Semantics** | Identifies the version of the `opencode-luthien` plugin that injected these headers. Useful for debugging version-specific behavior. | +| **Example** | `x-luthien-plugin-version: 0.1.0` | +| **Persisted to** | `request_logs.request_headers` JSONB (not a dedicated column) | + +--- + +## Gateway Behavior + +- **Inbound**: The gateway reads `x-luthien-session-id`, `x-luthien-agent`, and `x-luthien-model` from inbound requests and persists them to dedicated `request_logs` columns. +- **Outbound**: All `x-luthien-*` headers are **stripped** before forwarding to upstream providers (Anthropic, OpenAI, Gemini). They are internal observability headers and must not leak to external APIs. +- **Unknown headers**: Any `x-luthien-*` header not listed above is logged (via `request_headers` JSONB) and ignored. This supports additive evolution. +- **Missing headers**: When `x-luthien-*` headers are absent (plugin not loaded, proxy unreachable), the corresponding `request_logs` columns are NULL. Requests still succeed. + +--- + +## Versioning Policy + +This contract is at **v1.0**. + +| Change type | Policy | +|---|---| +| **Add a new `x-luthien-*` header** | Allowed. Bump minor version (v1.0 -> v1.1). Gateway ignores unknown headers. | +| **Remove an existing header** | **FORBIDDEN**. Removing a header is a breaking change. If removal is required, deprecate first (one release cycle), then remove in a new major version. | +| **Change the semantics of an existing header** | **FORBIDDEN**. Create a new header with a new name instead. | +| **Change the format of an existing header** | Treat as a semantic change — **FORBIDDEN** without a new header name. | + +The plugin sends `x-luthien-plugin-version` on every request so the gateway can detect version mismatches in logs. + +--- + +## Related + +- Plugin source: [LuthienResearch/opencode-luthien](https://github.com/LuthienResearch/opencode-luthien) +- Plugin README: see plugin repo for installation and configuration +- Gateway passthrough routes: `src/luthien_proxy/passthrough_routes.py` (Track A bridge code) +- Database schema: `migrations/postgres/008_add_request_logs_table.sql` (session_id), `migrations/postgres/018_add_agent_to_request_logs.sql` (agent) +- Track B: Native provider pipelines will replace the passthrough routes and may extend this contract diff --git a/pyproject.toml b/pyproject.toml index dbaa8830f..24a7b08e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "anthropic>=0.84.0", "aiohttp>=3.9.0", "sentry-sdk[fastapi]>=2.54.0", + "httpx-sse>=0.4", ] [tool.hatch.version] diff --git a/uv.lock b/uv.lock index 2d0ec8150..e7badadc2 100644 --- a/uv.lock +++ b/uv.lock @@ -1042,6 +1042,7 @@ dependencies = [ { name = "click" }, { name = "cryptography" }, { name = "httpx" }, + { name = "httpx-sse" }, { name = "jsonschema" }, { name = "litellm", extra = ["proxy"] }, { name = "opentelemetry-api" }, @@ -1084,6 +1085,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1.0" }, { name = "cryptography", specifier = ">=44.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "httpx-sse", specifier = ">=0.4" }, { name = "jsonschema", specifier = ">=4.17.0" }, { name = "litellm", extras = ["proxy"], specifier = ">=1.81.0,!=1.82.7,!=1.82.8" }, { name = "opentelemetry-api", specifier = ">=1.20.0" }, From 863025c7ff27f87dd0b37653e180a20c7c71c10d Mon Sep 17 00:00:00 2001 From: Paolo Calvi Date: Sat, 23 May 2026 21:58:52 +0000 Subject: [PATCH 2/5] docs(track-a): address PR #757 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - changelog: add required YAML frontmatter (category + pr) - contract: fix broken .sisyphus link → PR #757 reference - contract: fix migration ref 018 → 019 (collision resolved in PR-B) - contract: annotate forward refs (passthrough_routes.py, agent column) as 'introduced in PR-B' - contract: clarify agent NULL vs unknown — header absent → NULL; plugin sends 'unknown' when unavailable - contract: add Trust Boundary section (gateway trusts headers as received; operator responsibility) - contract: add header validation rules (session_id max 36 chars/UUIDv4, agent max 64 chars) - contract: document provider unknown-value handling (log + pass through, no rejection) - contract: clarify contract version is independent of plugin npm version --- changelog.d/track-a-foundation.md | 7 +++++-- docs/plugin-header-contract.md | 30 +++++++++++++++++++----------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/changelog.d/track-a-foundation.md b/changelog.d/track-a-foundation.md index ec352396c..98f934f16 100644 --- a/changelog.d/track-a-foundation.md +++ b/changelog.d/track-a-foundation.md @@ -1,3 +1,6 @@ -# Track A Foundation +--- +category: Chores & Docs +pr: 757 +--- -Adds plugin header contract documentation and `httpx-sse` dependency for upcoming multi-provider passthrough routes. +**Track A foundation**: Plugin header contract documentation and `httpx-sse` dependency for upcoming multi-provider passthrough routes. diff --git a/docs/plugin-header-contract.md b/docs/plugin-header-contract.md index 008549107..971a31215 100644 --- a/docs/plugin-header-contract.md +++ b/docs/plugin-header-contract.md @@ -1,13 +1,19 @@ # Plugin Header Contract -> **Version**: v1.0 +> **Version**: v1.0 (contract version, independent of plugin npm version) > **Status**: Active (Track A) -> **Related**: [opencode-luthien plugin](https://github.com/LuthienResearch/opencode-luthien), [Track A plan](.sisyphus/plans/track-a-opencode-luthien-bridge.md) +> **Related**: [opencode-luthien plugin](https://github.com/LuthienResearch/opencode-luthien), [PR #757](https://github.com/LuthienResearch/luthien-proxy/pull/757) This document defines the canonical set of HTTP headers injected by the `opencode-luthien` plugin into every proxied request. The gateway reads these headers to populate observability columns in `request_logs`. --- +## Trust Boundary + +The gateway trusts `x-luthien-*` headers as received — it does not authenticate their origin beyond the client credential check already applied to every request. Operators who expose the gateway to untrusted clients should front it with a reverse proxy that strips inbound `x-luthien-*` headers before they reach the gateway, preventing clients from spoofing session IDs or agent names in logs. + +--- + ## Headers ### `x-luthien-session-id` @@ -15,22 +21,24 @@ This document defines the canonical set of HTTP headers injected by the `opencod | Field | Value | |---|---| | **Source** | OpenCode session ID (UUIDv4, provided by OpenCode runtime) | -| **Type** | String (UUID format) | +| **Type** | String (UUIDv4 format, max 36 chars, pattern `[0-9a-f-]{36}`) | | **Required** | No — absent when plugin is not loaded or proxy is unreachable | | **Semantics** | Identifies the OpenCode session that originated the request. Unique per OpenCode process invocation. Shared across all requests within a single session. | | **Example** | `x-luthien-session-id: 550e8400-e29b-41d4-a716-446655440000` | | **Persisted to** | `request_logs.session_id` (dedicated column) | +> **Validation**: The gateway stores the value as-is without UUID validation. Values longer than 36 characters are truncated to fit the column. The plugin MUST send a valid UUIDv4. + ### `x-luthien-agent` | Field | Value | |---|---| | **Source** | OpenCode agent name (e.g., `build`, `test`, `review`) | -| **Type** | String | -| **Required** | No — defaults to `"unknown"` when agent name is unavailable | -| **Semantics** | Identifies which OpenCode agent mode was active when the request was made. Useful for filtering logs by agent type. | +| **Type** | String (max 64 chars, printable ASCII) | +| **Required** | No | +| **Semantics** | Identifies which OpenCode agent mode was active when the request was made. Useful for filtering logs by agent type. When the header is absent, `request_logs.agent` is NULL. The plugin sends `"unknown"` when the agent name is unavailable. | | **Example** | `x-luthien-agent: build` | -| **Persisted to** | `request_logs.agent` (dedicated column, added by migration 018) | +| **Persisted to** | `request_logs.agent` (dedicated column, introduced in PR-B / migration 019) | ### `x-luthien-provider` @@ -39,7 +47,7 @@ This document defines the canonical set of HTTP headers injected by the `opencod | **Source** | Plugin — derived from the AI SDK provider ID | | **Type** | String (one of: `anthropic`, `openai`, `google`) | | **Required** | No | -| **Semantics** | Identifies which AI provider the request targets. Redundant with the URL prefix (`/openai/`, `/gemini/`, `/anthropic/`) but included for convenience. | +| **Semantics** | Identifies which AI provider the request targets. Redundant with the URL prefix (`/openai/`, `/gemini/`, `/anthropic/`) but included for convenience. Unknown values are logged via `request_headers` JSONB and passed through — the gateway does not reject unrecognised provider strings, supporting forward-compatibility as new providers are added. | | **Example** | `x-luthien-provider: openai` | | **Persisted to** | `request_logs.request_headers` JSONB (not a dedicated column — derivable from `endpoint` URL prefix at query time) | @@ -69,7 +77,7 @@ This document defines the canonical set of HTTP headers injected by the `opencod ## Gateway Behavior -- **Inbound**: The gateway reads `x-luthien-session-id`, `x-luthien-agent`, and `x-luthien-model` from inbound requests and persists them to dedicated `request_logs` columns. +- **Inbound**: The gateway reads `x-luthien-session-id`, `x-luthien-agent`, and `x-luthien-model` from inbound requests and persists them to dedicated `request_logs` columns (agent column introduced in PR-B). - **Outbound**: All `x-luthien-*` headers are **stripped** before forwarding to upstream providers (Anthropic, OpenAI, Gemini). They are internal observability headers and must not leak to external APIs. - **Unknown headers**: Any `x-luthien-*` header not listed above is logged (via `request_headers` JSONB) and ignored. This supports additive evolution. - **Missing headers**: When `x-luthien-*` headers are absent (plugin not loaded, proxy unreachable), the corresponding `request_logs` columns are NULL. Requests still succeed. @@ -95,6 +103,6 @@ The plugin sends `x-luthien-plugin-version` on every request so the gateway can - Plugin source: [LuthienResearch/opencode-luthien](https://github.com/LuthienResearch/opencode-luthien) - Plugin README: see plugin repo for installation and configuration -- Gateway passthrough routes: `src/luthien_proxy/passthrough_routes.py` (Track A bridge code) -- Database schema: `migrations/postgres/008_add_request_logs_table.sql` (session_id), `migrations/postgres/018_add_agent_to_request_logs.sql` (agent) +- Gateway passthrough routes: `src/luthien_proxy/passthrough_routes.py` (introduced in PR-B) +- Database schema: `migrations/postgres/008_add_request_logs_table.sql` (session_id), `migrations/postgres/019_add_agent_to_request_logs.sql` (agent, introduced in PR-B) - Track B: Native provider pipelines will replace the passthrough routes and may extend this contract From c843d8e1c15e7157eef2c0b9054686ee3e41f2b8 Mon Sep 17 00:00:00 2001 From: Paolo Calvi Date: Sat, 23 May 2026 22:08:24 +0000 Subject: [PATCH 3/5] docs(track-a): address second round of PR #757 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session_id validation: drop inaccurate truncation claim; gateway stores as-is with no enforcement (behavior for malformed values deferred to PR-B) - trust boundary: narrow strip advice to specific headers listed in this doc; add note about TRUST_USER_ID_HEADER interaction to avoid breaking user attribution - x-luthien-provider: fix closed/open enum contradiction; rephrase type as 'String — known values: ...' to make open-ended nature explicit - header link: replace self-referential PR #757 link with PR #758 (gateway impl) --- docs/plugin-header-contract.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/plugin-header-contract.md b/docs/plugin-header-contract.md index 971a31215..c7f5f1acb 100644 --- a/docs/plugin-header-contract.md +++ b/docs/plugin-header-contract.md @@ -2,7 +2,7 @@ > **Version**: v1.0 (contract version, independent of plugin npm version) > **Status**: Active (Track A) -> **Related**: [opencode-luthien plugin](https://github.com/LuthienResearch/opencode-luthien), [PR #757](https://github.com/LuthienResearch/luthien-proxy/pull/757) +> **Related**: [opencode-luthien plugin](https://github.com/LuthienResearch/opencode-luthien), [PR #758](https://github.com/LuthienResearch/luthien-proxy/pull/758) (gateway implementation) This document defines the canonical set of HTTP headers injected by the `opencode-luthien` plugin into every proxied request. The gateway reads these headers to populate observability columns in `request_logs`. @@ -10,7 +10,9 @@ This document defines the canonical set of HTTP headers injected by the `opencod ## Trust Boundary -The gateway trusts `x-luthien-*` headers as received — it does not authenticate their origin beyond the client credential check already applied to every request. Operators who expose the gateway to untrusted clients should front it with a reverse proxy that strips inbound `x-luthien-*` headers before they reach the gateway, preventing clients from spoofing session IDs or agent names in logs. +The gateway trusts `x-luthien-*` headers as received — it does not authenticate their origin beyond the client credential check already applied to every request. Operators who expose the gateway to untrusted clients should configure a reverse proxy to strip the specific headers documented here (`x-luthien-session-id`, `x-luthien-agent`, `x-luthien-provider`, `x-luthien-model`, `x-luthien-plugin-version`) before they reach the gateway, preventing clients from spoofing session IDs or agent names in logs. + +> **Note**: `x-luthien-user-id` is a separate header controlled by the `TRUST_USER_ID_HEADER` gateway config. A blanket strip of all `x-luthien-*` headers would silently disable user attribution for operators who have intentionally enabled that setting. Strip only the headers listed above. --- @@ -27,7 +29,7 @@ The gateway trusts `x-luthien-*` headers as received — it does not authenticat | **Example** | `x-luthien-session-id: 550e8400-e29b-41d4-a716-446655440000` | | **Persisted to** | `request_logs.session_id` (dedicated column) | -> **Validation**: The gateway stores the value as-is without UUID validation. Values longer than 36 characters are truncated to fit the column. The plugin MUST send a valid UUIDv4. +> **Validation**: The gateway stores the value as-is with no length enforcement or UUID validation. The plugin MUST send a valid UUIDv4 (max 36 chars). Behavior for malformed or oversized values is undefined until PR-B adds explicit validation. ### `x-luthien-agent` @@ -45,7 +47,7 @@ The gateway trusts `x-luthien-*` headers as received — it does not authenticat | Field | Value | |---|---| | **Source** | Plugin — derived from the AI SDK provider ID | -| **Type** | String (one of: `anthropic`, `openai`, `google`) | +| **Type** | String — known values: `anthropic`, `openai`, `google` | | **Required** | No | | **Semantics** | Identifies which AI provider the request targets. Redundant with the URL prefix (`/openai/`, `/gemini/`, `/anthropic/`) but included for convenience. Unknown values are logged via `request_headers` JSONB and passed through — the gateway does not reject unrecognised provider strings, supporting forward-compatibility as new providers are added. | | **Example** | `x-luthien-provider: openai` | From d046044ff5a171490ceb81ce5d5cd3e608b448c5 Mon Sep 17 00:00:00 2001 From: Paolo Calvi Date: Sat, 23 May 2026 22:12:19 +0000 Subject: [PATCH 4/5] docs(track-a): address third round of PR #757 review findings - x-luthien-agent: document that gateway stores literal 'unknown' as-is (not normalized to NULL); advise consumers to COALESCE(agent, 'unknown') at query time to treat both cases uniformly - add status disclaimer at top: contract published ahead of implementation, forward refs to passthrough_routes.py and migration 019 land in PR #758 --- docs/plugin-header-contract.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugin-header-contract.md b/docs/plugin-header-contract.md index c7f5f1acb..d433396b6 100644 --- a/docs/plugin-header-contract.md +++ b/docs/plugin-header-contract.md @@ -1,7 +1,7 @@ # Plugin Header Contract > **Version**: v1.0 (contract version, independent of plugin npm version) -> **Status**: Active (Track A) +> **Status**: Contract published ahead of implementation — gateway ingestion code lands in [PR #758](https://github.com/LuthienResearch/luthien-proxy/pull/758). Forward references to `passthrough_routes.py` and `019_add_agent_to_request_logs.sql` will be accurate once PR #758 merges. > **Related**: [opencode-luthien plugin](https://github.com/LuthienResearch/opencode-luthien), [PR #758](https://github.com/LuthienResearch/luthien-proxy/pull/758) (gateway implementation) This document defines the canonical set of HTTP headers injected by the `opencode-luthien` plugin into every proxied request. The gateway reads these headers to populate observability columns in `request_logs`. @@ -38,7 +38,7 @@ The gateway trusts `x-luthien-*` headers as received — it does not authenticat | **Source** | OpenCode agent name (e.g., `build`, `test`, `review`) | | **Type** | String (max 64 chars, printable ASCII) | | **Required** | No | -| **Semantics** | Identifies which OpenCode agent mode was active when the request was made. Useful for filtering logs by agent type. When the header is absent, `request_logs.agent` is NULL. The plugin sends `"unknown"` when the agent name is unavailable. | +| **Semantics** | Identifies which OpenCode agent mode was active when the request was made. Useful for filtering logs by agent type. When the header is absent, `request_logs.agent` is NULL. The plugin sends the literal string `"unknown"` when the agent name is unavailable — the gateway stores this as-is (not normalized to NULL). Consumers should use `COALESCE(agent, 'unknown')` at query time to treat both cases uniformly. | | **Example** | `x-luthien-agent: build` | | **Persisted to** | `request_logs.agent` (dedicated column, introduced in PR-B / migration 019) | From 2da604ef802aeb644ccdd799ae8521a28a6022f4 Mon Sep 17 00:00:00 2001 From: Paolo Calvi Date: Sat, 23 May 2026 22:23:16 +0000 Subject: [PATCH 5/5] docs(track-a): address fourth round of PR #757 review findings - x-luthien-agent: change convention to omit-when-unavailable (NULL = unknown everywhere); drop 'unknown' placeholder guidance; document that NULL is the canonical signal for both 'plugin not loaded' and 'agent name unresolved' - x-luthien-session-id: tighten regex to proper UUIDv4 pattern with version/ variant nibbles ([0-9a-f]{8}-...-4...-[89ab]...) instead of loose hex-and-dash - Gateway Behavior: reword present tense to future tense ('will read', 'will be stripped') since code doesn't exist until PR-B; clarify outbound strip is by prefix match not enumeration - Add JSONB note clarifying request_headers stores raw inbound header set - Drop unenforced pseudo-spec max-length claims on x-luthien-agent --- docs/plugin-header-contract.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/plugin-header-contract.md b/docs/plugin-header-contract.md index d433396b6..46d58aeb6 100644 --- a/docs/plugin-header-contract.md +++ b/docs/plugin-header-contract.md @@ -23,22 +23,22 @@ The gateway trusts `x-luthien-*` headers as received — it does not authenticat | Field | Value | |---|---| | **Source** | OpenCode session ID (UUIDv4, provided by OpenCode runtime) | -| **Type** | String (UUIDv4 format, max 36 chars, pattern `[0-9a-f-]{36}`) | +| **Type** | String (UUIDv4 format: `[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}`) | | **Required** | No — absent when plugin is not loaded or proxy is unreachable | | **Semantics** | Identifies the OpenCode session that originated the request. Unique per OpenCode process invocation. Shared across all requests within a single session. | | **Example** | `x-luthien-session-id: 550e8400-e29b-41d4-a716-446655440000` | | **Persisted to** | `request_logs.session_id` (dedicated column) | -> **Validation**: The gateway stores the value as-is with no length enforcement or UUID validation. The plugin MUST send a valid UUIDv4 (max 36 chars). Behavior for malformed or oversized values is undefined until PR-B adds explicit validation. +> **Validation**: The gateway stores the value as-is with no length enforcement or UUID validation. The plugin MUST send a valid UUIDv4. Behavior for malformed or oversized values is undefined until PR-B adds explicit validation. ### `x-luthien-agent` | Field | Value | |---|---| | **Source** | OpenCode agent name (e.g., `build`, `test`, `review`) | -| **Type** | String (max 64 chars, printable ASCII) | -| **Required** | No | -| **Semantics** | Identifies which OpenCode agent mode was active when the request was made. Useful for filtering logs by agent type. When the header is absent, `request_logs.agent` is NULL. The plugin sends the literal string `"unknown"` when the agent name is unavailable — the gateway stores this as-is (not normalized to NULL). Consumers should use `COALESCE(agent, 'unknown')` at query time to treat both cases uniformly. | +| **Type** | String | +| **Required** | No — **omit this header when the agent name is unavailable**. Do not send a placeholder like `"unknown"`. | +| **Semantics** | Identifies which OpenCode agent mode was active when the request was made. Useful for filtering logs by agent type. When the header is absent, `request_logs.agent` is NULL — this is the canonical signal for "agent name unavailable or non-plugin traffic". Sending a placeholder string instead of omitting the header would conflate two distinct cases (plugin loaded but unresolved vs. plugin not loaded) and make them indistinguishable at query time. | | **Example** | `x-luthien-agent: build` | | **Persisted to** | `request_logs.agent` (dedicated column, introduced in PR-B / migration 019) | @@ -79,10 +79,12 @@ The gateway trusts `x-luthien-*` headers as received — it does not authenticat ## Gateway Behavior -- **Inbound**: The gateway reads `x-luthien-session-id`, `x-luthien-agent`, and `x-luthien-model` from inbound requests and persists them to dedicated `request_logs` columns (agent column introduced in PR-B). -- **Outbound**: All `x-luthien-*` headers are **stripped** before forwarding to upstream providers (Anthropic, OpenAI, Gemini). They are internal observability headers and must not leak to external APIs. -- **Unknown headers**: Any `x-luthien-*` header not listed above is logged (via `request_headers` JSONB) and ignored. This supports additive evolution. -- **Missing headers**: When `x-luthien-*` headers are absent (plugin not loaded, proxy unreachable), the corresponding `request_logs` columns are NULL. Requests still succeed. +- **Inbound**: The gateway will read `x-luthien-session-id`, `x-luthien-agent`, and `x-luthien-model` from inbound requests and persist them to dedicated `request_logs` columns (agent column introduced in PR-B). +- **Outbound**: All `x-luthien-*` headers will be **stripped** before forwarding to upstream providers (Anthropic, OpenAI, Gemini) — by prefix match, not by enumerating the documented header names. This ensures headers added in future plugin versions are also stripped. They are internal observability headers and must not leak to external APIs. +- **Unknown headers**: Any `x-luthien-*` header not listed above will be logged (via `request_headers` JSONB) and ignored. This supports additive evolution. +- **Missing headers**: When `x-luthien-*` headers are absent (plugin not loaded, proxy unreachable), the corresponding `request_logs` columns will be NULL. Requests still succeed. + +> **Note on `request_headers` JSONB**: `request_logs.request_headers` stores the raw inbound header set. Headers marked "Persisted to `request_headers` JSONB" are not routed to dedicated columns — they are accessible via JSON queries on the raw header blob. ---