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/App.tsx b/gui/src/App.tsx index e205469cd..daa304241 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -265,7 +265,7 @@ export default function App() {
-
+
>({}); 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 [openRouterFreeOnly, setOpenRouterFreeOnly] = useState(false); + const discoveryRequestRef = useRef(0); + const discoveryAbortRef = useRef(null); const aliveRef = useRef(true); const loadedPresetsRef = useRef(false); const previousFocusRef = useRef(null); @@ -88,14 +97,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 +161,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 +181,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 +190,16 @@ 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)); + setOpenRouterFreeOnly(false); }; const back = () => { + invalidateDiscoveryRequest(); setPreset(null); setForm(null); setEndpointChoice("custom"); @@ -164,6 +209,62 @@ export default function AddProviderModal({ setManualCode(""); setManualCodeMsg(""); setManualCodeOk(true); + setDiscoveryError(""); + setDiscoverySource(""); + setDiscoveredModels([]); + setSelectedModels([]); + setOpenRouterFreeOnly(false); + }; + + 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 (freeOnly = openRouterFreeOnly) => { + 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, ...(preset.id === "openrouter" ? { freeOnly } : {}) }), + 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 && (freeOnly || !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 +288,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 +408,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 +572,7 @@ export default function AddProviderModal({ value={endpointChoice} onChange={e => { const id = e.target.value; + invalidateDiscoveryRequest(); setEndpointChoice(id); setForm({ ...form, @@ -453,6 +584,8 @@ export default function AddProviderModal({ @@ -464,7 +597,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 +608,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 +647,88 @@ 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")} +
+
+ +
+ {preset.id === "openrouter" && ( + + )} + {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 +757,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/QuotaBars.tsx b/gui/src/components/QuotaBars.tsx index 63866aa80..2f4dbaae0 100644 --- a/gui/src/components/QuotaBars.tsx +++ b/gui/src/components/QuotaBars.tsx @@ -139,8 +139,8 @@ export default function QuotaBars({ quota, plan, threshold, t, className, layout threshold: number; t: TFn; className?: string; - /** compact = classic one-line rows; stacked = overview cards with clear reset copy */ - layout?: "compact" | "stacked"; + /** summary omits tracks for dense provider lists; stacked keeps the detailed overview. */ + layout?: "compact" | "summary" | "stacked"; }) { const { locale } = useI18n(); const rows = buildQuotaRows(quota, plan, t); @@ -154,6 +154,28 @@ export default function QuotaBars({ quota, plan, threshold, t, className, layout
); } + if (layout === "summary") { + return ( +
+ {rows.map((row, index) => { + const warn = isQuotaWarn(row.percent, threshold); + const exhausted = isQuotaExhausted(row.percent); + return ( + + {row.label} + {Math.round(row.percent)}% + {row.resetAt && {formatResetFuture(row.resetAt, t, locale)}} + {exhausted && {t("quota.limitReached")}} + + ); + })} +
+ ); + } return (
{rows.map((row, index) => ( 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/components/providers/OAuthPanel.tsx b/gui/src/components/providers/OAuthPanel.tsx index 3a1dd5625..2549364b0 100644 --- a/gui/src/components/providers/OAuthPanel.tsx +++ b/gui/src/components/providers/OAuthPanel.tsx @@ -1,5 +1,5 @@ import type { TFn } from "../../i18n"; -import { IconExternal, IconLink, IconLock } from "../../icons"; +import { IconExternal, IconLink, IconLock, IconPlus } from "../../icons"; interface Config { port: number; @@ -22,145 +22,124 @@ export interface OAuthPanelProps { manualCodeBusy: boolean; manualCodeMsg: string; config: Config | null; - setAdding: (v: boolean) => void; setLinkCopied: (v: boolean) => void; setDeviceCodeCopied: (v: boolean) => void; setManualCode: (v: string) => void; - requestLoginOAuth: (provider: string) => void; cancelLoginOAuth: (provider: string) => void; logoutOAuth: (provider: string) => void; submitManualCode: (provider: string) => void; providerIconSrc: (name: string) => string | undefined; oauthLabel: (id: string) => string; + onAddProvider: (intent: { tier?: "accounts" | "paid"; custom?: boolean }) => void; } export function OAuthPanel({ t, oauthProviders, keyProviders, oauthStatus, busy, loginInfo, linkCopied, - deviceCodeCopied, manualCode, manualCodeBusy, manualCodeMsg, config, setAdding, - setLinkCopied, setDeviceCodeCopied, setManualCode, requestLoginOAuth, - cancelLoginOAuth, logoutOAuth, submitManualCode, providerIconSrc, oauthLabel, + deviceCodeCopied, manualCode, manualCodeBusy, manualCodeMsg, config, + setLinkCopied, setDeviceCodeCopied, setManualCode, + cancelLoginOAuth, logoutOAuth, submitManualCode, providerIconSrc, oauthLabel, onAddProvider, }: OAuthPanelProps) { + const accountProviders = Array.from(new Set([ + ...oauthProviders, + ...Object.entries(config?.providers ?? {}) + .filter(([, provider]) => provider.authMode === "forward") + .map(([name]) => name), + ])); + const connectedAccounts = accountProviders.filter(provider => oauthStatus[provider]?.loggedIn || busy === provider); + const renderProviderRow = (name: string, kind: "account" | "key") => { + const st = oauthStatus[name] ?? { loggedIn: false }; + const isBusy = busy === name; + const provider = config?.providers[name]; + const icon = providerIconSrc(name); + const isForward = provider?.authMode === "forward"; + const keylessFree = provider?.keyOptional === true && !provider?.hasApiKey; + return ( +
+ + {icon && } + {name} + + + + + {kind === "account" ? (isBusy ? t("prov.waitingBrowser") : st.email ?? t("prov.loggedIn")) : keylessFree ? t("modal.badge.free") : t("prov.hasApiKey")} + + + + {kind === "account" && isForward && {t("prov.manageCodexAccounts")}} + {kind === "account" && !isForward && !isBusy && } + {kind === "account" && isBusy && } + + {kind === "account" && loginInfo?.provider === name && (loginInfo.url || loginInfo.instructions || loginInfo.deviceCode || isBusy) && ( + + {loginInfo.deviceCode && ( + + {t("prov.deviceCode")} + {loginInfo.deviceCode} + + + )} + + {loginInfo.url && {t("prov.didntOpen")}} + + {loginInfo.instructions && !loginInfo.deviceCode && {loginInfo.instructions}} + + + setManualCode(e.target.value)} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); void submitManualCode(name); } }} placeholder={t("prov.pasteRedirect")} aria-label={t("prov.pasteRedirect")} disabled={manualCodeBusy} /> + + + {manualCodeMsg || t("prov.pasteRedirectHint")} + + )} +
+ ); + }; + return ( -
-
+
+
- {t("prov.accountLogin")} + {t("prov.connections")}
-
- {oauthProviders.length === 0 && keyProviders.length === 0 && ( - {t("prov.noOauth")} - )} - {oauthProviders.map(p => { - const st = oauthStatus[p] ?? { loggedIn: false }; - const isBusy = busy === p; - const icon = providerIconSrc(p); - return ( -
- - {icon && } - {p} - - - - {st.loggedIn ? ( - {st.email ?? t("prov.loggedIn")} - ) : ( - {t("prov.notLoggedIn")} - )} - - - {st.loggedIn ? ( - - ) : isBusy ? ( - - ) : ( - - )} - - {loginInfo?.provider === p && (loginInfo.url || loginInfo.instructions || loginInfo.deviceCode || isBusy) && ( - - {loginInfo.deviceCode && ( - - {t("prov.deviceCode")} - {loginInfo.deviceCode} - - - )} - - {loginInfo.url && {t("prov.didntOpen")}} - - {loginInfo.instructions && !loginInfo.deviceCode && {loginInfo.instructions}} - {isBusy && ( - - )} - - - setManualCode(e.target.value)} - onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); void submitManualCode(p); } }} - placeholder={t("prov.pasteRedirect")} - aria-label={t("prov.pasteRedirect")} - disabled={manualCodeBusy} - /> - - - {manualCodeMsg || t("prov.pasteRedirectHint")} - - )} -
- ); - })} - {keyProviders.map(name => { - const provider = config?.providers[name]; - const icon = providerIconSrc(name); - const keylessFree = provider?.keyOptional === true && !provider?.hasApiKey; - const missingOpenAiKey = name === "openai-apikey" && !provider?.hasApiKey; - return ( -
- - {icon && } - {name} - - - - {missingOpenAiKey ? t("prov.openaiApiMissing") : keylessFree ? t("modal.badge.free") : t("prov.hasApiKey")} - - - {missingOpenAiKey && } - -
- ); - })} +
+
+
+ {t("pws.tab.accounts")} + +
+ {connectedAccounts.length > 0 ? ( +
{connectedAccounts.map(name => renderProviderRow(name, "account"))}
+ ) : {t("prov.noConnections")}} +
+
+
+ {t("pws.apiKeys")} + +
+ {keyProviders.length > 0 ? ( +
{keyProviders.map(name => renderProviderRow(name, "key"))}
+ ) : {t("prov.noConnections")}} +
+
+
{t("modal.customProvider")}
+ +
); diff --git a/gui/src/components/providers/ProviderCardList.tsx b/gui/src/components/providers/ProviderCardList.tsx index f10a35a48..2fe5655c0 100644 --- a/gui/src/components/providers/ProviderCardList.tsx +++ b/gui/src/components/providers/ProviderCardList.tsx @@ -1,9 +1,10 @@ import type { Dispatch, SetStateAction } from "react"; import type { TFn } from "../../i18n"; import type { AccountQuota } from "../../codex-quota-utils"; -import QuotaBars from "../QuotaBars"; +import QuotaBars, { buildQuotaRows } from "../QuotaBars"; import { IconChevron, IconLock, IconPlus, IconPower, IconTrash } from "../../icons"; import { oauthAccountDisplayLabel } from "../../provider-workspace/auth"; +import { formatTokenCount, type ProviderUsageTotals } from "../../provider-workspace/usage"; interface Config { port: number; @@ -20,6 +21,7 @@ export interface ProviderCardListProps { t: TFn; config: Config; quotaReports: Record; + usageTotals: Record; accountSets: Record; keyPools: Record; openAccounts: Record; @@ -46,7 +48,7 @@ export interface ProviderCardListProps { } export function ProviderCardList({ - t, config, quotaReports, accountSets, keyPools, openAccounts, addingKeyFor, + t, config, quotaReports, usageTotals, accountSets, keyPools, openAccounts, addingKeyFor, newKeyValue, busy, modeBusy, activeAccountNeedsReauth, setOpenAccounts, setAddingKeyFor, setNewKeyValue, loginOAuth, requestLoginOAuth, setOpenAiAccountMode, setProviderDisabled, removeProvider, switchAccount, @@ -54,14 +56,17 @@ export function ProviderCardList({ resolvedOpenAiAccountMode, }: ProviderCardListProps) { return ( -
-
+
+
{t("prov.port")}: {config.port} · {t("prov.default")}: {config.defaultProvider}
+
{Object.entries(config.providers).map(([name, prov]) => { const isDefault = name === config.defaultProvider; const isDisabled = prov.disabled === true; const quota = quotaReports[name]?.quota ?? null; + const hasQuotaRows = buildQuotaRows(quota, null, t).length > 0; + const totalTokens = usageTotals[name]?.totalTokens; const icon = providerIconSrc(name); const accountSet = prov.authMode === "oauth" ? accountSets[name] : undefined; const isKeyAuth = prov.authMode !== "oauth" && prov.authMode !== "forward"; @@ -154,7 +159,17 @@ export function ProviderCardList({
- {quota && } + {(quota || (!hasQuotaRows && totalTokens !== undefined)) && ( +
+ {quota && } + {!hasQuotaRows && totalTokens !== undefined && ( +
+ {t("dash.tokens30d")} + {formatTokenCount(totalTokens)} +
+ )} +
+ )} {showAccounts && ( <>
); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6c69b33ee..c84f71d0d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -201,6 +201,8 @@ export const de = { "dash.updateStatus.succeeded": "Update abgeschlossen.", "dash.updateStatus.failed": "Update fehlgeschlagen.", "prov.subtitle": "Konfiguriere die Upstream-Anbieter, die opencodex in Codex routet. Melde dich mit einem Konto an, fĂĽge einen Anbieter hinzu oder bearbeite die Rohkonfiguration.", + "prov.connections": "Verbundene Anbieter", + "prov.noConnections": "Noch keine Anbieter verbunden. Verwende Anbieter hinzufĂĽgen, um einen zu verbinden.", "prov.add": "Anbieter hinzufĂĽgen", "prov.editJson": "JSON bearbeiten", "prov.accountLogin": "Konto-Login", @@ -401,6 +403,15 @@ export const de = { "models.allowlistLabel": "Nur ausgewählte", "models.allowlistHint": "Nur geprĂĽfte Modelle gehen in den Katalog (leer = alle). NĂĽtzlich fĂĽr Anbieter mit tausenden Modellen.", "models.selectedCount": "{n} ausgewählt", + "models.smartRoutingTitle": "Intelligentes Routing", + "models.smartRoutingHint": "Erstellt aus deinen aktivierten Modellen drei automatische Routen fĂĽr Intelligenz, ausgewogene Nutzung und Kosten.", + "models.smartRoutingApply": "Intelligente Routen anwenden", + "models.smartRoutingApplying": "Wird angewendet…", + "models.smartRoutingApplied": "Intelligentes Routing fĂĽr {mode} angewendet.", + "models.smartRoutingFailed": "Intelligentes Routing konnte nicht angewendet werden.", + "models.smartRouting_intelligence": "Intelligenz", + "models.smartRouting_balance": "ausgewogene Nutzung", + "models.smartRouting_cost": "Kosten", "sub.subtitle": "Codex {cmd} bewirbt nur die ersten 5 Modelle (nach Priorität) als Overrides. Wähle hier bis zu 5 — natives gpt oder geroutet — und opencodex setzt ihre Katalog-Priorität, sodass genau diese fĂĽhren. Jedes andere Modell bleibt ĂĽber seinen exakten Namen aufrufbar; dies steuert nur die Anzeige.", "sub.featured": "Empfohlen", "sub.orderHint": "Die hier gewählte und angezeigte Reihenfolge bestimmt die Plätze 1–5 oben in der Codex-Modellauswahl und die Standard-Modellkandidaten fĂĽr {cmd}.", @@ -574,6 +585,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", @@ -1025,6 +1038,42 @@ 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.openRouterFreeOnly": "Nur kostenlose OpenRouter-Modelle verwenden", + "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 ed890ecfd..7f528e5fb 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -215,6 +215,8 @@ export const en = { // providers "prov.subtitle": "Configure the upstream providers opencodex routes into Codex. Log in with an account, add a provider, or edit the raw config.", + "prov.connections": "Connected providers", + "prov.noConnections": "No providers are connected yet. Use Add Provider to connect one.", "prov.add": "Add Provider", "prov.editJson": "Edit JSON", "prov.accountLogin": "Account login", @@ -417,6 +419,15 @@ export const en = { "models.allowlistLabel": "Only selected", "models.allowlistHint": "Only checked models ship to the catalog (empty = all). Useful for providers exposing thousands of models.", "models.selectedCount": "{n} selected", + "models.smartRoutingTitle": "Smart routing", + "models.smartRoutingHint": "Create three automatic routes from your enabled models for intelligence, balanced use, and cost.", + "models.smartRoutingApply": "Apply smart routes", + "models.smartRoutingApplying": "Applying…", + "models.smartRoutingApplied": "Applied {mode} smart routing.", + "models.smartRoutingFailed": "Could not apply smart routing.", + "models.smartRouting_intelligence": "intelligence", + "models.smartRouting_balance": "balanced", + "models.smartRouting_cost": "cost", // subagents "sub.subtitle": "Codex's {cmd} advertises only the first 5 models (by priority) as overrides. Pick up to 5 here — native gpt or routed — and opencodex sets their catalog priority so exactly these lead. Any other model is still callable by its exact name; this only controls what's shown.", @@ -632,6 +643,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", @@ -861,6 +874,42 @@ 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.openRouterFreeOnly": "Use free OpenRouter models only", + "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 114cd7b22..91caafb93 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -215,6 +215,8 @@ export const ja: Record = { // providers "prov.subtitle": "opencodex ㌠Codex ă«ă«ăĽă†ă‚Łăłă‚°ă™ă‚‹ä¸Šćµă—ă­ăイă€ăĽă‚’設定ă—ăľă™ă€‚アカウăłăă§ă­ă‚°ă‚¤ăłă™ă‚‹ă‹ă€ă—ă­ăイă€ăĽă‚’追加ă€ăľăźăŻç”źă®č¨­ĺ®šă‚’編集ă—ăľă™ă€‚", + "prov.connections": "接続ć¸ăżă—ă­ăイă€ăĽ", + "prov.noConnections": "接続ć¸ăżă®ă—ă­ăイă€ăĽăŻă‚りăľă›ă‚“。ă—ă­ăイă€ăĽă‚’追加ă‹ă‚‰ćŽĄç¶šă—ă¦ăŹă ă•ă„。", "prov.add": "ă—ă­ăイă€ăĽă‚’追加", "prov.editJson": "JSON を編集", "prov.accountLogin": "アカウăłăă­ă‚°ă‚¤ăł", @@ -382,6 +384,15 @@ export const ja: Record = { "models.allowlistLabel": "é¸ćŠžă®ăż", "models.allowlistHint": "ăă‚§ăクă—ăźă˘ă‡ă«ă®ăżă‚«ă‚żă­ă‚°ă«é€äżˇă—ăľă™(空 = ă™ăąă¦)。数ĺŤă®ă˘ă‡ă«ă‚’公開ă™ă‚‹ă—ă­ăイă€ăĽă§ćś‰ç”¨ă§ă™ă€‚", "models.selectedCount": "{n} ä»¶é¸ćŠž", + "models.smartRoutingTitle": "スăžăĽăă«ăĽă†ă‚Łăłă‚°", + "models.smartRoutingHint": "有効ăŞă˘ă‡ă«ă‹ă‚‰ă€çźĄč˝ă»ăă©ăłă‚ąă»ă‚łă‚ąăĺ‘ă‘ă®3ă¤ă®č‡Şĺ‹•ă«ăĽăを作ćă—ăľă™ă€‚", + "models.smartRoutingApply": "スăžăĽăă«ăĽăă‚’é©ç”¨", + "models.smartRoutingApplying": "é©ç”¨ä¸­â€¦", + "models.smartRoutingApplied": "{mode} ă®ă‚ąăžăĽăă«ăĽă†ă‚Łăłă‚°ă‚’é©ç”¨ă—ăľă—ăźă€‚", + "models.smartRoutingFailed": "スăžăĽăă«ăĽă†ă‚Łăłă‚°ă‚’é©ç”¨ă§ăŤăľă›ă‚“ă§ă—ăźă€‚", + "models.smartRouting_intelligence": "知č˝", + "models.smartRouting_balance": "ăă©ăłă‚ą", + "models.smartRouting_cost": "コスă", // subagents "sub.subtitle": "Codex ă® {cmd} ăŻćś€ĺťă® 5 ă˘ă‡ă«(優ĺ…度順)ă®ăżă‚’オăĽăăĽă©ă‚¤ă‰ă¨ă—ă¦é€šçźĄă—ăľă™ă€‚ă“ă“ă§ćś€ĺ¤§ 5 ă¤ă‚’é¸ă‚“ă§ăŹă ă•ㄠ— ăŤă‚¤ă†ă‚Łă– gpt ăľăźăŻă«ăĽă†ă‚Łăłă‚° — opencodex ăŚă‚«ă‚żă­ă‚°ĺ„Şĺ…度を設定ă—ă€ă“れらăŚĺ…é ­ă«ćťĄă‚‹ă‚ă†ă«ă—ăľă™ă€‚ä»–ă®ă˘ă‡ă«ă‚‚正確ăŞĺŤĺ‰Ťă§ĺ‘Ľăłĺ‡şă—可č˝ă§ă™; ă“れăŻčˇ¨ç¤şă®ăżă‚’ĺ¶ĺľˇă—ăľă™ă€‚", @@ -597,6 +608,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": "ă­ăĽă‚«ă«/ă—ă©ă‚¤ă™ăĽăăŤăăăŻăĽă‚Żă‚’許可", @@ -816,6 +829,42 @@ 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.openRouterFreeOnly": "OpenRouter ă®ç„ˇć–™ă˘ă‡ă«ă®ăżă‚’使用", + "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 7d8754059..6f03ed717 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -209,6 +209,8 @@ export const ko: Record = { // providers "prov.subtitle": "opencodexę°€ Codex로 라우팅í•는 업스트림 프로바이더를 설정합ë‹ë‹¤. 계정으로 로그인í•ę±°ë‚, 프로바이더를 추가í•ę±°ë‚, ě›ëł¸ 설정을 편집í•세요.", + "prov.connections": "ě—°ę˛°ëś í”„ëˇśë°”ěť´ëŤ”", + "prov.noConnections": "ě—°ę˛°ëś í”„ëˇśë°”ěť´ëŤ”ę°€ 없습ë‹ë‹¤. 프로바이더 추가ě—서 연결í•세요.", "prov.add": "프로바이더 추가", "prov.editJson": "JSON 편집", "prov.accountLogin": "계정 로그인", @@ -411,6 +413,15 @@ export const ko: Record = { "models.allowlistLabel": "ě„ íťë§Ś 노출", "models.allowlistHint": "체í¬í•ś 모델만 ěą´íëˇśę·¸ě— ë…¸ě¶śëŹĽěš” (비우면 전체). ě천 ę°ś 모델을 노출í•는 í”„ëˇśë°”ěť´ëŤ”ě— ěś ěš©í•´ěš”.", "models.selectedCount": "{n}ę°ś ě„ íť", + "models.smartRoutingTitle": "스ë§íЏ 라우팅", + "models.smartRoutingHint": "í™śě„±í™”ëś ëŞ¨ëŤ¸ě—서 지능, ę· í•, 비용을 위한 세 ę°śěť ěžëŹ™ 경로를 ë§Śë“­ë‹ë‹¤.", + "models.smartRoutingApply": "스ë§íЏ 경로 ě ěš©", + "models.smartRoutingApplying": "ě ěš© 중…", + "models.smartRoutingApplied": "{mode} 스ë§íЏ 라우팅을 ě ěš©í–습ë‹ë‹¤.", + "models.smartRoutingFailed": "스ë§íЏ 라우팅을 ě ěš©í•ě§€ 못í–습ë‹ë‹¤.", + "models.smartRouting_intelligence": "지능", + "models.smartRouting_balance": "ę· í•", + "models.smartRouting_cost": "비용", // subagents "sub.subtitle": "Codexěť {cmd} 는 ěš°ě„ ěśěś„ ěěś„ 5ę°ś 모델만 ě¤ë˛„라이드로 노출합ë‹ë‹¤. 여기서 최대 5개를 ě„ íťí•ë©´ — 네이티브 gpt ë는 ëťĽěš°íŚ…ëś ëŞ¨ëŤ¸ — opencodexę°€ ěą´í로그 ěš°ě„ ěśěś„를 설정해 ě •í™•íž ěť´ë“¤ěť´ ě•žě— ěµë‹ë‹¤. 다른 모델도 정확한 이름으로 í¸ě¶śí•  ě ěžěśĽë©°, ěť´ 설정은 표시 항목만 ě śě–´í•©ë‹ë‹¤.", @@ -593,6 +604,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": "로컬/사설 ë„¤íŠ¸ě›Śí¬ í—ěš©", @@ -1047,6 +1060,42 @@ 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.openRouterFreeOnly": "무료 OpenRouter 모델만 사용", + "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 a74b75a9e..837821ae0 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -215,6 +215,8 @@ export const ru: Record = { // providers "prov.subtitle": "НаŃтройте выŃеŃтоящих провайдеров, которых opencodex марŃŃ€ŃтизирŃет в Codex. Войдите в аккаŃнт, добавьте провайдера или отредактирŃйте конфигŃрацию врŃчнŃŃŽ.", + "prov.connections": "Подключенные провайдеры", + "prov.noConnections": "Нет подключенных провайдеров. ĐŃпользŃйте «Добавить провайдера» для подключения.", "prov.add": "Добавить провайдера", "prov.editJson": "Редактировать JSON", "prov.accountLogin": "Вход в аккаŃнт", @@ -417,6 +419,15 @@ export const ru: Record = { "models.allowlistLabel": "Только выбранные", "models.allowlistHint": "Đ’ каталог попадают только отмеченные модели (ĐżŃŃто = вŃе). Полезно для провайдеров, предоŃтавляющих тыŃячи моделей.", "models.selectedCount": "Выбрано: {n}", + "models.smartRoutingTitle": "Умная марŃŃ€Ńтизация", + "models.smartRoutingHint": "Создаёт из включённых моделей три автоматичеŃких марŃŃ€Ńта: для качеŃтва, баланŃа и ŃтоимоŃти.", + "models.smartRoutingApply": "Применить Ńмные марŃŃ€Ńты", + "models.smartRoutingApplying": "ПрименяетŃя…", + "models.smartRoutingApplied": "Применена Ńмная марŃŃ€Ńтизация для режима «{mode}».", + "models.smartRoutingFailed": "Не ŃдалоŃŃŚ применить ŃĐĽĐ˝ŃŃŽ марŃŃ€Ńтизацию.", + "models.smartRouting_intelligence": "качеŃтво", + "models.smartRouting_balance": "баланŃ", + "models.smartRouting_cost": "ŃтоимоŃть", // subagents "sub.subtitle": "{cmd} в Codex объявляет как переопределения только первые 5 моделей (по приоритетŃ). Выберите здеŃŃŚ Đ´Đľ 5 моделей — нативные gpt или марŃŃ€ŃтизирŃемые — и opencodex задаŃŃ‚ им приоритет в каталоге так, чтобы именно они Ńли первыми. ЛюбŃŃŽ Đ´Ń€ŃĐłŃŃŽ модель по-ĐżŃ€ĐµĐ¶Đ˝ĐµĐĽŃ ĐĽĐľĐ¶Đ˝Đľ вызвать по её Ń‚ĐľŃ‡Đ˝ĐľĐĽŃ Đ¸ĐĽĐµĐ˝Đ¸; эта наŃтройка Ńправляет только тем, что отображаетŃŃŹ.", @@ -632,6 +643,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": "РазреŃить локальнŃŃŽ/чаŃтнŃŃŽ Ńеть", @@ -861,6 +874,42 @@ 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.openRouterFreeOnly": "ĐŃпользовать только беŃплатные модели OpenRouter", + "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 81a2c961e..71b839891 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -209,6 +209,8 @@ export const zh: Record = { // providers "prov.subtitle": "é…Ťç˝® opencodex č·Żç”±ĺ° Codex 的上游ćŹäľ›ć–ąă€‚使用账ć·ç™»ĺ˝•ă€ć·»ĺŠ ćŹäľ›ć–ąďĽŚć–编辑原始配置。", + "prov.connections": "已连接的ćŹäľ›ć–ą", + "prov.noConnections": "尚未连接ćŹäľ›ć–ąă€‚请使用“添加ćŹäľ›ć–ąâ€ťčż›čˇŚčżžćŽĄă€‚", "prov.add": "添加ćŹäľ›ć–ą", "prov.editJson": "编辑 JSON", "prov.accountLogin": "č´¦ć·ç™»ĺ˝•", @@ -411,6 +413,15 @@ export const zh: Record = { "models.allowlistLabel": "仅所选", "models.allowlistHint": "仅勾选的模型进入目录ďĽç•™ç©ş = ĺ…¨é¨ďĽ‰ă€‚适用于暴露ćĺŤä¸Šä¸‡ć¨ˇĺž‹çš„ćŹäľ›ĺ•†ă€‚", "models.selectedCount": "已选 {n} 个", + "models.smartRoutingTitle": "智č˝č·Żç”±", + "models.smartRoutingHint": "从已ĺŻç”¨çš„模型ĺ›ĺ»şä¸‰ćťˇč‡ŞĺŠ¨č·Żç”±ďĽŚĺ†ĺ«ç”¨äşŽć™şč˝ă€ĺť‡čˇˇĺ’Ść本场景。", + "models.smartRoutingApply": "应用智č˝č·Żç”±", + "models.smartRoutingApplying": "正在应用…", + "models.smartRoutingApplied": "已应用 {mode} 智č˝č·Żç”±ă€‚", + "models.smartRoutingFailed": "无法应用智č˝č·Żç”±ă€‚", + "models.smartRouting_intelligence": "智č˝", + "models.smartRouting_balance": "均衡", + "models.smartRouting_cost": "ć本", // subagents "sub.subtitle": "Codex çš„ {cmd} ä»…ĺ°†äĽĺ…级最é«çš„前 5 个模型作为覆盖项公开。在此最多选择 5 个 — 原生 gpt ć–已路由模型 — opencodex 会设置ĺ®ä»¬çš„目录äĽĺ…级,使其正好排在前面。其他模型仍可按确ĺ‡ĺŤç§°č°ç”¨ďĽ›ć­¤č®ľç˝®ä»…控ĺ¶ćľç¤şéˇąă€‚", @@ -593,6 +604,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": "ĺ…许本地/ç§ćś‰ç˝‘络", @@ -1047,6 +1060,42 @@ 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.openRouterFreeOnly": "仅使用免费的 OpenRouter 模型", + "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/pages/Models.tsx b/gui/src/pages/Models.tsx index 953e05af0..301bd401a 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -46,6 +46,8 @@ interface ShadowCallData { model: string; } +type SmartRoutingMode = "intelligence" | "balance" | "cost"; + const CAP_OPTIONS = Array.from({ length: 18 }, (_, i) => 100_000 + i * 50_000); // 100k … 950k const CAP_OPTION_SET = new Set(CAP_OPTIONS); const CUSTOM_OPTION = "custom"; @@ -126,6 +128,8 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod // an API error must never masquerade as "no combos configured"). const [combos, setCombos] = useState(null); const [combosError, setCombosError] = useState(false); + const [smartRoutingBusy, setSmartRoutingBusy] = useState(false); + const [smartRoutingMode, setSmartRoutingMode] = useState("balance"); const [combosOpen, setCombosOpen] = useState(() => { try { return localStorage.getItem("ocx-models-combos-open") === "1"; } catch { return false; } }); @@ -141,6 +145,29 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod }); }; + const applySmartRouting = async () => { + setSmartRoutingBusy(true); + try { + const response = await fetch(`${apiBase}/api/smart-routing`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: smartRoutingMode }), + }); + const payload = await response.json() as { error?: string }; + if (!response.ok) throw new Error(payload.error || String(response.status)); + const comboResponse = await fetch(`${apiBase}/api/combos`); + if (!comboResponse.ok) throw new Error(String(comboResponse.status)); + setCombos(parseComboList(await comboResponse.json())); + setOk(true); + setStatus(t("models.smartRoutingApplied", { mode: t(`models.smartRouting_${smartRoutingMode}` as TKey) })); + } catch (error) { + setOk(false); + setStatus(error instanceof Error && error.message ? error.message : t("models.smartRoutingFailed")); + } finally { + setSmartRoutingBusy(false); + } + }; + useEffect(() => { let cancelled = false; fetch(`${apiBase}/api/combos`) @@ -917,6 +944,37 @@ export default function Models({ apiBase, viewMode }: { apiBase: string; viewMod const combosBlock = ( <> +
+
+
+ {t("models.smartRoutingTitle")} +
{t("models.smartRoutingHint")}
+
+ +
+
+ {(["intelligence", "balance", "cost"] as const).map(mode => { + const combo = combos?.find(item => item.id === `auto-${mode}`); + return ( + + ); + })} +
+
+ {combos !== null && !combosError && combos.length === 0 && (
diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 590b63a33..fb09e7c65 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -56,6 +56,7 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view const [oauthProviders, setOauthProviders] = useState([]); const [oauthStatus, setOauthStatus] = useState>({}); const [quotaReports, setQuotaReports] = useState>({}); + const [usageTotals, setUsageTotals] = useState>({}); const [busy, setBusy] = useState(null); const [modeBusy, setModeBusy] = useState(false); const [loginInfo, setLoginInfo] = useState<{ provider: string; url?: string; instructions?: string; deviceCode?: string } | null>(null); @@ -156,6 +157,18 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view } }, [apiBase]); + useEffect(() => { + let cancelled = false; + fetch(`${apiBase}/api/usage?range=30d`) + .then(r => r.ok ? r.json() : null) + .then((data: { providers?: Array<{ provider: string; requests: number; totalTokens?: number }> } | null) => { + if (cancelled || !data) return; + setUsageTotals(Object.fromEntries((data.providers ?? []).map(row => [row.provider, { requests: row.requests, totalTokens: row.totalTokens }]))); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [apiBase]); + const fetchCodexActiveReauth = useCallback(async () => { const generation = ++codexReauthGenerationRef.current; try { @@ -558,7 +571,7 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view

{t("nav.providers")}

- +
{status && {status}} @@ -695,7 +708,7 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view ) : ( <> - + )} @@ -707,14 +720,15 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view { setAddIntent(intent); setAdding(true); }} /> {editing ? ( @@ -726,7 +740,7 @@ export default function Providers({ apiBase, viewMode }: { apiBase: string; view /> ) : ( { if (busy) void cancelLoginOAuth(busy); setAdding(false); + setAddIntent(null); }} - onAdded={(name) => { setAdding(false); notify(t("prov.added", { name, cmd: "ocx sync" }), true); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); setModelsRefreshToken(n => n + 1); }} + onAdded={(name) => { setAdding(false); setAddIntent(null); notify(t("prov.added", { name, cmd: "ocx sync" }), true); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); setModelsRefreshToken(n => n + 1); }} accountRows={addModalAccountRows} accountStatus={accountLoginStatus} accountBusy={busy} 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.css b/gui/src/styles.css index c57f9ce13..4c06e70c5 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -343,6 +343,7 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } .main { min-width: 0; } .main-inner { max-width: 980px; margin: 0 auto; padding: 32px 36px 64px; } +.main-inner.main-inner--providers { max-width: 1440px; } .main-inner.main-inner--combos { max-width: none; margin: 0; @@ -936,19 +937,24 @@ table.logs-table { min-width: 960px; } .list-row .sub { font-size: var(--text-label); color: var(--muted); margin-top: 2px; } /* provider config card (Providers page) */ -.prov-card { overflow: hidden; } -.prov-card-main { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 15px 16px; flex-wrap: wrap; } +.prov-card { display: grid; grid-template-rows: minmax(0, 1fr) auto minmax(27px, auto); height: 100%; overflow: hidden; } +.provider-list-meta { margin-bottom: 8px; } +.provider-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: stretch; } +.prov-card-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; min-height: 128px; padding: 12px 14px 10px; } .prov-card-disabled { opacity: 0.62; } -.prov-card-info { display: flex; align-items: flex-start; gap: 11px; min-width: 0; flex: 1 1 360px; } +.prov-card-info { display: flex; align-items: flex-start; gap: 11px; min-width: 0; } .prov-card-copy { min-width: 0; flex: 1; } -.prov-title { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; flex-wrap: wrap; min-width: 0; } +.prov-title { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; min-width: 0; } .provider-icon { width: 31px; height: 31px; border-radius: var(--radius-xs); flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; background: var(--raised); border: 1px solid var(--border-soft); color: var(--text); } .provider-icon img { width: 19px; height: 19px; object-fit: contain; display: block; } .prov-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; min-width: 0; } .prov-meta > span { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.provider-quota { padding-left: 58px; } -.provider-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; } -.openai-mode-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; } +.prov-card-resource { min-height: 31px; margin: 0 14px 8px 55px; padding-top: 7px; border-top: 1px solid var(--border-soft); } +.provider-quota { padding: 0; } +.provider-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; flex-shrink: 0; } +.provider-actions .btn { white-space: nowrap; } +.openai-mode-row { display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: center; gap: 8px; margin-top: 9px; } +.openai-mode-control { justify-self: start; } .openai-mode-control .usage-segmented-btn { min-width: 76px; } .link-btn { background: none; border: none; color: var(--accent-hover); font: inherit; font-size: var(--text-control); cursor: pointer; padding: 6px 2px; text-decoration: underline; } .link-btn svg { width: 14px; height: 14px; flex-shrink: 0; } @@ -996,9 +1002,21 @@ button.prov-account-row.active { cursor: default; } .prov-account-keyform:hover { background: none; } .prov-account-keyform .input-sm { flex: 1 1 auto; min-width: 0; padding: 4px 8px; font-size: var(--text-label); height: var(--control-sm); } -/* account login grid (Providers page): shared name | status | action columns across rows */ +/* connected providers: compact account/key groups with shared row columns */ +.provider-connections { margin-bottom: 16px; padding: 0; overflow: hidden; border-color: var(--border); } +.provider-connections-head { min-height: 44px; padding: 0 16px; border-bottom: 1px solid var(--border-soft); background: var(--raised); } +.provider-connection-groups { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.25fr) minmax(160px, .5fr); } +.provider-connection-group { min-width: 0; padding: 14px 16px 16px; } +.provider-connection-groups > .muted { grid-column: 1 / -1; padding: 12px 14px 14px; } +.provider-connection-group + .provider-connection-group { border-left: 1px solid var(--border-soft); } +.provider-connection-group > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; color: var(--text); font-size: var(--text-control); font-weight: var(--weight-semibold); } +.provider-connection-group > header > span:last-child { font-family: var(--font-code); color: var(--faint); } +.provider-connection-add { margin: -5px -5px -5px auto; white-space: nowrap; } +.provider-connection-add svg, .provider-custom-add svg { width: 14px; height: 14px; } +.provider-connection-group--custom { display: flex; flex-direction: column; justify-content: space-between; background: var(--raised); } +.provider-custom-add { width: 100%; justify-content: center; border: 1px dashed var(--border); min-height: 36px; white-space: nowrap; } .oauth-grid { display: grid; grid-template-columns: minmax(140px, max-content) minmax(0, 1fr) max-content; column-gap: 16px; row-gap: 10px; align-items: center; } -.oauth-row { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; min-height: 30px; } +.oauth-row { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; min-height: 28px; } .oauth-name { display: inline-flex; align-items: center; gap: 8px; font-size: var(--text-control); font-weight: var(--weight-semibold); min-width: 0; } .oauth-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .provider-icon-sm { width: 24px; height: 24px; border-radius: var(--radius-xs); } @@ -1013,6 +1031,14 @@ button.prov-account-row.active { cursor: default; } .oauth-device-code { font-size: 20px; font-weight: 800; letter-spacing: .14em; color: var(--text); user-select: all; } .oauth-login-paste { display: flex; gap: 8px; align-items: center; width: 100%; } .oauth-login-paste .input { flex: 1; min-width: 0; font-size: var(--text-label); padding: 6px 10px; } +.quota-summary { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; padding: 0; } +.quota-summary-item { display: inline-grid; grid-template-columns: max-content max-content minmax(0, max-content) max-content; align-items: baseline; gap: 5px; color: var(--muted); font-size: var(--text-caption); } +.quota-summary-item strong { color: var(--text); font-family: var(--font-code); font-weight: var(--weight-semibold); } +.quota-summary-item--warn strong { color: var(--amber); } +.quota-summary-reset { color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.quota-summary-state { color: var(--amber); font-weight: var(--weight-semibold); } +.provider-usage-fallback { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; color: var(--muted); font-size: var(--text-caption); } +.provider-usage-fallback strong { color: var(--text); font-family: var(--font-code); font-weight: var(--weight-semibold); } /* responsive: compact sticky top bar (menu + brand + stop) with the desktop sidebar reused as an off-canvas drawer — 10 destinations no longer fit an always-visible @@ -1068,16 +1094,27 @@ button.prov-account-row.active { cursor: default; } .stat-row > .stat { min-width: 100px; } .tbl { min-width: 460px; } /* keep columns readable; .tbl-wrap scrolls horizontally */ .usage-cards { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } - .provider-quota { padding-left: 16px; } + .provider-card-grid { grid-template-columns: 1fr; } + .prov-card-main { grid-template-columns: 1fr; min-height: 0; } + .provider-actions { justify-content: flex-start; padding-left: 42px; } + .prov-card-resource { margin-left: 56px; } .prov-meta { display: grid; grid-template-columns: max-content minmax(0, 1fr); width: 100%; } .prov-meta .chip ~ span:not(:last-child) { display: none; } /* account login grid: let name/status truncate with ellipsis instead of wrapping */ .oauth-grid { grid-template-columns: minmax(0, auto) minmax(0, 1fr) max-content; column-gap: 8px; } + .provider-connection-groups { grid-template-columns: 1fr; } + .provider-connection-group + .provider-connection-group { border-left: 0; border-top: 1px solid var(--border-soft); } /* language menu: the drawer scrolls (overflow-y) so the beside-placement would be clipped at the drawer edge — open upward from the foot row instead. */ .sidebar .lang-toggle .select-dropdown-beside { top: auto; bottom: calc(100% + 6px); left: 0; right: auto; } } +@media (max-width: 980px) { + .provider-card-grid { grid-template-columns: 1fr; } + .provider-connection-groups { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .provider-connection-group--custom { grid-column: 1 / -1; border-left: 0 !important; border-top: 1px solid var(--border-soft); } +} + .usage-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 8px; } .usage-cards .stat-value { font-size: var(--text-title); font-weight: var(--weight-semibold); margin-top: 4px; } .usage-head { flex-wrap: wrap; } diff --git a/gui/src/styles/provider-catalog.css b/gui/src/styles/provider-catalog.css index e12d5b199..c1e467e06 100644 --- a/gui/src/styles/provider-catalog.css +++ b/gui/src/styles/provider-catalog.css @@ -1,67 +1,123 @@ -/* 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; min-height: 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-free-filter { display: inline-flex; align-items: center; gap: 7px; width: fit-content; color: var(--text); font-size: var(--text-label); cursor: pointer; } +.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..eb0874965 --- /dev/null +++ b/gui/tests/provider-catalog.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +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); +}); + +test("provider browser lets its row list scroll inside the modal", () => { + const css = readFileSync(new URL("../src/styles/provider-catalog.css", import.meta.url), "utf8"); + expect(css).toMatch(/\.provider-catalog-browser\s*\{[^}]*min-height:\s*0;/); + expect(css).toMatch(/\.provider-catalog-rows\s*\{[^}]*overflow-y:\s*auto;/); +}); + +test("provider connections only show connected accounts and configured API keys", () => { + const panel = readFileSync(new URL("../src/components/providers/OAuthPanel.tsx", import.meta.url), "utf8"); + const pools = readFileSync(new URL("../src/hooks/useProviderAccountPools.ts", import.meta.url), "utf8"); + expect(panel).toContain("oauthStatus[provider]?.loggedIn || busy === provider"); + expect(panel).toContain('provider.authMode === "forward"'); + expect(panel).toContain('href="#codex-auth"'); + expect(panel).toContain('t("pws.tab.accounts")'); + expect(panel).toContain('t("pws.apiKeys")'); + expect(pools).toMatch(/p\.hasApiKey\s*&&\s*p\.authMode\s*!==\s*"oauth"\s*&&\s*p\.authMode\s*!==\s*"forward"/); +}); + +test("provider cards retain compact visual quota bars", () => { + const cards = readFileSync(new URL("../src/components/providers/ProviderCardList.tsx", import.meta.url), "utf8"); + const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); + expect(cards).toContain('className="provider-quota"'); + expect(cards).not.toContain('layout="summary"'); + expect(css).toMatch(/\.quota-row\s*\{[^}]*grid-template-columns:/); + expect(css).toMatch(/\.bar\s*\{[^}]*height:/); +}); + +test("provider cards keep content and resource rows aligned", () => { + const cards = readFileSync(new URL("../src/components/providers/ProviderCardList.tsx", import.meta.url), "utf8"); + const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); + expect(cards).toContain('className="prov-card-resource"'); + expect(css).toMatch(/\.prov-card\s*\{[^}]*grid-template-rows:\s*minmax\(0, 1fr\) auto minmax\(27px, auto\);/); + expect(css).toMatch(/\.provider-card-grid\s*\{[^}]*align-items:\s*stretch;/); + expect(css).toMatch(/@media \(max-width: 760px\)[\s\S]*\.prov-card-main\s*\{[^}]*grid-template-columns:\s*1fr;/); +}); + +test("classic provider workspace exposes connection intents and a compact provider grid", () => { + const panel = readFileSync(new URL("../src/components/providers/OAuthPanel.tsx", import.meta.url), "utf8"); + const cards = readFileSync(new URL("../src/components/providers/ProviderCardList.tsx", import.meta.url), "utf8"); + const page = readFileSync(new URL("../src/pages/Providers.tsx", import.meta.url), "utf8"); + const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); + expect(panel).toContain('onAddProvider({ tier: "accounts" })'); + expect(panel).toContain('onAddProvider({ tier: "paid" })'); + expect(panel).toContain('onAddProvider({ custom: true })'); + expect(cards).toContain('className="provider-card-grid"'); + expect(cards).toContain('t("dash.tokens30d")'); + expect(page).toContain('fetch(`${apiBase}/api/usage?range=30d`)'); + expect(page).toContain('initialTier={addIntent?.tier}'); + expect(page).toContain('initialCustom={addIntent?.custom}'); + expect(css).toMatch(/\.provider-card-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2,/); + expect(css).toMatch(/@media \(max-width: 760px\)[\s\S]*\.provider-card-grid\s*\{[^}]*grid-template-columns:\s*1fr;/); +}); + +test("providers page uses the wide content shell", () => { + const app = readFileSync(new URL("../src/App.tsx", import.meta.url), "utf8"); + const styles = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8"); + + expect(app).toContain('page === "providers" ? " main-inner--providers" : ""'); + expect(styles).toContain(".main-inner.main-inner--providers { max-width: 1440px; }"); +}); 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 74c5d7cb0..864944554 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -58,20 +58,71 @@ export type ProviderModelsApiItem = { owned_by?: string; context_length?: number; max_model_len?: number; + pricing?: { + prompt: string; + completion: string; + }; metadata?: { capabilities?: Record; limits?: Record; }; }; -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; + const rawPricing = item.pricing !== null && typeof item.pricing === "object" && !Array.isArray(item.pricing) + ? item.pricing as Record + : null; + const pricing = rawPricing && typeof rawPricing.prompt === "string" && rawPricing.prompt.trim() + && typeof rawPricing.completion === "string" && rawPricing.completion.trim() + ? { prompt: rawPricing.prompt, completion: rawPricing.completion } + : undefined; + normalized.push({ + id, + ...(typeof item.owned_by === "string" ? { owned_by: item.owned_by } : {}), + ...(contextLength ? { context_length: contextLength } : {}), + ...(pricing ? { pricing } : {}), + ...(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 { @@ -334,7 +385,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, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); if (shouldLog) { @@ -364,10 +415,10 @@ 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, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); if (shouldLog) { console.warn( @@ -376,7 +427,6 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, } return models; } - const items = data; const live = items.map(m => applyProviderConfigHints(name, prov, { id: m.id, provider: name, diff --git a/src/combos/index.ts b/src/combos/index.ts index 63b234b8c..6b6547c41 100644 --- a/src/combos/index.ts +++ b/src/combos/index.ts @@ -38,3 +38,8 @@ export { concreteComboRequestBody, resetComboEffortWarningStateForTests, } from "./request"; +export { + buildSmartRoutingCombo, + SMART_ROUTING_MODES, + type SmartRoutingMode, +} from "./smart-routing"; diff --git a/src/combos/smart-routing.ts b/src/combos/smart-routing.ts new file mode 100644 index 000000000..03a7f6662 --- /dev/null +++ b/src/combos/smart-routing.ts @@ -0,0 +1,180 @@ +import type { CatalogModel } from "../codex/catalog"; +import { + getJawcodeModelMetadata, + getJawcodeModelMetadataCaseInsensitive, + resolveJawcodeProvider, +} from "../generated/jawcode-model-metadata"; +import type { OcxComboConfig, OcxConfig } from "../types"; +import { slugEquals } from "../providers/slug-codec"; +import { findExpectedPriceOverlay } from "../usage/expected-prices"; + +export type SmartRoutingMode = "intelligence" | "balance" | "cost"; + +export const SMART_ROUTING_MODES: readonly SmartRoutingMode[] = ["intelligence", "balance", "cost"]; +export const SMART_ROUTING_PROFILE_VERIFIED_AT = "2026-07-24"; + +interface CapabilityProfile { + pattern: RegExp; + coding: number; + agent: number; + tools: number; + reasoning: number; + instruction: number; + confidence: number; +} + +interface RankedModel { + model: CatalogModel; + quality: number; + cost?: number; + costScore: number; + utility: number; +} + +// Scores are broad coding-agent priors, not raw benchmark averages. They intentionally keep +// model capability independent from provider-specific price and availability. +const CAPABILITY_PROFILES: readonly CapabilityProfile[] = [ + { pattern: /(?:^|\/)claude-fable-5(?:$|[-:])/, coding: .98, agent: .98, tools: .96, reasoning: .97, instruction: .94, confidence: .94 }, + { pattern: /(?:^|\/)claude-sonnet-5(?:$|[-:])/, coding: .91, agent: .92, tools: .94, reasoning: .91, instruction: .94, confidence: .86 }, + { pattern: /(?:^|\/)claude-opus-4-8(?:$|[-:])/, coding: .92, agent: .94, tools: .94, reasoning: .95, instruction: .93, confidence: .93 }, + { pattern: /(?:^|\/)claude-opus-4-[67](?:$|[-:])/, coding: .84, agent: .91, tools: .91, reasoning: .93, instruction: .91, confidence: .90 }, + { pattern: /(?:^|\/)gpt-5\.5(?:$|[-:])/, coding: .91, agent: .97, tools: .94, reasoning: .96, instruction: .92, confidence: .92 }, + { pattern: /(?:^|\/)gpt-5\.3-codex(?:$|[-:])/, coding: .94, agent: .93, tools: .93, reasoning: .91, instruction: .92, confidence: .82 }, + { pattern: /(?:^|\/)gpt-5\.6-sol(?:$|[-:])/, coding: .94, agent: .95, tools: .95, reasoning: .97, instruction: .94, confidence: .78 }, + { pattern: /(?:^|\/)gpt-5\.6-terra(?:$|[-:])/, coding: .87, agent: .88, tools: .92, reasoning: .90, instruction: .92, confidence: .74 }, + { pattern: /(?:^|\/)gpt-5\.6-luna(?:$|[-:])/, coding: .71, agent: .70, tools: .86, reasoning: .72, instruction: .87, confidence: .72 }, + { pattern: /(?:^|\/)grok-4\.5(?:$|[-:])/, coding: .91, agent: .96, tools: .92, reasoning: .91, instruction: .89, confidence: .94 }, + { pattern: /(?:^|\/)grok-4\.(?:3|20)(?:$|[-:])/, coding: .79, agent: .83, tools: .87, reasoning: .85, instruction: .86, confidence: .76 }, + { pattern: /(?:^|\/)gemini-3\.6-flash(?:$|[-:])/, coding: .84, agent: .89, tools: .92, reasoning: .86, instruction: .90, confidence: .82 }, + { pattern: /(?:^|\/)gemini-3\.5-flash-lite(?:$|[-:])/, coding: .67, agent: .68, tools: .82, reasoning: .69, instruction: .84, confidence: .78 }, + { pattern: /(?:^|\/)gemini-3\.5-flash(?:$|[-:])/, coding: .80, agent: .84, tools: .89, reasoning: .82, instruction: .88, confidence: .80 }, + { pattern: /(?:^|\/)deepseek-v4-pro(?:$|[-:])/, coding: .97, agent: .91, tools: .94, reasoning: .96, instruction: .90, confidence: .83 }, + { pattern: /(?:^|\/)deepseek-v4-flash(?:$|[-:])/, coding: .89, agent: .85, tools: .91, reasoning: .88, instruction: .88, confidence: .78 }, + { pattern: /(?:^|\/)glm-5\.2(?:$|[-:\[])/, coding: .91, agent: .94, tools: .97, reasoning: .94, instruction: .90, confidence: .84 }, + { pattern: /(?:^|\/)kimi-k3(?:$|[-:\[])/, coding: .92, agent: .93, tools: .95, reasoning: .94, instruction: .91, confidence: .82 }, + { pattern: /(?:^|\/)kimi-k2\.7-code(?:$|[-:])/, coding: .91, agent: .90, tools: .94, reasoning: .90, instruction: .90, confidence: .85 }, + { pattern: /(?:^|\/)kimi-k2\.6(?:$|[-:])/, coding: .83, agent: .86, tools: .91, reasoning: .88, instruction: .89, confidence: .80 }, + { pattern: /(?:^|\/)minimax-m3(?:$|[-:])/, coding: .82, agent: .84, tools: .88, reasoning: .83, instruction: .87, confidence: .77 }, + { pattern: /(?:^|\/)minimax-m2\.7(?:$|[-:])/, coding: .77, agent: .79, tools: .85, reasoning: .79, instruction: .84, confidence: .76 }, + { pattern: /(?:^|\/)qwen3\.[5-8](?:$|[-:\[])/, coding: .84, agent: .82, tools: .88, reasoning: .88, instruction: .87, confidence: .68 }, + { pattern: /(?:^|\/)devstral-(?:2512|latest)(?:$|[-:])/, coding: .82, agent: .83, tools: .87, reasoning: .79, instruction: .86, confidence: .62 }, + { pattern: /(?:^|\/)mistral-(?:large|medium)(?:$|[-:])/, coding: .75, agent: .76, tools: .85, reasoning: .80, instruction: .86, confidence: .60 }, + { pattern: /(?:^|\/)command-a-plus(?:$|[-:])/, coding: .70, agent: .78, tools: .89, reasoning: .75, instruction: .88, confidence: .75 }, + { pattern: /(?:^|\/)nvidia-nemotron-3-(?:super|ultra)(?:$|[-:])/, coding: .84, agent: .85, tools: .88, reasoning: .90, instruction: .85, confidence: .76 }, + { pattern: /(?:^|\/)longcat-2\.0(?:$|[-:])/, coding: .84, agent: .86, tools: .88, reasoning: .87, instruction: .85, confidence: .72 }, + { pattern: /(?:^|\/)llama-4-maverick(?:$|[-:])/, coding: .70, agent: .70, tools: .80, reasoning: .76, instruction: .84, confidence: .69 }, + { pattern: /(?:^|\/)hermes-4-(?:405b|70b)(?:$|[-:])/, coding: .74, agent: .64, tools: .76, reasoning: .87, instruction: .84, confidence: .70 }, + { pattern: /(?:^|\/)lfm2-24b-a2b(?:$|[-:])/, coding: .28, agent: .50, tools: .78, reasoning: .42, instruction: .77, confidence: .88 }, +]; + +function metadata(provider: string, modelId: string) { + const resolved = resolveJawcodeProvider(provider); + for (const candidate of new Set([provider, resolved].filter((value): value is string => Boolean(value)))) { + const found = getJawcodeModelMetadata(candidate, modelId) + ?? getJawcodeModelMetadataCaseInsensitive(candidate, modelId); + if (found) return found; + } + return undefined; +} + +function modelCost(config: OcxConfig, model: CatalogModel): number | undefined { + const provider = config.providers[model.provider]; + if (provider?.authMode === "local") return 0; + const metaCost = metadata(model.provider, model.id)?.cost; + const overlay = findExpectedPriceOverlay(model.provider, model.id)?.cost4; + const cost = overlay + ?? (metaCost && Object.values(metaCost).some(value => value > 0) ? metaCost : undefined); + if (!cost || !Object.values(cost).some(value => value > 0)) return undefined; + return cost.input + cost.output * 2; +} + +function fallbackProfile(model: CatalogModel): Omit { + const meta = metadata(model.provider, model.id); + const reasoning = (model.reasoningEfforts?.length ?? 0) > 0 || meta?.reasoning === true; + return { + coding: reasoning ? .56 : .48, + agent: reasoning ? .55 : .47, + tools: .50, + reasoning: reasoning ? .62 : .43, + instruction: .55, + confidence: .32, + }; +} + +function modelQuality(model: CatalogModel, mode: SmartRoutingMode): number { + const id = model.id.toLowerCase(); + const profile = CAPABILITY_PROFILES.find(candidate => candidate.pattern.test(id)) ?? fallbackProfile(model); + const broad = profile.coding * .34 + + profile.agent * .27 + + profile.tools * .18 + + profile.reasoning * .14 + + profile.instruction * .07; + const uncertainty = (1 - profile.confidence) * (mode === "intelligence" ? .20 : mode === "balance" ? .12 : .08); + return Math.max(0, broad - uncertainty); +} + +function costScores(costs: readonly number[]): (cost: number | undefined) => number { + const known = [...costs].sort((a, b) => a - b); + if (known.length === 0) return () => 0; + const low = known[Math.floor((known.length - 1) * .1)] ?? known[0]!; + const high = known[Math.ceil((known.length - 1) * .9)] ?? known.at(-1)!; + const min = Math.log(low + .1); + const range = Math.max(.01, Math.log(high + .1) - min); + return cost => cost === undefined ? 0 : 1 - Math.min(1, Math.max(0, (Math.log(cost + .1) - min) / range)); +} + +function utility(mode: SmartRoutingMode, quality: number, costScore: number): number { + if (mode === "intelligence") return quality * .93 + costScore * .07; + if (mode === "balance") return quality * .65 + costScore * .35; + return quality * .34 + costScore * .66; +} + +export function buildSmartRoutingCombo( + mode: SmartRoutingMode, + models: readonly CatalogModel[], + config: OcxConfig, +): OcxComboConfig | null { + const eligible = models + .filter(model => model.provider !== "combo" && config.providers[model.provider]?.disabled !== true) + .filter(model => config.providers[model.provider] !== undefined) + .filter(model => !(config.disabledModels ?? []).some(disabled => slugEquals(disabled, model.provider, model.id))) + .filter(model => (model.contextWindow ?? metadata(model.provider, model.id)?.contextWindow ?? 32_000) >= 32_000) + .map(model => ({ model, quality: modelQuality(model, mode), cost: modelCost(config, model) })); + const scoreCost = costScores(eligible.flatMap(candidate => candidate.cost === undefined ? [] : [candidate.cost])); + const scored = eligible.map(candidate => { + const costScore = scoreCost(candidate.cost); + return { ...candidate, costScore, utility: utility(mode, candidate.quality, costScore) }; + }); + const bestQuality = Math.max(0, ...scored.map(candidate => candidate.quality)); + const capable = mode === "cost" + ? scored.filter(candidate => candidate.cost !== undefined && candidate.quality >= Math.max(.48, bestQuality - .18)) + : scored; + const byProvider = new Map(); + for (const candidate of capable) { + const current = byProvider.get(candidate.model.provider); + if (!current || candidate.utility > current.utility + || (candidate.utility === current.utility && candidate.quality > current.quality) + || (candidate.utility === current.utility && candidate.quality === current.quality + && candidate.model.id.localeCompare(current.model.id) < 0)) { + byProvider.set(candidate.model.provider, candidate); + } + } + const ranked = [...byProvider.values()] + .sort((a, b) => b.utility - a.utility + || b.quality - a.quality + || a.model.provider.localeCompare(b.model.provider) + || a.model.id.localeCompare(b.model.id)) + .slice(0, 8); + if (ranked.length === 0) return null; + const top = ranked[0]!.utility; + return { + strategy: "round-robin", + stickyLimit: 1, + defaultEffort: mode === "intelligence" ? "high" : mode === "balance" ? "medium" : "low", + targets: ranked.map(candidate => ({ + provider: candidate.model.provider, + model: candidate.model.id, + weight: Math.max(1, Math.round(10 * candidate.utility / top)), + })), + }; +} 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/combo-routes.ts b/src/server/management/combo-routes.ts index 564bb22fa..d05d02e72 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -1,7 +1,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 { catalogModelSlug, filterCatalogVisibleModels, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -75,6 +75,39 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise candidate === body.mode) + : undefined; + if (!mode) { + return jsonResponse({ error: `mode must be one of: ${SMART_ROUTING_MODES.join(", ")}` }, 400); + } + const models = filterCatalogVisibleModels(await fetchAllModels(config), config); + const combo = buildSmartRoutingCombo(mode, models, config); + if (!combo) { + const error = mode === "cost" + ? "no enabled models with verified pricing can be routed in cost mode" + : `no enabled models can be routed in ${mode} mode`; + return jsonResponse({ error }, 400); + } + const id = `auto-${mode}`; + config.combos = { ...(config.combos ?? {}), [id]: combo }; + saveConfig(config); + clearComboSelectionState(id); + clearComboTargetCooldowns(id); + await refreshCodexCatalogBestEffort(); + return jsonResponse({ + success: true, + mode, + id, + model: `combo/${id}`, + targets: combo.targets, + }); + } + if (url.pathname === "/api/combos" && req.method === "PUT") { let rawBody: unknown; try { rawBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 1f848c783..0a73e7f29 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"; @@ -291,7 +292,7 @@ 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 fallbackModels = body.freeOnly === true + ? staticModels.filter(model => model.id.endsWith(":free")) + : staticModels; + 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(fallbackModels.length + ? { ok: true, models: fallbackModels, 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(fallbackModels.length + ? { ok: true, models: fallbackModels, 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(fallbackModels.length + ? { ok: true, models: fallbackModels, 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 filteredRows = body.freeOnly === true + ? [...uniqueRows.values()].filter(row => { + const prompt = Number(row.pricing?.prompt); + const completion = Number(row.pricing?.completion); + return row.pricing !== undefined + && Number.isFinite(prompt) && prompt >= 0 + && Number.isFinite(completion) && completion >= 0 + && prompt === 0 && completion === 0; + }) + : [...uniqueRows.values()]; + const models = filteredRows.map(row => ({ + id: row.id, + ...(row.context_length ? { contextWindow: row.context_length } : {}), + })); + if (models.length === 0) { + const error = body.freeOnly === true + ? "OpenRouter currently reports no free models" + : "upstream model discovery returned an empty catalog"; + return jsonResponse(fallbackModels.length + ? { ok: true, models: fallbackModels, 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(fallbackModels.length + ? { ok: true, models: fallbackModels, source: "static", latencyMs, error } + : { ok: false, models: [], source: "live", latencyMs, error }); + } + } return null; } diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 5bbf5a0af..d0e7649a1 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -153,6 +153,59 @@ afterEach(() => { }); describe("combo management API", () => { + test("smart routing builds the selected mode even when cost pricing is unavailable", async () => { + await withTempHome(async () => { + const config = baseConfig({ + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://a.example/v1", + apiKey: "key", + liveModels: false, + models: ["unknown-reasoning-model"], + }, + b: { + adapter: "openai-chat", + baseUrl: "https://b.example/v1", + apiKey: "key", + liveModels: false, + models: ["unknown-agent-model"], + }, + }, + defaultProvider: "a", + combos: { manual: VALID_COMBO, "auto-balance": VALID_COMBO }, + }); + saveConfig(config); + let refreshes = 0; + + const response = await comboApi(config, "POST", "/api/smart-routing", { mode: "intelligence" }, async () => { refreshes += 1; }); + const body = await responseJson(response) as { id?: string }; + + expect(response?.status).toBe(200); + expect(body.id).toBe("auto-intelligence"); + expect(Object.keys(config.combos ?? {}).sort()).toEqual(["auto-balance", "auto-intelligence", "manual"]); + expect(config.combos?.["auto-intelligence"]?.strategy).toBe("round-robin"); + expect(config.combos?.["auto-balance"]).toEqual(VALID_COMBO); + expect(refreshes).toBe(1); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).combos["auto-intelligence"]).toBeDefined(); + }); + }); + + test("smart routing rejects an invalid mode without changing config", async () => { + await withTempHome(async () => { + const config = baseConfig(); + saveConfig(config); + const before = readFileSync(getConfigPath(), "utf8"); + + const response = await comboApi(config, "POST", "/api/smart-routing", { mode: "fastest" }); + const body = await responseJson(response); + + expect(response?.status).toBe(400); + expect(body.error).toBe("mode must be one of: intelligence, balance, cost"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + }); + }); + test("bare alias precedence yields back to a non-OpenAI selector after rename and deletion", async () => { await withTempHome(async () => { const selector = "deepseek/deepseek-chat"; 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-connection-test.test.ts b/tests/provider-connection-test.test.ts index f4bf64775..83713dc45 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -105,16 +105,21 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { }); test("a live 200 with model data reports ok:true with the count", async () => { - globalThis.fetch = (async () => new Response(JSON.stringify({ data: [{ id: "m-1" }, { id: "m-2" }] }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + let redirect: RequestRedirect | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + redirect = init?.redirect; + return new Response(JSON.stringify({ data: [{ id: "m-1" }, { id: "m-2" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; const config = baseConfig({ live: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1", apiKey: "sk-live" }, }); const { body } = await probe(config, "live"); expect(body.ok).toBe(true); expect(body.models).toBe(2); + expect(redirect).toBe("error"); }); test("Google's models-array response shape is accepted (x-goog-api-key path)", async () => { 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..4ae9928e9 --- /dev/null +++ b/tests/provider-free-directory.test.ts @@ -0,0 +1,214 @@ +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, freeOnly?: boolean): 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, ...(freeOnly === undefined ? {} : { freeOnly }) }), + }), 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("filters OpenRouter discovery to models with complete valid zero pricing", async () => { + globalThis.fetch = (async () => Response.json({ data: [ + { id: "paid/model", pricing: { prompt: "0.000001", completion: "0.000002" } }, + { id: "free/by-price", context_length: 128000, pricing: { prompt: "0", completion: "0" } }, + { id: "not-free/by-suffix:free" }, + { id: "not-free/partial-price", pricing: { prompt: "0" } }, + { id: "not-free/invalid-price", pricing: { prompt: "free", completion: "0" } }, + { id: "not-free/negative-price", pricing: { prompt: "-1", completion: "0" } }, + { id: "not-free/blank-price", pricing: { prompt: "", completion: "" } }, + ] })) as typeof fetch; + const response = await discover("openrouter", { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + authMode: "key", + apiKey: "secret", + }, true); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + ok: true, + source: "live", + models: [{ id: "free/by-price", contextWindow: 128000 }], + }); + }); + + test("validates freeOnly at the discovery boundary", async () => { + const url = new URL("http://127.0.0.1:10100/api/provider-presets/discover"); + const invalidType = await handleManagementAPI(new Request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ presetId: "openrouter", provider: {}, freeOnly: "yes" }), + }), url, config); + expect(invalidType?.status).toBe(400); + expect(await invalidType?.json()).toEqual({ error: "freeOnly must be a boolean" }); + + const unsupported = await discover("cohere", { + adapter: "openai-chat", + baseUrl: "https://api.cohere.com/compatibility/v1", + authMode: "key", + apiKey: "secret", + }, true); + expect(unsupported?.status).toBe(400); + expect(await unsupported?.json()).toEqual({ error: "freeOnly is only supported for OpenRouter" }); + }); + + 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..fbd3eac91 --- /dev/null +++ b/tests/provider-model-envelope.test.ts @@ -0,0 +1,23 @@ +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({ data: [{ id: "free-model", pricing: { prompt: "0", completion: "0" } }] })?.[0]?.pricing) + .toEqual({ prompt: "0", completion: "0" }); + expect(parseProviderModelsApiItems({ data: [{ id: "partial-price", pricing: { prompt: "0" } }] })?.[0]?.pricing) + .toBeUndefined(); + 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)", () => { diff --git a/tests/smart-routing.test.ts b/tests/smart-routing.test.ts new file mode 100644 index 000000000..87598eb57 --- /dev/null +++ b/tests/smart-routing.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { buildSmartRoutingCombo } from "../src/combos"; +import type { CatalogModel } from "../src/codex/catalog"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { adapter: "openai-chat", baseUrl: "https://example.com", apiKey: "x", ...overrides }; +} + +function config(): OcxConfig { + return { + port: 10100, + defaultProvider: "anthropic", + providers: { + anthropic: provider(), + google: provider(), + xai: provider(), + local: provider({ authMode: "local" }), + unknown: provider({ freeTier: true }), + disabled: provider({ disabled: true }), + }, + disabledModels: ["xai/grok-4.5"], + }; +} + +const models: CatalogModel[] = [ + { provider: "anthropic", id: "claude-fable-5", contextWindow: 1_000_000 }, + { provider: "anthropic", id: "claude-sonnet-5", contextWindow: 1_000_000 }, + { provider: "google", id: "gemini-3.6-flash", contextWindow: 1_000_000 }, + { provider: "google", id: "gemini-3.5-flash-lite", contextWindow: 1_000_000 }, + { provider: "xai", id: "grok-4.5", contextWindow: 500_000 }, + { provider: "local", id: "claude-sonnet-5", contextWindow: 1_000_000 }, + { provider: "unknown", id: "future-reasoner", reasoningEfforts: ["high"], contextWindow: 256_000 }, + { provider: "disabled", id: "deepseek-v4-pro", contextWindow: 1_000_000 }, + { provider: "combo", id: "existing" }, + { provider: "missing", id: "not-installed" }, +]; + +describe("smart routing", () => { + test("uses benchmark-informed capability, provider cost, and conservative unknown handling", () => { + const intelligence = buildSmartRoutingCombo("intelligence", models, config()); + const balance = buildSmartRoutingCombo("balance", models, config()); + const cost = buildSmartRoutingCombo("cost", models, config()); + + expect(intelligence!.targets.find(target => target.provider === "anthropic")?.model).toBe("claude-fable-5"); + expect(intelligence!.targets).toContainEqual(expect.objectContaining({ provider: "local", model: "claude-sonnet-5" })); + expect(balance!.targets.find(target => target.provider === "google")?.model).toBe("gemini-3.6-flash"); + expect(cost!.targets[0]).toMatchObject({ provider: "local", model: "claude-sonnet-5", weight: 10 }); + expect(cost!.targets.some(target => target.provider === "unknown")).toBe(false); + expect(cost!.targets.some(target => target.provider === "xai")).toBe(false); + expect(new Set(intelligence!.targets.map(target => target.provider)).size).toBe(intelligence!.targets.length); + expect(intelligence!.targets.every(target => Number.isInteger(target.weight) && target.weight! > 0)).toBe(true); + }); + + test("does not treat a remote free-tier flag as zero-cost pricing", () => { + const onlyUnknown: CatalogModel[] = [ + { provider: "unknown", id: "claude-sonnet-5", contextWindow: 1_000_000 }, + ]; + expect(buildSmartRoutingCombo("cost", onlyUnknown, config())).toBeNull(); + expect(buildSmartRoutingCombo("intelligence", onlyUnknown, config())).not.toBeNull(); + }); +});