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
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ classifiers = [
]

dependencies = [
"llm-rosetta[gateway]>=0.12.0",
# Bridge needs the argo--openai_responses shim and
# server.prefer_same_format, both released in 0.14.
"llm-rosetta[gateway]>=0.14",
]

[project.optional-dependencies]
Expand Down
24 changes: 19 additions & 5 deletions src/argoproxy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,7 @@ async def _argo_proxy_handler(
# Tag CLI log lines with the resolved user (contextvar-based)
user_token = set_request_user(effective_user)

# Anthropic metadata.user_id injection
if target_provider == "anthropic":
body.setdefault("metadata", {})
if isinstance(body["metadata"], dict):
body["metadata"]["user_id"] = effective_user
_apply_identity(body, effective_user, target_provider)

# Determine streaming with anthropic_stream_mode
is_stream = force_stream or detect_stream_request(source_provider, body)
Expand Down Expand Up @@ -415,6 +411,24 @@ def _find_gateway_model(
return None


def _apply_identity(
body: dict[str, Any], effective_user: str, target_provider: str
) -> None:
"""Stamp the resolved ARGO user onto *body*, in place.

ARGO treats a body-level ``user`` as an override of the bearer, so a
client-supplied one is *replaced*, not defaulted -- otherwise a
passthrough client could attribute requests to another ANL user.
Anthropic bodies have no ``user`` field and use metadata instead.
"""
if target_provider == "anthropic":
body.setdefault("metadata", {})
if isinstance(body["metadata"], dict):
body["metadata"]["user_id"] = effective_user
else:
body["user"] = effective_user


def _build_auth_override(provider_info: Any, username: str) -> dict[str, str]:
"""Build auth headers that override the default ProviderInfo credentials.

Expand Down
36 changes: 29 additions & 7 deletions src/argoproxy/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ def build_gateway_config(
) -> GatewayConfig:
"""Build a :class:`GatewayConfig` from ARGO's config and model registry.

The resulting config has two providers ("argo-anthropic" and
"argo-openai") backed by the ARGO upstream URLs, and a model table
built from the live :class:`ModelRegistry`.
The resulting config has three providers ("argo-anthropic", "argo-openai",
and "argo-openai-responses") backed by the ARGO upstream URLs, and a model
table built from the live :class:`ModelRegistry`.

All GPT models get both OpenAI providers so ``prefer_same_format`` can
route ``/v1/responses`` to ARGO's own Responses endpoint unconverted.
"""
providers = _build_providers(argo_config)
models = _build_models(model_registry)
Expand All @@ -43,6 +46,8 @@ def build_gateway_config(
"server": {
"host": argo_config.host,
"port": argo_config.port,
# Prefer the provider already speaking the client's wire format.
"prefer_same_format": True,
},
"debug": {
"verbose": argo_config.verbose,
Expand Down Expand Up @@ -73,6 +78,13 @@ def _build_providers(config: ArgoConfig) -> dict:
"base_url": config.native_anthropic_base_url,
"readonly": True,
},
# Same host as argo-openai -- only the wire format differs.
"argo-openai-responses": {
"shim": "argo--openai_responses",
"api_key": config.user,
"base_url": config.native_openai_base_url,
"readonly": True,
},
}


Expand All @@ -90,10 +102,20 @@ def _build_models(registry: ModelRegistry) -> dict:
capabilities = ["embedding"]
else:
capabilities = ["text", "vision", "tools", "reasoning"]
entry: dict = {
"provider": provider_name,
"capabilities": capabilities,
}
entry: dict
if family == "openai" and alias not in embed_models:
# All GPT models serve ARGO's Responses endpoint natively.
# A source matching neither dialect round-robins across the
# pair -- see tests.
entry = {
"providers": [provider_name, "argo-openai-responses"],
"capabilities": capabilities,
}
else:
entry = {
"provider": provider_name,
"capabilities": capabilities,
}
if alias in embed_models:
entry["type"] = "embedding"
if model_id != alias:
Expand Down
167 changes: 167 additions & 0 deletions tests/test_bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Tests for the ArgoConfig -> GatewayConfig bridge."""

from __future__ import annotations

from types import SimpleNamespace

from argoproxy.bridge import (
_build_models,
_build_providers,
build_gateway_config,
rebuild_gateway_models,
)

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

MODELS = {
"gpt-5": "gpt5",
"gpt-5.6-sol": "gpt5.6-sol",
"claude-opus-4-6": "claudeopus4.6",
"gemini-2.5-pro": "gemini25pro",
"text-embedding-3-small": "v3small",
}
EMBED_MODELS = {"text-embedding-3-small"}


class FakeConfig:
host = "127.0.0.1"
port = 44497
socket = ""
user = "test-user"
verbose = False
dump_requests = False
data_dir = ""
native_openai_base_url = "https://example.com/v1"
native_anthropic_base_url = "https://example.com"


def fake_registry() -> SimpleNamespace:
return SimpleNamespace(
available_models=dict(MODELS),
available_embed_models=set(EMBED_MODELS),
)


# ---------------------------------------------------------------------------
# Provider table
# ---------------------------------------------------------------------------


class TestBuildProviders:
def test_has_all_three_providers(self):
providers = _build_providers(FakeConfig())
assert set(providers) == {
"argo-openai",
"argo-anthropic",
"argo-openai-responses",
}
responses = providers["argo-openai-responses"]
assert responses["shim"] == "argo--openai_responses"
assert responses["base_url"] == providers["argo-openai"]["base_url"]


# ---------------------------------------------------------------------------
# Model table
# ---------------------------------------------------------------------------


class TestBuildModels:
def test_gpt_models_get_both_providers(self):
models = _build_models(fake_registry())
for alias in ("gpt-5", "gpt-5.6-sol"):
assert models[alias]["providers"] == [
"argo-openai",
"argo-openai-responses",
]
assert "provider" not in models[alias]

def test_claude_and_gemini_keep_single_provider(self):
models = _build_models(fake_registry())
assert models["claude-opus-4-6"]["provider"] == "argo-anthropic"
assert models["gemini-2.5-pro"]["provider"] == "argo-openai"
assert "providers" not in models["claude-opus-4-6"]
assert "providers" not in models["gemini-2.5-pro"]

def test_embedding_model_keeps_single_provider(self):
models = _build_models(fake_registry())
entry = models["text-embedding-3-small"]
assert entry["provider"] == "argo-openai"
assert entry["type"] == "embedding"
assert "providers" not in entry

def test_upstream_model_preserved(self):
models = _build_models(fake_registry())
assert models["gpt-5"]["upstream_model"] == "gpt5"
assert models["claude-opus-4-6"]["upstream_model"] == "claudeopus4.6"


# ---------------------------------------------------------------------------
# Server flag
# ---------------------------------------------------------------------------


class TestPreferSameFormat:
def test_enabled(self):
gc = build_gateway_config(FakeConfig(), fake_registry())
assert gc.prefer_same_format is True


# ---------------------------------------------------------------------------
# Resolution
# ---------------------------------------------------------------------------


class TestResolution:
def test_responses_source_reaches_responses_provider(self):
gc = build_gateway_config(FakeConfig(), fake_registry())
route, _info = gc.resolve("openai_responses", "gpt-5")
assert route.provider_name == "argo-openai-responses"
assert route.target_provider == "openai_responses"

def test_chat_source_reaches_chat_provider(self):
gc = build_gateway_config(FakeConfig(), fake_registry())
route, _info = gc.resolve("openai_chat", "gpt-5")
assert route.provider_name == "argo-openai"
assert route.target_provider == "openai_chat"

def test_claude_unaffected_by_the_extra_provider(self):
gc = build_gateway_config(FakeConfig(), fake_registry())
for source in ("openai_responses", "openai_chat", "anthropic"):
route, _info = gc.resolve(source, "claude-opus-4-6")
assert route.provider_name == "argo-anthropic"

def test_gemini_never_reaches_the_responses_provider(self):
gc = build_gateway_config(FakeConfig(), fake_registry())
for source in ("openai_responses", "openai_chat"):
route, _info = gc.resolve(source, "gemini-2.5-pro")
assert route.provider_name == "argo-openai"

def test_embeddings_route_outside_the_chat_table(self):
gc = build_gateway_config(FakeConfig(), fake_registry())
assert "text-embedding-3-small" not in gc.models
assert gc.embedding_models["text-embedding-3-small"] == "argo-openai"

def test_unmatched_source_round_robins_documented_limitation(self):
"""Reachable only via /v1/messages with a gpt* model.

Both conversions work, so this picks between two working paths.
Pinned so an upstream change surfaces here.
"""
gc = build_gateway_config(FakeConfig(), fake_registry())
seen = {gc.resolve("anthropic", "gpt-5")[0].provider_name for _ in range(20)}
assert seen == {"argo-openai", "argo-openai-responses"}

def test_rebuild_preserves_dual_provider_entries(self):
gc = build_gateway_config(FakeConfig(), fake_registry())
registry = fake_registry()
registry.available_models["gpt-6"] = "gpt6"

rebuild_gateway_models(gc, registry)

assert "gpt-6" in gc.models
route, _info = gc.resolve("openai_responses", "gpt-6")
assert route.provider_name == "argo-openai-responses"
route, _info = gc.resolve("openai_chat", "gpt-5")
assert route.provider_name == "argo-openai"
44 changes: 44 additions & 0 deletions tests/test_identity_injection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Tests for _apply_identity — body `user` handling on the proxy path."""

from argoproxy.app import _apply_identity


class TestApplyIdentity:
def test_sets_user_when_absent(self):
body = {"model": "gpt5", "input": "hi"}
_apply_identity(body, "brettin", "openai_chat")
assert body["user"] == "brettin"

def test_overwrites_client_supplied_user(self):
"""Defaulting would leave the spoofed value."""
body = {"model": "gpt5", "user": "someone-else"}
_apply_identity(body, "brettin", "openai_chat")
assert body["user"] == "brettin"

def test_overwrites_on_the_responses_route(self):
body = {"model": "gpt5", "user": "someone-else", "input": []}
_apply_identity(body, "brettin", "openai_responses")
assert body["user"] == "brettin"

def test_anthropic_target_gets_metadata_not_user(self):
body = {"model": "claudeopus4.6", "messages": []}
_apply_identity(body, "brettin", "anthropic")
assert body["metadata"]["user_id"] == "brettin"
assert "user" not in body

def test_anthropic_target_overwrites_client_user_id(self):
body = {"model": "claudeopus4.6", "metadata": {"user_id": "someone-else"}}
_apply_identity(body, "brettin", "anthropic")
assert body["metadata"]["user_id"] == "brettin"

def test_anthropic_target_tolerates_non_dict_metadata(self):
body = {"model": "claudeopus4.6", "metadata": "nonsense"}
_apply_identity(body, "brettin", "anthropic")
assert body["metadata"] == "nonsense"

def test_leaves_other_fields_alone(self):
body = {"model": "gpt5", "input": "hi", "temperature": 0.5}
_apply_identity(body, "brettin", "openai_chat")
assert body["model"] == "gpt5"
assert body["input"] == "hi"
assert body["temperature"] == 0.5