Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 33 additions & 6 deletions docs/guide/dispatchers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
5 changes: 3 additions & 2 deletions plugins/ai-proxy/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions plugins/ai-proxy/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>` (OpenAI/Ollama default). `api_key` → `x-api-key: <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 `?<param>=<key>` (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).",
Expand All @@ -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`).",
Expand Down Expand Up @@ -66,6 +99,7 @@
"type": "string",
"format": "uri"
},
"auth": { "$ref": "#/$defs/Auth" },
"allow": {
"type": "array",
"items": { "$ref": "#/$defs/GlobPattern" }
Expand Down Expand Up @@ -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.",
Expand Down
93 changes: 93 additions & 0 deletions plugins/ai-proxy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>` — the OpenAI/Ollama default.
Bearer,
/// `x-api-key: <key>` — the Anthropic default.
ApiKey,
/// Arbitrary credential header: `<name>: <key>`. Covers Brave
/// `X-Subscription-Token`, Azure OpenAI `api-key`, and similar.
Header(String),
/// Credential passed in the query string: `?<param>=<key>` (e.g. Google
/// Gemini's `key` parameter).
Query(String),
}

// ---------------------------------------------------------------------------
Expand All @@ -87,6 +119,10 @@ pub(crate) struct TargetConfig {
/// Custom base URL (Azure, self-hosted, Ollama remote, etc.).
#[serde(default)]
pub base_url: Option<String>,
/// Credential attachment strategy. When absent, defaults to the provider's
/// conventional header ([`Provider::default_auth`]).
#[serde(default)]
pub auth: Option<Auth>,
/// Allow-list of glob patterns. When set, the client's `model` must match
/// at least one entry; otherwise 403 `model_not_permitted`.
#[serde(default)]
Expand All @@ -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
Expand All @@ -117,6 +161,8 @@ pub(crate) struct Route {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub auth: Option<Auth>,
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
Expand All @@ -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(),
}
Expand Down Expand Up @@ -157,6 +204,10 @@ pub struct AiProxy {
pub(crate) api_key: Option<String>,
#[serde(default)]
pub(crate) base_url: Option<String>,
/// Credential attachment strategy for the flat config. Defaults to the
/// provider's conventional header when absent.
#[serde(default)]
pub(crate) auth: Option<Auth>,

/// Request timeout in seconds for LLM dispatch (chat completions,
/// responses). LLM calls can be slow; default is 120s.
Expand Down Expand Up @@ -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(),
},
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -1063,6 +1117,7 @@ mod tests {
provider,
api_key: None,
base_url: None,
auth: None,
allow: Vec::new(),
deny: Vec::new(),
}
Expand Down Expand Up @@ -1253,6 +1308,7 @@ mod tests {
provider,
api_key: None,
base_url: None,
auth: None,
allow: Vec::new(),
deny: Vec::new(),
}
Expand Down Expand Up @@ -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::<Auth>(r#""bearer""#).unwrap(), Auth::Bearer);
assert_eq!(serde_json::from_str::<Auth>(r#""api_key""#).unwrap(), Auth::ApiKey);
assert_eq!(
serde_json::from_str::<Auth>(r#"{"header":"X-Subscription-Token"}"#).unwrap(),
Auth::Header("X-Subscription-Token".to_string())
);
assert_eq!(
serde_json::from_str::<Auth>(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);
Expand Down
Loading