diff --git a/gui/public/provider-icons/LICENSE.litellm b/gui/public/provider-icons/LICENSE.litellm new file mode 100644 index 000000000..3bfef5bae --- /dev/null +++ b/gui/public/provider-icons/LICENSE.litellm @@ -0,0 +1,26 @@ +Portions of this software are licensed as follows: + +* All content that resides under the "enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "enterprise/LICENSE". +* Content outside of the above mentioned directories or restrictions above is available under the MIT license as defined below. +--- +MIT License + +Copyright (c) 2023 Berri AI + +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. diff --git a/gui/public/provider-icons/LICENSE.lobe-icons b/gui/public/provider-icons/LICENSE.lobe-icons new file mode 100644 index 000000000..1dd53d2a9 --- /dev/null +++ b/gui/public/provider-icons/LICENSE.lobe-icons @@ -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. diff --git a/gui/public/provider-icons/LICENSE.omniroute b/gui/public/provider-icons/LICENSE.omniroute new file mode 100644 index 000000000..b5c9f58d0 --- /dev/null +++ b/gui/public/provider-icons/LICENSE.omniroute @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 diegosouzapw + +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. diff --git a/gui/public/provider-icons/LICENSE.simple-icons b/gui/public/provider-icons/LICENSE.simple-icons new file mode 100644 index 000000000..99202c3a9 --- /dev/null +++ b/gui/public/provider-icons/LICENSE.simple-icons @@ -0,0 +1,30 @@ +# CC0 1.0 Universal + +## Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an “owner”) of an original work of authorship and/or a database (each, a “Work”). + +Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works (“Commons”) that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the “Affirmer”), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights (“Copyright and Related Rights”). Copyright and Related Rights include, but are not limited to, the following: + 1. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; + 2. moral rights retained by the original author(s) and/or performer(s); + 3. publicity and privacy rights pertaining to a person’s image or likeness depicted in a Work; + 4. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(i), below; + 5. rights protecting the extraction, dissemination, use and reuse of data in a Work; + 6. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and + 7. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “Waiver”). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer’s heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer’s express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer’s express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer’s Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “License”). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer’s express Statement of Purpose. + +4. Limitations and Disclaimers. + 1. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. + 2. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. + 3. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person’s Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. + 4. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. + +For more information, please see . diff --git a/gui/public/provider-icons/agentrouter.png b/gui/public/provider-icons/agentrouter.png new file mode 100644 index 000000000..851556f62 Binary files /dev/null and b/gui/public/provider-icons/agentrouter.png differ diff --git a/gui/public/provider-icons/agnesai.svg b/gui/public/provider-icons/agnesai.svg new file mode 100644 index 000000000..a3eedc08e --- /dev/null +++ b/gui/public/provider-icons/agnesai.svg @@ -0,0 +1 @@ +Agnes AI \ No newline at end of file diff --git a/gui/public/provider-icons/ai21-color.svg b/gui/public/provider-icons/ai21-color.svg new file mode 100644 index 000000000..7e62a9517 --- /dev/null +++ b/gui/public/provider-icons/ai21-color.svg @@ -0,0 +1 @@ +AI21 \ No newline at end of file diff --git a/gui/public/provider-icons/aionlabs-color.svg b/gui/public/provider-icons/aionlabs-color.svg new file mode 100644 index 000000000..644734ef4 --- /dev/null +++ b/gui/public/provider-icons/aionlabs-color.svg @@ -0,0 +1 @@ +AionLabs \ No newline at end of file diff --git a/gui/public/provider-icons/api-airforce.svg b/gui/public/provider-icons/api-airforce.svg new file mode 100644 index 000000000..a7e52fc99 --- /dev/null +++ b/gui/public/provider-icons/api-airforce.svg @@ -0,0 +1,5 @@ + + API Airforce + + + diff --git a/gui/public/provider-icons/arcee-color.svg b/gui/public/provider-icons/arcee-color.svg new file mode 100644 index 000000000..dc809fc95 --- /dev/null +++ b/gui/public/provider-icons/arcee-color.svg @@ -0,0 +1 @@ +Arcee \ No newline at end of file diff --git a/gui/public/provider-icons/baichuan-color.svg b/gui/public/provider-icons/baichuan-color.svg new file mode 100644 index 000000000..c44c5f944 --- /dev/null +++ b/gui/public/provider-icons/baichuan-color.svg @@ -0,0 +1 @@ +Baichuan diff --git a/gui/public/provider-icons/bazaarlink.svg b/gui/public/provider-icons/bazaarlink.svg new file mode 100644 index 000000000..3cf817478 --- /dev/null +++ b/gui/public/provider-icons/bazaarlink.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/gui/public/provider-icons/blackbox.png b/gui/public/provider-icons/blackbox.png new file mode 100644 index 000000000..e5728840c Binary files /dev/null and b/gui/public/provider-icons/blackbox.png differ diff --git a/gui/public/provider-icons/bluesminds.svg b/gui/public/provider-icons/bluesminds.svg new file mode 100644 index 000000000..d99ec9cdb --- /dev/null +++ b/gui/public/provider-icons/bluesminds.svg @@ -0,0 +1,5 @@ + + Bluesminds + + B + diff --git a/gui/public/provider-icons/cerebras-color.svg b/gui/public/provider-icons/cerebras-color.svg new file mode 100644 index 000000000..cf3701e32 --- /dev/null +++ b/gui/public/provider-icons/cerebras-color.svg @@ -0,0 +1 @@ +Cerebras diff --git a/gui/public/provider-icons/cohere-color.svg b/gui/public/provider-icons/cohere-color.svg new file mode 100644 index 000000000..6e5d9b981 --- /dev/null +++ b/gui/public/provider-icons/cohere-color.svg @@ -0,0 +1 @@ +Cohere diff --git a/gui/public/provider-icons/coze.svg b/gui/public/provider-icons/coze.svg new file mode 100644 index 000000000..743f6d6a6 --- /dev/null +++ b/gui/public/provider-icons/coze.svg @@ -0,0 +1 @@ +Coze \ No newline at end of file diff --git a/gui/public/provider-icons/deepinfra-color.svg b/gui/public/provider-icons/deepinfra-color.svg new file mode 100644 index 000000000..565c8095b --- /dev/null +++ b/gui/public/provider-icons/deepinfra-color.svg @@ -0,0 +1 @@ +DeepInfra \ No newline at end of file diff --git a/gui/public/provider-icons/duckduckgo.svg b/gui/public/provider-icons/duckduckgo.svg new file mode 100644 index 000000000..7b51a80e8 --- /dev/null +++ b/gui/public/provider-icons/duckduckgo.svg @@ -0,0 +1 @@ +DuckDuckGo \ No newline at end of file diff --git a/gui/public/provider-icons/freemodel-dev.svg b/gui/public/provider-icons/freemodel-dev.svg new file mode 100644 index 000000000..c155e808a --- /dev/null +++ b/gui/public/provider-icons/freemodel-dev.svg @@ -0,0 +1,5 @@ + + FreeModel Dev + + M + diff --git a/gui/public/provider-icons/friendli.svg b/gui/public/provider-icons/friendli.svg new file mode 100644 index 000000000..f866a9ab9 --- /dev/null +++ b/gui/public/provider-icons/friendli.svg @@ -0,0 +1 @@ +Friendli \ No newline at end of file diff --git a/gui/public/provider-icons/hackclub.svg b/gui/public/provider-icons/hackclub.svg new file mode 100644 index 000000000..ef1fbf19b --- /dev/null +++ b/gui/public/provider-icons/hackclub.svg @@ -0,0 +1 @@ +Hack Club \ No newline at end of file diff --git a/gui/public/provider-icons/hyperbolic-color.svg b/gui/public/provider-icons/hyperbolic-color.svg new file mode 100644 index 000000000..76536c29c --- /dev/null +++ b/gui/public/provider-icons/hyperbolic-color.svg @@ -0,0 +1 @@ +Hyperbolic \ No newline at end of file diff --git a/gui/public/provider-icons/iflytek-color.svg b/gui/public/provider-icons/iflytek-color.svg new file mode 100644 index 000000000..4962d9e16 --- /dev/null +++ b/gui/public/provider-icons/iflytek-color.svg @@ -0,0 +1 @@ +iFlyTekCloud \ No newline at end of file diff --git a/gui/public/provider-icons/inference.svg b/gui/public/provider-icons/inference.svg new file mode 100644 index 000000000..f3366fe75 --- /dev/null +++ b/gui/public/provider-icons/inference.svg @@ -0,0 +1 @@ +Inference \ No newline at end of file diff --git a/gui/public/provider-icons/kilocode.svg b/gui/public/provider-icons/kilocode.svg new file mode 100644 index 000000000..ff0526264 --- /dev/null +++ b/gui/public/provider-icons/kilocode.svg @@ -0,0 +1 @@ +Kilo Code \ No newline at end of file diff --git a/gui/public/provider-icons/liquid.svg b/gui/public/provider-icons/liquid.svg new file mode 100644 index 000000000..5e698b89c --- /dev/null +++ b/gui/public/provider-icons/liquid.svg @@ -0,0 +1 @@ +Liquid \ No newline at end of file diff --git a/gui/public/provider-icons/litellm.png b/gui/public/provider-icons/litellm.png new file mode 100644 index 000000000..261b7504d Binary files /dev/null and b/gui/public/provider-icons/litellm.png differ diff --git a/gui/public/provider-icons/llm7.svg b/gui/public/provider-icons/llm7.svg new file mode 100644 index 000000000..d60ff2c7d --- /dev/null +++ b/gui/public/provider-icons/llm7.svg @@ -0,0 +1 @@ +LLM77 diff --git a/gui/public/provider-icons/longcat-color.svg b/gui/public/provider-icons/longcat-color.svg new file mode 100644 index 000000000..fde6c8a36 --- /dev/null +++ b/gui/public/provider-icons/longcat-color.svg @@ -0,0 +1 @@ +LongCat \ No newline at end of file diff --git a/gui/public/provider-icons/monsterapi.svg b/gui/public/provider-icons/monsterapi.svg new file mode 100644 index 000000000..e815c0e59 --- /dev/null +++ b/gui/public/provider-icons/monsterapi.svg @@ -0,0 +1,5 @@ + + + + MA + diff --git a/gui/public/provider-icons/morph-color.svg b/gui/public/provider-icons/morph-color.svg new file mode 100644 index 000000000..dbe7c4167 --- /dev/null +++ b/gui/public/provider-icons/morph-color.svg @@ -0,0 +1 @@ +Morph \ No newline at end of file diff --git a/gui/public/provider-icons/nebius.svg b/gui/public/provider-icons/nebius.svg new file mode 100644 index 000000000..eb7a4f5e3 --- /dev/null +++ b/gui/public/provider-icons/nebius.svg @@ -0,0 +1 @@ +Nebius \ No newline at end of file diff --git a/gui/public/provider-icons/nlpcloud.svg b/gui/public/provider-icons/nlpcloud.svg new file mode 100644 index 000000000..9177026b3 --- /dev/null +++ b/gui/public/provider-icons/nlpcloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gui/public/provider-icons/nousresearch.svg b/gui/public/provider-icons/nousresearch.svg new file mode 100644 index 000000000..a0537ebc2 --- /dev/null +++ b/gui/public/provider-icons/nousresearch.svg @@ -0,0 +1 @@ +NousResearch \ No newline at end of file diff --git a/gui/public/provider-icons/novita-color.svg b/gui/public/provider-icons/novita-color.svg new file mode 100644 index 000000000..0658ce0f0 --- /dev/null +++ b/gui/public/provider-icons/novita-color.svg @@ -0,0 +1 @@ +Novita AI \ No newline at end of file diff --git a/gui/public/provider-icons/nscale.png b/gui/public/provider-icons/nscale.png new file mode 100644 index 000000000..cb6cdf41e Binary files /dev/null and b/gui/public/provider-icons/nscale.png differ diff --git a/gui/public/provider-icons/ovh.svg b/gui/public/provider-icons/ovh.svg new file mode 100644 index 000000000..8e80d0eb8 --- /dev/null +++ b/gui/public/provider-icons/ovh.svg @@ -0,0 +1 @@ +OVH \ No newline at end of file diff --git a/gui/public/provider-icons/pollinations.svg b/gui/public/provider-icons/pollinations.svg new file mode 100644 index 000000000..65b3b6dbd --- /dev/null +++ b/gui/public/provider-icons/pollinations.svg @@ -0,0 +1 @@ +Pollinations \ No newline at end of file diff --git a/gui/public/provider-icons/predibase.png b/gui/public/provider-icons/predibase.png new file mode 100644 index 000000000..55ecaed76 Binary files /dev/null and b/gui/public/provider-icons/predibase.png differ diff --git a/gui/public/provider-icons/provider-generic.svg b/gui/public/provider-icons/provider-generic.svg new file mode 100644 index 000000000..665d9910d --- /dev/null +++ b/gui/public/provider-icons/provider-generic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/gui/public/provider-icons/publicai.svg b/gui/public/provider-icons/publicai.svg new file mode 100644 index 000000000..d028fc3c9 --- /dev/null +++ b/gui/public/provider-icons/publicai.svg @@ -0,0 +1 @@ +PublicAIP \ No newline at end of file diff --git a/gui/public/provider-icons/puter.svg b/gui/public/provider-icons/puter.svg new file mode 100644 index 000000000..2bd9180ee --- /dev/null +++ b/gui/public/provider-icons/puter.svg @@ -0,0 +1 @@ +P diff --git a/gui/public/provider-icons/qoder-color.svg b/gui/public/provider-icons/qoder-color.svg new file mode 100644 index 000000000..c25f9b5d4 --- /dev/null +++ b/gui/public/provider-icons/qoder-color.svg @@ -0,0 +1 @@ +Qoder \ No newline at end of file diff --git a/gui/public/provider-icons/reka.png b/gui/public/provider-icons/reka.png new file mode 100644 index 000000000..000162576 Binary files /dev/null and b/gui/public/provider-icons/reka.png differ diff --git a/gui/public/provider-icons/requesty.svg b/gui/public/provider-icons/requesty.svg new file mode 100644 index 000000000..3b63649d5 --- /dev/null +++ b/gui/public/provider-icons/requesty.svg @@ -0,0 +1 @@ +RequestyR diff --git a/gui/public/provider-icons/sambanova-color.svg b/gui/public/provider-icons/sambanova-color.svg new file mode 100644 index 000000000..1de43be42 --- /dev/null +++ b/gui/public/provider-icons/sambanova-color.svg @@ -0,0 +1 @@ +SambaNova diff --git a/gui/public/provider-icons/scaleway.svg b/gui/public/provider-icons/scaleway.svg new file mode 100644 index 000000000..054f2c010 --- /dev/null +++ b/gui/public/provider-icons/scaleway.svg @@ -0,0 +1 @@ +Scaleway \ No newline at end of file diff --git a/gui/public/provider-icons/sensenova-color.svg b/gui/public/provider-icons/sensenova-color.svg new file mode 100644 index 000000000..17f1d82c5 --- /dev/null +++ b/gui/public/provider-icons/sensenova-color.svg @@ -0,0 +1 @@ +SenseNova \ No newline at end of file diff --git a/gui/public/provider-icons/siliconcloud-color.svg b/gui/public/provider-icons/siliconcloud-color.svg new file mode 100644 index 000000000..d5462b878 --- /dev/null +++ b/gui/public/provider-icons/siliconcloud-color.svg @@ -0,0 +1 @@ +SiliconCloud diff --git a/gui/public/provider-icons/spark-color.svg b/gui/public/provider-icons/spark-color.svg new file mode 100644 index 000000000..50c8faeae --- /dev/null +++ b/gui/public/provider-icons/spark-color.svg @@ -0,0 +1 @@ +Spark \ No newline at end of file diff --git a/gui/public/provider-icons/stepfun-color.svg b/gui/public/provider-icons/stepfun-color.svg new file mode 100644 index 000000000..d4011ed35 --- /dev/null +++ b/gui/public/provider-icons/stepfun-color.svg @@ -0,0 +1 @@ +StepFun diff --git a/gui/public/provider-icons/t3-web.svg b/gui/public/provider-icons/t3-web.svg new file mode 100644 index 000000000..9433d1201 --- /dev/null +++ b/gui/public/provider-icons/t3-web.svg @@ -0,0 +1 @@ +T3 WebT3 \ No newline at end of file diff --git a/gui/public/provider-icons/tencent-color.svg b/gui/public/provider-icons/tencent-color.svg new file mode 100644 index 000000000..98da272a2 --- /dev/null +++ b/gui/public/provider-icons/tencent-color.svg @@ -0,0 +1 @@ +Tencent \ No newline at end of file diff --git a/gui/public/provider-icons/together-color.svg b/gui/public/provider-icons/together-color.svg new file mode 100644 index 000000000..46bf0d4fa --- /dev/null +++ b/gui/public/provider-icons/together-color.svg @@ -0,0 +1 @@ +Together AI diff --git a/gui/public/provider-icons/uncloseai.svg b/gui/public/provider-icons/uncloseai.svg new file mode 100644 index 000000000..d3cd654cc --- /dev/null +++ b/gui/public/provider-icons/uncloseai.svg @@ -0,0 +1 @@ +UncloseAIU diff --git a/gui/public/provider-icons/volcengine-color.svg b/gui/public/provider-icons/volcengine-color.svg new file mode 100644 index 000000000..4b0cd2070 --- /dev/null +++ b/gui/public/provider-icons/volcengine-color.svg @@ -0,0 +1 @@ +Volcengine diff --git a/gui/public/provider-icons/zhipu-color.svg b/gui/public/provider-icons/zhipu-color.svg new file mode 100644 index 000000000..0c6e61cea --- /dev/null +++ b/gui/public/provider-icons/zhipu-color.svg @@ -0,0 +1 @@ +Zhipu \ No newline at end of file diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index efd84a35b..58901cabb 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { IconX, IconLock, IconKey, IconExternal } from "../icons"; import { useT } from "../i18n"; +import { providerIconSrc } from "../provider-icons"; import { buildProviderPostBody, codexPresetDescriptionKey, @@ -12,7 +13,7 @@ import { oauthTosRisk } from "../oauth-tos-risk"; import OAuthTosWarningModal from "./OAuthTosWarningModal"; import ProviderCatalog from "./provider-catalog/ProviderCatalog"; import type { AccountLoginRow, AccountLoginStatus } from "./provider-catalog/ProviderCatalog"; -import type { CatalogPreset } from "./provider-catalog/provider-presets"; +import { isPresetActionable, type CatalogPreset } from "./provider-catalog/provider-presets"; import { baseUrlForChoice, matchChoiceId, resolvedBaseUrlForChoice } from "../base-url-choice"; export type ProviderConfig = ProviderPayload; @@ -69,6 +70,13 @@ export default function AddProviderModal({ const [usageRank, setUsageRank] = useState>({}); const [endpointChoice, setEndpointChoice] = useState("custom"); const [oauthTosPending, setOauthTosPending] = useState(null); + const [discoveryBusy, setDiscoveryBusy] = useState(false); + const [discoveryError, setDiscoveryError] = useState(""); + const [discoverySource, setDiscoverySource] = useState<"live" | "static" | "">(""); + const [discoveredModels, setDiscoveredModels] = useState>([]); + const [selectedModels, setSelectedModels] = useState([]); + const discoveryRequestRef = useRef(0); + const discoveryAbortRef = useRef(null); const aliveRef = useRef(true); const loadedPresetsRef = useRef(false); const previousFocusRef = useRef(null); @@ -88,14 +96,35 @@ export default function AddProviderModal({ } return () => { aliveRef.current = false; + discoveryRequestRef.current += 1; + discoveryAbortRef.current?.abort(); + discoveryAbortRef.current = null; previousFocusRef.current?.focus(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only open hook }, []); useEffect(() => { const onKey = (e: KeyboardEvent) => { - // Child ToS warning owns Escape while it is open. - if (e.key === "Escape" && !oauthTosPending) onClose(); + // Child ToS warning owns keyboard navigation while it is open. + if (e.key === "Escape" && !oauthTosPending) { + onClose(); + return; + } + if (oauthTosPending) return; + if (e.key !== "Tab" || !dialogRef.current) return; + const focusable = Array.from(dialogRef.current.querySelectorAll( + "a[href], input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])" + )).filter(node => node.offsetParent !== null); + const first = focusable[0]; + const last = focusable.at(-1); + if (!first || !last) return; + if (e.shiftKey && (document.activeElement === first || !dialogRef.current.contains(document.activeElement))) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); @@ -131,7 +160,15 @@ export default function AddProviderModal({ return key ? t(key) : candidate.note; }; + const invalidateDiscoveryRequest = () => { + discoveryRequestRef.current += 1; + discoveryAbortRef.current?.abort(); + discoveryAbortRef.current = null; + setDiscoveryBusy(false); + }; + const choosePreset = (p: Preset) => { + invalidateDiscoveryRequest(); setPreset(p); const choiceId = matchChoiceId(p.baseUrlChoices, p.baseUrl); setEndpointChoice(choiceId); @@ -143,7 +180,7 @@ export default function AddProviderModal({ : p.baseUrl, authMode: p.auth, apiKey: "", - defaultModel: p.defaultModel ?? "", + defaultModel: p.defaultModel ?? p.models?.[0] ?? "", allowPrivateNetwork: false, }); setError(""); @@ -152,9 +189,15 @@ export default function AddProviderModal({ setManualCode(""); setManualCodeMsg(""); setManualCodeOk(true); + setDiscoveryError(""); + setDiscoverySource(p.models?.length ? "static" : ""); + const staticModels = (p.models ?? []).map(id => ({ id })); + setDiscoveredModels(staticModels); + setSelectedModels(staticModels.map(model => model.id)); }; const back = () => { + invalidateDiscoveryRequest(); setPreset(null); setForm(null); setEndpointChoice("custom"); @@ -164,6 +207,61 @@ export default function AddProviderModal({ setManualCode(""); setManualCodeMsg(""); setManualCodeOk(true); + setDiscoveryError(""); + setDiscoverySource(""); + setDiscoveredModels([]); + setSelectedModels([]); + }; + + const providerPostBody = () => { + if (!form || !preset) return null; + const resolvedBaseUrl = preset.baseUrlChoices?.length + ? resolvedBaseUrlForChoice(preset.baseUrlChoices, endpointChoice, form.baseUrl) + : form.baseUrl.trim(); + return buildProviderPostBody(preset, { ...form, baseUrl: resolvedBaseUrl }); + }; + + const discoverModels = async () => { + const postBody = providerPostBody(); + if (!postBody || !preset || preset.supportLevel === "reference") return; + const requestId = discoveryRequestRef.current + 1; + discoveryRequestRef.current = requestId; + discoveryAbortRef.current?.abort(); + const controller = new AbortController(); + discoveryAbortRef.current = controller; + setDiscoveryBusy(true); + setDiscoveryError(""); + try { + const res = await fetch(`${apiBase}/api/provider-presets/discover`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ presetId: preset.id, provider: postBody.provider }), + signal: controller.signal, + }); + const data = await res.json().catch(() => ({})) as { + ok?: boolean; + error?: string; + source?: "live" | "static"; + models?: Array; + }; + if (requestId !== discoveryRequestRef.current || controller.signal.aborted) return; + if (!res.ok) throw new Error(data.error || t("modal.failedStatus", { status: res.status })); + const models = (data.models ?? []).map(model => typeof model === "string" ? { id: model } : model).filter(model => model.id); + if (data.ok === false && models.length === 0) throw new Error(data.error || t("modal.discoveryFailed")); + setDiscoveredModels(models); + setSelectedModels(models.map(model => model.id)); + setDiscoverySource(data.source ?? (preset.discovery === "static" ? "static" : "live")); + if (data.error) setDiscoveryError(data.error); + if (models[0]) setForm(current => current && !current.defaultModel ? { ...current, defaultModel: models[0]!.id } : current); + } catch (cause) { + if (requestId !== discoveryRequestRef.current || controller.signal.aborted) return; + setDiscoveryError(cause instanceof Error ? cause.message : t("modal.networkError")); + } finally { + if (requestId === discoveryRequestRef.current) { + discoveryAbortRef.current = null; + setDiscoveryBusy(false); + } + } }; const submit = async () => { @@ -187,10 +285,16 @@ export default function AddProviderModal({ setSaving(true); setError(""); try { + const selectedSubset = selectedModels.length > 0 && selectedModels.length < discoveredModels.length + ? selectedModels + : undefined; const res = await fetch(`${apiBase}/api/providers`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(postBody), + body: JSON.stringify({ + ...postBody, + provider: selectedSubset ? { ...postBody.provider, selectedModels: selectedSubset } : postBody.provider, + }), }); if (!res.ok) { const d = await res.json().catch(() => ({})); @@ -301,35 +405,46 @@ export default function AddProviderModal({ const isCustom = preset?.id === "custom"; const isLocal = form?.authMode === "local"; const isReservedForward = preset ? isReservedCodexForwardPreset(preset) : false; + const isReference = preset ? !isPresetActionable(preset) : false; + const selectedIcon = preset ? providerIconSrc(preset.id, { + adapter: preset.adapter, + baseUrl: preset.baseUrl, + defaultModel: preset.defaultModel, + models: preset.models, + }) : undefined; + const setupUrl = preset?.dashboardUrl ?? preset?.documentationUrl; return ( <> -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
-

{preset ? t("modal.addNamed", { label: preset.label }) : t("modal.add")}

+

{t("modal.add")}

- {!preset ? ( - choosePreset(p)} - onSelectCustom={() => choosePreset(fallbackPresets[0]!)} - accountRows={accountRows} - accountStatus={accountStatus} - busyProvider={accountBusy} - onLogin={onAccountLogin} - onCancelLogin={onAccountCancelLogin} - onLogout={onAccountLogout} - /> - ) : form && ( + choosePreset(p)} + onSelectCustom={() => choosePreset(fallbackPresets[0]!)} + accountRows={accountRows} + accountStatus={accountStatus} + busyProvider={accountBusy} + onLogin={onAccountLogin} + onCancelLogin={onAccountCancelLogin} + onLogout={onAccountLogout} + onClearSelection={back} + selectedPreset={preset} + standalone={initialCustom && preset?.id === "custom"} + detail={preset && form ? ( preset.auth === "oauth" && form.authMode === "oauth" ? ( // OAuth login pane -
+
+ +
{preset.note ?? t("modal.oauthDefaultNote")}
{oauthSupported.includes(preset.oauthProvider ?? "") ? (
) : ( // API key / Codex-forward / free-tier form -
- {!isReservedForward && !isCustom && !isLocal && !preset.keyOptional && preset.note && ( -
- {t("modal.setupGuide")} -
    +
    + + + {!isReservedForward && !isCustom && !isLocal && ( +
    + {t("modal.setupGuide")} +
    1. - {t("modal.setupStep1Prefix")}{" "} - - {t("modal.setupDashboardLink", { label: preset.label })} - {" "} - {t("modal.setupStep1Suffix")} + {setupUrl ? <> + {t("modal.setupStep1Prefix")}{" "} + {t("modal.setupDashboardLink", { label: preset.label })} + {!preset.keyOptional && !isReference ? <> {t("modal.setupStep1Suffix")} : null} + : t("modal.setupReviewProvider", { label: preset.label })}
    2. -
    3. {t("modal.setupStep2")}
    4. -
    5. {t("modal.setupStep3")}
    6. + {isReference ?
    7. {t("modal.setupReferenceStep")}
    8. : preset.keyOptional ? <> +
    9. {t("modal.setupKeylessStep")}
    10. +
    11. {t("modal.setupDiscoverStep")}
    12. + : <> +
    13. {t("modal.setupStep2")}
    14. +
    15. {t("modal.setupStep3")}
    16. + }
    - {preset.note &&
    {preset.note}
    } + {preset.note &&
    {preset.note}
    } {/\{[^}]*\}/.test(form.baseUrl) && (
    {t("modal.baseUrlPlaceholderHint")}
    )} -
+
)} + {isReference &&
{t("modal.referenceExplanation")}
} + {!isReference && <> setForm({ ...form, name: e.target.value })} placeholder={t("modal.namePlaceholder")} /> {dup &&
{t("modal.duplicateWarn", { name: form.name.trim() })}
} {!isReservedForward && <> - { + invalidateDiscoveryRequest(); + setForm({ ...form, adapter: e.target.value }); + }}> {["openai-responses", "openai-chat", "anthropic", "google", "azure-openai", "cursor"].map(a => )} @@ -442,6 +569,7 @@ export default function AddProviderModal({ value={endpointChoice} onChange={e => { const id = e.target.value; + invalidateDiscoveryRequest(); setEndpointChoice(id); setForm({ ...form, @@ -453,6 +581,8 @@ export default function AddProviderModal({ @@ -464,7 +594,10 @@ export default function AddProviderModal({ setForm({ ...form, baseUrl: e.target.value })} + onChange={e => { + invalidateDiscoveryRequest(); + setForm({ ...form, baseUrl: e.target.value }); + }} placeholder={t("modal.baseUrlPlaceholder")} /> @@ -472,12 +605,18 @@ export default function AddProviderModal({ ) : ( - setForm({ ...form, baseUrl: e.target.value })} placeholder={t("modal.baseUrlPlaceholder")} /> + { + invalidateDiscoveryRequest(); + setForm({ ...form, baseUrl: e.target.value }); + }} placeholder={t("modal.baseUrlPlaceholder")} /> )} {!isReservedForward && ( )} @@ -505,23 +644,78 @@ export default function AddProviderModal({ )} - setForm({ ...form, apiKey: e.target.value })} placeholder={t("modal.apiKeyPlaceholder")} /> + { + invalidateDiscoveryRequest(); + setForm({ ...form, apiKey: e.target.value }); + }} placeholder={t("modal.apiKeyPlaceholder")} /> )} {!isReservedForward && setForm({ ...form, defaultModel: e.target.value })} placeholder={t("modal.defaultModelPlaceholder")} /> } + {!isReservedForward && !isCustom && ( +
+
+
+ {t("modal.modelDiscovery")} +
+ {discoverySource ? t(discoverySource === "live" ? "modal.modelsLive" : "modal.modelsStatic") : t("modal.modelDiscoveryHint")} +
+
+ +
+ {discoveryError &&
{discoveryError}
} + {discoveredModels.length > 0 && ( +
+ {discoveredModels.map(model => { + const checked = selectedModels.includes(model.id); + return ( + + ); + })} +
+ )} +
+ )} + } {error &&
{error}
} -
- - {preset.auth === "oauth" && } -
+
+ {!isReference && } + {preset.auth === "oauth" && } +
) - )} + ) : undefined} + />
{oauthTosPending && ( @@ -550,3 +744,29 @@ function Field({ label, children }: { label: string; children: React.ReactNode } ); } + +function ProviderDetailHeader({ preset, icon }: { preset: Preset; icon?: string }) { + const t = useT(); + const docs = preset.documentationUrl ?? preset.dashboardUrl; + return ( +
+ {icon ? : preset.label.slice(0, 1)} +
+

{preset.label}

+ {preset.id} +
+ {preset.accessGroups?.map(group => ( + + {t(`modal.group.${group}`)} + + ))} + {preset.supportLevel && + {t(preset.supportLevel === "reference" ? "modal.badge.reference" : preset.supportLevel === "experimental" ? "modal.badge.experimental" : "modal.badge.supported")} + } + {preset.verification && {t(`modal.verification.${preset.verification}`)}} +
+
+ {docs && {t("modal.providerDocs")}} +
+ ); +} diff --git a/gui/src/components/provider-catalog/ProviderCatalog.tsx b/gui/src/components/provider-catalog/ProviderCatalog.tsx index 47e1201b3..cdf09b30d 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -1,15 +1,16 @@ -/** - * ProviderCatalog — the browse surface of the add-provider modal: Accounts / - * Free / Paid tabs over a single searchable scroll list, and account login - * rows on the Accounts tab. Presentational: presets/usage arrive via props; - * view state (tab, query) lives here; selection lifts up. - */ -import { useMemo, useState } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { useT } from "../../i18n"; +import { providerIconSrc } from "../../provider-icons"; import { + ACCESS_GROUPS, + accessGroupCounts, bucketPresets, + curatedPresets, + filterByAccessGroup, filterPresets, + freeCatalogSections, type CatalogPreset, + type ProviderAccessGroup, } from "./provider-presets"; export type AccountLoginStatus = { loggedIn: boolean; email?: string; error?: string; needsReauth?: boolean }; @@ -18,11 +19,11 @@ export type AccountLoginRow = { label: string; kind: "oauth" | "key" | "codex"; statusLabel?: string; - /** Optional deep-link for codex/account-pool management. */ href?: string; }; export type CatalogTier = "accounts" | "free" | "paid"; +type FreeCatalogGroup = ProviderAccessGroup | "all" | "existing"; export default function ProviderCatalog({ presets, @@ -37,6 +38,10 @@ export default function ProviderCatalog({ onLogin, onCancelLogin, onLogout, + onClearSelection, + selectedPreset, + detail, + standalone = false, }: { presets: CatalogPreset[]; usageRank?: Record; @@ -44,31 +49,47 @@ export default function ProviderCatalog({ initialTier?: CatalogTier; onSelectPreset: (preset: CatalogPreset) => void; onSelectCustom: () => void; - /** Accounts-tab login rows; empty (default) degrades to preset-only rendering. */ accountRows?: AccountLoginRow[]; accountStatus?: Record; busyProvider?: string | null; onLogin?: (provider: string) => void; onCancelLogin?: (provider: string) => void; onLogout?: (provider: string) => void; + onClearSelection?: () => void; + selectedPreset?: CatalogPreset | null; + detail?: ReactNode; + standalone?: boolean; }) { const t = useT(); const [tier, setTier] = useState(initialTier); const [query, setQuery] = useState(""); + const [group, setGroup] = useState("all"); const catalog = useMemo(() => presets.filter(p => p.id !== "custom"), [presets]); - - /** Usage-ranked order: requests desc, then label (050a sortPresets is the no-usage fallback). */ const ranked = useMemo(() => [...catalog].sort((a, b) => { - const ra = usageRank[a.id] ?? 0; - const rb = usageRank[b.id] ?? 0; - if (rb !== ra) return rb - ra; - return a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) || a.id.localeCompare(b.id); + const difference = (usageRank[b.id] ?? 0) - (usageRank[a.id] ?? 0); + return difference || a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) || a.id.localeCompare(b.id); }), [catalog, usageRank]); - const buckets = useMemo(() => bucketPresets(ranked), [ranked]); - const tierList = buckets[tier]; + const curated = useMemo(() => curatedPresets(ranked), [ranked]); + const counts = useMemo(() => accessGroupCounts(curated), [curated]); + const freeSections = useMemo(() => freeCatalogSections(ranked), [ranked]); + const freeDirectory = freeSections.directory; + const tierList = tier === "free" + ? group === "all" ? freeDirectory : group === "existing" ? freeSections.existing : filterByAccessGroup(curated, group) + : buckets[tier]; const rows = useMemo(() => filterPresets(tierList, query), [tierList, query]); + const filteredAccountRows = useMemo(() => { + const normalized = query.trim().toLowerCase(); + return normalized + ? accountRows.filter(row => row.label.toLowerCase().includes(normalized) || row.id.toLowerCase().includes(normalized)) + : accountRows; + }, [accountRows, query]); + const searchCount = tier === "accounts" ? accountRows.length + : tier === "free" && group === "all" ? freeDirectory.length + : tierList.length; + + if (standalone) return
{detail}
; const badges = (p: CatalogPreset) => { const auth = p.codexAccountMode === "direct" ? {t("modal.badge.direct")} @@ -76,108 +97,141 @@ export default function ProviderCatalog({ : p.auth === "oauth" ? {t("modal.badge.oauth")} : p.auth === "forward" ? {t("modal.badge.codexLogin")} : p.auth === "local" ? {t("modal.badge.local")} - : p.keyOptional ? null // keyless free: the Free badge alone says it all + : p.keyOptional ? null : {t("modal.badge.apiKey")}; - // Free pricing is orthogonal to auth: NVIDIA (freeTier + key required) shows BOTH - // the Free badge and the API-key badge — free pricing never hides a key requirement. - const free = (p.freeTier || p.keyOptional) && p.auth === "key" - ? {t("modal.badge.free")} - : null; - return <>{free}{auth}; + const access = p.accessGroups?.includes("recurring-or-keyless") + ? {t("modal.badge.recurring")} + : (p.freeTier || p.keyOptional) && p.auth === "key" + ? {t("modal.badge.free")} + : null; + const support = p.supportLevel === "reference" + ? {t("modal.badge.reference")} + : p.supportLevel === "experimental" + ? {t("modal.badge.experimental")} + : null; + return <>{access}{auth}{support}; }; return (
-
+
{ + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + const tabs = Array.from(event.currentTarget.querySelectorAll('[role="tab"]')); + const current = tabs.indexOf(document.activeElement as HTMLButtonElement); + const next = event.key === 'Home' ? 0 + : event.key === 'End' ? tabs.length - 1 + : event.key === 'ArrowLeft' ? (current - 1 + tabs.length) % tabs.length + : (current + 1) % tabs.length; + event.preventDefault(); + tabs[next]?.focus(); + tabs[next]?.click(); + }} + > {(["accounts", "free", "paid"] as const).map(candidate => ( ))}
- {tier === "accounts" && ( -
- {t("modal.accountsHint")} -
- )} +
+ - setQuery(e.target.value)} - placeholder={t("modal.search")} - /> +
+
+ { setQuery(e.target.value); onClearSelection?.(); }} + placeholder={t("modal.searchCount", { count: searchCount })} + aria-label={t("modal.searchCount", { count: searchCount })} + /> +
-
- {presetsLoading && rows.length === 0 && ( -
{t("modal.catalogLoading")}
- )} - {tier !== "accounts" && rows.map(p => ( - - ))} - {tier !== "accounts" && !presetsLoading && rows.length === 0 && ( -
{t("modal.noMatch")}
- )} +
+ {presetsLoading && rows.length === 0 &&
{t("modal.catalogLoading")}
} + {tier !== "accounts" && rows.map(p => { + const icon = providerIconSrc(p.id, { adapter: p.adapter, baseUrl: p.baseUrl, defaultModel: p.defaultModel, models: p.models }); + return ( + + ); + })} + {tier !== "accounts" && !presetsLoading && rows.length === 0 &&
{t("modal.noMatch")}
} - {tier === "accounts" && accountRows.map(row => { - const status = accountStatus[row.id]; - const busy = busyProvider === row.id; - const loggedIn = !!status?.loggedIn; - const statusText = loggedIn - ? (status?.email ?? row.statusLabel ?? t("modal.accountLoggedIn")) - : (status?.error ?? row.statusLabel ?? t("modal.accountLoggedOut")); - return ( -
-
-
{row.label}
-
{statusText}
-
-
- {row.kind === "key" ? null : row.kind === "codex" ? ( - <> - {loggedIn && ( - {t("modal.accountManage")} - )} - {onLogin && ( - + {tier === "accounts" && filteredAccountRows.map(row => { + const status = accountStatus[row.id]; + const busy = busyProvider === row.id; + const loggedIn = !!status?.loggedIn; + const statusText = loggedIn ? (status.email ?? row.statusLabel ?? t("modal.accountLoggedIn")) : (status?.error ?? row.statusLabel ?? t("modal.accountLoggedOut")); + return ( +
+ {row.label}{statusText} + + {row.kind === "key" ? null : row.kind === "codex" ? <> + {loggedIn && {t("modal.accountManage")}} + {onLogin && } + : loggedIn ? ( + onLogout && + ) : busy ? ( + onCancelLogin && + ) : ( + onLogin && )} - - ) : loggedIn ? ( - onLogout && - ) : busy ? ( - onCancelLogin && - ) : ( - onLogin && - )} -
-
- ); - })} - {tier === "accounts" && accountRows.length === 0 && !presetsLoading && ( -
{t("modal.noMatch")}
- )} -
+ +
+ ); + })} + {tier === "accounts" && filteredAccountRows.length === 0 && !presetsLoading &&
{t("modal.noMatch")}
} +
+
-
-
- {tier !== "accounts" && ( - - )} +
+ {tier === "accounts" + ?
{t("modal.tab.accounts")}{t("modal.accountsHint")}
+ : detail ??
{t("modal.selectProvider")}{t("modal.selectProviderHint")}
} +
); diff --git a/gui/src/components/provider-catalog/provider-presets.ts b/gui/src/components/provider-catalog/provider-presets.ts index 42d6d4c7f..cea689d22 100644 --- a/gui/src/components/provider-catalog/provider-presets.ts +++ b/gui/src/components/provider-catalog/provider-presets.ts @@ -9,6 +9,14 @@ import { providerTier, type ProviderTier, type WorkspaceProvider } from "../../provider-workspace/catalog"; import type { ProviderPayload } from "../../provider-payload"; +export type ProviderAccessGroup = + | "recurring-or-keyless" + | "recurring-uncapped" + | "recurring-credit" + | "signup-credit"; + +export type ProviderSupportLevel = "supported" | "experimental" | "reference"; + /** Row shape returned by GET /api/provider-presets (mirrors DerivedProviderPreset). */ export interface CatalogPreset { id: string; @@ -33,6 +41,53 @@ export interface CatalogPreset { baseUrlChoices?: Array<{ id: string; label: string; baseUrl?: string }>; codexAccountMode?: "direct" | "pool"; provider?: ProviderPayload; + accessGroups?: readonly ProviderAccessGroup[]; + supportLevel?: ProviderSupportLevel; + verification?: "official" | "primary" | "unverified"; + documentationUrl?: string; + modelsUrl?: string; + discovery?: "live" | "static" | "hybrid" | "unsupported"; + lastVerified?: string; + models?: string[]; + liveModels?: boolean; +} + +export const ACCESS_GROUPS: ProviderAccessGroup[] = [ + "recurring-or-keyless", + "recurring-uncapped", + "recurring-credit", + "signup-credit", +]; + +/** Curated free-directory rows. A provider may belong to more than one group. */ +export function curatedPresets(presets: CatalogPreset[]): CatalogPreset[] { + return presets.filter(p => (p.accessGroups?.length ?? 0) > 0); +} + +export function accessGroupCounts(presets: CatalogPreset[]): Record { + const counts = Object.fromEntries(ACCESS_GROUPS.map(group => [group, 0])) as Record; + for (const preset of curatedPresets(presets)) { + for (const group of new Set(preset.accessGroups)) counts[group] += 1; + } + return counts; +} + +export function filterByAccessGroup(presets: CatalogPreset[], group: ProviderAccessGroup | "all"): CatalogPreset[] { + const curated = curatedPresets(presets); + return group === "all" ? curated : curated.filter(p => p.accessGroups?.includes(group)); +} + +/** Keep the requested directory at 81 while retaining legacy free/local presets in a separate lane. */ +export function freeCatalogSections(presets: CatalogPreset[]): { directory: CatalogPreset[]; existing: CatalogPreset[] } { + const directory = curatedPresets(presets); + const free = bucketPresets(presets).free; + if (directory.length === 0) return { directory: free, existing: [] }; + const directoryIds = new Set(directory.map(preset => preset.id)); + return { directory, existing: free.filter(preset => !directoryIds.has(preset.id)) }; +} + +export function isPresetActionable(preset: CatalogPreset): boolean { + return preset.supportLevel !== "reference"; } /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6cade5be0..4feb196e0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -554,6 +554,8 @@ export const de = { "modal.endpoint": "Endpunkt", "modal.endpoint.tokenPlan": "Token-Plan", "modal.endpoint.payAsYouGo": "Pay as you go", + "modal.endpoint.chinaMainland": "Festlandchina", + "modal.endpoint.international": "International", "modal.endpoint.custom": "Benutzerdefiniert", "modal.defaultModel": "Standardmodell (optional)", "modal.allowPrivateNetwork": "Lokales/privates Netzwerk erlauben", @@ -997,6 +999,41 @@ export const de = { "time.minutesAgo": "vor {n} Min.", "time.hoursAgo": "vor {n} Std.", "time.daysAgo": "vor {n} T.", + "modal.catalogModes": "Zugriffsarten der Anbieter", + "modal.accessGroups": "Kostenlose Zugriffsgruppen", + "modal.providerDirectory": "Anbieterverzeichnis", + "modal.searchCount": "{count} Anbieter durchsuchen", + "modal.group.all": "Alle Anbieter", + "modal.group.recurring-or-keyless": "Regelmäßig / ohne SchlĂĽssel", + "modal.group.recurring-uncapped": "Kein öffentliches Token-Limit", + "modal.group.recurring-credit": "Regelmäßiges Guthaben", + "modal.group.signup-credit": "Startguthaben", + "modal.group.existing": "Vorhandene kostenlose & lokale", + "modal.paidHint": "Kostenpflichtige Anbieter und Anbieter mit einmaligem Startguthaben durchsuchen.", + "modal.selectProvider": "Anbieter auswählen", + "modal.selectProviderHint": "Wähle einen Anbieter, um die Einrichtung anzusehen und Modelle zu suchen.", + "modal.badge.recurring": "Regelmäßig kostenlos", + "modal.badge.reference": "Referenz", + "modal.badge.experimental": "Experimentell", + "modal.badge.supported": "UnterstĂĽtzt", + "modal.verification.official": "Offizielle Dokumentation", + "modal.verification.primary": "Primärquelle", + "modal.verification.unverified": "Nicht verifiziert", + "modal.providerDocs": "Anbieterdokumentation", + "modal.referenceExplanation": "Dieser Eintrag dient nur als Referenz. Automatische Aufrufe und die Modellsuche bleiben deaktiviert, bis ein dokumentierter Transportweg verifiziert wurde.", + "modal.modelDiscovery": "Modellsuche", + "modal.modelDiscoveryHint": "VerfĂĽgbare Modelle fĂĽr diese Einrichtung suchen.", + "modal.discoverModels": "Modelle suchen", + "modal.discovering": "Suche läuft…", + "modal.modelsLive": "Live-Antwort des Anbieters", + "modal.modelsStatic": "Statischer Katalog", + "modal.discoveredModels": "Gefundene Modelle", + "modal.discoveryFailed": "FĂĽr diese Einrichtung konnten keine Modelle gefunden werden.", + "modal.backToDirectory": "ZurĂĽck zum Anbieterverzeichnis", + "modal.setupReviewProvider": "Einrichtungsdokumentation fĂĽr {label} prĂĽfen.", + "modal.setupReferenceStep": "Nutze diesen Eintrag als Einrichtungshilfe, bis verifizierte AufrufunterstĂĽtzung verfĂĽgbar ist.", + "modal.setupKeylessStep": "Bestätige den Endpunkt unten; ein API-SchlĂĽssel ist optional.", + "modal.setupDiscoverStep": "Modelle suchen und ein Standardmodell auswählen.", "modal.noMatch": "Kein Treffer.", "modal.oauthDefaultNote": "Mit deinem Konto anmelden — kein API-SchlĂĽssel nötig.", "modal.oauthComingSoon": "OAuth-Login fĂĽr {label} kommt im nächsten Update. Nutze vorerst einen API-SchlĂĽssel.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 81bc40d28..a3d8b32b9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -611,6 +611,8 @@ export const en = { "modal.endpoint": "Endpoint", "modal.endpoint.tokenPlan": "Token plan", "modal.endpoint.payAsYouGo": "Pay as you go", + "modal.endpoint.chinaMainland": "China mainland", + "modal.endpoint.international": "International", "modal.endpoint.custom": "Custom", "modal.defaultModel": "Default model (optional)", "modal.allowPrivateNetwork": "Allow local/private network", @@ -840,6 +842,41 @@ export const en = { "time.minutesAgo": "{n}m ago", "time.hoursAgo": "{n}h ago", "time.daysAgo": "{n}d ago", + "modal.catalogModes": "Provider access modes", + "modal.accessGroups": "Free access groups", + "modal.providerDirectory": "Provider directory", + "modal.searchCount": "Search {count} providers", + "modal.group.all": "All providers", + "modal.group.recurring-or-keyless": "Recurring / keyless", + "modal.group.recurring-uncapped": "No public token cap", + "modal.group.recurring-credit": "Recurring credits", + "modal.group.signup-credit": "Signup credits", + "modal.group.existing": "Existing free & local", + "modal.paidHint": "Browse paid providers and providers with one-time signup credit.", + "modal.selectProvider": "Select a provider", + "modal.selectProviderHint": "Choose a provider to view setup and discover models.", + "modal.badge.recurring": "Free recurring", + "modal.badge.reference": "Reference", + "modal.badge.experimental": "Experimental", + "modal.badge.supported": "Supported", + "modal.verification.official": "Official docs", + "modal.verification.primary": "Primary source", + "modal.verification.unverified": "Unverified", + "modal.providerDocs": "Provider docs", + "modal.referenceExplanation": "This entry is reference-only. Automated calling and model discovery remain disabled until a documented transport has been verified.", + "modal.modelDiscovery": "Model discovery", + "modal.modelDiscoveryHint": "Find available models for this setup.", + "modal.discoverModels": "Discover models", + "modal.discovering": "Discovering…", + "modal.modelsLive": "Live provider response", + "modal.modelsStatic": "Static catalog", + "modal.discoveredModels": "Discovered models", + "modal.discoveryFailed": "No models could be discovered for this setup.", + "modal.backToDirectory": "Back to provider directory", + "modal.setupReviewProvider": "Review the {label} setup documentation.", + "modal.setupReferenceStep": "Use this entry as setup guidance until verified calling support is available.", + "modal.setupKeylessStep": "Confirm the endpoint below; an API key is optional.", + "modal.setupDiscoverStep": "Discover and choose a default model.", "modal.noMatch": "No match.", "modal.oauthDefaultNote": "Log in with your account — no API key needed.", "modal.oauthComingSoon": "OAuth login for {label} arrives in the next update. Use an API key for now.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e7dfd58c1..3c0cceddb 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -576,6 +576,8 @@ export const ja: Record = { "modal.endpoint": "エăłă‰ăťă‚¤ăłă", "modal.endpoint.tokenPlan": "ăăĽă‚Żăłă—ă©ăł", "modal.endpoint.payAsYouGo": "従量課金", + "modal.endpoint.chinaMainland": "中国本土", + "modal.endpoint.international": "国際", "modal.endpoint.custom": "カスタă ", "modal.defaultModel": "ă‡ă•ă‚©ă«ăă˘ă‡ă«(任意)", "modal.allowPrivateNetwork": "ă­ăĽă‚«ă«/ă—ă©ă‚¤ă™ăĽăăŤăăăŻăĽă‚Żă‚’許可", @@ -795,6 +797,41 @@ export const ja: Record = { "time.minutesAgo": "{n}ĺ†ĺ‰Ť", "time.hoursAgo": "{n}時間前", "time.daysAgo": "{n}日前", + "modal.catalogModes": "ă—ă­ăイă€ăĽă®ă‚˘ă‚Żă‚»ă‚ąć–ąĺĽŹ", + "modal.accessGroups": "無料アクセスă®ă‚°ă«ăĽă—", + "modal.providerDirectory": "ă—ă­ăイă€ăĽă‡ă‚Łă¬ă‚ŻăăŞ", + "modal.searchCount": "{count} ä»¶ă®ă—ă­ăイă€ăĽă‚’検索", + "modal.group.all": "ă™ăąă¦ă®ă—ă­ăイă€ăĽ", + "modal.group.recurring-or-keyless": "定期無料 / ă‚­ăĽä¸Ťč¦", + "modal.group.recurring-uncapped": "公開ăăĽă‚Żăłä¸Šé™ăŞă—", + "modal.group.recurring-credit": "定期ä»ä¸Žă‚Żă¬ă‚¸ăă", + "modal.group.signup-credit": "登録時クă¬ă‚¸ăă", + "modal.group.existing": "ć—˘ĺ­ă®ç„ˇć–™ă»ă­ăĽă‚«ă«", + "modal.paidHint": "有料ă—ă­ăイă€ăĽă¨ă€ç™»éڞ㙂ă«ä¸€ĺş¦ă ă‘クă¬ă‚¸ăăăŚä»ä¸Žă•れるă—ă­ăイă€ăĽă‚’参照ă—ăľă™ă€‚", + "modal.selectProvider": "ă—ă­ăイă€ăĽă‚’é¸ćŠž", + "modal.selectProviderHint": "ă—ă­ăイă€ăĽă‚’é¸ćŠžă—ă¦ă€č¨­ĺ®šă®ç˘şčŞŤă¨ă˘ă‡ă«ă®ć¤śĺ‡şă‚’行ă„ăľă™ă€‚", + "modal.badge.recurring": "定期無料", + "modal.badge.reference": "参č€ć…ĺ ±", + "modal.badge.experimental": "試験的", + "modal.badge.supported": "対応ć¸ăż", + "modal.verification.official": "公式ă‰ă‚­ăĄăˇăłă", + "modal.verification.primary": "一次ć…ĺ ±", + "modal.verification.unverified": "未検証", + "modal.providerDocs": "ă—ă­ăイă€ăĽă®ă‰ă‚­ăĄăˇăłă", + "modal.referenceExplanation": "ă“ă®é …ç›®ăŻĺŹ‚č€ć…ĺ ±ă®ăżă§ă™ă€‚文書化ă•れăźé€šäżˇć–ąĺĽŹăŚć¤śč¨Ľă•れるăľă§ă€č‡Şĺ‹•呼ăłĺ‡şă—ă¨ă˘ă‡ă«ć¤śĺ‡şăŻç„ˇĺŠąă§ă™ă€‚", + "modal.modelDiscovery": "ă˘ă‡ă«ć¤śĺ‡ş", + "modal.modelDiscoveryHint": "ă“ă®č¨­ĺ®šă§ĺ©ç”¨ĺŹŻč˝ăŞă˘ă‡ă«ă‚’検索ă—ăľă™ă€‚", + "modal.discoverModels": "ă˘ă‡ă«ă‚’検出", + "modal.discovering": "検出中…", + "modal.modelsLive": "ă—ă­ăイă€ăĽă‹ă‚‰ă®ă©ă‚¤ă–ĺżśç­”", + "modal.modelsStatic": "静的カタă­ă‚°", + "modal.discoveredModels": "検出ă•れăźă˘ă‡ă«", + "modal.discoveryFailed": "ă“ă®č¨­ĺ®šă§ă˘ă‡ă«ă‚’検出ă§ăŤăľă›ă‚“ă§ă—ăźă€‚", + "modal.backToDirectory": "ă—ă­ăイă€ăĽă‡ă‚Łă¬ă‚ŻăăŞă«ć»ă‚‹", + "modal.setupReviewProvider": "{label} ă®č¨­ĺ®šă‰ă‚­ăĄăˇăłăを確認ă—ăľă™ă€‚", + "modal.setupReferenceStep": "検証ć¸ăżă®ĺ‘Ľăłĺ‡şă—ă«ĺŻľĺżśă™ă‚‹ăľă§ă€ă“ă®é …目を設定ガイă‰ă¨ă—ă¦ĺ©ç”¨ă—ă¦ăŹă ă•ă„。", + "modal.setupKeylessStep": "下č¨ă®ă‚¨ăłă‰ăťă‚¤ăłăを確認ă—ăľă™ă€‚API ă‚­ăĽăŻä»»ć„Źă§ă™ă€‚", + "modal.setupDiscoverStep": "ă˘ă‡ă«ă‚’検出ă—ă¦ă€ć—˘ĺ®šă®ă˘ă‡ă«ă‚’é¸ćŠžă—ăľă™ă€‚", "modal.noMatch": "一致ăŞă—。", "modal.oauthDefaultNote": "アカウăłăă§ă­ă‚°ă‚¤ăł — API ă‚­ăĽä¸Ťč¦ă§ă™ă€‚", "modal.oauthComingSoon": "{label} ă® OAuth ă­ă‚°ă‚¤ăłăŻć¬ˇĺ›žă®ć›´ć–°ă§ĺŻľĺżśäşĺ®šă§ă™ă€‚今㯠API ă‚­ăĽă‚’ăŠä˝żă„ăŹă ă•ă„。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index df1797959..bff685846 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -573,6 +573,8 @@ export const ko: Record = { "modal.endpoint": "엔드포인트", "modal.endpoint.tokenPlan": "í† í° í”Śëžś", "modal.endpoint.payAsYouGo": "종량제", + "modal.endpoint.chinaMainland": "중국 본토", + "modal.endpoint.international": "ęµ­ě ś", "modal.endpoint.custom": "ě‚¬ěš©ěž ě§€ě •", "modal.defaultModel": "기본 모델(ě„ íť)", "modal.allowPrivateNetwork": "로컬/사설 ë„¤íŠ¸ě›Śí¬ í—ěš©", @@ -1019,6 +1021,41 @@ export const ko: Record = { "time.minutesAgo": "{n}ë¶„ ě „", "time.hoursAgo": "{n}시간 ě „", "time.daysAgo": "{n}일 ě „", + "modal.catalogModes": "프로바이더 ě ‘ę·Ľ 방식", + "modal.accessGroups": "무료 ě ‘ę·Ľ 그룹", + "modal.providerDirectory": "프로바이더 디렉터리", + "modal.searchCount": "프로바이더 {count}ę°ś 검ě‰", + "modal.group.all": "모든 프로바이더", + "modal.group.recurring-or-keyless": "정기 무료 / 키 ë¶í•„ěš”", + "modal.group.recurring-uncapped": "공개 í† í° í•śëŹ„ 없음", + "modal.group.recurring-credit": "정기 í¬ë ë”§", + "modal.group.signup-credit": "가입 í¬ë ë”§", + "modal.group.existing": "기존 무료 ë°Ź 로컬", + "modal.paidHint": "유료 프로바이더와 가입 시 일회성 í¬ë ë”§ěť„ ě śęłµí•는 프로바이더를 찾아보세요.", + "modal.selectProvider": "프로바이더 ě„ íť", + "modal.selectProviderHint": "프로바이더를 ě„ íťí•´ 설정을 확인í•ęł  모델을 검ě‰í•세요.", + "modal.badge.recurring": "정기 무료", + "modal.badge.reference": "참고 ěžëŁŚ", + "modal.badge.experimental": "실í—ě ", + "modal.badge.supported": "ě§€ě›ë¨", + "modal.verification.official": "공식 문서", + "modal.verification.primary": "1ě°¨ ě¶śě˛", + "modal.verification.unverified": "검증ëě§€ 않음", + "modal.providerDocs": "프로바이더 문서", + "modal.referenceExplanation": "ěť´ 항목은 참고용입ë‹ë‹¤. ë¬¸ě„śí™”ëś ě „ě†ˇ 방식이 ę˛€ě¦ťë  ë•ŚęąŚě§€ ěžëŹ™ í¸ě¶śęłĽ 모델 검ě‰ěť€ 비활성화ë©ë‹ë‹¤.", + "modal.modelDiscovery": "모델 검ě‰", + "modal.modelDiscoveryHint": "ěť´ 설정ě—서 사용 가능한 모델을 찾습ë‹ë‹¤.", + "modal.discoverModels": "모델 검ě‰", + "modal.discovering": "ę˛€ě‰ ě¤‘â€¦", + "modal.modelsLive": "프로바이더 실시간 응답", + "modal.modelsStatic": "ě •ě  ěą´í로그", + "modal.discoveredModels": "검ě‰ëś 모델", + "modal.discoveryFailed": "ěť´ 설정ě—서 모델을 ě°ľě§€ 못í–습ë‹ë‹¤.", + "modal.backToDirectory": "프로바이더 디렉터리로 돌아가기", + "modal.setupReviewProvider": "{label} 설정 문서를 확인í•세요.", + "modal.setupReferenceStep": "ę˛€ě¦ťëś í¸ě¶ś ě§€ě›ěť´ ě śęłµë  ë•ŚęąŚě§€ ěť´ 항목을 설정 ě•내로 사용í•세요.", + "modal.setupKeylessStep": "ě•„ëž ě—”ë“śíŹ¬ěť¸íŠ¸ëĄĽ 확인í•세요. API 키는 ě„ íť ě‚¬í•­ěž…ë‹ë‹¤.", + "modal.setupDiscoverStep": "모델을 검ě‰í•ęł  기본 모델을 ě„ íťí•세요.", "modal.noMatch": "ěťĽěą í•­ëŞ© 없음.", "modal.oauthDefaultNote": "계정으로 로그인 — API 키 ë¶í•„ěš”.", "modal.oauthComingSoon": "{label} OAuth 로그인은 다음 ě—…ëŤ°ěť´íŠ¸ě— ě śęłµë©ë‹ë‹¤. ě§€ę¸ěť€ API 키를 사용í•세요.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 148af25f6..5577e1577 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -611,6 +611,8 @@ export const ru: Record = { "modal.endpoint": "Конечная точка", "modal.endpoint.tokenPlan": "Пакет токенов", "modal.endpoint.payAsYouGo": "Оплата по фактŃ", + "modal.endpoint.chinaMainland": "Материковый Китай", + "modal.endpoint.international": "МеждŃнародный", "modal.endpoint.custom": "Своя", "modal.defaultModel": "Модель по Ńмолчанию (необязательно)", "modal.allowPrivateNetwork": "РазреŃить локальнŃŃŽ/чаŃтнŃŃŽ Ńеть", @@ -840,6 +842,41 @@ export const ru: Record = { "time.minutesAgo": "{n} мин назад", "time.hoursAgo": "{n} ч назад", "time.daysAgo": "{n} Đ´Đ˝. назад", + "modal.catalogModes": "Режимы Đ´ĐľŃŃ‚Ńпа Đş провайдерам", + "modal.accessGroups": "ГрŃппы беŃплатного Đ´ĐľŃŃ‚Ńпа", + "modal.providerDirectory": "Каталог провайдеров", + "modal.searchCount": "ПоиŃĐş Ńреди {count} провайдеров", + "modal.group.all": "Đ’Ńе провайдеры", + "modal.group.recurring-or-keyless": "РегŃлярно / без ключа", + "modal.group.recurring-uncapped": "Без ĐżŃбличного лимита токенов", + "modal.group.recurring-credit": "РегŃлярные кредиты", + "modal.group.signup-credit": "Кредиты за региŃтрацию", + "modal.group.existing": "СŃщеŃтвŃющие беŃплатные и локальные", + "modal.paidHint": "ПроŃматривайте платных провайдеров и провайдеров Ń Ń€Đ°Đ·ĐľĐ˛Ń‹ĐĽ кредитом за региŃтрацию.", + "modal.selectProvider": "Выберите провайдера", + "modal.selectProviderHint": "Выберите провайдера, чтобы проŃмотреть наŃŃ‚Ń€ĐľĐąĐşŃ Đ¸ найти модели.", + "modal.badge.recurring": "РегŃлярно беŃплатно", + "modal.badge.reference": "Справка", + "modal.badge.experimental": "Đ­ĐşŃпериментально", + "modal.badge.supported": "ПоддерживаетŃŃŹ", + "modal.verification.official": "Официальная докŃментация", + "modal.verification.primary": "Первичный иŃточник", + "modal.verification.unverified": "Не проверено", + "modal.providerDocs": "ДокŃментация провайдера", + "modal.referenceExplanation": "Эта запиŃŃŚ предназначена только для Ńправки. ĐвтоматичеŃкие вызовы и поиŃĐş моделей ĐľŃтанŃŃ‚ŃŃŹ отключёнными, пока не бŃдет проверен докŃментированный ŃпоŃоб подключения.", + "modal.modelDiscovery": "ПоиŃĐş моделей", + "modal.modelDiscoveryHint": "Найдите модели, Đ´ĐľŃŃ‚Ńпные для этой наŃтройки.", + "modal.discoverModels": "Найти модели", + "modal.discovering": "ПоиŃк…", + "modal.modelsLive": "Ответ провайдера в реальном времени", + "modal.modelsStatic": "СтатичеŃкий каталог", + "modal.discoveredModels": "Найденные модели", + "modal.discoveryFailed": "Не ŃдалоŃŃŚ найти модели для этой наŃтройки.", + "modal.backToDirectory": "ВернŃтьŃŃŹ Đş ĐşĐ°Ń‚Đ°Đ»ĐľĐłŃ ĐżŃ€ĐľĐ˛Đ°ĐąĐ´ĐµŃ€ĐľĐ˛", + "modal.setupReviewProvider": "ОзнакомьтеŃŃŚ Ń Đ´ĐľĐşŃментацией по наŃтройке {label}.", + "modal.setupReferenceStep": "ĐŃпользŃйте ŃŤŃ‚Ń Đ·Đ°ĐżĐ¸ŃŃŚ как Ń€ŃководŃтво по наŃтройке, пока не появитŃŃŹ поддержка проверенных вызовов.", + "modal.setupKeylessStep": "Подтвердите конечнŃŃŽ Ń‚ĐľŃ‡ĐşŃ Đ˝Đ¸Đ¶Đµ; API-ключ необязателен.", + "modal.setupDiscoverStep": "Найдите модели и выберите модель по Ńмолчанию.", "modal.noMatch": "Ничего не найдено.", "modal.oauthDefaultNote": "Войдите ŃĐľ Ńвоим аккаŃнтом — API-ключ не Đ˝Ńжен.", "modal.oauthComingSoon": "Вход через OAuth для {label} появитŃŃŹ в ŃледŃющем обновлении. Пока иŃпользŃйте API-ключ.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e6414158f..9764bc991 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -573,6 +573,8 @@ export const zh: Record = { "modal.endpoint": "端点", "modal.endpoint.tokenPlan": "Token 套é¤", "modal.endpoint.payAsYouGo": "按量ä»č´ą", + "modal.endpoint.chinaMainland": "中国大陆", + "modal.endpoint.international": "国际", "modal.endpoint.custom": "自定义", "modal.defaultModel": "é»č®¤ć¨ˇĺž‹ďĽĺŹŻé€‰ďĽ‰", "modal.allowPrivateNetwork": "ĺ…许本地/ç§ćś‰ç˝‘络", @@ -1019,6 +1021,41 @@ export const zh: Record = { "time.minutesAgo": "{n} ĺ†é’źĺ‰Ť", "time.hoursAgo": "{n} 小时前", "time.daysAgo": "{n} 天前", + "modal.catalogModes": "ćŹäľ›ĺ•†č®żé—®ć–ąĺĽŹ", + "modal.accessGroups": "免费访问ĺ†ç»„", + "modal.providerDirectory": "ćŹäľ›ĺ•†ç›®ĺ˝•", + "modal.searchCount": "ćśç´˘ {count} 个ćŹäľ›ĺ•†", + "modal.group.all": "所有ćŹäľ›ĺ•†", + "modal.group.recurring-or-keyless": "定期免费 / 无需密钥", + "modal.group.recurring-uncapped": "无公开 Token 上é™", + "modal.group.recurring-credit": "定期赠é€é˘ťĺş¦", + "modal.group.signup-credit": "注册赠é€é˘ťĺş¦", + "modal.group.existing": "现有免费与本地", + "modal.paidHint": "浏č§ä»č´ąćŹäľ›ĺ•†ĺŹŠćŹäľ›ä¸€ć¬ˇć€§ćł¨ĺ†Śčµ é€é˘ťĺş¦çš„ćŹäľ›ĺ•†ă€‚", + "modal.selectProvider": "选择ćŹäľ›ĺ•†", + "modal.selectProviderHint": "选择ćŹäľ›ĺ•†ä»ĄćźĄçś‹č®ľç˝®ĺą¶ĺŹ‘çŽ°ć¨ˇĺž‹ă€‚", + "modal.badge.recurring": "定期免费", + "modal.badge.reference": "参č€čµ„ć–™", + "modal.badge.experimental": "实验性", + "modal.badge.supported": "已支ćŚ", + "modal.verification.official": "ĺ®ć–ąć–‡ćˇŁ", + "modal.verification.primary": "第一方来ćş", + "modal.verification.unverified": "未验čŻ", + "modal.providerDocs": "ćŹäľ›ĺ•†ć–‡ćˇŁ", + "modal.referenceExplanation": "此条目仅供参č€ă€‚在已记录的传输方式通过验čŻĺ‰ŤďĽŚč‡ŞĺЍč°ç”¨ĺ’Ść¨ˇĺž‹ĺŹ‘çŽ°ĺŠźč˝ĺ°†äżťćŚç¦ç”¨ă€‚", + "modal.modelDiscovery": "模型发现", + "modal.modelDiscoveryHint": "查找此设置可用的模型。", + "modal.discoverModels": "发现模型", + "modal.discovering": "正在发现…", + "modal.modelsLive": "ćŹäľ›ĺ•†ĺ®žć—¶ĺ“Ťĺş”", + "modal.modelsStatic": "éť™ć€ç›®ĺ˝•", + "modal.discoveredModels": "已发现的模型", + "modal.discoveryFailed": "无法为此设置发现模型。", + "modal.backToDirectory": "返回ćŹäľ›ĺ•†ç›®ĺ˝•", + "modal.setupReviewProvider": "查看 {label} 的设置文档。", + "modal.setupReferenceStep": "在经过验čŻçš„č°ç”¨ć”ŻćŚĺŹŻç”¨ĺ‰ŤďĽŚčŻ·ĺ°†ć­¤ćťˇç›®ç”¨ä˝śč®ľç˝®ćŚ‡ĺŤ—ă€‚", + "modal.setupKeylessStep": "确认下方端点;API 密钥为可选项。", + "modal.setupDiscoverStep": "发现模型并选择é»č®¤ć¨ˇĺž‹ă€‚", "modal.noMatch": "无匹配。", "modal.oauthDefaultNote": "使用账ć·ç™»ĺ˝• — 无需 API 密钥。", "modal.oauthComingSoon": "{label} çš„ OAuth 登录将在下次更新ćŹäľ›ă€‚请ĺ…使用 API 密钥。", diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index ec9dfcb66..48e631a45 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -1,47 +1,117 @@ const PROVIDER_ICON_ALIASES: Record = { + agentrouter: "agentrouter.png", + agnes: "agnesai.svg", + agy: "provider-generic.svg", + ai21: "ai21-color.svg", + aihorde: "provider-generic.svg", + ainative: "provider-generic.svg", + aion: "aionlabs-color.svg", anthropic: "claude-color.svg", "anthropic-apikey": "claude-color.svg", "azure-openai": "openai.svg", + "arcee-ai": "arcee-color.svg", + "api-airforce": "api-airforce.svg", + bazaarlink: "bazaarlink.svg", + blackbox: "blackbox.png", + bluesminds: "bluesminds.svg", chatgpt: "openai.svg", - "cloudflare-ai-gateway": "cloudflare-ai-gateway-color.svg", + cerebras: "cerebras-color.svg", + "cloudflare-ai-gateway": "cloudflare-ai-gateway-color.svg", + "cloudflare-ai": "cloudflare-ai-gateway-color.svg", "cloudflare-workers-ai": "cloudflare-ai-gateway-color.svg", + cohere: "cohere-color.svg", + coze: "coze.svg", cursor: "cursor-color.svg", deepseek: "deepseek-color.svg", + deepinfra: "deepinfra-color.svg", + doubao: "volcengine-color.svg", + "duckduckgo-web": "duckduckgo.svg", + "felo-web": "provider-generic.svg", firepass: "firepass-color.svg", fireworks: "fireworks-color.svg", + friendliai: "friendli.svg", + "freemodel-dev": "freemodel-dev.svg", github: "github-copilot-color.svg", + "github-models": "github-copilot-color.svg", "github-copilot": "copilot-color.svg", "gitlab-duo": "gitlab-duo-color.svg", google: "gemini-color.svg", + gemini: "gemini-color.svg", "google-antigravity": "antigravity-color.svg", "google-vertex": "gemini-color.svg", + vertex: "gemini-color.svg", groq: "groq-color.svg", + hackclub: "hackclub.svg", huggingface: "huggingface-color.svg", + huggingchat: "huggingface-color.svg", + hyperbolic: "hyperbolic-color.svg", + iflytek: "iflytek-color.svg", + "inference-net": "inference.svg", kimi: "kimi-color.svg", "kimi-code": "kimi-color.svg", kiro: "kiro-color.svg", + "kilo-gateway": "kilocode.svg", "lm-studio": "lm-studio-color.svg", + litellm: "litellm.png", + liquid: "liquid.svg", + llm7: "llm7.svg", + longcat: "longcat-color.svg", mistral: "mistral-color.svg", moonshot: "moonshot-color.svg", + monsterapi: "monsterapi.svg", + morph: "morph-color.svg", + "muse-spark-web": "provider-generic.svg", + nara: "provider-generic.svg", + navy: "provider-generic.svg", + nebius: "nebius.svg", nvidia: "nvidia-color.svg", + "nous-research": "nousresearch.svg", + novita: "novita-color.svg", + nlpcloud: "nlpcloud.svg", + nscale: "nscale.png", ollama: "ollama-color.svg", "ollama-cloud": "ollama-color.svg", openai: "openai.svg", "openai-apikey": "openai.svg", "opencode-free": "opencode.svg", + opencode: "opencode.svg", "opencode-go": "opencode.svg", "opencode-zen": "opencode.svg", openrouter: "openrouter-color.svg", + ovhcloud: "ovh.svg", + pollinations: "pollinations.svg", + predibase: "predibase.png", + publicai: "publicai.svg", + puter: "puter.svg", qianfan: "qianfan-color.svg", + baidu: "qianfan-color.svg", + baichuan: "baichuan-color.svg", alibaba: "alibaba-color.svg", "alibaba-token-plan": "alibaba-color.svg", "alibaba-token-plan-intl": "alibaba-color.svg", "qwen-cloud": "qwen-portal-color.svg", + "qwen-web": "qwen-portal-color.svg", + qoder: "qoder-color.svg", + reka: "reka.png", + requesty: "requesty.svg", + routeway: "provider-generic.svg", + sambanova: "sambanova-color.svg", + scaleway: "scaleway.svg", + siliconflow: "siliconcloud-color.svg", + sensenova: "sensenova-color.svg", + sparkdesk: "spark-color.svg", + stepfun: "stepfun-color.svg", + "t3-web": "t3-web.svg", + together: "together-color.svg", + uncloseai: "uncloseai.svg", + tencent: "tencent-color.svg", "vercel-ai-gateway": "vercel-ai-gateway-color.svg", vllm: "vllm-color.svg", xai: "grok-color.svg", "mimo-free": "xiaomi-color.svg", xiaomi: "xiaomi-color.svg", + glm: "zhipu-color.svg", + "glm-cn": "zhipu-color.svg", }; /** Brand colors for monochrome Simple-Icons SVGs (fill is black by default). */ @@ -61,11 +131,11 @@ const PROVIDER_BRAND_COLORS: Record = { mistral: "#FF7000", openrouter: "#6566F1", google: "#8E75B2", - "google-vertex": "#8E75B2", + "google-vertex": "#8E75B2", alibaba: "#FF6A00", "alibaba-token-plan": "#FF6A00", "alibaba-token-plan-intl": "#FF6A00", - kimi: "#1A6CFF", + kimi: "#1A6CFF", "kimi-code": "#1A6CFF", moonshot: "#1A6CFF", }; @@ -81,7 +151,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { openai: "OpenAI (Codex login)", "openai-apikey": "OpenAI API", "azure-openai": "Azure OpenAI", - "cloudflare-ai-gateway": "Cloudflare AI Gateway", + "cloudflare-ai-gateway": "Cloudflare AI Gateway", "cloudflare-workers-ai": "Cloudflare Workers AI", nvidia: "NVIDIA NIM", ollama: "Ollama", @@ -121,16 +191,38 @@ const PROVIDER_DISPLAY_NAMES: Record = { type ProviderIconHints = { adapter?: string; baseUrl?: string; + defaultModel?: string; + models?: readonly string[]; }; function providerIconAlias(provider: string): string | undefined { return PROVIDER_ICON_ALIASES[provider.toLowerCase()]; } -/** Optional hints kept for call-site compatibility; resolution is name-based for now. */ -export function providerIconSrc(provider: string, _hints?: ProviderIconHints): string | undefined { - void _hints; - const icon = providerIconAlias(provider); +const MODEL_FAMILY_ICONS: ReadonlyArray = [ + [/\b(?:claude|anthropic)\b/i, "claude-color.svg"], + [/\b(?:gpt|openai|o[1345](?:\b|-))\b/i, "openai.svg"], + [/\b(?:gemini|gemma)\b/i, "gemini-color.svg"], + [/\bdeepseek\b/i, "deepseek-color.svg"], + [/\b(?:qwen|qwq)\b/i, "qwen-portal-color.svg"], + [/\b(?:kimi|moonshot)\b/i, "kimi-color.svg"], + [/\bmistral(?:ai)?\b/i, "mistral-color.svg"], + [/\b(?:grok|xai)\b/i, "grok-color.svg"], + [/\b(?:ernie|qianfan)\b/i, "qianfan-color.svg"], +]; + +function modelFamilyIcon(hints?: ProviderIconHints): string | undefined { + const models = [hints?.defaultModel, ...(hints?.models ?? [])].filter((model): model is string => !!model); + for (const model of models) { + const match = MODEL_FAMILY_ICONS.find(([pattern]) => pattern.test(model)); + if (match) return match[1]; + } + return undefined; +} + +/** Resolve a local provider mark first, then a known model-family mark. */ +export function providerIconSrc(provider: string, hints?: ProviderIconHints): string | undefined { + const icon = providerIconAlias(provider) ?? modelFamilyIcon(hints); return icon ? `/provider-icons/${icon}` : undefined; } diff --git a/gui/src/styles/provider-catalog.css b/gui/src/styles/provider-catalog.css index e12d5b199..3c9f71bde 100644 --- a/gui/src/styles/provider-catalog.css +++ b/gui/src/styles/provider-catalog.css @@ -1,67 +1,122 @@ -/* Add-provider catalog: tabs, browse rows, account rows (WP050b). */ +.provider-loader-overlay { padding: 24px 16px; } -.provider-catalog { - display: flex; - flex-direction: column; - gap: 10px; +.modal-card.provider-loader-card { + width: min(1400px, calc(100vw - 32px)); + max-width: none; + height: min(860px, calc(100dvh - 48px)); + max-height: none; + padding: 0; + overflow: hidden; } -.provider-catalog-tabs { - display: flex; - gap: 4px; +.provider-loader-card > .modal-head { + min-height: 62px; + margin: 0; + padding: 12px 18px; border-bottom: 1px solid var(--border); } +.provider-catalog { display: flex; flex-direction: column; min-height: 0; height: calc(100% - 62px); } +.provider-catalog-standalone { padding: 20px; overflow-y: auto; } +.provider-catalog-standalone .provider-detail-form { width: min(720px, 100%); margin: 0 auto; } +.provider-catalog-tabs { display: flex; gap: 4px; padding: 0 18px; border-bottom: 1px solid var(--border); } .provider-catalog-tab { - appearance: none; - background: none; - border: none; - border-bottom: 2px solid transparent; - padding: 8px 12px; - color: var(--muted); - font: inherit; - cursor: pointer; -} - -.provider-catalog-tab.active { - color: var(--fg); - border-bottom-color: var(--accent); -} - -.provider-catalog-accounts-hint { - padding: 2px 2px 0; + appearance: none; background: none; border: 0; border-bottom: 2px solid transparent; + min-height: 42px; padding: 8px 12px; color: var(--muted); font: inherit; cursor: pointer; } - -.provider-catalog-search { - width: 100%; +.provider-catalog-tab.active { color: var(--text); border-bottom-color: var(--accent); } +.provider-catalog-workspace { display: grid; grid-template-columns: 220px minmax(300px, 390px) minmax(420px, 1fr); min-height: 0; flex: 1; } +.provider-catalog-groups { display: flex; flex-direction: column; gap: 4px; padding: 14px 10px; border-right: 1px solid var(--border); min-width: 0; } +.provider-catalog-group { + display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; + min-height: 42px; padding: 8px 10px; border: 1px solid transparent; border-radius: var(--radius-sm); + background: transparent; color: var(--muted); font: inherit; font-size: var(--text-control); text-align: left; cursor: pointer; } - -.provider-catalog-rows { - display: flex; - flex-direction: column; - gap: 6px; - max-height: 360px; - overflow-y: auto; +.provider-catalog-group span { overflow-wrap: anywhere; } +.provider-catalog-group strong { font-size: var(--text-label); font-variant-numeric: tabular-nums; } +.provider-catalog-group:hover { background: var(--raised); color: var(--text); } +.provider-catalog-group.active { background: var(--accent-soft); border-color: var(--accent-ring); color: var(--text); } +.provider-catalog-custom { margin: auto 8px 4px; align-self: flex-start; } +.provider-catalog-accounts-hint { padding: 10px; line-height: var(--leading-body); } +.provider-catalog-browser { display: flex; flex-direction: column; min-width: 0; border-right: 1px solid var(--border); } +.provider-catalog-search-wrap { padding: 14px; border-bottom: 1px solid var(--border-soft); } +.provider-catalog-search { width: 100%; } +.provider-catalog-rows { display: flex; flex-direction: column; gap: 7px; padding: 10px; overflow-y: auto; min-height: 0; } +.provider-catalog-row { + display: flex; align-items: center; gap: 10px; width: 100%; min-height: 58px; padding: 8px 10px; + border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; + color: var(--text); font: inherit; text-align: left; cursor: pointer; } - -.provider-catalog-badges { - display: flex; - gap: 4px; - align-items: center; - flex-shrink: 0; +.provider-catalog-row:hover { background: var(--raised); } +.provider-catalog-row.active { border-color: var(--accent); background: var(--accent-soft); } +.provider-catalog-icon { + display: grid; place-items: center; flex: 0 0 34px; width: 34px; height: 34px; + border-radius: var(--radius-sm); background: var(--raised); font-weight: var(--weight-semibold); } - -.provider-catalog-empty { - padding: 8px; +.provider-catalog-icon img { width: 23px; height: 23px; object-fit: contain; } +.provider-catalog-row-copy { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 2px; } +.provider-catalog-row-copy .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-control); font-weight: var(--weight-semibold); } +.provider-catalog-row-copy .sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: var(--text-label); } +.provider-catalog-badges { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; flex: 0 0 auto; max-width: 46%; } +.provider-catalog-empty { padding: 10px; } +.provider-catalog-account-row { cursor: default; } +.provider-catalog-detail { min-width: 0; padding: 20px 24px 24px; overflow-y: auto; } +.provider-catalog-detail-empty { display: grid; place-content: center; gap: 7px; height: 100%; color: var(--muted); text-align: center; } +.provider-catalog-detail-empty strong { color: var(--text); font-size: var(--text-subtitle); } +.provider-detail-form { display: flex; flex-direction: column; gap: 12px; } +.provider-detail-mobile-back { display: none; align-self: flex-start; } +.provider-detail-header { display: flex; align-items: flex-start; gap: 12px; padding-bottom: 14px; border-bottom: 1px solid var(--border); } +.provider-detail-header .provider-catalog-icon { flex-basis: 42px; width: 42px; height: 42px; } +.provider-detail-header .provider-catalog-icon img { width: 29px; height: 29px; } +.provider-detail-title { flex: 1; min-width: 0; } +.provider-detail-title h4 { margin: 0 0 2px; font-size: var(--text-subtitle); } +.provider-detail-meta { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 7px; } +.provider-detail-docs { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; font-size: var(--text-label); } +.provider-detail-docs svg { width: 14px; height: 14px; flex: 0 0 auto; } +.provider-detail-guide { margin: 0; padding: 12px 14px; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--raised); } +.provider-detail-guide ol { margin: 8px 0 0; padding-left: 20px; color: var(--muted); font-size: var(--text-label); line-height: var(--leading-relaxed); } +.provider-detail-reference { padding: 10px 12px; border: 1px solid var(--amber); border-radius: var(--radius-sm); background: var(--amber-soft); color: var(--amber); font-size: var(--text-label); line-height: var(--leading-body); } +.provider-model-discovery { display: grid; gap: 8px; padding-top: 4px; border-top: 1px solid var(--border-soft); } +.provider-model-discovery-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.provider-model-discovery-head strong { font-size: var(--text-control); } +.provider-model-source { color: var(--muted); font-size: var(--text-label); } +.provider-model-list { display: grid; max-height: 210px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); } +.provider-model-row { display: flex; align-items: center; gap: 9px; padding: 8px 10px; font-size: var(--text-label); cursor: pointer; } +.provider-model-row + .provider-model-row { border-top: 1px solid var(--border-soft); } +.provider-model-row:hover { background: var(--raised); } +.provider-detail-actions { + position: sticky; bottom: -24px; z-index: 2; display: flex; align-items: center; gap: 8px; + margin: 2px -24px -24px; padding: 14px 24px; border-top: 1px solid var(--border); + background: color-mix(in oklab, var(--surface) 94%, transparent); backdrop-filter: blur(12px); } +.provider-detail-actions .provider-detail-spacer { flex: 1; } -.provider-catalog-account-row .sub { - overflow: hidden; - text-overflow: ellipsis; +@media (max-width: 1050px) { + .provider-catalog-workspace { grid-template-columns: 180px minmax(260px, 320px) minmax(0, 1fr); } + .provider-catalog-detail { padding: 18px; } } -.provider-catalog-footer { - display: flex; - gap: 8px; - align-items: center; +@media (max-width: 760px) { + .provider-loader-overlay { padding: 12px 10px; } + .modal-card.provider-loader-card { width: calc(100vw - 20px); height: calc(100dvh - 24px); } + .provider-loader-card > .modal-head { min-height: 56px; padding: 8px 12px; } + .provider-catalog { height: calc(100% - 56px); } + .provider-catalog-tabs { padding: 0 8px; overflow-x: auto; } + .provider-catalog-tab { min-width: max-content; min-height: 44px; } + .provider-catalog-workspace { display: grid; grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } + .provider-catalog-groups { flex-direction: row; gap: 6px; padding: 8px; overflow-x: auto; border: 0; border-bottom: 1px solid var(--border); } + .provider-catalog-group { display: flex; min-width: max-content; min-height: 38px; } + .provider-catalog-custom { margin: 0 4px; min-width: max-content; align-self: center; } + .provider-catalog-browser { border: 0; } + .provider-catalog-search-wrap { padding: 10px; } + .provider-catalog-detail { display: none; padding: 14px; } + .provider-catalog-workspace.has-selection .provider-catalog-groups, + .provider-catalog-workspace.has-selection .provider-catalog-browser { display: none; } + .provider-catalog-workspace.has-selection .provider-catalog-detail { display: block; } + .provider-catalog-workspace.is-accounts .provider-catalog-detail { display: none; } + .provider-catalog-badges .badge-muted { display: none; } + .provider-detail-header { flex-wrap: wrap; } + .provider-detail-mobile-back { display: inline-flex; } + .provider-detail-docs { width: 100%; } + .provider-detail-actions { bottom: -14px; margin: 2px -14px -14px; padding: 12px 14px; } } diff --git a/gui/tests/provider-catalog.test.ts b/gui/tests/provider-catalog.test.ts new file mode 100644 index 000000000..5454957eb --- /dev/null +++ b/gui/tests/provider-catalog.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { + accessGroupCounts, + curatedPresets, + filterByAccessGroup, + filterPresets, + freeCatalogSections, + isPresetActionable, + type CatalogPreset, +} from "../src/components/provider-catalog/provider-presets"; +import { FREE_PROVIDER_DIRECTORY } from "../../src/providers/free-directory"; +import { deriveProviderPresets } from "../../src/providers/derive"; + +const rows: CatalogPreset[] = FREE_PROVIDER_DIRECTORY.map(row => ({ + id: row.id, + label: row.label, + adapter: row.adapter, + baseUrl: row.baseUrl, + auth: row.authKind, + accessGroups: row.accessGroups, + supportLevel: row.supportLevel, +})); + +test("curated directory keeps 81 unique providers and counts glm-cn in both groups", () => { + expect(curatedPresets(rows)).toHaveLength(81); + expect(accessGroupCounts(rows)).toEqual({ + "recurring-or-keyless": 43, + "recurring-uncapped": 13, + "recurring-credit": 2, + "signup-credit": 24, + }); + expect(filterByAccessGroup(rows, "recurring-uncapped").some(row => row.id === "glm-cn")).toBe(true); + expect(filterByAccessGroup(rows, "signup-credit").some(row => row.id === "glm-cn")).toBe(true); +}); + +test("actual preset projection keeps the 81-row directory and seven legacy free providers reachable", () => { + const presets: CatalogPreset[] = deriveProviderPresets().filter(row => row.id !== "custom").map(row => ({ + id: row.id, + label: row.label, + adapter: row.adapter, + baseUrl: row.baseUrl, + auth: row.auth, + keyOptional: row.keyOptional, + freeTier: row.freeTier, + accessGroups: row.accessGroups, + })); + const sections = freeCatalogSections(presets); + expect(sections.directory).toHaveLength(81); + expect(sections.existing.map(row => row.id)).toEqual([ + "ollama", "vllm", "lm-studio", "litellm", "opencode-free", "mimo-free", "cloudflare-workers-ai", + ]); +}); + +test("search matches provider label/id and reference presets are not actionable", () => { + expect(filterPresets(rows, "google gemini").map(row => row.id)).toEqual(["gemini"]); + expect(isPresetActionable(rows.find(row => row.id === "duckduckgo-web")!)).toBe(false); + expect(isPresetActionable(rows.find(row => row.id === "groq")!)).toBe(true); +}); diff --git a/gui/tests/provider-discovery-race.test.tsx b/gui/tests/provider-discovery-race.test.tsx new file mode 100644 index 000000000..f519ddeec --- /dev/null +++ b/gui/tests/provider-discovery-race.test.tsx @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import AddProviderModal from "../src/components/AddProviderModal"; +import { LanguageProvider } from "../src/i18n/provider"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +const originalFetch = globalThis.fetch; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +test("stale discovery responses cannot overwrite a changed endpoint or provider", async () => { + const discoveryResolvers: Array<(response: Response) => void> = []; + const deferredDiscoveries: Promise[] = []; + const discoverySignals: Array = []; + + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/oauth/providers")) return Promise.resolve(Response.json({ providers: [] })); + if (url.endsWith("/api/usage?range=30d")) return Promise.resolve(Response.json({ providers: [] })); + if (url.endsWith("/api/provider-presets")) { + return Promise.resolve(Response.json({ + providers: [ + { + id: "alpha", + label: "Alpha", + adapter: "openai-chat", + baseUrl: "https://alpha-cn.example/v1", + auth: "key", + baseUrlChoices: [ + { id: "china-mainland", label: "China mainland", baseUrl: "https://alpha-cn.example/v1" }, + { id: "international", label: "International", baseUrl: "https://alpha-global.example/v1" }, + ], + accessGroups: ["recurring-or-keyless"], + supportLevel: "supported", + verification: "official", + discovery: "live", + liveModels: true, + }, + { + id: "beta", + label: "Beta", + adapter: "openai-chat", + baseUrl: "https://beta.example/v1", + auth: "key", + accessGroups: ["recurring-or-keyless"], + supportLevel: "supported", + verification: "official", + discovery: "static", + liveModels: false, + models: ["beta-static"], + defaultModel: "beta-static", + }, + ], + })); + } + if (url.endsWith("/api/provider-presets/discover")) { + const requestIndex = deferredDiscoveries.length; + discoverySignals.push(init?.signal ?? undefined); + expect(JSON.parse(String(init?.body))).toMatchObject({ + presetId: "alpha", + provider: { + baseUrl: requestIndex === 0 ? "https://alpha-cn.example/v1" : "https://alpha-global.example/v1", + }, + }); + const deferred = new Promise(resolve => { discoveryResolvers.push(resolve); }); + deferredDiscoveries.push(deferred); + return deferred; + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }) as typeof fetch; + + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root: Root; + + await act(async () => { + root = createRoot(container); + root.render( + + {}} + onAdded={() => {}} + /> + , + ); + }); + await act(async () => { + await new Promise(resolve => window.setTimeout(resolve, 0)); + }); + + const button = (label: string) => [...container.querySelectorAll("button")] + .find(candidate => candidate.textContent?.includes(label)); + + await act(async () => { button("Alpha")!.click(); }); + await act(async () => { button("Discover models")!.click(); }); + expect(discoverySignals[0]?.aborted).toBe(false); + + const endpointSelect = [...container.querySelectorAll("label")] + .find(label => label.textContent?.includes("Endpoint")) + ?.querySelector("select"); + expect(endpointSelect).toBeDefined(); + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLSelectElement.prototype, "value")! + .set!.call(endpointSelect, "international"); + endpointSelect!.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + expect(endpointSelect?.value).toBe("international"); + expect(discoverySignals[0]?.aborted).toBe(true); + + await act(async () => { + discoveryResolvers[0]!(Response.json({ ok: true, source: "live", models: [{ id: "alpha-cn-stale" }] })); + await deferredDiscoveries[0]; + await Promise.resolve(); + }); + expect(container.textContent).not.toContain("alpha-cn-stale"); + + await act(async () => { button("Discover models")!.click(); }); + expect(discoverySignals[1]?.aborted).toBe(false); + + await act(async () => { button("Beta")!.click(); }); + expect(discoverySignals[1]?.aborted).toBe(true); + expect(container.textContent).toContain("beta-static"); + + await act(async () => { + discoveryResolvers[1]!(Response.json({ ok: true, source: "live", models: [{ id: "alpha-global-stale" }] })); + await deferredDiscoveries[1]; + await Promise.resolve(); + }); + + expect(container.textContent).toContain("Beta"); + expect(container.textContent).toContain("beta-static"); + expect(container.textContent).not.toContain("alpha-global-stale"); + const defaultModelInput = [...container.querySelectorAll("label")] + .find(label => label.textContent?.includes("Default model")) + ?.querySelector("input"); + expect(defaultModelInput?.value).toBe("beta-static"); + + await act(async () => { root.unmount(); }); +}); diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 04556b0b4..2b2722fe0 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -63,14 +63,53 @@ export type ProviderModelsApiItem = { }; }; -export function isProviderModelsApiItems(value: unknown): value is ProviderModelsApiItem[] { - return Array.isArray(value) && value.every(item => - item !== null - && typeof item === "object" - && !Array.isArray(item) - && typeof (item as { id?: unknown }).id === "string" - && (item as { id: string }).id.trim().length > 0 - ); +/** Normalize the common model-catalog envelopes without weakening malformed-response fallback. */ +export function parseProviderModelsApiItems(value: unknown, allowModelsEnvelope = false): ProviderModelsApiItem[] | null { + const rows = Array.isArray(value) + ? value + : value !== null && typeof value === "object" + ? (Array.isArray((value as { data?: unknown }).data) + ? (value as { data: unknown[] }).data + : allowModelsEnvelope && Array.isArray((value as { models?: unknown }).models) + ? (value as { models: unknown[] }).models + : null) + : null; + if (!rows) return null; + + const normalized: ProviderModelsApiItem[] = []; + for (const row of rows) { + if (typeof row === "string" && row.trim()) { + normalized.push({ id: row.trim() }); + continue; + } + if (row === null || typeof row !== "object" || Array.isArray(row)) return null; + const item = row as Record; + const rawId = typeof item.id === "string" ? item.id + : typeof item.name === "string" ? item.name + : typeof item.model === "string" ? item.model + : ""; + const id = rawId.trim() + .replace(/^models\//, "") + .replace(/^accounts\/fireworks\/models\//, ""); + if (!id) return null; + const contextLength = [ + item.context_length, + item.context_window, + item.contextWindow, + item.inputTokenLimit, + item.max_context_length, + item.max_model_len, + ].find(candidate => typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) as number | undefined; + normalized.push({ + id, + ...(typeof item.owned_by === "string" ? { owned_by: item.owned_by } : {}), + ...(contextLength ? { context_length: contextLength } : {}), + ...(item.metadata !== null && typeof item.metadata === "object" && !Array.isArray(item.metadata) + ? { metadata: item.metadata as ProviderModelsApiItem["metadata"] } + : {}), + }); + } + return normalized; } export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined { @@ -326,7 +365,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, return models; } - const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) }); + const res = await fetch(url, { headers, redirect: "error", signal: AbortSignal.timeout(8000) }); if (!res.ok) { const { models, fallback } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); console.warn( @@ -352,17 +391,16 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, ); return models; } - const data = json !== null && typeof json === "object" && !Array.isArray(json) - ? (json as { data?: unknown }).data - : undefined; - if (!isProviderModelsApiItems(data)) { + const allowsModelsEnvelope = (prov.adapter === "google" && (prov.googleMode ?? "ai-studio") === "ai-studio") + || new URL(url).pathname.replace(/\/+$/, "").endsWith("/api/tags"); + const items = parseProviderModelsApiItems(json, allowsModelsEnvelope); + if (!items) { const { models, fallback } = failedDiscoveryFallback({ reason: "invalid_response" }); console.warn( `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, ); return models; } - const items = data; const live = items.map(m => applyProviderConfigHints(name, prov, { id: m.id, provider: name, diff --git a/src/oauth/index.ts b/src/oauth/index.ts index a8382accc..0cc74bb1b 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -13,7 +13,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity" import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; -import { effectiveGoogleMode } from "../providers/registry"; +import { effectiveGoogleMode, getProviderRegistryEntry } from "../providers/registry"; import { resolveProviderTransport } from "../providers/xai-transport"; import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; @@ -436,13 +436,16 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined, ); const headers: Record = { ...(effectiveProvider.headers ?? {}) }; + const registry = getProviderRegistryEntry(providerName); + const sameRegistryEndpoint = registry?.baseUrl.replace(/\/+$/, "") === effectiveProvider.baseUrl.replace(/\/+$/, ""); + const trustedModelsUrl = sameRegistryEndpoint ? registry?.modelsUrl : undefined; if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") { // Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer), // models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 — // enough to list everything without a pageToken loop. Vertex/antigravity keep the // generic branch (they fall back to their static model lists). if (apiKey) headers["x-goog-api-key"] = apiKey; - return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers }; + return { url: trustedModelsUrl ?? `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers }; } if (effectiveProvider.adapter === "anthropic") { headers["anthropic-version"] = "2023-06-01"; @@ -452,10 +455,10 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und } else if (apiKey) { headers["x-api-key"] = apiKey; } - return { url: `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers }; + return { url: trustedModelsUrl ?? `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers }; } if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - return { url: `${effectiveProvider.baseUrl}/models`, headers }; + return { url: trustedModelsUrl ?? `${effectiveProvider.baseUrl}/models`, headers }; } /** diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 7277d5e87..22f19a6d4 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -1,5 +1,6 @@ import type { CodexAccountMode, OcxProviderConfig } from "../types"; import { PROVIDER_REGISTRY, type ProviderRegistryEntry } from "./registry"; +import type { ProviderAccessGroup } from "./free-directory"; export interface DerivedKeyLoginProvider { label: string; @@ -67,6 +68,15 @@ export interface DerivedProviderPreset { baseUrlChoices?: Array<{ id: string; label: string; baseUrl?: string }>; /** Immutable canonical provider config seed for the reserved canonical `openai` forward preset. */ provider?: OcxProviderConfig; + accessGroups?: readonly ProviderAccessGroup[]; + supportLevel?: "supported" | "experimental" | "reference"; + verification?: "official" | "primary" | "unverified"; + documentationUrl?: string; + modelsUrl?: string; + discovery?: "live" | "static" | "hybrid" | "unsupported"; + lastVerified?: string; + models?: string[]; + liveModels?: boolean; } export function listRegistryEntries(): readonly ProviderRegistryEntry[] { @@ -125,7 +135,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon export function deriveKeyLoginMap(): Record { const out: Record = {}; for (const entry of PROVIDER_REGISTRY) { - if (entry.authKind !== "key") continue; + if (entry.authKind !== "key" || entry.supportLevel === "reference" || entry.directoryOnly) continue; if (!entry.dashboardUrl) throw new Error(`Registry key provider missing dashboardUrl: ${entry.id}`); out[entry.id] = { label: entry.label, @@ -165,7 +175,7 @@ export function deriveKeyLoginMap(): Record { } export function deriveInitProviders(): DerivedInitProvider[] { - return PROVIDER_REGISTRY.map(entry => ({ + return PROVIDER_REGISTRY.filter(entry => entry.supportLevel !== "reference" && !entry.directoryOnly).map(entry => ({ id: entry.id, label: formatInitLabel(entry), adapter: entry.adapter, @@ -192,7 +202,7 @@ export function deriveOAuthIds(): string[] { export function deriveProviderPresets(): DerivedProviderPreset[] { const presets = PROVIDER_REGISTRY - .filter(entry => entry.featured || entry.authKind === "key" || entry.dashboardPreset) + .filter(entry => entry.featured || entry.authKind === "key" || entry.dashboardPreset || entry.accessGroups?.length) .map(entryToPreset); return [...dedupePresets(presets), customPreset()]; } @@ -231,6 +241,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.freeTier === undefined && seed.freeTier !== undefined) prov.freeTier = seed.freeTier; if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip; if (!prov.headers && seed.headers) prov.headers = { ...seed.headers }; + if (prov.googleMode === undefined && seed.googleMode !== undefined) prov.googleMode = seed.googleMode; + if (prov.project === undefined && seed.project !== undefined) prov.project = seed.project; + if (prov.location === undefined && seed.location !== undefined) prov.location = seed.location; } export function deriveFeaturedProviderIds(): string[] { @@ -270,6 +283,15 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset { ...(entry.keyOptional ? { keyOptional: true } : {}), ...(entry.freeTier ? { freeTier: true } : {}), ...(entry.baseUrlChoices ? { baseUrlChoices: entry.baseUrlChoices.map(c => ({ ...c })) } : {}), + ...(entry.accessGroups ? { accessGroups: [...entry.accessGroups] } : {}), + ...(entry.supportLevel ? { supportLevel: entry.supportLevel } : {}), + ...(entry.verification ? { verification: entry.verification } : {}), + ...(entry.documentationUrl ? { documentationUrl: entry.documentationUrl } : {}), + ...(entry.modelsUrl ? { modelsUrl: entry.modelsUrl } : {}), + ...(entry.discovery ? { discovery: entry.discovery } : {}), + ...(entry.lastVerified ? { lastVerified: entry.lastVerified } : {}), + ...(entry.models ? { models: [...entry.models] } : {}), + ...(entry.liveModels !== undefined ? { liveModels: entry.liveModels } : {}), }; } diff --git a/src/providers/free-directory.ts b/src/providers/free-directory.ts new file mode 100644 index 000000000..3cd4647af --- /dev/null +++ b/src/providers/free-directory.ts @@ -0,0 +1,168 @@ +export type ProviderAccessGroup = + | "recurring-or-keyless" + | "recurring-uncapped" + | "recurring-credit" + | "signup-credit"; + +export const FREE_PROVIDER_ACCESS_GROUPS = { + "recurring-or-keyless": [ + "agy", "aihorde", "api-airforce", "arcee-ai", "bazaarlink", "blackbox", "bluesminds", "cerebras", + "cloudflare-ai", "cohere", "coze", "duckduckgo-web", "felo-web", "friendliai", "gemini", "github-models", + "groq", "hackclub", "huggingchat", "huggingface", "iflytek", "inference-net", "kiro", "liquid", "llm7", + "mistral", "morph", "muse-spark-web", "nara", "navy", "nlpcloud", "ollama-cloud", "opencode", "openrouter", + "ovhcloud", "pollinations", "puter", "qwen-web", "reka", "sambanova", "sparkdesk", "t3-web", "uncloseai", + ], + "recurring-uncapped": [ + "agnes", "ainative", "aion", "baidu", "glm", "glm-cn", "kilo-gateway", "opencode-zen", "requesty", + "routeway", "sealion", "siliconflow", "tencent", + ], + "recurring-credit": ["bytez", "nous-research"], + "signup-credit": [ + "agentrouter", "ai21", "baichuan", "deepinfra", "deepseek", "doubao", "fireworks", "freemodel-dev", "glm-cn", + "hyperbolic", "longcat", "monsterapi", "nebius", "novita", "nscale", "nvidia", "predibase", "publicai", "qoder", + "scaleway", "sensenova", "stepfun", "together", "vertex", + ], +} as const satisfies Record; + +export interface FreeDirectoryProvider { + id: string; + label: string; + adapter: string; + baseUrl: string; + authKind: "oauth" | "key"; + accessGroups: readonly ProviderAccessGroup[]; + supportLevel: "supported" | "experimental" | "reference"; + verification: "official" | "primary" | "unverified"; + documentationUrl?: string; + modelsUrl?: string; + discovery: "live" | "static" | "hybrid" | "unsupported"; + lastVerified: string; + dashboardUrl?: string; + keyOptional?: boolean; + models?: string[]; + liveModels: boolean; + note?: string; + googleMode?: "ai-studio" | "vertex"; +} + +type ConnectableOverride = Partial>; + +const LAST_VERIFIED = "2026-07-23"; +const openAi = (baseUrl: string, dashboardUrl: string, extra: Partial = {}): ConnectableOverride => ({ + baseUrl, + dashboardUrl, + adapter: "openai-chat", + authKind: "key", + supportLevel: "experimental", + verification: "primary", + discovery: "live", + liveModels: true, + ...extra, +}); + +// API roots are limited to documented or primary-source integrations. Consumer-web/session +// providers remain reference-only: this directory never asks users to paste cookies or bypass WAFs. +const CONNECTABLE: Record = { + kiro: { baseUrl: "https://runtime.us-east-1.kiro.dev", dashboardUrl: "https://kiro.dev", adapter: "kiro", authKind: "oauth", supportLevel: "supported", verification: "official", documentationUrl: "https://kiro.dev/docs/cli/authentication/", discovery: "static", liveModels: false }, + aihorde: openAi("https://oai.aihorde.net/v1", "https://aihorde.net", { modelsUrl: "https://oai.aihorde.net/v1/models" }), + "arcee-ai": openAi("https://conductor.arcee.ai/v1", "https://conductor.arcee.ai", { verification: "official", documentationUrl: "https://docs.arcee.ai/" }), + cerebras: openAi("https://api.cerebras.ai/v1", "https://cloud.cerebras.ai/platform/apikeys", { supportLevel: "supported", verification: "official", documentationUrl: "https://inference-docs.cerebras.ai/api-reference/models/list" }), + "cloudflare-ai": openAi("https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", "https://dash.cloudflare.com/?to=/:account/ai/workers-ai", { supportLevel: "supported", verification: "official", documentationUrl: "https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/", discovery: "static", liveModels: false, models: ["@cf/meta/llama-3.3-70b-instruct-fp8-fast", "@cf/qwen/qwq-32b"] }), + cohere: openAi("https://api.cohere.com/compatibility/v1", "https://dashboard.cohere.com/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.cohere.com/reference/list-models", modelsUrl: "https://api.cohere.com/compatibility/v1/models" }), + friendliai: openAi("https://api.friendli.ai/serverless/v1", "https://suite.friendli.ai", { modelsUrl: "https://api.friendli.ai/serverless/v1/models" }), + gemini: { baseUrl: "https://generativelanguage.googleapis.com", dashboardUrl: "https://aistudio.google.com/apikey", adapter: "google", authKind: "key", supportLevel: "supported", verification: "official", documentationUrl: "https://ai.google.dev/api/models", discovery: "live", liveModels: true, googleMode: "ai-studio", models: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview"] }, + "github-models": openAi("https://models.github.ai/inference", "https://github.com/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.github.com/en/github-models/prototyping-with-ai-models", discovery: "static", liveModels: false, models: ["openai/gpt-4.1", "meta/llama-4-scout-17b-16e-instruct"] }), + groq: openAi("https://api.groq.com/openai/v1", "https://console.groq.com/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://console.groq.com/docs/api-reference#models" }), + hackclub: openAi("https://ai.hackclub.com/proxy/v1", "https://ai.hackclub.com", { modelsUrl: "https://ai.hackclub.com/proxy/v1/models" }), + huggingface: openAi("https://router.huggingface.co/v1", "https://huggingface.co/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://huggingface.co/docs/inference-providers/index", modelsUrl: "https://router.huggingface.co/v1/models" }), + iflytek: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/Web.html" }), + liquid: openAi("https://inference.liquid.ai/v1", "https://playground.liquid.ai", { modelsUrl: "https://inference.liquid.ai/v1/models" }), + mistral: openAi("https://api.mistral.ai/v1", "https://console.mistral.ai/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.mistral.ai/api/endpoint/models" }), + morph: openAi("https://api.morphllm.com/v1", "https://morphllm.com", { verification: "official", documentationUrl: "https://docs.morphllm.com/api-reference/introduction" }), + "ollama-cloud": openAi("https://ollama.com/v1", "https://ollama.com/settings/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ollama.com/cloud", modelsUrl: "https://ollama.com/api/tags" }), + opencode: openAi("https://opencode.ai/zen/v1", "https://opencode.ai/auth", { supportLevel: "supported", verification: "official", documentationUrl: "https://opencode.ai/docs/zen/", modelsUrl: "https://opencode.ai/zen/v1/models" }), + openrouter: openAi("https://openrouter.ai/api/v1", "https://openrouter.ai/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://openrouter.ai/docs/api/api-reference/models/get-models" }), + ovhcloud: openAi("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", "https://console.ovhcloud.com", { keyOptional: true, modelsUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models" }), + pollinations: openAi("https://gen.pollinations.ai/v1", "https://pollinations.ai", { keyOptional: true }), + reka: openAi("https://api.reka.ai/v1", "https://platform.reka.ai"), + sambanova: openAi("https://api.sambanova.ai/v1", "https://cloud.sambanova.ai/apis", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.sambanova.ai/cloud/docs/api-reference/models" }), + sparkdesk: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/Web.html" }), + agnes: openAi("https://apihub.agnes-ai.com/v1", "https://agnes-ai.com"), + ainative: openAi("https://api.ainative.studio/api/v1", "https://ainative.studio", { modelsUrl: "https://api.ainative.studio/api/v1/models" }), + aion: openAi("https://api.aionlabs.ai/v1", "https://aionlabs.ai", { modelsUrl: "https://api.aionlabs.ai/v1/models" }), + baidu: openAi("https://qianfan.baidubce.com/v2", "https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application", { verification: "official", documentationUrl: "https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Fm2vrveyu", discovery: "static", liveModels: false, models: ["ernie-5.1", "ernie-5.0", "ernie-4.5-turbo-128k"] }), + glm: openAi("https://api.z.ai/api/coding/paas/v4", "https://z.ai/manage-apikey/apikey-list", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.z.ai/guides/overview/quick-start", discovery: "static", liveModels: false, models: ["glm-5"] }), + "glm-cn": openAi("https://open.bigmodel.cn/api/coding/paas/v4", "https://open.bigmodel.cn/usercenter/apikeys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.bigmodel.cn/cn/api/introduction", discovery: "static", liveModels: false, models: ["glm-4.5-flash"] }), + "kilo-gateway": openAi("https://api.kilo.ai/api/gateway", "https://kilo.ai", { modelsUrl: "https://api.kilo.ai/api/gateway/models" }), + "opencode-zen": openAi("https://opencode.ai/zen/v1", "https://opencode.ai/auth", { supportLevel: "supported", verification: "official", documentationUrl: "https://opencode.ai/docs/zen/" }), + requesty: openAi("https://router.requesty.ai/v1", "https://app.requesty.ai", { modelsUrl: "https://router.requesty.ai/v1/models" }), + routeway: openAi("https://api.routeway.ai/v1", "https://routeway.ai", { modelsUrl: "https://api.routeway.ai/v1/models" }), + sealion: openAi("https://api.sea-lion.ai/v1", "https://sea-lion.ai", { discovery: "static", liveModels: false, models: ["aisingapore/Llama-SEA-LION-v3.5-70B-R", "aisingapore/Llama-SEA-LION-v3-70B-IT", "aisingapore/Gemma-SEA-LION-v4-27B-IT", "aisingapore/Qwen-SEA-LION-v4.5-27B-IT", "aisingapore/Qwen-SEA-LION-v4-32B-IT"] }), + siliconflow: openAi("https://api.siliconflow.cn/v1", "https://cloud.siliconflow.cn/account/ak", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.siliconflow.cn/en/api-reference/models/get-model-list" }), + tencent: openAi("https://api.hunyuan.cloud.tencent.com/v1", "https://console.cloud.tencent.com/hunyuan/api-key", { verification: "official", documentationUrl: "https://cloud.tencent.com/document/product/1729/111007", discovery: "static", liveModels: false, models: ["hunyuan-turbos-latest", "hunyuan-t1-latest", "hunyuan-pro", "hunyuan-lite"] }), + bytez: openAi("https://api.bytez.com/models/v2/openai/v1", "https://bytez.com", { verification: "unverified", documentationUrl: "https://docs.bytez.com/", discovery: "static", liveModels: false, models: ["meta-llama/Llama-3.3-70B-Instruct", "mistralai/Mistral-7B-Instruct-v0.3", "Qwen/Qwen2.5-72B-Instruct"], note: "The recurring-credit classification is retained from the requested catalog, but the current reset terms could not be independently verified." }), + "nous-research": openAi("https://inference-api.nousresearch.com/v1", "https://portal.nousresearch.com", { discovery: "static", liveModels: false, models: ["Hermes-4-405B", "Hermes-4-70B"] }), + agentrouter: { baseUrl: "https://agentrouter.org", dashboardUrl: "https://agentrouter.org", adapter: "anthropic", authKind: "key", supportLevel: "experimental", verification: "primary", modelsUrl: "https://agentrouter.org/v1/models", discovery: "live", liveModels: true }, + ai21: openAi("https://api.ai21.com/studio/v1", "https://studio.ai21.com/account/api-key", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.ai21.com/reference/models" }), + baichuan: openAi("https://api.baichuan-ai.com/v1", "https://platform.baichuan-ai.com/console/apikey", { verification: "official" }), + deepinfra: openAi("https://api.deepinfra.com/v1/openai", "https://deepinfra.com/dash/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://deepinfra.com/docs/openai_api" }), + deepseek: openAi("https://api.deepseek.com", "https://platform.deepseek.com/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://api-docs.deepseek.com/api/list-models" }), + doubao: openAi("https://ark.cn-beijing.volces.com/api/v3", "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", { verification: "official" }), + fireworks: openAi("https://api.fireworks.ai/inference/v1", "https://fireworks.ai/account/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.fireworks.ai/api-reference/list-models", modelsUrl: "https://api.fireworks.ai/v1/accounts/fireworks/models?filter=supports_serverless=true" }), + "freemodel-dev": openAi("https://api.freemodel.dev/v1", "https://freemodel.dev", { modelsUrl: "https://api.freemodel.dev/v1/models" }), + hyperbolic: openAi("https://api.hyperbolic.xyz/v1", "https://app.hyperbolic.xyz/settings", { verification: "official" }), + longcat: openAi("https://api.longcat.chat/openai/v1", "https://longcat.chat", { verification: "official", discovery: "static", liveModels: false, models: ["LongCat-2.0"] }), + monsterapi: openAi("https://api.monsterapi.ai/v1", "https://monsterapi.ai", { verification: "official" }), + nebius: openAi("https://api.tokenfactory.nebius.com/v1", "https://studio.nebius.com", { verification: "official" }), + novita: openAi("https://api.novita.ai/openai/v1", "https://novita.ai/settings/key-management", { supportLevel: "supported", verification: "official", modelsUrl: "https://api.novita.ai/openai/v1/models" }), + nscale: openAi("https://inference.api.nscale.com/v1", "https://console.nscale.com", { verification: "official" }), + nvidia: openAi("https://integrate.api.nvidia.com/v1", "https://build.nvidia.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.api.nvidia.com/nim/reference/llm-apis" }), + publicai: openAi("https://api.publicai.co/v1", "https://publicai.co"), + scaleway: openAi("https://api.scaleway.ai/v1", "https://console.scaleway.com/generative-api", { verification: "official" }), + sensenova: openAi("https://token.sensenova.cn/v1", "https://console.sensenova.cn", { verification: "official" }), + stepfun: openAi("https://api.stepfun.com/v1", "https://platform.stepfun.com", { verification: "official" }), + together: openAi("https://api.together.xyz/v1", "https://api.together.xyz/settings/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.together.ai/reference/models-1" }), + vertex: { baseUrl: "https://aiplatform.googleapis.com", dashboardUrl: "https://console.cloud.google.com/vertex-ai", adapter: "google", authKind: "key", supportLevel: "experimental", verification: "official", documentationUrl: "https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference", discovery: "static", liveModels: false, googleMode: "vertex", models: ["gemini-3.1-pro-preview", "gemini-3.1-flash-lite", "gemini-3-flash-preview"], note: "Vertex accepts an express-mode API key here. ADC users can instead configure project and location through the existing Google Vertex provider." }, +}; + +const LABELS: Record = { + agy: "AGY", aihorde: "AI Horde", "api-airforce": "API Airforce", "arcee-ai": "Arcee AI", + bazaarlink: "BazaarLink", blackbox: "Blackbox", bluesminds: "Bluesminds", "cloudflare-ai": "Cloudflare AI", + coze: "Coze", "duckduckgo-web": "DuckDuckGo Web", "felo-web": "Felo Web", friendliai: "FriendliAI", + gemini: "Google Gemini", "github-models": "GitHub Models", huggingchat: "HuggingChat", huggingface: "Hugging Face", + iflytek: "iFlytek", "inference-net": "Inference.net", llm7: "LLM7", "muse-spark-web": "Muse Spark Web", + nlpcloud: "NLP Cloud", "ollama-cloud": "Ollama Cloud", ovhcloud: "OVHcloud", "qwen-web": "Qwen Web", + "t3-web": "T3 Web", uncloseai: "UncloseAI", ainative: "AI Native", baidu: "Baidu Qianfan", + glm: "Z.AI GLM", "glm-cn": "BigModel GLM (CN)", "kilo-gateway": "Kilo Gateway", "opencode-zen": "OpenCode Zen", + sealion: "SEA-LION", bytez: "Bytez", "nous-research": "Nous Research", agentrouter: "AgentRouter", + ai21: "AI21", baichuan: "Baichuan", deepinfra: "DeepInfra", deepseek: "DeepSeek", doubao: "Doubao", + "freemodel-dev": "FreeModel.dev", nebius: "Nebius", novita: "Novita", nscale: "Nscale", nvidia: "NVIDIA NIM", + publicai: "PublicAI", qoder: "Qoder", sensenova: "SenseNova", stepfun: "StepFun", vertex: "Google Vertex AI", +}; + +const referenceNote = "Reference entry only: no safe documented API integration is enabled. Configure it manually only with provider documentation; consumer-web cookies and anti-bot bypasses are intentionally unsupported."; + +export const FREE_PROVIDER_DIRECTORY: readonly FreeDirectoryProvider[] = Object.values(FREE_PROVIDER_ACCESS_GROUPS) + .flat() + .filter((id, index, ids) => ids.indexOf(id) === index) + .map(id => { + const accessGroups = (Object.entries(FREE_PROVIDER_ACCESS_GROUPS) as [ProviderAccessGroup, readonly string[]][]) + .filter(([, ids]) => ids.includes(id)) + .map(([group]) => group); + const override = CONNECTABLE[id]; + return { + id, + label: LABELS[id] ?? id.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" "), + adapter: "openai-chat", + baseUrl: "", + authKind: "key", + accessGroups, + supportLevel: "reference", + verification: "unverified", + discovery: "unsupported", + lastVerified: LAST_VERIFIED, + liveModels: false, + ...(!override ? { note: referenceNote } : {}), + ...override, + } satisfies FreeDirectoryProvider; + }); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index bdf09f52b..5a579d445 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2,6 +2,7 @@ import type { CodexAccountMode, OcxProviderConfig } from "../types"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS } from "./antigravity-models"; import type { ProviderBaseUrlChoice } from "./base-url-choices"; +import { FREE_PROVIDER_DIRECTORY, type ProviderAccessGroup } from "./free-directory"; import { QWEN_CLOUD_BASE_URL_CHOICES, QWEN_CLOUD_TOKEN_PLAN_BASE_URL, ALIBABA_INTL_BASE_URL_CHOICES, ALIBABA_INTL_TOKEN_PLAN_BASE_URL, @@ -83,6 +84,15 @@ export interface ProviderRegistryEntry { googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; project?: string; location?: string; + accessGroups?: readonly ProviderAccessGroup[]; + supportLevel?: "supported" | "experimental" | "reference"; + verification?: "official" | "primary" | "unverified"; + documentationUrl?: string; + modelsUrl?: string; + discovery?: "live" | "static" | "hybrid" | "unsupported"; + lastVerified?: string; + /** Catalog-only row; excluded from legacy CLI login/init projections. */ + directoryOnly?: boolean; } export type ProviderConfigSeed = Pick< @@ -333,7 +343,7 @@ const UMANS_MODEL_INPUT_MODALITIES: Record = Object.fromEntrie UMANS_MODELS.map(id => [id, UMANS_TEXT_ONLY_MODELS.includes(id) ? ["text"] : ["text", "image"]]), ); -export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ +const BASE_PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ { id: "openai", label: "OpenAI (Codex login)", @@ -763,6 +773,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.siliconflow.cn/account/ak", + baseUrlChoices: [ + { id: "china-mainland", label: "China mainland", baseUrl: "https://api.siliconflow.cn/v1" }, + { id: "international", label: "International", baseUrl: "https://api.siliconflow.com/v1" }, + ], liveModels: true, note: "OpenAI-compatible live model catalog; reasoning controls vary by model.", }, @@ -1010,6 +1024,31 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ { id: "gitlab-duo", label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" }, ]; +const BASE_PROVIDER_IDS = new Set(BASE_PROVIDER_REGISTRY.map(entry => entry.id)); +const FREE_DIRECTORY_BY_ID = new Map(FREE_PROVIDER_DIRECTORY.map(entry => [entry.id, entry])); + +export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ + ...BASE_PROVIDER_REGISTRY.map(entry => { + const directory = FREE_DIRECTORY_BY_ID.get(entry.id); + if (!directory) return entry; + return { + ...entry, + accessGroups: directory.accessGroups, + supportLevel: directory.supportLevel, + verification: directory.verification, + ...(directory.documentationUrl ? { documentationUrl: directory.documentationUrl } : {}), + ...(directory.modelsUrl ? { modelsUrl: directory.modelsUrl } : {}), + discovery: directory.discovery, + lastVerified: directory.lastVerified, + }; + }), + ...FREE_PROVIDER_DIRECTORY.filter(entry => !BASE_PROVIDER_IDS.has(entry.id)).map(entry => ({ + ...entry, + dashboardPreset: true, + directoryOnly: true, + })), +]; + export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | undefined { return PROVIDER_REGISTRY.find(entry => entry.id === id); } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 1f848c783..5ce6d4bcd 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { parseProviderModelsApiItems } from "../../codex/catalog/provider-fetch"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -24,7 +25,7 @@ import { import { removeCredential } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; -import { deriveProviderPresets } from "../../providers/derive"; +import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; @@ -406,5 +407,113 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise + choice.baseUrl?.replace(/\/+$/, "") === normalizedSuppliedBaseUrl + ); + const acceptsResolvedBaseUrl = entry.allowBaseUrlOverride === true || matchesResolvedTemplate || matchesOfficialChoice; + if (suppliedBaseUrl && suppliedBaseUrl.replace(/\/+$/, "") !== entry.baseUrl.replace(/\/+$/, "") && !acceptsResolvedBaseUrl) { + return jsonResponse({ error: "provider baseUrl does not match the trusted preset" }, 400); + } + const provider = { + ...providerConfigSeed(entry), + ...(suppliedBaseUrl ? { baseUrl: suppliedBaseUrl } : {}), + ...(supplied.apiKey ? { apiKey: supplied.apiKey } : {}), + ...(supplied.defaultModel ? { defaultModel: supplied.defaultModel } : {}), + ...(supplied.allowPrivateNetwork === true ? { allowPrivateNetwork: true } : {}), + ...(supplied.project ? { project: supplied.project } : {}), + ...(supplied.location ? { location: supplied.location } : {}), + }; + const staticModels = (entry.models ?? []).map(id => ({ + id, + ...(entry.modelContextWindows?.[id] ? { contextWindow: entry.modelContextWindows[id] } : {}), + })); + const destinationError = await providerDestinationResolvedError(presetId, provider); + if (destinationError) return jsonResponse({ error: destinationError }, 400); + if (entry.discovery === "static" || entry.discovery === "unsupported" || entry.liveModels === false) { + return jsonResponse(staticModels.length + ? { ok: true, models: staticModels, source: "static", latencyMs: 0 } + : { ok: false, models: [], source: "static", latencyMs: 0, error: "No static model catalog is available for this preset" }); + } + const { resolveModelsAuthToken, buildModelsRequest } = await import("../../oauth"); + const apiKey = await resolveModelsAuthToken(presetId, provider); + const request = buildModelsRequest(provider, apiKey, presetId); + const modelsDestinationError = await providerDestinationResolvedError(presetId, { baseUrl: request.url }); + if (modelsDestinationError) return jsonResponse({ error: modelsDestinationError }, 400); + const started = Date.now(); + try { + const response = await fetch(request.url, { headers: request.headers, redirect: "error", signal: AbortSignal.timeout(8000) }); + const latencyMs = Date.now() - started; + if (!response.ok) { + const error = `upstream model discovery returned ${response.status}`; + return jsonResponse(staticModels.length + ? { ok: true, models: staticModels, source: "static", latencyMs, error } + : { ok: false, models: [], source: "live", latencyMs, error }); + } + const allowsModelsEnvelope = (entry.adapter === "google" && (entry.googleMode ?? "ai-studio") === "ai-studio") + || new URL(request.url).pathname.replace(/\/+$/, "").endsWith("/api/tags"); + const rows = parseProviderModelsApiItems(await response.json().catch(() => null), allowsModelsEnvelope); + if (!rows) { + const error = "upstream model discovery returned an unexpected shape"; + return jsonResponse(staticModels.length + ? { ok: true, models: staticModels, source: "static", latencyMs, error } + : { ok: false, models: [], source: "live", latencyMs, error }); + } + const uniqueRows = new Map(); + for (const row of rows) { + const current = uniqueRows.get(row.id); + if (!current || (!current.context_length && row.context_length)) uniqueRows.set(row.id, row); + if (uniqueRows.size >= 2000) break; + } + const models = [...uniqueRows.values()].map(row => ({ + id: row.id, + ...(row.context_length ? { contextWindow: row.context_length } : {}), + })); + if (models.length === 0) { + const error = "upstream model discovery returned an empty catalog"; + return jsonResponse(staticModels.length + ? { ok: true, models: staticModels, source: "static", latencyMs, error } + : { ok: false, models: [], source: "live", latencyMs, error }); + } + return jsonResponse({ ok: true, models, source: "live", latencyMs }); + } catch { + const latencyMs = Date.now() - started; + const error = "model discovery request failed"; + return jsonResponse(staticModels.length + ? { ok: true, models: staticModels, source: "static", latencyMs, error } + : { ok: false, models: [], source: "live", latencyMs, error }); + } + } return null; } diff --git a/tests/google-models-listing.test.ts b/tests/google-models-listing.test.ts index f31bd1341..bba1f6475 100644 --- a/tests/google-models-listing.test.ts +++ b/tests/google-models-listing.test.ts @@ -50,7 +50,7 @@ describe("buildModelsRequest google routing", () => { }); describe("google models listing via catalog", () => { - test("treats a { models } 2xx shape as malformed and degrades to the static seed", async () => { + test("normalizes the Google { models } envelope and caches the live catalog", async () => { clearModelCache("google"); const warning = spyOn(console, "warn").mockImplementation(() => {}); const seen: { url: string; headers: Record }[] = []; @@ -77,11 +77,15 @@ describe("google models listing via catalog", () => { expect(seen[0].url).toBe("https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000"); expect(seen[0].headers["x-goog-api-key"]).toBe("gk-123"); const ids = models.filter(m => m.provider === "google").map(m => m.id); - expect(ids).toEqual(["gemini-3.1-pro-preview", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.6-flash"]); - expect(ids).not.toContain("gemini-3-pro"); - expect(ids).not.toContain("gemini-3-flash"); - expect(getStaleCached("google")).toBeNull(); - expect(warning.mock.calls.flat().join(" ")).toContain("google"); + expect(ids).toEqual(["gemini-3-flash", "gemini-3-pro", "text-embedding-004"]); + expect(getStaleCached("google")?.map(model => model.id)).toEqual([ + "gemini-3-pro", + "text-embedding-004", + "gemini-3-flash", + ]); + const warningText = warning.mock.calls.flat().join(" "); + expect(warningText).toContain("omitted configured model ids"); + expect(warningText).not.toContain("malformed 2xx"); } finally { warning.mockRestore(); } diff --git a/tests/provider-directory-routing.test.ts b/tests/provider-directory-routing.test.ts new file mode 100644 index 000000000..065f3df95 --- /dev/null +++ b/tests/provider-directory-routing.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +const originalFetch = globalThis.fetch; +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-provider-directory-route-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-provider-directory-route-")); + process.env.OPENCODEX_HOME = testDir; + globalThis.fetch = originalFetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +test("a directory-only provider can be saved and routes a chat call through its generic adapter", async () => { + const preset = getProviderRegistryEntry("pollinations"); + expect(preset).toMatchObject({ + directoryOnly: true, + adapter: "openai-chat", + baseUrl: "https://gen.pollinations.ai/v1", + keyOptional: true, + }); + + const upstream: Array<{ url: string; authorization: string | null; body: Record }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + if (url.origin !== "https://gen.pollinations.ai") { + throw new Error(`unexpected upstream request: ${request.method} ${request.url}`); + } + if (url.pathname === "/v1/models") { + return Response.json({ data: [{ id: "test-model" }] }); + } + if (url.pathname === "/v1/chat/completions") { + upstream.push({ + url: request.url, + authorization: request.headers.get("authorization"), + body: await request.clone().json() as Record, + }); + const frames = [ + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: "directory route ok" } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`, + "data: [DONE]\n\n", + ]; + return new Response(frames.join(""), { headers: { "content-type": "text/event-stream" } }); + } + return new Response("unexpected directory provider path", { status: 404 }); + }) as typeof fetch; + + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "seed", + providers: { + seed: { + adapter: "openai-chat", + baseUrl: "https://seed.example/v1", + authMode: "key", + models: ["seed-model"], + liveModels: false, + }, + }, + } as OcxConfig); + + const server = startServer(0); + try { + const addResponse = await originalFetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "pollinations", + setDefault: true, + provider: { + adapter: preset?.adapter, + baseUrl: preset?.baseUrl, + authMode: "key", + apiKey: "directory-secret", + defaultModel: "test-model", + selectedModels: ["test-model"], + }, + }), + }); + expect(addResponse.status).toBe(200); + + const saved = JSON.parse(readFileSync(join(testDir, "config.json"), "utf8")) as OcxConfig; + expect(saved.defaultProvider).toBe("pollinations"); + expect(saved.providers.pollinations).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://gen.pollinations.ai/v1", + apiKey: "directory-secret", + keyOptional: true, + defaultModel: "test-model", + selectedModels: ["test-model"], + }); + + const chatResponse = await originalFetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "pollinations/test-model", + stream: false, + messages: [{ role: "user", content: "hello" }], + }), + }); + expect(chatResponse.status).toBe(200); + expect(await chatResponse.json()).toMatchObject({ + object: "chat.completion", + model: "pollinations/test-model", + choices: [{ message: { role: "assistant", content: "directory route ok" }, finish_reason: "stop" }], + }); + expect(upstream).toHaveLength(1); + expect(upstream[0]).toMatchObject({ + url: "https://gen.pollinations.ai/v1/chat/completions", + authorization: "Bearer directory-secret", + body: { model: "test-model" }, + }); + } finally { + await server.stop(true); + } +}, 10_000); diff --git a/tests/provider-free-directory.test.ts b/tests/provider-free-directory.test.ts new file mode 100644 index 000000000..5d6ea26c3 --- /dev/null +++ b/tests/provider-free-directory.test.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { buildModelsRequest } from "../src/oauth"; +import { deriveProviderPresets, enrichProviderFromRegistry } from "../src/providers/derive"; +import { FREE_PROVIDER_ACCESS_GROUPS, FREE_PROVIDER_DIRECTORY } from "../src/providers/free-directory"; +import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../src/providers/registry"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const config: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: {}, +}; + +function discover(presetId: string, provider: OcxProviderConfig): Promise { + const url = new URL("http://127.0.0.1:10100/api/provider-presets/discover"); + return handleManagementAPI(new Request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ presetId, provider }), + }), url, config); +} + +describe("free provider directory", () => { + test("encodes the exact four groups, one 81-provider union, and only the declared overlap", () => { + expect(FREE_PROVIDER_ACCESS_GROUPS["recurring-or-keyless"]).toHaveLength(43); + expect(FREE_PROVIDER_ACCESS_GROUPS["recurring-uncapped"]).toHaveLength(13); + expect(FREE_PROVIDER_ACCESS_GROUPS["recurring-credit"]).toHaveLength(2); + expect(FREE_PROVIDER_ACCESS_GROUPS["signup-credit"]).toHaveLength(24); + expect(FREE_PROVIDER_DIRECTORY).toHaveLength(81); + expect(new Set(FREE_PROVIDER_DIRECTORY.map(row => row.id)).size).toBe(81); + const memberships = FREE_PROVIDER_DIRECTORY.filter(row => row.accessGroups.length > 1); + expect(memberships.map(row => row.id)).toEqual(["glm-cn"]); + expect(memberships[0]?.accessGroups).toEqual(["recurring-uncapped", "signup-credit"]); + expect(FREE_PROVIDER_DIRECTORY.filter(row => row.discovery === "static" && !row.models?.length).map(row => row.id)).toEqual(["kiro"]); + }); + + test("merges requested ids into the registry once and exposes all 81 as presets", () => { + const requested = new Set(FREE_PROVIDER_DIRECTORY.map(row => row.id)); + expect(PROVIDER_REGISTRY.filter(row => requested.has(row.id))).toHaveLength(81); + expect(new Set(PROVIDER_REGISTRY.map(row => row.id)).size).toBe(PROVIDER_REGISTRY.length); + expect(deriveProviderPresets().filter(row => row.accessGroups?.length)).toHaveLength(81); + expect(PROVIDER_REGISTRY.filter(row => row.accessGroups?.length && row.discovery === "static" && !row.models?.length)).toEqual([]); + expect(getProviderRegistryEntry("pollinations")?.keyOptional).toBe(true); + expect(getProviderRegistryEntry("siliconflow")?.baseUrlChoices).toEqual([ + { id: "china-mainland", label: "China mainland", baseUrl: "https://api.siliconflow.cn/v1" }, + { id: "international", label: "International", baseUrl: "https://api.siliconflow.com/v1" }, + ]); + for (const directory of FREE_PROVIDER_DIRECTORY) { + const registered = getProviderRegistryEntry(directory.id); + expect({ adapter: registered?.adapter, baseUrl: registered?.baseUrl, authKind: registered?.authKind }) + .toEqual({ adapter: directory.adapter, baseUrl: directory.baseUrl, authKind: directory.authKind }); + } + }); + + test("buildModelsRequest uses a trusted modelsUrl only for the canonical endpoint", () => { + const cohere = getProviderRegistryEntry("cohere")!; + const canonical = buildModelsRequest({ adapter: cohere.adapter, baseUrl: cohere.baseUrl, authMode: "key" }, "secret", "cohere"); + expect(canonical.url).toBe("https://api.cohere.com/compatibility/v1/models"); + const custom = buildModelsRequest({ adapter: cohere.adapter, baseUrl: "https://proxy.example/v1", authMode: "key" }, "secret", "cohere"); + expect(custom.url).toBe("https://proxy.example/v1/models"); + }); + + test("registry enrichment preserves the Vertex transport mode used by saved presets", () => { + const provider: OcxProviderConfig = { + adapter: "google", + baseUrl: "https://aiplatform.googleapis.com", + authMode: "key", + apiKey: "secret", + }; + enrichProviderFromRegistry("vertex", provider); + expect(provider.googleMode).toBe("vertex"); + }); + + test("blocks reference-only presets before any network request", async () => { + let called = false; + globalThis.fetch = (() => { called = true; return Promise.reject(new Error("unexpected")); }) as typeof fetch; + const response = await discover("duckduckgo-web", { adapter: "openai-chat", baseUrl: "https://duckduckgo.com", authMode: "key" }); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ error: "reference-only provider presets cannot be connected automatically" }); + expect(called).toBe(false); + }); + + test("only accepts resolved template URLs that stay on the trusted preset path", async () => { + let called = false; + globalThis.fetch = (() => { called = true; return Promise.reject(new Error("unexpected")); }) as typeof fetch; + for (const baseUrl of [ + "https://attacker.example/client/v4/accounts/account/ai/v1", + "https://api.cloudflare.com/client/v4/accounts/../ai/v1", + "https://api.cloudflare.com/client/v4/accounts/%2e%2e/ai/v1", + ]) { + const response = await discover("cloudflare-ai", { + adapter: "openai-chat", + baseUrl, + authMode: "key", + apiKey: "secret", + }); + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ error: "provider baseUrl does not match the trusted preset" }); + } + expect(called).toBe(false); + + const resolved = await discover("cloudflare-ai", { + adapter: "openai-chat", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/account-id/ai/v1", + authMode: "key", + apiKey: "secret", + }); + expect(resolved?.status).toBe(200); + expect(await resolved?.json()).toMatchObject({ ok: true, source: "static" }); + expect(called).toBe(false); + }); + + test("returns normalized live models without persisting the provider", async () => { + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe("https://api.cohere.com/compatibility/v1/models"); + expect(init?.redirect).toBe("error"); + return Response.json({ data: [{ id: "command-a", context_window: 256000 }, { id: "command-a" }] }); + }) as typeof fetch; + const response = await discover("cohere", { adapter: "openai-chat", baseUrl: "https://api.cohere.com/compatibility/v1", authMode: "key", apiKey: "secret" }); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ ok: true, source: "live", models: [{ id: "command-a", contextWindow: 256000 }] }); + expect(config.providers.cohere).toBeUndefined(); + }); + + test("accepts only SiliconFlow's two official regional endpoints for discovery", async () => { + let calls = 0; + globalThis.fetch = (async input => { + calls += 1; + expect(String(input)).toBe("https://api.siliconflow.com/v1/models"); + return Response.json({ data: [{ id: "Qwen/Qwen3-Coder" }] }); + }) as typeof fetch; + const international = await discover("siliconflow", { + adapter: "openai-chat", + baseUrl: "https://api.siliconflow.com/v1", + authMode: "key", + apiKey: "secret", + }); + expect(international?.status).toBe(200); + expect(await international?.json()).toMatchObject({ ok: true, source: "live", models: [{ id: "Qwen/Qwen3-Coder" }] }); + + const arbitrary = await discover("siliconflow", { + adapter: "openai-chat", + baseUrl: "https://siliconflow-proxy.example/v1", + authMode: "key", + apiKey: "secret", + }); + expect(arbitrary?.status).toBe(400); + expect(await arbitrary?.json()).toEqual({ error: "provider baseUrl does not match the trusted preset" }); + expect(calls).toBe(1); + }); + + test("falls back to static models and reports a sanitized live error", async () => { + globalThis.fetch = (async () => new Response("credential secret should never leak", { status: 503 })) as typeof fetch; + const response = await discover("openrouter", { adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authMode: "key", apiKey: "secret" }); + const json = await response?.json() as { ok: boolean; source: string; models: unknown[]; error: string }; + expect(json.ok).toBe(true); + expect(json.source).toBe("static"); + expect(json.models.length).toBeGreaterThan(0); + expect(json.error).toBe("upstream model discovery returned 503"); + expect(JSON.stringify(json)).not.toContain("secret"); + }); +}); diff --git a/tests/provider-model-envelope.test.ts b/tests/provider-model-envelope.test.ts new file mode 100644 index 000000000..cd3af7ca8 --- /dev/null +++ b/tests/provider-model-envelope.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { parseProviderModelsApiItems } from "../src/codex/catalog/provider-fetch"; + +describe("provider model catalog envelopes", () => { + test("normalizes OpenAI, Google/Ollama, and top-level catalog shapes", () => { + expect(parseProviderModelsApiItems({ data: [{ id: "openai-model" }] })?.map(model => model.id)) + .toEqual(["openai-model"]); + expect(parseProviderModelsApiItems({ models: [{ name: "models/gemini-flash", inputTokenLimit: 1_000_000 }] }, true)) + .toEqual([{ id: "gemini-flash", context_length: 1_000_000 }]); + expect(parseProviderModelsApiItems([{ id: "accounts/fireworks/models/llama" }])?.map(model => model.id)) + .toEqual(["llama"]); + }); + + test("rejects malformed rows instead of making an empty catalog authoritative", () => { + expect(parseProviderModelsApiItems({ models: [{ displayName: "missing id" }] }, true)).toBeNull(); + expect(parseProviderModelsApiItems({ models: [{ name: " " }] }, true)).toBeNull(); + expect(parseProviderModelsApiItems({ models: [{ name: "models/gemini-flash" }] })).toBeNull(); + }); +}); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 14d840e8f..b7bc99279 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -416,13 +416,13 @@ describe("provider registry parity", () => { expect(moonshot?.preserveReasoningContentModels).toContain("kimi-k3"); }); - test("LiteLLM is the only registry seed with optional key authentication", () => { + test("optional-key registry seeds are explicit", () => { const litellm = PROVIDER_REGISTRY.find(entry => entry.id === "litellm"); const optionalKeyProviders = PROVIDER_REGISTRY.filter(entry => entry.keyOptional).map(entry => entry.id); expect(litellm?.authKind).toBe("key"); expect(providerConfigSeed(litellm!).keyOptional).toBe(true); - expect(optionalKeyProviders).toEqual(["litellm", "opencode-free", "mimo-free"]); + expect(optionalKeyProviders).toEqual(["litellm", "opencode-free", "mimo-free", "ovhcloud", "pollinations"]); }); test("NVIDIA NIM is free-tier priced but still requires an API key", () => { diff --git a/tests/provider-workspace-data.test.ts b/tests/provider-workspace-data.test.ts index cd521b795..4cdaa648e 100644 --- a/tests/provider-workspace-data.test.ts +++ b/tests/provider-workspace-data.test.ts @@ -28,6 +28,7 @@ import { formatProviderDisplayName, isCatalogProviderId, providerBrandColor, + providerIconSrc, } from "../gui/src/provider-icons"; import { bucketPresets, @@ -37,6 +38,7 @@ import { type CatalogPreset, } from "../gui/src/components/provider-catalog/provider-presets"; import { isLocalProvider, providerKind } from "../gui/src/provider-workspace/kind"; +import { FREE_PROVIDER_DIRECTORY } from "../src/providers/free-directory"; /** Base defaults matching a minimal, unconfigured provider value. */ function prov(overrides: Partial = {}): WorkspaceProvider { @@ -469,6 +471,20 @@ describe("provider-icons", () => { expect(isCatalogProviderId("openai-multi")).toBe(false); expect(isCatalogProviderId("my-proxy")).toBe(false); }); + + test("provider icons prefer provider brands and fall back to model families", () => { + expect(providerIconSrc("together", { models: ["openai/gpt-4.1"] })).toBe("/provider-icons/together-color.svg"); + expect(providerIconSrc("litellm")).toBe("/provider-icons/litellm.png"); + expect(providerIconSrc("model-gateway", { defaultModel: "anthropic/claude-sonnet-5" })).toBe("/provider-icons/claude-color.svg"); + expect(providerIconSrc("model-gateway", { models: ["unknown/model", "Qwen/Qwen3-Coder"] })).toBe("/provider-icons/qwen-portal-color.svg"); + expect(providerIconSrc("unknown", { models: ["unknown/model"] })).toBeUndefined(); + }); + + test("every free provider catalog entry has a local icon", () => { + for (const provider of FREE_PROVIDER_DIRECTORY) { + expect(providerIconSrc(provider.id, { models: provider.models })).toMatch(/^\/provider-icons\//); + } + }); }); describe("add-provider catalog presets (WP050a)", () => {