Skip to content
Open
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
68 changes: 68 additions & 0 deletions aider/args.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env python

import argparse
import json
import os
import sys
from pathlib import Path
Expand Down Expand Up @@ -32,6 +33,63 @@ def default_env_file(git_root):
return os.path.join(git_root, ".env") if git_root else ".env"


def parse_custom_headers(header_values):
"""Parse --custom-headers values into a dict of HTTP headers.

Each item may be a KEY=VALUE / KEY:VALUE pair or a JSON object string.
A dict is returned as-is (with keys/values stringified).
"""
headers = {}
if not header_values:
return headers

if isinstance(header_values, dict):
return {str(k): str(v) for k, v in header_values.items()}

if isinstance(header_values, str):
header_values = [header_values]

for raw in header_values:
if raw is None:
continue
if isinstance(raw, dict):
headers.update({str(k): str(v) for k, v in raw.items()})
continue

item = str(raw).strip()
if not item:
continue

if item.startswith("{"):
try:
parsed = json.loads(item)
except json.JSONDecodeError as err:
raise ValueError(f"Invalid JSON in --custom-headers: {err}") from err
if not isinstance(parsed, dict):
raise ValueError(
"--custom-headers JSON must be an object of header name/value pairs"
)
headers.update({str(k): str(v) for k, v in parsed.items()})
continue

if "=" in item:
key, value = item.split("=", 1)
elif ":" in item:
key, value = item.split(":", 1)
else:
raise ValueError(
f"Invalid --custom-headers format: {raw}."
" Use KEY=VALUE, KEY:VALUE, or a JSON object."
)

key = key.strip()
if not key:
raise ValueError(f"Invalid --custom-headers format: {raw}")
headers[key] = value.strip()

return headers


def get_parser(default_config_files, git_root):
parser = configargparse.ArgumentParser(
description="aider is AI pair programming in your terminal",
Expand Down Expand Up @@ -77,6 +135,16 @@ def get_parser(default_config_files, git_root):
"--openai-api-base",
help="Specify the api base url",
)
group.add_argument(
"--custom-headers",
action="append",
metavar="HEADER",
help=(
"Add custom HTTP headers for OpenAI-compatible/BYOK endpoints. Accepts KEY=VALUE,"
" KEY:VALUE, or a JSON object. Can be used multiple times"
),
default=[],
)
group.add_argument(
"--openai-api-type",
help="(deprecated, use --set-env OPENAI_API_TYPE=<value>)",
Expand Down
33 changes: 28 additions & 5 deletions aider/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,13 @@ def fetch_openrouter_model_info(self, model):

class Model(ModelSettings):
def __init__(
self, model, weak_model=None, editor_model=None, editor_edit_format=None, verbose=False
self,
model,
weak_model=None,
editor_model=None,
editor_edit_format=None,
verbose=False,
extra_headers=None,
):
# Map any alias to its canonical name
model = MODEL_ALIASES.get(model, model)
Expand Down Expand Up @@ -358,15 +364,16 @@ def __init__(
self.max_chat_history_tokens = min(max(max_input_tokens / 16, 1024), 8192)

self.configure_model_settings(model)
self.apply_extra_headers(extra_headers)
if weak_model is False:
self.weak_model_name = None
else:
self.get_weak_model(weak_model)
self.get_weak_model(weak_model, extra_headers=extra_headers)

if editor_model is False:
self.editor_model_name = None
else:
self.get_editor_model(editor_model, editor_edit_format)
self.get_editor_model(editor_model, editor_edit_format, extra_headers=extra_headers)

def get_model_info(self, model):
return model_info_manager.get_model_info(model)
Expand Down Expand Up @@ -600,7 +607,21 @@ def apply_generic_model_settings(self, model):
def __str__(self):
return self.name

def get_weak_model(self, provided_weak_model_name):
def apply_extra_headers(self, extra_headers):
"""Merge extra HTTP headers into LiteLLM extra_params for API requests."""
if not extra_headers:
return

if not self.extra_params:
self.extra_params = {}
else:
self.extra_params = dict(self.extra_params)

merged = dict(self.extra_params.get("extra_headers") or {})
merged.update(extra_headers)
self.extra_params["extra_headers"] = merged

def get_weak_model(self, provided_weak_model_name, extra_headers=None):
# If weak_model_name is provided, override the model settings
if provided_weak_model_name:
self.weak_model_name = provided_weak_model_name
Expand All @@ -616,13 +637,14 @@ def get_weak_model(self, provided_weak_model_name):
self.weak_model = Model(
self.weak_model_name,
weak_model=False,
extra_headers=extra_headers,
)
return self.weak_model

def commit_message_models(self):
return [self.weak_model, self]

def get_editor_model(self, provided_editor_model_name, editor_edit_format):
def get_editor_model(self, provided_editor_model_name, editor_edit_format, extra_headers=None):
# If editor_model_name is provided, override the model settings
if provided_editor_model_name:
self.editor_model_name = provided_editor_model_name
Expand All @@ -635,6 +657,7 @@ def get_editor_model(self, provided_editor_model_name, editor_edit_format):
self.editor_model = Model(
self.editor_model_name,
editor_model=False,
extra_headers=extra_headers,
)

if not self.editor_edit_format:
Expand Down
50 changes: 50 additions & 0 deletions tests/basic/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,56 @@ def test_gpt_5_5_model_settings(self):
self.assertEqual(model.editor_model.name, editor_name)
self.assertIn("reasoning_effort", model.accepts_settings)

def test_parse_custom_headers_key_value_and_json(self):
from aider.args import parse_custom_headers

self.assertEqual(parse_custom_headers(["X-Api-Key=secret"]), {"X-Api-Key": "secret"})
self.assertEqual(
parse_custom_headers(["X-Tenant: acme", "X-Trace=abc"]),
{"X-Tenant": "acme", "X-Trace": "abc"},
)
self.assertEqual(
parse_custom_headers(['{"Authorization": "Bearer tok", "X-Org": "42"}']),
{"Authorization": "Bearer tok", "X-Org": "42"},
)
self.assertEqual(
parse_custom_headers(["X-Foo=bar", '{"X-Bar": "baz"}']),
{"X-Foo": "bar", "X-Bar": "baz"},
)

@patch("aider.models.litellm.completion")
def test_custom_headers_applied_when_initializing_model_client(self, mock_completion):
headers = {"X-Custom-Auth": "token-123", "X-Org-Id": "org-9"}
model = Model("gpt-4", extra_headers=headers, weak_model=False)

self.assertEqual(model.extra_params["extra_headers"]["X-Custom-Auth"], "token-123")
self.assertEqual(model.extra_params["extra_headers"]["X-Org-Id"], "org-9")

messages = [{"role": "user", "content": "Hello"}]
model.send_completion(messages, functions=None, stream=False)

mock_completion.assert_called_once()
kwargs = mock_completion.call_args.kwargs
self.assertEqual(kwargs["extra_headers"]["X-Custom-Auth"], "token-123")
self.assertEqual(kwargs["extra_headers"]["X-Org-Id"], "org-9")
self.assertEqual(kwargs["model"], model.name)

@patch("aider.models.litellm.completion")
def test_custom_headers_merge_with_existing_extra_headers(self, mock_completion):
model = Model(
"claude-3-5-sonnet-20240620",
extra_headers={"X-Gateway": "byok"},
weak_model=False,
)
extra_headers = model.extra_params["extra_headers"]
self.assertEqual(extra_headers["X-Gateway"], "byok")
self.assertEqual(extra_headers["anthropic-beta"], ANTHROPIC_BETA_HEADER)

model.send_completion([{"role": "user", "content": "Hi"}], functions=None, stream=False)
sent = mock_completion.call_args.kwargs["extra_headers"]
self.assertEqual(sent["X-Gateway"], "byok")
self.assertEqual(sent["anthropic-beta"], ANTHROPIC_BETA_HEADER)


if __name__ == "__main__":
unittest.main()