fix(vision): forward custom-endpoint credentials in vision auto-detect

A custom:<name> main provider resolves at runtime to the bare provider id
"custom". In the vision auto-detect chain, the main-provider branch called
resolve_provider_client("custom", ...) WITHOUT explicit_base_url/api_key,
so it returned (None, None) ("no endpoint credentials found") and the whole
chain fell through to OpenRouter/Nous. A user on a custom endpoint with no
aggregator configured then got "No LLM provider configured for task=vision
provider=auto" on every image, even though their main model fully supports
vision.

Recover the live endpoint that set_runtime_main() records each turn
(_RUNTIME_MAIN_BASE_URL/_API_KEY/_API_MODE) and forward it to Step 1, with
a fallback to _resolve_custom_runtime() for non-gateway callers. Mirrors the
existing explicit-base_url branch directly above.

Adds TestResolveVisionCustomProvider covering custom, custom:<name>, and the
no-runtime fallback path.
This commit is contained in:
Jacky Zeng 2026-06-22 15:37:42 +08:00 committed by Teknium
parent 8bf797f1c2
commit 25aa626cb4
2 changed files with 145 additions and 1 deletions

View file

@ -543,6 +543,124 @@ class TestResolveVisionMainFirst:
mock_strict.assert_called_once_with("nous", None)
# ── Vision — custom provider endpoint credential passthrough ────────────────
class TestResolveVisionCustomProvider:
"""Custom-endpoint mains must forward base_url/api_key to Step 1.
Regression: a ``custom:<name>`` main provider resolves to the bare
runtime provider id ``"custom"``. ``resolve_provider_client("custom")``
has no built-in endpoint, so without forwarding the live base_url/api_key
it returns ``(None, None)`` and vision falls through to OpenRouter / Nous,
which an offline / aggregator-less user has never configured breaking
vision entirely with ``No LLM provider configured for task=vision
provider=auto``. The fix recovers the live endpoint that
``set_runtime_main()`` recorded for the turn.
"""
def test_custom_main_forwards_runtime_endpoint(self, monkeypatch):
"""custom main with recorded runtime endpoint → Step 1 builds a client."""
import agent.auxiliary_client as aux
monkeypatch.setattr(aux, "_RUNTIME_MAIN_BASE_URL", "https://my.endpoint.example/v1")
monkeypatch.setattr(aux, "_RUNTIME_MAIN_API_KEY", "sk-runtime-key")
monkeypatch.setattr(aux, "_RUNTIME_MAIN_API_MODE", "anthropic_messages")
with patch(
"agent.auxiliary_client._read_main_provider", return_value="custom",
), patch(
"agent.auxiliary_client._read_main_model", return_value="claude-opus-4-8",
), patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", None, None, None, None),
), patch(
"agent.auxiliary_client.resolve_provider_client"
) as mock_resolve:
mock_client = MagicMock()
mock_resolve.return_value = (mock_client, "claude-opus-4-8")
from agent.auxiliary_client import resolve_vision_provider_client
provider, client, model = resolve_vision_provider_client()
assert provider == "custom"
assert client is mock_client
assert model == "claude-opus-4-8"
# The endpoint credentials recorded for the turn MUST be forwarded,
# otherwise resolve_provider_client("custom") returns (None, None).
kwargs = mock_resolve.call_args.kwargs
assert kwargs.get("explicit_base_url") == "https://my.endpoint.example/v1"
assert kwargs.get("explicit_api_key") == "sk-runtime-key"
assert kwargs.get("is_vision") is True
def test_custom_prefixed_main_forwards_runtime_endpoint(self, monkeypatch):
"""A ``custom:<name>`` provider id also forwards the runtime endpoint."""
import agent.auxiliary_client as aux
monkeypatch.setattr(aux, "_RUNTIME_MAIN_BASE_URL", "https://named.example/v1")
monkeypatch.setattr(aux, "_RUNTIME_MAIN_API_KEY", "sk-named")
monkeypatch.setattr(aux, "_RUNTIME_MAIN_API_MODE", "")
with patch(
"agent.auxiliary_client._read_main_provider",
return_value="custom:copilot-gateway",
), patch(
"agent.auxiliary_client._read_main_model", return_value="claude-opus-4-8",
), patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", None, None, None, None),
), patch(
"agent.auxiliary_client.resolve_provider_client"
) as mock_resolve:
mock_client = MagicMock()
mock_resolve.return_value = (mock_client, "claude-opus-4-8")
from agent.auxiliary_client import resolve_vision_provider_client
provider, client, model = resolve_vision_provider_client()
assert provider == "custom:copilot-gateway"
assert client is mock_client
kwargs = mock_resolve.call_args.kwargs
assert kwargs.get("explicit_base_url") == "https://named.example/v1"
assert kwargs.get("explicit_api_key") == "sk-named"
assert kwargs.get("is_vision") is True
def test_custom_main_no_runtime_falls_back_to_configured_endpoint(self, monkeypatch):
"""No recorded runtime endpoint → resolve the configured custom endpoint."""
import agent.auxiliary_client as aux
monkeypatch.setattr(aux, "_RUNTIME_MAIN_BASE_URL", "")
monkeypatch.setattr(aux, "_RUNTIME_MAIN_API_KEY", "")
monkeypatch.setattr(aux, "_RUNTIME_MAIN_API_MODE", "")
with patch(
"agent.auxiliary_client._read_main_provider", return_value="custom",
), patch(
"agent.auxiliary_client._read_main_model", return_value="claude-opus-4-8",
), patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", None, None, None, None),
), patch(
"agent.auxiliary_client._resolve_custom_runtime",
return_value=("https://configured.example/v1", "sk-configured", "chat_completions"),
), patch(
"agent.auxiliary_client.resolve_provider_client"
) as mock_resolve:
mock_client = MagicMock()
mock_resolve.return_value = (mock_client, "claude-opus-4-8")
from agent.auxiliary_client import resolve_vision_provider_client
provider, client, model = resolve_vision_provider_client()
assert client is mock_client
kwargs = mock_resolve.call_args.kwargs
assert kwargs.get("explicit_base_url") == "https://configured.example/v1"
assert kwargs.get("explicit_api_key") == "sk-configured"
# ── Constant cleanup ────────────────────────────────────────────────────────