diff --git a/README.md b/README.md index 1e75f8f..89b4f31 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet - [Quick start](#quick-start) - [Headers and resource attributes](#headers-and-resource-attributes) - [Dynamic headers](#dynamic-headers) + - [Outbound headers](#outbound-headers) - [Disabling specific metrics](#disabling-specific-metrics) - [Disabling OTLP logs (`OPENCODE_DISABLE_LOGS`)](#disabling-otlp-logs) - [Disabling traces (`OPENCODE_DISABLE_TRACES`)](#disabling-traces) @@ -107,6 +108,8 @@ The environment variables (set them in your shell profile — `~/.zshrc`, `~/.ba | `OPENCODE_OTLP_METRICS_TEMPORALITY` | *(unset)* | Metrics aggregation temporality: `delta`, `cumulative`, or `lowmemory`. Required for Datadog (`delta`). Copied to `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`. | | `OPENCODE_TRACEPARENT` | *(unset)* | W3C [`traceparent`](https://www.w3.org/TR/trace-context/#traceparent-header) string. When set, all spans are parented under this remote context so opencode traces nest inside a caller's trace (e.g. a CI job). Invalid values are logged and ignored. Note: with the default `ParentBased` sampler, a value with the sampled flag off (`...-00`) suppresses all trace export. | | `OPENCODE_TRACESTATE` | *(unset)* | W3C [`tracestate`](https://www.w3.org/TR/trace-context/#tracestate-header) string, parsed alongside `OPENCODE_TRACEPARENT` and attached to the remote parent context. Ignored unless a valid `OPENCODE_TRACEPARENT` is also set. | +| `OPENCODE_OUTBOUND_HEADERS` | *(unset)* | Comma-separated `key=value` headers injected into outgoing LLM requests via the `chat.headers` hook. Example: `x-enable-phoenix-tracing=true` | +| `OPENCODE_OUTBOUND_ENDPOINTS` | *(unset)* | Comma-separated endpoint URLs. When set, outbound headers are only injected for requests whose resolved URL matches one of these by hostname. When unset, headers are sent to every provider. | ### Plugin options (opencode.json) @@ -148,6 +151,8 @@ Option keys mirror the resolved config and map to the environment variables: | `metricsTemporality` | `OPENCODE_OTLP_METRICS_TEMPORALITY` | | `disabledMetrics` | `OPENCODE_DISABLE_METRICS` (array, not a comma string) | | `disabledTraces` | `OPENCODE_DISABLE_TRACES` (array, not a comma string) | +| `outboundHeaders` | `OPENCODE_OUTBOUND_HEADERS` | +| `outboundEndpoints` | `OPENCODE_OUTBOUND_ENDPOINTS` (array, not a comma string) | > **Security note:** `opencode.json` is frequently committed to version control. Keep secrets such as `otlpHeaders` in an environment variable or an opencode `{env:VAR}` substitution (e.g. `"otlpHeaders": "{env:OTEL_HEADERS}"`) rather than inline. @@ -209,6 +214,22 @@ For a Cloud Run collector using IAM authentication, `get-token.sh` might be `gcl If `OPENCODE_OTLP_HEADERS` is also set, helper-provided headers override static headers with the same name. Header values are never logged. +### Outbound headers + +`OPENCODE_OTLP_HEADERS` above controls headers sent to your *collector*. `OPENCODE_OUTBOUND_HEADERS` is separate: it injects custom headers into the outgoing *LLM* requests opencode makes, via the `chat.headers` hook. This is useful for LLM gateways that require specific headers — feature flags, routing hints, or tracing toggles such as Arize Phoenix's `x-enable-phoenix-tracing`. + +```bash +export OPENCODE_OUTBOUND_HEADERS="x-enable-phoenix-tracing=true" +``` + +By default these headers are sent on every LLM request, including requests to third-party providers such as Anthropic and OpenAI. Use `OPENCODE_OUTBOUND_ENDPOINTS` to restrict injection to your own gateways: + +```bash +export OPENCODE_OUTBOUND_ENDPOINTS="https://litellm.example.com" +``` + +When set, headers are only injected when the resolved request URL — `provider.options.baseURL` if you have overridden it, otherwise the provider's default `model.api.url` — matches one of the listed endpoints by hostname. Scope your endpoints if any outbound header carries a value you would not want sent to a third-party provider. + ### Disabling specific metrics Use `OPENCODE_DISABLE_METRICS` to suppress individual metrics. The value is a comma-separated list of metric name suffixes (without the prefix). diff --git a/src/config.ts b/src/config.ts index 9be26f7..fd65c22 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,8 @@ export type PluginConfig = { metricsTemporality: MetricsTemporality | undefined disabledMetrics: Set disabledTraces: Set + outboundHeaders: string | undefined + outboundEndpoints: string[] } export function parseAttributePairs(raw: string | undefined): Record { @@ -69,6 +71,8 @@ export type OtelPluginOptions = { metricsTemporality?: MetricsTemporality disabledMetrics?: string[] disabledTraces?: string[] + outboundHeaders?: string + outboundEndpoints?: string[] } const VALID_PROTOCOLS = new Set(["grpc", "http/protobuf", "http/json"]) @@ -182,6 +186,10 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig { const optionTraces = pickStringList(resolvedOptions.disabledTraces) const disabledTraces = expandDisabledTraces(optionTraces ?? splitList(process.env["OPENCODE_DISABLE_TRACES"])) + const outboundHeaders = pickString(resolvedOptions.outboundHeaders) ?? process.env["OPENCODE_OUTBOUND_HEADERS"] + const optionEndpoints = pickStringList(resolvedOptions.outboundEndpoints) + const outboundEndpoints = normalizeList(optionEndpoints ?? splitList(process.env["OPENCODE_OUTBOUND_ENDPOINTS"])) + return { enabled: pickBoolean(resolvedOptions.enabled) ?? hasNonEmptyEnv("OPENCODE_ENABLE_TELEMETRY"), logsEnabled: pickBoolean(resolvedOptions.logsEnabled) ?? !hasNonEmptyEnv("OPENCODE_DISABLE_LOGS"), @@ -199,6 +207,8 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig { metricsTemporality, disabledMetrics, disabledTraces, + outboundHeaders, + outboundEndpoints, } } diff --git a/src/handlers/chat-headers.ts b/src/handlers/chat-headers.ts new file mode 100644 index 0000000..d8a6292 --- /dev/null +++ b/src/handlers/chat-headers.ts @@ -0,0 +1,51 @@ +import type { ProviderContext } from "@opencode-ai/plugin" +import type { Model, UserMessage } from "@opencode-ai/sdk" +import type { HandlerContext } from "../types.ts" + +/** + * Resolves the URL an LLM request will be sent to. + * + * Mirrors opencode's own resolution: a non-empty `baseURL` override from the + * user's provider config wins, otherwise the provider's default `model.api.url` + * is used. + */ +export function getResolvedURL(provider: ProviderContext, model: Model): string { + const baseURL = provider.options?.["baseURL"] + if (typeof baseURL === "string" && baseURL !== "") return baseURL + return model.api?.url ?? "" +} + +/** + * Returns `true` when `resolvedURL`'s hostname matches one of `allowed`. + * + * An empty `allowed` list matches everything. An unparseable URL matches + * nothing, so a configured allowlist fails closed. + */ +export function matchesEndpoint(resolvedURL: string, allowed: string[]): boolean { + if (allowed.length === 0) return true + try { + const host = new URL(resolvedURL).hostname + return allowed.some((ep) => { + try { + return new URL(ep).hostname === host + } catch { + return false + } + }) + } catch { + return false + } +} + +/** Injects the configured outbound headers into an LLM request whose endpoint is in scope. */ +export function handleChatHeaders( + input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, + output: { headers: Record }, + ctx: HandlerContext, +): void { + const resolvedURL = getResolvedURL(input.provider, input.model) + if (!matchesEndpoint(resolvedURL, ctx.outboundEndpoints)) return + for (const [key, value] of Object.entries(ctx.outboundHeaders)) { + output.headers[key] = value + } +} diff --git a/src/index.ts b/src/index.ts index 75bc279..d83a303 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSess import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "./handlers/message.ts" import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts" import { handleSessionDiff, handleCommandExecuted } from "./handlers/activity.ts" +import { handleChatHeaders } from "./handlers/chat-headers.ts" import { agentAttrs, getSessionAgentMeta, setBoundedMap } from "./util.ts" import type { SessionTotals } from "./types.ts" @@ -157,6 +158,8 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree sessionSpanContexts, messageSpans, messageOutputs, + outboundHeaders: parseAttributePairs(config.outboundHeaders), + outboundEndpoints: config.outboundEndpoints, } let shuttingDown = false @@ -206,6 +209,10 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree } }, + "chat.headers": safe("chat.headers", async (input, output) => { + handleChatHeaders(input, output, ctx) + }), + "chat.message": safe("chat.message", async (input, output) => { const agent = input.agent ?? "unknown" const startTime = Date.now() diff --git a/src/types.ts b/src/types.ts index 2aa33cb..001d4be 100644 --- a/src/types.ts +++ b/src/types.ts @@ -100,4 +100,6 @@ export type HandlerContext = { sessionSpanContexts: Map messageSpans: Map messageOutputs: Map + outboundHeaders: Record + outboundEndpoints: string[] } diff --git a/tests/chat-headers.test.ts b/tests/chat-headers.test.ts new file mode 100644 index 0000000..2f83ee3 --- /dev/null +++ b/tests/chat-headers.test.ts @@ -0,0 +1,99 @@ +import { describe, test, expect } from "bun:test" +import { handleChatHeaders, getResolvedURL, matchesEndpoint } from "../src/handlers/chat-headers.ts" +import { makeCtx } from "./helpers.ts" + +describe("getResolvedURL", () => { + test("returns provider.options.baseURL when set", () => { + const provider = { options: { baseURL: "https://litellm.example.com" } } as any + const model = { api: { url: "https://api.openai.com" } } as any + expect(getResolvedURL(provider, model)).toBe("https://litellm.example.com") + }) + + test("falls back to model.api.url when baseURL unset", () => { + expect(getResolvedURL({ options: {} } as any, { api: { url: "https://api.openai.com" } } as any)) + .toBe("https://api.openai.com") + }) + + test("falls back to model.api.url when baseURL is an empty string", () => { + expect(getResolvedURL({ options: { baseURL: "" } } as any, { api: { url: "https://api.openai.com" } } as any)) + .toBe("https://api.openai.com") + }) + + test("returns empty string when both unset", () => { + expect(getResolvedURL({ options: {} } as any, {} as any)).toBe("") + }) +}) + +describe("matchesEndpoint", () => { + test("matches everything when the allowlist is empty", () => { + expect(matchesEndpoint("https://api.openai.com", [])).toBe(true) + }) + + test("matches by hostname, ignoring path", () => { + expect(matchesEndpoint("https://litellm.example.com/v1/chat", ["https://litellm.example.com"])).toBe(true) + }) + + test("rejects a non-matching hostname", () => { + expect(matchesEndpoint("https://api.openai.com/v1/chat", ["https://litellm.example.com"])).toBe(false) + }) + + test("fails closed on an unparseable resolved URL", () => { + expect(matchesEndpoint("not-a-url", ["https://litellm.example.com"])).toBe(false) + }) + + test("ignores unparseable allowlist entries", () => { + expect(matchesEndpoint("https://litellm.example.com", ["", "https://litellm.example.com"])).toBe(true) + }) +}) + +describe("handleChatHeaders", () => { + const makeInput = (providerOpts: Record = {}, api: any = { url: "https://api.openai.com" }) => ({ + sessionID: "sess-1", + agent: "test", + model: { api } as any, + provider: { options: providerOpts } as any, + message: { id: "msg-1" } as any, + }) + const makeOutput = () => ({ headers: {} as Record }) + + test("injects headers when no endpoint filter is configured", () => { + const { ctx } = makeCtx() + ctx.outboundHeaders = { "x-test": "value" } + const output = makeOutput() + handleChatHeaders(makeInput(), output, ctx) + expect(output.headers["x-test"]).toBe("value") + }) + + test("injects headers when the endpoint matches", () => { + const { ctx } = makeCtx() + ctx.outboundHeaders = { "x-test": "value" } + ctx.outboundEndpoints = ["https://litellm.example.com"] + const output = makeOutput() + handleChatHeaders(makeInput({ baseURL: "https://litellm.example.com" }), output, ctx) + expect(output.headers["x-test"]).toBe("value") + }) + + test("skips headers when the endpoint does not match", () => { + const { ctx } = makeCtx() + ctx.outboundHeaders = { "x-test": "value" } + ctx.outboundEndpoints = ["https://litellm.example.com"] + const output = makeOutput() + handleChatHeaders(makeInput(), output, ctx) // defaults to api.openai.com + expect(output.headers["x-test"]).toBeUndefined() + }) + + test("injects multiple headers", () => { + const { ctx } = makeCtx() + ctx.outboundHeaders = { "x-a": "1", "x-b": "2" } + const output = makeOutput() + handleChatHeaders(makeInput(), output, ctx) + expect(output.headers).toEqual({ "x-a": "1", "x-b": "2" }) + }) + + test("is a no-op when no headers are configured", () => { + const { ctx } = makeCtx() + const output = makeOutput() + handleChatHeaders(makeInput(), output, ctx) + expect(Object.keys(output.headers).length).toBe(0) + }) +}) diff --git a/tests/config.test.ts b/tests/config.test.ts index deb96fe..36ad546 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -75,6 +75,8 @@ describe("loadConfig", () => { "OPENCODE_DISABLE_METRICS", "OPENCODE_DISABLE_LOGS", "OPENCODE_DISABLE_TRACES", + "OPENCODE_OUTBOUND_HEADERS", + "OPENCODE_OUTBOUND_ENDPOINTS", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_RESOURCE_ATTRIBUTES", "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", @@ -325,6 +327,24 @@ describe("loadConfig", () => { process.env["OPENCODE_DISABLE_TRACES"] = "1" expect(loadConfig().disabledTraces).toEqual(new Set(TRACE_TYPES)) }) + + test("outboundHeaders is undefined when unset", () => { + expect(loadConfig().outboundHeaders).toBeUndefined() + }) + + test("reads OPENCODE_OUTBOUND_HEADERS as a raw string", () => { + process.env["OPENCODE_OUTBOUND_HEADERS"] = "x-enable-phoenix-tracing=true" + expect(loadConfig().outboundHeaders).toBe("x-enable-phoenix-tracing=true") + }) + + test("outboundEndpoints is empty when unset", () => { + expect(loadConfig().outboundEndpoints).toEqual([]) + }) + + test("parses OPENCODE_OUTBOUND_ENDPOINTS into a trimmed list", () => { + process.env["OPENCODE_OUTBOUND_ENDPOINTS"] = " https://a.example.com , https://b.example.com " + expect(loadConfig().outboundEndpoints).toEqual(["https://a.example.com", "https://b.example.com"]) + }) }) describe("loadConfig options", () => { @@ -341,6 +361,8 @@ describe("loadConfig options", () => { "OPENCODE_DISABLE_METRICS", "OPENCODE_DISABLE_LOGS", "OPENCODE_DISABLE_TRACES", + "OPENCODE_OUTBOUND_HEADERS", + "OPENCODE_OUTBOUND_ENDPOINTS", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_RESOURCE_ATTRIBUTES", "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", @@ -452,6 +474,17 @@ describe("loadConfig options", () => { expect(disabledTraces).toEqual(new Set(["llm", "tool"])) }) + test("option outboundHeaders overrides the env var", () => { + process.env["OPENCODE_OUTBOUND_HEADERS"] = "x-env=1" + expect(loadConfig({ outboundHeaders: "x-option=1" }).outboundHeaders).toBe("x-option=1") + }) + + test("option outboundEndpoints trims entries and overrides the env var", () => { + process.env["OPENCODE_OUTBOUND_ENDPOINTS"] = "https://env.example.com" + expect(loadConfig({ outboundEndpoints: [" https://option.example.com "] }).outboundEndpoints) + .toEqual(["https://option.example.com"]) + }) + test("env values still apply when no options are passed", () => { process.env["OPENCODE_ENABLE_TELEMETRY"] = "1" process.env["OPENCODE_OTLP_ENDPOINT"] = "http://env:4317" diff --git a/tests/helpers.ts b/tests/helpers.ts index 14d2bff..961eb57 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -242,6 +242,8 @@ export function makeCtx( sessionSpanContexts: new Map(), messageSpans: new Map(), messageOutputs: new Map(), + outboundHeaders: {}, + outboundEndpoints: [], } return {