Skip to content

Commit b25fb82

Browse files
authored
Merge pull request #132 from Serverless-Devs/hanxie
fix(model): resolve proxy credentials and fallback detection
2 parents 1ba7b75 + 5965e8f commit b25fb82

6 files changed

Lines changed: 235 additions & 33 deletions

File tree

‎agentrun/model/__client_async_template.py‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -286,18 +286,23 @@ async def get_async(
286286
"""
287287

288288
# 优先查 ModelService,未命中再回退 ModelProxy,避免无谓的 404
289-
error: Optional[HTTPError] = None
290289
if backend_type == BackendType.SERVICE or backend_type is None:
291290
try:
292291
result = await self.__control_api.get_model_service_async(
293292
model_service_name=name, config=config
294293
)
295-
return ModelService.from_inner_object(result)
296-
except HTTPError as e:
297-
error = e
298-
299-
if backend_type == BackendType.SERVICE and error is not None:
300-
raise error.to_resource_error("Model", name) from error
294+
model_service = ModelService.from_inner_object(result)
295+
if (
296+
backend_type is None
297+
and model_service.provider_settings is None
298+
):
299+
raise ValueError(
300+
f"ModelService '{name}' returned no provider settings"
301+
)
302+
return model_service
303+
except Exception:
304+
if backend_type == BackendType.SERVICE:
305+
raise
301306

302307
try:
303308
result = await self.__control_api.get_model_proxy_async(

‎agentrun/model/__model_proxy_async_template.py‎

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,47 @@ async def refresh_async(self, config: Optional[Config] = None):
214214
def model_info(self, config: Optional[Config] = None) -> BaseInfo:
215215
cfg = Config.with_configs(self._config, config)
216216

217+
model_name = (
218+
pydash.get(self, "proxy_config.endpoints[0].model_names[0]")
219+
if self.proxy_mode == ProxyMode.SINGLE
220+
else self.model_proxy_name
221+
) or ""
222+
223+
# A credential bound to a ModelProxy authenticates callers against the
224+
# proxy's public data-plane endpoint. Resolve it with the same
225+
# request-scoped Config (AK/SK/STS) used to fetch the proxy.
226+
if self.credential_name:
227+
from agentrun.credential import Credential
228+
229+
credential = Credential.get_by_name(
230+
self.credential_name, config=cfg
231+
)
232+
api_key = credential.credential_secret or ""
233+
if not api_key:
234+
raise ValueError(
235+
f"Credential '{self.credential_name}' has no secret"
236+
" configured"
237+
)
238+
if not self.endpoint:
239+
raise ValueError(
240+
f"ModelProxy '{self.model_proxy_name}' has no endpoint"
241+
" configured"
242+
)
243+
244+
public_config = credential.credential_public_config or {}
245+
header_key = public_config.get("headerKey") or "X-API-Key"
246+
prefix = public_config.get("prefix") or ""
247+
headers = cfg.get_headers().copy()
248+
headers[str(header_key)] = f"{prefix}{api_key}"
249+
250+
return BaseInfo(
251+
api_key=api_key,
252+
base_url=f"{self.endpoint.rstrip('/')}/v1",
253+
model=model_name,
254+
headers=headers,
255+
provider="openai",
256+
)
257+
217258
if self._data_client is None:
218259
self._data_client = ModelDataAPI(
219260
self.model_proxy_name or "",
@@ -223,12 +264,7 @@ def model_info(self, config: Optional[Config] = None) -> BaseInfo:
223264

224265
self._data_client.update_model_name(
225266
model_proxy_name=self.model_proxy_name,
226-
model_name=(
227-
pydash.get(self, "proxy_config.endpoints[0].model_names[0]")
228-
if self.proxy_mode == ProxyMode.SINGLE
229-
else self.model_proxy_name
230-
)
231-
or "",
267+
model_name=model_name,
232268
credential_name=self.credential_name,
233269
config=cfg,
234270
)

‎agentrun/model/client.py‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -513,18 +513,23 @@ async def get_async(
513513
"""
514514

515515
# 优先查 ModelService,未命中再回退 ModelProxy,避免无谓的 404
516-
error: Optional[HTTPError] = None
517516
if backend_type == BackendType.SERVICE or backend_type is None:
518517
try:
519518
result = await self.__control_api.get_model_service_async(
520519
model_service_name=name, config=config
521520
)
522-
return ModelService.from_inner_object(result)
523-
except HTTPError as e:
524-
error = e
525-
526-
if backend_type == BackendType.SERVICE and error is not None:
527-
raise error.to_resource_error("Model", name) from error
521+
model_service = ModelService.from_inner_object(result)
522+
if (
523+
backend_type is None
524+
and model_service.provider_settings is None
525+
):
526+
raise ValueError(
527+
f"ModelService '{name}' returned no provider settings"
528+
)
529+
return model_service
530+
except Exception:
531+
if backend_type == BackendType.SERVICE:
532+
raise
528533

529534
try:
530535
result = await self.__control_api.get_model_proxy_async(
@@ -554,18 +559,23 @@ def get(
554559
"""
555560

556561
# 优先查 ModelService,未命中再回退 ModelProxy,避免无谓的 404
557-
error: Optional[HTTPError] = None
558562
if backend_type == BackendType.SERVICE or backend_type is None:
559563
try:
560564
result = self.__control_api.get_model_service(
561565
model_service_name=name, config=config
562566
)
563-
return ModelService.from_inner_object(result)
564-
except HTTPError as e:
565-
error = e
566-
567-
if backend_type == BackendType.SERVICE and error is not None:
568-
raise error.to_resource_error("Model", name) from error
567+
model_service = ModelService.from_inner_object(result)
568+
if (
569+
backend_type is None
570+
and model_service.provider_settings is None
571+
):
572+
raise ValueError(
573+
f"ModelService '{name}' returned no provider settings"
574+
)
575+
return model_service
576+
except Exception:
577+
if backend_type == BackendType.SERVICE:
578+
raise
569579

570580
try:
571581
result = self.__control_api.get_model_proxy(

‎agentrun/model/model_proxy.py‎

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,47 @@ def refresh(self, config: Optional[Config] = None):
385385
def model_info(self, config: Optional[Config] = None) -> BaseInfo:
386386
cfg = Config.with_configs(self._config, config)
387387

388+
model_name = (
389+
pydash.get(self, "proxy_config.endpoints[0].model_names[0]")
390+
if self.proxy_mode == ProxyMode.SINGLE
391+
else self.model_proxy_name
392+
) or ""
393+
394+
# A credential bound to a ModelProxy authenticates callers against the
395+
# proxy's public data-plane endpoint. Resolve it with the same
396+
# request-scoped Config (AK/SK/STS) used to fetch the proxy.
397+
if self.credential_name:
398+
from agentrun.credential import Credential
399+
400+
credential = Credential.get_by_name(
401+
self.credential_name, config=cfg
402+
)
403+
api_key = credential.credential_secret or ""
404+
if not api_key:
405+
raise ValueError(
406+
f"Credential '{self.credential_name}' has no secret"
407+
" configured"
408+
)
409+
if not self.endpoint:
410+
raise ValueError(
411+
f"ModelProxy '{self.model_proxy_name}' has no endpoint"
412+
" configured"
413+
)
414+
415+
public_config = credential.credential_public_config or {}
416+
header_key = public_config.get("headerKey") or "X-API-Key"
417+
prefix = public_config.get("prefix") or ""
418+
headers = cfg.get_headers().copy()
419+
headers[str(header_key)] = f"{prefix}{api_key}"
420+
421+
return BaseInfo(
422+
api_key=api_key,
423+
base_url=f"{self.endpoint.rstrip('/')}/v1",
424+
model=model_name,
425+
headers=headers,
426+
provider="openai",
427+
)
428+
388429
if self._data_client is None:
389430
self._data_client = ModelDataAPI(
390431
self.model_proxy_name or "",
@@ -394,12 +435,7 @@ def model_info(self, config: Optional[Config] = None) -> BaseInfo:
394435

395436
self._data_client.update_model_name(
396437
model_proxy_name=self.model_proxy_name,
397-
model_name=(
398-
pydash.get(self, "proxy_config.endpoints[0].model_names[0]")
399-
if self.proxy_mode == ProxyMode.SINGLE
400-
else self.model_proxy_name
401-
)
402-
or "",
438+
model_name=model_name,
403439
credential_name=self.credential_name,
404440
config=cfg,
405441
)

‎tests/unittests/model/test_client.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,10 @@ def test_get_service(self, mock_control_api_class):
883883
mock_control_api_class.return_value = mock_control_api
884884

885885
mock_result = MagicMock()
886+
mock_result.to_map.return_value = {
887+
"modelServiceName": "test",
888+
"providerSettings": {"baseUrl": "https://example.com/v1"},
889+
}
886890
mock_control_api.get_model_service.return_value = mock_result
887891

888892
client = ModelClient()
@@ -906,6 +910,10 @@ def test_get_auto_detect(self, mock_control_api_class):
906910
mock_control_api_class.return_value = mock_control_api
907911

908912
mock_result = MagicMock()
913+
mock_result.to_map.return_value = {
914+
"modelServiceName": "test",
915+
"providerSettings": {"baseUrl": "https://example.com/v1"},
916+
}
909917
mock_control_api.get_model_service.return_value = mock_result
910918

911919
client = ModelClient()
@@ -944,6 +952,35 @@ def test_get_auto_detect_falls_back_to_proxy(self, mock_control_api_class):
944952
mock_control_api.get_model_proxy.assert_called_once()
945953
assert isinstance(result, ModelProxy)
946954

955+
@patch.dict(
956+
os.environ,
957+
{
958+
"AGENTRUN_ACCESS_KEY_ID": "test-access-key",
959+
"AGENTRUN_ACCESS_KEY_SECRET": "test-secret",
960+
"AGENTRUN_ACCOUNT_ID": "test-account",
961+
},
962+
)
963+
@patch("agentrun.model.client.ModelControlAPI")
964+
def test_get_auto_detect_falls_back_when_service_is_incomplete(
965+
self, mock_control_api_class
966+
):
967+
"""A service-shaped response without provider settings is not usable."""
968+
mock_control_api = MagicMock()
969+
mock_control_api_class.return_value = mock_control_api
970+
971+
service_result = MagicMock()
972+
service_result.to_map.return_value = {"modelServiceName": "test"}
973+
mock_control_api.get_model_service.return_value = service_result
974+
proxy_result = MagicMock()
975+
proxy_result.to_map.return_value = {"modelProxyName": "test"}
976+
mock_control_api.get_model_proxy.return_value = proxy_result
977+
978+
result = ModelClient().get("test")
979+
980+
mock_control_api.get_model_service.assert_called_once()
981+
mock_control_api.get_model_proxy.assert_called_once()
982+
assert isinstance(result, ModelProxy)
983+
947984
@patch.dict(
948985
os.environ,
949986
{
@@ -1029,6 +1066,40 @@ async def test_get_async_auto_detect_fallback(self, mock_control_api_class):
10291066
mock_control_api.get_model_proxy_async.assert_called_once()
10301067
assert isinstance(result, ModelProxy)
10311068

1069+
@patch.dict(
1070+
os.environ,
1071+
{
1072+
"AGENTRUN_ACCESS_KEY_ID": "test-access-key",
1073+
"AGENTRUN_ACCESS_KEY_SECRET": "test-secret",
1074+
"AGENTRUN_ACCOUNT_ID": "test-account",
1075+
},
1076+
)
1077+
@patch("agentrun.model.client.ModelControlAPI")
1078+
@pytest.mark.asyncio
1079+
async def test_get_async_falls_back_when_service_is_incomplete(
1080+
self, mock_control_api_class
1081+
):
1082+
"""Async auto-detection also rejects an incomplete service response."""
1083+
mock_control_api = MagicMock()
1084+
mock_control_api_class.return_value = mock_control_api
1085+
1086+
service_result = MagicMock()
1087+
service_result.to_map.return_value = {"modelServiceName": "test"}
1088+
mock_control_api.get_model_service_async = AsyncMock(
1089+
return_value=service_result
1090+
)
1091+
proxy_result = MagicMock()
1092+
proxy_result.to_map.return_value = {"modelProxyName": "test"}
1093+
mock_control_api.get_model_proxy_async = AsyncMock(
1094+
return_value=proxy_result
1095+
)
1096+
1097+
result = await ModelClient().get_async("test")
1098+
1099+
mock_control_api.get_model_service_async.assert_called_once()
1100+
mock_control_api.get_model_proxy_async.assert_called_once()
1101+
assert isinstance(result, ModelProxy)
1102+
10321103
@patch.dict(
10331104
os.environ,
10341105
{

‎tests/unittests/model/test_model_proxy.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,50 @@ def test_model_info_single_mode(self, mock_data_api_class):
515515

516516
assert result.model == "gpt-4"
517517

518+
@patch("agentrun.credential.Credential.get_by_name")
519+
def test_model_info_with_bound_credential(self, mock_get_credential):
520+
mock_get_credential.return_value = MagicMock(
521+
credential_secret="resolved-secret",
522+
credential_public_config={
523+
"headerKey": "X-API-Key",
524+
"prefix": "prefix-",
525+
},
526+
)
527+
config = Config(
528+
access_key_id="request-ak",
529+
access_key_secret="request-sk",
530+
security_token="request-sts",
531+
headers={"X-Trace": "trace-1"},
532+
)
533+
proxy = ModelProxy(
534+
model_proxy_name="test-proxy",
535+
credential_name="test-credential",
536+
endpoint="https://123.agentrun-data.cn-hangzhou.aliyuncs.com/models/test-proxy",
537+
proxy_mode=ProxyMode.SINGLE,
538+
proxy_config=ProxyConfig(
539+
endpoints=[ProxyConfigEndpoint(model_names=["deepseek-v4-pro"])]
540+
),
541+
)
542+
543+
info = proxy.model_info(config=config)
544+
545+
mock_get_credential.assert_called_once()
546+
assert mock_get_credential.call_args.args == ("test-credential",)
547+
credential_config = mock_get_credential.call_args.kwargs["config"]
548+
assert credential_config.get_access_key_id() == "request-ak"
549+
assert credential_config.get_access_key_secret() == "request-sk"
550+
assert credential_config.get_security_token() == "request-sts"
551+
assert info.api_key == "resolved-secret"
552+
assert info.base_url == (
553+
"https://123.agentrun-data.cn-hangzhou.aliyuncs.com/"
554+
"models/test-proxy/v1"
555+
)
556+
assert info.model == "deepseek-v4-pro"
557+
assert info.headers == {
558+
"X-Trace": "trace-1",
559+
"X-API-Key": "prefix-resolved-secret",
560+
}
561+
518562

519563
class TestModelProxyCompletions:
520564
"""Tests for ModelProxy.completions method"""

0 commit comments

Comments
 (0)