Skip to content
Merged
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
15 changes: 15 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ See [deployment and operation](service/studio_release_notifier/README.md).

## Features

- **Ark API Keys**: Each page load reads the current API Key list directly from
the cloud provider. Enabled, disabled, all-permission and custom-permission
keys are listed with their status and permission scope beneath the name.
Disabled keys remain visible but cannot be selected. Search matches names,
statuses and permission scopes; pagination preserves distinct keys with the
same name. Model options combine account activation with the selected key's
model permissions. Models outside that scope remain visible but disabled,
and changing keys clears model selections that are no longer permitted.
Explicitly granted video and shut-down models appear in the searchable model
dropdown with their status and remain disabled, without expanding the form.
Current permissions are re-applied even when model metadata is cached.
Overlapping identical requests share one in-flight cloud query, including
failures; later API Key list reads still fetch the latest cloud data.
This behavior applies to both Volcengine and BytePlus

- **Agent publication review**: Developers deploy privately and apply from an
Agent card. The review center's Agent tab lets administrators inspect the
submitted Runtime metadata, approve with an optional comment, or return with
Expand Down
117 changes: 73 additions & 44 deletions frontend/server/model_catalog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@

from frontend.server.video.client import ArkHttpClient, ArkServiceError
from veadk.utils.volcengine_sign import volcengine_signed_request
from veadk.utils.logger import get_logger

from .errors import cloud_payload, cloud_request_error, cloud_response_error
from .protocol import (
CloudCredentials,
CredentialResolver,
Expand All @@ -33,6 +35,7 @@

_PAGE_SIZE = 100
_MAX_PAGES = 100
logger = get_logger(__name__)


@dataclass(frozen=True)
Expand Down Expand Up @@ -118,28 +121,20 @@ async def list_models(self, *, api_key: str | None = None) -> list[dict[str, Any
},
)
if response.status_code >= 400:
raise ModelCatalogError(
"模型服务鉴权失败,请检查所选 API Key 后重试。",
status_code=502,
raise cloud_response_error(
response, action="ListModels", secrets=(api_key,)
)
except ModelCatalogError:
raise
except ArkServiceError as error:
raise ModelCatalogError(
str(error),
status_code=error.status_code,
raise cloud_request_error(
error, action="ListModels", secrets=(api_key,)
) from error
except Exception as error:
raise ModelCatalogError(
"模型服务凭据不可用,请检查 Studio 的模型凭据后重试。",
status_code=503,
) from error
try:
payload = response.json()
except ValueError as error:
raise ModelCatalogError(
"模型服务返回了无法解析的结果,请稍后重试。"
raise cloud_request_error(
error, action="ListModels", secrets=(api_key,)
) from error
payload = cloud_payload(response, action="ListModels", secrets=(api_key,))
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, list) or not all(
isinstance(item, dict) for item in data
Expand All @@ -153,6 +148,9 @@ async def _activation_page(
credentials: CloudCredentials,
page_number: int,
) -> dict[str, Any]:
logger.info(
f"Studio cloud request action=ListModelActivations provider={self._config.provider} page={page_number}"
)
access_key, secret_key, session_token = credentials
try:
payload = await asyncio.to_thread(
Expand All @@ -175,19 +173,17 @@ async def _activation_page(
"Action": "ListModelActivations",
"Version": "2024-01-01",
},
response_type="response",
)
except Exception as error:
raise ModelCatalogError(
"无法获取模型开通状态,请检查云账号权限后重试。"
raise cloud_request_error(
error, action="ListModelActivations", secrets=credentials
) from error
payload = cloud_payload(
payload, action="ListModelActivations", secrets=credentials
)
if not isinstance(payload, dict):
raise ModelCatalogError("模型开通状态服务返回了无法解析的结果。")
metadata = payload.get("ResponseMetadata")
upstream_error = metadata.get("Error") if isinstance(metadata, dict) else None
if upstream_error:
raise ModelCatalogError(
"获取模型开通状态失败,请检查 ark:ListModelActivations 权限后重试。"
)
return payload


Expand All @@ -205,18 +201,16 @@ def __init__(
self._resolve_credentials = resolve_credentials
self._signed_request = signed_request

async def list_keys(self) -> list[dict[str, str]]:
async def list_keys(self) -> list[dict[str, Any]]:
credentials = self._credentials()
keys: list[dict[str, str]] = []
keys: list[dict[str, Any]] = []
seen_ids: set[str] = set()
scanned = 0
for page_number in range(1, _MAX_PAGES + 1):
payload = await self._request(
credentials=credentials,
action="ListApiKeys",
request_body={
"ProjectName": "default",
"Filter": {"AllowAll": True},
"PageNumber": page_number,
"PageSize": _PAGE_SIZE,
},
Expand All @@ -233,14 +227,56 @@ async def list_keys(self) -> list[dict[str, str]]:
for item in items:
key_id = str(item.get("Id") or "").strip()
name = str(item.get("Name") or "").strip()
if key_id and name and key_id not in seen_ids:
if key_id and key_id not in seen_ids:
seen_ids.add(key_id)
keys.append({"id": key_id, "name": name})
access_control = item.get("AccessControlInfo")
allow_all = (
access_control.get("AllowAll")
if isinstance(access_control, dict)
else None
)
access_rules = (
access_control.get("AccessRules")
if isinstance(access_control, dict)
else None
)
model_rule = (
access_rules.get("model")
if isinstance(access_rules, dict)
else None
)
model_access = None
if (
isinstance(model_rule, dict)
and model_rule.get("Effect") in {"allow", "deny"}
and isinstance(model_rule.get("EffectToAll"), bool)
):
model_access = {
"effect": model_rule["Effect"],
"all_models": model_rule["EffectToAll"],
"model_ids": [
value
for value in (model_rule.get("Ids") or [])
if isinstance(value, str)
],
}
keys.append(
{
"id": key_id,
"name": name,
"status": str(item.get("Status") or "").strip() or None,
"allow_all": allow_all
if isinstance(allow_all, bool)
else None,
"model_access": model_access,
}
)
new_count += 1
scanned += len(items)
total_count = _safe_int(result.get("TotalCount"), len(keys))
if not items or scanned >= total_count:
return keys
total_count = _safe_int(result.get("TotalCount"), -1)
if (total_count >= 0 and len(keys) >= total_count) or (
not items and total_count < 0
):
return sorted(keys, key=lambda key: (key["name"].casefold(), key["id"]))
if new_count == 0:
raise ModelCatalogError("API Key 列表分页异常,请稍后重试。")
raise ModelCatalogError("API Key 列表分页异常,请稍后重试。")
Expand Down Expand Up @@ -297,22 +333,15 @@ async def _request(
"Version": "2024-01-01",
**(query or {}),
},
response_type="response",
)
except Exception as error:
raise ModelCatalogError(
"无法访问 API Key 服务,请检查云账号权限后重试。"
raise cloud_request_error(
error, action=action, secrets=credentials
) from error
payload = cloud_payload(payload, action=action, secrets=credentials)
if not isinstance(payload, dict):
raise ModelCatalogError("API Key 服务返回了无法解析的结果。")
metadata = payload.get("ResponseMetadata")
upstream_error = metadata.get("Error") if isinstance(metadata, dict) else None
if upstream_error:
permission = (
"ark:ListApiKeys" if action == "ListApiKeys" else "ark:GetRawApiKey"
)
raise ModelCatalogError(
f"访问 API Key 服务失败,请检查 {permission} 权限后重试。"
)
return payload


Expand Down
138 changes: 138 additions & 0 deletions frontend/server/model_catalog/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Preserve cloud error responses without forwarding request credentials."""

from __future__ import annotations

import json
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any

from fastapi.responses import JSONResponse, Response

from .protocol import ModelCatalogError

if TYPE_CHECKING:
import httpx
import requests


def _redact_credentials(text: str, secrets: Sequence[str | None]) -> str:
for value in sorted({value for value in secrets if value}, key=len, reverse=True):
text = text.replace(value, "[redacted]")
return text


def cloud_response_error(
response: requests.Response | httpx.Response,
*,
action: str,
secrets: Sequence[str | None] = (),
) -> ModelCatalogError:
return ModelCatalogError(
f"{action} 请求失败(云服务 HTTP {response.status_code})",
status_code=response.status_code if response.status_code >= 400 else 502,
upstream_body=_redact_credentials(response.text, secrets),
upstream_status=response.status_code,
upstream_request_id=response.headers.get("X-Request-Id", ""),
action=action,
source="upstream",
)


def cloud_request_error(
error: Exception,
*,
action: str,
secrets: Sequence[str | None] = (),
) -> ModelCatalogError:
import httpx
import requests

response = getattr(error, "response", None)
# requests.Response is false for HTTP errors, so do not test its truthiness.
if isinstance(response, (requests.Response, httpx.Response)):
return cloud_response_error(response, action=action, secrets=secrets)
cause = error.__cause__
if isinstance(cause, Exception):
return cloud_request_error(cause, action=action, secrets=secrets)
detail = _redact_credentials(str(error), secrets)
timeout = isinstance(
error, (requests.Timeout, httpx.TimeoutException, TimeoutError)
)
return ModelCatalogError(
f"{action} 请求未完成,未收到云服务响应,请稍后重试({type(error).__name__}: {detail})",
status_code=504 if timeout else 502,
action=action,
source="transport",
)


def cloud_payload(
result: Any,
*,
action: str,
secrets: Sequence[str | None] = (),
) -> Any:
import httpx
import requests

response = (
result if isinstance(result, (requests.Response, httpx.Response)) else None
)
if response is not None:
if response.status_code >= 400:
raise cloud_response_error(response, action=action, secrets=secrets)
try:
result = response.json()
except ValueError as error:
if action == "GetRawApiKey":
raise ModelCatalogError("API Key 服务返回了无法解析的结果") from error
raise cloud_response_error(
response, action=action, secrets=secrets
) from error
metadata = result.get("ResponseMetadata") if isinstance(result, dict) else None
error = metadata.get("Error") if isinstance(metadata, dict) else None
if error or (isinstance(result, dict) and result.get("error")):
if response is None:
# Injectable clients may already have decoded the JSON response.
response = httpx.Response(200, text=json.dumps(result, ensure_ascii=False))
raise cloud_response_error(response, action=action, secrets=secrets)
return result


def model_catalog_error_response(error: ModelCatalogError) -> Response:
headers = {"Cache-Control": "no-store", "X-Studio-Error-Source": error.source}
if error.action:
headers["X-Studio-Upstream-Action"] = error.action
if error.upstream_status is not None:
headers["X-Studio-Upstream-Status"] = str(error.upstream_status)
if error.upstream_request_id:
headers["X-Request-Id"] = error.upstream_request_id
if error.upstream_body is not None:
try:
json.loads(error.upstream_body)
media_type = "application/json"
except ValueError:
media_type = "text/plain"
return Response(
content=error.upstream_body,
status_code=error.status_code,
media_type=media_type,
headers=headers,
)
return JSONResponse(
{"detail": str(error)}, status_code=error.status_code, headers=headers
)
Loading
Loading