From e4bc66ee10029038616f089d1c0e10f46346218a Mon Sep 17 00:00:00 2001 From: evanlowe <62918515+evanlowe@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:16:02 +0800 Subject: [PATCH 1/2] fix(studio): refresh Ark API keys and show model permissions --- frontend/README.md | 15 + frontend/server/model_catalog/client.py | 117 +-- frontend/server/model_catalog/errors.py | 130 ++++ frontend/server/model_catalog/models.py | 27 +- frontend/server/model_catalog/protocol.py | 21 +- frontend/server/model_catalog/routes.py | 25 +- frontend/server/model_catalog/service.py | 205 ++++- frontend/src/adk/client.ts | 28 +- frontend/src/create/CustomCreate.css | 5 + frontend/src/create/CustomCreate.tsx | 70 +- frontend/src/create/IntelligentCreate.tsx | 3 +- frontend/src/create/NewAgentWorkbench.tsx | 45 +- .../src/create/modelApiKeyPresentation.ts | 56 ++ frontend/src/i18n/resources/en-US/adk.json | 8 +- frontend/src/i18n/resources/en-US/create.json | 21 + frontend/src/i18n/resources/zh-CN/adk.json | 8 +- frontend/src/i18n/resources/zh-CN/create.json | 21 + .../src/migrations/MigrationWorkspace.tsx | 1 + .../tests/modelApiKeyPresentation.test.mjs | 67 ++ frontend/tests/modelApiKeySecurity.test.mjs | 28 +- frontend/tests/modelSelector.test.mjs | 20 +- frontend/tests/newAgentWorkbench.test.mjs | 5 +- .../test_api_key_model_permissions.py | 213 +++++ .../model_catalog/test_api_key_refresh.py | 134 ++++ .../server/model_catalog/test_cloud_errors.py | 98 +++ .../model_catalog/test_concurrent_requests.py | 297 +++++++ .../model_catalog/test_model_catalog.py | 2 +- .../{index-YHtt_Wvo.js => index-Lu5VNfHM.js} | 533 ++++++------- ...VHmOoQTo.js => CodeDiffEditor-CY8AAarl.js} | 2 +- ...NaOB.js => FileExplorerEditor-FPS-5LM5.js} | 2 +- ...1l.js => MarkdownPromptEditor-CdrKT5NM.js} | 2 +- ....js => QuickAgentCreateDialog-D9P_guDo.js} | 2 +- .../{arc-bIcrqnw9.js => arc-CcYs4AqC.js} | 2 +- ...hannel-D2-ZRzX-.js => channel-BPXHnwOn.js} | 2 +- ...ex.es-WN9L6jEM.js => index.es-Cwofp_ct.js} | 2 +- ...n-B4L0nGjB.js => jspdf.es.min-CmXjtqTy.js} | 6 +- ...{linear-4d9tyw0j.js => linear-AeM3AW63.js} | 2 +- ...{index-SLIzo_j8.css => index-C0EiMigV.css} | 2 +- ..._w.js => abnfDiagram-N423BO3Z-6VQJcXUh.js} | 2 +- ... architectureDiagram-T3A2C74G-CnmatUmI.js} | 2 +- ...8.js => blockDiagram-VBNYF7ZC-DaVlF_bW.js} | 2 +- ...EWj-.js => c4Diagram-5PPSVZJV-BaW3bKHD.js} | 2 +- ...dOovYGmF.js => chunk-2GRJ4B5K-BV7bFKE2.js} | 2 +- ...CXlneivp.js => chunk-2Q5K7J3B-8NwXgpKJ.js} | 2 +- ...l67iHS9-.js => chunk-5RXB4S5H-Dppt5VfK.js} | 2 +- ...B4RQ96cB.js => chunk-5VM5RSS4-CCwMNLoC.js} | 2 +- ...CuKikUgZ.js => chunk-6Q2QTUOP-qzqDUDtR.js} | 2 +- ...C3yCZ74-.js => chunk-GF5L2VYU-Ce77c0k9.js} | 2 +- ...ljw6DFQN.js => chunk-JWPE2WC7-DV3p4yF4.js} | 2 +- ...DrhSv83i.js => chunk-KBJHAD2P-TQD-3-wm.js} | 2 +- ...lu8Iy_FD.js => chunk-RYQCIY6F-vWKXtZZY.js} | 2 +- ...NJLA8EFW.js => chunk-XXDRQBXY-BUpEP0f-.js} | 2 +- .../mermaid/classDiagram-JCYQIIEL-Co7Y_BLy.js | 1 + .../mermaid/classDiagram-JCYQIIEL-bY0zn3Ce.js | 1 - .../classDiagram-v2-OCEON4UE-Co7Y_BLy.js | 1 + .../classDiagram-v2-OCEON4UE-bY0zn3Ce.js | 1 - ...z.js => cose-bilkent-JH36ORCC-Dri_WkQg.js} | 2 +- ...5ozDyK.js => cynefin-OW5HDTMX-CH7euwgs.js} | 2 +- ...js => cynefinDiagram-MW4NZA55-CHsDYFwp.js} | 2 +- ...DRIQL0jM.js => dagre-VZM6K2ZE-AOXymlMx.js} | 2 +- ...asmnOS.js => diagram-7IWD3JNH-DYMRC6p1.js} | 2 +- ...Jg80kZ.js => diagram-B4RE2ZJO-Oe67D5gg.js} | 2 +- ...tAwW_m.js => diagram-LBJQPF4R-BJHl6ndP.js} | 2 +- ...o_gQ9g.js => diagram-Q27KOJAE-CRMJyT2G.js} | 2 +- ...m207N9.js => diagram-UB23O5K3-Da7TvGUR.js} | 2 +- ...jT.js => ebnfDiagram-BXEA7PRR-DuBdI9UU.js} | 2 +- ...wgIa.js => erDiagram-JOGREHBK-CapjEck5.js} | 2 +- ...up.js => flowDiagram-UKHOOZJN-DwsQz4xu.js} | 2 +- ...J.js => ganttDiagram-PKOTCBZU-Chjzpqae.js} | 2 +- ...s => gitGraphDiagram-DS77QQ5N-CF1e2TvY.js} | 2 +- ...jn.js => infoDiagram-6WML65LV-IkDcQM1c.js} | 2 +- ...s => ishikawaDiagram-WSZJBQD7-BLVYHZPc.js} | 2 +- ...js => journeyDiagram-NVQOT4AX-vMx0QtKO.js} | 2 +- ...=> kanban-definition-27J2QSJJ-CbBFeNuz.js} | 2 +- ...e-Z21zWdLo.js => mermaid.core-D4lqlGFD.js} | 8 +- ...> mindmap-definition-FAOFIHXS-kxDx9KLB.js} | 2 +- ...vsK.js => pegDiagram-VL7TDLO6-BBAKjbDI.js} | 2 +- ...Jg5.js => pieDiagram-7S7Q4E2Y-JbEQzCh4.js} | 2 +- ...s => quadrantDiagram-CIZ2JOQS-BkxLw3dT.js} | 2 +- ...s => railroadDiagram-AXF67PYL-DSrdp5Ot.js} | 2 +- ...> requirementDiagram-LRYGKXZP-BNLtLlGE.js} | 2 +- ....js => sankeyDiagram-W5VNT64P-BZfRNIXp.js} | 2 +- ...s => sequenceDiagram-SI44F4Z6-D4EGayNL.js} | 2 +- ...oP.js => sizeCapture-X5ZJPWSS-BhvxbRFt.js} | 2 +- ...2.js => stateDiagram-OKZ733FA-BhogZu5B.js} | 2 +- .../stateDiagram-v2-UEYNNEHI-BY9qpn6q.js | 1 + .../stateDiagram-v2-UEYNNEHI-Cc43Rynl.js | 1 - ...BO2J.js => swimlanes-SLNWSIFB-kzrpDy6l.js} | 4 +- .../swimlanesDiagram-ULZ7WXOC-BXVFBVDn.js | 8 - .../swimlanesDiagram-ULZ7WXOC-Br749QLv.js | 8 + ... timeline-definition-Z64GVDOM-Dj9C65wi.js} | 2 +- ...rE.js => vennDiagram-T6HMQDX7-zZd48mOy.js} | 2 +- ...js => wardleyDiagram-T6FBY63Y-C41YQ6Zi.js} | 2 +- ...js => xychartDiagram-ELKLHX3M-CxlQZzYX.js} | 2 +- veadk/webui/index.html | 4 +- veadk/webui/website-integration.js | 734 +++++++++--------- 96 files changed, 2225 insertions(+), 866 deletions(-) create mode 100644 frontend/server/model_catalog/errors.py create mode 100644 frontend/src/create/modelApiKeyPresentation.ts create mode 100644 frontend/tests/modelApiKeyPresentation.test.mjs create mode 100644 tests/frontend/server/model_catalog/test_api_key_model_permissions.py create mode 100644 tests/frontend/server/model_catalog/test_api_key_refresh.py create mode 100644 tests/frontend/server/model_catalog/test_cloud_errors.py create mode 100644 tests/frontend/server/model_catalog/test_concurrent_requests.py rename veadk/webui/assets/app/{index-YHtt_Wvo.js => index-Lu5VNfHM.js} (71%) rename veadk/webui/assets/chunks/{CodeDiffEditor-VHmOoQTo.js => CodeDiffEditor-CY8AAarl.js} (99%) rename veadk/webui/assets/chunks/{FileExplorerEditor-BR_VNaOB.js => FileExplorerEditor-FPS-5LM5.js} (96%) rename veadk/webui/assets/chunks/{MarkdownPromptEditor-BtDsMV1l.js => MarkdownPromptEditor-CdrKT5NM.js} (99%) rename veadk/webui/assets/chunks/{QuickAgentCreateDialog-DZkalL45.js => QuickAgentCreateDialog-D9P_guDo.js} (97%) rename veadk/webui/assets/chunks/{arc-bIcrqnw9.js => arc-CcYs4AqC.js} (98%) rename veadk/webui/assets/chunks/{channel-D2-ZRzX-.js => channel-BPXHnwOn.js} (53%) rename veadk/webui/assets/chunks/{index.es-WN9L6jEM.js => index.es-Cwofp_ct.js} (99%) rename veadk/webui/assets/chunks/{jspdf.es.min-B4L0nGjB.js => jspdf.es.min-CmXjtqTy.js} (99%) rename veadk/webui/assets/chunks/{linear-4d9tyw0j.js => linear-AeM3AW63.js} (98%) rename veadk/webui/assets/styles/{index-SLIzo_j8.css => index-C0EiMigV.css} (81%) rename veadk/webui/assets/visualizations/mermaid/{abnfDiagram-N423BO3Z-Pr3_nE_w.js => abnfDiagram-N423BO3Z-6VQJcXUh.js} (86%) rename veadk/webui/assets/visualizations/mermaid/{architectureDiagram-T3A2C74G-CLqza2cb.js => architectureDiagram-T3A2C74G-CnmatUmI.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{blockDiagram-VBNYF7ZC-BnQJkIU8.js => blockDiagram-VBNYF7ZC-DaVlF_bW.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{c4Diagram-5PPSVZJV-R4LAEWj-.js => c4Diagram-5PPSVZJV-BaW3bKHD.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-2GRJ4B5K-dOovYGmF.js => chunk-2GRJ4B5K-BV7bFKE2.js} (93%) rename veadk/webui/assets/visualizations/mermaid/{chunk-2Q5K7J3B-CXlneivp.js => chunk-2Q5K7J3B-8NwXgpKJ.js} (66%) rename veadk/webui/assets/visualizations/mermaid/{chunk-5RXB4S5H-l67iHS9-.js => chunk-5RXB4S5H-Dppt5VfK.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-5VM5RSS4-B4RQ96cB.js => chunk-5VM5RSS4-CCwMNLoC.js} (83%) rename veadk/webui/assets/visualizations/mermaid/{chunk-6Q2QTUOP-CuKikUgZ.js => chunk-6Q2QTUOP-qzqDUDtR.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-GF5L2VYU-C3yCZ74-.js => chunk-GF5L2VYU-Ce77c0k9.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-JWPE2WC7-ljw6DFQN.js => chunk-JWPE2WC7-DV3p4yF4.js} (78%) rename veadk/webui/assets/visualizations/mermaid/{chunk-KBJHAD2P-DrhSv83i.js => chunk-KBJHAD2P-TQD-3-wm.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{chunk-RYQCIY6F-lu8Iy_FD.js => chunk-RYQCIY6F-vWKXtZZY.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-XXDRQBXY-NJLA8EFW.js => chunk-XXDRQBXY-BUpEP0f-.js} (53%) create mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-JCYQIIEL-Co7Y_BLy.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-JCYQIIEL-bY0zn3Ce.js create mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-v2-OCEON4UE-Co7Y_BLy.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-v2-OCEON4UE-bY0zn3Ce.js rename veadk/webui/assets/visualizations/mermaid/{cose-bilkent-JH36ORCC-CosS97mz.js => cose-bilkent-JH36ORCC-Dri_WkQg.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{cynefin-OW5HDTMX-Bp5ozDyK.js => cynefin-OW5HDTMX-CH7euwgs.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{cynefinDiagram-MW4NZA55-QD4Sw38T.js => cynefinDiagram-MW4NZA55-CHsDYFwp.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{dagre-VZM6K2ZE-DRIQL0jM.js => dagre-VZM6K2ZE-AOXymlMx.js} (97%) rename veadk/webui/assets/visualizations/mermaid/{diagram-7IWD3JNH-NXasmnOS.js => diagram-7IWD3JNH-DYMRC6p1.js} (96%) rename veadk/webui/assets/visualizations/mermaid/{diagram-B4RE2ZJO-7bJg80kZ.js => diagram-B4RE2ZJO-Oe67D5gg.js} (97%) rename veadk/webui/assets/visualizations/mermaid/{diagram-LBJQPF4R-D-tAwW_m.js => diagram-LBJQPF4R-BJHl6ndP.js} (94%) rename veadk/webui/assets/visualizations/mermaid/{diagram-Q27KOJAE-BCo_gQ9g.js => diagram-Q27KOJAE-CRMJyT2G.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{diagram-UB23O5K3-DHm207N9.js => diagram-UB23O5K3-Da7TvGUR.js} (95%) rename veadk/webui/assets/visualizations/mermaid/{ebnfDiagram-BXEA7PRR-jZtGwnjT.js => ebnfDiagram-BXEA7PRR-DuBdI9UU.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{erDiagram-JOGREHBK-RlJFwgIa.js => erDiagram-JOGREHBK-CapjEck5.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{flowDiagram-UKHOOZJN-DdqyWeup.js => flowDiagram-UKHOOZJN-DwsQz4xu.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{ganttDiagram-PKOTCBZU-DnDi1wLJ.js => ganttDiagram-PKOTCBZU-Chjzpqae.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{gitGraphDiagram-DS77QQ5N-BcpZvu8d.js => gitGraphDiagram-DS77QQ5N-CF1e2TvY.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{infoDiagram-6WML65LV-BofePmjn.js => infoDiagram-6WML65LV-IkDcQM1c.js} (69%) rename veadk/webui/assets/visualizations/mermaid/{ishikawaDiagram-WSZJBQD7-BfmTUdin.js => ishikawaDiagram-WSZJBQD7-BLVYHZPc.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{journeyDiagram-NVQOT4AX-DV6R1OaZ.js => journeyDiagram-NVQOT4AX-vMx0QtKO.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{kanban-definition-27J2QSJJ-DcMMzBDQ.js => kanban-definition-27J2QSJJ-CbBFeNuz.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{mermaid.core-Z21zWdLo.js => mermaid.core-D4lqlGFD.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{mindmap-definition-FAOFIHXS-C-IY-y3X.js => mindmap-definition-FAOFIHXS-kxDx9KLB.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{pegDiagram-VL7TDLO6-Ak6mGvsK.js => pegDiagram-VL7TDLO6-BBAKjbDI.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{pieDiagram-7S7Q4E2Y-DIAZ9Jg5.js => pieDiagram-7S7Q4E2Y-JbEQzCh4.js} (96%) rename veadk/webui/assets/visualizations/mermaid/{quadrantDiagram-CIZ2JOQS-Cd5LdbVj.js => quadrantDiagram-CIZ2JOQS-BkxLw3dT.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{railroadDiagram-AXF67PYL-DGRg7-KE.js => railroadDiagram-AXF67PYL-DSrdp5Ot.js} (84%) rename veadk/webui/assets/visualizations/mermaid/{requirementDiagram-LRYGKXZP-CJcALy2-.js => requirementDiagram-LRYGKXZP-BNLtLlGE.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sankeyDiagram-W5VNT64P-ayFwK91O.js => sankeyDiagram-W5VNT64P-BZfRNIXp.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sequenceDiagram-SI44F4Z6-D3QVvuQx.js => sequenceDiagram-SI44F4Z6-D4EGayNL.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sizeCapture-X5ZJPWSS-BwfolFoP.js => sizeCapture-X5ZJPWSS-BhvxbRFt.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{stateDiagram-OKZ733FA-BLzxBy82.js => stateDiagram-OKZ733FA-BhogZu5B.js} (96%) create mode 100644 veadk/webui/assets/visualizations/mermaid/stateDiagram-v2-UEYNNEHI-BY9qpn6q.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/stateDiagram-v2-UEYNNEHI-Cc43Rynl.js rename veadk/webui/assets/visualizations/mermaid/{swimlanes-SLNWSIFB-CPInBO2J.js => swimlanes-SLNWSIFB-kzrpDy6l.js} (99%) delete mode 100644 veadk/webui/assets/visualizations/mermaid/swimlanesDiagram-ULZ7WXOC-BXVFBVDn.js create mode 100644 veadk/webui/assets/visualizations/mermaid/swimlanesDiagram-ULZ7WXOC-Br749QLv.js rename veadk/webui/assets/visualizations/mermaid/{timeline-definition-Z64GVDOM-c7KGa6hk.js => timeline-definition-Z64GVDOM-Dj9C65wi.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{vennDiagram-T6HMQDX7-Dl6WACrE.js => vennDiagram-T6HMQDX7-zZd48mOy.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{wardleyDiagram-T6FBY63Y-KPJbNelm.js => wardleyDiagram-T6FBY63Y-C41YQ6Zi.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{xychartDiagram-ELKLHX3M-B5M_OtJ-.js => xychartDiagram-ELKLHX3M-CxlQZzYX.js} (99%) diff --git a/frontend/README.md b/frontend/README.md index f8f9c01d2..7ad74bdd0 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 diff --git a/frontend/server/model_catalog/client.py b/frontend/server/model_catalog/client.py index 834eecc6d..4a38c5a87 100644 --- a/frontend/server/model_catalog/client.py +++ b/frontend/server/model_catalog/client.py @@ -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, @@ -33,6 +35,7 @@ _PAGE_SIZE = 100 _MAX_PAGES = 100 +logger = get_logger(__name__) @dataclass(frozen=True) @@ -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 @@ -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( @@ -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 @@ -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, }, @@ -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 列表分页异常,请稍后重试。") @@ -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 diff --git a/frontend/server/model_catalog/errors.py b/frontend/server/model_catalog/errors.py new file mode 100644 index 000000000..447918ad7 --- /dev/null +++ b/frontend/server/model_catalog/errors.py @@ -0,0 +1,130 @@ +# 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 Any + +import httpx +import requests +from fastapi.responses import JSONResponse, Response + +from .protocol import ModelCatalogError + + +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: + 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: + 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 + ) diff --git a/frontend/server/model_catalog/models.py b/frontend/server/model_catalog/models.py index 0219b973e..915eb0401 100644 --- a/frontend/server/model_catalog/models.py +++ b/frontend/server/model_catalog/models.py @@ -18,7 +18,7 @@ from typing import Literal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field def _to_camel(value: str) -> str: @@ -42,11 +42,21 @@ class ModelOption(ApiModel): activation_state: str lifecycle_status: str available: bool + api_key_allowed: bool | None = None + + +class ModelApiKeyModelAccess(ApiModel): + effect: Literal["allow", "deny"] + all_models: bool + model_ids: list[str] class ModelApiKeyOption(ApiModel): id: str name: str + status: str | None = None + allow_all: bool | None = None + model_access: ModelApiKeyModelAccess | None = Field(default=None, exclude=True) class ModelApiKeysResponse(ApiModel): @@ -59,10 +69,25 @@ class ModelApiKeyValueResponse(ApiModel): value: str +ModelPermissionState = Literal[ + "Available", "Shutdown", "VideoGeneration", "Unsupported", "NotActivated", "Unknown" +] + + +class ModelApiKeyModelPermission(ApiModel): + name: str + state: ModelPermissionState + model_id: str | None = None + + class ModelOptionsResponse(ApiModel): provider: Literal["volcengine", "byteplus"] selected_api_key_id: str | None = None models: list[ModelOption] + api_key_model_permissions: list[ModelApiKeyModelPermission] | None = None + model_permission_catalog: dict[str, ModelApiKeyModelPermission] = Field( + default_factory=dict, exclude=True + ) __all__ = [ diff --git a/frontend/server/model_catalog/protocol.py b/frontend/server/model_catalog/protocol.py index 0dc83bb45..7d52bec48 100644 --- a/frontend/server/model_catalog/protocol.py +++ b/frontend/server/model_catalog/protocol.py @@ -26,11 +26,26 @@ class ModelCatalogError(RuntimeError): - """A sanitized, retryable failure safe to return to a Studio client.""" - - def __init__(self, message: str, *, status_code: int = 502) -> None: + """Catalog failure with the original cloud response when one was received.""" + + def __init__( + self, + message: str, + *, + status_code: int = 502, + upstream_body: str | None = None, + upstream_status: int | None = None, + upstream_request_id: str = "", + action: str = "", + source: Literal["studio", "transport", "upstream"] = "studio", + ) -> None: super().__init__(message) self.status_code = status_code + self.upstream_body = upstream_body + self.upstream_status = upstream_status + self.upstream_request_id = upstream_request_id + self.action = action + self.source = source __all__ = [ diff --git a/frontend/server/model_catalog/routes.py b/frontend/server/model_catalog/routes.py index 528e65c98..5fdcad2fa 100644 --- a/frontend/server/model_catalog/routes.py +++ b/frontend/server/model_catalog/routes.py @@ -33,6 +33,7 @@ ModelApiKeyValueResponse, ModelOptionsResponse, ) +from .errors import model_catalog_error_response from .protocol import ( CredentialResolver, ModelCatalogError, @@ -218,16 +219,15 @@ def mount_model_catalog_routes( ) async def model_api_keys( request: Request, + response: Response, refresh: bool = Query(default=False), - ) -> ModelApiKeysResponse: + ) -> ModelApiKeysResponse | Response: authorize(request) + response.headers.update(_NO_STORE_HEADERS) try: return await service.list_api_keys(force_refresh=refresh) except ModelCatalogError as error: - raise HTTPException( - status_code=error.status_code, - detail=str(error), - ) from error + return model_catalog_error_response(error) @app.post( "/web/model-api-keys/{api_key_id}/value", @@ -239,7 +239,7 @@ async def model_api_key_value( api_key_id: str, request: Request, response: Response, - ) -> ModelApiKeyValueResponse: + ) -> ModelApiKeyValueResponse | Response: try: authorize(request) except HTTPException as error: @@ -252,11 +252,7 @@ async def model_api_key_value( try: value = await service.resolve_raw_key(api_key_id) except ModelCatalogError as error: - raise HTTPException( - status_code=error.status_code, - detail=str(error), - headers=_NO_STORE_HEADERS, - ) from None + return model_catalog_error_response(error) except Exception: # noqa: BLE001 - never expose unexpected upstream details raise HTTPException( status_code=502, @@ -276,7 +272,7 @@ async def model_options( api_key_id: str | None = Query(default=None, alias="apiKeyId"), refresh: bool = Query(default=False), scope: Literal["development"] | None = Query(default=None), - ) -> ModelOptionsResponse: + ) -> ModelOptionsResponse | Response: authorize(request) try: if api_key_id is None and not refresh: @@ -300,10 +296,7 @@ async def model_options( } ) except ModelCatalogError as error: - raise HTTPException( - status_code=error.status_code, - detail=str(error), - ) from error + return model_catalog_error_response(error) __all__ = ["build_model_catalog_service", "mount_model_catalog_routes"] diff --git a/frontend/server/model_catalog/service.py b/frontend/server/model_catalog/service.py index 29641c3d1..950ef6bae 100644 --- a/frontend/server/model_catalog/service.py +++ b/frontend/server/model_catalog/service.py @@ -25,10 +25,12 @@ from .client import ModelApiKeyClient, ModelCatalogClient from .models import ( + ModelApiKeyModelPermission, ModelApiKeyOption, ModelApiKeysResponse, ModelOption, ModelOptionsResponse, + ModelPermissionState, ) from .protocol import ModelCatalogError, Provider @@ -56,6 +58,9 @@ def __init__( self._clock = clock self._cached: dict[str, tuple[ModelOptionsResponse, float]] = {} self._lock = asyncio.Lock() + self._pending_options: dict[ + tuple[str | None, bool], asyncio.Task[ModelOptionsResponse] + ] = {} async def list_api_keys( self, *, force_refresh: bool = False @@ -85,8 +90,31 @@ async def list_options( *, api_key_id: str | None = None, force_refresh: bool = False, + ) -> ModelOptionsResponse: + request_key = (api_key_id, force_refresh) + pending = self._pending_options.get(request_key) + if pending is None: + pending = asyncio.create_task( + self._list_options(api_key_id=api_key_id, force_refresh=force_refresh) + ) + self._pending_options[request_key] = pending + + def finished(task: asyncio.Task[ModelOptionsResponse]) -> None: + if self._pending_options.get(request_key) is task: + del self._pending_options[request_key] + + pending.add_done_callback(finished) + # One browser cancelling its request must not cancel other callers. + return await asyncio.shield(pending) + + async def _list_options( + self, + *, + api_key_id: str | None = None, + force_refresh: bool = False, ) -> ModelOptionsResponse: selected_key_id = api_key_id + selected_key: ModelApiKeyOption | None = None api_key: str | None = None if self._api_keys is not None: key_catalog = await self._api_keys.list_keys(force_refresh=force_refresh) @@ -101,16 +129,19 @@ async def list_options( force_refresh=force_refresh, known_keys=key_catalog, ) + selected_key = next( + key for key in key_catalog.keys if key.id == selected_key_id + ) cache_key = selected_key_id or "__legacy_default__" now = self._clock() cached = self._cached.get(cache_key) if not force_refresh and cached is not None and now < cached[1]: - return cached[0] + return apply_api_key_permissions(cached[0], selected_key) async with self._lock: now = self._clock() cached = self._cached.get(cache_key) if not force_refresh and cached is not None and now < cached[1]: - return cached[0] + return apply_api_key_permissions(cached[0], selected_key) try: list_models = ( self._client.list_models() @@ -125,9 +156,12 @@ async def list_options( provider=self._provider, selected_api_key_id=selected_key_id, models=join_model_options(activations, models), + model_permission_catalog=build_model_permission_catalog( + activations, models + ), ) except ModelCatalogError: - if cached is None: + if cached is None or force_refresh: raise logger.warning( "Refreshing the Studio model catalog failed; serving cached data." @@ -136,16 +170,16 @@ async def list_options( cached[0], self._clock() + self._ttl_seconds, ) - return cached[0] + return apply_api_key_permissions(cached[0], selected_key) self._cached[cache_key] = ( refreshed, self._clock() + self._ttl_seconds, ) - return refreshed + return apply_api_key_permissions(refreshed, selected_key) class ModelApiKeyService: - """Cache safe key metadata and raw values separately.""" + """Read key metadata live; cache raw values only on the server.""" def __init__( self, @@ -161,45 +195,40 @@ def __init__( self._default_key_name = (default_key_name or "").strip() self._ttl_seconds = ttl_seconds self._clock = clock - self._cached_keys: ModelApiKeysResponse | None = None - self._keys_expire_at = 0.0 self._raw_keys: dict[str, tuple[str, float]] = {} - self._lock = asyncio.Lock() + self._pending_keys: asyncio.Task[ModelApiKeysResponse] | None = None async def list_keys(self, *, force_refresh: bool = False) -> ModelApiKeysResponse: - now = self._clock() - if ( - not force_refresh - and self._cached_keys is not None - and now < self._keys_expire_at - ): - return self._cached_keys - async with self._lock: - now = self._clock() - if ( - not force_refresh - and self._cached_keys is not None - and now < self._keys_expire_at - ): - return self._cached_keys - raw_items = await self._client.list_keys() - keys = [ModelApiKeyOption(**item) for item in raw_items] - default_key_id = next( - ( - item.id - for item in keys - if self._default_key_name and item.name == self._default_key_name - ), - keys[0].id if keys else None, - ) - response = ModelApiKeysResponse( - provider=self._provider, - keys=keys, - default_key_id=default_key_id, - ) - self._cached_keys = response - self._keys_expire_at = self._clock() + self._ttl_seconds - return response + """Share overlapping reads only; subsequent reads still fetch live data.""" + pending = self._pending_keys + if pending is None: + pending = asyncio.create_task(self._read_keys()) + self._pending_keys = pending + + def finished(task: asyncio.Task[ModelApiKeysResponse]) -> None: + if self._pending_keys is task: + self._pending_keys = None + + pending.add_done_callback(finished) + return await asyncio.shield(pending) + + async def _read_keys(self) -> ModelApiKeysResponse: + raw_items = await self._client.list_keys() + keys = [ModelApiKeyOption(**item) for item in raw_items] + selectable = [item for item in keys if item.status != "Restricted"] + default_key_id = next( + ( + item.id + for item in selectable + if self._default_key_name and item.name == self._default_key_name + ), + selectable[0].id if selectable else None, + ) + return ModelApiKeysResponse( + provider=self._provider, + keys=keys, + default_key_id=default_key_id, + ) async def resolve_raw_key( self, @@ -214,6 +243,13 @@ async def resolve_raw_key( "所选 API Key 不存在或已被删除,请刷新后重新选择。", status_code=404, ) + if any( + item.id == key_id and item.status == "Restricted" for item in catalog.keys + ): + raise ModelCatalogError( + "所选 API Key 已禁用,请在控制台启用或选择其他 API Key", + status_code=400, + ) now = self._clock() cached = self._raw_keys.get(key_id) if not force_refresh and cached is not None and now < cached[1]: @@ -226,6 +262,91 @@ async def resolve_raw_key( return raw_key +def apply_api_key_permissions( + response: ModelOptionsResponse, + key: ModelApiKeyOption | None, +) -> ModelOptionsResponse: + if key is None or key.allow_all is None: + return response + permissions = None + rule = key.model_access + if not key.allow_all and rule and rule.effect == "allow" and not rule.all_models: + permissions = [ + response.model_permission_catalog.get( + name, ModelApiKeyModelPermission(name=name, state="Unknown") + ) + for name in dict.fromkeys(rule.model_ids) + ] + models = [] + for model in response.models: + allowed = key.allow_all + if not allowed and key.model_access is not None: + rule = key.model_access + matches = rule.all_models or bool( + {model.name, model.id}.intersection(rule.model_ids) + ) + allowed = matches if rule.effect == "allow" else not matches + models.append( + model.model_copy( + update={ + "api_key_allowed": allowed, + "available": model.available and allowed, + } + ) + ) + return response.model_copy( + update={ + "api_key_model_permissions": permissions, + "models": sorted( + models, + key=lambda model: ( + model.api_key_allowed is False, + not model.available, + model.display_name.casefold(), + model.id, + ), + ), + } + ) + + +def build_model_permission_catalog( + activations: list[dict[str, Any]], models: list[dict[str, Any]] +) -> dict[str, ModelApiKeyModelPermission]: + """Explain every explicitly granted model, including non-agent and retired models.""" + activated = { + _text(item, "FoundationModelName") + for item in activations + if _text(item, "State") == "Available" + } + catalog: dict[str, ModelApiKeyModelPermission] = {} + priority = { + "Available": 0, + "NotActivated": 1, + "VideoGeneration": 2, + "Unsupported": 3, + "Shutdown": 4, + } + for model in models: + name = _text(model, "name") + state: ModelPermissionState + if _text(model, "status") == "Shutdown": + state = "Shutdown" + elif _text(model, "domain") == "VideoGeneration": + state = "VideoGeneration" + elif not _supports_agent(model): + state = "Unsupported" + else: + state = "Available" if name in activated else "NotActivated" + for identifier in {name, _text(model, "id")} - {""}: + current = catalog.get(identifier) + if current is None or priority[state] < priority[current.state]: + catalog[identifier] = ModelApiKeyModelPermission( + name=identifier, state=state, model_id=_text(model, "id") or None + ) + return catalog + + def join_model_options( activations: list[dict[str, Any]], models: list[dict[str, Any]], diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index 835948dbc..3841b7ada 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -50,17 +50,28 @@ export interface ModelOption { activationState: string; lifecycleStatus: string; available: boolean; + apiKeyAllowed?: boolean; + unavailableReason?: ModelApiKeyModelPermission["state"]; } export interface ModelOptionsResponse { provider: CloudProvider; selectedApiKeyId?: string; models: ModelOption[]; + apiKeyModelPermissions?: ModelApiKeyModelPermission[]; +} + +export interface ModelApiKeyModelPermission { + name: string; + modelId?: string; + state: "Available" | "Shutdown" | "VideoGeneration" | "Unsupported" | "NotActivated" | "Unknown"; } export interface ModelApiKeyOption { id: string; name: string; + status?: string; + allowAll?: boolean; } export interface ModelApiKeysResponse { @@ -460,6 +471,7 @@ async function apiFetch( }; const requiresLogin = async (response: Response) => { + if (response.headers.get("X-Studio-Error-Source") === "upstream") return false; if (isAuthenticationRedirect(response)) return true; if (response.status !== 401) return false; try { @@ -511,12 +523,26 @@ export async function httpErrorMessage( res: Response, fallback: string, ): Promise { - const context = adkT("common.fallbackWithHttpStatus", { fallback, status: res.status }); + const source = res.headers.get("X-Studio-Error-Source"); + const status = source === "upstream" + ? res.headers.get("X-Studio-Upstream-Status") ?? res.status + : res.status; + const context = adkT("common.fallbackWithHttpStatus", { fallback, status }); const text = await res.text().catch(() => ""); + if (source === "upstream") { + return adkT("client.errorWithCloudResponse", { + context, + action: res.headers.get("X-Studio-Upstream-Action") ?? "", + response: text || adkT("client.emptyCloudResponse"), + }); + } if (!text) return context; try { const data = JSON.parse(text) as { detail?: unknown; error?: unknown }; const detail = formatErrorDetail(data.detail ?? data.error); + if (source === "transport" || source === "studio") { + return detail ? `${context}\n${detail}` : context; + } return detail ? adkT("client.errorWithDetailAndRawResponse", { context, detail, response: text }) : adkT("client.errorWithRawResponse", { context, response: text }); diff --git a/frontend/src/create/CustomCreate.css b/frontend/src/create/CustomCreate.css index 766593008..cafdffdf5 100644 --- a/frontend/src/create/CustomCreate.css +++ b/frontend/src/create/CustomCreate.css @@ -2830,9 +2830,14 @@ scrollbar-gutter: stable; } .cw-model-key-option { + min-height: 48px; justify-content: space-between; gap: 12px; } +.cw-model-key-option:disabled { + cursor: not-allowed; + opacity: 0.6; +} .cw-model-key-option > span { min-width: 0; overflow: hidden; diff --git a/frontend/src/create/CustomCreate.tsx b/frontend/src/create/CustomCreate.tsx index 126a17244..159951250 100644 --- a/frontend/src/create/CustomCreate.tsx +++ b/frontend/src/create/CustomCreate.tsx @@ -93,6 +93,7 @@ import { isOrchestratorType, } from "./agentTypeMeta"; import { localPickerMatches } from "./localPickerSearch"; +import { modelApiKeyDescription, modelApiKeyMatches, modelOptionsWithPermissions } from "./modelApiKeyPresentation"; import { draftToYaml } from "./configYaml"; import { deploymentMcpSecretValues, @@ -738,6 +739,8 @@ function vikingMemoryDisplayName(item: VikingMemoryRef, fallback = createT("trad } function modelAvailabilityKey(model: ModelOption): string { + if (model.apiKeyAllowed === false) return "modelApiKey.noModelPermission"; + if (model.unavailableReason) return `modelApiKey.permissionState.${model.unavailableReason}`; if (model.available) return "traditional.model.available"; if (model.lifecycleStatus === "Retiring") return "traditional.model.retiring"; if (model.activationState && model.activationState !== "Available") { @@ -747,7 +750,9 @@ function modelAvailabilityKey(model: ModelOption): string { } function isModelSelectable(model: ModelOption): boolean { - return model.available || model.lifecycleStatus === "Retiring"; + return model.apiKeyAllowed !== false && + !model.unavailableReason && + (model.available || model.lifecycleStatus === "Retiring"); } interface ModelMenuPosition { @@ -1020,7 +1025,7 @@ function ModelOptionSelect({ response.keys.find((key) => key.id === apiKeyId) ?? response.keys.find((key) => key.name === apiKeyName) ?? response.keys.find((key) => key.id === response.defaultKeyId) ?? - response.keys[0]; + response.keys.find((key) => key.status !== "Restricted"); if (selected) onApiKeyChange(selected); }) .catch((err) => { @@ -1052,8 +1057,13 @@ function ModelOptionSelect({ }) .then((response) => { if (!controller.signal.aborted) { - setModels(response.models); + const nextModels = modelOptionsWithPermissions(response.models, response.apiKeyModelPermissions); + setModels(nextModels); setModelsApiKeyId(apiKeyId); + const denied = new Set(nextModels.filter((model) => model.apiKeyAllowed === false || model.unavailableReason).map((model) => model.id)); + if (denied.has(value.trim())) onChange(""); + const permittedFallbacks = fallbacks.filter((fallback) => typeof fallback !== "string" || !denied.has(fallback)); + if (permittedFallbacks.length !== fallbacks.length) onFallbacksChange(permittedFallbacks); } }) .catch((err) => { @@ -1072,7 +1082,7 @@ function ModelOptionSelect({ const visibleModels = modelsAreCurrent ? models : []; const selectedApiKey = apiKeys.find((key) => key.id === apiKeyId); const selectedApiKeyLabel = selectedApiKey - ? selectedApiKey.name + ? selectedApiKey.name || t("modelApiKey.unnamed") : apiKeyId ? t("traditional.model.currentApiKey") : keysLoading @@ -1083,9 +1093,9 @@ function ModelOptionSelect({ const filteredApiKeys = useMemo( () => apiKeys.filter((key) => - localPickerMatches(apiKeySearchQuery, [key.name]), + modelApiKeyMatches(apiKeySearchQuery, key.name, modelApiKeyDescription(key, t)), ), - [apiKeySearchQuery, apiKeys], + [apiKeySearchQuery, apiKeys, t], ); const selectedModel = visibleModels.find( (model) => model.id === normalizedValue, @@ -1168,7 +1178,7 @@ function ModelOptionSelect({ menuAriaLabel={t("traditional.model.apiKeyList")} searchAriaLabel={t("traditional.model.searchApiKey")} searchValue={apiKeySearchQuery} - searchPlaceholder={t("traditional.model.searchApiKeyName")} + searchPlaceholder={t("modelApiKey.search")} onSearchChange={setApiKeySearchQuery} empty={filteredApiKeys.length === 0} emptyLabel={t("traditional.model.noMatchingApiKey")} @@ -1176,23 +1186,28 @@ function ModelOptionSelect({ renderOptions={(closeMenu) => filteredApiKeys.map((key) => { const selected = key.id === apiKeyId; + const description = modelApiKeyDescription(key, t); return ( ); }) @@ -1242,7 +1257,7 @@ function ModelOptionSelect({ const selected = model.id === normalizedValue; const selectable = isModelSelectable(model); const activationRequired = - !selectable && model.activationState !== "Available"; + model.apiKeyAllowed !== false && !model.unavailableReason && !selectable && model.activationState !== "Available"; if (activationRequired) { return (