From 18486b4530a07a835f7a52627257f1d9e5d02240 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Mon, 20 Jul 2026 23:27:45 +0200 Subject: [PATCH] feat(ai-proxy): configurable credential attachment via `auth` field `Provider` conflated two orthogonal concerns: the wire protocol (OpenAI Chat Completions/Responses vs Anthropic Messages) and the auth header convention. The credential header was hardcoded in three places (OpenAI transport, Anthropic transport, and the `/v1/models` aggregator). Add an `auth` field on targets, routes, and the flat config, decoupled from `provider`: - `bearer` -> Authorization: Bearer (OpenAI/Ollama default) - `api_key` -> x-api-key: (Anthropic default) - `{ header: "..." }` -> arbitrary credential header - `{ query: "..." }` -> key in the query string When omitted, `auth` defaults to the provider's convention, so existing configs are unchanged. This lets OpenAI-compatible endpoints with non-standard credential headers (Brave AI Grounding `X-Subscription-Token`, Azure OpenAI `api-key`) be configured without a dedicated provider type. The three hardcoded auth sites now share a single `apply_auth` helper; custom `auth` is also propagated to the `/v1/models` aggregator. - lib.rs: `Auth` enum, `Provider::default_auth`, `TargetConfig::effective_auth`, `auth` on `TargetConfig`/`Route`/flat config - providers/mod.rs: unified `apply_auth` (+ unit tests) - openai/anthropic transports + responses passthrough + models aggregator read `apply_auth` - config-schema.json: `Auth` def + property on the three surfaces; vacuum rulesets regenerated - docs (dispatchers guide, extensions reference) + CHANGELOG Cargo.lock: local SDK path deps resynced 0.7.0 -> 0.8.1 (build side effect). --- CHANGELOG.md | 4 + docs/guide/dispatchers.md | 39 ++++++-- docs/reference/extensions.md | 6 ++ .../barbacane-validate-dispatch-config.js | 1 + plugins/ai-proxy/Cargo.lock | 5 +- plugins/ai-proxy/config-schema.json | 35 +++++++ plugins/ai-proxy/src/lib.rs | 93 +++++++++++++++++++ plugins/ai-proxy/src/protocols/models.rs | 54 ++++++----- plugins/ai-proxy/src/protocols/responses.rs | 14 +-- plugins/ai-proxy/src/providers/anthropic.rs | 4 +- plugins/ai-proxy/src/providers/mod.rs | 85 +++++++++++++++++ plugins/ai-proxy/src/providers/openai.rs | 22 +++-- 12 files changed, 315 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf66b3..48c4206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **plugins/ai-proxy**: configurable credential attachment via a new `auth` field on targets, routes, and the flat config. `auth` is orthogonal to `provider` (which selects the wire protocol): `bearer` → `Authorization: Bearer`, `api_key` → `x-api-key`, `{ header: "Name" }` → an arbitrary credential header, `{ query: "param" }` → key in the query string. When omitted it defaults to the provider's convention (bearer for OpenAI/Ollama, `x-api-key` for Anthropic), so existing configs are unchanged. This lets OpenAI-compatible endpoints with non-standard credential headers (e.g. Brave AI Grounding's `X-Subscription-Token`, Azure OpenAI's `api-key`) be configured without a dedicated provider type. Internally, the three previously hardcoded auth call sites (OpenAI transport, Anthropic transport, `/v1/models` aggregator) now share a single `apply_auth` implementation. + ## [0.8.1] - 2026-07-15 Patch release: fixes a regression that made the `barbacane-standalone` image unable to serve specs under 0.8's capability enforcement. diff --git a/docs/guide/dispatchers.md b/docs/guide/dispatchers.md index 885ec33..35c7734 100644 --- a/docs/guide/dispatchers.md +++ b/docs/guide/dispatchers.md @@ -845,6 +845,7 @@ Now `gpt-3.5-turbo` falls through to ollama; `gpt-4o-mini` still routes to OpenA | `provider` | string | If no `targets`/`routes` | - | Provider type: `openai`, `anthropic`, or `ollama` | | `api_key` | string | No | - | API key. Supports `env://VAR` substitution. Omit for Ollama | | `base_url` | string | No | Provider default | Custom endpoint URL (Azure OpenAI, self-hosted vLLM, remote Ollama, etc.) | +| `auth` | string \| object | No | Provider convention | How the `api_key` is attached. `bearer` → `Authorization: Bearer` (OpenAI/Ollama default), `api_key` → `x-api-key` (Anthropic default), `{ header: "Name" }` → arbitrary credential header, `{ query: "param" }` → key in the query string. See [Custom credential headers](#custom-credential-headers) | | `timeout` | integer | No | 120 | LLM request timeout (seconds) for chat completions and responses | | `models_timeout_ms` | integer | No | 5000 | Per-provider timeout (**milliseconds**) for the `/v1/models` aggregator only — separate from `timeout` because discovery doesn't need 120s of patience | | `max_tokens` | integer | No | - | Default `max_tokens` injected when the client omits it. Required for Anthropic (enforced by their API). Cost guardrail for OpenAI/Ollama | @@ -853,15 +854,41 @@ Now `gpt-3.5-turbo` falls through to ollama; `gpt-4o-mini` still routes to OpenA | `targets` | object | No | - | Named provider targets selectable via `ai.target` context | | `default_target` | string | No | - | Target to use when no `ai.target` context key is set and no route matched | -`TargetConfig` (entries in `targets` map and `fallback` array): `provider` (required), `api_key`, `base_url`, `allow`, `deny`. Same shape as `routes[]` minus the `pattern`. +`TargetConfig` (entries in `targets` map and `fallback` array): `provider` (required), `api_key`, `base_url`, `auth`, `allow`, `deny`. Same shape as `routes[]` minus the `pattern`. **Provider defaults:** -| Provider | Default Base URL | -|----------|-----------------| -| `openai` | `https://api.openai.com` | -| `anthropic` | `https://api.anthropic.com` | -| `ollama` | `http://localhost:11434` | +| Provider | Default Base URL | Default `auth` | +|----------|-----------------|----------------| +| `openai` | `https://api.openai.com` | `bearer` (`Authorization: Bearer`) | +| `anthropic` | `https://api.anthropic.com` | `api_key` (`x-api-key`) | +| `ollama` | `http://localhost:11434` | `bearer` | + +#### Custom credential headers + +`provider` selects the **wire protocol** (OpenAI Chat Completions / Responses vs Anthropic Messages); `auth` selects **how the key is attached**, independently. This lets an OpenAI-compatible endpoint that uses a non-standard credential header be configured without a dedicated provider type. + +```yaml +x-barbacane-dispatch: + name: ai-proxy + config: + routes: + # Brave AI Grounding — OpenAI-compatible surface, but the credential + # rides in X-Subscription-Token instead of Authorization: Bearer. + - pattern: "brave-*" + provider: openai + base_url: "https://api.search.brave.com/res" + api_key: "env://BRAVE_API_KEY" + auth: { header: "X-Subscription-Token" } + # Azure OpenAI — OpenAI protocol, api-key header. + - pattern: "gpt-*" + provider: openai + base_url: "env://AZURE_OPENAI_ENDPOINT" + api_key: "env://AZURE_OPENAI_KEY" + auth: { header: "api-key" } +``` + +Omit `auth` to use the provider's conventional header (the common case). Use `{ query: "key" }` for APIs that expect the credential as a query-string parameter. #### Provider fallback diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 9563668..a466807 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -189,6 +189,11 @@ x-barbacane-dispatch: - pattern: "gpt-*" provider: openai api_key: env://OPENAI_API_KEY + - pattern: "brave-*" # OpenAI-compatible endpoint with a custom auth header + provider: openai + base_url: https://api.search.brave.com/res + api_key: env://BRAVE_API_KEY + auth: { header: "X-Subscription-Token" } targets: # Optional. Named provider targets selected via ai.target context premium: provider: openai @@ -203,6 +208,7 @@ x-barbacane-dispatch: provider: string # Optional. openai | anthropic | ollama api_key: string # Optional. env://VAR supported base_url: string # Optional. Override provider default (Azure, vLLM, etc.) + auth: string | object # Optional. bearer | api_key | {header: "Name"} | {query: "param"}. Default: provider convention ``` The dispatcher also ships as a multi-file spec fragment under [`schemas/ai-gateway.yaml`](../guide/spec-configuration.md) — drop it into a project's `specs/` folder to bind all three operations to the same `ai-proxy` config via a YAML anchor. diff --git a/docs/rulesets/functions/barbacane-validate-dispatch-config.js b/docs/rulesets/functions/barbacane-validate-dispatch-config.js index bd9fdc1..da19086 100644 --- a/docs/rulesets/functions/barbacane-validate-dispatch-config.js +++ b/docs/rulesets/functions/barbacane-validate-dispatch-config.js @@ -8,6 +8,7 @@ const schemas = { provider: { type: "string" }, api_key: { type: "string", writeOnly: true }, base_url: { type: "string" }, + auth: { type: "undefined" }, timeout: { type: "integer", minimum: 1 }, models_timeout_ms: { type: "integer", minimum: 1 }, max_tokens: { type: "integer", minimum: 1 }, diff --git a/plugins/ai-proxy/Cargo.lock b/plugins/ai-proxy/Cargo.lock index 1561021..dd0d15f 100644 --- a/plugins/ai-proxy/Cargo.lock +++ b/plugins/ai-proxy/Cargo.lock @@ -24,15 +24,16 @@ dependencies = [ [[package]] name = "barbacane-plugin-macros" -version = "0.7.0" +version = "0.8.1" dependencies = [ + "proc-macro2", "quote", "syn", ] [[package]] name = "barbacane-plugin-sdk" -version = "0.7.0" +version = "0.8.1" dependencies = [ "barbacane-plugin-macros", "base64", diff --git a/plugins/ai-proxy/config-schema.json b/plugins/ai-proxy/config-schema.json index cd9ff8b..6ad3c09 100644 --- a/plugins/ai-proxy/config-schema.json +++ b/plugins/ai-proxy/config-schema.json @@ -10,6 +10,38 @@ "description": "Glob pattern (`*`, `?`, `[...]`). Matched case-sensitively against the client's `model` field. Restricted character set so vacuum surfaces nonsense like regex syntax at lint time; runtime uses the `globset` crate.", "pattern": "^[A-Za-z0-9_*?\\[\\]\\-:.+/]+$" }, + "Auth": { + "description": "Credential attachment strategy. Orthogonal to `provider` (which picks the wire protocol): the provider only supplies a default convention, which this overrides. Lets an OpenAI-compatible endpoint with a non-standard credential header be configured without a new provider — e.g. Brave AI Grounding (`X-Subscription-Token`), Azure OpenAI (`api-key`), or a key-in-query API. When omitted, defaults to `bearer` for OpenAI/Ollama and `api_key` (the `x-api-key` header) for Anthropic.", + "oneOf": [ + { + "type": "string", + "enum": ["bearer", "api_key"], + "description": "`bearer` → `Authorization: Bearer ` (OpenAI/Ollama default). `api_key` → `x-api-key: ` (Anthropic default)." + }, + { + "type": "object", + "additionalProperties": false, + "required": ["header"], + "properties": { + "header": { + "type": "string", + "description": "Arbitrary credential header name; the key is sent as its value (e.g. `X-Subscription-Token`, `api-key`)." + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["query"], + "properties": { + "query": { + "type": "string", + "description": "Query-string parameter name; the key is appended to the upstream URL as `?=` (e.g. Google Gemini's `key`)." + } + } + } + ] + }, "TargetConfig": { "type": "object", "description": "A named provider target: provider type, credentials, optional custom endpoint, and optional catalog `allow` / `deny` policy. The model identifier comes from the client request body, never from this config (ADR-0030 §0).", @@ -31,6 +63,7 @@ "format": "uri", "description": "Custom provider base URL. Defaults: OpenAI → `https://api.openai.com`, Anthropic → `https://api.anthropic.com`, Ollama → `http://localhost:11434`. Use this for Azure OpenAI, self-hosted vLLM, remote Ollama, etc." }, + "auth": { "$ref": "#/$defs/Auth" }, "allow": { "type": "array", "description": "Catalog allow-list of glob patterns. When set, the client's `model` must match at least one entry, otherwise 403 `model_not_permitted`. Combine with `deny` if needed (`deny` is evaluated after `allow`).", @@ -66,6 +99,7 @@ "type": "string", "format": "uri" }, + "auth": { "$ref": "#/$defs/Auth" }, "allow": { "type": "array", "items": { "$ref": "#/$defs/GlobPattern" } @@ -94,6 +128,7 @@ "format": "uri", "description": "Custom base URL for the flat config. Overrides the provider default." }, + "auth": { "$ref": "#/$defs/Auth" }, "timeout": { "type": "integer", "description": "Request timeout in seconds for LLM dispatch (chat completions, responses). LLM calls can take 10–60 s; set higher for long-running completions.", diff --git a/plugins/ai-proxy/src/lib.rs b/plugins/ai-proxy/src/lib.rs index 04bbce9..b03adc6 100644 --- a/plugins/ai-proxy/src/lib.rs +++ b/plugins/ai-proxy/src/lib.rs @@ -64,6 +64,38 @@ impl Provider { pub(crate) fn is_openai_compatible(&self) -> bool { matches!(self, Provider::OpenAI | Provider::Ollama) } + + /// Default credential convention for the provider. `Provider` selects the + /// wire protocol; the auth header it conventionally uses is a *separate* + /// concern that [`Auth`] can override (ADR-0030 §0 refinement). OpenAI-shape + /// providers default to bearer; Anthropic to its `x-api-key` header. + pub(crate) fn default_auth(&self) -> Auth { + match self { + Provider::Anthropic => Auth::ApiKey, + Provider::OpenAI | Provider::Ollama => Auth::Bearer, + } + } +} + +/// How the API key is attached to the upstream request. Orthogonal to +/// [`Provider`] (which picks the wire protocol): the provider only supplies a +/// *default* convention, which an explicit `auth` overrides. This lets an +/// OpenAI-compatible endpoint with a non-standard credential header be +/// configured without minting a new provider variant — e.g. Brave AI Grounding +/// (`X-Subscription-Token`), Azure OpenAI (`api-key`), or a key-in-query API. +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Auth { + /// `Authorization: Bearer ` — the OpenAI/Ollama default. + Bearer, + /// `x-api-key: ` — the Anthropic default. + ApiKey, + /// Arbitrary credential header: `: `. Covers Brave + /// `X-Subscription-Token`, Azure OpenAI `api-key`, and similar. + Header(String), + /// Credential passed in the query string: `?=` (e.g. Google + /// Gemini's `key` parameter). + Query(String), } // --------------------------------------------------------------------------- @@ -87,6 +119,10 @@ pub(crate) struct TargetConfig { /// Custom base URL (Azure, self-hosted, Ollama remote, etc.). #[serde(default)] pub base_url: Option, + /// Credential attachment strategy. When absent, defaults to the provider's + /// conventional header ([`Provider::default_auth`]). + #[serde(default)] + pub auth: Option, /// Allow-list of glob patterns. When set, the client's `model` must match /// at least one entry; otherwise 403 `model_not_permitted`. #[serde(default)] @@ -103,6 +139,14 @@ impl TargetConfig { .as_deref() .unwrap_or_else(|| self.provider.default_base_url()) } + + /// Resolved credential strategy: the explicit `auth` if set, else the + /// provider's default convention. + pub(crate) fn effective_auth(&self) -> Auth { + self.auth + .clone() + .unwrap_or_else(|| self.provider.default_auth()) + } } /// A `routes` entry: dispatch to `provider` when the client's `model` field @@ -117,6 +161,8 @@ pub(crate) struct Route { #[serde(default)] pub base_url: Option, #[serde(default)] + pub auth: Option, + #[serde(default)] pub allow: Vec, #[serde(default)] pub deny: Vec, @@ -130,6 +176,7 @@ impl Route { provider: self.provider.clone(), api_key: self.api_key.clone(), base_url: self.base_url.clone(), + auth: self.auth.clone(), allow: self.allow.clone(), deny: self.deny.clone(), } @@ -157,6 +204,10 @@ pub struct AiProxy { pub(crate) api_key: Option, #[serde(default)] pub(crate) base_url: Option, + /// Credential attachment strategy for the flat config. Defaults to the + /// provider's conventional header when absent. + #[serde(default)] + pub(crate) auth: Option, /// Request timeout in seconds for LLM dispatch (chat completions, /// responses). LLM calls can be slow; default is 120s. @@ -475,6 +526,7 @@ impl AiProxy { provider: p.clone(), api_key: self.api_key.clone(), base_url: self.base_url.clone(), + auth: self.auth.clone(), allow: Vec::new(), deny: Vec::new(), }, @@ -1027,6 +1079,7 @@ mod tests { }), api_key: Some("test-key".to_string()), base_url: None, + auth: None, timeout: 120, models_timeout_ms: 5_000, max_tokens: None, @@ -1046,6 +1099,7 @@ mod tests { provider: None, api_key: None, base_url: None, + auth: None, timeout: 120, models_timeout_ms: 5_000, max_tokens: None, @@ -1063,6 +1117,7 @@ mod tests { provider, api_key: None, base_url: None, + auth: None, allow: Vec::new(), deny: Vec::new(), } @@ -1253,6 +1308,7 @@ mod tests { provider, api_key: None, base_url: None, + auth: None, allow: Vec::new(), deny: Vec::new(), } @@ -2039,6 +2095,43 @@ mod tests { assert_eq!(t.effective_base_url(), "https://my-azure.openai.com"); } + // --- auth strategy (ADR-0030 §0 refinement) --- + + #[test] + fn auth_deserializes_unit_and_map_forms() { + assert_eq!(serde_json::from_str::(r#""bearer""#).unwrap(), Auth::Bearer); + assert_eq!(serde_json::from_str::(r#""api_key""#).unwrap(), Auth::ApiKey); + assert_eq!( + serde_json::from_str::(r#"{"header":"X-Subscription-Token"}"#).unwrap(), + Auth::Header("X-Subscription-Token".to_string()) + ); + assert_eq!( + serde_json::from_str::(r#"{"query":"key"}"#).unwrap(), + Auth::Query("key".to_string()) + ); + } + + #[test] + fn effective_auth_defaults_to_provider_convention() { + assert_eq!(target_with(Provider::OpenAI).effective_auth(), Auth::Bearer); + assert_eq!(target_with(Provider::Ollama).effective_auth(), Auth::Bearer); + assert_eq!(target_with(Provider::Anthropic).effective_auth(), Auth::ApiKey); + } + + #[test] + fn effective_auth_explicit_overrides_provider_default() { + // Brave AI Grounding: OpenAI wire protocol, but a custom credential + // header — configured without a new provider variant. + let brave = TargetConfig { + auth: Some(Auth::Header("X-Subscription-Token".to_string())), + ..target_with(Provider::OpenAI) + }; + assert_eq!( + brave.effective_auth(), + Auth::Header("X-Subscription-Token".to_string()) + ); + } + #[test] fn target_effective_base_url_default() { let t = target_with(Provider::Anthropic); diff --git a/plugins/ai-proxy/src/protocols/models.rs b/plugins/ai-proxy/src/protocols/models.rs index 74359f0..d8c6ab7 100644 --- a/plugins/ai-proxy/src/protocols/models.rs +++ b/plugins/ai-proxy/src/protocols/models.rs @@ -28,8 +28,8 @@ //! - **`schemas/ai-gateway.yaml` spec fragment** that operators drop into //! their `specs/` folder. Tracked as PR-6 in the implementation plan. -use crate::providers::openai::openai_headers; -use crate::{build_response, host, http_call, AiProxy, HttpRequest, Provider, Response}; +use crate::providers::openai::openai_base_headers; +use crate::{build_response, host, http_call, AiProxy, Auth, HttpRequest, Provider, Response}; use barbacane_plugin_sdk::prelude::*; use std::collections::{BTreeMap, BTreeSet}; @@ -119,6 +119,7 @@ struct UpstreamProvider { provider: Provider, base_url: String, api_key: Option, + auth: Option, } fn collect_unique_providers(plugin: &AiProxy) -> Vec { @@ -128,6 +129,7 @@ fn collect_unique_providers(plugin: &AiProxy) -> Vec { let push = |provider: &Provider, api_key: Option<&str>, base_url: Option<&str>, + auth: Option<&Auth>, seen: &mut BTreeSet<(String, String)>, out: &mut Vec| { let resolved_base = base_url @@ -140,6 +142,7 @@ fn collect_unique_providers(plugin: &AiProxy) -> Vec { provider: provider.clone(), base_url: resolved_base, api_key: api_key.map(String::from), + auth: auth.cloned(), }); } }; @@ -149,6 +152,7 @@ fn collect_unique_providers(plugin: &AiProxy) -> Vec { &route.provider, route.api_key.as_deref(), route.base_url.as_deref(), + route.auth.as_ref(), &mut seen, &mut out, ); @@ -158,6 +162,7 @@ fn collect_unique_providers(plugin: &AiProxy) -> Vec { &target.provider, target.api_key.as_deref(), target.base_url.as_deref(), + target.auth.as_ref(), &mut seen, &mut out, ); @@ -167,6 +172,7 @@ fn collect_unique_providers(plugin: &AiProxy) -> Vec { p, plugin.api_key.as_deref(), plugin.base_url.as_deref(), + plugin.auth.as_ref(), &mut seen, &mut out, ); @@ -190,34 +196,33 @@ fn fetch_provider_models( plugin: &AiProxy, upstream: &UpstreamProvider, ) -> Result, UpstreamFailure> { - let (url, headers) = match upstream.provider { - Provider::OpenAI => { - let url = format!("{}/v1/models", upstream.base_url.trim_end_matches('/')); - let target = synthetic_target(upstream); - (url, openai_headers(&target)) - } + let base = upstream.base_url.trim_end_matches('/'); + let (mut url, mut headers) = match upstream.provider { + Provider::OpenAI => (format!("{}/v1/models", base), openai_base_headers()), Provider::Anthropic => { - let url = format!("{}/v1/models", upstream.base_url.trim_end_matches('/')); let mut h = BTreeMap::new(); h.insert("content-type".to_string(), "application/json".to_string()); h.insert( "anthropic-version".to_string(), crate::providers::anthropic::ANTHROPIC_API_VERSION.to_string(), ); - if let Some(key) = upstream.api_key.as_deref() { - h.insert("x-api-key".to_string(), key.to_string()); - } - (url, h) - } - Provider::Ollama => { - // Ollama has no `/v1/models`; the OpenAI-compat surface uses - // `/api/tags`, which we translate to OpenAI list shape below. - ( - format!("{}/api/tags", upstream.base_url.trim_end_matches('/')), - BTreeMap::new(), - ) + (format!("{}/v1/models", base), h) } + // Ollama has no `/v1/models`; the OpenAI-compat surface uses + // `/api/tags`, which we translate to OpenAI list shape below. It's an + // unauthenticated local endpoint, so no credential is attached. + Provider::Ollama => (format!("{}/api/tags", base), BTreeMap::new()), }; + if !matches!(upstream.provider, Provider::Ollama) { + if let Some(key) = upstream.api_key.as_deref() { + crate::providers::apply_auth( + &synthetic_target(upstream).effective_auth(), + key, + &mut headers, + &mut url, + ); + } + } let req = HttpRequest { method: "GET".to_string(), @@ -254,12 +259,14 @@ fn fetch_provider_models( } /// Build a synthetic [`crate::TargetConfig`] from an [`UpstreamProvider`] so -/// we can reuse [`openai_headers`] without duplicating the auth-header logic. +/// we can reuse [`crate::providers::apply_auth`] without duplicating the +/// auth-attachment logic. fn synthetic_target(upstream: &UpstreamProvider) -> crate::TargetConfig { crate::TargetConfig { provider: upstream.provider.clone(), api_key: upstream.api_key.clone(), base_url: Some(upstream.base_url.clone()), + auth: upstream.auth.clone(), allow: Vec::new(), deny: Vec::new(), } @@ -337,6 +344,7 @@ mod tests { provider: None, api_key: None, base_url: None, + auth: None, timeout: 120, models_timeout_ms: 5_000, max_tokens: None, @@ -353,6 +361,7 @@ mod tests { provider, api_key: None, base_url: base_url.map(String::from), + auth: None, allow: Vec::new(), deny: Vec::new(), } @@ -364,6 +373,7 @@ mod tests { provider, api_key: None, base_url: Some(base_url.to_string()), + auth: None, allow: Vec::new(), deny: Vec::new(), } diff --git a/plugins/ai-proxy/src/protocols/responses.rs b/plugins/ai-proxy/src/protocols/responses.rs index ec6e22f..fdc3c16 100644 --- a/plugins/ai-proxy/src/protocols/responses.rs +++ b/plugins/ai-proxy/src/protocols/responses.rs @@ -20,7 +20,7 @@ //! path (mirrors ADR-0024 Chat Completions until true SSE translation lands). //! The OpenAI passthrough streams normally via `host_http_stream`. -use crate::providers::openai::{openai_headers, openai_url}; +use crate::providers::openai::{openai_base_headers, openai_url}; use crate::{ error_response, host, host_http_stream, http_call, AiProxy, HttpRequest, Provider, Response, TargetConfig, @@ -72,10 +72,7 @@ impl ResponsesPreflight { // OpenAI defaults `store` to true server-side. Treat `Some(true)` and // a missing field as downgrade-required; only an explicit `store: false` // skips the warning. - let store_downgrade = match v.get("store") { - Some(serde_json::Value::Bool(false)) => false, - _ => true, - }; + let store_downgrade = !matches!(v.get("store"), Some(serde_json::Value::Bool(false))); Ok(Self { store_downgrade }) } @@ -177,8 +174,11 @@ fn openai_passthrough( req: &Request, streaming: bool, ) -> Result { - let url = openai_url(target, &req.path); - let mut headers = openai_headers(target); + let mut url = openai_url(target, &req.path); + let mut headers = openai_base_headers(); + if let Some(key) = &target.api_key { + crate::providers::apply_auth(&target.effective_auth(), key, &mut headers, &mut url); + } if streaming { headers.insert("accept".to_string(), "text/event-stream".to_string()); } diff --git a/plugins/ai-proxy/src/providers/anthropic.rs b/plugins/ai-proxy/src/providers/anthropic.rs index d7eb13e..2c7c3f5 100644 --- a/plugins/ai-proxy/src/providers/anthropic.rs +++ b/plugins/ai-proxy/src/providers/anthropic.rs @@ -26,7 +26,7 @@ impl AiProxy { body: &[u8], ) -> Result { let base = target.effective_base_url().trim_end_matches('/'); - let url = format!("{}/v1/messages", base); + let mut url = format!("{}/v1/messages", base); let mut headers = BTreeMap::new(); headers.insert("content-type".to_string(), "application/json".to_string()); @@ -35,7 +35,7 @@ impl AiProxy { ANTHROPIC_API_VERSION.to_string(), ); if let Some(key) = &target.api_key { - headers.insert("x-api-key".to_string(), key.clone()); + super::apply_auth(&target.effective_auth(), key, &mut headers, &mut url); } set_http_request_body(body); diff --git a/plugins/ai-proxy/src/providers/mod.rs b/plugins/ai-proxy/src/providers/mod.rs index 15e13f6..edf4d3e 100644 --- a/plugins/ai-proxy/src/providers/mod.rs +++ b/plugins/ai-proxy/src/providers/mod.rs @@ -8,3 +8,88 @@ pub mod anthropic; pub mod ollama; pub mod openai; + +use crate::Auth; +use std::collections::BTreeMap; + +/// Attach the credential to an outbound request per the target's [`Auth`] +/// strategy. The single source of auth-attachment logic shared by every +/// transport (OpenAI, Anthropic, and the `/v1/models` aggregator) so a new +/// credential convention is added in one place, not three. +/// +/// Header names are lowercased to match the transports' canonical header map +/// (the host canonicalizes on the wire, and HTTP header names are +/// case-insensitive per RFC 9110 §5.1). Query auth appends to the URL, +/// preserving any pre-existing query string. +pub(crate) fn apply_auth( + auth: &Auth, + key: &str, + headers: &mut BTreeMap, + url: &mut String, +) { + match auth { + Auth::Bearer => { + headers.insert("authorization".to_string(), format!("Bearer {}", key)); + } + Auth::ApiKey => { + headers.insert("x-api-key".to_string(), key.to_string()); + } + Auth::Header(name) => { + headers.insert(name.to_ascii_lowercase(), key.to_string()); + } + Auth::Query(param) => { + let sep = if url.contains('?') { '&' } else { '?' }; + url.push(sep); + url.push_str(param); + url.push('='); + url.push_str(key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn apply(auth: Auth, url: &str) -> (BTreeMap, String) { + let mut headers = BTreeMap::new(); + let mut u = url.to_string(); + apply_auth(&auth, "SECRET", &mut headers, &mut u); + (headers, u) + } + + #[test] + fn bearer_sets_authorization_header() { + let (h, u) = apply(Auth::Bearer, "https://x/v1/chat/completions"); + assert_eq!(h.get("authorization").map(String::as_str), Some("Bearer SECRET")); + assert_eq!(u, "https://x/v1/chat/completions"); + } + + #[test] + fn api_key_sets_x_api_key_header() { + let (h, _) = apply(Auth::ApiKey, "https://x"); + assert_eq!(h.get("x-api-key").map(String::as_str), Some("SECRET")); + } + + #[test] + fn header_variant_lowercases_name() { + // Brave: X-Subscription-Token. Case is normalized to the canonical + // lowercase header map; HTTP header names are case-insensitive. + let (h, _) = apply(Auth::Header("X-Subscription-Token".to_string()), "https://x"); + assert_eq!(h.get("x-subscription-token").map(String::as_str), Some("SECRET")); + assert!(h.get("authorization").is_none()); + } + + #[test] + fn query_appends_to_url_preserving_existing_query() { + let (h, u) = apply(Auth::Query("key".to_string()), "https://x/models?alt=json"); + assert!(h.is_empty()); + assert_eq!(u, "https://x/models?alt=json&key=SECRET"); + } + + #[test] + fn query_adds_question_mark_when_no_existing_query() { + let (_, u) = apply(Auth::Query("key".to_string()), "https://x/models"); + assert_eq!(u, "https://x/models?key=SECRET"); + } +} diff --git a/plugins/ai-proxy/src/providers/openai.rs b/plugins/ai-proxy/src/providers/openai.rs index 652affb..1a09c04 100644 --- a/plugins/ai-proxy/src/providers/openai.rs +++ b/plugins/ai-proxy/src/providers/openai.rs @@ -15,8 +15,11 @@ impl AiProxy { target: &TargetConfig, req: &Request, ) -> Result { - let url = openai_url(target, &req.path); - let headers = openai_headers(target); + let mut url = openai_url(target, &req.path); + let mut headers = openai_base_headers(); + if let Some(key) = &target.api_key { + super::apply_auth(&target.effective_auth(), key, &mut headers, &mut url); + } let body = self.maybe_inject_max_tokens(&req.body); if let Some(ref b) = body { @@ -39,8 +42,11 @@ impl AiProxy { target: &TargetConfig, req: &Request, ) -> Result { - let url = openai_url(target, &req.path); - let mut headers = openai_headers(target); + let mut url = openai_url(target, &req.path); + let mut headers = openai_base_headers(); + if let Some(key) = &target.api_key { + super::apply_auth(&target.effective_auth(), key, &mut headers, &mut url); + } // Ensure Accept header for SSE headers.insert("accept".to_string(), "text/event-stream".to_string()); @@ -94,11 +100,11 @@ pub(crate) fn openai_url(target: &TargetConfig, req_path: &str) -> String { format!("{}{}", base, req_path) } -pub(crate) fn openai_headers(target: &TargetConfig) -> BTreeMap { +/// Base headers common to every OpenAI-compatible request. Credential +/// attachment is applied separately via [`super::apply_auth`] at the call site, +/// so `Auth::Query` can also reach the URL. +pub(crate) fn openai_base_headers() -> BTreeMap { let mut headers = BTreeMap::new(); headers.insert("content-type".to_string(), "application/json".to_string()); - if let Some(key) = &target.api_key { - headers.insert("authorization".to_string(), format!("Bearer {}", key)); - } headers }