From 5965e8f341ccb77f7ae6f31b263b7bbd49c11c7e Mon Sep 17 00:00:00 2001 From: "hanxie.wq" Date: Mon, 7 Sep 2026 14:38:21 +0800 Subject: [PATCH] fix(model): resolve proxy credentials and fallback detection Change-Id: Ic762f1ec003cee398a27c30ce06a37e933e483e6 Tests: 77 model unit tests passed.\nType check: uv run mypy --config-file mypy.ini . was blocked by numpy stubs using Python 3.12 type syntax while mypy targets Python 3.10 (1 error). --- agentrun/model/__client_async_template.py | 19 +++-- .../model/__model_proxy_async_template.py | 48 +++++++++++-- agentrun/model/client.py | 38 ++++++---- agentrun/model/model_proxy.py | 48 +++++++++++-- tests/unittests/model/test_client.py | 71 +++++++++++++++++++ tests/unittests/model/test_model_proxy.py | 44 ++++++++++++ 6 files changed, 235 insertions(+), 33 deletions(-) diff --git a/agentrun/model/__client_async_template.py b/agentrun/model/__client_async_template.py index d82049d..8df493a 100644 --- a/agentrun/model/__client_async_template.py +++ b/agentrun/model/__client_async_template.py @@ -286,18 +286,23 @@ async def get_async( """ # 优先查 ModelService,未命中再回退 ModelProxy,避免无谓的 404 - error: Optional[HTTPError] = None if backend_type == BackendType.SERVICE or backend_type is None: try: result = await self.__control_api.get_model_service_async( model_service_name=name, config=config ) - return ModelService.from_inner_object(result) - except HTTPError as e: - error = e - - if backend_type == BackendType.SERVICE and error is not None: - raise error.to_resource_error("Model", name) from error + model_service = ModelService.from_inner_object(result) + if ( + backend_type is None + and model_service.provider_settings is None + ): + raise ValueError( + f"ModelService '{name}' returned no provider settings" + ) + return model_service + except Exception: + if backend_type == BackendType.SERVICE: + raise try: result = await self.__control_api.get_model_proxy_async( diff --git a/agentrun/model/__model_proxy_async_template.py b/agentrun/model/__model_proxy_async_template.py index 35afe62..c4a0a3f 100644 --- a/agentrun/model/__model_proxy_async_template.py +++ b/agentrun/model/__model_proxy_async_template.py @@ -214,6 +214,47 @@ async def refresh_async(self, config: Optional[Config] = None): def model_info(self, config: Optional[Config] = None) -> BaseInfo: cfg = Config.with_configs(self._config, config) + model_name = ( + pydash.get(self, "proxy_config.endpoints[0].model_names[0]") + if self.proxy_mode == ProxyMode.SINGLE + else self.model_proxy_name + ) or "" + + # A credential bound to a ModelProxy authenticates callers against the + # proxy's public data-plane endpoint. Resolve it with the same + # request-scoped Config (AK/SK/STS) used to fetch the proxy. + if self.credential_name: + from agentrun.credential import Credential + + credential = Credential.get_by_name( + self.credential_name, config=cfg + ) + api_key = credential.credential_secret or "" + if not api_key: + raise ValueError( + f"Credential '{self.credential_name}' has no secret" + " configured" + ) + if not self.endpoint: + raise ValueError( + f"ModelProxy '{self.model_proxy_name}' has no endpoint" + " configured" + ) + + public_config = credential.credential_public_config or {} + header_key = public_config.get("headerKey") or "X-API-Key" + prefix = public_config.get("prefix") or "" + headers = cfg.get_headers().copy() + headers[str(header_key)] = f"{prefix}{api_key}" + + return BaseInfo( + api_key=api_key, + base_url=f"{self.endpoint.rstrip('/')}/v1", + model=model_name, + headers=headers, + provider="openai", + ) + if self._data_client is None: self._data_client = ModelDataAPI( self.model_proxy_name or "", @@ -223,12 +264,7 @@ def model_info(self, config: Optional[Config] = None) -> BaseInfo: self._data_client.update_model_name( model_proxy_name=self.model_proxy_name, - model_name=( - pydash.get(self, "proxy_config.endpoints[0].model_names[0]") - if self.proxy_mode == ProxyMode.SINGLE - else self.model_proxy_name - ) - or "", + model_name=model_name, credential_name=self.credential_name, config=cfg, ) diff --git a/agentrun/model/client.py b/agentrun/model/client.py index 449794f..6344ce0 100644 --- a/agentrun/model/client.py +++ b/agentrun/model/client.py @@ -513,18 +513,23 @@ async def get_async( """ # 优先查 ModelService,未命中再回退 ModelProxy,避免无谓的 404 - error: Optional[HTTPError] = None if backend_type == BackendType.SERVICE or backend_type is None: try: result = await self.__control_api.get_model_service_async( model_service_name=name, config=config ) - return ModelService.from_inner_object(result) - except HTTPError as e: - error = e - - if backend_type == BackendType.SERVICE and error is not None: - raise error.to_resource_error("Model", name) from error + model_service = ModelService.from_inner_object(result) + if ( + backend_type is None + and model_service.provider_settings is None + ): + raise ValueError( + f"ModelService '{name}' returned no provider settings" + ) + return model_service + except Exception: + if backend_type == BackendType.SERVICE: + raise try: result = await self.__control_api.get_model_proxy_async( @@ -554,18 +559,23 @@ def get( """ # 优先查 ModelService,未命中再回退 ModelProxy,避免无谓的 404 - error: Optional[HTTPError] = None if backend_type == BackendType.SERVICE or backend_type is None: try: result = self.__control_api.get_model_service( model_service_name=name, config=config ) - return ModelService.from_inner_object(result) - except HTTPError as e: - error = e - - if backend_type == BackendType.SERVICE and error is not None: - raise error.to_resource_error("Model", name) from error + model_service = ModelService.from_inner_object(result) + if ( + backend_type is None + and model_service.provider_settings is None + ): + raise ValueError( + f"ModelService '{name}' returned no provider settings" + ) + return model_service + except Exception: + if backend_type == BackendType.SERVICE: + raise try: result = self.__control_api.get_model_proxy( diff --git a/agentrun/model/model_proxy.py b/agentrun/model/model_proxy.py index d9e1642..9a0cf8a 100644 --- a/agentrun/model/model_proxy.py +++ b/agentrun/model/model_proxy.py @@ -385,6 +385,47 @@ def refresh(self, config: Optional[Config] = None): def model_info(self, config: Optional[Config] = None) -> BaseInfo: cfg = Config.with_configs(self._config, config) + model_name = ( + pydash.get(self, "proxy_config.endpoints[0].model_names[0]") + if self.proxy_mode == ProxyMode.SINGLE + else self.model_proxy_name + ) or "" + + # A credential bound to a ModelProxy authenticates callers against the + # proxy's public data-plane endpoint. Resolve it with the same + # request-scoped Config (AK/SK/STS) used to fetch the proxy. + if self.credential_name: + from agentrun.credential import Credential + + credential = Credential.get_by_name( + self.credential_name, config=cfg + ) + api_key = credential.credential_secret or "" + if not api_key: + raise ValueError( + f"Credential '{self.credential_name}' has no secret" + " configured" + ) + if not self.endpoint: + raise ValueError( + f"ModelProxy '{self.model_proxy_name}' has no endpoint" + " configured" + ) + + public_config = credential.credential_public_config or {} + header_key = public_config.get("headerKey") or "X-API-Key" + prefix = public_config.get("prefix") or "" + headers = cfg.get_headers().copy() + headers[str(header_key)] = f"{prefix}{api_key}" + + return BaseInfo( + api_key=api_key, + base_url=f"{self.endpoint.rstrip('/')}/v1", + model=model_name, + headers=headers, + provider="openai", + ) + if self._data_client is None: self._data_client = ModelDataAPI( self.model_proxy_name or "", @@ -394,12 +435,7 @@ def model_info(self, config: Optional[Config] = None) -> BaseInfo: self._data_client.update_model_name( model_proxy_name=self.model_proxy_name, - model_name=( - pydash.get(self, "proxy_config.endpoints[0].model_names[0]") - if self.proxy_mode == ProxyMode.SINGLE - else self.model_proxy_name - ) - or "", + model_name=model_name, credential_name=self.credential_name, config=cfg, ) diff --git a/tests/unittests/model/test_client.py b/tests/unittests/model/test_client.py index 2d2a0f8..516a7df 100644 --- a/tests/unittests/model/test_client.py +++ b/tests/unittests/model/test_client.py @@ -883,6 +883,10 @@ def test_get_service(self, mock_control_api_class): mock_control_api_class.return_value = mock_control_api mock_result = MagicMock() + mock_result.to_map.return_value = { + "modelServiceName": "test", + "providerSettings": {"baseUrl": "https://example.com/v1"}, + } mock_control_api.get_model_service.return_value = mock_result client = ModelClient() @@ -906,6 +910,10 @@ def test_get_auto_detect(self, mock_control_api_class): mock_control_api_class.return_value = mock_control_api mock_result = MagicMock() + mock_result.to_map.return_value = { + "modelServiceName": "test", + "providerSettings": {"baseUrl": "https://example.com/v1"}, + } mock_control_api.get_model_service.return_value = mock_result client = ModelClient() @@ -944,6 +952,35 @@ def test_get_auto_detect_falls_back_to_proxy(self, mock_control_api_class): mock_control_api.get_model_proxy.assert_called_once() assert isinstance(result, ModelProxy) + @patch.dict( + os.environ, + { + "AGENTRUN_ACCESS_KEY_ID": "test-access-key", + "AGENTRUN_ACCESS_KEY_SECRET": "test-secret", + "AGENTRUN_ACCOUNT_ID": "test-account", + }, + ) + @patch("agentrun.model.client.ModelControlAPI") + def test_get_auto_detect_falls_back_when_service_is_incomplete( + self, mock_control_api_class + ): + """A service-shaped response without provider settings is not usable.""" + mock_control_api = MagicMock() + mock_control_api_class.return_value = mock_control_api + + service_result = MagicMock() + service_result.to_map.return_value = {"modelServiceName": "test"} + mock_control_api.get_model_service.return_value = service_result + proxy_result = MagicMock() + proxy_result.to_map.return_value = {"modelProxyName": "test"} + mock_control_api.get_model_proxy.return_value = proxy_result + + result = ModelClient().get("test") + + mock_control_api.get_model_service.assert_called_once() + mock_control_api.get_model_proxy.assert_called_once() + assert isinstance(result, ModelProxy) + @patch.dict( os.environ, { @@ -1029,6 +1066,40 @@ async def test_get_async_auto_detect_fallback(self, mock_control_api_class): mock_control_api.get_model_proxy_async.assert_called_once() assert isinstance(result, ModelProxy) + @patch.dict( + os.environ, + { + "AGENTRUN_ACCESS_KEY_ID": "test-access-key", + "AGENTRUN_ACCESS_KEY_SECRET": "test-secret", + "AGENTRUN_ACCOUNT_ID": "test-account", + }, + ) + @patch("agentrun.model.client.ModelControlAPI") + @pytest.mark.asyncio + async def test_get_async_falls_back_when_service_is_incomplete( + self, mock_control_api_class + ): + """Async auto-detection also rejects an incomplete service response.""" + mock_control_api = MagicMock() + mock_control_api_class.return_value = mock_control_api + + service_result = MagicMock() + service_result.to_map.return_value = {"modelServiceName": "test"} + mock_control_api.get_model_service_async = AsyncMock( + return_value=service_result + ) + proxy_result = MagicMock() + proxy_result.to_map.return_value = {"modelProxyName": "test"} + mock_control_api.get_model_proxy_async = AsyncMock( + return_value=proxy_result + ) + + result = await ModelClient().get_async("test") + + mock_control_api.get_model_service_async.assert_called_once() + mock_control_api.get_model_proxy_async.assert_called_once() + assert isinstance(result, ModelProxy) + @patch.dict( os.environ, { diff --git a/tests/unittests/model/test_model_proxy.py b/tests/unittests/model/test_model_proxy.py index 6f0890e..d0e742c 100644 --- a/tests/unittests/model/test_model_proxy.py +++ b/tests/unittests/model/test_model_proxy.py @@ -515,6 +515,50 @@ def test_model_info_single_mode(self, mock_data_api_class): assert result.model == "gpt-4" + @patch("agentrun.credential.Credential.get_by_name") + def test_model_info_with_bound_credential(self, mock_get_credential): + mock_get_credential.return_value = MagicMock( + credential_secret="resolved-secret", + credential_public_config={ + "headerKey": "X-API-Key", + "prefix": "prefix-", + }, + ) + config = Config( + access_key_id="request-ak", + access_key_secret="request-sk", + security_token="request-sts", + headers={"X-Trace": "trace-1"}, + ) + proxy = ModelProxy( + model_proxy_name="test-proxy", + credential_name="test-credential", + endpoint="https://123.agentrun-data.cn-hangzhou.aliyuncs.com/models/test-proxy", + proxy_mode=ProxyMode.SINGLE, + proxy_config=ProxyConfig( + endpoints=[ProxyConfigEndpoint(model_names=["deepseek-v4-pro"])] + ), + ) + + info = proxy.model_info(config=config) + + mock_get_credential.assert_called_once() + assert mock_get_credential.call_args.args == ("test-credential",) + credential_config = mock_get_credential.call_args.kwargs["config"] + assert credential_config.get_access_key_id() == "request-ak" + assert credential_config.get_access_key_secret() == "request-sk" + assert credential_config.get_security_token() == "request-sts" + assert info.api_key == "resolved-secret" + assert info.base_url == ( + "https://123.agentrun-data.cn-hangzhou.aliyuncs.com/" + "models/test-proxy/v1" + ) + assert info.model == "deepseek-v4-pro" + assert info.headers == { + "X-Trace": "trace-1", + "X-API-Key": "prefix-resolved-secret", + } + class TestModelProxyCompletions: """Tests for ModelProxy.completions method"""