feat(provider): 完成阶段E协议适配、国内预设与模型路由

This commit is contained in:
2026-09-04 06:19:32 +08:00
parent 9b8b10cdb1
commit 1fe75e3fd2
57 changed files with 4208 additions and 592 deletions
@@ -0,0 +1,40 @@
Lobe Icons — Copyright (c) 2023 LobeHub. MIT license; see LICENSE.
Source: https://github.com/lobehub/lobe-icons
Revision: 4aaf4ee1fb2678a7f989ea570f0f6ce14a9abf75
Source directory: packages/static-svg/icons/
Assets are bundled locally. Brand trademarks belong to their respective owners.
File mapping (local: upstream):
anthropic.svg: anthropic.svg
baidu.svg: baidu-color.svg
deepseek.svg: deepseek-color.svg
hunyuan.svg: hunyuan-color.svg
kimi.svg: kimi-color.svg
minimax.svg: minimax-color.svg
ollama.svg: ollama.svg
openai.svg: openai.svg
qwen.svg: qwen-color.svg
siliconflow.svg: siliconcloud-color.svg
stepfun.svg: stepfun-color.svg
volcengine.svg: volcengine-color.svg
zhipu.svg: zhipu-color.svg
MIT License
Copyright (c) 2023 LobeHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,19 @@
Lobe Icons — Copyright (c) 2023 LobeHub. MIT license; see LICENSE.
Source: https://github.com/lobehub/lobe-icons
Revision: 4aaf4ee1fb2678a7f989ea570f0f6ce14a9abf75
Source directory: packages/static-svg/icons/
Assets are bundled locally. Brand trademarks belong to their respective owners.
File mapping (local: upstream):
anthropic.svg: anthropic.svg
baidu.svg: baidu-color.svg
deepseek.svg: deepseek-color.svg
hunyuan.svg: hunyuan-color.svg
kimi.svg: kimi-color.svg
minimax.svg: minimax-color.svg
ollama.svg: ollama.svg
openai.svg: openai.svg
qwen.svg: qwen-color.svg
siliconflow.svg: siliconcloud-color.svg
stepfun.svg: stepfun-color.svg
volcengine.svg: volcengine-color.svg
zhipu.svg: zhipu-color.svg
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 LobeHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Baidu</title><path d="M8.859 11.735c1.017-1.71 4.059-3.083 6.202.286 1.579 2.284 4.284 4.397 4.284 4.397s2.027 1.601.73 4.684c-1.24 2.956-5.64 1.607-6.005 1.49l-.024-.009s-1.746-.568-3.776-.112c-2.026.458-3.773.286-3.773.286l-.045-.001c-.328-.01-2.38-.187-3.001-2.968-.675-3.028 2.365-4.687 2.592-4.968.226-.288 1.802-1.37 2.816-3.085zm.986 1.738v2.032h-1.64s-1.64.138-2.213 2.014c-.2 1.252.177 1.99.242 2.148.067.157.596 1.073 1.927 1.342h3.078v-7.514l-1.394-.022zm3.588 2.191l-1.44.024v3.956s.064.985 1.44 1.344h3.541v-5.3h-1.528v3.979h-1.46s-.466-.068-.553-.447v-3.556zM9.82 16.715v3.06H8.58s-.863-.045-1.126-1.049c-.136-.445.02-.959.088-1.16.063-.203.353-.671.951-.85H9.82zm9.525-9.036c2.086 0 2.646 2.06 2.646 2.742 0 .688.284 3.597-2.309 3.655-2.595.057-2.704-1.77-2.704-3.08 0-1.374.277-3.317 2.367-3.317zM4.24 6.08c1.523-.135 2.645 1.55 2.762 2.513.07.625.393 3.486-1.975 4-2.364.515-3.244-2.249-2.984-3.544 0 0 .28-2.797 2.197-2.969zm8.847-1.483c.14-1.31 1.69-3.316 2.931-3.028 1.236.285 2.367 1.944 2.137 3.37-.224 1.428-1.345 3.313-3.095 3.082-1.748-.226-2.143-1.823-1.973-3.424zM9.425 1c1.307 0 2.364 1.519 2.364 3.398 0 1.879-1.057 3.4-2.364 3.4s-2.367-1.521-2.367-3.4C7.058 2.518 8.118 1 9.425 1z" fill="#2932E1" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Hunyuan</title><circle cx="12" cy="12" fill="#0055E9" r="12"></circle><path d="M12 0c.518 0 1.028.033 1.528.096A6.188 6.188 0 0112.12 12.28l-.12.001c-2.99 0-5.242 2.179-5.554 5.11-.223 2.086.353 4.412 2.242 6.146C3.672 22.1 0 17.479 0 12 0 5.373 5.373 0 12 0z" fill="#A8DFF5"></path><path d="M5.286 5a2.438 2.438 0 01.682 3.38c-3.962 5.966-3.215 10.743 2.648 15.136C3.636 22.056 0 17.452 0 12c0-1.787.39-3.482 1.09-5.006.253-.435.525-.872.817-1.311A2.438 2.438 0 015.286 5z" fill="#0055E9"></path><path d="M12.98.04c.272.021.543.053.81.093.583.106 1.117.254 1.538.44 6.638 2.927 8.07 10.052 1.748 15.642a4.125 4.125 0 01-5.822-.358c-1.51-1.706-1.3-4.184.357-5.822.858-.848 3.108-1.223 4.045-2.441 1.257-1.634 2.122-6.009-2.523-7.506L12.98.039z" fill="#00BCFF"></path><path d="M13.528.096A6.187 6.187 0 0112 12.281a5.75 5.75 0 00-1.71.255c.147-.905.595-1.784 1.321-2.501.858-.848 3.108-1.223 4.045-2.441 1.27-1.651 2.14-6.104-2.676-7.554.184.014.367.033.548.056z" fill="#ECECEE"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="#fff"></path></svg>

After

Width:  |  Height:  |  Size: 773 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-_R_0_" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-_R_0_)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-_R_0_)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-_R_0_" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z" fill="#6E29F6" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 520 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Stepfun</title><path d="M22.012 0h1.032v.927H24v.968h-.956V3.78h-1.032V1.896h-1.878v-.97h1.878V0zM2.6 12.371V1.87h.969v10.502h-.97zm10.423.66h10.95v.918h-6.208v9.579h-4.742V13.03zM5.629 3.333v12.356H0v4.51h10.386V8L20.859 8l-.003-4.668-15.227.001z" fill="url(#lobe-icons-stepfun-_R_0_)" fill-rule="evenodd"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-stepfun-_R_0_" x1="1.646" x2="18.342" y1="1.916" y2="22.091"><stop stop-color="#01A9FF"></stop><stop offset="1" stop-color="#0160FF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 676 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Volcengine</title><path d="M19.44 10.153l-2.936 11.586a.215.215 0 00.214.261h5.87a.215.215 0 00.214-.261l-2.95-11.586a.214.214 0 00-.412 0zM3.28 12.778l-2.275 8.96A.214.214 0 001.22 22h4.532a.212.212 0 00.214-.165.214.214 0 000-.097l-2.276-8.96a.214.214 0 00-.41 0z" fill="#00E5E5"></path><path d="M7.29 5.359L3.148 21.738a.215.215 0 00.203.261h8.29a.214.214 0 00.215-.261L7.7 5.358a.214.214 0 00-.41 0z" fill="#006EFF"></path><path d="M14.44.15a.214.214 0 00-.41 0L8.366 21.739a.214.214 0 00.214.261H19.9a.216.216 0 00.171-.078.214.214 0 00.044-.183L14.439.15z" fill="#006EFF"></path><path d="M10.278 7.741L6.685 21.736a.214.214 0 00.214.264h7.17a.215.215 0 00.214-.264L10.688 7.741a.214.214 0 00-.41 0z" fill="#00E5E5"></path></svg>

After

Width:  |  Height:  |  Size: 858 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+36
View File
@@ -416,6 +416,39 @@ export interface ProviderPreset {
base_url: string
default_credential_id?: string | null
requires_credential: boolean
logo_id?: string
description?: string
capabilities?: string[]
}
export type ProviderUpdateRequest = Partial<Omit<ProviderConfig, 'provider_id' | 'credential_id' | 'base_url'>> & {
credential_id?: string | null
base_url?: string | null
}
export type RoutingCapability = 'embedding' | 'transcription' | 'speaker_matching'
export interface ModelBinding {
provider_id: string
model: string
endpoint: string
dimensions?: number | null
}
export interface ModelRoutingConfig {
version: number
embedding: ModelBinding | null
transcription: ModelBinding | null
speaker_matching: ModelBinding | null
}
export interface ModelRoutingResponse {
config: ModelRoutingConfig
local_backends: Array<{
capability: RoutingCapability
status: 'placeholder' | 'not_installed' | 'ready'
message: string
}>
}
// ============ Tasks ============
@@ -721,6 +754,9 @@ export interface ApiProviderPreset {
base_url: string
default_credential_id?: string | null
requires_credential: boolean
logo_id?: string
description?: string
capabilities?: string[]
}
export interface ApiModelInfo {
@@ -0,0 +1,170 @@
// @vitest-environment happy-dom
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ModelRoutingResponse, ProviderConfig } from '@/contracts'
import { ApiErrorClass } from '@/services/apiClient'
import * as service from '@/services/modelRoutingService'
import { listProviders } from '@/services/providerService'
import ModelRoutingSettings from './ModelRoutingSettings.vue'
vi.mock('@/services/modelRoutingService', () => ({ getModelRouting: vi.fn(), saveModelRouting: vi.fn() }))
vi.mock('@/services/providerService', () => ({ listProviders: vi.fn() }))
const providers: ProviderConfig[] = [
{ provider_id: 'p1', provider_type: 'openai_compatible', name: 'Custom API', enabled: true, default_model: 'chat-model', capabilities: {}, has_credential: true },
{ provider_id: 'p2', provider_type: 'openai_chat', name: 'OpenAI', enabled: true, default_model: '', capabilities: {}, has_credential: true },
{ provider_id: 'responses', provider_type: 'openai_responses', name: 'Responses', enabled: true, default_model: '', capabilities: {}, has_credential: true },
{ provider_id: 'anthropic', provider_type: 'anthropic_messages', name: 'Anthropic', enabled: true, default_model: '', capabilities: {}, has_credential: true },
{ provider_id: 'ollama', provider_type: 'ollama', name: 'Ollama', enabled: true, default_model: '', capabilities: {}, has_credential: false },
{ provider_id: 'disabled', provider_type: 'openai_chat', name: 'Disabled', enabled: false, default_model: '', capabilities: {}, has_credential: true },
]
const initial: ModelRoutingResponse = { config: { version: 3, embedding: null, transcription: null, speaker_matching: null }, local_backends: [
{ capability: 'embedding', status: 'placeholder', message: 'hash fallback' },
{ capability: 'transcription', status: 'not_installed', message: 'ASR not installed' },
{ capability: 'speaker_matching', status: 'not_installed', message: 'speaker not installed' },
] }
const wrappers: ReturnType<typeof mount>[] = []
async function render() {
const wrapper = mount(ModelRoutingSettings)
wrappers.push(wrapper)
await flushPromises()
return wrapper
}
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(service.getModelRouting).mockResolvedValue(structuredClone(initial))
vi.mocked(listProviders).mockResolvedValue(providers)
vi.mocked(service.saveModelRouting).mockImplementation(async config => ({ ...initial, config: { ...config, version: config.version + 1 } }))
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
describe('ModelRoutingSettings', () => {
it('loads local selections honestly, explains index rebuilds, and disables incompatible providers', async () => {
const wrapper = await render()
expect(wrapper.findAll('select').map(select => (select.element as HTMLSelectElement).value)).toEqual(['', '', ''])
expect(wrapper.text()).toContain('当前为占位实现')
expect(wrapper.text()).toContain('真实本地 ASR 尚未接入')
expect(wrapper.text()).toContain('真实本地说话人匹配尚未接入')
expect(wrapper.text()).toContain('重建全部')
expect(wrapper.text()).toContain('重建完成前继续使用本地检索')
expect(wrapper.text()).toContain('不是 OpenAI 标准接口')
for (const id of ['responses', 'anthropic', 'ollama', 'disabled']) expect(wrapper.get(`option[value="${id}"]`).attributes()).toHaveProperty('disabled')
expect(wrapper.get('option[value="p1"]').attributes()).not.toHaveProperty('disabled')
})
it('saves all three independent bindings with expected version and remote dimensions', async () => {
const wrapper = await render()
for (const capability of ['embedding', 'transcription', 'speaker_matching']) {
const card = wrapper.get(`[data-capability="${capability}"]`)
await card.get('select').setValue('p1')
await card.get('[data-field="model"]').setValue(`${capability}-model`)
}
await wrapper.get('[data-field="dimensions"]').setValue('3072')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenCalledWith({ version: 3,
embedding: { provider_id: 'p1', model: 'embedding-model', endpoint: '/embeddings', dimensions: 3072 },
transcription: { provider_id: 'p1', model: 'transcription-model', endpoint: '/audio/transcriptions' },
speaker_matching: { provider_id: 'p1', model: 'speaker_matching-model', endpoint: '/audio/speaker-matches' },
})
expect(wrapper.text()).toContain('配置版本 4')
expect(wrapper.text()).toContain('模型路由已保存')
await wrapper.get('[data-capability="embedding"] select').setValue('')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 4, embedding: null }))
})
it('supports omitted dimensions and clears stale models/endpoints when switching providers', async () => {
const wrapper = await render()
const card = wrapper.get('[data-capability="embedding"]')
await card.get('select').setValue('p1')
await card.get('[data-field="model"]').setValue('custom-embedding')
await card.get('[data-field="endpoint"]').setValue('/custom/embeddings')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenCalledWith(expect.objectContaining({ embedding: { provider_id: 'p1', model: 'custom-embedding', endpoint: '/custom/embeddings', dimensions: null } }))
await card.get('select').setValue('p2')
expect((card.get('[data-field="model"]').element as HTMLInputElement).value).toBe('')
expect((card.get('[data-field="endpoint"]').element as HTMLInputElement).value).toBe('/embeddings')
})
it('shows a loading state and does not offer a default local configuration after load failure', async () => {
let fail!: (error: Error) => void
vi.mocked(service.getModelRouting).mockReturnValueOnce(new Promise((_, reject) => { fail = reject }))
const wrapper = await render()
expect(wrapper.text()).toContain('正在加载模型路由')
expect(wrapper.find('form').exists()).toBe(false)
fail(new Error('offline'))
await flushPromises()
expect(wrapper.text()).toContain('加载失败:offline')
expect(wrapper.find('form').exists()).toBe(false)
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.find('form').exists()).toBe(true)
})
it('keeps unsaved input on save failure and retries without inventing a new version', async () => {
const wrapper = await render()
const card = wrapper.get('[data-capability="transcription"]')
await card.get('select').setValue('p1')
await card.get('[data-field="model"]').setValue('asr-model')
vi.mocked(service.saveModelRouting).mockRejectedValueOnce(new Error('disk full'))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('保存失败:disk full')
expect((card.get('[data-field="model"]').element as HTMLInputElement).value).toBe('asr-model')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 3 }))
})
it('blocks overwrite after a conflict until explicitly reloading the latest configuration', async () => {
const wrapper = await render()
vi.mocked(service.saveModelRouting).mockRejectedValueOnce(new ApiErrorClass('MODEL_ROUTING_VERSION_CONFLICT', 'stale'))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('配置版本冲突')
expect(wrapper.get('button[type="submit"]').attributes()).toHaveProperty('disabled')
await wrapper.get('form').trigger('submit')
expect(service.saveModelRouting).toHaveBeenCalledTimes(1)
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, config: { ...initial.config, version: 8 } })
await wrapper.findAll('button').find(button => button.text().includes('放弃当前输入'))!.trigger('click')
await flushPromises()
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.saveModelRouting).toHaveBeenLastCalledWith(expect.objectContaining({ version: 8 }))
})
it('prevents invalid dimensions, endpoints, and missing provider bindings from being saved', async () => {
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, config: { ...initial.config, embedding: { provider_id: 'missing', model: 'old-model', endpoint: '/embeddings' } } })
const wrapper = await render()
expect(wrapper.text()).toContain('原提供商已不可用')
await wrapper.get('form').trigger('submit')
expect(service.saveModelRouting).not.toHaveBeenCalled()
const card = wrapper.get('[data-capability="embedding"]')
await card.get('select').setValue('p1')
await card.get('[data-field="model"]').setValue('embedding-model')
await card.get('[data-field="dimensions"]').setValue('1.5')
await wrapper.get('form').trigger('submit')
expect(wrapper.text()).toContain('嵌入维度必须为 116384 的整数')
await card.get('[data-field="dimensions"]').setValue('16385')
await wrapper.get('form').trigger('submit')
expect(service.saveModelRouting).not.toHaveBeenCalled()
expect(card.get('[data-field="dimensions"]').attributes('max')).toBe('16384')
await card.get('[data-field="dimensions"]').setValue('')
await card.get('[data-field="endpoint"]').setValue('https://example.test/embeddings')
await wrapper.get('form').trigger('submit')
expect(wrapper.text()).toContain('Endpoint 必须是以 / 开头的相对路径')
expect(service.saveModelRouting).not.toHaveBeenCalled()
})
it('labels injected ready local backends accurately', async () => {
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, local_backends: [{ capability: 'transcription', status: 'ready', message: 'Local ASR ready' }] })
const wrapper = await render()
const card = wrapper.get('[data-capability="transcription"]')
expect(card.get('option[value=""]').text()).toBe('本地 · 已就绪')
expect(card.text()).toContain('本地后端已就绪')
expect(card.text()).toContain('Local ASR ready')
expect(card.text()).not.toContain('真实本地 ASR 尚未接入')
})
})
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import type { ModelBinding, ModelRoutingConfig, ModelRoutingResponse, ProviderConfig, RoutingCapability } from '@/contracts'
import { getModelRouting, saveModelRouting } from '@/services/modelRoutingService'
import { listProviders } from '@/services/providerService'
import { ApiErrorClass } from '@/services/apiClient'
const capabilities: Array<{ id: RoutingCapability; name: string; endpoint: string; placeholder: string; local: string }> = [
{ id: 'embedding', name: '向量嵌入 · Embedding', endpoint: '/embeddings', placeholder: '例如 text-embedding-3-small', local: '当前为占位实现,尚未接入真实本地嵌入模型。' },
{ id: 'transcription', name: '语音转写 · Transcription', endpoint: '/audio/transcriptions', placeholder: '输入转写模型 ID', local: '真实本地 ASR 尚未接入,等待阶段 F;当前无法进行本地语音识别。' },
{ id: 'speaker_matching', name: '说话人匹配 · Speaker matching', endpoint: '/audio/speaker-matches', placeholder: '输入说话人匹配模型 ID', local: '真实本地说话人匹配尚未接入,等待阶段 F;当前无法进行本地声纹匹配。' },
]
type Draft = { provider_id: string; model: string; endpoint: string; dimensions: string | number }
const drafts = reactive(Object.fromEntries(capabilities.map(item => [item.id, { provider_id: '', model: '', endpoint: item.endpoint, dimensions: '' }])) as Record<RoutingCapability, Draft>)
const providers = ref<ProviderConfig[]>([])
const response = ref<ModelRoutingResponse | null>(null)
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const saved = ref(false)
const conflict = ref(false)
let active = true
const eligible = (provider: ProviderConfig) => provider.enabled && ['openai_chat', 'openai_compatible'].includes(provider.provider_type)
const available = computed(() => providers.value.filter(eligible))
const unavailable = computed(() => providers.value.filter(provider => !eligible(provider)))
const localBackend = (capability: RoutingCapability) => response.value?.local_backends.find(item => item.capability === capability)
const localLabel = (capability: RoutingCapability) => {
const status = localBackend(capability)?.status
return status === 'ready' ? '已就绪' : status === 'placeholder' ? '占位实现' : '尚未接入'
}
const protocols = [
{ id: 'openai_chat', label: 'OpenAI Chat' }, { id: 'openai_compatible', label: 'OpenAI Compatible' },
{ id: 'openai_responses', label: 'Responses' }, { id: 'anthropic_messages', label: 'Anthropic' }, { id: 'ollama', label: 'Ollama' },
]
function applyResponse(result: ModelRoutingResponse) {
response.value = result
for (const item of capabilities) {
const binding = result.config[item.id]
Object.assign(drafts[item.id], { provider_id: binding?.provider_id ?? '', model: binding?.model ?? '', endpoint: binding?.endpoint ?? item.endpoint, dimensions: binding?.dimensions?.toString() ?? '' })
}
}
async function load() {
if (loading.value || saving.value) return
loading.value = true
error.value = ''
saved.value = false
try {
const [routing, items] = await Promise.all([getModelRouting(), listProviders()])
if (!active) return
providers.value = items
applyResponse(routing)
conflict.value = false
} catch (reason) {
if (active) error.value = `加载失败:${reason instanceof Error ? reason.message : '无法读取模型路由或提供商'}`
} finally { loading.value = false }
}
onMounted(load)
onBeforeUnmount(() => { active = false })
function changeProvider(capability: RoutingCapability) {
const draft = drafts[capability]
draft.model = ''
draft.dimensions = ''
draft.endpoint = capabilities.find(item => item.id === capability)!.endpoint
saved.value = false
}
function bindingFor(capability: RoutingCapability): ModelBinding | null {
const draft = drafts[capability]
if (!draft.provider_id) return null
if (!available.value.some(provider => provider.provider_id === draft.provider_id)) throw new Error('请选择已启用且协议可用的提供商,或切换到本地。')
if (!draft.model.trim()) throw new Error('请填写所选 API 的模型 ID。')
if (!/^\/[A-Za-z0-9_/-]+$/.test(draft.endpoint) || draft.endpoint.startsWith('//')) throw new Error('Endpoint 必须是以 / 开头的相对路径,只能包含字母、数字、下划线、连字符和 /。')
const binding: ModelBinding = { provider_id: draft.provider_id, model: draft.model.trim(), endpoint: draft.endpoint }
if (capability === 'embedding') {
const dimension = String(draft.dimensions).trim()
if (dimension && (!/^\d+$/.test(dimension) || !Number.isSafeInteger(Number(dimension)) || Number(dimension) < 1 || Number(dimension) > 16384)) throw new Error('嵌入维度必须为 1–16384 的整数,或留空使用 API 默认值。')
binding.dimensions = dimension ? Number(dimension) : null
}
return binding
}
async function save() {
if (!response.value || loading.value || saving.value || conflict.value) return
saving.value = true
error.value = ''
saved.value = false
try {
const config: ModelRoutingConfig = {
version: response.value.config.version,
embedding: bindingFor('embedding'), transcription: bindingFor('transcription'), speaker_matching: bindingFor('speaker_matching'),
}
const result = await saveModelRouting(config)
if (active) { applyResponse(result); saved.value = true }
} catch (reason) {
if (!active) return
conflict.value = reason instanceof ApiErrorClass && /CONFLICT|VERSION|HTTP_409/i.test(reason.code)
error.value = conflict.value
? '配置版本冲突:其他窗口已修改路由。当前输入尚未保存,请重新加载最新配置后再编辑。'
: `保存失败:${reason instanceof Error ? reason.message : '请重试'}`
} finally { saving.value = false }
}
</script>
<template>
<section class="routing-settings" aria-labelledby="routing-title" :aria-busy="loading || saving">
<div><h2 id="routing-title">能力模型路由</h2><p class="subtle">向量嵌入语音转写和说话人匹配分别选择提供商与模型独立于默认聊天模型API Key 模型提供商中管理</p></div>
<p class="subtle">未选择提供商即使用本地路径API 请求失败配置不可用或响应无效时服务端会回退到当前本地处理本地占位不代表真实模型已接入</p>
<p v-if="loading" role="status">正在加载模型路由</p>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div class="inline-actions"><button type="button" class="button-secondary" :disabled="loading || saving" @click="load">{{ conflict ? '放弃当前输入并加载最新配置' : response ? '重新加载放弃未保存更改' : '重试加载' }}</button><span v-if="response" class="subtle">配置版本 {{ response.config.version }}</span></div>
<form v-if="response" @submit.prevent="save" @input="saved = false" @change="saved = false">
<fieldset :disabled="loading || saving || conflict">
<article v-for="capability in capabilities" :key="capability.id" class="routing-card" :data-capability="capability.id">
<h3>{{ capability.name }}</h3>
<p v-if="capability.id === 'embedding'" class="embedding-notice">更换模型或接口后请重建全部索引重建完成前继续使用本地检索</p>
<div class="protocols" aria-label="协议可用性">
<span v-for="protocol in protocols" :key="protocol.id" class="badge" :class="{ 'protocol-unavailable': !['openai_chat', 'openai_compatible'].includes(protocol.id) }">{{ protocol.label }}{{ ['openai_chat', 'openai_compatible'].includes(protocol.id) ? ' · 可用' : ' · 不可用' }}</span>
</div>
<label class="field"><span>处理方式 / 提供商</span><select v-model="drafts[capability.id].provider_id" class="select" data-field="provider" @change="changeProvider(capability.id)">
<option value="">本地 · {{ localLabel(capability.id) }}</option>
<option v-for="provider in available" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }} · {{ provider.provider_type }}</option>
<option v-for="provider in unavailable" :key="provider.provider_id" :value="provider.provider_id" disabled>{{ provider.name }} · {{ provider.enabled ? '协议不可用' : '未启用' }}</option>
<option v-if="drafts[capability.id].provider_id && !providers.some(provider => provider.provider_id === drafts[capability.id].provider_id)" :value="drafts[capability.id].provider_id" disabled>原提供商已不可用 · {{ drafts[capability.id].provider_id }}</option>
</select></label>
<div v-if="drafts[capability.id].provider_id" class="routing-fields">
<label class="field"><span>模型 ID</span><input v-model="drafts[capability.id].model" class="input" data-field="model" :placeholder="capability.placeholder" maxlength="256" required /></label>
<label class="field"><span>Endpoint相对 Base URL</span><input v-model="drafts[capability.id].endpoint" class="input" data-field="endpoint" :placeholder="capability.endpoint" maxlength="256" required /></label>
<label v-if="capability.id === 'embedding'" class="field"><span>向量维度(可选)</span><input v-model="drafts.embedding.dimensions" class="input" data-field="dimensions" type="number" min="1" max="16384" step="1" placeholder="留空使用 API 默认维度" /><small class="subtle">填写模型支持的 116384 整数维度或留空使用 API 默认值</small></label>
</div>
<p v-if="capability.id === 'speaker_matching'" class="subtle">说话人匹配使用本应用自定义 HTTP multipart 契约该端点不是 OpenAI 标准接口服务需实现对应的说话人匹配请求和响应</p>
<div class="local-status" :class="{ selected: !drafts[capability.id].provider_id }">
<strong>{{ drafts[capability.id].provider_id ? '本地回退状态' : '当前本地状态' }}</strong>
<p>{{ localBackend(capability.id)?.status === 'ready' ? '本地后端已就绪。' : capability.local }}</p>
<p v-for="backend in response.local_backends.filter(item => item.capability === capability.id)" :key="backend.capability" class="subtle"><span class="badge">{{ backend.status === 'ready' ? '已就绪' : backend.status === 'placeholder' ? '占位实现' : '未安装 / 未接入' }}</span> {{ backend.message }}</p>
</div>
</article>
</fieldset>
<div class="inline-actions"><button type="submit" class="button-primary" :disabled="loading || saving || conflict">{{ saving ? '保存中…' : '保存模型路由' }}</button><span v-if="saved" role="status">模型路由已保存</span></div>
</form>
</section>
</template>
<style scoped>
.routing-settings, form, fieldset { display: grid; gap: var(--space-lg); }
.routing-settings { border-top: 1px solid var(--color-border-default); padding-top: var(--space-xl); margin-top: var(--space-md); }
fieldset { min-width: 0; padding: 0; margin: 0; border: 0; }
.routing-card { display: grid; gap: var(--space-md); padding: var(--space-lg); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-primary); }
.routing-card h3 { margin: 0; }
.protocols { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
.protocol-unavailable { opacity: .65; }
.embedding-notice { padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-accent-soft); color: var(--color-text-primary); }
.routing-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-md); }
.local-status { display: grid; gap: var(--space-xs); padding: var(--space-md); background: var(--color-background-secondary); border-radius: var(--radius-md); }
.local-status.selected { border-left: 3px solid var(--color-accent-primary); }
@media (max-width: 700px) { .routing-fields { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,157 @@
// @vitest-environment happy-dom
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProviderConfig, ProviderPreset } from '@/contracts'
import * as service from '@/services/providerService'
import ProviderForm from './ProviderForm.vue'
import ProviderPresetSelector from './ProviderPresetSelector.vue'
vi.mock('@/services/providerService', () => ({ listProviderPresets: vi.fn(), getCredentialStatus: vi.fn(), putCredential: vi.fn(), createProvider: vi.fn(), updateProvider: vi.fn() }))
const presets: ProviderPreset[] = [
{ preset_id: 'deepseek', name: 'DeepSeek', provider_type: 'openai_compatible', base_url: 'https://deepseek.example.test', default_credential_id: 'shared-deepseek', requires_credential: true, logo_id: 'deepseek' },
{ preset_id: 'qwen', name: '通义千问', provider_type: 'openai_compatible', base_url: 'https://qwen.example.test', default_credential_id: 'shared-qwen', requires_credential: true, logo_id: 'qwen' },
]
const existing: ProviderConfig = { provider_id: 'p1', provider_type: 'openai_compatible', name: 'DeepSeek', base_url: presets[0].base_url, default_model: 'old-model', enabled: true, credential_id: 'old-shared-key', has_credential: true, capabilities: {} }
const wrappers: ReturnType<typeof mount>[] = []
async function render(provider?: ProviderConfig) {
const wrapper = mount(ProviderForm, { props: { provider, models: [{ model_id: 'old-model', name: 'Old', capabilities: {} }] } })
wrappers.push(wrapper)
await flushPromises()
return wrapper
}
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(service.listProviderPresets).mockResolvedValue(presets)
vi.mocked(service.getCredentialStatus).mockResolvedValue(true)
vi.mocked(service.putCredential).mockResolvedValue()
vi.mocked(service.createProvider).mockResolvedValue(existing)
vi.mocked(service.updateProvider).mockResolvedValue(existing)
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
describe('ProviderForm', () => {
it('filters compact preset chips and resolves bundled logos', async () => {
const wrapper = await render()
await wrapper.get('#provider-search').setValue('通义')
expect(wrapper.find('[data-preset="deepseek"]').exists()).toBe(false)
expect(wrapper.find('[data-preset="qwen"]').exists()).toBe(true)
expect(wrapper.get('[data-preset="qwen"] img').attributes('src')).not.toMatch(/^https?:/)
})
it('clears the secret, old model and credential on preset and custom selection', async () => {
const wrapper = await render(existing)
await wrapper.get('input[type="password"]').setValue('draft-secret')
await wrapper.get('[data-preset="qwen"]').trigger('click')
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
expect((wrapper.get('[data-field="model"]').element as HTMLInputElement).value).toBe('')
expect(wrapper.findAll('datalist option')).toHaveLength(0)
await wrapper.get('form').trigger('submit')
expect(service.updateProvider).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请输入 API Key')
await wrapper.get('input[type="password"]').setValue('new-secret')
wrapper.getComponent(ProviderPresetSelector).vm.$emit('update:modelValue', '')
await flushPromises()
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
})
it('allocates different credential IDs for two new providers using the same preset', async () => {
for (let i = 0; i < 2; i++) {
const wrapper = await render()
await wrapper.get('[data-preset="deepseek"]').trigger('click')
await wrapper.get('input[type="password"]').setValue(`test-key-${i}`)
await wrapper.get('form').trigger('submit')
await flushPromises()
}
const ids = vi.mocked(service.putCredential).mock.calls.map(call => call[0])
expect(ids).toHaveLength(2)
expect(new Set(ids).size).toBe(2)
ids.forEach(id => expect(id).toMatch(/^provider-key-[0-9a-f-]{36}$/))
vi.mocked(service.createProvider).mock.calls.forEach(([data], index) => {
expect(data.credential_id).toBe(ids[index])
expect(JSON.stringify(data)).not.toContain('test-key')
expect(JSON.stringify(data)).not.toContain('shared-deepseek')
})
})
it('accepts a custom API key and clears it after a failed credential save', async () => {
const wrapper = await render()
await wrapper.get('[data-field="name"]').setValue('Custom')
await wrapper.get('[data-field="base-url"]').setValue('https://custom.example.test/v1')
await wrapper.get('input[type="password"]').setValue('custom-test-key')
vi.mocked(service.putCredential).mockRejectedValueOnce(new Error('credential store unavailable'))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
expect(wrapper.text()).toContain('credential store unavailable')
expect(service.createProvider).not.toHaveBeenCalled()
await wrapper.get('input[type="password"]').setValue('custom-test-key')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.createProvider).toHaveBeenCalledWith(expect.objectContaining({ name: 'Custom', credential_id: expect.stringMatching(/^provider-key-/) }))
})
it('preserves its own existing key when untouched and rotates shared legacy references when replacing a key', async () => {
const untouched = await render(existing)
await untouched.get('form').trigger('submit')
await flushPromises()
expect(service.updateProvider).toHaveBeenLastCalledWith('p1', expect.objectContaining({ credential_id: 'old-shared-key', default_model: 'old-model' }))
const rotated = await render(existing)
await rotated.get('input[type="password"]').setValue('replacement-test-key')
await rotated.get('form').trigger('submit')
await flushPromises()
expect(service.putCredential).toHaveBeenCalledWith(expect.stringMatching(/^provider-key-/), 'replacement-test-key')
expect(service.updateProvider).toHaveBeenLastCalledWith('p1', expect.objectContaining({ credential_id: vi.mocked(service.putCredential).mock.calls[0][0] }))
})
it('persists edited protocols and unlinks the previous credential and model', async () => {
const wrapper = await render(existing)
await wrapper.get('[data-field="protocol"]').setValue('openai_responses')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.updateProvider).toHaveBeenCalledWith('p1', expect.objectContaining({ provider_type: 'openai_responses', default_model: '', credential_id: null }))
})
it('does not let a late credential status reuse a key after switching presets', async () => {
let resolveStatus!: (configured: boolean) => void
vi.mocked(service.getCredentialStatus).mockReturnValue(new Promise(resolve => { resolveStatus = resolve }))
const wrapper = await render(existing)
await wrapper.get('[data-preset="qwen"]').trigger('click')
resolveStatus(true)
await flushPromises()
await wrapper.get('form').trigger('submit')
expect(service.updateProvider).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请输入 API Key')
})
it('clears secrets on close and unmount, and stops a pending credential save from creating a provider', async () => {
let finish!: () => void
vi.mocked(service.putCredential).mockReturnValue(new Promise(resolve => { finish = resolve }))
const wrapper = await render()
await wrapper.get('[data-preset="deepseek"]').trigger('click')
await wrapper.get('input[type="password"]').setValue('pending-test-key')
await wrapper.get('form').trigger('submit')
await wrapper.get('[aria-label="关闭提供商表单"]').trigger('click')
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
wrapper.unmount()
finish()
await flushPromises()
expect(service.createProvider).not.toHaveBeenCalled()
const reopened = await render()
expect((reopened.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
})
it('clears a secret after provider save failure and keeps only the successfully saved reference for retry', async () => {
const wrapper = await render()
await wrapper.get('[data-preset="deepseek"]').trigger('click')
await wrapper.get('input[type="password"]').setValue('retry-test-key')
vi.mocked(service.createProvider).mockRejectedValueOnce(new Error('provider save failed'))
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('provider save failed')
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(service.putCredential).toHaveBeenCalledTimes(1)
expect(service.createProvider).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import type { ModelInfo, ProviderConfig, ProviderPreset, ProviderType } from '@/contracts'
import * as service from '@/services/providerService'
import ProviderPresetSelector from './ProviderPresetSelector.vue'
const props = defineProps<{ provider?: ProviderConfig; models?: ModelInfo[] }>()
const emit = defineEmits<{ close: []; saved: [provider: ProviderConfig] }>()
const newCredentialId = () => `provider-key-${crypto.randomUUID()}`
const form = reactive({
preset_id: '', provider_type: props.provider?.provider_type ?? 'openai_compatible' as ProviderType,
name: props.provider?.name ?? '', base_url: props.provider?.base_url ?? '',
default_model: props.provider?.default_model ?? '', enabled: props.provider?.enabled ?? true,
})
const credentialId = ref(props.provider?.credential_id || newCredentialId())
const apiKey = ref('')
const configured = ref(false)
const credentialLoading = ref(false)
const credentialError = ref('')
const presets = ref<ProviderPreset[]>([])
const presetsLoading = ref(false)
const presetsError = ref('')
const saving = ref(false)
const error = ref('')
const contextChanged = ref(false)
const dialog = ref<HTMLElement>()
const previousFocus = document.activeElement as HTMLElement | null
let active = true
let credentialGeneration = 0
const selectedPreset = computed(() => presets.value.find(preset => preset.preset_id === form.preset_id))
const modelOptions = computed(() => contextChanged.value ? [] : props.models ?? [])
async function loadPresets() {
presetsLoading.value = true
presetsError.value = ''
try {
presets.value = await service.listProviderPresets()
if (!contextChanged.value) form.preset_id = presets.value.find(preset => preset.provider_type === props.provider?.provider_type && preset.base_url === props.provider?.base_url)?.preset_id ?? ''
} catch { presetsError.value = '预设加载失败,请重试,或填写自定义服务。' }
finally { presetsLoading.value = false }
}
onMounted(async () => {
void loadPresets()
if (props.provider?.credential_id) {
const generation = credentialGeneration
credentialLoading.value = true
try {
const result = await service.getCredentialStatus(credentialId.value)
if (active && generation === credentialGeneration) configured.value = result
} catch {
if (active && generation === credentialGeneration) credentialError.value = '无法检查已保存的凭据。可输入新密钥,或关闭后重试。'
} finally {
if (generation === credentialGeneration) credentialLoading.value = false
}
}
await nextTick()
if (active) dialog.value?.querySelector<HTMLInputElement>('input')?.focus()
})
function detachCredential() {
credentialGeneration++
apiKey.value = ''
credentialId.value = newCredentialId()
configured.value = false
credentialLoading.value = false
credentialError.value = ''
form.default_model = ''
contextChanged.value = true
error.value = ''
}
function applyPreset(id: string) {
if (form.preset_id === id) return
detachCredential()
form.preset_id = id
const preset = presets.value.find(item => item.preset_id === id)
Object.assign(form, { provider_type: preset?.provider_type ?? 'openai_compatible', name: preset?.name ?? '', base_url: preset?.base_url ?? '' })
}
function changeConnection() {
form.preset_id = ''
detachCredential()
}
function close() {
active = false
apiKey.value = ''
emit('close')
}
onBeforeUnmount(() => {
active = false
apiKey.value = ''
previousFocus?.focus()
})
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); close() }
if (event.key !== 'Tab') return
const elements = Array.from(dialog.value?.querySelectorAll<HTMLElement>('button, input, select, [tabindex="0"]') ?? []).filter(element => !element.matches(':disabled'))
const first = elements[0], last = elements[elements.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
}
async function save() {
if (saving.value || credentialLoading.value || !active) return
error.value = ''
saving.value = true
try {
if (!form.name.trim() || (form.provider_type !== 'mock' && !form.base_url.trim())) throw new Error('请填写名称和 Base URL。')
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error('请输入 API Key。密钥将由后端加密保存。')
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false }
if (apiKey.value.trim()) {
// Rotate even an existing reference: older installations may share preset credential IDs.
const nextId = newCredentialId()
const request = service.putCredential(nextId, apiKey.value.trim())
apiKey.value = ''
await request
if (!active) return
credentialId.value = nextId
configured.value = true
}
const reference = configured.value ? credentialId.value : undefined
// A failed status check must not silently unlink the provider's existing credential.
if (credentialError.value && !reference) throw new Error(credentialError.value)
const saved = props.provider
? await service.updateProvider(props.provider.provider_id, { ...data, credential_id: reference ?? null })
: await service.createProvider({ ...data, credential_id: reference })
if (active) { emit('saved', saved); close() }
} catch (reason) {
if (active) error.value = reason instanceof Error ? reason.message : 'Provider 保存失败,请重试。'
} finally { apiKey.value = ''; saving.value = false }
}
</script>
<template>
<div class="modal-backdrop provider-backdrop" @click.self="close" @keydown="handleKeydown">
<div ref="dialog" class="modal provider-modal" role="dialog" aria-modal="true" aria-labelledby="provider-form-title" :aria-busy="saving">
<div class="form-heading"><h2 id="provider-form-title">{{ provider ? '编辑 Provider' : '新增 Provider' }}</h2><button type="button" class="button-secondary" aria-label="关闭提供商表单" @click="close">关闭</button></div>
<p v-if="presetsLoading" class="subtle" role="status">正在加载提供商预设</p>
<div v-if="presetsError" class="error-banner" role="alert">{{ presetsError }} <button type="button" class="button-secondary" :disabled="presetsLoading || saving" @click="loadPresets">重试</button></div>
<form @submit.prevent="save">
<fieldset :disabled="saving">
<ProviderPresetSelector :presets="presets" :model-value="form.preset_id" @update:model-value="applyPreset" />
<p v-if="selectedPreset?.description" class="subtle">{{ selectedPreset.description }}</p>
<div class="form-grid">
<label class="field"><span>接入协议</span><select v-model="form.provider_type" class="select" data-field="protocol" @change="changeConnection"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option><option v-if="provider?.provider_type === 'mock'" value="mock">Mock</option></select></label>
<label class="field"><span>名称</span><input v-model="form.name" class="input" data-field="name" required /></label>
<label class="field wide"><span>Base URL</span><input v-model="form.base_url" class="input" data-field="base-url" placeholder="https://api.example.com/v1" :required="form.provider_type !== 'mock'" @change="changeConnection" /></label>
<label class="field wide"><span>API Key</span><input v-model="apiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="configured ? '已配置,留空表示不修改' : '请输入 API Key(无鉴权服务可留空)'" /><small class="subtle">密钥由本地 AI Core 加密保存提供商配置仅保存独立的凭据引用</small></label>
<p v-if="credentialLoading" class="subtle wide" role="status">正在检查凭据状态</p>
<p v-if="credentialError" class="error-text wide" role="alert">{{ credentialError }}</p>
<label class="field wide"><span>默认聊天模型</span><input v-model="form.default_model" class="input" data-field="model" list="provider-model-options" placeholder="输入模型 ID,或保存后获取模型列表" /><datalist id="provider-model-options"><option v-for="model in modelOptions" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></label>
</div>
<label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> 启用</label>
</fieldset>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div class="inline-actions form-footer"><button class="button-primary" type="submit" :disabled="saving || credentialLoading">{{ saving ? '保存中…' : '保存提供商' }}</button><button type="button" class="button-secondary" @click="close">取消</button></div>
</form>
</div>
</div>
</template>
<style scoped>
.provider-modal { width: min(820px, 100%); max-height: 90dvh; }
.form-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); margin-bottom: var(--space-md); }
.form-heading h2 { margin: 0; }
fieldset { display: grid; gap: var(--space-md); border: 0; padding: 0; margin: 0; min-width: 0; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md); }
.wide { grid-column: 1 / -1; }
.error-text { color: var(--color-error); }
.form-footer { padding-top: var(--space-sm); }
@media (max-width: 600px) { .provider-backdrop { padding: 12px; }.provider-modal { padding: var(--space-lg); max-height: 94dvh; }.form-grid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ logoId?: string }>()
const assets = import.meta.glob<string>('../../assets/providers/*.svg', { eager: true, query: '?url', import: 'default' })
const source = computed(() => {
const id = props.logoId === 'openai-responses' ? 'openai' : props.logoId
return assets[`../../assets/providers/${id}.svg`]
})
</script>
<template>
<span class="provider-logo" :class="{ 'dark-logo': logoId === 'kimi' }" aria-hidden="true">
<img v-if="source" :src="source" alt="" width="22" height="22" />
<span v-else class="custom-logo">+</span>
</span>
</template>
<style scoped>
.provider-logo { display: inline-flex; flex: 0 0 28px; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 7px; background: #fff; color: #252b36; }
img { display: block; object-fit: contain; }
.dark-logo { background: #111; }
.custom-logo { font-size: 23px; line-height: 1; }
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ProviderPreset } from '@/contracts'
import ProviderLogo from './ProviderLogo.vue'
const props = defineProps<{ presets: ProviderPreset[]; modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const search = ref('')
const filtered = computed(() => {
const query = search.value.trim().toLocaleLowerCase()
return props.presets.filter(preset => [preset.name, preset.preset_id, preset.description, preset.base_url]
.some(value => value?.toLocaleLowerCase().includes(query)))
})
</script>
<template>
<div class="preset-selector">
<label class="field" for="provider-search"><span>提供商预设</span><input id="provider-search" v-model="search" class="input" type="search" placeholder="搜索提供商,例如 通义千问 / DeepSeek" /></label>
<div class="preset-grid" role="group" aria-label="提供商预设">
<button type="button" class="preset-chip" :class="{ selected: !modelValue }" :aria-pressed="!modelValue" @click="emit('update:modelValue', '')"><ProviderLogo /><span>自定义</span></button>
<button v-for="preset in filtered" :key="preset.preset_id" type="button" class="preset-chip" :class="{ selected: modelValue === preset.preset_id }" :aria-pressed="modelValue === preset.preset_id" :title="preset.description || preset.name" :data-preset="preset.preset_id" @click="emit('update:modelValue', preset.preset_id)">
<ProviderLogo :logo-id="preset.logo_id || preset.preset_id" /><span>{{ preset.name }}</span>
</button>
</div>
<p v-if="search && !filtered.length" class="subtle" role="status">没有匹配的预设可以使用自定义服务</p>
</div>
</template>
<style scoped>
.preset-selector { display: grid; gap: var(--space-sm); }
.preset-grid { display: flex; flex-wrap: wrap; gap: 8px; max-height: 220px; overflow-y: auto; padding: 3px; }
.preset-chip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; border: 1px solid var(--color-border-default); border-radius: 11px; background: var(--color-surface-primary); color: var(--color-text-primary); cursor: pointer; font: inherit; font-size: 13px; }
.preset-chip:hover { background: var(--color-background-hover); }
.preset-chip.selected { border-color: #377cf6; background: color-mix(in srgb, #377cf6 12%, var(--color-surface-primary)); color: #377cf6; box-shadow: 0 0 0 1px #377cf6; }
.preset-chip:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
</style>
+15 -85
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import type { ProviderConfig, ProviderType } from '@/contracts'
import { onMounted, ref } from 'vue'
import type { ProviderConfig } from '@/contracts'
import ProviderForm from './ProviderForm.vue'
import ProviderLogo from './ProviderLogo.vue'
import ModelRoutingSettings from './ModelRoutingSettings.vue'
import { useProviderStore } from '@/stores/provider'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
@@ -15,14 +18,9 @@ const settingsStore = useSettingsStore()
const providerStore = useProviderStore()
const themeStore = useThemeStore()
const showProviderForm = ref(false)
const editingProviderId = ref<string | null>(null)
const editingProvider = ref<ProviderConfig>()
const providerAction = ref('')
const testResults = ref<Record<string, string>>({})
const providerApiKey = ref('')
const providerForm = reactive({ preset_id: '', provider_type: 'openai_compatible' as ProviderType, name: '', base_url: '', default_model: '', credential_id: '', enabled: true })
const formModels = computed(() => editingProviderId.value ? providerStore.modelsByProvider[editingProviderId.value] ?? [] : [])
const selectedPreset = computed(() => providerStore.presets.find((item) => item.preset_id === providerForm.preset_id) ?? null)
onMounted(async () => {
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
await providerStore.refreshEnabledModels()
@@ -36,54 +34,15 @@ function presetIdFor(provider?: ProviderConfig) {
}
function openProvider(provider?: ProviderConfig) {
editingProviderId.value = provider?.provider_id ?? null
const presetId = presetIdFor(provider)
const preset = providerStore.presets.find((item) => item.preset_id === presetId)
Object.assign(providerForm, { preset_id: presetId, provider_type: provider?.provider_type ?? 'openai_compatible', name: provider?.name ?? '', base_url: provider?.base_url ?? '', default_model: provider?.default_model ?? '', credential_id: provider?.credential_id ?? preset?.default_credential_id ?? '', enabled: provider?.enabled ?? true })
providerApiKey.value = ''
editingProvider.value = provider
providerAction.value = ''
showProviderForm.value = true
if (providerForm.credential_id) void providerStore.loadCredentialStatus(providerForm.credential_id).catch(() => undefined)
if (provider) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
}
function applyProviderPreset() {
const preset = providerStore.presets.find((item) => item.preset_id === providerForm.preset_id)
if (!preset) return
Object.assign(providerForm, {
provider_type: preset.provider_type,
name: preset.name,
base_url: preset.base_url,
credential_id: preset.default_credential_id ?? '',
})
providerApiKey.value = ''
if (providerForm.credential_id) void providerStore.loadCredentialStatus(providerForm.credential_id).catch(() => undefined)
}
function closeProvider() {
providerApiKey.value = ''
showProviderForm.value = false
}
async function saveProvider() {
providerAction.value = ''
const credentialId = providerForm.credential_id.trim()
const requiresApiKey = Boolean(selectedPreset.value?.requires_credential)
if (requiresApiKey && !providerApiKey.value && !providerStore.credentialConfiguredById[credentialId]) {
providerAction.value = '请输入 API Key。密钥将由后端加密保存。'
return
}
const data = { ...providerForm, base_url: providerForm.base_url || undefined, credential_id: providerForm.credential_id || undefined, capabilities: {}, has_credential: Boolean(providerForm.credential_id) }
try {
if (providerApiKey.value) await providerStore.saveCredential(credentialId, providerApiKey.value)
const saved = editingProviderId.value
? await providerStore.updateProvider(editingProviderId.value, data)
: await providerStore.addProvider(data)
closeProvider()
if (saved.enabled) void providerStore.loadModels(saved.provider_id).catch(() => undefined)
} catch (error) {
providerApiKey.value = ''
providerAction.value = error instanceof Error ? error.message : 'Provider 保存失败'
}
async function providerSaved(provider: ProviderConfig) {
await providerStore.loadProviders()
if (provider.enabled) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
}
async function removeProvider(provider: ProviderConfig) { if (!confirm(`确定删除 Provider“${provider.name}”吗?`)) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : '删除失败' } }
@@ -107,14 +66,14 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<div v-else-if="activeSection === 'providers'" class="settings-section">
<div class="section-head">
<div><h2>模型提供商</h2><p class="subtle">支持 OpenAIDeepSeekOllama 和自定义兼容服务</p></div>
<div><h2>模型提供商</h2><p class="subtle">选择国内外提供商预设或配置自定义 API 与独立密钥</p></div>
<button class="button-primary" @click="openProvider()">新增 Provider</button>
</div>
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
<div class="provider-list">
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
<div class="provider-main">
<div class="inline-actions"><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
<div class="inline-actions"><ProviderLogo :logo-id="providerStore.presets.find(preset => preset.preset_id === presetIdFor(provider))?.logo_id || presetIdFor(provider)" /><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
<p class="subtle">{{ provider.base_url || '本地内置' }} · 默认模型 {{ provider.default_model || '未设置' }}</p>
<div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div>
<div v-if="providerStore.modelsByProvider[provider.provider_id]?.length" class="model-picker">
@@ -138,42 +97,13 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
</div>
</div>
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><button class="button-secondary" @click="settingsStore.rebuildIndex('fts')">重建文本索引</button><button class="button-secondary" @click="settingsStore.rebuildIndex('vector')">重建向量索引</button></div></div>
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>索引与模型</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>待处理任务 {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes }}</strong><small>笔记</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">重建全部</button><button class="button-secondary" @click="settingsStore.rebuildIndex('fts')">重建文本索引</button><button class="button-secondary" @click="settingsStore.rebuildIndex('vector')">重建向量索引</button></div><ModelRoutingSettings /></div>
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>权限策略</h2><p class="muted section-description">高影响能力默认需要确认。未知权限由后端拒绝。</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><select :value="policy" class="select short" @change="settingsStore.setPermission(String(permission), ($event.target as HTMLSelectElement).value as 'allow' | 'confirm' | 'deny')"><option value="allow">允许</option><option value="confirm">每次确认</option><option value="deny">拒绝</option></select></div></div></div>
<div v-else class="panel settings-section"><h2>AI Core 诊断</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>Sidecar 状态</h3><p class="subtle">AI Core 不可用时,Markdown 编辑仍可继续使用。</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>开发 API 地址</h3><p class="subtle">正式桌面环境由 Sidecar Manager 动态提供。</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">重新检测</button><button class="button-secondary" @click="settingsStore.restartAiCore">重启 AI Core</button></div></div>
<div v-if="showProviderForm" class="modal-backdrop" @click.self="closeProvider">
<div class="modal">
<h2>{{ editingProviderId ? '编辑 Provider' : '新增 Provider' }}</h2>
<form @submit.prevent="saveProvider">
<div class="field">
<label>提供商预设</label>
<select v-model="providerForm.preset_id" class="select" @change="applyProviderPreset">
<option value="">自定义</option>
<option v-for="preset in providerStore.presets" :key="preset.preset_id" :value="preset.preset_id">{{ preset.name }}</option>
</select>
</div>
<div class="field"><label>接入协议</label><select v-model="providerForm.provider_type" class="select"><option value="openai_compatible">OpenAI Compatible</option><option value="openai_chat">OpenAI Chat</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic_messages">Anthropic Messages</option><option value="ollama">Ollama</option></select></div>
<div class="field"><label>名称</label><input v-model="providerForm.name" class="input" required /></div>
<div class="field"><label>Base URL</label><input v-model="providerForm.base_url" class="input" placeholder="https://api.example.com/v1" required /></div>
<div class="field">
<label>默认模型</label>
<input v-model="providerForm.default_model" class="input" :list="editingProviderId ? 'provider-model-options' : undefined" placeholder="保存后自动获取,也可以手动输入" />
<datalist id="provider-model-options"><option v-for="model in formModels" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist>
</div>
<div v-if="selectedPreset?.requires_credential" class="field">
<label>API Key</label>
<input v-model="providerApiKey" class="input" type="password" autocomplete="new-password" spellcheck="false" :placeholder="providerStore.credentialConfiguredById[providerForm.credential_id] ? '已配置,留空表示不修改' : '请输入 API Key'" />
<small class="subtle">提交后由本地 AI Core 加密保存页面不会回显已保存的密钥</small>
</div>
<div v-else-if="!selectedPreset" class="field"><label>Credential ID</label><input v-model="providerForm.credential_id" class="input" placeholder="自定义凭据标识" /><small class="subtle">自定义服务可以引用 Host 注入或后端已保存的凭据</small></div>
<label class="inline-actions"><input v-model="providerForm.enabled" type="checkbox" /> 启用</label>
<div class="inline-actions"><button class="button-primary">保存并获取模型</button><button type="button" class="button-secondary" @click="closeProvider">取消</button></div>
</form>
</div>
</div>
<ProviderForm v-if="showProviderForm" :provider="editingProvider" :models="editingProvider ? providerStore.modelsByProvider[editingProvider.provider_id] : []" @close="showProviderForm = false" @saved="providerSaved" />
</section>
</template>
+1
View File
@@ -10,6 +10,7 @@ export * as skillService from './skillService'
export * as pluginService from './pluginService'
export * as mcpServerService from './mcpServerService'
export * as providerService from './providerService'
export * as modelRoutingService from './modelRoutingService'
export * as taskService from './taskService'
export * as indexService from './indexService'
export * as systemService from './systemService'
@@ -0,0 +1,28 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getModelRouting, saveModelRouting } from './modelRoutingService'
const config = { version: 7, embedding: { provider_id: 'p1', model: 'embedding', endpoint: '/embeddings', dimensions: 1024 }, transcription: null, speaker_matching: null }
const result = { config, local_backends: [{ capability: 'embedding', status: 'placeholder', message: 'hash' }] }
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
afterEach(() => { vi.unstubAllGlobals() })
describe('model routing service', () => {
it('round-trips versioned routing without an extra config wrapper', async () => {
vi.mocked(fetch).mockImplementation(async () => json(result))
expect(await getModelRouting()).toEqual(result)
expect(await saveModelRouting(config)).toEqual(result)
expect(fetch).toHaveBeenNthCalledWith(1, '/api/model-routing', expect.objectContaining({ method: 'GET' }))
expect(fetch).toHaveBeenNthCalledWith(2, '/api/model-routing', expect.objectContaining({ method: 'PUT', body: JSON.stringify(config) }))
})
it('surfaces load, save and version conflict errors instead of returning local defaults', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(json({ error: { code: 'MODEL_ROUTING_VERSION_CONFLICT', message: 'conflict' } }, 409))
.mockResolvedValueOnce(json({ error: { code: 'SAVE_FAILED', message: 'disk full' } }, 500))
await expect(getModelRouting()).rejects.toMatchObject({ code: 'NETWORK_ERROR' })
await expect(saveModelRouting(config)).rejects.toMatchObject({ code: 'MODEL_ROUTING_VERSION_CONFLICT' })
await expect(saveModelRouting(config)).rejects.toMatchObject({ code: 'SAVE_FAILED' })
})
})
@@ -0,0 +1,11 @@
import type { ModelRoutingConfig, ModelRoutingResponse } from '@/contracts'
import apiClient from './apiClient'
export function getModelRouting(): Promise<ModelRoutingResponse> {
return apiClient.get('/api/model-routing')
}
// version is the last version read from the server (optimistic concurrency).
export function saveModelRouting(config: ModelRoutingConfig): Promise<ModelRoutingResponse> {
return apiClient.put('/api/model-routing', config)
}
@@ -0,0 +1,35 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createProvider, listProviderPresets, putCredential, updateProvider } from './providerService'
const provider = { provider_id: 'provider-1', provider_type: 'openai_chat', name: 'Custom', capabilities: [], enabled: true }
const json = (body: unknown) => new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } })
beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) })
afterEach(() => { vi.unstubAllGlobals() })
describe('provider wire contracts', () => {
it('retains preset logos, descriptions and capabilities', async () => {
const preset = { preset_id: 'qwen', logo_id: 'qwen', description: '通义千问', capabilities: ['chat', 'embedding'] }
vi.mocked(fetch).mockResolvedValue(json({ items: [preset] }))
expect(await listProviderPresets()).toEqual([preset])
})
it('persists protocol edits and explicit credential unlinking', async () => {
vi.mocked(fetch).mockResolvedValue(json(provider))
await updateProvider('provider-1', { provider_type: 'openai_responses', default_model: '', credential_id: null })
expect(fetch).toHaveBeenCalledWith('/api/providers/provider-1', expect.objectContaining({ method: 'PATCH' }))
expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body))).toEqual({ provider_type: 'openai_responses', default_model: '', credential_id: null })
})
it('sends secrets only to credentials and a reference to provider configuration', async () => {
vi.mocked(fetch).mockResolvedValueOnce(json({ configured: true })).mockResolvedValueOnce(json(provider))
await putCredential('provider-key-test', 'test-only-key')
await createProvider({ name: 'Custom', provider_type: 'openai_compatible', default_model: '', enabled: true, credential_id: 'provider-key-test', has_credential: true, capabilities: {} })
const calls = vi.mocked(fetch).mock.calls
expect(calls[0][0]).toBe('/api/credentials/provider-key-test')
expect(JSON.parse(String(calls[0][1]?.body))).toEqual({ api_key: 'test-only-key' })
expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ credential_id: 'provider-key-test' })
expect(calls[1][1]?.body).not.toContain('test-only-key')
expect(calls[1][1]?.body).not.toContain('has_credential')
})
})
+3 -2
View File
@@ -1,5 +1,5 @@
import apiClient from './apiClient'
import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset } from '@/contracts'
import type { ApiModelInfo, ApiProviderConfig, ApiProviderPreset, ModelCapability, ModelInfo, OperationResponse, ProviderConfig, ProviderPreset, ProviderUpdateRequest } from '@/contracts'
function capabilityMap(capabilities: string[]): Partial<ModelCapability> {
return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial<ModelCapability>
@@ -61,8 +61,9 @@ export async function putCredential(credentialId: string, apiKey: string): Promi
)
}
export async function updateProvider(providerId: string, data: Partial<ProviderConfig>): Promise<ProviderConfig> {
export async function updateProvider(providerId: string, data: ProviderUpdateRequest): Promise<ProviderConfig> {
const response = await apiClient.patch<ApiProviderConfig>(`/api/providers/${providerId}`, {
provider_type: data.provider_type,
name: data.name,
base_url: data.base_url,
default_model: data.default_model,