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
19 changes: 12 additions & 7 deletions agentrun/model/__client_async_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +294 to +305

try:
result = await self.__control_api.get_model_proxy_async(
Expand Down
48 changes: 42 additions & 6 deletions agentrun/model/__model_proxy_async_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "",
Expand All @@ -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,
)
Expand Down
38 changes: 24 additions & 14 deletions agentrun/model/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +521 to +532

try:
result = await self.__control_api.get_model_proxy_async(
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 保留异常契约,避免将网络失败当作类型探测失败

这里改成 except Exception 后,有两种已对照 base 复现的行为变化:

  1. 显式 backend_type=BackendType.SERVICE,接口返回 404 + model not found:原来经 to_resource_error()ResourceNotExistError,现在直接抛 HTTPError,已有的 except ResourceNotExistError 无法捕获。ModelService.get_by_name() 和异步路径同样受影响。
  2. 自动探测时,Service 查询因网络超时抛底层 UnretryableException,随后 Proxy 查询返回 404:原来保留网络异常且不查 Proxy,现在吞掉网络异常,最终变成 ResourceNotExistError,将查询失败误报为资源不存在。此场景通过 mock 底层 SDK、保留真实 ModelControlAPI 转换逻辑复现。

建议只在成功解析但缺少 provider_settings 时直接进入 Proxy 查询,保留原有的 HTTPError 捕获及 SERVICE 分支的 to_resource_error() 转换,不扩大到捕获所有异常。请同步修改 get_async()__client_async_template.py,并增加断言具体异常类型的回归测试;现有 pytest.raises(Exception) 覆盖不了第一种变化。

raise

try:
result = self.__control_api.get_model_proxy(
Expand Down
48 changes: 42 additions & 6 deletions agentrun/model/model_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "",
Expand All @@ -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,
)
Expand Down
71 changes: 71 additions & 0 deletions tests/unittests/model/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -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,
{
Expand Down
44 changes: 44 additions & 0 deletions tests/unittests/model/test_model_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
Loading